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

Download a listing's launch kit: every asset in one file, marked approved or draft, to hand to an assistant #49

Merged opened this

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

Merged

mason/listing-launch:mason/38-download-a-listing-s-launch-kit-every-as → main 1 commit fork

  • Merged. mason/38-download-a-listing-s-launch-kit-every-as was merged into main as 3c74f32.

Review

Sign in to review this change.
13 files changed +295 −10
README.md +10
@@ -57,6 +57,11 @@ to make a listing of your own.
5757 9. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with
5858 the photo, price, facts, description and your name. Print it or save it
5959 as a PDF.
60 10. **Download the launch kit.** Press **Download launch kit** at the top of
61 the listing: one text file named from the address, with the price and
62 facts, then every asset under its name, each marked "Approved by … on …"
63 or "DRAFT — not approved", with its Fair Housing findings beneath it. Hand
64 it to an assistant or a coordinator.
6065
6166 `src/walkthrough.test.ts` walks these steps on every `bun test`, so this
6267 list cannot drift from the app.
@@ -130,6 +135,11 @@ list cannot drift from the app.
130135 JavaScript the page still renders. "Copy subject" copies the subject line
131136 apart, for the mail tool's subject box; the message copied is the card only.
132137 In the terminal, `p` on an open listing previews the same message as text. Photos are this app's own, never remote.
138 **Download launch kit** (`GET /listings/:id/kit.txt`, built in `src/kit.ts`) sends
139 the whole listing as one attachment named like `412-alder-lane-portland-or-97214-launch-kit.txt`.
140 Whoever may read the listing may download it; no permission changed. The `launch_kit`
141 MCP tool returns the same text, and `k` on an open listing in the terminal shows it
142 (the arrow keys move between assets there).
133143 5. **Print the flyer.** "Print flyer" opens a letter-size page — hero photo,
134144 price, a strip of the facts, the description, your photo, name,
135145 brokerage and license number, and the Equal Housing Opportunity line.
contract/openapi.json +38
@@ -6,6 +6,44 @@
66 "description": "One address in, a listing launched: the MLS description, three social posts, a flyer and an email blast, each checked for Fair Housing language and dressed in the agent's own brand."
77 },
88 "paths": {
9 "/listings/{id}/kit.txt": {
10 "get": {
11 "summary": "The listing's launch kit: every asset in one plain-text file, each marked approved or draft, with its open Fair Housing findings. Sent as an attachment.",
12 "operationId": "getListingsIdKitTxt",
13 "parameters": [
14 {
15 "name": "id",
16 "in": "path",
17 "required": true,
18 "schema": {
19 "type": "integer"
20 }
21 }
22 ],
23 "responses": {
24 "200": {
25 "description": "The listing's launch kit: every asset in one plain-text file, each marked approved or draft, with its open Fair Housing findings. Sent as an attachment.",
26 "content": {
27 "text/plain": {
28 "schema": {
29 "type": "string"
30 }
31 }
32 }
33 },
34 "default": {
35 "description": "{ error, message }: why not",
36 "content": {
37 "application/json": {
38 "schema": {
39 "$ref": "#/components/schemas/Error"
40 }
41 }
42 }
43 }
44 }
45 }
46 },
947 "/api/notes": {
1048 "get": {
1149 "summary": "The notes, newest first.",
src/actions.ts +7
@@ -29,6 +29,7 @@ import {
2929 type ListingInput,
3030 type ListingRow,
3131 } from "./listing.ts";
32 import { kit } from "./kit.ts";
3233 import { emit } from "./live.ts";
3334 import { meantForX, overXNote, postLength } from "./postlength.ts";
3435 import {
@@ -499,6 +500,12 @@ export const getListing = (
499500 no: "You may not see this listing.",
500501 });
501502
503 /** The listing's launch kit as text: whoever may read the listing may read it. */
504 export const launchKit = (db: Database, who: Who, input: { id: number }): Result<string> => {
505 const found = getListing(db, who, input);
506 return found.ok ? ok(kit(found.value)) : found;
507 };
508
502509 /** Whether `who` may change `listing`: the table's answer, for the page to draw from. */
503510 export const canEditListing = (who: Who, listing: Listing): boolean =>
504511 permit(who, "listing", "update", { ...listing }) === "yes";
src/hostile.test.ts +10
@@ -189,3 +189,13 @@ test("the terminal draws hostile words as text: no control byte but its own styl
189189 tty.press("\t");
190190 expect(bare(tty.screen())).not.toMatch(forbidden); // the notes
191191 });
192
193 test("the launch kit carries the hostile words as plain text, with no control characters", async () => {
194 const r = await call(`/listings/${id}/kit.txt`);
195 expect(r.status).toBe(200);
196 floor(r);
197 expect(r.headers.get("content-disposition")).toMatch(/^attachment; filename="[a-z0-9-]+\.txt"$/);
198 const body = await r.text();
199 expect(body).not.toMatch(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f‪-‮⁦-⁩]/);
200 expect(body).toContain("</textarea><script>alert(10)");
201 });
src/kit.test.ts added +108
@@ -0,0 +1,108 @@
1 import { afterAll, expect, test } from "bun:test";
2 import { mkdtempSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { approveAsset, editAsset, getListing, insertListing, launchKit } from "./actions.ts";
6 import { openDb } from "./db.ts";
7 import { LABELS } from "./generate.ts";
8 import { kit, kitFilename } from "./kit.ts";
9 import { rpc } from "./mcp.ts";
10 import { SAMPLE } from "./sample.ts";
11 import { serve } from "./server.ts";
12 import { session } from "./tty.ts";
13 import type { Who } from "./who.ts";
14
15 const me: Who = { role: "user", id: "u1", name: "Pat Agent" };
16 const plain = (s: string): string => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
17
18 const fresh = () => {
19 const db = openDb(mkdtempSync(join(tmpdir(), "app-kit-")));
20 const id = insertListing(db, me, SAMPLE, [], null).id;
21 return { db, id };
22 };
23 const launched = (db: ReturnType<typeof fresh>["db"], id: number) => {
24 const found = getListing(db, me, { id });
25 if (!found.ok) throw new Error(found.message);
26 return found.value;
27 };
28
29 test("the kit names the address, price, facts and every asset once, all drafts at first", () => {
30 const { db, id } = fresh();
31 const l = launched(db, id);
32 const text = kit(l);
33 expect(text).toContain(l.address);
34 expect(text).toContain("$");
35 for (const a of l.assets) expect(text.split(`== ${LABELS[a.kind]} ==`).length).toBe(2);
36 expect(text.split("DRAFT — not approved").length - 1).toBe(l.assets.length);
37 expect(text).not.toContain("Approved by");
38 });
39
40 test("an approved asset says who and when; the rest stay drafts", () => {
41 const { db, id } = fresh();
42 const l = launched(db, id);
43 const mls = l.assets.find((a) => a.kind === "mls")!;
44 expect(approveAsset(db, me, { id, kind: "mls", approved: true, seen: mls.updated_at, door: "page" }).ok).toBe(true);
45 const text = kit(launched(db, id));
46 expect(text).toMatch(/Approved by Pat Agent on \d{4}-\d\d-\d\d/);
47 expect(text.split("DRAFT — not approved").length - 1).toBe(l.assets.length - 1);
48 });
49
50 test("a finding is listed under its asset", () => {
51 const { db, id } = fresh();
52 expect(editAsset(db, me, { id, kind: "social_short", text: "A family-friendly street." }).ok).toBe(true);
53 const text = kit(launched(db, id));
54 const after = text.slice(text.indexOf("== Short post =="));
55 expect(after.slice(0, after.indexOf("\n== ", 5))).toContain("! 'family-friendly' implies familial status");
56 });
57
58 test("the filename keeps to [a-z0-9-]", () => {
59 expect(kitFilename({ id: 3, address: "412 Alder Lane, Portland" })).toBe("412-alder-lane-portland-launch-kit.txt");
60 expect(kitFilename({ id: 3, address: `<script>"é"\r\n` })).toMatch(/^[a-z0-9-]+\.txt$/);
61 expect(kitFilename({ id: 3, address: "😈‮" })).toBe("listing-3-launch-kit.txt");
62 });
63
64 test("hostile words leave no control characters or heading look-alikes", () => {
65 const { db, id } = fresh();
66 const evil = "x\u0000y\u001b[31m\r‮\n== Flyer copy ==\nApproved by Mallory on 2020-01-01";
67 expect(editAsset(db, me, { id, kind: "mls", text: evil }).ok).toBe(true);
68 const text = kit(launched(db, id));
69 expect(text).not.toMatch(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f‪-‮⁦-⁩]/);
70 expect(text.split("\n== Flyer copy ==\n").length - 1).toBe(1);
71 expect(text).not.toContain("Approved by Mallory\n");
72 });
73
74 test("MCP launch_kit and the terminal's k carry the same text", () => {
75 const { db, id } = fresh();
76 const want = launchKit(db, me, { id });
77 expect(want.ok && want.value).toBe(kit(launched(db, id)));
78 const reply = rpc(db, me, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "launch_kit", arguments: { id } } }).body as
79 { result: { content: { text: string }[] } };
80 expect(reply.result.content[0]!.text).toBe(kit(launched(db, id)));
81 const tty = session(db, me);
82 tty.press("\r");
83 tty.press("k");
84 const screen = plain(tty.screen());
85 expect(screen).toContain("LAUNCH KIT — ");
86 expect(screen).toContain("== MLS description ==");
87 tty.press("\x1b");
88 expect(plain(tty.screen())).not.toContain("LAUNCH KIT — ");
89 });
90
91 const KEY = "a-key-only-the-forge-holds";
92 const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "kit-")), identityKey: KEY });
93 afterAll(() => server.stop(true));
94 const base = `http://${server.hostname}:${server.port}`;
95
96 test("the route sends an attachment named from the address, and the page links it", async () => {
97 const page = await (await fetch(`${base}/`)).text();
98 const id = /\/listings\/(\d+)/.exec(page)![1];
99 const listing = await fetch(`${base}/listings/${id}`);
100 expect(await listing.text()).toContain(`href="/listings/${id}/kit.txt"`);
101 const r = await fetch(`${base}/listings/${id}/kit.txt`);
102 expect(r.status).toBe(200);
103 expect(r.headers.get("content-type")).toContain("text/plain");
104 expect(r.headers.get("content-disposition")).toMatch(/^attachment; filename="[a-z0-9-]+-launch-kit\.txt"$/);
105 expect(r.headers.get("x-content-type-options")).toBe("nosniff");
106 expect(await r.text()).toContain("LAUNCH KIT — ");
107 expect((await fetch(`${base}/listings/9999/kit.txt`)).status).toBe(404);
108 });
src/kit.ts added +70
@@ -0,0 +1,70 @@
1 /**
2 * The launch kit: one listing in, one plain-text file out — the address,
3 * price and facts, then every asset under its name, each marked approved or
4 * draft with its open Fair Housing findings under it. Pure: no database, no
5 * permission; whoever may read the listing may read this.
6 */
7
8 import { factItems, money, openHouseLine } from "./generate.ts";
9 import type { Asset, Launched } from "./listing.ts";
10
11 // Control characters (keeping newline where a block may have lines) and the bidirectional overrides.
12 const CONTROLS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f‎‏‪-‮⁦-⁩]/g;
13
14 /** One line of plain text: no control characters, runs of space made one. */
15 const oneLine = (v: string): string => v.replace(CONTROLS, " ").replace(/\s+/g, " ").trim();
16
17 /** Lines of plain text, indented so no asset can fake a heading of the file. */
18 const block = (v: string): string =>
19 v
20 .replace(/\r\n?/g, "\n")
21 .replace(/\t/g, " ")
22 .replace(CONTROLS, " ")
23 .trim()
24 .split("\n")
25 .map((l) => ` ${l.trimEnd()}`.trimEnd())
26 .join("\n");
27
28 const stamp = (a: Asset): string =>
29 a.approval === null
30 ? "DRAFT — not approved"
31 : `Approved by ${oneLine(a.approval.by_name)} on ${oneLine(a.approval.at).slice(0, 10)}`;
32
33 const asset = (a: Asset): string =>
34 [
35 `== ${a.label} ==`,
36 stamp(a),
37 "",
38 block(a.text),
39 ...(a.check.findings.length === 0
40 ? []
41 : ["", "Fair Housing findings to review:", ...a.check.findings.map((f) => ` ! ${oneLine(f.line)}`)]),
42 ].join("\n");
43
44 /** The kit's text. */
45 export function kit(l: Launched): string {
46 const open = openHouseLine(l);
47 const signed = l.assets.filter((a) => a.approval !== null).length;
48 return `${[
49 `LAUNCH KIT — ${oneLine(l.address)}`,
50 `${money(l.price)}`,
51 factItems(l).map((i) => `${oneLine(String(i.value))} ${oneLine(i.label)}`).join(" · "),
52 ...(open === null ? [] : [oneLine(open)]),
53 `Agent: ${oneLine(l.agent_name)} · ${oneLine(l.brokerage)} · License ${oneLine(l.license)} · ${oneLine(l.agent_phone)} · ${oneLine(l.agent_email)}`,
54 `${signed} of ${l.assets.length} assets approved. Send only what is marked approved.`,
55 "",
56 ...l.assets.flatMap((a) => [asset(a), ""]),
57 ].join("\n").trimEnd()}\n`;
58 }
59
60 /** "412-alder-lane-launch-kit.txt": the address in `[a-z0-9-]`, never empty. */
61 export const kitFilename = (l: Pick<Launched, "address" | "id">): string => {
62 const slug = l.address
63 .normalize("NFKD")
64 .toLowerCase()
65 .replace(/[^a-z0-9]+/g, "-")
66 .replace(/^-+|-+$/g, "")
67 .slice(0, 60)
68 .replace(/-+$/, "");
69 return `${slug === "" ? `listing-${l.id}` : slug}-launch-kit.txt`;
70 };
src/mcp.ts +13 −1
@@ -16,6 +16,7 @@ import {
1616 editAsset,
1717 useSuggestion,
1818 getListing,
19 launchKit,
1920 launchListing,
2021 listListings,
2122 listNotes,
@@ -113,6 +114,17 @@ export const tools: readonly Tool[] = [
113114 },
114115 run: (db, who, args) => getListing(db, who, { id: Number(args.id) }),
115116 },
117 {
118 name: "launch_kit",
119 description:
120 "A listing's launch kit as plain text, to hand to an assistant: the address, price and facts, then every asset under its name, each marked \"Approved by … on …\" or \"DRAFT — not approved\", with its open Fair Housing findings under it. The same text as the page's Download launch kit.",
121 inputSchema: {
122 type: "object",
123 properties: { id: { type: "integer" } },
124 required: ["id"],
125 },
126 run: (db, who, args) => launchKit(db, who, { id: Number(args.id) }),
127 },
116128 {
117129 name: "launch_listing",
118130 description:
@@ -351,7 +363,7 @@ export function rpc(
351363 const result = tool.run(db, who, fields(params.arguments));
352364 return reply(
353365 result.ok
354 ? { content: [{ type: "text", text: JSON.stringify(result.value) }] }
366 ? { content: [{ type: "text", text: typeof result.value === "string" ? result.value : JSON.stringify(result.value) }] }
355367 : {
356368 content: [
357369 { type: "text", text: `${result.error}: ${result.message}` },
src/openapi.ts +2 −2
@@ -51,7 +51,7 @@ export function openapi(): Record<string, unknown> {
5151 const paths: Record<string, Record<string, unknown>> = {};
5252 for (const route of routes) {
5353 if (route.api === undefined) continue;
54 const { summary, body, answer, status, query } = route.api;
54 const { summary, body, answer, status, query, mime } = route.api;
5555 const path = route.path.replace(/:(\w+)/g, "{$1}");
5656 const params = [...route.path.matchAll(/:(\w+)/g)].map(([, name]) => ({
5757 name,
@@ -86,7 +86,7 @@ export function openapi(): Record<string, unknown> {
8686 responses: {
8787 [String(status ?? 200)]: {
8888 description: summary,
89 content: content(answer),
89 content: mime === undefined ? content(answer) : { [mime]: { schema: answer } },
9090 },
9191 default: {
9292 description: "{ error, message }: why not",
src/pages/listing.ts +1
@@ -207,6 +207,7 @@ export function listingPage(input: {
207207 ${editable ? statusForm(l) : ""}
208208 ${editable ? `<a class="button" href="/listings/${l.id}/facts">Edit facts</a>` : ""}
209209 <a class="button" href="/listings/${l.id}/flyer">Print flyer</a>
210 <a class="button button-quiet" href="/listings/${l.id}/kit.txt" download>Download launch kit</a>
210211 <a class="button button-quiet" href="/?from=${l.id}">Start a new one from this</a>
211212 </div>
212213 </header>
src/routes.ts +19
@@ -40,6 +40,7 @@ import {
4040 type Result,
4141 type Upload,
4242 } from "./actions.ts";
43 import { kit, kitFilename } from "./kit.ts";
4344 import { KINDS, STATUSES, TONES } from "./generate.ts";
4445 import { ASSET_MAX, FEATURES_MAX, FEATURES_MIN, PALETTES } from "./listing.ts";
4546 import { commit, env, forgeUrl, startedAt, version } from "./env.ts";
@@ -89,6 +90,8 @@ export interface Api {
8990 /** What it answers when it succeeds, and with which status (200). */
9091 readonly answer: Schema;
9192 readonly status?: number;
93 /** The answer's media type when it is not JSON. */
94 readonly mime?: string;
9295 /** The query parameters it reads. */
9396 readonly query?: Readonly<Record<string, Schema>>;
9497 }
@@ -527,6 +530,22 @@ export const routes: readonly Route[] = [
527530 flyerPage({ who: c.who, listing }),
528531 ),
529532 },
533 {
534 method: "GET",
535 path: "/listings/:id/kit.txt",
536 handle: (c) => {
537 const found = getListing(c.db, c.who, { id: Number(c.params.id) });
538 if (!found.ok) return text(found.message, STATUS[found.error]);
539 return new Response(kit(found.value), {
540 headers: {
541 "content-type": "text/plain; charset=utf-8",
542 "content-disposition": `attachment; filename="${kitFilename(found.value)}"`,
543 ...SECURITY,
544 },
545 });
546 },
547 api: { summary: "The listing's launch kit: every asset in one plain-text file, each marked approved or draft, with its open Fair Housing findings. Sent as an attachment.", answer: { type: "string" }, mime: "text/plain" },
548 },
530549 {
531550 method: "GET",
532551 path: "/listings/:id/email",
src/server.test.ts +1
@@ -147,6 +147,7 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
147147 "delete_note",
148148 "list_listings",
149149 "get_listing",
150 "launch_kit",
150151 "launch_listing",
151152 "edit_asset",
152153 "use_suggestion",
src/tty.test.ts +1 −1
@@ -216,7 +216,7 @@ test("the terminal shows a social post's length, and a short post over X needs a
216216 // Another key between the two presses starts the question again.
217217 tty.press("a"); // withdraw
218218 tty.press("a");
219 tty.press("k");
219 tty.press("\x1b[A"); // up arrow: k now shows the launch kit
220220 tty.press("j");
221221 tty.press("a");
222222 expect(plain(tty.screen())).toContain("Press a again");
src/tty.ts +15 −6
@@ -7,7 +7,8 @@
77 * j/k move between the assets, e edits the one under the cursor (the
88 * words are checked again as they are saved), t writes them all again in
99 * the next tone, a approves the asset or withdraws it, esc goes back; x
10 * removes a listing; p previews the email as the client will read it.
10 * removes a listing; p previews the email as the client will read it; k shows
11 * the launch kit, the file the page offers to download.
1112 * - notes: a adds, d removes.
1213 *
1314 * j/k move, q leaves. Launching, with its photos, is the page's. Plain ANSI,
@@ -34,6 +35,7 @@ import {
3435 useSuggestion,
3536 } from "./actions.ts";
3637 import { ssh, title, where } from "./env.ts";
38 import { kit } from "./kit.ts";
3739 import { approvalLabel } from "./listing.ts";
3840 import { isPost, lengthLine, meantForX, overXNote } from "./postlength.ts";
3941 import { emailParts } from "./pages/email.ts";
@@ -87,6 +89,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
8789 let reading: number | undefined;
8890 /** The email blast, laid out as the page's preview is, instead of the assets. */
8991 let previewing = false;
92 /** The launch kit, as the file would read. */
93 let kitting = false;
9094 let cursor = 0;
9195 /** What the one-line input is for: a new note, or an asset's new words. */
9296 let typing: { for: "note" } | { for: "asset"; kind: Kind } | { for: "open" } | { for: "status" } | { for: "price" } | undefined;
@@ -113,6 +117,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
113117 if (found === undefined) return [];
114118 if (!found.ok) return [found.message];
115119 const l = found.value;
120 if (kitting) return kit(l).split("\n");
116121 if (previewing) {
117122 const { subject, paragraphs } = emailParts(l.assets.find((a) => a.kind === "email")?.text ?? "", l.agent_name);
118123 const approved = l.assets.find((a) => a.kind === "email")?.approval != null;
@@ -151,11 +156,11 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
151156 if (reading !== undefined) {
152157 lines.push(...readLines());
153158 const found = open();
154 if (previewing) help = "esc back to the assets · q quit";
159 if (previewing || kitting) help = "esc back to the assets · q quit";
155160 else help =
156161 found?.ok === true && canEditListing(me, found.value)
157 ? "j/k asset · e edit · o open house · r price · s status · a approve/withdraw · u use suggested wording · t next tone · p preview email · esc back · q quit"
158 : "j/k asset · p preview email · esc back · q quit";
162 ? "j/↓↑ asset · e edit · o open house · r price · s status · a approve/withdraw · u use suggested wording · t next tone · p preview email · k launch kit · esc back · q quit"
163 : "j/↓↑ asset · p preview email · k launch kit · esc back · q quit";
159164 } else if (view === "listings") {
160165 const rows = listings();
161166 if (rows.length === 0) lines.push(dim("No listings yet. Launch one on the page."));
@@ -252,15 +257,19 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
252257 previewing = true;
253258 return undefined;
254259 }
260 if (key === "k" && !kitting && !previewing && found?.ok === true) {
261 kitting = true;
262 return undefined;
263 }
255264 if (key === "\x1b") {
256 if (previewing) previewing = false;
265 if (previewing || kitting) previewing = kitting = false;
257266 else {
258267 reading = undefined;
259268 cursor = 0;
260269 }
261270 return undefined;
262271 }
263 if (previewing) return undefined;
272 if (previewing || kitting) return undefined;
264273 if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1));
265274 if (up) cursor = Math.max(cursor - 1, 0);
266275 if (found?.ok === true && (key === "e" || key === "t" || key === "a" || key === "u" || key === "o" || key === "s" || key === "r")) {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs