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
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/72-launching-an-address-you-already-launche → main 2 commits fork 2 threads · 2 unresolved
12 files
changed +218
−14
README.md +7
| @@ -135,6 +135,13 @@ list cannot drift from the app. | ||
| 135 | 135 | colour) is asked once: the new-listing form starts filled in from your most |
| 136 | 136 | recent listing (only ever your own), every field editable, and your headshot |
| 137 | 137 | 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. | |
| 138 | 145 | 2. **Pick a tone** — *classic* (measured), *warm* (inviting) or *bold* |
| 139 | 146 | (short and punchy) — and press **Launch**. |
| 140 | 147 | 3. **Read every word.** The listing opens at its own address |
contract/openapi.json +2 −2
| @@ -406,7 +406,7 @@ | ||
| 406 | 406 | } |
| 407 | 407 | }, |
| 408 | 408 | "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.", | |
| 410 | 410 | "operationId": "postApiListings", |
| 411 | 411 | "requestBody": { |
| 412 | 412 | "required": true, |
| @@ -547,7 +547,7 @@ | ||
| 547 | 547 | }, |
| 548 | 548 | "responses": { |
| 549 | 549 | "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.", | |
| 551 | 551 | "content": { |
| 552 | 552 | "application/json": { |
| 553 | 553 | "schema": { |
src/actions.ts +14 −2
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | 10 | import { record, type AuditRow } from "./audit.ts"; |
| 11 | 11 | import { permit } from "./contract.ts"; |
| 12 | import { earlierAt, type Earlier } from "./duplicate.ts"; | |
| 12 | 13 | import { check, type Check } from "./fairhousing.ts"; |
| 13 | 14 | import { generate, isBackOnMarket, KINDS, STATUS_KINDS, TONES, type Kind, type Status } from "./generate.ts"; |
| 14 | 15 | import { |
| @@ -62,10 +63,11 @@ export type Failure = | ||
| 62 | 63 | | "not_found" |
| 63 | 64 | | "invalid" |
| 64 | 65 | | "conflict" |
| 65 | | "person_required"; | |
| 66 | | "person_required" | |
| 67 | | "duplicate_address"; | |
| 66 | 68 | export type Result<T> = |
| 67 | 69 | | { ok: true; value: T } |
| 68 | | { ok: false; error: Failure; message: string }; | |
| 70 | | { ok: false; error: Failure; message: string; existing?: Earlier }; | |
| 69 | 71 | const ok = <T>(value: T): Result<T> => ({ ok: true, value }); |
| 70 | 72 | const fail = <T>(error: Failure, message: string): Result<T> => ({ |
| 71 | 73 | ok: false, |
| @@ -599,6 +601,8 @@ export const launchListing = ( | ||
| 599 | 601 | fields: Readonly<Record<string, unknown>>; |
| 600 | 602 | photos?: readonly Upload[]; |
| 601 | 603 | headshot?: Upload | undefined; |
| 604 | /** Launch even though the person already launched this address. */ | |
| 605 | again?: boolean; | |
| 602 | 606 | }, |
| 603 | 607 | ): Result<Launched> => |
| 604 | 608 | audited(db, who, "listing", "create", undefined, launch(db, who, input)); |
| @@ -610,12 +614,20 @@ function launch( | ||
| 610 | 614 | fields: Readonly<Record<string, unknown>>; |
| 611 | 615 | photos?: readonly Upload[]; |
| 612 | 616 | headshot?: Upload | undefined; |
| 617 | again?: boolean; | |
| 613 | 618 | }, |
| 614 | 619 | ): Result<Launched> { |
| 615 | 620 | if (who.role === "anon" || permit(who, "listing", "create") !== "yes") |
| 616 | 621 | return refuse(who, "Sign in to launch a listing.", "You may not launch a listing."); |
| 617 | 622 | const valid = validate(input.fields); |
| 618 | 623 | 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 | }; | |
| 619 | 631 | const uploads = (input.photos ?? []).filter((p) => p.bytes.byteLength > 0); |
| 620 | 632 | if (uploads.length > PHOTOS_MAX) |
| 621 | 633 | return fail("invalid", `Up to ${PHOTOS_MAX} photos.`); |
src/contract.test.ts +1 −1
| @@ -84,7 +84,7 @@ const doors: Record< | ||
| 84 | 84 | delete: (who, authorId) => deleteNote(db, who, { id: noteBy(authorId) }).ok, |
| 85 | 85 | }, |
| 86 | 86 | listing: { |
| 87 | create: (who) => launchListing(db, who, { fields: { ...SAMPLE } }).ok, | |
| 87 | create: (who) => launchListing(db, who, { fields: { ...SAMPLE }, again: true }).ok, | |
| 88 | 88 | list: (who) => listListings(db, who).ok, |
| 89 | 89 | read: (who, authorId) => getListing(db, who, { id: listingBy(authorId) }).ok, |
| 90 | 90 | 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 <b>"'); | |
| 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 <script>alert(1)</script>"); | |
| 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 }); | |
| 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 = | ||
| 42 | 42 | const form = new FormData(); |
| 43 | 43 | for (const [k, v] of Object.entries(SAMPLE)) |
| 44 | 44 | form.set(k, Array.isArray(v) ? v.join("\n") : String(v)); |
| 45 | form.set("again", "1"); | |
| 45 | 46 | form.set("address", "18 Quarry Road, Bend, OR 97701"); |
| 46 | 47 | for (const p of photos) form.append("photos", new Blob([p]), "photo.png"); |
| 47 | 48 | form.set("headshot", new Blob([JPEG]), "me.jpg"); |
src/mcp.ts +4 −3
| @@ -135,7 +135,7 @@ export const tools: readonly Tool[] = [ | ||
| 135 | 135 | { |
| 136 | 136 | name: "launch_listing", |
| 137 | 137 | 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.", | |
| 139 | 139 | inputSchema: { |
| 140 | 140 | type: "object", |
| 141 | 141 | properties: { |
| @@ -155,10 +155,11 @@ export const tools: readonly Tool[] = [ | ||
| 155 | 155 | agent_phone: { type: "string" }, |
| 156 | 156 | agent_email: { type: "string" }, |
| 157 | 157 | open_houses: OPEN_HOUSES, |
| 158 | again: { type: "boolean", description: "Launch even though you already launched this address." }, | |
| 158 | 159 | }, |
| 159 | 160 | required: ["address", "beds", "baths", "sqft", "lot", "year", "price", "features", "agent_name", "brokerage", "license", "agent_phone", "agent_email"], |
| 160 | 161 | }, |
| 161 | run: (db, who, args) => launchListing(db, who, { fields: args }), | |
| 162 | run: (db, who, args) => launchListing(db, who, { fields: args, again: args.again === true }), | |
| 162 | 163 | }, |
| 163 | 164 | { |
| 164 | 165 | name: "edit_asset", |
| @@ -392,7 +393,7 @@ export function rpc( | ||
| 392 | 393 | ? { content: [{ type: "text", text: typeof result.value === "string" ? result.value : JSON.stringify(result.value) }] } |
| 393 | 394 | : { |
| 394 | 395 | content: [ |
| 395 | { type: "text", text: `${result.error}: ${result.message}` }, | |
| 396 | { type: "text", text: `${result.error}: ${result.message}${result.existing === undefined ? "" : ` id: ${result.existing.id}`}` }, | |
| 396 | 397 | ], |
| 397 | 398 | isError: true, |
| 398 | 399 | }, |
src/pages/home.ts +15 −2
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | import { count, money, STATUS_LABELS, TONES, type Tone } from "../generate.ts"; |
| 9 | 9 | import type { ListingSummary } from "../actions.ts"; |
| 10 | 10 | import { counts, Q_MAX, find, FILTERS, type Filter, type Find } from "../find.ts"; |
| 11 | import type { Earlier } from "../duplicate.ts"; | |
| 11 | 12 | import { escape } from "../http.ts"; |
| 12 | 13 | import { |
| 13 | 14 | FEATURES_MAX, |
| @@ -70,6 +71,8 @@ export function home(input: { | ||
| 70 | 71 | /** The listing the agent's saved headshot comes from, when they have one. */ |
| 71 | 72 | headshotFrom?: { address: string } | undefined; |
| 72 | 73 | notice?: string | undefined; |
| 74 | /** The listing the agent already has at the address they typed. */ | |
| 75 | duplicate?: Earlier | undefined; | |
| 73 | 76 | }): string { |
| 74 | 77 | const { who, listings, draft, notice } = input; |
| 75 | 78 | const wanted = input.find ?? { status: "all", q: "" }; |
| @@ -77,7 +80,7 @@ export function home(input: { | ||
| 77 | 80 | `<section class="compose" aria-labelledby="compose-title"> |
| 78 | 81 | <h2 id="compose-title">Launch a listing</h2> |
| 79 | 82 | <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)} | |
| 81 | 84 | </section> |
| 82 | 85 | <section class="launched" aria-labelledby="launched-title"> |
| 83 | 86 | <h2 id="launched-title">Launched</h2> |
| @@ -114,9 +117,18 @@ export function form( | ||
| 114 | 117 | draft: Draft, |
| 115 | 118 | editing?: Editing, |
| 116 | 119 | headshotFrom?: { address: string }, |
| 120 | duplicate?: Earlier, | |
| 117 | 121 | ): string { |
| 118 | 122 | const tone = draft.tone ?? "classic"; |
| 119 | 123 | 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>`; | |
| 120 | 132 | const launch = |
| 121 | 133 | editing !== undefined |
| 122 | 134 | ? `<button class="button button-launch">Save and write all again</button> |
| @@ -124,12 +136,13 @@ export function form( | ||
| 124 | 136 | <span class="muted">Every asset is written again from these facts: your edits are replaced and every approval is cleared.</span>` |
| 125 | 137 | : who.role === "anon" |
| 126 | 138 | ? `<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}`; | |
| 128 | 140 | 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> |
| 129 | 141 | <input class="input file" type="file" name="photos" multiple accept="image/jpeg,image/png,image/webp"></label>`; |
| 130 | 142 | 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> |
| 131 | 143 | <input class="input file" type="file" name="headshot" accept="image/jpeg,image/png,image/webp"></label>`; |
| 132 | 144 | return `<form class="card form" method="post" ${editing === undefined ? 'action="/listings" enctype="multipart/form-data"' : `action="/listings/${editing.id}/facts"`}> |
| 145 | ${again} | |
| 133 | 146 | <fieldset> |
| 134 | 147 | <legend>The property</legend> |
| 135 | 148 | ${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 | ||
| 27 | 27 | features: SAMPLE.features.slice(0, 5).join("\n"), |
| 28 | 28 | })) |
| 29 | 29 | form.set(k, String(v)); |
| 30 | form.set("again", "1"); | |
| 30 | 31 | if (headshot) form.set("headshot", new File([PNG], "me.png", { type: "image/png" })); |
| 31 | 32 | const r = await fetch(base + "/listings", { method: "POST", body: form, redirect: "manual", headers: as(id) }); |
| 32 | 33 | expect(r.status).toBe(303); |
src/routes.ts +25 −4
| @@ -345,6 +345,7 @@ const STATUS: Record<Failure, number> = { | ||
| 345 | 345 | invalid: 422, |
| 346 | 346 | conflict: 409, |
| 347 | 347 | person_required: 403, |
| 348 | duplicate_address: 409, | |
| 348 | 349 | }; |
| 349 | 350 | |
| 350 | 351 | /** 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 => | ||
| 352 | 353 | result.ok |
| 353 | 354 | ? json(result.value, status) |
| 354 | 355 | : 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 | }, | |
| 356 | 361 | STATUS[result.error], |
| 357 | 362 | ); |
| 358 | 363 | |
| @@ -459,11 +464,26 @@ export const routes: readonly Route[] = [ | ||
| 459 | 464 | const form = await readForm(c.request, LAUNCH_MAX, 0); |
| 460 | 465 | if (form === undefined) |
| 461 | 466 | return back("Too large: up to 24 MB of photos in all."); |
| 467 | const typed = textFields(form); | |
| 462 | 468 | const launched = launchListing(c.db, c.who, { |
| 463 | fields: textFields(form), | |
| 469 | fields: typed, | |
| 464 | 470 | photos: await uploads(form, "photos"), |
| 465 | 471 | headshot: (await uploads(form, "headshot"))[0], |
| 472 | again: typed.again === "1", | |
| 466 | 473 | }); |
| 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 | } | |
| 467 | 487 | return launched.ok |
| 468 | 488 | ? back("Launched. Read every word before it goes out.", `/listings/${launched.value.id}`) |
| 469 | 489 | : back(launched.message); |
| @@ -915,10 +935,11 @@ export const routes: readonly Route[] = [ | ||
| 915 | 935 | handle: async (c) => { |
| 916 | 936 | const body = await readJson(c.request); |
| 917 | 937 | 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); | |
| 919 | 940 | }, |
| 920 | 941 | 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.", | |
| 922 | 943 | body: LISTING_BODY, |
| 923 | 944 | answer: LISTING, |
| 924 | 945 | status: 201, |
src/signin.test.ts +1
| @@ -24,6 +24,7 @@ const launch = async (extra: Record<string, string>): Promise<string> => { | ||
| 24 | 24 | features: "Sunny kitchen\nQuiet street\nNew roof\nBig deck\nOak floors", ...extra, |
| 25 | 25 | })) |
| 26 | 26 | form.set(k, String(v)); |
| 27 | form.set("again", "1"); | |
| 27 | 28 | const made = await call("/listings", { method: "POST", body: form }); |
| 28 | 29 | return /\/listings\/(\d+)/.exec(made.headers.get("location")!)![1]; |
| 29 | 30 | }; |
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).