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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/listing-launch:mason/7-show-what-is-approved-on-the-home-list-a → main 2 commits fork 1 thread · 1 unresolved

  • Merged. mason/7-show-what-is-approved-on-the-home-list-a was merged into main as 05204c5.

Review

Sign in to review this change.
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
2929 read (`seen`, the asset's `updated_at`; the form also sends the box's text):
3030 if they changed, it answers 409 and approves nothing. Approval is a
3131 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.
3237 4. **Preview the email.** The Email tab's "Preview email" opens
3338 `/listings/:id/email`: the subject, hero photo, price and facts, the body,
3439 your photo, name, brokerage, license and contact in your colour, and the
contract/openapi.json +28 −1
@@ -1246,6 +1246,32 @@
12461246 "flags": {
12471247 "type": "integer",
12481248 "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 }
12491275 }
12501276 },
12511277 "required": [
@@ -1271,7 +1297,8 @@
12711297 "headshot",
12721298 "created_at",
12731299 "updated_at",
1274 "flags"
1300 "flags",
1301 "approvals"
12751302 ]
12761303 },
12771304 "AuditRow": {
src/actions.ts +17 −9
@@ -390,11 +390,17 @@ export function insertListing(
390390 })();
391391 }
392392
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
393399 export function listListings(
394400 db: Database,
395401 who: Who,
396402 input: { limit?: number } = {},
397 ): Result<(Listing & { flags: number })[]> {
403 ): Result<ListingSummary[]> {
398404 const limit = Math.min(
399405 Math.max(Math.trunc(input.limit ?? 100) || 100, 1),
400406 500,
@@ -414,16 +420,18 @@ export function listListings(
414420 `SELECT * FROM listing WHERE ${may.where.map((c) => `${c} = ?2`).join(" OR ")} ORDER BY id DESC LIMIT ?1`,
415421 )
416422 .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",
419425 );
420426 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 };

Fine, but tty.ts's list rows must consume approvals too (see review body).

