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

Type your details once: a new listing's form starts with the agent's name, brokerage, license, phone, email, colours and headshot from their last listing #71

Merged opened this

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

Merged

mason/listing-launch:mason/62-type-your-details-once-a-new-listing-s-f → main 2 commits fork 1 thread · 1 unresolved

  • Merged. mason/62-type-your-details-once-a-new-listing-s-f was merged into main as 2cfa641.

Review

Sign in to review this change.
7 files changed +144 −16
README.md +3 −1
@@ -132,7 +132,9 @@ list cannot drift from the app.
132132 the same filter and search as the home list; an unknown status is All and
133133 `q` is capped at 100 characters.
134134 Your brand (name, brokerage, license number, phone, email, headshot,
135 colour) is asked once and carried to your next listing.
135 colour) is asked once: the new-listing form starts filled in from your most
136 recent listing (only ever your own), every field editable, and your headshot
137 is reused unless you choose a new file (the form says which listing it is from).
136138 2. **Pick a tone** — *classic* (measured), *warm* (inviting) or *bold*
137139 (short and punchy) — and press **Launch**.
138140 3. **Read every word.** The listing opens at its own address
src/actions.ts +2 −8
@@ -44,6 +44,7 @@ import {
4444 unwrite as unwritePhotos,
4545 write as writePhoto,
4646 } from "./photos.ts";
47 import { headshotFrom } from "./agent.ts";
4748 import type { Who } from "./who.ts";
4849
4950 export interface Note {
@@ -638,14 +639,7 @@ function launch(
638639 ? { name: photoName(face.kind), bytes: input.headshot!.bytes }
639640 : undefined;
640641 // No new headshot: the agent's last one carries over.
641 const headshot =
642 newFace?.name ??
643 db
644 .query<{ headshot: string }, [string]>(
645 "SELECT headshot FROM listing WHERE author_id = ?1 AND headshot IS NOT NULL ORDER BY id DESC LIMIT 1",
646 )
647 .get(who.id)?.headshot ??
648 null;
642 const headshot = newFace?.name ?? headshotFrom(db, who)?.name ?? null;
649643 const writing = newFace === undefined ? files : [...files, newFace];
650644 let listing: Launched;
651645 try {
src/agent.ts added +41
@@ -0,0 +1,41 @@
1 /**
2 * What the app remembers of the agent: their brand and headshot from the
3 * listing they launched last. Only listings the person authored are read, so
4 * nobody ever sees another person's details.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import { fromRow, type Listing, type ListingRow } from "./listing.ts";
9 import { pathOf } from "./photos.ts";
10 import type { Who } from "./who.ts";
11
12 /** The signed-in person's most recent listing, or nothing (anon, or none yet). */
13 export function lastListing(db: Database, who: Who): Listing | undefined {
14 if (who.role === "anon") return undefined;
15 const row = db
16 .query<ListingRow, [string]>(
17 "SELECT * FROM listing WHERE author_id = ?1 ORDER BY id DESC LIMIT 1",
18 )
19 .get(who.id);
20 return row === null ? undefined : fromRow(row);
21 }
22
23 /**
24 * The headshot a launch without a new one reuses, and the address of the
25 * listing it came from: the author's newest headshot whose file is still
26 * there. The form's note and the launch both read this, so they name one file.
27 */
outdated

Medium: this repeats the query in src/actions.ts ~641 and adds a pathOf file check the launch does not have. If the file is gone, the form hides the note but the launch still reuses the missing file. Have the launch call one shared helper (name and file, with the pathOf check) and have the form read its address.

28 export function headshotFrom(
29 db: Database,
30 who: Who,
31 ): { name: string; address: string } | undefined {
32 if (who.role === "anon") return undefined;
33 const row = db
34 .query<{ address: string; headshot: string }, [string]>(
35 "SELECT address, headshot FROM listing WHERE author_id = ?1 AND headshot IS NOT NULL ORDER BY id DESC LIMIT 1",
36 )
37 .get(who.id);
38 return row === null || pathOf(db, row.headshot) === undefined
39 ? undefined
40 : { name: row.headshot, address: row.address };
41 }
src/hostile.test.ts +2
@@ -102,6 +102,8 @@ for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/
102102 expectInert(html);
103103 // The words are still there, as typed, escaped.
104104 if (path !== "/") expect(html).toContain(`&quot;&gt;&lt;img src=x onerror=alert(2)&gt;`);
105 // On the home page the hostile brokerage is prefilled into the form, escaped.
106 if (path === "/") expect(html).toContain(`name="brokerage" value="&#39;&gt;&lt;iframe src=&quot;javascript:alert(8)&quot;&gt;&lt;/iframe&gt; 🏠"`);
105107 });
106108 }
107109
src/pages/home.ts +10 −3
@@ -67,6 +67,8 @@ export function home(input: {
6767 listings: readonly ListingSummary[];
6868 find?: Find;
6969 draft: Draft;
70 /** The listing the agent's saved headshot comes from, when they have one. */
71 headshotFrom?: { address: string } | undefined;
7072 notice?: string | undefined;
7173 }): string {
7274 const { who, listings, draft, notice } = input;
@@ -75,7 +77,7 @@ export function home(input: {
7577 `<section class="compose" aria-labelledby="compose-title">
7678 <h2 id="compose-title">Launch a listing</h2>
7779 <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>
78 ${form(who, draft)}
80 ${form(who, draft, undefined, input.headshotFrom)}
7981 </section>
8082 <section class="launched" aria-labelledby="launched-title">
8183 <h2 id="launched-title">Launched</h2>
@@ -107,7 +109,12 @@ export interface Editing {
107109 readonly id: number;
108110 }
109111
110 export function form(who: Who, draft: Draft, editing?: Editing): string {
112 export function form(
113 who: Who,
114 draft: Draft,
115 editing?: Editing,
116 headshotFrom?: { address: string },
117 ): string {
111118 const tone = draft.tone ?? "classic";
112119 const palette = draft.palette ?? "navy";
113120 const launch =
@@ -120,7 +127,7 @@ export function form(who: Who, draft: Draft, editing?: Editing): string {
120127 : `<button class="button button-launch">Launch</button>`;
121128 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>
122129 <input class="input file" type="file" name="photos" multiple accept="image/jpeg,image/png,image/webp"></label>`;
123 const headshot = `<label class="field"><span>Headshot <small class="muted">optional; your last one carries over</small></span>
130 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>
124131 <input class="input file" type="file" name="headshot" accept="image/jpeg,image/png,image/webp"></label>`;
125132 return `<form class="card form" method="post" ${editing === undefined ? 'action="/listings" enctype="multipart/form-data"' : `action="/listings/${editing.id}/facts"`}>
126133 <fieldset>
src/prefill.test.ts added +83
@@ -0,0 +1,83 @@
1 import { afterAll, expect, test } from "bun:test";
2 import { mkdtempSync, readdirSync, rmSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { SAMPLE } from "./sample.ts";
6 import { serve } from "./server.ts";
7 import { sign } from "./who.ts";
8
9 const KEY = "a-key-only-the-forge-holds";
10 const dataDir = mkdtempSync(join(tmpdir(), "prefill-"));
11 const server = serve({ port: 0, dataDir, identityKey: KEY });
12 const base = `http://${server.hostname}:${server.port}`;
13 afterAll(() => server.stop(true));
14
15 const as = (id: string) => {
16 const identity = `${id}|${id}|user|${Date.now() + 600_000}`;
17 return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
18 };
19 const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]);
20
21 const launch = async (id: string, brokerage: string, headshot?: boolean): Promise<number> => {
22 const form = new FormData();
23 for (const [k, v] of Object.entries({
24 address: "412 Alder Lane", beds: 3, baths: 2, sqft: 1800, lot: "0.2 acre", year: 1999,
25 price: 500000, tone: "classic", palette: "forest", agent_name: `Agent ${id}`, brokerage,
26 license: "L-123456", agent_phone: "+1 (503) 555-0100", agent_email: `${id}@example.com`,
27 features: SAMPLE.features.slice(0, 5).join("\n"),
28 }))
29 form.set(k, String(v));
30 if (headshot) form.set("headshot", new File([PNG], "me.png", { type: "image/png" }));
31 const r = await fetch(base + "/listings", { method: "POST", body: form, redirect: "manual", headers: as(id) });
32 expect(r.status).toBe(303);
33 return Number(/\/listings\/(\d+)/.exec(r.headers.get("location")!)![1]);
34 };
35 let withFace = 0;
36 const home = async (id?: string, q = "") =>
37 (await fetch(`${base}/${q}`, { headers: id === undefined ? {} : as(id) })).text();
38
39 test("the author's form starts with their last listing's details, editable", async () => {
40 await launch("ann", "Old Brokerage");
41 withFace = await launch("ann", "Newest <Realty>", true);
42 const html = await home("ann");
43 expect(html).toContain('name="agent_name" value="Agent ann"');
44 expect(html).toContain('name="brokerage" value="Newest &lt;Realty&gt;"');
45 expect(html).toContain('name="license" value="L-123456"');
46 expect(html).toContain('name="agent_phone" value="+1 (503) 555-0100"');
47 expect(html).toContain('name="agent_email" value="[email protected]"');
48 expect(html).toContain('value="forest" checked');
49 expect(html).not.toMatch(/name="brokerage" value="[^"]*"[^>]*readonly/);
50 });
51
52 test("a search does not lose the prefill", async () => {
53 expect(await home("ann", "?q=nothing-matches")).toContain('name="agent_name" value="Agent ann"');
54 });
55
56 test("another person, a newcomer and anon see the form empty", async () => {
57 for (const html of [await home("bob"), await home()]) {
58 expect(html).toContain('name="agent_name" value=""');
59 expect(html).not.toContain("Agent ann");
60 expect(html).not.toContain("Newest");
61 expect(html).not.toContain("Using your headshot");
62 }
63 });
64
65 test("the headshot is reused and the form says whose it is", async () => {
66 expect(await home("ann")).toContain("Using your headshot from 412 Alder Lane — choose a file to replace it");
67 const reused = await launch("ann", "Third");
68 const face = async (n: number) => {
69 const flyer = await (await fetch(`${base}/listings/${n}/flyer`, { headers: as("ann") })).text();
70 return /class="flyer-face" src="[^"]*\/photos\/([0-9a-f]{32}\.png)"/.exec(flyer)?.[1];
71 };
72 expect(await face(reused)).toBeDefined();
73 expect(await face(reused)).toBe(await face(withFace));
74 });
75
76 test("when the headshot file is gone, the note goes and the launch does not reuse it", async () => {
77 const dir = join(dataDir, "photos");
78 for (const f of readdirSync(dir)) rmSync(join(dir, f));
79 expect(await home("ann")).not.toContain("Using your headshot");
80 const after = await launch("ann", "Fourth");
81 const flyer = await (await fetch(`${base}/listings/${after}/flyer`, { headers: as("ann") })).text();
82 expect(flyer).not.toMatch(/class="flyer-face" src="[^"]*\/photos\//);
83 });
src/routes.ts +3 −4
@@ -64,6 +64,7 @@ import {
6464 } from "./http.ts";
6565 import { events } from "./live.ts";
6666 import { rpc } from "./mcp.ts";
67 import { headshotFrom, lastListing } from "./agent.ts";
6768 import { draftOf, home, type Draft } from "./pages/home.ts";
6869 import { layout } from "./pages/layout.ts";
6970 import { factsPage } from "./pages/facts.ts";
@@ -424,10 +425,7 @@ export const routes: readonly Route[] = [
424425 const from = whole(c.url.searchParams.get("from"));
425426 const chosen =
426427 from === undefined ? undefined : getListing(c.db, c.who, { id: from });
427 const mine =
428 c.who.role === "anon" || !listings.ok
429 ? undefined
430 : listings.value.find((l) => l.author_id === (c.who as { id: string }).id);
428 const mine = lastListing(c.db, c.who);
431429 const draft: Draft =
432430 chosen?.ok === true
433431 ? withoutOpenHouses(draftOf(chosen.value, false))
@@ -440,6 +438,7 @@ export const routes: readonly Route[] = [
440438 listings: listings.ok ? listings.value : [],
441439 find: wanted,
442440 draft,
441 headshotFrom: headshotFrom(c.db, c.who),
443442 notice,
444443 }),
445444 200,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs