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

Merged opened this

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

Merged

mason/listing-launch:mason/121-fix-a-flagged-fair-housing-phrase-in-eve → main 2 commits fork 3 threads · 3 unresolved

  • Merged. mason/121-fix-a-flagged-fair-housing-phrase-in-eve was merged into main as 803dad4.

Review

Sign in to review this change.
9 files changed +385 −9
README.md +12 −1
@@ -16,7 +16,13 @@ to make a listing of your own.
1616 2. **Read an asset and its check.** The MLS tab has the description in a box,
1717 and beside it the **Fair Housing** check: green when it is clear. Open the
1818 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.
2026 3. **Start a new one from the sample.** Back on Alder Lane, press **Start a
2127 new one from this**. The form fills in with the sample's facts. Change
2228 the address to your own listing, then press **Launch**. The sample
@@ -285,6 +291,11 @@ list cannot drift from the app.
285291 with JavaScript; the `use_suggestion` MCP tool takes `{ id, kind, start,
286292 end, text }`, and `u` in the terminal uses the first suggestion on the
287293 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.
288299 Each asset also has an **Approve** button ("Approve with 2 flags" when
289300 the check still has findings): it records who signed off and when, the
290301 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";
1010 import { record, type AuditRow } from "./audit.ts";
1111 import { permit } from "./contract.ts";
1212 import { earlierAt, type Earlier } from "./duplicate.ts";
13 import { check, type Check } from "./fairhousing.ts";
13 import { check, replacePhrase, type Check } from "./fairhousing.ts";
1414 import { generate, isBackOnMarket, isOffMarket, KINDS, remindable, REMINDER_KINDS, STATUS_KINDS, TONES, withoutOpenHouse, type Kind, type Status } from "./generate.ts";
1515 import {
1616 agentOf,
@@ -907,6 +907,61 @@ function suggest(
907907 );
908908 }
909909
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);
outdated

Write the audit entries inside the db.transaction so changes and their trail commit together.

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
910965 /** One earlier version of an asset's words, as the page lists it. */
911966 export interface Version {
912967 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[] = [
9696 },
9797 {
9898 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)",
100110 ),
101111 touches: "familial status",
102112 level: "risk",
@@ -164,7 +174,8 @@ export const RULES: readonly Rule[] = [
164174 touches: "religion",
165175 level: "risk",
166176 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",
168179 },
169180 {
170181 pattern: rx(
@@ -324,3 +335,50 @@ export function check(text: string, rules: readonly Rule[] = RULES): Check {
324335 findings.push(f);
325336 return { clear: findings.length === 0, findings };
326337 }
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 {
1717 deleteNote,
1818 editAsset,
1919 useSuggestion,
20 useSuggestionEverywhere,
2021 getListing,
2122 launchKit,
2223 launchListing,
@@ -225,6 +226,20 @@ export const tools: readonly Tool[] = [
225226 text: args.text,
226227 }),
227228 },
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 },
228243 {
229244 name: "list_versions",
230245 description:
src/pages/listing.ts +36 −1
@@ -5,7 +5,7 @@
55 */
66
77 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";
99 import { classHref } from "./fairhousing.ts";
1010 import {
1111 factItems,
@@ -53,6 +53,40 @@ export function annotate(text: string, check: Check): string {
5353 export const suggestionHref = (id: number, kind: string): string =>
5454 `/listings/${id}/assets/${kind}/suggestion`;
5555
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
5690 /** One tap on a finding's rewrite: the place and the words as seen, so a stale page cannot replace other words. */
5791 const useButton = (f: Finding, to: string): string =>
5892 f.dropIn === null
@@ -304,6 +338,7 @@ export function listingPage(input: {
304338 </header>
305339 ${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>`}
306340 ${propertyLink(l, origin)}
341 ${phraseSummary(l, editable)}
307342 ${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>`}
308343 <div class="tabs" data-tabs>
309344 <nav class="tablist" role="tablist" aria-label="Assets">${TABS.map(
src/routes.ts +16 −1
@@ -46,6 +46,7 @@ import {
4646 updatePhotos,
4747 updateNote,
4848 useSuggestion,
49 useSuggestionEverywhere,
4950 type Failure,
5051 type Result,
5152 type Upload,
@@ -92,7 +93,7 @@ import { dayOf } from "./showings.ts";
9293 import { postcardPage } from "./pages/postcard.ts";
9394 import { signInPage } from "./pages/signin.ts";
9495 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";
9697 import { notesPage } from "./pages/notes.ts";
9798 import { pathOf, PHOTOS_MAX, PHOTOS_TOTAL, typeOf } from "./photos.ts";
9899 import { tokensCss } from "./tokens.ts";
@@ -897,6 +898,20 @@ export const routes: readonly Route[] = [
897898 return back(done.ok ? "Saved and checked." : done.message, to);
898899 },
899900 },
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 },
900915 {
901916 method: "POST",
902917 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 () =>
152152 "launch_listing",
153153 "edit_asset",
154154 "use_suggestion",
155 "use_suggestion_everywhere",
155156 "list_versions",
156157 "restore_asset",
157158 "approve_asset",
src/tty.ts +13 −3
@@ -55,6 +55,7 @@ import {
5555 setStatus,
5656 updateFacts,
5757 useSuggestion,
58 useSuggestionEverywhere,
5859 } from "./actions.ts";
5960 import { publicOrigin, ssh, title, where } from "./env.ts";
6061 import { eml, emlFilename, NEEDS_APPROVING } from "./eml.ts";
@@ -63,6 +64,7 @@ import { reportText } from "./report.ts";
6364 import { scheduleText, weekText } from "./schedule.ts";
6465 import { now } from "./clock.ts";
6566 import { approvalLabel, propertyLine } from "./listing.ts";
67 import { changedLine } from "./pages/listing.ts";
6668 import { countLine, isMls, isText, overNote } from "./postlength.ts";
6769 import { emailParts } from "./pages/email.ts";
6870 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 }
256258 else if (versioning !== undefined) help = "1-5 put that version back · esc back to the assets · q quit";
257259 else help =
258260 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"
260262 : "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";
261263 } else if (view === "listings") {
262264 const rows = listings();
@@ -476,10 +478,18 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
476478 if (previewing || kitting || reporting || planning) return undefined;
477479 if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1));
478480 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")) {
480482 if (!canEditListing(me, found.value))
481483 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") {
483493 // The first finding on this asset that has a drop-in rewrite, taken as the page's button takes it.
484494 const asset = assets[cursor];
485495 const finding = asset?.check.findings.find((f) => f.dropIn !== null);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs