Public: anyone can read this; the crew can change it.
Fix a flagged Fair Housing phrase in every asset at once: one tap puts the suggested words in place wherever the phrase appears, and each changed asset waits for approval again #126
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/121-fix-a-flagged-fair-housing-phrase-in-eve → main 2 commits fork 3 threads · 3 unresolved
9 files
changed +385
−9
README.md +12 −1
| @@ -16,7 +16,13 @@ to make a listing of your own. | ||
| 16 | 16 | 2. **Read an asset and its check.** The MLS tab has the description in a box, |
| 17 | 17 | and beside it the **Fair Housing** check: green when it is clear. Open the |
| 18 | 18 | card for *27 Birchwood Court, Boise* to see it catch something: the |
| 19 | risky words are underlined, each with a one-line reason. | |
| 19 | risky words are underlined, each with a one-line reason. Above the tabs, | |
| 20 | the page lists each flagged phrase once, with how many assets it is in; | |
| 21 | press **Use the suggestion everywhere** beside one to put the suggested | |
| 22 | words in every asset at once. Each asset it changes needs approval again | |
| 23 | (three taps clear the sample's 22 findings; you still approve every asset). | |
| 24 | In the terminal, `U` on a listing does the same for the first suggestible | |
| 25 | phrase on the asset under the cursor. | |
| 20 | 26 | 3. **Start a new one from the sample.** Back on Alder Lane, press **Start a |
| 21 | 27 | new one from this**. The form fills in with the sample's facts. Change |
| 22 | 28 | the address to your own listing, then press **Launch**. The sample |
| @@ -285,6 +291,11 @@ list cannot drift from the app. | ||
| 285 | 291 | with JavaScript; the `use_suggestion` MCP tool takes `{ id, kind, start, |
| 286 | 292 | end, text }`, and `u` in the terminal uses the first suggestion on the |
| 287 | 293 | asset under the cursor. |
| 294 | To take a phrase in every asset at once, the page has **Use the suggestion | |
| 295 | everywhere** (a plain form POST to `/listings/:id/fair-housing/suggestion` | |
| 296 | with `phrase`), the terminal has `U`, and the `use_suggestion_everywhere` | |
| 297 | MCP tool takes `{ id, phrase }`: one transaction, one audit entry per | |
| 298 | changed asset, approval cleared on each, nothing approved. | |
| 288 | 299 | Each asset also has an **Approve** button ("Approve with 2 flags" when |
| 289 | 300 | the check still has findings): it records who signed off and when, the |
| 290 | 301 | header counts "4 of 7 approved" and turns to "Ready to send" at 7 of 7, |
src/actions.ts +56 −1
| @@ -10,7 +10,7 @@ import type { Database } from "bun:sqlite"; | ||
| 10 | 10 | import { record, type AuditRow } from "./audit.ts"; |
| 11 | 11 | import { permit } from "./contract.ts"; |
| 12 | 12 | import { earlierAt, type Earlier } from "./duplicate.ts"; |
| 13 | import { check, type Check } from "./fairhousing.ts"; | |
| 13 | import { check, replacePhrase, type Check } from "./fairhousing.ts"; | |
| 14 | 14 | import { generate, isBackOnMarket, isOffMarket, KINDS, remindable, REMINDER_KINDS, STATUS_KINDS, TONES, withoutOpenHouse, type Kind, type Status } from "./generate.ts"; |
| 15 | 15 | import { |
| 16 | 16 | agentOf, |
| @@ -907,6 +907,61 @@ function suggest( | ||
| 907 | 907 | ); |
| 908 | 908 | } |
| 909 | 909 | |
| 910 | /** What taking a suggestion everywhere did: the assets it changed, and the listing as it now stands. */ | |
| 911 | export interface Everywhere { | |
| 912 | readonly id: number; | |
| 913 | /** The kinds whose words changed; each lost its approval. */ | |
| 914 | readonly changed: readonly Kind[]; | |
| 915 | readonly listing: Launched; | |
| 916 | } | |
| 917 | ||
| 918 | /** | |
| 919 | * Take one flagged phrase's suggested wording in every asset of a listing at | |
| 920 | * once: each place `phrase` is flagged (and has a drop-in) is replaced, the | |
| 921 | * old words kept as a version, the check run again and the approval cleared, | |
| 922 | * all in one transaction. Nothing is approved by it. One audit entry per | |
| 923 | * changed asset; a phrase that is nowhere (or has only advice) is a `conflict` | |
| 924 | * and changes nothing. | |
| 925 | */ | |
| 926 | export function useSuggestionEverywhere( | |
| 927 | db: Database, | |
| 928 | who: Who, | |
| 929 | input: { id: number; phrase: unknown }, | |
| 930 | ): Result<Everywhere> { | |
| 931 | const found = listingFor(db, who, "update", input.id, { | |
| 932 | signIn: "Sign in to edit this listing.", | |
| 933 | no: "Only the listing's agent or an admin can edit it.", | |
| 934 | }); | |
| 935 | const refused = (r: Result<Everywhere>): Result<Everywhere> => { | |
| 936 | record(db, { who, resource: "listing", verb: "update", target: input.id, outcome: r.ok ? "ok" : r.error }); | |
| 937 | return r; | |
| 938 | }; | |
| 939 | if (!found.ok) return refused(found); | |
| 940 | if (typeof input.phrase !== "string" || input.phrase.trim() === "" || input.phrase.length > 200) | |
| 941 | return refused(fail("invalid", "Say which phrase: the flagged words, as written.")); | |
| 942 | const phrase = input.phrase; | |
| 943 | const changed: Kind[] = []; | |
| 944 | db.transaction(() => { | |
| 945 | for (const a of found.value.assets) { | |
| 946 | const next = replacePhrase(a.text, phrase, a.check); | |
| 947 | if (next.count === 0) continue; | |
| 948 | keepWords(db, found.value.id, a.kind, next.text, who, "suggestion used everywhere"); | |
| 949 | db.query( | |
| 950 | "UPDATE asset SET body = ?1, edited = 1, approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE listing_id = ?2 AND kind = ?3", | |
| 951 | ).run(next.text, found.value.id, a.kind); | |
| 952 | changed.push(a.kind); | |
| 953 | record(db, { who, resource: "listing", verb: "update", target: found.value.id, outcome: "ok" }); | |
| 954 | } | |
| 955 | if (changed.length === 0) return; | |
| 956 | if (changed.some((k) => k !== "email_update")) redraftUpdate(db, found.value.id); | |
| 957 | touch(db, found.value.id); | |
| 958 | })(); | |
| 959 | if (changed.length === 0) | |
| 960 | return refused(fail("conflict", "That phrase is no longer in any asset, or has advice rather than a phrase to put in its place.")); | |
| 961 | emit(db, "listing.changed", { id: found.value.id }); | |
| 962 | return ok({ id: found.value.id, changed, listing: launched(db, found.value.id)! }); | |
| 963 | } | |
| 964 | ||
| 910 | 965 | /** One earlier version of an asset's words, as the page lists it. */ |
| 911 | 966 | export interface Version { |
| 912 | 967 | readonly id: number; |
src/everywhere.test.ts added +176
| @@ -0,0 +1,176 @@ | ||
| 1 | /** One tap for a flagged phrase in every asset: Birchwood's 22 findings are three phrases. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { approveAsset, getListing, launchListing, listVersions, useSuggestion, useSuggestionEverywhere } from "./actions.ts"; | |
| 7 | import type { AuditRow } from "./audit.ts"; | |
| 8 | import { openDb } from "./db.ts"; | |
| 9 | import { check, groupByPhrase, replacePhrase } from "./fairhousing.ts"; | |
| 10 | import { rpc } from "./mcp.ts"; | |
| 11 | import { listingPage } from "./pages/listing.ts"; | |
| 12 | import { SAMPLE_HOUSE } from "./sample.ts"; | |
| 13 | import { serve } from "./server.ts"; | |
| 14 | import { session } from "./tty.ts"; | |
| 15 | import { sign, type Who } from "./who.ts"; | |
| 16 | ||
| 17 | const me: Who = { role: "user", id: "u1", name: "Pat" }; | |
| 18 | const other: Who = { role: "user", id: "u2", name: "Sam" }; | |
| 19 | const root: Who = { role: "admin", id: "r", name: "Root" }; | |
| 20 | const anon: Who = { role: "anon" }; | |
| 21 | ||
| 22 | const launch = () => { | |
| 23 | const db = openDb(mkdtempSync(join(tmpdir(), "app-everywhere-"))); | |
| 24 | const made = launchListing(db, me, { fields: { ...SAMPLE_HOUSE } }); | |
| 25 | if (!made.ok) throw new Error(made.message); | |
| 26 | return { db, id: made.value.id }; | |
| 27 | }; | |
| 28 | const read = (db: ReturnType<typeof openDb>, id: number) => { | |
| 29 | const l = getListing(db, me, { id }); | |
| 30 | if (!l.ok) throw new Error(l.message); | |
| 31 | return l.value; | |
| 32 | }; | |
| 33 | const findings = (db: ReturnType<typeof openDb>, id: number) => | |
| 34 | read(db, id).assets.reduce((n, a) => n + a.check.findings.length, 0); | |
| 35 | const trail = (db: ReturnType<typeof openDb>) => | |
| 36 | db.query<AuditRow, []>("SELECT * FROM audit WHERE verb = 'update' AND outcome = 'ok'").all(); | |
| 37 | ||
| 38 | const plain = (s: string): string => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); | |
| 39 | const PHRASES = ["Family-friendly", "Walking distance to church", "Great for young couples starting out"]; | |
| 40 | ||
| 41 | test("Birchwood's 22 findings are three phrases, and three taps clear them", () => { | |
| 42 | const { db, id } = launch(); | |
| 43 | expect(findings(db, id)).toBe(22); | |
| 44 | expect(groupByPhrase(read(db, id).assets.map((a) => a.check)).map((p) => p.text)).toEqual(PHRASES); | |
| 45 | for (const phrase of PHRASES) expect(useSuggestionEverywhere(db, me, { id, phrase }).ok).toBe(true); | |
| 46 | expect(findings(db, id)).toBe(0); | |
| 47 | }); | |
| 48 | ||
| 49 | test("each changed asset loses its approval, keeps its old words, and nothing becomes approved", () => { | |
| 50 | const { db, id } = launch(); | |
| 51 | const mls = read(db, id).assets.find((a) => a.kind === "mls")!; | |
| 52 | expect(approveAsset(db, me, { id, kind: "mls", door: "page", seen: mls.updated_at }).ok).toBe(true); | |
| 53 | const before = read(db, id).assets.filter((a) => a.approval !== null).length; | |
| 54 | expect(before).toBe(1); | |
| 55 | const done = useSuggestionEverywhere(db, me, { id, phrase: "Family-friendly" }); | |
| 56 | if (!done.ok) throw new Error(done.message); | |
| 57 | expect(done.value.changed.length).toBe(7); | |
| 58 | expect(read(db, id).assets.filter((a) => a.approval !== null)).toHaveLength(0); | |
| 59 | const versions = listVersions(db, me, { id }); | |
| 60 | if (!versions.ok) throw new Error(versions.message); | |
| 61 | expect(versions.value["mls"]![0]!.text).toContain("Family-friendly"); | |
| 62 | expect(versions.value["mls"]![0]!.why).toBe("suggestion used everywhere"); | |
| 63 | expect(read(db, id).assets.find((a) => a.kind === "mls")!.text).toContain("Near parks and schools street"); | |
outdated
Simplify: call useSuggestion directly via a static import instead of a dynamic import in a promise chain. |
||
| 64 | }); | |
| 65 | ||
| 66 | test("a guest or another user is refused as on the single route; an admin may", () => { | |
| 67 | const { db, id } = launch(); | |
| 68 | const mls = read(db, id).assets.find((a) => a.kind === "mls")!; | |
| 69 | const f = mls.check.findings.find((x) => x.dropIn !== null)!; | |
| 70 | for (const who of [anon, other]) { | |
| 71 | const one = useSuggestion(db, who, { id, kind: "mls", start: f.start, end: f.end, text: f.text }); | |
| 72 | const all = useSuggestionEverywhere(db, who, { id, phrase: f.text }); | |
| 73 | if (one.ok || all.ok) throw new Error("a stranger was let through"); | |
| 74 | expect([all.error, all.message]).toEqual([one.error, one.message]); | |
| 75 | } | |
| 76 | expect(findings(db, id)).toBe(22); | |
| 77 | expect(useSuggestionEverywhere(db, root, { id, phrase: "Family-friendly" }).ok).toBe(true); | |
| 78 | }); | |
| 79 | ||
| 80 | test("a phrase no longer present changes nothing and is a conflict", () => { | |
| 81 | const { db, id } = launch(); | |
| 82 | expect(useSuggestionEverywhere(db, me, { id, phrase: "Family-friendly" }).ok).toBe(true); | |
| 83 | const rows = trail(db).length; | |
| 84 | const again = useSuggestionEverywhere(db, me, { id, phrase: "Family-friendly" }); | |
| 85 | expect(!again.ok && again.error).toBe("conflict"); | |
| 86 | expect(trail(db).length).toBe(rows); | |
| 87 | expect(useSuggestionEverywhere(db, me, { id, phrase: 5 }).ok).toBe(false); | |
| 88 | }); | |
| 89 | ||
| 90 | test("the audit trail holds one entry per changed asset", () => { | |
| 91 | const { db, id } = launch(); | |
| 92 | const base = trail(db).length; | |
| 93 | const done = useSuggestionEverywhere(db, me, { id, phrase: "Great for young couples starting out" }); | |
| 94 | if (!done.ok) throw new Error(done.message); | |
| 95 | expect(done.value.changed.length).toBe(6); | |
| 96 | expect(trail(db).length - base).toBe(6); | |
| 97 | }); | |
| 98 | ||
| 99 | test("overlapping matches in one asset are replaced from the end, and capitals follow the sentence", () => { | |
| 100 | const text = "Family-friendly street. Quiet and family-friendly."; | |
| 101 | const next = replacePhrase(text, "family-friendly"); | |
| 102 | expect(next).toEqual({ text: "Near parks and schools street. Quiet and near parks and schools.", count: 2 }); | |
| 103 | expect(check(next.text).clear).toBe(true); | |
| 104 | }); | |
| 105 | ||
| 106 | test("the listing page lists each phrase once with its count, and a button only for whoever may edit", () => { | |
| 107 | const { db, id } = launch(); | |
| 108 | const l = read(db, id); | |
| 109 | const page = (editable: boolean) => listingPage({ who: me, listing: l, editable }); | |
| 110 | const html = page(true); | |
| 111 | expect(html).toContain("3 phrases to review"); | |
| 112 | expect(html).toContain("in 7 assets"); | |
| 113 | expect(html).toContain("in 6 assets"); | |
| 114 | expect(html.match(/Use the suggestion everywhere<\/button>/g)).toHaveLength(3); | |
| 115 | expect(html).toContain(`action="/listings/${id}/fair-housing/suggestion"`); | |
| 116 | expect(page(false)).not.toContain("Use the suggestion everywhere</button>"); | |
| 117 | }); | |
| 118 | ||
| 119 | test("an everywhere change reads in the sample's sentences", () => { | |
| 120 | const { db, id } = launch(); | |
| 121 | useSuggestionEverywhere(db, me, { id, phrase: "Great for young couples starting out" }); | |
| 122 | expect(read(db, id).assets.find((a) => a.kind === "mls")!.text).toContain("An easy, flexible layout. "); | |
| 123 | }); | |
| 124 | ||
| 125 | test("the terminal's U takes the phrase in every asset and says so; a stranger is refused", () => { | |
| 126 | const { db, id } = launch(); | |
| 127 | const tty = session(db, me); | |
| 128 | tty.press("\r"); | |
| 129 | tty.press("U"); | |
| 130 | expect(plain(tty.screen())).toContain("Changed 7 assets; each needs approval again."); | |
| 131 | const left = read(db, id).assets.reduce((n, a) => n + a.check.findings.length, 0); | |
| 132 | expect(left).toBe(22 - 8 - 0); | |
| 133 | const guest = session(db, other); | |
| 134 | guest.press("\r"); | |
| 135 | guest.press("U"); | |
| 136 | expect(plain(guest.screen())).toContain("Only the listing's agent or an admin can edit it."); | |
| 137 | }); | |
| 138 | ||
| 139 | test("the MCP tool does the same, refused for a stranger", () => { | |
| 140 | const { db, id } = launch(); | |
| 141 | const call = (who: Who) => | |
| 142 | rpc(db, who, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "use_suggestion_everywhere", arguments: { id, phrase: "family-friendly" } } }); | |
| 143 | expect(JSON.stringify(call(other).body)).toContain('"isError":true'); | |
| 144 | expect(JSON.stringify(call(me).body)).not.toContain('"isError":true'); | |
| 145 | expect(findings(db, id)).toBeLessThan(22); | |
| 146 | }); | |
| 147 | ||
| 148 | const KEY = "a-key-only-the-forge-holds"; | |
| 149 | test("the route: guest and cross-origin are refused, the owner gets the notice", async () => { | |
| 150 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-everywhere-http-")), identityKey: KEY }); | |
| 151 | try { | |
| 152 | const base = `http://${server.hostname}:${server.port}`; | |
| 153 | const identity = `route-u|route|user|${Date.now() + 600_000}`; | |
| 154 | const mine = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; | |
| 155 | const form = (extra: Record<string, string> = {}) => ({ method: "POST", redirect: "manual" as const, body: new URLSearchParams({ phrase: "Family-friendly" }), headers: extra }); | |
| 156 | const home = await (await fetch(base + "/", { headers: mine })).text(); | |
| 157 | const made = await fetch(base + "/listings", { method: "POST", redirect: "manual", headers: mine, body: Object.entries({ ...SAMPLE_HOUSE, features: SAMPLE_HOUSE.features.join("\n"), open_houses: undefined }).reduce((f, [k, v]) => (v === undefined ? f : (f.set(k, String(v)), f)), new FormData()) }); | |
| 158 | const id = /\/listings\/(\d+)/.exec(made.headers.get("location") ?? "")?.[1] ?? /\/listings\/(\d+)/.exec(home)?.[1]; | |
| 159 | expect(id).toBeDefined(); | |
| 160 | const path = `${base}/listings/${id}/fair-housing/suggestion`; | |
| 161 | const cross = await fetch(path, form({ ...mine, "sec-fetch-site": "cross-site" })); | |
| 162 | expect(cross.status).toBe(403); | |
| 163 | const guest = await fetch(path, form()); | |
| 164 | expect(guest.status).not.toBe(200); | |
| 165 | const page = await (await fetch(`${base}/listings/${id}`, { headers: mine })).text(); | |
| 166 | expect(page).toContain("3 phrases to review"); | |
| 167 | const done = await fetch(path, form(mine)); | |
| 168 | expect(done.status).toBe(303); | |
| 169 | const notice = (done.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 170 | const after = await (await fetch(`${base}/listings/${id}`, { headers: { ...mine, cookie: notice } })).text(); | |
| 171 | expect(after).toContain("2 phrases to review"); | |
| 172 | expect(after).toContain("each needs approval again"); | |
| 173 | } finally { | |
| 174 | server.stop(true); | |
| 175 | } | |
| 176 | }); |
src/fairhousing.ts +60 −2
| @@ -96,7 +96,17 @@ export const RULES: readonly Rule[] = [ | ||
| 96 | 96 | }, |
| 97 | 97 | { |
| 98 | 98 | pattern: rx( |
| 99 | "(?:perfect|ideal|great|made) for (?:a |the |your )?(?:young |growing |large |small |new )?(?:famil(?:y|ies)|kids|children|couples?|singles|newlyweds|retirees|empty[- ]nesters|seniors)", | |
| 99 | "(?:perfect|ideal|great|made) for (?:a |the |your )?(?:young |growing |large |small |new )?couples?(?: (?:just )?starting out)?", | |
| 100 | ), | |
| 101 | touches: "familial status", | |
| 102 | level: "risk", | |
| 103 | why: "describes the buyer, not the home, by household", | |
| 104 | say: "an easy, flexible layout", | |
| 105 | dropIn: "an easy, flexible layout", | |
| 106 | }, | |
| 107 | { | |
| 108 | pattern: rx( | |
| 109 | "(?:perfect|ideal|great|made) for (?:a |the |your )?(?:young |growing |large |small |new )?(?:famil(?:y|ies)|kids|children|singles|newlyweds|retirees|empty[- ]nesters|seniors)", | |
| 100 | 110 | ), |
| 101 | 111 | touches: "familial status", |
| 102 | 112 | level: "risk", |
| @@ -164,7 +174,8 @@ export const RULES: readonly Rule[] = [ | ||
| 164 | 174 | touches: "religion", |
| 165 | 175 | level: "risk", |
| 166 | 176 | why: "uses a place of worship to signal who belongs", |
| 167 | say: "half a mile to downtown", | |
| 177 | say: "near shops, parks and transit", | |
| 178 | dropIn: "near shops, parks and transit", | |
| 168 | 179 | }, |
| 169 | 180 | { |
| 170 | 181 | pattern: rx( |
| @@ -324,3 +335,50 @@ export function check(text: string, rules: readonly Rule[] = RULES): Check { | ||
| 324 | 335 | findings.push(f); |
| 325 | 336 | return { clear: findings.length === 0, findings }; |
| 326 | 337 | } |
| 338 | ||
| 339 | /** One flagged phrase across a listing's assets: what it says, what replaces it, and where it appears. */ | |
| 340 | export interface Phrase { | |
| 341 | /** The words as first seen. Phrases are told apart without regard to case. */ | |
| 342 | readonly text: string; | |
| 343 | readonly dropIn: string | null; | |
| 344 | /** How many assets carry it. */ | |
| 345 | readonly assets: number; | |
| 346 | /** How many times it appears in all. */ | |
| 347 | readonly findings: number; | |
| 348 | } | |
| 349 | ||
| 350 | /** The words as a phrase is keyed: the same words in any case are one phrase. */ | |
| 351 | export const phraseKey = (text: string): string => text.toLowerCase(); | |
| 352 | ||
| 353 | /** The flagged phrases across `checks` (one per asset), each once, in the order first met. */ | |
| 354 | export function groupByPhrase(checks: readonly Check[]): Phrase[] { | |
| 355 | const by = new Map<string, { text: string; dropIn: string | null; assets: number; findings: number }>(); | |
| 356 | for (const c of checks) { | |
| 357 | const here = new Set<string>(); | |
| 358 | for (const f of c.findings) { | |
| 359 | const key = phraseKey(f.text); | |
| 360 | const p = by.get(key) ?? { text: f.text, dropIn: f.dropIn, assets: 0, findings: 0 }; | |
| 361 | p.findings++; | |
| 362 | if (!here.has(key)) p.assets++; | |
| 363 | here.add(key); | |
| 364 | by.set(key, p); | |
| 365 | } | |
| 366 | } | |
| 367 | return [...by.values()]; | |
| 368 | } | |
| 369 | ||
| 370 | /** | |
| 371 | * `text` with every finding of `phrase` that has a drop-in replaced by it. | |
| 372 | * Replaced from the end so earlier offsets stay true; a replacement for words | |
| 373 | * that began with a capital keeps its capital. | |
| 374 | */ | |
| 375 | export function replacePhrase(text: string, phrase: string, c: Check = check(text)): { text: string; count: number } { | |
| 376 | const key = phraseKey(phrase); | |
| 377 | const hits = c.findings.filter((f) => phraseKey(f.text) === key && f.dropIn !== null); | |
| 378 | let next = text; | |
| 379 | for (const f of [...hits].reverse()) { | |
| 380 | const say = /^\p{Lu}/u.test(f.text) ? f.dropIn![0]!.toUpperCase() + f.dropIn!.slice(1) : f.dropIn!; | |
| 381 | next = next.slice(0, f.start) + say + next.slice(f.end); | |
| 382 | } | |
| 383 | return { text: next, count: hits.length }; | |
| 384 | } |
src/mcp.ts +15
| @@ -17,6 +17,7 @@ import { | ||
| 17 | 17 | deleteNote, |
| 18 | 18 | editAsset, |
| 19 | 19 | useSuggestion, |
| 20 | useSuggestionEverywhere, | |
| 20 | 21 | getListing, |
| 21 | 22 | launchKit, |
| 22 | 23 | launchListing, |
| @@ -225,6 +226,20 @@ export const tools: readonly Tool[] = [ | ||
| 225 | 226 | text: args.text, |
| 226 | 227 | }), |
| 227 | 228 | }, |
| 229 | { | |
| 230 | name: "use_suggestion_everywhere", | |
| 231 | description: | |
| 232 | "Take one flagged phrase's suggested wording in every asset of a listing at once (get_listing shows each finding's text and dropIn): each place the phrase is flagged and has a drop-in is replaced, the old words kept as a version, the check run again and approval cleared; one audit entry per changed asset. Nothing is approved by it. Refused as a conflict where the phrase is nowhere. Only the listing's agent or an admin can. Returns the kinds changed.", | |
| 233 | inputSchema: { | |
| 234 | type: "object", | |
| 235 | properties: { | |
| 236 | id: { type: "integer" }, | |
| 237 | phrase: { type: "string", maxLength: 200, description: "The flagged words as a finding showed them; case does not matter." }, | |
| 238 | }, | |
| 239 | required: ["id", "phrase"], | |
| 240 | }, | |
| 241 | run: (db, who, args) => useSuggestionEverywhere(db, who, { id: Number(args.id), phrase: args.phrase }), | |
| 242 | }, | |
| 228 | 243 | { |
| 229 | 244 | name: "list_versions", |
| 230 | 245 | description: |
src/pages/listing.ts +36 −1
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import type { LoggedShowing, Version } from "../actions.ts"; |
| 8 | import type { Check, Finding } from "../fairhousing.ts"; | |
| 8 | import { groupByPhrase, type Check, type Finding } from "../fairhousing.ts"; | |
| 9 | 9 | import { classHref } from "./fairhousing.ts"; |
| 10 | 10 | import { |
| 11 | 11 | factItems, |
| @@ -53,6 +53,40 @@ export function annotate(text: string, check: Check): string { | ||
| 53 | 53 | export const suggestionHref = (id: number, kind: string): string => |
| 54 | 54 | `/listings/${id}/assets/${kind}/suggestion`; |
| 55 | 55 | |
| 56 | /** Where a phrase's suggested wording is taken in every asset at once. */ | |
| 57 | export const everywhereHref = (id: number): string => `/listings/${id}/fair-housing/suggestion`; | |
| 58 | ||
| 59 | /** What taking a suggestion everywhere says it did, on the page and in the terminal. */ | |
| 60 | export const changedLine = (n: number): string => | |
| 61 | `Changed ${n} ${n === 1 ? "asset" : "assets"}; ${n === 1 ? "it needs" : "each needs"} approval again.`; | |
| 62 | ||
| 63 | /** | |
| 64 | * The listing's Fair Housing summary: each flagged phrase once, in how many | |
| 65 | * assets it appears and what to say instead. Where the phrase has a drop-in | |
| 66 | * and the reader may edit, one button takes it in every asset; the assets it | |
| 67 | * changes then wait for approval again. | |
| 68 | */ | |
| 69 | export function phraseSummary(l: Launched, editable: boolean): string { | |
| 70 | const phrases = groupByPhrase(l.assets.map((a) => a.check)); | |
| 71 | if (phrases.length === 0) return ""; | |
| 72 | return `<section class="card stack" id="fair-housing" aria-labelledby="fh-sum-h"> | |
| 73 | <h3 id="fh-sum-h">Fair Housing: ${phrases.length} ${phrases.length === 1 ? "phrase" : "phrases"} to review</h3> | |
| 74 | <ul class="fh-list" aria-label="Flagged phrases across all assets">${phrases | |
| 75 | .map( | |
| 76 | (p) => | |
| 77 | `<li><b>“${escape(p.text)}”</b> in ${p.assets} ${p.assets === 1 ? "asset" : "assets"}${p.dropIn === null ? ". Rewrite it by hand: see each asset." : `: say “${escape(p.dropIn)}”`}${ | |
| 78 | p.dropIn === null || !editable | |
| 79 | ? "" | |
| 80 | : ` <form class="fh-fix" method="post" action="${everywhereHref(l.id)}"> | |
| 81 | <input type="hidden" name="phrase" value="${escape(p.text)}"> | |
| 82 | <button class="button button-quiet" aria-label="Use the suggestion everywhere for “${escape(p.text)}”">Use the suggestion everywhere</button> | |
| 83 | </form>` | |
| 84 | }</li>`, | |
| 85 | ) | |
| 86 | .join("")}</ul> | |
| 87 | </section>`; | |
| 88 | } | |
| 89 | ||
| 56 | 90 | /** One tap on a finding's rewrite: the place and the words as seen, so a stale page cannot replace other words. */ |
| 57 | 91 | const useButton = (f: Finding, to: string): string => |
| 58 | 92 | f.dropIn === null |
| @@ -304,6 +338,7 @@ export function listingPage(input: { | ||
| 304 | 338 | </header> |
| 305 | 339 | ${editable ? "" : `<p class="card read-only">You are reading ${escape(l.author_name === "Sample listing" ? "the sample listing" : `${l.author_name}'s listing`)}. <a href="/?from=${l.id}">Start a new one from it</a> to make the words your own.</p>`} |
| 306 | 340 | ${propertyLink(l, origin)} |
| 341 | ${phraseSummary(l, editable)} | |
| 307 | 342 | ${l.photos.length === 0 ? "" : `<ul class="strip">${l.photos.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="${escape(altOf(l, p, `Photo ${i + 1} of ${l.address}`))}" width="240" height="160" loading="${i < 3 ? "eager" : "lazy"}"></li>`).join("")}</ul>`} |
| 308 | 343 | <div class="tabs" data-tabs> |
| 309 | 344 | <nav class="tablist" role="tablist" aria-label="Assets">${TABS.map( |
src/routes.ts +16 −1
| @@ -46,6 +46,7 @@ import { | ||
| 46 | 46 | updatePhotos, |
| 47 | 47 | updateNote, |
| 48 | 48 | useSuggestion, |
| 49 | useSuggestionEverywhere, | |
| 49 | 50 | type Failure, |
| 50 | 51 | type Result, |
| 51 | 52 | type Upload, |
| @@ -92,7 +93,7 @@ import { dayOf } from "./showings.ts"; | ||
| 92 | 93 | import { postcardPage } from "./pages/postcard.ts"; |
| 93 | 94 | import { signInPage } from "./pages/signin.ts"; |
| 94 | 95 | import { cardPage } from "./pages/card.ts"; |
| 95 | import { approval, asideOf, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts"; | |
| 96 | import { approval, asideOf, changedLine, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts"; | |
| 96 | 97 | import { notesPage } from "./pages/notes.ts"; |
| 97 | 98 | import { pathOf, PHOTOS_MAX, PHOTOS_TOTAL, typeOf } from "./photos.ts"; |
| 98 | 99 | import { tokensCss } from "./tokens.ts"; |
| @@ -897,6 +898,20 @@ export const routes: readonly Route[] = [ | ||
| 897 | 898 | return back(done.ok ? "Saved and checked." : done.message, to); |
| 898 | 899 | }, |
| 899 | 900 | }, |
| 901 | { | |
This route has no terminal or MCP counterpart; add the tty key (and README line) so both doors can do it. |
||
| 902 | method: "POST", | |
| 903 | path: "/listings/:id/fair-housing/suggestion", | |
| 904 | handle: async (c) => { | |
| 905 | const form = await readForm(c.request); | |
| 906 | if (form === undefined) return text("too large", 413); | |
| 907 | const id = Number(c.params.id); | |
| 908 | const done = useSuggestionEverywhere(c.db, c.who, { id, phrase: form.get("phrase") }); | |
| 909 | return back( | |
| 910 | done.ok ? changedLine(done.value.changed.length) : done.message, | |
| 911 | `/listings/${id}#fair-housing`, | |
| 912 | ); | |
| 913 | }, | |
| 914 | }, | |
| 900 | 915 | { |
| 901 | 916 | method: "POST", |
| 902 | 917 | path: "/listings/:id/assets/:kind/approve", |
src/server.test.ts +1
| @@ -152,6 +152,7 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 152 | 152 | "launch_listing", |
| 153 | 153 | "edit_asset", |
| 154 | 154 | "use_suggestion", |
| 155 | "use_suggestion_everywhere", | |
| 155 | 156 | "list_versions", |
| 156 | 157 | "restore_asset", |
| 157 | 158 | "approve_asset", |
src/tty.ts +13 −3
| @@ -55,6 +55,7 @@ import { | ||
| 55 | 55 | setStatus, |
| 56 | 56 | updateFacts, |
| 57 | 57 | useSuggestion, |
| 58 | useSuggestionEverywhere, | |
| 58 | 59 | } from "./actions.ts"; |
| 59 | 60 | import { publicOrigin, ssh, title, where } from "./env.ts"; |
| 60 | 61 | import { eml, emlFilename, NEEDS_APPROVING } from "./eml.ts"; |
| @@ -63,6 +64,7 @@ import { reportText } from "./report.ts"; | ||
| 63 | 64 | import { scheduleText, weekText } from "./schedule.ts"; |
| 64 | 65 | import { now } from "./clock.ts"; |
| 65 | 66 | import { approvalLabel, propertyLine } from "./listing.ts"; |
| 67 | import { changedLine } from "./pages/listing.ts"; | |
| 66 | 68 | import { countLine, isMls, isText, overNote } from "./postlength.ts"; |
| 67 | 69 | import { emailParts } from "./pages/email.ts"; |
| 68 | 70 | import { factItems, money, openHouseLine, TONES, type Kind, type OpenHouse } from "./generate.ts"; |
| @@ -256,7 +258,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 256 | 258 | else if (versioning !== undefined) help = "1-5 put that version back · esc back to the assets · q quit"; |
| 257 | 259 | else help = |
| 258 | 260 | found?.ok === true && canEditListing(me, found.value) |
| 259 | ? "j/↓↑ asset · e edit · o open house · r price · s status · a approve/withdraw · u use suggested wording · v list earlier versions and put one back · t next tone · p preview email · P preview follow-up · O preview reminder · V visitors · k launch kit · R seller report · w launch week · esc back · q quit" | |
| 261 | ? "j/↓↑ asset · e edit · o open house · r price · s status · a approve/withdraw · u use suggested wording · U use it in every asset · v list earlier versions and put one back · t next tone · p preview email · P preview follow-up · O preview reminder · V visitors · k launch kit · R seller report · w launch week · esc back · q quit" | |
| 260 | 262 | : "j/↓↑ asset · p preview email · P preview follow-up · O preview reminder · k launch kit · R seller report · w launch week · esc back · q quit"; |
| 261 | 263 | } else if (view === "listings") { |
| 262 | 264 | const rows = listings(); |
| @@ -476,10 +478,18 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 476 | 478 | if (previewing || kitting || reporting || planning) return undefined; |
| 477 | 479 | if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1)); |
| 478 | 480 | if (up) cursor = Math.max(cursor - 1, 0); |
| 479 | if (found?.ok === true && (key === "e" || key === "t" || key === "a" || key === "u" || key === "o" || key === "s" || key === "r" || key === "v")) { | |
| 481 | if (found?.ok === true && (key === "e" || key === "t" || key === "a" || key === "u" || key === "U" || key === "o" || key === "s" || key === "r" || key === "v")) { | |
| 480 | 482 | if (!canEditListing(me, found.value)) |
| 481 | 483 | said = me.role === "anon" ? "Sign in to edit this listing." : "Only the listing's agent or an admin can edit it."; |
| 482 | else if (key === "u") { | |
| 484 | else if (key === "U") { | |
| 485 | // The same first suggestible finding, but its phrase is taken in every asset of the listing. | |
| 486 | const finding = assets[cursor]?.check.findings.find((f) => f.dropIn !== null); | |
| 487 | if (finding === undefined) said = "Nothing here has a suggested wording to use."; | |
| 488 | else { | |
| 489 | const done = useSuggestionEverywhere(db, me, { id: found.value.id, phrase: finding.text }); | |
| 490 | said = done.ok ? green(changedLine(done.value.changed.length)) : done.message; | |
| 491 | } | |
| 492 | } else if (key === "u") { | |
| 483 | 493 | // The first finding on this asset that has a drop-in rewrite, taken as the page's button takes it. |
| 484 | 494 | const asset = assets[cursor]; |
| 485 | 495 | const finding = asset?.check.findings.find((f) => f.dropIn !== null); |
Write the audit entries inside the db.transaction so changes and their trail commit together.