434 }),
427435 );
428436 }
429437
src/listing.ts +4
@@ -265,3 +265,7 @@ export function assetText(v: unknown): Checked<string> {
265265 ? { ok: true, value: t }
266266 : { ok: false, message: `An asset is 1 to ${ASSET_MAX} characters.` };
267267 }
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
464464 expect(page).toContain("&lt;script&gt;");
465465 expect((await fetch(`${base}/listings/999999/email`)).status).toBe(404);
466466 });
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";
1010 import type { Launched } from "../listing.ts";
1111 import type { Who } from "../who.ts";
1212 import { layout } from "./layout.ts";
13 import { photoUrl } from "./listing.ts";
13 import { draftBand, photoUrl } from "./listing.ts";
1414
1515 /** The asset's text split into its subject and body paragraphs; the signature the page draws itself is dropped. */
1616 export function emailParts(
@@ -38,6 +38,7 @@ export function email(l: Launched): string {
3838 );
3939 const hero = l.photos[0];
4040 return `<article class="email palette-${escape(l.palette)}" data-email>
41 ${draftBand(l, "email")}
4142 <p class="email-subject"><span class="muted">Subject</span> <span data-subject>${escape(subject)}</span></p>
4243 <div class="email-card">
4344 ${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 @@
66 */
77
88 import { count, money, TONES, type Tone } from "../generate.ts";
9 import type { ListingSummary } from "../actions.ts";
910 import { escape } from "../http.ts";
1011 import {
1112 FEATURES_MAX,
@@ -17,7 +18,7 @@ import { PHOTOS_MAX } from "../photos.ts";
1718 import { SAMPLE } from "../sample.ts";
1819 import type { Who } from "../who.ts";
1920 import { ago, layout, toast } from "./layout.ts";
20 import { photoUrl } from "./listing.ts";
21 import { approvalPill, photoUrl } from "./listing.ts";
2122
2223 const TONE_SAYS: Readonly<Record<Tone, string>> = {
2324 classic: "Measured and precise. Lets the facts carry it.",
@@ -55,7 +56,7 @@ export function draftOf(listing: Listing, brandOnly: boolean): Draft {
5556
5657 export function home(input: {
5758 who: Who;
58 listings: readonly (Listing & { flags: number })[];
59 listings: readonly ListingSummary[];
5960 draft: Draft;
6061 notice?: string | undefined;
6162 }): string {
@@ -140,7 +141,7 @@ function form(who: Who, draft: Draft): string {
140141 </form>`;
141142 }
142143
143 function list(listings: readonly (Listing & { flags: number })[]): string {
144 function list(listings: readonly ListingSummary[]): string {
144145 if (listings.length === 0)
145146 return `<p class="empty">Nothing launched yet. Fill in the form above.</p>`;
146147 return `<ul class="cards">${listings
@@ -152,7 +153,7 @@ function list(listings: readonly (Listing & { flags: number })[]): string {
152153 <span class="card-body">
153154 <b>${escape(l.address)}</b>
154155 <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>
156157 </span>
157158 </a>
158159 </li>`;
src/pages/listing.ts +13 −2
@@ -13,7 +13,7 @@ import {
1313 TONES,
1414 } from "../generate.ts";
1515 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";
1717 import { isSample } from "../photos.ts";
1818 import type { Who } from "../who.ts";
1919 import { ago, layout, toast } from "./layout.ts";
@@ -57,6 +57,16 @@ const flags = (l: Launched): number =>
5757 const approvedCount = (l: Launched): number =>
5858 l.assets.filter((a) => a.approval !== null).length;
5959
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
6070 /** The approve control beside Copy: a button naming the flags, or who signed off and a way to take it back. */
6171 function approval(l: Launched, a: Asset): string {
6272 const to = `/listings/${l.id}/assets/${a.kind}/approve`;
@@ -130,7 +140,7 @@ export function listingPage(input: {
130140 <p class="muted"><small>${escape(l.agent_name)} · ${escape(l.brokerage)} · updated ${ago(l.updated_at)}</small></p>
131141 </div>
132142 <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)}
134144 ${n === 0 ? `<span class="fh-pill fh-ok">Fair Housing: clear</span>` : `<span class="fh-pill fh-warn">Fair Housing: ${n} to review</span>`}
135145 ${tone}
136146 <a class="button" href="/listings/${l.id}/flyer">Print flyer</a>
@@ -173,6 +183,7 @@ export function flyer(l: Launched): string {
173183 const copy = flyerParts(l.assets.find((a) => a.kind === "flyer")?.text ?? "");
174184 const [hero, ...more] = l.photos;
175185 return `<article class="flyer palette-${l.palette}">
186 ${draftBand(l, "flyer")}
176187 <div class="flyer-hero">
177188 ${hero === undefined ? `<div class="flyer-hero-empty brand-fill"></div>` : `<img src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="1200" height="800">`}
178189 <p class="flyer-badge brand-fill">Just listed</p>
src/routes.ts +10 −1
@@ -199,8 +199,17 @@ export const LISTING_SUMMARY: Schema = {
199199 type: "integer",
200200 description: "Phrases the Fair Housing check flags across every asset.",
201201 },
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 },
202211 },
203 required: [...LISTING_REQUIRED, "flags"],
212 required: [...LISTING_REQUIRED, "flags", "approvals"],
204213 };
205214 export const LISTING: Schema = {
206215 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", (
138138 tty.press("\x1b");
139139 expect(plain(tty.screen())).toContain("▸ MLS description");
140140 });
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 {
3131 retoneListing,
3232 } from "./actions.ts";
3333 import { ssh, title, where } from "./env.ts";
34 import { approvalLabel } from "./listing.ts";
3435 import { emailParts } from "./pages/email.ts";
3536 import { money, TONES, type Kind } from "./generate.ts";
3637 import type { Who } from "./who.ts";
@@ -73,7 +74,9 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
7374 const l = found.value;
7475 if (previewing) {
7576 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;
7678 return [
79 ...(approved ? [] : [amber("DRAFT — not approved"), ""]),
7780 bold(`Subject: ${subject}`), "",
7881 bold(`${l.address} · ${money(l.price)}`),
7982 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 }
8386 ];
8487 }
8588 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)}`), ""];
8790 // Scroll so the asset under the cursor starts the page.
8891 const body = l.assets.slice(cursor).flatMap((a, i) => [
8992 `${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 }
113116 const rows = listings();
114117 if (rows.length === 0) lines.push(dim("No listings yet. Launch one on the page."));
115118 rows.forEach((l, i) => {
119 const done = l.approvals.filter((a) => a.approved).length;
120 const ready = approvalLabel(done, l.approvals.length);
116121 const flag = l.flags === 0 ? green("clear") : amber(`${l.flags} to review`);
117122 const text = `${l.address} · ${money(l.price)}`;
118123 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)}`,
120125 );
121126 });
122127 help = "j/k move · enter open · x remove · tab notes · q quit";
static/app.css +18
@@ -404,6 +404,24 @@ h3 {
404404 flex-wrap: wrap;
405405 }
406406
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(--space-2) var(--space-3);
411 text-align: center;
412 font-weight: 700;
413 letter-spacing: 0.04em;
414 text-transform: uppercase;
415 color: var(--color-warn-fg);
416 background: var(--color-warn-bg);
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
407425 /* Fair Housing: green when clear, amber with the words and why. */
408426 .fh-pill,
409427 .fh-status {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs