Public: anyone can read this; the crew can change it.

Approve each asset before it leaves: a human signs off every message #8

Merged opened this

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

Merged

mason/listing-launch:mason/4-approve-each-asset-before-it-leaves-a-hu → main 2 commits fork 3 threads · 3 unresolved

  • Merged. mason/4-approve-each-asset-before-it-leaves-a-hu was merged into main as 59b2a73.

Review

Sign in to review this change.
15 files changed +433 −18
README.md +9
@@ -20,6 +20,15 @@ One address in, a listing launched: the MLS description, three social posts, a f
2020 clear, amber with the risky words underlined and a one-line reason —
2121 *"'family-friendly' implies familial status; say 'near parks and
2222 schools'"*. The check runs again as you type and on every save.
23 Each asset also has an **Approve** button ("Approve with 2 flags" when
24 the check still has findings): it records who signed off and when, the
25 header counts "4 of 6 approved" and turns to "Ready to send" at 6 of 6,
26 and editing an asset or **Rewrite all** clears the approval. The same
27 action is `PATCH /api/listings/:id` with `{ kind, approved }`, the `approve_asset` MCP tool
28 and `a` in the terminal. Approving is bound to the words you
29 read (`seen`, the asset's `updated_at`; the form also sends the box's text):
30 if they changed, it answers 409 and approves nothing. Approval is a
31 record, not a gate: Copy and Print flyer do not look at it.
2332 4. **Print the flyer.** "Print flyer" opens a letter-size page — hero photo,
2433 price, a strip of the facts, the description, your photo, name,
2534 brokerage and license number, and the Equal Housing Opportunity line.
contract/openapi.json +40 −4
@@ -490,7 +490,7 @@
490490 }
491491 },
492492 "patch": {
493 "summary": "Edit one asset's words, or write every asset again in a tone.",
493 "summary": "Edit one asset's words, sign one off or withdraw it ({ kind, approved }), or write every asset again in a tone.",
494494 "operationId": "patchApiListingsId",
495495 "parameters": [
496496 {
@@ -508,7 +508,7 @@
508508 "application/json": {
509509 "schema": {
510510 "type": "object",
511 "description": "Either one asset's new words ({ kind, text }), or every asset written again in a tone ({ tone }), which replaces edits.",
511 "description": "Either one asset's new words ({ kind, text }), one asset signed off or withdrawn ({ kind, approved, seen }), or every asset written again in a tone ({ tone }), which replaces edits.",
512512 "properties": {
513513 "kind": {
514514 "enum": [
@@ -531,6 +531,13 @@
531531 "warm",
532532 "bold"
533533 ]
534 },
535 "approved": {
536 "type": "boolean"
537 },
538 "seen": {
539 "type": "string",
540 "description": "To approve: the asset's updated_at as you read it; a conflict (409) if the words have changed since."
534541 }
535542 }
536543 }
@@ -539,7 +546,7 @@
539546 },
540547 "responses": {
541548 "200": {
542 "description": "Edit one asset's words, or write every asset again in a tone.",
549 "description": "Edit one asset's words, sign one off or withdraw it ({ kind, approved }), or write every asset again in a tone.",
543550 "content": {
544551 "application/json": {
545552 "schema": {
@@ -1052,6 +1059,34 @@
10521059 "clear",
10531060 "findings"
10541061 ]
1062 },
1063 "approval": {
1064 "description": "Who signed off these words and when; null if nobody has.",
1065 "oneOf": [
1066 {
1067 "type": "null"
1068 },
1069 {
1070 "type": "object",
1071 "properties": {
1072 "by_id": {
1073 "type": "string"
1074 },
1075 "by_name": {
1076 "type": "string"
1077 },
1078 "at": {
1079 "type": "string",
1080 "format": "date-time"
1081 }
1082 },
1083 "required": [
1084 "by_id",
1085 "by_name",
1086 "at"
1087 ]
1088 }
1089 ]
10551090 }
10561091 },
10571092 "required": [
@@ -1060,7 +1095,8 @@
10601095 "text",
10611096 "edited",
10621097 "updated_at",
1063 "check"
1098 "check",
1099 "approval"
10641100 ]
10651101 }
10661102 }
src/actions.ts +77 −3
@@ -47,7 +47,12 @@ export interface Note {
4747 readonly updated_at: string;
4848 }
4949
50 export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid";
50 export type Failure =
51 | "signin_required"
52 | "forbidden"
53 | "not_found"
54 | "invalid"
55 | "conflict";
5156 export type Result<T> =
5257 | { ok: true; value: T }
5358 | { ok: false; error: Failure; message: string };
@@ -327,7 +332,7 @@ function launched(db: Database, id: number): Launched | undefined {
327332 if (row === null) return undefined;
328333 const assets = db
329334 .query<AssetRow, [number]>(
330 "SELECT kind, body, edited, updated_at FROM asset WHERE listing_id = ?1",
335 "SELECT kind, body, edited, updated_at, approved_by_id, approved_by_name, approved_at FROM asset WHERE listing_id = ?1",
331336 )
332337 .all(id);
333338 return { ...fromRow(row), assets: assetsOf(assets) };
@@ -339,6 +344,7 @@ function writeAssets(db: Database, listing: Listing): void {
339344 const put = db.query(
340345 `INSERT INTO asset (listing_id, kind, body, edited) VALUES (?1, ?2, ?3, 0)
341346 ON CONFLICT (listing_id, kind) DO UPDATE SET body = excluded.body, edited = 0,
347 approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL,
342348 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`,
343349 );
344350 for (const kind of KINDS) put.run(listing.id, kind, words[kind]);
@@ -572,7 +578,7 @@ function edit(
572578 if (!text.ok) return fail("invalid", text.message);
573579 db.transaction(() => {
574580 db.query(
575 "UPDATE asset SET body = ?1, edited = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE listing_id = ?2 AND kind = ?3",
581 "UPDATE asset SET body = ?1, edited = 1, approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE listing_id = ?2 AND kind = ?3",
576582 ).run(text.value, found.value.id, input.kind);
577583 touch(db, found.value.id);
578584 })();
@@ -580,6 +586,74 @@ function edit(
580586 return ok(launched(db, found.value.id)!);
581587 }
582588
589 /**
590 * A person signs off one asset's words (or takes it back): who and when go
591 * on the row, and any change to the words clears it. Whoever may update the
592 * listing may; findings do not stop it, the page only says how many there
593 * were. Approving is bound to what was seen: `seen` is the asset's
594 * `updated_at` as the person read it, and `shown`, when a form has the box's
595 * text, is what it holds now; if either differs from what is stored, nothing
596 * is approved (`conflict`). Both directions are audit rows, named in the
597 * outcome.
598 */
599 export function approveAsset(
600 db: Database,
601 who: Who,
602 input: { id: number; kind: string; approved?: unknown; seen?: unknown; shown?: unknown },
603 ): Result<Launched> {
604 const approving = input.approved === undefined || input.approved === true;
605 const done = approve(db, who, input, approving);
606 record(db, {
607 who,
608 resource: "listing",
609 verb: "update",
610 target: done.ok ? done.value.id : input.id,
611 outcome: done.ok ? (approving ? "approved" : "unapproved") : done.error,
612 });
613 return done;
614 }
615
616 function approve(
617 db: Database,
618 who: Who,
619 input: { id: number; kind: string; approved?: unknown; seen?: unknown; shown?: unknown },
620 approving: boolean,
621 ): Result<Launched> {
622 const found = listingFor(db, who, "update", input.id, {
623 signIn: "Sign in to approve this listing's words.",
624 no: "Only the listing's agent or an admin can approve its words.",
625 });
626 if (!found.ok) return found;
627 if (who.role === "anon") return fail("signin_required", "Sign in to approve.");
628 if (input.approved !== undefined && typeof input.approved !== "boolean")
629 return fail("invalid", "approved is true or false.");
630 if (!isKind(input.kind))
631 return fail("invalid", `An asset is one of ${KINDS.join(", ")}.`);
632 const asset = found.value.assets.find((a) => a.kind === input.kind);
633 if (asset === undefined) return fail("not_found", "No such asset.");
634 if (approving) {
635 if (typeof input.seen !== "string" || input.seen === "")
636 return fail("invalid", "Say which version you read: `seen` is the asset's updated_at.");
637 const shown = input.shown === undefined ? undefined : assetText(input.shown);
638 if (
639 input.seen !== asset.updated_at ||
640 (shown !== undefined && (!shown.ok || shown.value !== asset.text))
641 )
642 return fail(
643 "conflict",
644 "These words changed since you read them. Save your changes and read them again before approving.",
645 );
646 db.query(
647 "UPDATE asset SET approved_by_id = ?1, approved_by_name = ?2, approved_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE listing_id = ?3 AND kind = ?4",
648 ).run(who.id, who.name, found.value.id, input.kind);
649 } else
650 db.query(
651 "UPDATE asset SET approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL WHERE listing_id = ?1 AND kind = ?2",
652 ).run(found.value.id, input.kind);
653 emit(db, "listing.changed", { id: found.value.id });
654 return ok(launched(db, found.value.id)!);
655 }
656
583657 const touch = (db: Database, id: number): void => {
584658 db.query(
585659 "UPDATE listing SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?1",
src/contract.test.ts +36
@@ -10,6 +10,7 @@ import { tmpdir } from "node:os";
1010 import { join } from "node:path";
1111 import {
1212 addNote,
13 approveAsset,
1314 deleteListing,
1415 deleteNote,
1516 editAsset,
@@ -27,6 +28,7 @@ import { record } from "./audit.ts";
2728 import { actingAs, identities, table, type Verb } from "./can.ts";
2829 import { contract } from "./contract.ts";
2930 import { openDb } from "./db.ts";
31 import { rpc } from "./mcp.ts";
3032 import { SAMPLE } from "./sample.ts";
3133 import type { Who } from "./who.ts";
3234
@@ -134,3 +136,37 @@ test("every door answers as the table says, as every identity", () => {
134136 }
135137 expect(asked).toBeGreaterThan(0);
136138 });
139
140 /** Approving is `listing:update`: the action and the MCP tool let through exactly whom the table's update cell does. */
141 test("approving an asset answers as the listing's update cell says, at the action and the tool", () => {
142 let asked = 0;
143 for (const cell of table(contract)) {
144 if (cell.resource !== "listing" || cell.verb !== "update") continue;
145 const who = whoIs(cell.as);
146 const { mine, theirs } = actingAs(contract, cell.as);
147 const attempts = (authorId: string) => {
148 const id = listingBy(authorId);
149 const seen = getListing(db, { role: "admin", id: "root", name: "root" }, { id });
150 const updated = seen.ok ? seen.value.assets.find((a) => a.kind === "mls")!.updated_at : "";
151 const args = { id, kind: "mls", seen: updated };
152 const called = rpc(db, who, {
153 jsonrpc: "2.0",
154 id: 1,
155 method: "tools/call",
156 params: { name: "approve_asset", arguments: args },
157 });
158 const tool = !(JSON.stringify(called.body).includes('"isError":true'));
159 return [approveAsset(db, who, args).ok, tool];
160 };
161 const onMine = attempts(String(mine["author_id"]));
162 const onTheirs = attempts(String(theirs["author_id"]));
163 const said = `approve as ${cell.as}`;
164 asked++;
165 if (cell.answer === "yes")
166 expect([said, onMine, onTheirs]).toEqual([said, [true, true], [true, true]]);
167 else if (cell.answer === "no")
168 expect([said, onMine, onTheirs]).toEqual([said, [false, false], [false, false]]);
169 else expect([said, onMine, onTheirs]).toEqual([said, [true, true], [false, false]]);
170 }
171 expect(asked).toBeGreaterThan(0);
172 });
src/db.ts +4
@@ -84,6 +84,10 @@ export const migrations: string[] = [
8484 data TEXT NOT NULL,
8585 at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
8686 )`,
87 // Who signed off an asset's words, and when; cleared when the words change.
88 `ALTER TABLE asset ADD COLUMN approved_by_id TEXT`,
89 `ALTER TABLE asset ADD COLUMN approved_by_name TEXT`,
90 `ALTER TABLE asset ADD COLUMN approved_at TEXT`,
8791 ];
8892
8993 export function openDb(dir = dataDir): Database {
src/listing.ts +19
@@ -53,6 +53,14 @@ export interface Asset {
5353 readonly updated_at: string;
5454 /** The Fair Housing check, run on the words as they are now. */
5555 readonly check: Check;
56 /** Who signed off these words and when; null once they change. */
57 readonly approval: Approval | null;
58 }
59
60 export interface Approval {
61 readonly by_id: string;
62 readonly by_name: string;
63 readonly at: string;
5664 }
5765
5866 /** A listing with its five assets, each checked. */
@@ -104,6 +112,9 @@ export interface AssetRow {
104112 readonly body: string;
105113 readonly edited: number;
106114 readonly updated_at: string;
115 readonly approved_by_id: string | null;
116 readonly approved_by_name: string | null;
117 readonly approved_at: string | null;
107118 }
108119
109120 /** The stored assets, in the order a person reads them, each checked now. */
@@ -120,6 +131,14 @@ export const assetsOf = (rows: readonly AssetRow[]): Asset[] =>
120131 edited: row.edited === 1,
121132 updated_at: row.updated_at,
122133 check: check(row.body),
134 approval:
135 row.approved_at === null
136 ? null
137 : {
138 by_id: row.approved_by_id ?? "",
139 by_name: row.approved_by_name ?? "",
140 at: row.approved_at,
141 },
123142 },
124143 ];
125144 });
src/listings.test.ts +118 −1
@@ -8,7 +8,7 @@ import { afterAll, expect, test } from "bun:test";
88 import { existsSync, mkdtempSync, readdirSync } from "node:fs";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
11 import { launchListing, retoneListing } from "./actions.ts";
11 import { approveAsset, editAsset, getListing, launchListing, retoneListing } from "./actions.ts";
1212 import { openDb } from "./db.ts";
1313 import { validate } from "./listing.ts";
1414 import { sniff } from "./photos.ts";
@@ -316,3 +316,120 @@ test("no control character, C0 or C1, reaches a terminal from typed words", asyn
316316 const note = addNote(db, { role: "user", id: "u1", name: "me" }, { body: `hi${csi}there` });
317317 expect(note.ok && note.value.body).toBe("hi 31m [2Jthere");
318318 });
319
320 test("approval is recorded with who and when, cleared by an edit or a rewrite, and audited", async () => {
321 const db = openDb(mkdtempSync(join(tmpdir(), "app-approve-")));
322 const me: Who = { role: "user", id: "u1", name: "me" };
323 const other: Who = { role: "user", id: "u2", name: "other" };
324 const made = launchListing(db, me, { fields: { ...SAMPLE } });
325 if (!made.ok) throw new Error(made.message);
326 const id = made.value.id;
327 const mls = (l: { assets: readonly { kind: string; approval: unknown }[] }) =>
328 l.assets.find((a) => a.kind === "mls")!.approval as { by_name: string; at: string } | null;
329 expect(mls(made.value)).toBeNull();
330 const seen = (kind: string) => {
331 const l = getListing(db, me, { id });
332 return l.ok ? l.value.assets.find((a) => a.kind === kind)!.updated_at : "";
333 };
334
335 const approved = approveAsset(db, me, { id, kind: "mls", seen: seen("mls") });
336 if (!approved.ok) throw new Error(approved.message);
337 expect(mls(approved.value)?.by_name).toBe("me");
338 expect(mls(approved.value)?.at).toMatch(/^\d{4}-/);
339 expect(approved.value.assets.filter((a) => a.approval !== null).length).toBe(1);
340
341 // Someone else is refused; the row stays as it was.
342 const refused = approveAsset(db, other, { id, kind: "email", seen: seen("email") });
343 expect(refused.ok).toBe(false);
344 if (!refused.ok) expect(refused.error).toBe("forbidden");
345 expect(approveAsset(db, { role: "anon" }, { id, kind: "email", seen: seen("email") }).ok).toBe(false);
346 expect(approveAsset(db, me, { id, kind: "nope" }).ok).toBe(false);
347
348 // An edit clears that asset only; a rewrite clears the rest.
349 approveAsset(db, me, { id, kind: "email", seen: seen("email") });
350 const edited = editAsset(db, me, { id, kind: "mls", text: "Fresh words." });
351 if (!edited.ok) throw new Error(edited.message);
352 expect(mls(edited.value)).toBeNull();
353 expect(edited.value.assets.find((a) => a.kind === "email")!.approval).not.toBeNull();
354 const rewritten = retoneListing(db, me, { id, tone: "warm" });
355 if (!rewritten.ok) throw new Error(rewritten.message);
356 expect(rewritten.value.assets.every((a) => a.approval === null)).toBe(true);
357
358 // Approval is bound to what was seen: stale, missing or non-boolean input approves nothing.
359 const stale = seen("mls");
360 editAsset(db, me, { id, kind: "mls", text: "Changed after you read it." });
361 const late = approveAsset(db, me, { id, kind: "mls", seen: stale });
362 expect(late.ok).toBe(false);
363 if (!late.ok) expect(late.error).toBe("conflict");
364 const unseen = approveAsset(db, me, { id, kind: "mls" });
365 expect(unseen.ok || unseen.error).toBe("invalid");
366 const typed = approveAsset(db, me, { id, kind: "mls", seen: seen("mls"), shown: "Unsaved typing." });
367 expect(typed.ok || typed.error).toBe("conflict");
368 for (const approved of ["no", null, 0, "false"]) {
369 const bad = approveAsset(db, me, { id, kind: "mls", approved, seen: seen("mls") });
370 expect(bad.ok || bad.error).toBe("invalid");
371 }
372 const same = approveAsset(db, me, { id, kind: "mls", seen: seen("mls"), shown: "Changed after you read it.\r\n" });
373 expect(same.ok).toBe(true);
374 approveAsset(db, me, { id, kind: "mls", approved: false });
375
376 // Withdrawing is the same action.
377 approveAsset(db, me, { id, kind: "flyer", seen: seen("flyer") });
378 const withdrawn = approveAsset(db, me, { id, kind: "flyer", approved: false });
379 expect(withdrawn.ok && withdrawn.value.assets.every((a) => a.approval === null)).toBe(true);
380
381 const outcomes = db
382 .query<{ outcome: string }, []>("SELECT outcome FROM audit WHERE verb = 'update' ORDER BY id")
383 .all()
384 .map((r) => r.outcome);
385 expect(outcomes).toContain("approved");
386 expect(outcomes).toContain("unapproved");
387 expect(outcomes).toContain("forbidden");
388 });
389
390 test("the page says how many are approved, names the flags, and the form and API approve", async () => {
391 const alice = as("carol");
392 const { to } = await launch(alice);
393 const id = Number(to.split("/").at(-1));
394 let page = await (await fetch(`${base}/listings/${id}`)).text();
395 expect(page).toContain("0 of 6 approved");
396 expect(page).not.toContain("Approve</button>"); // anonymous readers have no button
397 // Two flagged phrases make the button say so.
398 const edit = new FormData();
399 edit.set("text", "A family-friendly street with a master suite.");
400 await fetch(`${base}/listings/${id}/assets/social_short`, { method: "POST", body: edit, headers: alice, redirect: "manual" });
401 page = await (await fetch(`${base}/listings/${id}`, { headers: alice })).text();
402 expect(page).toMatch(/Approve with \d flags?<\/button>/);
403 expect(page).toContain(">Approve</button>");
404
405 const seenIn = (html: string, kind: string) =>
406 html.match(new RegExp(`name="seen" value="([^"]+)"[^<]*<button[^>]*assets/${kind}/approve`))?.[1] ?? "";
407 const form = new FormData();
408 form.set("approved", "1");
409 form.set("seen", seenIn(page, "mls"));
410 form.set("text", "unsaved typing");
411 const stale = await fetch(`${base}/listings/${id}/assets/mls/approve`, { method: "POST", body: form, headers: alice, redirect: "manual" });
412 expect(stale.status).toBe(303);
413 expect(await (await fetch(`${base}/listings/${id}`, { headers: alice })).text()).toContain("0 of 6 approved");
414 form.delete("text");
415 const posted = await fetch(`${base}/listings/${id}/assets/mls/approve`, { method: "POST", body: form, headers: alice, redirect: "manual" });
416 expect(posted.status).toBe(303);
417 page = await (await fetch(`${base}/listings/${id}`, { headers: alice })).text();
418 expect(page).toContain("1 of 6 approved");
419 expect(page).toContain("Approved by carol");
420
421 const api = (who: Record<string, string>, body: unknown) =>
422 fetch(`${base}/api/listings/${id}`, { method: "PATCH", headers: { ...who, "content-type": "application/json" }, body: JSON.stringify(body) });
423 const listed = (await (await fetch(`${base}/api/listings/${id}`)).json()) as { assets: { kind: string; updated_at: string }[] };
424 const at = (kind: string) => listed.assets.find((a) => a.kind === kind)!.updated_at;
425 expect((await api(bob, { kind: "email", approved: true, seen: at("email") })).status).toBe(403);
426 expect((await api(alice, { kind: "email", approved: "no", seen: at("email") })).status).toBe(422);
427 expect((await api(alice, { kind: "email", approved: null, seen: at("email") })).status).toBe(422);
428 expect((await api(alice, { kind: "email", approved: true, seen: "2000-01-01T00:00:00.000Z" })).status).toBe(409);
429 for (const kind of ["social_short", "social_medium", "social_carousel", "flyer", "email"])
430 expect((await api(alice, { kind, approved: true, seen: at(kind) })).status).toBe(200);
431 page = await (await fetch(`${base}/listings/${id}`, { headers: alice })).text();
432 expect(page).toContain("Ready to send");
433 const back = await api(alice, { kind: "mls", approved: false });
434 expect(((await back.json()) as { assets: { approval: unknown }[] }).assets.filter((a) => a.approval === null).length).toBe(1);
435 });
src/mcp.ts +23
@@ -9,6 +9,7 @@
99 import type { Database } from "bun:sqlite";
1010 import {
1111 addNote,
12 approveAsset,
1213 checkWords,
1314 deleteListing,
1415 deleteNote,
@@ -144,6 +145,28 @@ export const tools: readonly Tool[] = [
144145 text: args.text,
145146 }),
146147 },
148 {
149 name: "approve_asset",
150 description:
151 "Sign off one asset's words as the signed-in person (approved: false takes it back). Editing the words clears it; approving needs `seen`, the asset's updated_at you read. Only the listing's agent or an admin can.",
152 inputSchema: {
153 type: "object",
154 properties: {
155 id: { type: "integer" },
156 kind: { enum: [...KINDS] },
157 approved: { type: "boolean", description: "true (the default) approves; false withdraws." },
158 seen: { type: "string", description: "To approve: the asset's updated_at as get_listing showed it; refused as a conflict if the words changed since." },
159 },
160 required: ["id", "kind"],
161 },
162 run: (db, who, args) =>
163 approveAsset(db, who, {
164 id: Number(args.id),
165 kind: String(args.kind ?? ""),
166 approved: args.approved,
167 seen: args.seen,
168 }),
169 },
147170 {
148171 name: "retone_listing",
149172 description:
src/pages/listing.ts +16
@@ -54,6 +54,20 @@ export function fairHousing(text: string, check: Check): string {
5454 const flags = (l: Launched): number =>
5555 l.assets.reduce((n, a) => n + a.check.findings.length, 0);
5656
57 const approvedCount = (l: Launched): number =>
58 l.assets.filter((a) => a.approval !== null).length;
59
60 /** The approve control beside Copy: a button naming the flags, or who signed off and a way to take it back. */
61 function approval(l: Launched, a: Asset): string {
62 const to = `/listings/${l.id}/assets/${a.kind}/approve`;
63 if (a.approval !== null)
64 return `<span class="approved" data-approved>✓ Approved by ${escape(a.approval.by_name)} ${ago(a.approval.at)}</span>
65 <button class="button button-quiet" formaction="${to}" name="approved" value="0">Withdraw</button>`;
66 const n = a.check.findings.length;
67 return `<input type="hidden" name="seen" value="${escape(a.updated_at)}">
68 <button class="button" data-approve formaction="${to}" name="approved" value="1">${n === 0 ? "Approve" : `Approve with ${n} ${n === 1 ? "flag" : "flags"}`}</button>`;
outdated

This button lives in the edit form, so without JS unsaved text is dropped and the stored text is approved. Send the text's hash/updated_at and refuse if it changed.

69 }
70
5771 function editor(l: Launched, a: Asset, editable: boolean): string {
5872 const rows = Math.min(16, Math.max(4, Math.ceil(a.text.length / 70) + a.text.split("\n").length));
5973 return `<section class="asset" id="${a.kind}" aria-labelledby="${a.kind}-title">
@@ -64,6 +78,7 @@ function editor(l: Launched, a: Asset, editable: boolean): string {
6478 <div class="row asset-actions">
6579 ${editable ? `<button class="button">Save</button>` : ""}
6680 <button type="button" class="button button-quiet" data-copy hidden>Copy</button>
81 ${editable ? approval(l, a) : a.approval === null ? "" : `<span class="approved">✓ Approved by ${escape(a.approval.by_name)} ${ago(a.approval.at)}</span>`}
6782 <small class="muted" data-count>${a.text.length} characters</small>
6883 </div>
6984 </form>
@@ -115,6 +130,7 @@ export function listingPage(input: {
115130 <p class="muted"><small>${escape(l.agent_name)} · ${escape(l.brokerage)} · updated ${ago(l.updated_at)}</small></p>
116131 </div>
117132 <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>`}
118134 ${n === 0 ? `<span class="fh-pill fh-ok">Fair Housing: clear</span>` : `<span class="fh-pill fh-warn">Fair Housing: ${n} to review</span>`}
119135 ${tone}
120136 <a class="button" href="/listings/${l.id}/flyer">Print flyer</a>
src/routes.ts +48 −4
@@ -15,6 +15,7 @@ import type { Database } from "bun:sqlite";
1515 import { existsSync, readFileSync } from "node:fs";
1616 import {
1717 addNote,
18 approveAsset,
1819 BODY_MAX,
1920 canEditListing,
2021 checkWords,
@@ -215,8 +216,23 @@ export const LISTING: Schema = {
215216 edited: { type: "boolean" },
216217 updated_at: { type: "string", format: "date-time" },
217218 check: CHECK,
219 approval: {
220 description: "Who signed off these words and when; null if nobody has.",
221 oneOf: [
222 { type: "null" },
223 {
224 type: "object",
225 properties: {
226 by_id: { type: "string" },
227 by_name: { type: "string" },
228 at: { type: "string", format: "date-time" },
229 },
230 required: ["by_id", "by_name", "at"],
231 },
232 ],
233 },
218234 },
219 required: ["kind", "label", "text", "edited", "updated_at", "check"],
235 required: ["kind", "label", "text", "edited", "updated_at", "check", "approval"],
220236 },
221237 },
222238 },
@@ -230,11 +246,16 @@ const LISTING_BODY: Schema = {
230246 const LISTING_CHANGE: Schema = {
231247 type: "object",
232248 description:
233 "Either one asset's new words ({ kind, text }), or every asset written again in a tone ({ tone }), which replaces edits.",
249 "Either one asset's new words ({ kind, text }), one asset signed off or withdrawn ({ kind, approved, seen }), or every asset written again in a tone ({ tone }), which replaces edits.",
234250 properties: {
235251 kind: { enum: [...KINDS] },
236252 text: { type: "string", minLength: 1, maxLength: ASSET_MAX },
237253 tone: { enum: [...TONES] },
254 approved: { type: "boolean" },
255 seen: {
256 type: "string",
257 description: "To approve: the asset's updated_at as you read it; a conflict (409) if the words have changed since.",
258 },
238259 },
239260 };
240261 export const ERROR: Schema = {
@@ -255,6 +276,7 @@ const STATUS: Record<Failure, number> = {
255276 forbidden: 403,
256277 not_found: 404,
257278 invalid: 422,
279 conflict: 409,
258280 };
259281
260282 /** A result as JSON: the value, or `{ error, message }` with the matching status. */
@@ -431,6 +453,26 @@ export const routes: readonly Route[] = [
431453 return back(edited.ok ? "Saved and checked." : edited.message, to);
432454 },
433455 },
456 {
457 method: "POST",
458 path: "/listings/:id/assets/:kind/approve",
459 handle: async (c) => {
460 const form = await readForm(c.request);
461 if (form === undefined) return text("too large", 413);
462 const id = Number(c.params.id);
463 const kind = c.params.kind ?? "";
464 const approving = form.get("approved") !== "0";
465 const done = approveAsset(c.db, c.who, {
466 id,
467 kind,
468 approved: approving,
469 seen: form.get("seen"),
470 ...(form.has("text") ? { shown: form.get("text") } : {}),
471 });
472 const to = `/listings/${id}?tab=${tabOf(kind)}#${encodeURIComponent(kind)}`;
473 return back(done.ok ? (approving ? "Approved." : "Approval withdrawn.") : done.message, to);
474 },
475 },
434476 {
435477 method: "POST",
436478 path: "/listings/:id/tone",
@@ -614,10 +656,12 @@ export const routes: readonly Route[] = [
614656 return answer(
615657 body.tone !== undefined && body.kind === undefined
616658 ? retoneListing(c.db, c.who, { id, tone: body.tone })
617 : editAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), text: body.text }),
659 : body.approved !== undefined && body.text === undefined
660 ? approveAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), approved: body.approved, seen: body.seen })
661 : editAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), text: body.text }),
618662 );
619663 },
620 api: { summary: "Edit one asset's words, or write every asset again in a tone.", body: LISTING_CHANGE, answer: LISTING },
664 api: { summary: "Edit one asset's words, sign one off or withdraw it ({ kind, approved }), or write every asset again in a tone.", body: LISTING_CHANGE, answer: LISTING },
621665 },
622666 {
623667 method: "DELETE",
src/server.test.ts +1
@@ -149,6 +149,7 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
149149 "get_listing",
150150 "launch_listing",
151151 "edit_asset",
152 "approve_asset",
152153 "retone_listing",
153154 "delete_listing",
154155 "check_fair_housing",
src/tty.test.ts +17
@@ -106,3 +106,20 @@ test("what the terminal does reaches a web page live, from another connection",
106106 server.stop(true);
107107 }
108108 });
109
110 test("a approves the asset under the cursor, and withdraws it; a stranger's a is refused", () => {
111 const { db } = fresh();
112 const tty = session(db, me);
113 tty.press("\r");
114 expect(plain(tty.screen())).toContain("0 of 6 approved");
115 tty.press("a");
116 expect(plain(tty.screen())).toContain("Approved.");
117 expect(plain(tty.screen())).toContain("1 of 6 approved");
118 expect(plain(tty.screen())).toContain("approved by me");
119 tty.press("a");
120 expect(plain(tty.screen())).toContain("Approval withdrawn.");
121 const theirs = session(db, other);
122 theirs.press("\r");
123 theirs.press("a");
124 expect(plain(theirs.screen())).toContain("0 of 6 approved");
125 });
src/tty.ts +18 −6
@@ -6,7 +6,8 @@
66 * - listings: enter opens one — every asset with its Fair Housing findings;
77 * j/k move between the assets, e edits the one under the cursor (the
88 * words are checked again as they are saved), t writes them all again in
9 * the next tone, esc goes back; x removes a listing.
9 * the next tone, a approves the asset or withdraws it, esc goes back; x
10 * removes a listing.
1011 * - notes: a adds, d removes.
1112 *
1213 * j/k move, q leaves. Launching, with its photos, is the page's. Plain ANSI,
@@ -17,6 +18,7 @@
1718 import type { Database } from "bun:sqlite";
1819 import {
1920 addNote,
21 approveAsset,
2022 canDelete,
2123 canEditListing,
2224 canRemoveListing,
@@ -66,10 +68,11 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
6668 if (found === undefined) return [];
6769 if (!found.ok) return [found.message];
6870 const l = found.value;
69 const head = [bold(`${l.address} · ${money(l.price)}`), dim(`${l.tone} tone`), ""];
71 const signed = l.assets.filter((a) => a.approval !== null).length;
72 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`}`), ""];
7073 // Scroll so the asset under the cursor starts the page.
7174 const body = l.assets.slice(cursor).flatMap((a, i) => [
72 `${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") : ""}`,
75 `${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}`)}`,
7376 ...a.text.split("\n").map((line) => ` ${line}`),
7477 ...a.check.findings.map((f) => amber(` ! ${f.line}`)),
7578 "",
@@ -89,7 +92,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
8992 const found = open();
9093 help =
9194 found?.ok === true && canEditListing(me, found.value)
92 ? "j/k asset · e edit · t next tone · esc back · q quit"
95 ? "j/k asset · e edit · a approve/withdraw · t next tone · esc back · q quit"
9396 : "j/k asset · esc back · q quit";
9497 } else if (view === "listings") {
9598 const rows = listings();
@@ -171,10 +174,19 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
171174 }
172175 if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1));
173176 if (up) cursor = Math.max(cursor - 1, 0);
174 if (found?.ok === true && (key === "e" || key === "t")) {
177 if (found?.ok === true && (key === "e" || key === "t" || key === "a")) {
175178 if (!canEditListing(me, found.value))
176179 said = me.role === "anon" ? "Sign in to edit this listing." : "Only the listing's agent or an admin can edit it.";
177 else if (key === "e") {
180 else if (key === "a") {
181 const asset = assets[cursor];
182 if (asset !== undefined) {
183 const withdrawing = asset.approval !== null;
184 const done = approveAsset(db, me, { id: found.value.id, kind: asset.kind, approved: !withdrawing, seen: asset.updated_at });
185 said = done.ok
186 ? withdrawing ? "Approval withdrawn." : green("Approved.")

withdrawing ? false : true is !withdrawing.

187 : done.message;
188 }
189 } else if (key === "e") {
178190 const asset = assets[cursor];
179191 if (asset !== undefined) {
180192 typing = { for: "asset", kind: asset.kind };
static/app.css +5
@@ -554,6 +554,11 @@ h3 {
554554 margin-top: var(--space-2);
555555 flex-wrap: wrap;
556556 }
557 .approved {
558 color: var(--color-ok-fg);
559 font-size: var(--text-sm);
560 font-weight: 600;
561 }
557562 .danger-zone {
558563 margin-top: var(--space-5);
559564 }
static/listing.js +2
@@ -49,9 +49,11 @@ for (const area of document.querySelectorAll("textarea[data-check]")) {
4949 const kind = area.dataset.check;
5050 const aside = document.querySelector(`[data-fh="${kind}"]`);
5151 const counter = area.closest("form").querySelector("[data-count]");
52 const approve = area.closest("form").querySelector("[data-approve]");
5253 let timer;
5354 let asked = 0;
5455 area.addEventListener("input", () => {
56 if (approve) approve.disabled = true;
5557 if (counter) counter.textContent = `${area.value.length} characters · not saved`;
5658 clearTimeout(timer);
5759 timer = setTimeout(async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs