Public: anyone can read this; the crew can change it.
Show what is approved on the home list and mark unapproved flyers and emails as drafts #17
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/7-show-what-is-approved-on-the-home-list-a → main 2 commits fork 1 thread · 1 unresolved
12 files
changed +159
−20
README.md +5
| @@ -29,6 +29,11 @@ One address in, a listing launched: the MLS description, three social posts, a f | ||
| 29 | 29 | read (`seen`, the asset's `updated_at`; the form also sends the box's text): |
| 30 | 30 | if they changed, it answers 409 and approves nothing. Approval is a |
| 31 | 31 | record, not a gate: Copy and Print flyer do not look at it. |
| 32 | Each listing on the home list (and in the terminal's list) reads "Ready to send" | |
| 33 | or "3 of 6 approved" beside its Fair Housing pill, live through `/events`; | |
| 34 | `GET /api/listings` and the MCP list carry `approvals` (`kind`, `approved`) per | |
| 35 | listing. The flyer and the email preview wear a "Draft — not approved" band | |
| 36 | (on paper too; the terminal's preview says it in text) until their asset is approved. | |
| 32 | 37 | 4. **Preview the email.** The Email tab's "Preview email" opens |
| 33 | 38 | `/listings/:id/email`: the subject, hero photo, price and facts, the body, |
| 34 | 39 | your photo, name, brokerage, license and contact in your colour, and the |
contract/openapi.json +28 −1
| @@ -1246,6 +1246,32 @@ | ||
| 1246 | 1246 | "flags": { |
| 1247 | 1247 | "type": "integer", |
| 1248 | 1248 | "description": "Phrases the Fair Housing check flags across every asset." |
| 1249 | }, | |
| 1250 | "approvals": { | |
| 1251 | "type": "array", | |
| 1252 | "description": "Each asset and whether somebody has signed off its words.", | |
| 1253 | "items": { | |
| 1254 | "type": "object", | |
| 1255 | "properties": { | |
| 1256 | "kind": { | |
| 1257 | "enum": [ | |
| 1258 | "mls", | |
| 1259 | "social_short", | |
| 1260 | "social_medium", | |
| 1261 | "social_carousel", | |
| 1262 | "flyer", | |
| 1263 | "email" | |
| 1264 | ] | |
| 1265 | }, | |
| 1266 | "approved": { | |
| 1267 | "type": "boolean" | |
| 1268 | } | |
| 1269 | }, | |
| 1270 | "required": [ | |
| 1271 | "kind", | |
| 1272 | "approved" | |
| 1273 | ] | |
| 1274 | } | |
| 1249 | 1275 | } |
| 1250 | 1276 | }, |
| 1251 | 1277 | "required": [ |
| @@ -1271,7 +1297,8 @@ | ||
| 1271 | 1297 | "headshot", |
| 1272 | 1298 | "created_at", |
| 1273 | 1299 | "updated_at", |
| 1274 | "flags" | |
| 1300 | "flags", | |
| 1301 | "approvals" | |
| 1275 | 1302 | ] |
| 1276 | 1303 | }, |
| 1277 | 1304 | "AuditRow": { |
src/actions.ts +17 −9
| @@ -390,11 +390,17 @@ export function insertListing( | ||
| 390 | 390 | })(); |
| 391 | 391 | } |
| 392 | 392 | |
| 393 | /** A listing in the list: the Fair Housing flags and which assets are signed off. */ | |
| 394 | export type ListingSummary = Listing & { | |
| 395 | flags: number; | |
| 396 | approvals: { kind: string; approved: boolean }[]; | |
| 397 | }; | |
| 398 | ||
| 393 | 399 | export function listListings( |
| 394 | 400 | db: Database, |
| 395 | 401 | who: Who, |
| 396 | 402 | input: { limit?: number } = {}, |
| 397 | ): Result<(Listing & { flags: number })[]> { | |
| 403 | ): Result<ListingSummary[]> { | |
| 398 | 404 | const limit = Math.min( |
| 399 | 405 | Math.max(Math.trunc(input.limit ?? 100) || 100, 1), |
| 400 | 406 | 500, |
| @@ -414,16 +420,18 @@ export function listListings( | ||
| 414 | 420 | `SELECT * FROM listing WHERE ${may.where.map((c) => `${c} = ?2`).join(" OR ")} ORDER BY id DESC LIMIT ?1`, |
| 415 | 421 | ) |
| 416 | 422 | .all(limit, who.role === "anon" ? "" : who.id); |
| 417 | const bodies = db.query<{ body: string }, [number]>( | |
| 418 | "SELECT body FROM asset WHERE listing_id = ?1", | |
| 423 | const bodies = db.query<{ kind: string; body: string; approved_at: string | null }, [number]>( | |
| 424 | "SELECT kind, body, approved_at FROM asset WHERE listing_id = ?1 ORDER BY rowid", | |
| 419 | 425 | ); |
| 420 | 426 | return ok( |
| 421 | rows.map((row) => ({ | |
| 422 | ...fromRow(row), | |
| 423 | flags: bodies | |
| 424 | .all(row.id) | |
| 425 | .reduce((n, a) => n + check(a.body).findings.length, 0), | |
| 426 | })), | |
| 427 | rows.map((row) => { | |
| 428 | const assets = bodies.all(row.id); | |
| 429 | return { | |
| 430 | ...fromRow(row), | |
| 431 | flags: assets.reduce((n, a) => n + check(a.body).findings.length, 0), | |
| 432 | approvals: assets.map((a) => ({ kind: a.kind, approved: a.approved_at !== null })), | |
| 433 | }; | |
| 434 | }), | |
| 427 | 435 | ); |
| 428 | 436 | } |
| 429 | 437 |
src/listing.ts +4
| @@ -265,3 +265,7 @@ export function assetText(v: unknown): Checked<string> { | ||
| 265 | 265 | ? { ok: true, value: t } |
| 266 | 266 | : { ok: false, message: `An asset is 1 to ${ASSET_MAX} characters.` }; |
| 267 | 267 | } |
| 268 | ||
| 269 | /** "Ready to send" once every asset is signed off, else how many are. */ | |
| 270 | export const approvalLabel = (approved: number, total: number): string => | |
| 271 | approved === total ? "Ready to send" : `${approved} of ${total} approved`; |
src/listings.test.ts +31
| @@ -464,3 +464,34 @@ test("the email preview escapes a hostile address and 404s for an unknown id", a | ||
| 464 | 464 | expect(page).toContain("<script>"); |
| 465 | 465 | expect((await fetch(`${base}/listings/999999/email`)).status).toBe(404); |
| 466 | 466 | }); |
| 467 | ||
| 468 | test("the home list says what is approved, and flyer and email wear a draft band until approved", async () => { | |
| 469 | const alice = as("carol"); | |
| 470 | const { to } = await launch(alice); | |
| 471 | const id = Number(to.split("/").at(-1)); | |
| 472 | const card = (html: string) => html.slice(html.indexOf(`href="/listings/${id}"`)).split("</li>")[0]!; | |
| 473 | const home = async () => card(await (await fetch(`${base}/`)).text()); | |
| 474 | expect(await home()).toContain("0 of 6 approved"); | |
| 475 | for (const path of ["flyer", "email"]) | |
| 476 | expect(await (await fetch(`${base}${to}/${path}`)).text()).toContain("Draft — not approved"); | |
| 477 | expect(await (await fetch(`${base}${to}`)).text()).toContain("Draft — not approved"); | |
| 478 | ||
| 479 | const listed = (await (await fetch(`${base}/api/listings/${id}`)).json()) as { assets: { kind: string; updated_at: string }[] }; | |
| 480 | const approve = (kind: string) => | |
| 481 | fetch(`${base}/api/listings/${id}`, { | |
| 482 | method: "PATCH", | |
| 483 | headers: { ...alice, "content-type": "application/json" }, | |
| 484 | body: JSON.stringify({ kind, approved: true, seen: listed.assets.find((a) => a.kind === kind)!.updated_at }), | |
| 485 | }); | |
| 486 | for (const kind of ["flyer", "email"]) expect((await approve(kind)).status).toBe(200); | |
| 487 | for (const path of ["flyer", "email"]) | |
| 488 | expect(await (await fetch(`${base}${to}/${path}`)).text()).not.toContain("Draft — not approved"); | |
| 489 | expect(await home()).toContain("2 of 6 approved"); | |
| 490 | ||
| 491 | const summary = ((await (await fetch(`${base}/api/listings`)).json()) as { id: number; approvals: { kind: string; approved: boolean }[] }[]).find((l) => l.id === id)!; | |
| 492 | expect(summary.approvals).toHaveLength(6); | |
| 493 | expect(summary.approvals.filter((a) => a.approved).map((a) => a.kind).sort()).toEqual(["email", "flyer"]); | |
| 494 | ||
| 495 | for (const a of listed.assets) if (a.kind !== "flyer" && a.kind !== "email") expect((await approve(a.kind)).status).toBe(200); | |
| 496 | expect(await home()).toContain("Ready to send"); | |
| 497 | }); |
src/pages/email.ts +2 −1
| @@ -10,7 +10,7 @@ import { escape } from "../http.ts"; | ||
| 10 | 10 | import type { Launched } from "../listing.ts"; |
| 11 | 11 | import type { Who } from "../who.ts"; |
| 12 | 12 | import { layout } from "./layout.ts"; |
| 13 | import { photoUrl } from "./listing.ts"; | |
| 13 | import { draftBand, photoUrl } from "./listing.ts"; | |
| 14 | 14 | |
| 15 | 15 | /** The asset's text split into its subject and body paragraphs; the signature the page draws itself is dropped. */ |
| 16 | 16 | export function emailParts( |
| @@ -38,6 +38,7 @@ export function email(l: Launched): string { | ||
| 38 | 38 | ); |
| 39 | 39 | const hero = l.photos[0]; |
| 40 | 40 | return `<article class="email palette-${escape(l.palette)}" data-email> |
| 41 | ${draftBand(l, "email")} | |
| 41 | 42 | <p class="email-subject"><span class="muted">Subject</span> <span data-subject>${escape(subject)}</span></p> |
| 42 | 43 | <div class="email-card"> |
| 43 | 44 | ${hero === undefined ? "" : `<img class="email-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="600" height="400">`} |
src/pages/home.ts +5 −4
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { count, money, TONES, type Tone } from "../generate.ts"; |
| 9 | import type { ListingSummary } from "../actions.ts"; | |
| 9 | 10 | import { escape } from "../http.ts"; |
| 10 | 11 | import { |
| 11 | 12 | FEATURES_MAX, |
| @@ -17,7 +18,7 @@ import { PHOTOS_MAX } from "../photos.ts"; | ||
| 17 | 18 | import { SAMPLE } from "../sample.ts"; |
| 18 | 19 | import type { Who } from "../who.ts"; |
| 19 | 20 | import { ago, layout, toast } from "./layout.ts"; |
| 20 | import { photoUrl } from "./listing.ts"; | |
| 21 | import { approvalPill, photoUrl } from "./listing.ts"; | |
| 21 | 22 | |
| 22 | 23 | const TONE_SAYS: Readonly<Record<Tone, string>> = { |
| 23 | 24 | classic: "Measured and precise. Lets the facts carry it.", |
| @@ -55,7 +56,7 @@ export function draftOf(listing: Listing, brandOnly: boolean): Draft { | ||
| 55 | 56 | |
| 56 | 57 | export function home(input: { |
| 57 | 58 | who: Who; |
| 58 | listings: readonly (Listing & { flags: number })[]; | |
| 59 | listings: readonly ListingSummary[]; | |
| 59 | 60 | draft: Draft; |
| 60 | 61 | notice?: string | undefined; |
| 61 | 62 | }): string { |
| @@ -140,7 +141,7 @@ function form(who: Who, draft: Draft): string { | ||
| 140 | 141 | </form>`; |
| 141 | 142 | } |
| 142 | 143 | |
| 143 | function list(listings: readonly (Listing & { flags: number })[]): string { | |
| 144 | function list(listings: readonly ListingSummary[]): string { | |
| 144 | 145 | if (listings.length === 0) |
| 145 | 146 | return `<p class="empty">Nothing launched yet. Fill in the form above.</p>`; |
| 146 | 147 | return `<ul class="cards">${listings |
| @@ -152,7 +153,7 @@ function list(listings: readonly (Listing & { flags: number })[]): string { | ||
| 152 | 153 | <span class="card-body"> |
| 153 | 154 | <b>${escape(l.address)}</b> |
| 154 | 155 | <span>${money(l.price)} · ${l.beds} bd · ${l.baths} ba · ${count(l.sqft)} sq ft</span> |
| 155 | <span class="row card-meta">${status(l.flags)}<small class="muted">${escape(l.author_name)} · ${ago(l.updated_at)}</small></span> | |
| 156 | <span class="row card-meta">${approvalPill(l.approvals.filter((a) => a.approved).length, l.approvals.length)}${status(l.flags)}<small class="muted">${escape(l.author_name)} · ${ago(l.updated_at)}</small></span> | |
| 156 | 157 | </span> |
| 157 | 158 | </a> |
| 158 | 159 | </li>`; |
src/pages/listing.ts +13 −2
| @@ -13,7 +13,7 @@ import { | ||
| 13 | 13 | TONES, |
| 14 | 14 | } from "../generate.ts"; |
| 15 | 15 | import { asset as assetHref, escape } from "../http.ts"; |
| 16 | import { ASSET_MAX, type Asset, type Launched, type Listing } from "../listing.ts"; | |
| 16 | import { approvalLabel, ASSET_MAX, type Asset, type Launched, type Listing } from "../listing.ts"; | |
| 17 | 17 | import { isSample } from "../photos.ts"; |
| 18 | 18 | import type { Who } from "../who.ts"; |
| 19 | 19 | import { ago, layout, toast } from "./layout.ts"; |
| @@ -57,6 +57,16 @@ const flags = (l: Launched): number => | ||
| 57 | 57 | const approvedCount = (l: Launched): number => |
| 58 | 58 | l.assets.filter((a) => a.approval !== null).length; |
| 59 | 59 | |
| 60 | /** "Ready to send" once every asset is signed off, else how many are. */ | |
| 61 | export const approvalPill = (approved: number, total: number): string => | |
| 62 | `<span class="fh-pill${approved === total ? " fh-ok" : ""}">${approvalLabel(approved, total)}</span>`; | |
| 63 | ||
| 64 | /** The band an unapproved flyer or email wears, on screen and on paper; nothing once approved. */ | |
| 65 | export const draftBand = (l: Launched, kind: "flyer" | "email"): string => | |
| 66 | l.assets.find((a) => a.kind === kind)?.approval == null | |
| 67 | ? `<p class="draft-band" role="note">Draft — not approved</p>` | |
| 68 | : ""; | |
| 69 | ||
| 60 | 70 | /** The approve control beside Copy: a button naming the flags, or who signed off and a way to take it back. */ |
| 61 | 71 | function approval(l: Launched, a: Asset): string { |
| 62 | 72 | const to = `/listings/${l.id}/assets/${a.kind}/approve`; |
| @@ -130,7 +140,7 @@ export function listingPage(input: { | ||
| 130 | 140 | <p class="muted"><small>${escape(l.agent_name)} · ${escape(l.brokerage)} · updated ${ago(l.updated_at)}</small></p> |
| 131 | 141 | </div> |
| 132 | 142 | <div class="listing-tools"> |
| 133 | ${approvedCount(l) === l.assets.length ? `<span class="fh-pill fh-ok">Ready to send</span>` : `<span class="fh-pill">${approvedCount(l)} of ${l.assets.length} approved</span>`} | |
| 143 | ${approvalPill(approvedCount(l), l.assets.length)} | |
| 134 | 144 | ${n === 0 ? `<span class="fh-pill fh-ok">Fair Housing: clear</span>` : `<span class="fh-pill fh-warn">Fair Housing: ${n} to review</span>`} |
| 135 | 145 | ${tone} |
| 136 | 146 | <a class="button" href="/listings/${l.id}/flyer">Print flyer</a> |
| @@ -173,6 +183,7 @@ export function flyer(l: Launched): string { | ||
| 173 | 183 | const copy = flyerParts(l.assets.find((a) => a.kind === "flyer")?.text ?? ""); |
| 174 | 184 | const [hero, ...more] = l.photos; |
| 175 | 185 | return `<article class="flyer palette-${l.palette}"> |
| 186 | ${draftBand(l, "flyer")} | |
| 176 | 187 | <div class="flyer-hero"> |
| 177 | 188 | ${hero === undefined ? `<div class="flyer-hero-empty brand-fill"></div>` : `<img src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="1200" height="800">`} |
| 178 | 189 | <p class="flyer-badge brand-fill">Just listed</p> |
src/routes.ts +10 −1
| @@ -199,8 +199,17 @@ export const LISTING_SUMMARY: Schema = { | ||
| 199 | 199 | type: "integer", |
| 200 | 200 | description: "Phrases the Fair Housing check flags across every asset.", |
| 201 | 201 | }, |
| 202 | approvals: { | |
| 203 | type: "array", | |
| 204 | description: "Each asset and whether somebody has signed off its words.", | |
| 205 | items: { | |
| 206 | type: "object", | |
| 207 | properties: { kind: { enum: [...KINDS] }, approved: { type: "boolean" } }, | |
| 208 | required: ["kind", "approved"], | |
| 209 | }, | |
| 210 | }, | |
| 202 | 211 | }, |
| 203 | required: [...LISTING_REQUIRED, "flags"], | |
| 212 | required: [...LISTING_REQUIRED, "flags", "approvals"], | |
| 204 | 213 | }; |
| 205 | 214 | export const LISTING: Schema = { |
| 206 | 215 | type: "object", |
src/tty.test.ts +19
| @@ -138,3 +138,22 @@ test("p previews the email as the client reads it; esc returns to the assets", ( | ||
| 138 | 138 | tty.press("\x1b"); |
| 139 | 139 | expect(plain(tty.screen())).toContain("▸ MLS description"); |
| 140 | 140 | }); |
| 141 | ||
| 142 | test("the listings view says what is approved, and the email preview is a draft until approved", () => { | |
| 143 | const { db } = fresh(); | |
| 144 | const tty = session(db, me); | |
| 145 | expect(plain(tty.screen())).toContain("0 of 6 approved"); | |
| 146 | tty.press("\r"); | |
| 147 | tty.press("p"); | |
| 148 | expect(plain(tty.screen())).toContain("DRAFT — not approved"); | |
| 149 | tty.press("\x1b"); | |
| 150 | for (let i = 0; i < 6; i++) { | |
| 151 | tty.press("a"); | |
| 152 | tty.press("j"); | |
| 153 | } | |
| 154 | tty.press("p"); | |
| 155 | expect(plain(tty.screen())).not.toContain("DRAFT — not approved"); | |
| 156 | tty.press("\x1b"); | |
| 157 | tty.press("\x1b"); | |
| 158 | expect(plain(tty.screen())).toContain("Ready to send"); | |
| 159 | }); |
src/tty.ts +7 −2
| @@ -31,6 +31,7 @@ import { | ||
| 31 | 31 | retoneListing, |
| 32 | 32 | } from "./actions.ts"; |
| 33 | 33 | import { ssh, title, where } from "./env.ts"; |
| 34 | import { approvalLabel } from "./listing.ts"; | |
| 34 | 35 | import { emailParts } from "./pages/email.ts"; |
| 35 | 36 | import { money, TONES, type Kind } from "./generate.ts"; |
| 36 | 37 | import type { Who } from "./who.ts"; |
| @@ -73,7 +74,9 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 73 | 74 | const l = found.value; |
| 74 | 75 | if (previewing) { |
| 75 | 76 | const { subject, paragraphs } = emailParts(l.assets.find((a) => a.kind === "email")?.text ?? "", l.agent_name); |
| 77 | const approved = l.assets.find((a) => a.kind === "email")?.approval != null; | |
| 76 | 78 | return [ |
| 79 | ...(approved ? [] : [amber("DRAFT — not approved"), ""]), | |
| 77 | 80 | bold(`Subject: ${subject}`), "", |
| 78 | 81 | bold(`${l.address} · ${money(l.price)}`), |
| 79 | 82 | dim(`${l.beds} beds · ${l.baths} baths · ${l.sqft.toLocaleString("en-US")} sq ft · ${l.lot} lot · ${l.year} built`), "", |
| @@ -83,7 +86,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 83 | 86 | ]; |
| 84 | 87 | } |
| 85 | 88 | const signed = l.assets.filter((a) => a.approval !== null).length; |
| 86 | const head = [bold(`${l.address} · ${money(l.price)}`), dim(`${l.tone} tone · ${signed === l.assets.length ? "Ready to send" : `${signed} of ${l.assets.length} approved`}`), ""]; | |
| 89 | const head = [bold(`${l.address} · ${money(l.price)}`), dim(`${l.tone} tone · ${approvalLabel(signed, l.assets.length)}`), ""]; | |
| 87 | 90 | // Scroll so the asset under the cursor starts the page. |
| 88 | 91 | const body = l.assets.slice(cursor).flatMap((a, i) => [ |
| 89 | 92 | `${i === 0 ? "▸" : " "} ${i === 0 ? bold(a.label) : a.label} ${a.check.clear ? green("Fair Housing: clear") : amber(`Fair Housing: ${a.check.findings.length} to review`)}${a.edited ? dim(" edited") : ""}${a.approval === null ? "" : green(` approved by ${a.approval.by_name}`)}`, |
| @@ -113,10 +116,12 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 113 | 116 | const rows = listings(); |
| 114 | 117 | if (rows.length === 0) lines.push(dim("No listings yet. Launch one on the page.")); |
| 115 | 118 | rows.forEach((l, i) => { |
| 119 | const done = l.approvals.filter((a) => a.approved).length; | |
| 120 | const ready = approvalLabel(done, l.approvals.length); | |
| 116 | 121 | const flag = l.flags === 0 ? green("clear") : amber(`${l.flags} to review`); |
| 117 | 122 | const text = `${l.address} · ${money(l.price)}`; |
| 118 | 123 | lines.push( |
| 119 | `${i === cursor ? "▸" : " "} ${i === cursor ? bold(text) : text} ${flag} ${dim(l.author_name)}`, | |
| 124 | `${i === cursor ? "▸" : " "} ${i === cursor ? bold(text) : text} ${done === l.approvals.length ? green(ready) : dim(ready)} ${flag} ${dim(l.author_name)}`, | |
| 120 | 125 | ); |
| 121 | 126 | }); |
| 122 | 127 | help = "j/k move · enter open · x remove · tab notes · q quit"; |
static/app.css +18
| @@ -404,6 +404,24 @@ h3 { | ||
| 404 | 404 | flex-wrap: wrap; |
| 405 | 405 | } |
| 406 | 406 | |
| 407 | /* A flyer or email nobody has signed off wears this, on screen and on paper. */ | |
| 408 | .draft-band { | |
| 409 | margin: 0; | |
| 410 | padding: var() var(); | |
| 411 | text-align: center; | |
| 412 | font-weight: 700; | |
| 413 | letter-spacing: 0.04em; | |
| 414 | text-transform: uppercase; | |
| 415 | color: var(); | |
| 416 | background: var(); | |
| 417 | border-bottom: 2px dashed currentColor; | |
| 418 | -webkit-print-color-adjust: exact; | |
| 419 | print-color-adjust: exact; | |
| 420 | } | |
| 421 | .card-meta .fh-pill { | |
| 422 | white-space: nowrap; | |
| 423 | } | |
| 424 | ||
| 407 | 425 | /* Fair Housing: green when clear, amber with the words and why. */ |
| 408 | 426 | .fh-pill, |
| 409 | 427 | .fh-status { |
new above
offline
Fine, but tty.ts's list rows must consume
approvalstoo (see review body).