Public: anyone can read this; the crew can change it.
Approve each asset before it leaves: a human signs off every message #8
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/4-approve-each-asset-before-it-leaves-a-hu → main 2 commits fork 3 threads · 3 unresolved
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 | ||
| 20 | 20 | clear, amber with the risky words underlined and a one-line reason — |
| 21 | 21 | *"'family-friendly' implies familial status; say 'near parks and |
| 22 | 22 | 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. | |
| 23 | 32 | 4. **Print the flyer.** "Print flyer" opens a letter-size page — hero photo, |
| 24 | 33 | price, a strip of the facts, the description, your photo, name, |
| 25 | 34 | brokerage and license number, and the Equal Housing Opportunity line. |
contract/openapi.json +40 −4
| @@ -490,7 +490,7 @@ | ||
| 490 | 490 | } |
| 491 | 491 | }, |
| 492 | 492 | "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.", | |
| 494 | 494 | "operationId": "patchApiListingsId", |
| 495 | 495 | "parameters": [ |
| 496 | 496 | { |
| @@ -508,7 +508,7 @@ | ||
| 508 | 508 | "application/json": { |
| 509 | 509 | "schema": { |
| 510 | 510 | "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.", | |
| 512 | 512 | "properties": { |
| 513 | 513 | "kind": { |
| 514 | 514 | "enum": [ |
| @@ -531,6 +531,13 @@ | ||
| 531 | 531 | "warm", |
| 532 | 532 | "bold" |
| 533 | 533 | ] |
| 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." | |
| 534 | 541 | } |
| 535 | 542 | } |
| 536 | 543 | } |
| @@ -539,7 +546,7 @@ | ||
| 539 | 546 | }, |
| 540 | 547 | "responses": { |
| 541 | 548 | "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.", | |
| 543 | 550 | "content": { |
| 544 | 551 | "application/json": { |
| 545 | 552 | "schema": { |
| @@ -1052,6 +1059,34 @@ | ||
| 1052 | 1059 | "clear", |
| 1053 | 1060 | "findings" |
| 1054 | 1061 | ] |
| 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 | ] | |
| 1055 | 1090 | } |
| 1056 | 1091 | }, |
| 1057 | 1092 | "required": [ |
| @@ -1060,7 +1095,8 @@ | ||
| 1060 | 1095 | "text", |
| 1061 | 1096 | "edited", |
| 1062 | 1097 | "updated_at", |
| 1063 | "check" | |
| 1098 | "check", | |
| 1099 | "approval" | |
| 1064 | 1100 | ] |
| 1065 | 1101 | } |
| 1066 | 1102 | } |
src/actions.ts +77 −3
| @@ -47,7 +47,12 @@ export interface Note { | ||
| 47 | 47 | readonly updated_at: string; |
| 48 | 48 | } |
| 49 | 49 | |
| 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"; | |
| 51 | 56 | export type Result<T> = |
| 52 | 57 | | { ok: true; value: T } |
| 53 | 58 | | { ok: false; error: Failure; message: string }; |
| @@ -327,7 +332,7 @@ function launched(db: Database, id: number): Launched | undefined { | ||
| 327 | 332 | if (row === null) return undefined; |
| 328 | 333 | const assets = db |
| 329 | 334 | .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", | |
| 331 | 336 | ) |
| 332 | 337 | .all(id); |
| 333 | 338 | return { ...fromRow(row), assets: assetsOf(assets) }; |
| @@ -339,6 +344,7 @@ function writeAssets(db: Database, listing: Listing): void { | ||
| 339 | 344 | const put = db.query( |
| 340 | 345 | `INSERT INTO asset (listing_id, kind, body, edited) VALUES (?1, ?2, ?3, 0) |
| 341 | 346 | 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, | |
| 342 | 348 | updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`, |
| 343 | 349 | ); |
| 344 | 350 | for (const kind of KINDS) put.run(listing.id, kind, words[kind]); |
| @@ -572,7 +578,7 @@ function edit( | ||
| 572 | 578 | if (!text.ok) return fail("invalid", text.message); |
| 573 | 579 | db.transaction(() => { |
| 574 | 580 | 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", | |
| 576 | 582 | ).run(text.value, found.value.id, input.kind); |
| 577 | 583 | touch(db, found.value.id); |
| 578 | 584 | })(); |
| @@ -580,6 +586,74 @@ function edit( | ||
| 580 | 586 | return ok(launched(db, found.value.id)!); |
| 581 | 587 | } |
| 582 | 588 | |
| 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 | ||
| 583 | 657 | const touch = (db: Database, id: number): void => { |
| 584 | 658 | db.query( |
| 585 | 659 | "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"; | ||
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { |
| 12 | 12 | addNote, |
| 13 | approveAsset, | |
| 13 | 14 | deleteListing, |
| 14 | 15 | deleteNote, |
| 15 | 16 | editAsset, |
| @@ -27,6 +28,7 @@ import { record } from "./audit.ts"; | ||
| 27 | 28 | import { actingAs, identities, table, type Verb } from "./can.ts"; |
| 28 | 29 | import { contract } from "./contract.ts"; |
| 29 | 30 | import { openDb } from "./db.ts"; |
| 31 | import { rpc } from "./mcp.ts"; | |
| 30 | 32 | import { SAMPLE } from "./sample.ts"; |
| 31 | 33 | import type { Who } from "./who.ts"; |
| 32 | 34 | |
| @@ -134,3 +136,37 @@ test("every door answers as the table says, as every identity", () => { | ||
| 134 | 136 | } |
| 135 | 137 | expect(asked).toBeGreaterThan(0); |
| 136 | 138 | }); |
| 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[] = [ | ||
| 84 | 84 | data TEXT NOT NULL, |
| 85 | 85 | at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| 86 | 86 | )`, |
| 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`, | |
| 87 | 91 | ]; |
| 88 | 92 | |
| 89 | 93 | export function openDb(dir = dataDir): Database { |
src/listing.ts +19
| @@ -53,6 +53,14 @@ export interface Asset { | ||
| 53 | 53 | readonly updated_at: string; |
| 54 | 54 | /** The Fair Housing check, run on the words as they are now. */ |
| 55 | 55 | 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; | |
| 56 | 64 | } |
| 57 | 65 | |
| 58 | 66 | /** A listing with its five assets, each checked. */ |
| @@ -104,6 +112,9 @@ export interface AssetRow { | ||
| 104 | 112 | readonly body: string; |
| 105 | 113 | readonly edited: number; |
| 106 | 114 | readonly updated_at: string; |
| 115 | readonly approved_by_id: string | null; | |
| 116 | readonly approved_by_name: string | null; | |
| 117 | readonly approved_at: string | null; | |
| 107 | 118 | } |
| 108 | 119 | |
| 109 | 120 | /** The stored assets, in the order a person reads them, each checked now. */ |
| @@ -120,6 +131,14 @@ export const assetsOf = (rows: readonly AssetRow[]): Asset[] => | ||
| 120 | 131 | edited: row.edited === 1, |
| 121 | 132 | updated_at: row.updated_at, |
| 122 | 133 | 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 | }, | |
| 123 | 142 | }, |
| 124 | 143 | ]; |
| 125 | 144 | }); |
src/listings.test.ts +118 −1
| @@ -8,7 +8,7 @@ import { afterAll, expect, test } from "bun:test"; | ||
| 8 | 8 | import { existsSync, mkdtempSync, readdirSync } from "node:fs"; |
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | import { launchListing, retoneListing } from "./actions.ts"; | |
| 11 | import { approveAsset, editAsset, getListing, launchListing, retoneListing } from "./actions.ts"; | |
| 12 | 12 | import { openDb } from "./db.ts"; |
| 13 | 13 | import { validate } from "./listing.ts"; |
| 14 | 14 | import { sniff } from "./photos.ts"; |
| @@ -316,3 +316,120 @@ test("no control character, C0 or C1, reaches a terminal from typed words", asyn | ||
| 316 | 316 | const note = addNote(db, { role: "user", id: "u1", name: "me" }, { body: `hi${csi}there` }); |
| 317 | 317 | expect(note.ok && note.value.body).toBe("hi 31m [2Jthere"); |
| 318 | 318 | }); |
| 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 @@ | ||
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | 10 | import { |
| 11 | 11 | addNote, |
| 12 | approveAsset, | |
| 12 | 13 | checkWords, |
| 13 | 14 | deleteListing, |
| 14 | 15 | deleteNote, |
| @@ -144,6 +145,28 @@ export const tools: readonly Tool[] = [ | ||
| 144 | 145 | text: args.text, |
| 145 | 146 | }), |
| 146 | 147 | }, |
| 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 | }, | |
| 147 | 170 | { |
| 148 | 171 | name: "retone_listing", |
| 149 | 172 | description: |
src/pages/listing.ts +16
| @@ -54,6 +54,20 @@ export function fairHousing(text: string, check: Check): string { | ||
| 54 | 54 | const flags = (l: Launched): number => |
| 55 | 55 | l.assets.reduce((n, a) => n + a.check.findings.length, 0); |
| 56 | 56 | |
| 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>`; | |
| 69 | } | |
| 70 | ||
| 57 | 71 | function editor(l: Launched, a: Asset, editable: boolean): string { |
| 58 | 72 | const rows = Math.min(16, Math.max(4, Math.ceil(a.text.length / 70) + a.text.split("\n").length)); |
| 59 | 73 | 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 { | ||
| 64 | 78 | <div class="row asset-actions"> |
| 65 | 79 | ${editable ? `<button class="button">Save</button>` : ""} |
| 66 | 80 | <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>`} | |
| 67 | 82 | <small class="muted" data-count>${a.text.length} characters</small> |
| 68 | 83 | </div> |
| 69 | 84 | </form> |
| @@ -115,6 +130,7 @@ export function listingPage(input: { | ||
| 115 | 130 | <p class="muted"><small>${escape(l.agent_name)} · ${escape(l.brokerage)} · updated ${ago(l.updated_at)}</small></p> |
| 116 | 131 | </div> |
| 117 | 132 | <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>`} | |
| 118 | 134 | ${n === 0 ? `<span class="fh-pill fh-ok">Fair Housing: clear</span>` : `<span class="fh-pill fh-warn">Fair Housing: ${n} to review</span>`} |
| 119 | 135 | ${tone} |
| 120 | 136 | <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"; | ||
| 15 | 15 | import { existsSync, readFileSync } from "node:fs"; |
| 16 | 16 | import { |
| 17 | 17 | addNote, |
| 18 | approveAsset, | |
| 18 | 19 | BODY_MAX, |
| 19 | 20 | canEditListing, |
| 20 | 21 | checkWords, |
| @@ -215,8 +216,23 @@ export const LISTING: Schema = { | ||
| 215 | 216 | edited: { type: "boolean" }, |
| 216 | 217 | updated_at: { type: "string", format: "date-time" }, |
| 217 | 218 | 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 | }, | |
| 218 | 234 | }, |
| 219 | required: ["kind", "label", "text", "edited", "updated_at", "check"], | |
| 235 | required: ["kind", "label", "text", "edited", "updated_at", "check", "approval"], | |
| 220 | 236 | }, |
| 221 | 237 | }, |
| 222 | 238 | }, |
| @@ -230,11 +246,16 @@ const LISTING_BODY: Schema = { | ||
| 230 | 246 | const LISTING_CHANGE: Schema = { |
| 231 | 247 | type: "object", |
| 232 | 248 | 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.", | |
| 234 | 250 | properties: { |
| 235 | 251 | kind: { enum: [...KINDS] }, |
| 236 | 252 | text: { type: "string", minLength: 1, maxLength: ASSET_MAX }, |
| 237 | 253 | 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 | }, | |
| 238 | 259 | }, |
| 239 | 260 | }; |
| 240 | 261 | export const ERROR: Schema = { |
| @@ -255,6 +276,7 @@ const STATUS: Record<Failure, number> = { | ||
| 255 | 276 | forbidden: 403, |
| 256 | 277 | not_found: 404, |
| 257 | 278 | invalid: 422, |
| 279 | conflict: 409, | |
| 258 | 280 | }; |
| 259 | 281 | |
| 260 | 282 | /** A result as JSON: the value, or `{ error, message }` with the matching status. */ |
| @@ -431,6 +453,26 @@ export const routes: readonly Route[] = [ | ||
| 431 | 453 | return back(edited.ok ? "Saved and checked." : edited.message, to); |
| 432 | 454 | }, |
| 433 | 455 | }, |
| 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 | }, | |
| 434 | 476 | { |
| 435 | 477 | method: "POST", |
| 436 | 478 | path: "/listings/:id/tone", |
| @@ -614,10 +656,12 @@ export const routes: readonly Route[] = [ | ||
| 614 | 656 | return answer( |
| 615 | 657 | body.tone !== undefined && body.kind === undefined |
| 616 | 658 | ? 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 }), | |
| 618 | 662 | ); |
| 619 | 663 | }, |
| 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 }, | |
| 621 | 665 | }, |
| 622 | 666 | { |
| 623 | 667 | 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 () => | ||
| 149 | 149 | "get_listing", |
| 150 | 150 | "launch_listing", |
| 151 | 151 | "edit_asset", |
| 152 | "approve_asset", | |
| 152 | 153 | "retone_listing", |
| 153 | 154 | "delete_listing", |
| 154 | 155 | "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", | ||
| 106 | 106 | server.stop(true); |
| 107 | 107 | } |
| 108 | 108 | }); |
| 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 @@ | ||
| 6 | 6 | * - listings: enter opens one — every asset with its Fair Housing findings; |
| 7 | 7 | * j/k move between the assets, e edits the one under the cursor (the |
| 8 | 8 | * 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. | |
| 10 | 11 | * - notes: a adds, d removes. |
| 11 | 12 | * |
| 12 | 13 | * j/k move, q leaves. Launching, with its photos, is the page's. Plain ANSI, |
| @@ -17,6 +18,7 @@ | ||
| 17 | 18 | import type { Database } from "bun:sqlite"; |
| 18 | 19 | import { |
| 19 | 20 | addNote, |
| 21 | approveAsset, | |
| 20 | 22 | canDelete, |
| 21 | 23 | canEditListing, |
| 22 | 24 | canRemoveListing, |
| @@ -66,10 +68,11 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 66 | 68 | if (found === undefined) return []; |
| 67 | 69 | if (!found.ok) return [found.message]; |
| 68 | 70 | 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`}`), ""]; | |
| 70 | 73 | // Scroll so the asset under the cursor starts the page. |
| 71 | 74 | 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}`)}`, | |
| 73 | 76 | ...a.text.split("\n").map((line) => ` ${line}`), |
| 74 | 77 | ...a.check.findings.map((f) => amber(` ! ${f.line}`)), |
| 75 | 78 | "", |
| @@ -89,7 +92,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 89 | 92 | const found = open(); |
| 90 | 93 | help = |
| 91 | 94 | 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" | |
| 93 | 96 | : "j/k asset · esc back · q quit"; |
| 94 | 97 | } else if (view === "listings") { |
| 95 | 98 | const rows = listings(); |
| @@ -171,10 +174,19 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 171 | 174 | } |
| 172 | 175 | if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1)); |
| 173 | 176 | 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")) { | |
| 175 | 178 | if (!canEditListing(me, found.value)) |
| 176 | 179 | 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.") | |
|
||
| 187 | : done.message; | |
| 188 | } | |
| 189 | } else if (key === "e") { | |
| 178 | 190 | const asset = assets[cursor]; |
| 179 | 191 | if (asset !== undefined) { |
| 180 | 192 | typing = { for: "asset", kind: asset.kind }; |
static/app.css +5
| @@ -554,6 +554,11 @@ h3 { | ||
| 554 | 554 | margin-top: var(); |
| 555 | 555 | flex-wrap: wrap; |
| 556 | 556 | } |
| 557 | .approved { | |
| 558 | color: var(); | |
| 559 | font-size: var(); | |
| 560 | font-weight: 600; | |
| 561 | } | |
| 557 | 562 | .danger-zone { |
| 558 | 563 | margin-top: var(); |
| 559 | 564 | } |
static/listing.js +2
| @@ -49,9 +49,11 @@ for (const area of document.querySelectorAll("textarea[data-check]")) { | ||
| 49 | 49 | const kind = area.dataset.check; |
| 50 | 50 | const aside = document.querySelector(`[data-fh="${kind}"]`); |
| 51 | 51 | const counter = area.closest("form").querySelector("[data-count]"); |
| 52 | const approve = area.closest("form").querySelector("[data-approve]"); | |
| 52 | 53 | let timer; |
| 53 | 54 | let asked = 0; |
| 54 | 55 | area.addEventListener("input", () => { |
| 56 | if (approve) approve.disabled = true; | |
| 55 | 57 | if (counter) counter.textContent = `${area.value.length} characters · not saved`; |
| 56 | 58 | clearTimeout(timer); |
| 57 | 59 | timer = setTimeout(async () => { |
new above
offline
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.