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

Launching an address you already launched asks first, and links to the listing you have #74

Merged opened this

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

Merged

mason/listing-launch:mason/72-launching-an-address-you-already-launche → main 2 commits fork 2 threads · 2 unresolved

  • Merged. mason/72-launching-an-address-you-already-launche was merged into main as 63f9fc2.

Review

Sign in to review this change.
12 files changed +218 −14
README.md +7
@@ -135,6 +135,13 @@ list cannot drift from the app.
135135 colour) is asked once: the new-listing form starts filled in from your most
136136 recent listing (only ever your own), every field editable, and your headshot
137137 is reused unless you choose a new file (the form says which listing it is from).
138 Launching an address you already launched (same address, any case or
139 spacing; only your own listings are looked at) asks first: the form comes
140 back with everything you typed and a link to the listing you have, and
141 **launch another anyway** goes ahead (choose the photos again). The JSON API
142 and the MCP `launch_listing` tool answer 409 `duplicate_address` with the
143 existing `id` unless the request says `again: true`. The terminal has no
144 launch command, so it has nothing to ask.
138145 2. **Pick a tone** — *classic* (measured), *warm* (inviting) or *bold*
139146 (short and punchy) — and press **Launch**.
140147 3. **Read every word.** The listing opens at its own address
contract/openapi.json +2 −2
@@ -406,7 +406,7 @@
406406 }
407407 },
408408 "post": {
409 "summary": "Launch a listing from its facts and the agent's brand: all five assets, generated and checked.",
409 "summary": "Launch a listing from its facts and the agent's brand: all five assets, generated and checked. When the signed-in person already launched the same address (any case or spacing) the answer is 409 duplicate_address with the existing id, unless `again: true` is sent.",
410410 "operationId": "postApiListings",
411411 "requestBody": {
412412 "required": true,
@@ -547,7 +547,7 @@
547547 },
548548 "responses": {
549549 "201": {
550 "description": "Launch a listing from its facts and the agent's brand: all five assets, generated and checked.",
550 "description": "Launch a listing from its facts and the agent's brand: all five assets, generated and checked. When the signed-in person already launched the same address (any case or spacing) the answer is 409 duplicate_address with the existing id, unless `again: true` is sent.",
551551 "content": {
552552 "application/json": {
553553 "schema": {
src/actions.ts +14 −2
@@ -9,6 +9,7 @@
99 import type { Database } from "bun:sqlite";
1010 import { record, type AuditRow } from "./audit.ts";
1111 import { permit } from "./contract.ts";
12 import { earlierAt, type Earlier } from "./duplicate.ts";
1213 import { check, type Check } from "./fairhousing.ts";
1314 import { generate, isBackOnMarket, KINDS, STATUS_KINDS, TONES, type Kind, type Status } from "./generate.ts";
1415 import {
@@ -62,10 +63,11 @@ export type Failure =
6263 | "not_found"
6364 | "invalid"
6465 | "conflict"
65 | "person_required";
66 | "person_required"
67 | "duplicate_address";
6668 export type Result<T> =
6769 | { ok: true; value: T }
68 | { ok: false; error: Failure; message: string };
70 | { ok: false; error: Failure; message: string; existing?: Earlier };
6971 const ok = <T>(value: T): Result<T> => ({ ok: true, value });
7072 const fail = <T>(error: Failure, message: string): Result<T> => ({
7173 ok: false,
@@ -599,6 +601,8 @@ export const launchListing = (
599601 fields: Readonly<Record<string, unknown>>;
600602 photos?: readonly Upload[];
601603 headshot?: Upload | undefined;
604 /** Launch even though the person already launched this address. */
605 again?: boolean;
602606 },
603607 ): Result<Launched> =>
604608 audited(db, who, "listing", "create", undefined, launch(db, who, input));
@@ -610,12 +614,20 @@ function launch(
610614 fields: Readonly<Record<string, unknown>>;
611615 photos?: readonly Upload[];
612616 headshot?: Upload | undefined;
617 again?: boolean;
613618 },
614619 ): Result<Launched> {
615620 if (who.role === "anon" || permit(who, "listing", "create") !== "yes")
616621 return refuse(who, "Sign in to launch a listing.", "You may not launch a listing.");
617622 const valid = validate(input.fields);
618623 if (!valid.ok) return fail("invalid", valid.message);
624 // Asked before a photo is read or written: a refused launch keeps no file.
625 const earlier = input.again === true ? undefined : earlierAt(db, who, valid.value.address);
626 if (earlier !== undefined)
627 return {
628 ...fail("duplicate_address", `You launched ${earlier.address} already (listing ${earlier.id}). Open it, or launch again with again: true.`),
629 existing: earlier,
630 };
619631 const uploads = (input.photos ?? []).filter((p) => p.bytes.byteLength > 0);
620632 if (uploads.length > PHOTOS_MAX)
621633 return fail("invalid", `Up to ${PHOTOS_MAX} photos.`);
src/contract.test.ts +1 −1
@@ -84,7 +84,7 @@ const doors: Record<
8484 delete: (who, authorId) => deleteNote(db, who, { id: noteBy(authorId) }).ok,
8585 },
8686 listing: {
87 create: (who) => launchListing(db, who, { fields: { ...SAMPLE } }).ok,
87 create: (who) => launchListing(db, who, { fields: { ...SAMPLE }, again: true }).ok,
8888 list: (who) => listListings(db, who).ok,
8989 read: (who, authorId) => getListing(db, who, { id: listingBy(authorId) }).ok,
9090 update: (who, authorId) =>
src/duplicate.test.ts added +117
@@ -0,0 +1,117 @@
1 import { afterAll, expect, test } from "bun:test";
2 import { mkdtempSync, readdirSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { mkdtempSync as tmp } from "node:fs";
6 import { launchListing } from "./actions.ts";
7 import { openDb } from "./db.ts";
8 import { rpc } from "./mcp.ts";
9 import { SAMPLE } from "./sample.ts";
10 import { serve } from "./server.ts";
11 import { sign } from "./who.ts";
12
13 const KEY = "a-key-only-the-forge-holds";
14 const dataDir = mkdtempSync(join(tmpdir(), "duplicate-"));
15 const server = serve({ port: 0, dataDir, identityKey: KEY });
16 const base = `http://${server.hostname}:${server.port}`;
17 afterAll(() => server.stop(true));
18
19 const as = (id: string) => {
20 const identity = `${id}|${id}|user|${Date.now() + 600_000}`;
21 return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
22 };
23 const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]);
24
25 const facts = (address: string): Record<string, string> => ({
26 ...Object.fromEntries(
27 Object.entries(SAMPLE).map(([k, v]) => [k, Array.isArray(v) ? v.join("\n") : String(v)]),
28 ),
29 address,
30 });
31 const post = (id: string, address: string, extra: Record<string, string> = {}, photo = false) => {
32 const form = new FormData();
33 for (const [k, v] of Object.entries({ ...facts(address), ...extra })) form.set(k, v);
34 if (photo) form.set("photos", new File([PNG], "a.png", { type: "image/png" }));
35 return fetch(base + "/listings", { method: "POST", body: form, redirect: "manual", headers: as(id) });
36 };
37 const idOf = (r: Response): number => Number(/\/listings\/(\d+)/.exec(r.headers.get("location")!)![1]);
38 const api = (id: string, body: object) =>
39 fetch(base + "/api/listings", {
40 method: "POST",
41 body: JSON.stringify({ ...facts("9 Elm Court, Bend, OR"), features: SAMPLE.features, ...body }),
42 headers: { ...as(id), "content-type": "application/json" },
43 });
44
45 test("the author is asked first: the form comes back with every value, the link and again", async () => {
46 const first = idOf(await post("ann", "412 Alder Lane", {}, true));
47 const photos = () => readdirSync(join(dataDir, "photos")).length;
48 const before = photos();
49 const r = await post("ann", " 412 ALDER lane ", { agent_name: "Ann <b>" }, true);
50 expect(r.status).toBe(200);
51 const page = await r.text();
52 expect(page).toContain("You launched 412 Alder Lane on");
53 expect(page).toContain(`href="/listings/${first}"`);
54 expect(page).toContain('name="again" value="1"');
55 // Enter in a field submits with the form's first submit button: it must not be the again one.
56 const form = page.slice(page.indexOf('<form class="card form"'));
57 expect(form.match(/<button[^>]*>/)![0]).not.toContain("again");
58 expect(page).toContain('value="Ann &lt;b&gt;"');
59 expect(photos()).toBe(before);
60 const again = await post("ann", "412 Alder Lane", { again: "1" });
61 expect(again.status).toBe(303);
62 expect(idOf(again)).toBeGreaterThan(first);
63 });
64
65 test("another person's listing at the address is never named and never asks", async () => {
66 await post("bea", "77 Pine Street");
67 const r = await post("cal", "77 pine street");
68 expect(r.status).toBe(303);
69 });
70
71 test("a hostile address is escaped in the notice", async () => {
72 const hostile = `<script>alert(1)</script> "Lane" 1`;
73 await post("dee", hostile);
74 const page = await (await post("dee", hostile)).text();
75 expect(page).not.toContain("<script>alert(1)");
76 expect(page).toContain("You launched &lt;script&gt;alert(1)&lt;/script&gt;");
77 });
78
79 test("the API answers 409 duplicate_address with the id unless again is true", async () => {
80 const made = await api("eve", {});
81 expect(made.status).toBe(201);
82 const id = ((await made.json()) as { id: number }).id;
83 const dup = await api("eve", { address: " 9 elm COURT, Bend, OR" });
84 expect(dup.status).toBe(409);
85 expect(await dup.json()).toMatchObject({ error: "duplicate_address", id });
outdated

This does not test box 4: it only shows that GET ?from=1 has no notice. Add a test that launching from the sample with a changed address launches without a notice. The MCP launch_listing path also needs a test (409-style error text with id, and again: true).

86 expect((await api("eve", { again: true })).status).toBe(201);
87 expect((await api("fay", {})).status).toBe(201);
88 });
89
90 test("the sample's 'start a new one' form carries no notice", async () => {
91 const page = await (await fetch(`${base}/?from=1`, { headers: as("ann") })).text();
92 expect(page).not.toContain("You launched");
93 });
94
95 test("starting from the sample with a changed address launches with no notice", async () => {
96 const page = await (await fetch(`${base}/?from=1`, { headers: as("gus") })).text();
97 expect(page).toContain(SAMPLE.address);
98 const r = await post("gus", "55 Birch Way, Bend, OR 97701");
99 expect(r.status).toBe(303);
100 expect((await post("gus", SAMPLE.address)).status).toBe(303);
101 });
102
103 test("the MCP launch_listing tool refuses a duplicate with its id, and again launches", () => {
104 const db = openDb(tmp(join(tmpdir(), "duplicate-mcp-")));
105 const me = { role: "user", id: "m1", name: "m" } as const;
106 const made = launchListing(db, me, { fields: SAMPLE });
107 if (!made.ok) throw new Error(made.message);
108 const call = (args: object) =>
109 rpc(db, me, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "launch_listing", arguments: args } }).body as {
110 result: { isError?: boolean; content: { text: string }[] };
111 };
112 const dup = call({ ...SAMPLE, address: ` ${SAMPLE.address.toUpperCase()} ` }).result;
113 expect(dup.isError).toBe(true);
114 expect(dup.content[0]!.text).toContain("duplicate_address");
115 expect(dup.content[0]!.text).toContain(`id: ${made.value.id}`);
116 expect(call({ ...SAMPLE, again: true }).result.isError).toBeUndefined();
117 });
src/duplicate.ts added +30
@@ -0,0 +1,30 @@
1 /**
2 * The listing a person already launched at an address: the same address
3 * compared trimmed, case- and space-insensitive. It reads only rows the
4 * person authored, so it can never name anyone else's listing.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import type { Who } from "./who.ts";
9
10 export interface Earlier {
11 readonly id: number;
12 readonly address: string;
13 readonly created_at: string;
14 }
15
16 /** An address as compared: no case, no whitespace at all. */
17 export const sameAddress = (a: string): string => a.toLowerCase().replace(/\s+/g, "");
18
19 /** The person's latest listing at `address`, or undefined. */
20 export function earlierAt(db: Database, who: Who, address: unknown): Earlier | undefined {
21 if (who.role === "anon" || typeof address !== "string") return undefined;
22 const wanted = sameAddress(address);
23 if (wanted === "") return undefined;
24 return db
25 .query<Earlier, [string]>(
26 "SELECT id, address, created_at FROM listing WHERE author_id = ?1 ORDER BY id DESC",
27 )
28 .all(who.id)
29 .find((row) => sameAddress(row.address) === wanted);
30 }
src/listings.test.ts +1
@@ -42,6 +42,7 @@ const launchForm = (photos: Uint8Array<ArrayBuffer>[] = [PNG, JPEG]): FormData =
4242 const form = new FormData();
4343 for (const [k, v] of Object.entries(SAMPLE))
4444 form.set(k, Array.isArray(v) ? v.join("\n") : String(v));
45 form.set("again", "1");
4546 form.set("address", "18 Quarry Road, Bend, OR 97701");
4647 for (const p of photos) form.append("photos", new Blob([p]), "photo.png");
4748 form.set("headshot", new Blob([JPEG]), "me.jpg");
src/mcp.ts +4 −3
@@ -135,7 +135,7 @@ export const tools: readonly Tool[] = [
135135 {
136136 name: "launch_listing",
137137 description:
138 "Launch a listing from its facts and the agent's brand (no photos here): address, beds, baths, sqft, lot, year, price, five to ten features, tone, palette, open_houses (optional, up to two), agent_name, brokerage, license, agent_phone, agent_email.",
138 "Launch a listing from its facts and the agent's brand (no photos here): address, beds, baths, sqft, lot, year, price, five to ten features, tone, palette, open_houses (optional, up to two), agent_name, brokerage, license, agent_phone, agent_email. An address you already launched answers duplicate_address with the existing listing id unless `again` is true.",
139139 inputSchema: {
140140 type: "object",
141141 properties: {
@@ -155,10 +155,11 @@ export const tools: readonly Tool[] = [
155155 agent_phone: { type: "string" },
156156 agent_email: { type: "string" },
157157 open_houses: OPEN_HOUSES,
158 again: { type: "boolean", description: "Launch even though you already launched this address." },
158159 },
159160 required: ["address", "beds", "baths", "sqft", "lot", "year", "price", "features", "agent_name", "brokerage", "license", "agent_phone", "agent_email"],
160161 },
161 run: (db, who, args) => launchListing(db, who, { fields: args }),
162 run: (db, who, args) => launchListing(db, who, { fields: args, again: args.again === true }),
162163 },
163164 {
164165 name: "edit_asset",
@@ -392,7 +393,7 @@ export function rpc(
392393 ? { content: [{ type: "text", text: typeof result.value === "string" ? result.value : JSON.stringify(result.value) }] }
393394 : {
394395 content: [
395 { type: "text", text: `${result.error}: ${result.message}` },
396 { type: "text", text: `${result.error}: ${result.message}${result.existing === undefined ? "" : ` id: ${result.existing.id}`}` },
396397 ],
397398 isError: true,
398399 },
src/pages/home.ts +15 −2
@@ -8,6 +8,7 @@
88 import { count, money, STATUS_LABELS, TONES, type Tone } from "../generate.ts";
99 import type { ListingSummary } from "../actions.ts";
1010 import { counts, Q_MAX, find, FILTERS, type Filter, type Find } from "../find.ts";
11 import type { Earlier } from "../duplicate.ts";
1112 import { escape } from "../http.ts";
1213 import {
1314 FEATURES_MAX,
@@ -70,6 +71,8 @@ export function home(input: {
7071 /** The listing the agent's saved headshot comes from, when they have one. */
7172 headshotFrom?: { address: string } | undefined;
7273 notice?: string | undefined;
74 /** The listing the agent already has at the address they typed. */
75 duplicate?: Earlier | undefined;
7376 }): string {
7477 const { who, listings, draft, notice } = input;
7578 const wanted = input.find ?? { status: "all", q: "" };
@@ -77,7 +80,7 @@ export function home(input: {
7780 `<section class="compose" aria-labelledby="compose-title">
7881 <h2 id="compose-title">Launch a listing</h2>
7982 <p class="muted lede">The facts and photos in; the MLS description, three social posts, a flyer and an email blast out — in your brand, each checked for Fair Housing language. You approve every word.</p>
80 ${form(who, draft, undefined, input.headshotFrom)}
83 ${form(who, draft, undefined, input.headshotFrom, input.duplicate)}
8184 </section>
8285 <section class="launched" aria-labelledby="launched-title">
8386 <h2 id="launched-title">Launched</h2>
@@ -114,9 +117,18 @@ export function form(
114117 draft: Draft,
115118 editing?: Editing,
116119 headshotFrom?: { address: string },
120 duplicate?: Earlier,
117121 ): string {
118122 const tone = draft.tone ?? "classic";
119123 const palette = draft.palette ?? "navy";
124 const again =
125 duplicate === undefined
126 ? ""
127 : `<p class="card read-only" role="status">You launched ${escape(duplicate.address)} on ${escape(new Date(duplicate.created_at).toLocaleDateString("en", { month: "short", day: "numeric" }))} — <a href="/listings/${duplicate.id}">open it</a>, or launch another anyway with the button after Launch <small class="muted">(choose any photos again)</small></p>`;
outdated

This notice holds a submit button (name=again) and is rendered before the Launch button, so Enter in a text field submits with again=1 and bypasses the check. Move the button after Launch in tree order or give it the form attribute outside the form's default flow. Add a test.

128 const anyway =
129 duplicate === undefined
130 ? ""
131 : `<button class="button button-quiet" name="again" value="1">Launch another anyway</button>`;
120132 const launch =
121133 editing !== undefined
122134 ? `<button class="button button-launch">Save and write all again</button>
@@ -124,12 +136,13 @@ export function form(
124136 <span class="muted">Every asset is written again from these facts: your edits are replaced and every approval is cleared.</span>`
125137 : who.role === "anon"
126138 ? `<a class="button" href="/__forge/signin?to=/">Sign in to launch</a>`
127 : `<button class="button button-launch">Launch</button>`;
139 : `<button class="button button-launch">Launch</button>${anyway}`;
128140 const photos = `<label class="field"><span>Photos <small class="muted">up to ${PHOTOS_MAX}, JPEG, PNG or WebP, 24 MB in all; the first is the hero</small></span>
129141 <input class="input file" type="file" name="photos" multiple accept="image/jpeg,image/png,image/webp"></label>`;
130142 const headshot = `<label class="field"><span>Headshot <small class="muted">${headshotFrom === undefined ? "optional; your last one carries over" : `Using your headshot from ${escape(headshotFrom.address)} — choose a file to replace it`}</small></span>
131143 <input class="input file" type="file" name="headshot" accept="image/jpeg,image/png,image/webp"></label>`;
132144 return `<form class="card form" method="post" ${editing === undefined ? 'action="/listings" enctype="multipart/form-data"' : `action="/listings/${editing.id}/facts"`}>
145 ${again}
133146 <fieldset>
134147 <legend>The property</legend>
135148 ${field(draft, "address", "Address", 'required minlength="5" maxlength="120" autocomplete="street-address"', SAMPLE.address)}
src/prefill.test.ts +1
@@ -27,6 +27,7 @@ const launch = async (id: string, brokerage: string, headshot?: boolean): Promis
2727 features: SAMPLE.features.slice(0, 5).join("\n"),
2828 }))
2929 form.set(k, String(v));
30 form.set("again", "1");
3031 if (headshot) form.set("headshot", new File([PNG], "me.png", { type: "image/png" }));
3132 const r = await fetch(base + "/listings", { method: "POST", body: form, redirect: "manual", headers: as(id) });
3233 expect(r.status).toBe(303);
src/routes.ts +25 −4
@@ -345,6 +345,7 @@ const STATUS: Record<Failure, number> = {
345345 invalid: 422,
346346 conflict: 409,
347347 person_required: 403,
348 duplicate_address: 409,
348349 };
349350
350351 /** A result as JSON: the value, or `{ error, message }` with the matching status. */
@@ -352,7 +353,11 @@ const answer = <T>(result: Result<T>, status = 200): Response =>
352353 result.ok
353354 ? json(result.value, status)
354355 : json(
355 { error: result.error, message: result.message },
356 {
357 error: result.error,
358 message: result.message,
359 ...(result.existing === undefined ? {} : { id: result.existing.id }),
360 },
356361 STATUS[result.error],
357362 );
358363
@@ -459,11 +464,26 @@ export const routes: readonly Route[] = [
459464 const form = await readForm(c.request, LAUNCH_MAX, 0);
460465 if (form === undefined)
461466 return back("Too large: up to 24 MB of photos in all.");
467 const typed = textFields(form);
462468 const launched = launchListing(c.db, c.who, {
463 fields: textFields(form),
469 fields: typed,
464470 photos: await uploads(form, "photos"),
465471 headshot: (await uploads(form, "headshot"))[0],
472 again: typed.again === "1",
466473 });
474 // The form again, every typed value kept, with the listing they have.
475 if (!launched.ok && launched.existing !== undefined) {
476 const all = listListings(c.db, c.who, {});
477 return html(
478 home({
479 who: c.who,
480 listings: all.ok ? all.value : [],
481 draft: typed,
482 headshotFrom: headshotFrom(c.db, c.who),
483 duplicate: launched.existing,
484 }),
485 );
486 }
467487 return launched.ok
468488 ? back("Launched. Read every word before it goes out.", `/listings/${launched.value.id}`)
469489 : back(launched.message);
@@ -915,10 +935,11 @@ export const routes: readonly Route[] = [
915935 handle: async (c) => {
916936 const body = await readJson(c.request);
917937 if (body === undefined) return json({ error: "too_large" }, 413);
918 return answer(launchListing(c.db, c.who, { fields: fields(body) }), 201);
938 const sent = fields(body);
939 return answer(launchListing(c.db, c.who, { fields: sent, again: sent.again === true }), 201);
919940 },
920941 api: {
921 summary: "Launch a listing from its facts and the agent's brand: all five assets, generated and checked.",
942 summary: "Launch a listing from its facts and the agent's brand: all five assets, generated and checked. When the signed-in person already launched the same address (any case or spacing) the answer is 409 duplicate_address with the existing id, unless `again: true` is sent.",
922943 body: LISTING_BODY,
923944 answer: LISTING,
924945 status: 201,
src/signin.test.ts +1
@@ -24,6 +24,7 @@ const launch = async (extra: Record<string, string>): Promise<string> => {
2424 features: "Sunny kitchen\nQuiet street\nNew roof\nBig deck\nOak floors", ...extra,
2525 }))
2626 form.set(k, String(v));
27 form.set("again", "1");
2728 const made = await call("/listings", { method: "POST", body: form });
2829 return /\/listings\/(\d+)/.exec(made.headers.get("location")!)![1];
2930 };

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs