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

After an open house, get a 'Thanks for stopping by' email to approve, addressed to the visitors who signed in #79

Merged opened this

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

Merged

mason/listing-launch:mason/64-after-an-open-house-get-a-thanks-for-sto → main 4 commits fork 2 threads · 2 unresolved

  • Merged. mason/64-after-an-open-house-get-a-thanks-for-sto was merged into main as 8d67df6.

Review

Sign in to review this change.
16 files changed +556 −45
README.md +15 −2
@@ -10,7 +10,7 @@ to make a listing of your own.
1010 1. **Open the sample.** On the first screen, under "Launched", press the
1111 card for *412 Alder Lane, Portland*. Its page shows the price, the
1212 photos and the words, in tabs: MLS, Social, Flyer and Email. It opens
13 finished: all six assets approved (the pill reads "Ready to send"), each signed off by "Morgan Reyes
13 finished: all seven assets approved (the pill reads "Ready to send"), each signed off by "Morgan Reyes
1414 (sample)", so you see what a launch looks like at the end. (Birchwood
1515 Court stays at 0, and your own copy starts at 0.)
1616 2. **Read an asset and its check.** The MLS tab has the description in a box,
@@ -57,6 +57,18 @@ to make a listing of your own.
5757 times, your name and phone in your colour, and fifteen blank rows for name,
5858 email, phone and whether the visitor works with an agent. Print it; nothing
5959 typed on paper is stored. A listing with no open house answers 404 there.
60 A listing with an open house also gets an **Open house follow-up email**
61 (Email tab, "Preview follow-up" on the email page): a thanks in the
62 listing's tone with the facts, the property page link and your signature.
63 It is a draft until a person approves it (an assistant connected to the app
64 may draft and edit it, never approve it), has the Fair Housing check and
65 version history like every asset, is rewritten when the facts change, and
66 goes when the open house does. Below it, **Would go to** lists the people
67 from the sign-in sheet, which you type in from the paper (name and email,
68 escaped, at most 200). The list is personal data: only the listing's agent
69 or an admin can read or change it, through the listing's existing update
70 cell, so nobody gains a permission. The app sends nothing; copy the
71 email into your mail tool.
6072 9. **Mark it sold.** On your listing, pick **Sold** in the status box beside
6173 the tone, add the price it sold at if you want it said, and press **Set
6274 status**. Two more assets appear, a status post under Social and a status
@@ -294,6 +306,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
294306 | `src/fairhousing.ts` | the Fair Housing rules: phrase, context, class, reason, rewrite |
295307 | `src/photos.ts` | photos as files beside the database, known by their bytes |
296308 | `src/sample.ts` | the sample listing, planted once |
309 | `src/visitors.ts` | the open house sign-in sheet's visitors: the boundary for a name and an email |
297310 | `contract.json` | who may do what: roles, and the least role each verb needs |
298311 | `src/contract.ts`, `src/can.ts` | the table, read at boot; the forge's evaluator, verbatim |
299312 | `src/security.ts` | the door: rationing, same origin, the table before the body |
@@ -406,7 +419,7 @@ listing with `enter` to read every asset with its Fair Housing findings;
406419 `j`/`k` move between assets, `e` edits the one under the cursor (type `\n`
407420 for a line break, `enter` saves and checks it again), `t` rewrites them all
408421 in the next tone, `s` sets the status (`sold 752000`, `under contract`, `coming soon`,
409 `active`), `x` removes a listing of your own. `tab` switches to the notes. Launching a listing, with its photos, is the page's alone.
422 `active`), `p` previews the email as the client reads it, `P` the open house follow-up with the sign-in visitors it would go to, `V` lists those visitors (`a` adds one as `Name, email`, `d` removes the one under the cursor; only the listing's agent or an admin, a guest sees none), `x` removes a listing of your own. `tab` switches to the notes. Launching a listing, with its photos, is the page's alone.
410423
411424 No runtime dependencies: Bun's own `bun:sqlite`, `Bun.serve` and `node:`
412425 modules. The typefaces are the system's (a Palatino/Iowan serif for
contract/openapi.json +6 −3
@@ -648,7 +648,8 @@
648648 "flyer",
649649 "email",
650650 "email_status",
651 "email_price"
651 "email_price",
652 "email_followup"
652653 ]
653654 },
654655 "text": {
@@ -1336,7 +1337,8 @@
13361337 "flyer",
13371338 "email",
13381339 "email_status",
1339 "email_price"
1340 "email_price",
1341 "email_followup"
13401342 ]
13411343 },
13421344 "label": {
@@ -1679,7 +1681,8 @@
16791681 "flyer",
16801682 "email",
16811683 "email_status",
1682 "email_price"
1684 "email_price",
1685 "email_followup"
16831686 ]
16841687 },
16851688 "approved": {
src/actions.ts +60
@@ -47,6 +47,7 @@ import {
4747 write as writePhoto,
4848 } from "./photos.ts";
4949 import { headshotFrom } from "./agent.ts";
50 import { validVisitor, VISITORS_MAX, type Visitor } from "./visitors.ts";
5051 import type { Who } from "./who.ts";
5152
5253 export interface Note {
@@ -1038,6 +1039,8 @@ function changeFacts(
10381039 const was = v.price < now.price ? (now.was_price ?? now.price) : now.was_price !== null && v.price < now.was_price ? now.was_price : null;
10391040 db.query("UPDATE listing SET was_price = ?1 WHERE id = ?2").run(was, now.id);
10401041 touch(db, now.id);
1042 // The sign-in sheet belongs to the open house: with it gone, so are the names.
1043 if (v.open_houses.length === 0) db.query("DELETE FROM visitor WHERE listing_id = ?1").run(now.id);
10411044 writeAssets(db, { ...now, ...v, was_price: was }, KINDS, { who, why: "facts changed" });
10421045 })();
10431046 emit(db, "listing.changed", { id: now.id });
@@ -1213,6 +1216,63 @@ function removeListing(
12131216 return ok({ id: found.value.id });
12141217 }
12151218
1219 /*
1220 * Visitors. The people who signed the open house sheet, typed in by the
1221 * listing's agent. Personal data: they are read and changed only through the
1222 * listing's update door (its author or an admin), never with the listing, and
1223 * nothing here sends them anything.
1224 */
1225
1226 const visitorsFor = (db: Database, who: Who, id: number, signIn: string): Result<Launched> =>
1227 listingFor(db, who, "update", id, { signIn, no: "Only the listing's agent or an admin can see who signed in." });
1228
1229 export function listVisitors(db: Database, who: Who, input: { id: number }): Result<Visitor[]> {
1230 const found = visitorsFor(db, who, input.id, "Sign in to see who signed in.");
1231 if (!found.ok) return found;
1232 return ok(
1233 db
1234 .query<Visitor, [number]>("SELECT id, name, email FROM visitor WHERE listing_id = ?1 ORDER BY id")
1235 .all(found.value.id),
1236 );
1237 }
1238
1239 export const addVisitor = (
1240 db: Database,
1241 who: Who,
1242 input: { id: number; name: unknown; email: unknown },
1243 ): Result<{ id: number }> => audited(db, who, "listing", "update", input.id, putVisitor(db, who, input));
outdated

Visitor rows are never cleared when the open house is removed; delete them in updateFacts when open_houses becomes empty, with a test.

1244
1245 function putVisitor(db: Database, who: Who, input: { id: number; name: unknown; email: unknown }): Result<{ id: number }> {
1246 const found = visitorsFor(db, who, input.id, "Sign in to add a visitor.");
1247 if (!found.ok) return found;
1248 if (found.value.open_houses.length === 0) return fail("invalid", "This listing has no open house.");
1249 const valid = validVisitor(input);
1250 if (!valid.ok) return fail("invalid", valid.message);
1251 const have = db.query<{ n: number }, [number]>("SELECT count(*) AS n FROM visitor WHERE listing_id = ?1").get(found.value.id)!.n;
1252 if (have >= VISITORS_MAX) return fail("invalid", `At most ${VISITORS_MAX} visitors on one sheet.`);
1253 db.query("INSERT INTO visitor (listing_id, name, email) VALUES (?1, ?2, ?3)").run(found.value.id, valid.value.name, valid.value.email);
1254 emit(db, "listing.changed", { id: found.value.id });
1255 return ok({ id: found.value.id });
1256 }
1257
1258 export const removeVisitor = (
1259 db: Database,
1260 who: Who,
1261 input: { id: number; visitor: number },
1262 ): Result<{ id: number }> => audited(db, who, "listing", "update", input.id, dropVisitor(db, who, input));
1263
1264 function dropVisitor(db: Database, who: Who, input: { id: number; visitor: number }): Result<{ id: number }> {
1265 const found = visitorsFor(db, who, input.id, "Sign in to remove a visitor.");
1266 if (!found.ok) return found;
1267 const gone = db.query("DELETE FROM visitor WHERE id = ?1 AND listing_id = ?2").run(
1268 Number.isInteger(input.visitor) ? input.visitor : -1,
1269 found.value.id,
1270 );
1271 if (gone.changes === 0) return fail("not_found", "No such visitor.");
1272 emit(db, "listing.changed", { id: found.value.id });
1273 return ok({ id: found.value.id });
1274 }
1275
12161276 /** The Fair Housing check on any words: no row, no door, nothing kept. */
12171277 export const checkWords = (text: string): Check =>
12181278 check(text.slice(0, ASSET_MAX));
src/db.ts +10
@@ -167,6 +167,16 @@ export const migrations: string[] = [
167167 WHERE author_id = 'sample' AND photos = '["sample-condo-front.svg","sample-condo-living.svg"]'`,
168168 `UPDATE listing SET photo_alt = json_object('sample-house-front.svg', 'Brick front with a two-car garage', 'sample-house-yard.svg', 'Fenced yard with a mature oak')
169169 WHERE author_id = 'sample' AND photos = '["sample-house-front.svg","sample-house-yard.svg"]'`,
170 // Who signed the open house sheet, typed in by the listing's agent: personal data, read only through the
171 // listing's update door (its author or an admin), never with the listing. They go with their listing.
172 `CREATE TABLE IF NOT EXISTS visitor (
173 id INTEGER PRIMARY KEY,
174 listing_id INTEGER NOT NULL REFERENCES listing (id) ON DELETE CASCADE,
175 name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 80),
176 email TEXT NOT NULL CHECK (length(email) BETWEEN 3 AND 120),
177 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
178 );
179 CREATE INDEX IF NOT EXISTS visitor_listing ON visitor (listing_id, id)`,
170180 ];
171181
172182 export function openDb(dir = dataDir): Database {
src/followup.test.ts added +171
@@ -0,0 +1,171 @@
1 /**
2 * The open house follow-up: a thanks email in the listing's tone, drafted
3 * with the open house, unapproved until a person signs it off, gone with the
4 * open house, and shown beside the visitors from the sign-in sheet (author
5 * only, escaped). The app sends nothing.
6 */
7 import { expect, test } from "bun:test";
8 import { mkdtempSync } from "node:fs";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { addVisitor, approveAsset, getListing, launchListing, listVisitors, removeVisitor, updateFacts } from "./actions.ts";
12 import { openDb } from "./db.ts";
13 import { check } from "./fairhousing.ts";
14 import { generate, TONES } from "./generate.ts";
15 import { agentOf } from "./listing.ts";
16 import { rpc } from "./mcp.ts";
17 import { emailPage } from "./pages/email.ts";
18 import { SAMPLE } from "./sample.ts";
19 import type { Who } from "./who.ts";
20
21 const me: Who = { role: "user", id: "u1", name: "me" };
22 const other: Who = { role: "user", id: "u2", name: "other" };
23 const anon: Who = { role: "anon" };
24 const sunday = { date: "2027-03-07", start: "13:00", end: "15:00" };
25
26 const launch = (open_houses: unknown[] = [sunday]) => {
27 const db = openDb(mkdtempSync(join(tmpdir(), "app-followup-")));
28 const made = launchListing(db, me, { fields: { ...SAMPLE, open_houses } });
29 if (!made.ok) throw new Error(made.message);
30 return { db, id: made.value.id };
31 };
32 const read = (db: ReturnType<typeof openDb>, id: number) => {
33 const found = getListing(db, me, { id });
34 if (!found.ok) throw new Error(found.message);
35 return found.value;
36 };
37 const follow = (db: ReturnType<typeof openDb>, id: number) => read(db, id).assets.find((a) => a.kind === "email_followup");
38
39 test("the words: subject, thanks, the facts and a signature, clear, in every tone", () => {
40 const seen = new Set<string>();
41 for (const tone of TONES) {
42 const words = generate({ facts: { ...SAMPLE, tone, open_houses: [sunday] }, agent: agentOf(SAMPLE) }).email_followup!;
43 expect(words).toStartWith("Subject: Thank you for visiting 412 Alder Lane\n");
44 expect(words).toContain(SAMPLE.address);
45 expect(words).toContain("The facts:");
46 expect(words).toContain(SAMPLE.agent_name);
47 expect(check(words).clear).toBe(true);
48 seen.add(words);
49 }
50 expect(seen.size).toBe(3);
51 expect(generate({ facts: { ...SAMPLE, open_houses: [] }, agent: agentOf(SAMPLE) }).email_followup).toBeUndefined();
52 });
53
54 test("a listing with an open house gets it, unapproved; one without gets none", () => {
55 const { db, id } = launch();
56 expect(follow(db, id)!.approval).toBeNull();
57 const bare = launch([]);
58 expect(follow(bare.db, bare.id)).toBeUndefined();
59 });
60
61 test("removing the open house removes it; adding one brings it back; changed facts clear the approval", () => {
62 const { db, id } = launch();
63 const seen = follow(db, id)!.updated_at;
64 expect(approveAsset(db, me, { id, kind: "email_followup", approved: true, seen, door: "page" }).ok).toBe(true);
65 expect(follow(db, id)!.approval).not.toBeNull();
66 expect(updateFacts(db, me, { id, fields: { price: SAMPLE.price + 1000 } }).ok).toBe(true);
67 expect(follow(db, id)!.approval).toBeNull();
68 expect(updateFacts(db, me, { id, fields: { open_houses: [] } }).ok).toBe(true);
69 expect(follow(db, id)).toBeUndefined();
70 expect(updateFacts(db, me, { id, fields: { open_houses: [sunday] } }).ok).toBe(true);
71 expect(follow(db, id)).toBeDefined();
72 });
73
74 test("only a person approves it: the API and the MCP are refused", () => {
75 const { db, id } = launch();
76 const seen = follow(db, id)!.updated_at;
77 const api = approveAsset(db, me, { id, kind: "email_followup", approved: true, seen, door: "api" });
78 expect(!api.ok && api.error).toBe("person_required");
79 const res = rpc(db, me, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "approve_asset", arguments: { id, kind: "email_followup" } } });
80 expect(JSON.stringify(res.body)).toMatch(/person|isError|refus|cannot/i);
81 expect(follow(db, id)!.approval).toBeNull();
82 });
83
84 test("a hostile visitor name is stored as typed, shown escaped, and only to the author", () => {
85 const { db, id } = launch();
86 const name = `<img src=x onerror=alert(1)>"&'`;
87 expect(addVisitor(db, me, { id, name, email: "[email protected]" }).ok).toBe(true);
88 const listed = listVisitors(db, me, { id });
89 expect(listed.ok && listed.value.map((v) => v.name)).toEqual([name]);
90
91 const listing = read(db, id);
92 const page = emailPage({ who: me, listing, kind: "email_followup", visitors: listed.ok ? listed.value : [] });
93 expect(page).not.toContain("<img src=x");
94 expect(page).toContain("&lt;img src=x onerror=alert(1)&gt;");
95 expect(page).toContain("Would go to (1)");
96 // The blast page never lists them, and neither does a page handed no visitors.
97 expect(emailPage({ who: me, listing })).not.toContain("Would go to");
98 expect(emailPage({ who: anon, listing, kind: "email_followup" })).not.toContain("[email protected]");
99
100 for (const who of [anon, other]) {
101 expect(listVisitors(db, who, { id }).ok).toBe(false);
102 expect(addVisitor(db, who, { id, name: "x", email: "[email protected]" }).ok).toBe(false);
103 }
104 expect(JSON.stringify(read(db, id))).not.toContain("[email protected]");
105 });
106
107 test("visitors are validated, need an open house, and can be removed", () => {
108 const { db, id } = launch();
109 for (const bad of [
110 { name: "", email: "[email protected]" },
111 { name: "x".repeat(81), email: "[email protected]" },
112 { name: "Ann", email: "not an email" },
113 { name: "Ann", email: "[email protected]<script>" },
114 ]) {
115 const r = addVisitor(db, me, { id, ...bad });
116 expect([bad, !r.ok && r.error]).toEqual([bad, "invalid"]);
117 }
118 expect(addVisitor(db, me, { id, name: "Ann\nLee", email: "[email protected]" }).ok).toBe(true);
119 const [v] = (listVisitors(db, me, { id }) as { ok: true; value: { id: number; name: string }[] }).value;
120 expect(v!.name).toBe("Ann Lee");
121 expect(removeVisitor(db, other, { id, visitor: v!.id }).ok).toBe(false);
122 expect(removeVisitor(db, me, { id, visitor: v!.id }).ok).toBe(true);
123 const gone = removeVisitor(db, me, { id, visitor: v!.id });
124 expect(!gone.ok && gone.error).toBe("not_found");
125
126 const bare = launch([]);
127 const none = addVisitor(bare.db, me, { id: bare.id, name: "Ann", email: "[email protected]" });
128 expect(!none.ok && none.error).toBe("invalid");
129 });
130
131 test("the pages: the follow-up shows with its visitors to the author, a stranger sees none, and a cross-site add is refused", async () => {
132 const { serve } = await import("./server.ts");
133 const { sign } = await import("./who.ts");
134 const KEY = "a-key-only-the-forge-holds";
135 const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-followup-web-")), identityKey: KEY });
136 try {
137 const base = `http://${server.hostname}:${server.port}`;
138 const as = (name: string) => {
139 const identity = `${name}|${name}|user|${Date.now() + 600_000}`;
140 return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
141 };
142 const made = await fetch(`${base}/api/listings`, { method: "POST", headers: { ...as("pat"), "content-type": "application/json" }, body: JSON.stringify({ ...SAMPLE, open_houses: [sunday] }) });
143 const { id } = (await made.json()) as { id: number };
144 const form = new FormData();
145 form.set("name", "<b>Sam</b>");
146 form.set("email", "[email protected]");
147 const add = (headers: Record<string, string>) => fetch(`${base}/listings/${id}/visitors`, { method: "POST", headers, body: form, redirect: "manual" });
148 expect((await add({ ...as("pat"), origin: "https://evil.example" })).status).toBe(403);
149 expect((await add(as("pat"))).status).toBe(303);
150 expect((await add(as("eve"))).headers.get("location")).not.toContain("Added");
151
152 const url = `${base}/listings/${id}/email?which=followup`;
153 const mine = await (await fetch(url, { headers: as("pat") })).text();
154 expect(mine).toContain("Thank you for visiting");
155 expect(mine).toContain("&lt;b&gt;Sam&lt;/b&gt;");
156 expect(mine).not.toContain("<b>Sam</b>");
157 const theirs = await (await fetch(url, { headers: as("eve") })).text();
158 expect(theirs).toContain("Thank you for visiting");
159 expect(theirs).not.toContain("[email protected]");
160 expect(await (await fetch(url)).text()).not.toContain("[email protected]");
161 } finally {
162 server.stop(true);
163 }
164 });
165
166 test("the visitors go when the open house does", () => {
167 const { db, id } = launch();
168 expect(addVisitor(db, me, { id, name: "Ann", email: "[email protected]" }).ok).toBe(true);
169 expect(updateFacts(db, me, { id, fields: { open_houses: [] } }).ok).toBe(true);
170 expect(db.query("SELECT count(*) AS n FROM visitor WHERE listing_id = ?1").get(id)).toEqual({ n: 0 });
171 });
src/generate.ts +25 −1
@@ -27,6 +27,7 @@ export const KINDS = [
2727 "email",
2828 "email_status",
2929 "email_price",
30 "email_followup",
3031 ] as const;
3132 export type Kind = (typeof KINDS)[number];
3233
@@ -42,6 +43,7 @@ export const LABELS: Readonly<Record<Kind, string>> = {
4243 email_status: "Status email",
4344 social_price: "Price improvement post",
4445 email_price: "Price improvement email",
46 email_followup: "Open house follow-up email",
4547 };
4648
4749 /** The assets that exist only while a listing is coming soon, under contract, sold, or back on the market. */
@@ -489,7 +491,28 @@ function priceEmail(f: Facts, a: Agent): string {
489491 return `Subject: New price — ${where}, now ${now}, was ${was}\n\n${open}\n\nThe facts: ${plural(f.beds, "bedroom")}, ${plural(f.baths, "bath")}, ${count(f.sqft)} square feet, built in ${f.year}.\n\n${close}\n\n${a.name}\n${a.brokerage} · License ${a.license}\n${a.phone} · ${a.email}`;
490492 }
491493
492 /** A listing's assets: the six, the two status ones unless it is active, the two price ones when the price was lowered. */
494 /** The thanks sent after an open house; exists only while the listing has one. */
495 function followUpEmail(f: Facts, a: Agent): string {
496 const where = street(f.address);
497 const open = {
498 classic: `Thank you for visiting ${f.address} at the open house. It was a pleasure to show you the home.`,
499 warm: `Thank you so much for stopping by ${f.address}! It was lovely to meet you and show you around.`,
500 bold: `Thanks for coming to ${f.address}. Great to meet you.`,
501 }[f.tone];
502 const offered = {
503 classic: `The home is offered at ${money(f.price)}.`,
504 warm: `In case you would like to look again, it is offered at ${money(f.price)}.`,
505 bold: `It is offered at ${money(f.price)}.`,
506 }[f.tone];
507 const close = {
508 classic: "If you have questions, or would like a second look or the disclosure package, reply to this email or call me directly.",
509 warm: "If anything came to mind after you left, just reply to this email and I will be glad to help, or to set up a second visit.",
510 bold: "Want a second look? Reply now and I will get you on the schedule.",
511 }[f.tone];
512 return `Subject: Thank you for visiting ${where}\n\n${open} ${offered}\n\nThe facts: ${plural(f.beds, "bedroom")}, ${plural(f.baths, "bath")}, ${count(f.sqft)} square feet, built in ${f.year}.\n\n${close}\n\n${a.name}\n${a.brokerage} · License ${a.license}\n${a.phone} · ${a.email}`;
513 }
514
515 /** A listing's assets: the six, the two status ones unless it is active, the two price ones when the price was lowered, the follow-up email while there is an open house. */
493516 export function generate(listing: {
494517 readonly facts: Facts;
495518 readonly agent: Agent;
@@ -506,6 +529,7 @@ export function generate(listing: {
506529 email: email(f, a),
507530 ...(announced ? { social_status: statusPost(f, a), email_status: statusEmail(f, a) } : {}),
508531 ...(cut ? { social_price: pricePost(f, a), email_price: priceEmail(f, a) } : {}),
532 ...(openHouseLine(f) === null ? {} : { email_followup: followUpEmail(f, a) }),
509533 };
510534 }
511535
src/openhouse.test.ts +7 −3
@@ -103,7 +103,7 @@ test("adding, changing and clearing it writes the assets again and clears approv
103103 if (set.ok) expect(set.value.assets.every((a) => a.approval === null)).toBe(true);
104104
105105 // A change to something else keeps it.
106 expect(approve()).toBe(6);
106 expect(approve()).toBe(7);
107107 expect(updateFacts(db, me, { id, fields: { price: 650000 } })).toMatchObject({ ok: true, value: { open_houses: [sunday] } });
108108
109109 const moved = updateFacts(db, me, { id, fields: { open_houses: [{ ...sunday, date: "2027-03-14" }] } });
@@ -111,7 +111,7 @@ test("adding, changing and clearing it writes the assets again and clears approv
111111 expect(text("mls")).toContain("Sunday, March 14, 1–3 pm");
112112 expect(text("mls")).not.toContain("March 7");
113113
114 expect(approve()).toBe(8);
114 expect(approve()).toBe(9);
115115 const cleared = updateFacts(db, me, { id, fields: { open_houses: [] } });
116116 if (!cleared.ok) throw new Error(cleared.message);
117117 expect(cleared.value.open_houses).toEqual([]);
@@ -174,7 +174,11 @@ test("PATCH /api/listings/:id sets and clears it, and the API describes it", asy
174174 expect(launch.status).toBe(201);
175175 const made = (await launch.json()) as { id: number; open_houses: unknown[]; assets: { text: string }[] };
176176 expect(made.open_houses).toEqual([sunday]);
177 expect(made.assets.every((a) => a.text.includes("Sunday, March 7, 1–3 pm"))).toBe(true);
177 // The thanks speaks of the visit that happened, not of the time it was held: it names the property instead.
178 const [thanks, rest] = [made.assets.filter((a) => (a as { kind: string }).kind === "email_followup"), made.assets.filter((a) => (a as { kind: string }).kind !== "email_followup")];
179 expect(rest.filter((a) => a.text !== "").every((a) => a.text.includes("Sunday, March 7, 1–3 pm"))).toBe(true);
180 expect(thanks.length).toBe(1);
181 expect(thanks[0]!.text).toContain(SAMPLE.address);
178182 const patch = (facts: unknown) => fetch(`${base}/api/listings/${made.id}`, { method: "PATCH", headers, body: JSON.stringify({ facts }) });
179183 expect((await patch({ open_houses: [{ ...sunday, end: "12:00" }] })).status).toBe(422);
180184 const cleared = (await (await patch({ open_houses: [] })).json()) as { open_houses: unknown[]; assets: { text: string }[] };
src/pages/email.ts +44 −6
@@ -8,6 +8,7 @@
88 import { factItems, money } from "../generate.ts";
99 import { escape } from "../http.ts";
1010 import { altOf, propertyPath, type Launched } from "../listing.ts";
11 import type { Visitor } from "../visitors.ts";
1112 import type { Who } from "../who.ts";
1213 import { layout } from "./layout.ts";
1314 import { draftBand, photoUrl } from "./listing.ts";
@@ -31,14 +32,14 @@ export function emailParts(
3132 }
3233
3334 /** The message itself, one article: what is copied is what is shown. */
34 export function email(l: Launched, origin = ""): string {
35 export function email(l: Launched, origin = "", kind: "email" | "email_followup" = "email"): string {
3536 const { subject, paragraphs } = emailParts(
36 l.assets.find((a) => a.kind === "email")?.text ?? "",
37 l.assets.find((a) => a.kind === kind)?.text ?? "",
3738 l.agent_name,
3839 );
3940 const hero = l.photos[0];
4041 return `<article class="email palette-${escape(l.palette)}" data-email>
41 ${draftBand(l, "email")}
42 ${draftBand(l, kind)}
4243 <p class="email-subject"><span class="muted">Subject</span> <span data-subject>${escape(subject)}</span></p>
4344 <div class="email-card">
4445 ${hero === undefined ? "" : `<img class="email-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(altOf(l, hero, l.address))}" width="600" height="400">`}
@@ -60,17 +61,54 @@ export function email(l: Launched, origin = ""): string {
6061 </article>`;
6162 }
6263
63 export function emailPage(input: { who: Who; listing: Launched; origin?: string }): string {
64 /** Who the follow-up would go to, from the sign-in sheet; the app sends nothing. Only handed to the listing's author. */
65 function visitorList(l: Launched, visitors: readonly Visitor[]): string {
66 const rows = visitors
67 .map(
68 (v) => `<li class="row visitor">
69 <span><b>${escape(v.name)}</b> <span class="muted">${escape(v.email)}</span></span>
70 <form method="post" action="/listings/${l.id}/visitors/${v.id}/delete"><button class="button button-quiet" aria-label="Remove ${escape(v.name)} from the list">Remove</button></form>
71 </li>`,
72 )
73 .join("");
74 return `<section class="visitors" aria-labelledby="visitors-title">
75 <h3 id="visitors-title">Would go to (${visitors.length})</h3>
76 <p class="muted">The people from the sign-in sheet. This app sends nothing: copy the email and paste it into your mail tool.</p>
77 ${visitors.length === 0 ? `<p class="muted">Nobody yet. Add the names from the paper sheet.</p>` : `<ul class="visitor-list">${rows}</ul>`}
78 <form class="row" method="post" action="/listings/${l.id}/visitors">
79 <label class="sr" for="visitor-name">Name</label>
80 <input class="input" id="visitor-name" name="name" maxlength="80" required autocomplete="off" placeholder="Name">
81 <label class="sr" for="visitor-email">Email</label>
82 <input class="input" id="visitor-email" name="email" type="email" maxlength="120" required autocomplete="off" placeholder="Email">
83 <button class="button">Add visitor</button>
84 </form>
85 </section>`;
86 }
87
88 export function emailPage(input: {
89 who: Who;
90 listing: Launched;
91 origin?: string;
92 /** Which email: the blast, or the open house follow-up. */
93 kind?: "email" | "email_followup";
94 /** The sign-in sheet's visitors, for the follow-up; absent for anyone but the listing's author. */
95 visitors?: readonly Visitor[];
96 }): string {
6497 const { who, listing: l } = input;
98 const kind = input.kind === "email_followup" && l.assets.some((a) => a.kind === "email_followup") ? "email_followup" : "email";
99 const follow = l.assets.some((a) => a.kind === "email_followup");
100 const path = `/listings/${l.id}/email`;
65101 return layout(
66102 `<div class="print-bar row">
67103 <a href="/listings/${l.id}?tab=email">← Back to the listing</a>
104 ${follow ? (kind === "email" ? `<a href="${path}?which=followup">Open house follow-up</a>` : `<a href="${path}">Email blast</a>`) : ""}
68105 <button type="button" class="button" data-copy-email hidden>Copy as email</button>
69106 <button type="button" class="button button-quiet" data-copy-subject hidden>Copy subject</button>
70107 <span class="muted" data-copy-note role="status">Paste it into your mail tool; the photos load from this page.</span>
71108 </div>
72 ${email(l, input.origin)}`,
109 ${email(l, input.origin, kind)}
110 ${kind === "email_followup" && input.visitors !== undefined ? visitorList(l, input.visitors) : ""}`,
73111 who,
74 { path: `/listings/${l.id}/email`, heading: `Email: ${l.address}` },
112 { path: kind === "email" ? path : `${path}?which=followup`, heading: `${kind === "email" ? "Email" : "Follow-up email"}: ${l.address}` },
75113 );
76114 }
src/pages/listing.ts +3 −3
@@ -112,7 +112,7 @@ export const statusForm = (l: Listing): string =>
112112 </form>`;
113113
114114 /** The band an unapproved flyer, email or square picture (its first social post) wears, on screen and on paper; nothing once approved. */
115 export const draftBand = (l: Launched, kind: "flyer" | "email" | "social_short"): string =>
115 export const draftBand = (l: Launched, kind: "flyer" | "email" | "email_followup" | "social_short"): string =>
116116 l.assets.find((a) => a.kind === kind)?.approval == null
117117 ? `<p class="draft-band" role="note">Draft — not approved</p>`
118118 : "";
@@ -188,7 +188,7 @@ const TABS: readonly { id: string; label: string; kinds: readonly string[] }[] =
188188 { id: "mls", label: "MLS", kinds: ["mls"] },
189189 { id: "social", label: "Social", kinds: ["social_short", "social_medium", "social_carousel", "social_status", "social_price"] },
190190 { id: "flyer", label: "Flyer", kinds: ["flyer"] },
191 { id: "email", label: "Email", kinds: ["email", "email_status", "email_price"] },
191 { id: "email", label: "Email", kinds: ["email", "email_status", "email_price", "email_followup"] },
192192 ];
193193
194194 /** The tab that holds an asset, or a tab named outright; the first otherwise. */
@@ -263,7 +263,7 @@ export function listingPage(input: {
263263 (t) => `<div class="tabpanel" role="tabpanel" id="panel-${t.id}" aria-labelledby="tab-${t.id}"${t.id === open ? "" : " hidden"}>
264264 ${t.id === "flyer" ? `<div class="flyer-preview"><a href="/listings/${l.id}/flyer" aria-label="Open the printable flyer">${flyer(l, origin)}</a></div>` : ""}
265265 ${t.id === "social" ? `<p><a class="button button-quiet" href="/listings/${l.id}/card">Square picture</a></p>` : ""}
266 ${t.id === "email" ? `<p><a class="button button-quiet" href="/listings/${l.id}/email">Preview email</a></p>` : ""}
266 ${t.id === "email" ? `<p><a class="button button-quiet" href="/listings/${l.id}/email">Preview email</a>${l.assets.some((a) => a.kind === "email_followup") ? ` <a class="button button-quiet" href="/listings/${l.id}/email?which=followup">Preview follow-up</a>` : ""}</p>` : ""}
267267 ${l.assets
268268 .filter((a) => t.kinds.includes(a.kind))
269269 .map((a) => editor(l, a, editable, input.versions?.[a.kind] ?? []))
src/report.test.ts +3 −3
@@ -20,15 +20,15 @@ const call = (path: string, who?: string, init: RequestInit = {}) =>
2020
2121 const rows = (html: string) => [...html.matchAll(/<li class="report-row[^"]*">/g)].length;
2222
23 test("the Alder Lane sample reports six approved rows by its sample agent", async () => {
23 test("the Alder Lane sample reports seven approved rows by its sample agent", async () => {
2424 const ids = [...(await (await call("/", "pat")).text()).matchAll(/href="\/listings\/(\d+)"/g)].map((m) => m[1]);
2525 let id = "";
2626 for (const i of new Set(ids)) if ((await (await call(`/listings/${i}/report`)).text()).includes("412 Alder Lane")) id = i;
2727 const r = await call(`/listings/${id}/report`);
2828 expect(r.status).toBe(200);
2929 const html = await r.text();
30 expect(rows(html)).toBe(6);
31 expect(html.match(/Approved by [^<]*\(sample\) on \d{4}-\d{2}-\d{2}/g)?.length).toBe(6);
30 expect(rows(html)).toBe(7);
31 expect(html.match(/Approved by [^<]*\(sample\) on \d{4}-\d{2}-\d{2}/g)?.length).toBe(7);
3232 expect(html).not.toContain("Draft — not approved yet");
3333 expect(html).toContain(`/homes/${id}`);
3434 expect(html).toContain("412 Alder Lane");
src/routes.ts +37 −4
@@ -24,7 +24,10 @@ import {
2424 editAsset,
2525 getAudit,
2626 getHome,
27 addVisitor,
2728 getListing,
29 listVisitors,
30 removeVisitor,
2831 getNote,
2932 launchListing,
3033 mayLaunch,
@@ -653,10 +656,40 @@ export const routes: readonly Route[] = [
653656 {
654657 method: "GET",
655658 path: "/listings/:id/email",
656 handle: (c) =>
657 page(c.who, getListing(c.db, c.who, { id: Number(c.params.id) }), (listing) =>
658 emailPage({ who: c.who, listing, origin: publicOrigin }),
659 ),
659 handle: (c) => {
660 const id = Number(c.params.id);
661 const follow = c.url.searchParams.get("which") === "followup";
662 const visitors = follow ? listVisitors(c.db, c.who, { id }) : undefined;
663 return page(c.who, getListing(c.db, c.who, { id }), (listing) =>
664 emailPage({
665 who: c.who,
666 listing,
667 origin: publicOrigin,
668 kind: follow ? "email_followup" : "email",
669 ...(visitors?.ok ? { visitors: visitors.value } : {}),
670 }),
671 );
672 },
673 },
674 {
675 method: "POST",
676 path: "/listings/:id/visitors",
677 handle: async (c) => {
678 const form = await readForm(c.request);
679 if (form === undefined) return text("too large", 413);
680 const id = Number(c.params.id);
681 const done = addVisitor(c.db, c.who, { id, name: form.get("name"), email: form.get("email") });
682 return back(done.ok ? "Added." : done.message, `/listings/${id}/email?which=followup`);
683 },
684 },
685 {
686 method: "POST",
687 path: "/listings/:id/visitors/:visitor/delete",
688 handle: (c) => {
689 const id = Number(c.params.id);
690 const done = removeVisitor(c.db, c.who, { id, visitor: Number(c.params.visitor) });

The page has add/remove visitors, but src/tty.ts has no counterpart (and its p preview only knows the blast email). Add the terminal door with the same listVisitors/addVisitor/removeVisitor gate, and tests.

691 return back(done.ok ? "Removed." : done.message, `/listings/${id}/email?which=followup`);
692 },
660693 },
661694 {
662695 method: "GET",
src/sample.test.ts +2 −2
@@ -58,11 +58,11 @@ const approvedOf = (db: ReturnType<typeof fresh>, like: string) => {
5858 return r.value.assets.filter((a) => a.approval !== null);
5959 };
6060
61 test("Alder Lane opens with all six assets approved by its sample agent; Birchwood has none", () => {
61 test("Alder Lane opens with all seven assets approved by its sample agent; Birchwood has none", () => {
6262 const db = fresh();
6363 seed(db);
6464 const alder = approvedOf(db, "412 Alder%");
65 expect(alder).toHaveLength(6);
65 expect(alder).toHaveLength(7);
6666 for (const a of alder) expect(a.approval).toMatchObject({ by_name: "Morgan Reyes (sample)", via: "sample" });
6767 expect(approvedOf(db, "27 Birchwood%")).toHaveLength(0);
6868 expect(approvedOf(db, "88 Meridian%")).toHaveLength(0);
src/tty.test.ts +59
@@ -329,3 +329,62 @@ test("the terminal's help line names the launch week", () => {
329329 tty.press("\r");
330330 expect(plain(tty.screen())).toContain("w launch week");
331331 });
332
333 test("P previews the follow-up with its visitors; V lists, adds and removes them, for the agent only, and the page hears it", async () => {
334 const { launchListing, listVisitors, updateFacts } = await import("./actions.ts");
335 const db = openDb(mkdtempSync(join(tmpdir(), "app-tty-visitors-")));
336 const made = launchListing(db, me, { fields: { ...SAMPLE, open_houses: [{ date: "2027-03-07", start: "13:00", end: "15:00" }] } });
337 if (!made.ok) throw new Error(made.message);
338 const id = made.value.id;
339 const changes = (): number => db.query<{ n: number }, []>("SELECT count(*) AS n FROM event WHERE type = 'listing.changed'").get()!.n;
340 const before = changes();
341 {
342 const tty = session(db, me);
343 tty.press("\r");
344 tty.press("P");
345 expect(plain(tty.screen())).toContain("DRAFT — not approved");
346 expect(plain(tty.screen())).toContain("Subject: Thank you for visiting");
347 expect(plain(tty.screen())).toContain("Would go to (0)");
348 tty.press("\x1b");
349 tty.press("V");
350 expect(plain(tty.screen())).toContain("Nobody yet.");
351 tty.press("a");
352 for (const key of "Sam Lee, [email protected]") tty.press(key);
353 tty.press("\r");
354 expect(plain(tty.screen())).toContain("Added.");
355 tty.press(" ");
356 expect(plain(tty.screen())).toContain("Sam Lee [email protected]");
357 tty.press("a");
358 for (const key of "Nobody, not-an-email") tty.press(key);
359 tty.press("\r");
360 expect(plain(tty.screen())).toContain("looks like [email protected]");
361 tty.press(" ");
362 tty.press("\x1b");
363 tty.press("P");
364 expect(plain(tty.screen())).toContain("Would go to (1)");
365 tty.press("\x1b");
366 tty.press("V");
367 tty.press("d");
368 expect(plain(tty.screen())).toContain("Removed.");
369 const left = listVisitors(db, me, { id });
370 expect(left.ok && left.value).toEqual([]);
371 // Each write reached the event table the page follows.
372 expect(changes() - before).toBe(2);
373
374 // Someone else, and a guest, see none and add none.
375 for (const who of [other, { role: "anon" } as Who]) {
376 const t = session(db, who);
377 t.press("\r");
378 t.press("V");
379 expect(plain(t.screen())).not.toContain("Would go to");
380 t.press("a");
381 expect(plain(t.screen())).not.toContain("Visitor: name, email");
382 }
383
384 // The open house goes: the names go with it.
385 const added = (await import("./actions.ts")).addVisitor(db, me, { id, name: "Ann", email: "[email protected]" });
386 expect(added.ok).toBe(true);
387 expect(updateFacts(db, me, { id, fields: { open_houses: [] } }).ok).toBe(true);
388 expect(db.query("SELECT count(*) AS n FROM visitor WHERE listing_id = ?1").get(id)).toEqual({ n: 0 });
389 }
390 });
src/tty.ts +80 −17
@@ -8,7 +8,10 @@
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, v puts back its
1010 * earlier words, esc goes back; x
11 * removes a listing; p previews the email as the client will read it; k shows
11 * removes a listing; p previews the email as the client will read it; P previews the open house
12 * follow-up the same way, with the sign-in visitors it would go to; V lists
13 * those visitors (the listing's agent or an admin; a guest sees none): a adds
14 * one as `Name, email`, d removes the one under the cursor, esc goes back; k shows
1215 * the launch kit, the file the page offers to download; R shows the seller
1316 * report, approved words only, as the page prints it; w shows the launch week,
1417 * which asset goes out on which day, a draft marked and never as going out.
@@ -23,6 +26,7 @@ import type { Database } from "bun:sqlite";
2326 import { FILTERS, parseFind, type Find } from "./find.ts";
2427 import {
2528 addNote,
29 addVisitor,
2630 approveAsset,
2731 canDelete,
2832 canEditListing,
@@ -33,7 +37,9 @@ import {
3337 getListing,
3438 listListings,
3539 listNotes,
40 listVisitors,
3641 listVersions,
42 removeVisitor,
3743 restoreAsset,
3844 retoneListing,
3945 setStatus,
@@ -95,8 +101,11 @@ export const fromLine = (line: string): string => line.replace(/\\n/g, "\n");
95101 export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }) {
96102 let view: "listings" | "notes" = "listings";
97103 let reading: number | undefined;
98 /** The email blast, laid out as the page's preview is, instead of the assets. */
99 let previewing = false;
104 /** The email blast or the open house follow-up, laid out as the page's preview is, instead of the assets. */
105 let previewing: "email" | "email_followup" | undefined;
106 /** The sign-in visitors of the open house, the cursor on one. */
107 let visiting = false;
108 let visitorCursor = 0;
100109 /** The launch kit, as the file would read. */
101110 let kitting = false;
102111 /** The seller report, as the page prints it. */
@@ -107,7 +116,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
107116 let versioning: Kind | undefined;
108117 let cursor = 0;
109118 /** What the one-line input is for: a new note, or an asset's new words. */
110 let typing: { for: "note" } | { for: "asset"; kind: Kind } | { for: "open" } | { for: "status" } | { for: "price" } | { for: "search" } | undefined;
119 let typing: { for: "note" } | { for: "asset"; kind: Kind } | { for: "open" } | { for: "status" } | { for: "price" } | { for: "search" } | { for: "visitor" } | undefined;
111120 let typed = "";
112121 /** The filter and the words the listings view is narrowed to. */
113122 let found: Find = { status: "all", q: "" };
@@ -128,6 +137,11 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
128137 return found === undefined ? undefined : scrub(found);
129138 };
130139
140 const visitors = (id: number) => {
141 const listed = listVisitors(db, me, { id });
142 return listed.ok ? { ok: true as const, value: scrub(listed.value) } : listed;
143 };
144
131145 function readLines(): string[] {
132146 const found = open();
133147 if (found === undefined) return [];
@@ -144,9 +158,19 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
144158 ...(list.length === 0 ? [dim("None yet.")] : list.map((v, i) => `${i + 1}) ${dim(`${v.at.slice(0, 16).replace("T", " ")} · ${scrub(v.by_name)} · ${v.why}`)} ${scrub(v.text.split("\n")[0] ?? "").slice(0, 60)}`)),
145159 ];
146160 }
161 if (visiting) {
162 const sheet = visitors(l.id);
163 if (!sheet.ok) return [sheet.message];
164 return [
165 bold(`Would go to (${sheet.value.length}): ${l.address}`), "",
166 ...(sheet.value.length === 0 ? [dim("Nobody yet. Press a to add the names from the paper sheet.")] : sheet.value.map((v, i) => `${i === visitorCursor ? "▸" : " "} ${i === visitorCursor ? bold(v.name) : v.name} ${dim(v.email)}`)),
167 ];
168 }
147169 if (previewing) {
148 const { subject, paragraphs } = emailParts(l.assets.find((a) => a.kind === "email")?.text ?? "", l.agent_name);
149 const approved = l.assets.find((a) => a.kind === "email")?.approval != null;
170 const kind = previewing;
171 const { subject, paragraphs } = emailParts(l.assets.find((a) => a.kind === kind)?.text ?? "", l.agent_name);
172 const approved = l.assets.find((a) => a.kind === kind)?.approval != null;
173 const sheet = kind === "email_followup" ? visitors(l.id) : undefined;
150174 return [
151175 ...(approved ? [] : [amber("DRAFT — not approved"), ""]),
152176 bold(`Subject: ${subject}`), "",
@@ -155,6 +179,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
155179 ...paragraphs.flatMap((p) => [...p.split("\n"), ""]),
156180 bold(l.agent_name), `${l.brokerage} · License ${l.license}`, `${l.agent_phone} · ${l.agent_email}`, "",
157181 dim("Equal Housing Opportunity."),
182 ...(sheet?.ok === true ? ["", bold(`Would go to (${sheet.value.length})`), ...sheet.value.map((v) => `${v.name} ${dim(v.email)}`)] : []),
158183 ];
159184 }
160185 const signed = l.assets.filter((a) => a.approval !== null).length;
@@ -182,12 +207,13 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
182207 if (reading !== undefined) {
183208 lines.push(...readLines());
184209 const found = open();
185 if (previewing || kitting || reporting || planning) help = "esc back to the assets · q quit";
210 if (visiting) help = "j/k move · a add (Name, email) · d remove · esc back to the assets · q quit";
211 else if (previewing || kitting || reporting || planning) help = "esc back to the assets · q quit";
186212 else if (versioning !== undefined) help = "1-5 put that version back · esc back to the assets · q quit";
187213 else help =
188214 found?.ok === true && canEditListing(me, found.value)
189 ? "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 · k launch kit · R seller report · w launch week · esc back · q quit"
190 : "j/↓↑ asset · p preview email · k launch kit · R seller report · w launch week · esc back · q quit";
215 ? "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 · V visitors · k launch kit · R seller report · w launch week · esc back · q quit"
216 : "j/↓↑ asset · p preview email · P preview follow-up · k launch kit · R seller report · w launch week · esc back · q quit";
191217 } else if (view === "listings") {
192218 const rows = listings();
193219 lines.push(dim(`Showing ${found.status.replace(/[-_]/g, " ")}${found.q === "" ? "" : ` matching "${found.q}"`}`));
@@ -220,7 +246,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
220246 }
221247 lines.push("");
222248 if (typing !== undefined) {
223 const label = typing.for === "note" ? "New note" : typing.for === "search" ? "Search by address (enter searches, empty clears, esc drops)" : typing.for === "price" ? "Price in whole dollars, like 849000; a lower price adds a price improvement post and email (enter saves, esc drops)" : typing.for === "status" ? "Status: coming soon, active, under contract, or sold with an optional price, like sold 752000 (enter saves, esc drops)" : typing.for === "open" ? "Open house: date start-end, like 2027-03-09 13:00-15:00; separate two with ; (empty clears, enter saves, esc drops)" : `Edit (\\n breaks a line, enter saves, esc drops)`;
249 const label = typing.for === "note" ? "New note" : typing.for === "visitor" ? "Visitor: name, email (enter adds, esc drops)" : typing.for === "search" ? "Search by address (enter searches, empty clears, esc drops)" : typing.for === "price" ? "Price in whole dollars, like 849000; a lower price adds a price improvement post and email (enter saves, esc drops)" : typing.for === "status" ? "Status: coming soon, active, under contract, or sold with an optional price, like sold 752000 (enter saves, esc drops)" : typing.for === "open" ? "Open house: date start-end, like 2027-03-09 13:00-15:00; separate two with ; (empty clears, enter saves, esc drops)" : `Edit (\\n breaks a line, enter saves, esc drops)`;
224250 // The tail of what is typed, so the cursor stays on the screen.
225251 const room = Math.max(20, size.columns - 4);
226252 lines.push(dim(label), `${typed.length > room ? `…${typed.slice(-room + 1)}` : typed}${esc}7m ${esc}0m`);
@@ -236,6 +262,10 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
236262 const added = addNote(db, me, { body: typed });
237263 if (!added.ok) said = added.message;
238264 cursor = 0;
265 } else if (typing.for === "visitor") {
266 const at = typed.lastIndexOf(",");
267 const done = addVisitor(db, me, { id: reading!, name: at < 0 ? typed : typed.slice(0, at), email: at < 0 ? "" : typed.slice(at + 1) });
268 said = done.ok ? "Added." : done.message;
239269 } else if (typing.for === "search") {
240270 found = parseFind(found.status, typed);
241271 cursor = 0;
@@ -285,19 +315,50 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
285315 if (reading !== undefined) {
286316 const found = open();
287317 const assets = found?.ok === true ? found.value.assets : [];
288 if (key === "p" && !previewing && found?.ok === true) {
289 previewing = true;
318 const idle = !kitting && !reporting && !planning && !previewing && !visiting;
319 if ((key === "p" || key === "P") && idle && found?.ok === true) {
320 if (key === "P" && !assets.some((a) => a.kind === "email_followup")) said = "This listing has no open house follow-up.";
321 else previewing = key === "p" ? "email" : "email_followup";
290322 return undefined;
291323 }
292 if (key === "R" && !kitting && !reporting && !planning && !previewing && found?.ok === true) {
324 if (key === "V" && idle && found?.ok === true) {
325 visiting = true;
326 visitorCursor = 0;
327 return undefined;
328 }
329 if (visiting && found?.ok === true && key !== "\x1b") {
330 const sheet = visitors(found.value.id);
331 const rows = sheet.ok ? sheet.value : [];
332 if (down) visitorCursor = Math.min(visitorCursor + 1, Math.max(0, rows.length - 1));
333 if (up) visitorCursor = Math.max(visitorCursor - 1, 0);
334 if (key === "a") {
335 if (!sheet.ok) said = sheet.message;
336 else {
337 typing = { for: "visitor" };
338 typed = "";
339 }
340 }
341 if (key === "d") {
342 const row = rows[visitorCursor];
343 if (!sheet.ok) said = sheet.message;
344 else if (row === undefined) said = "Nothing to remove.";
345 else {
346 const done = removeVisitor(db, me, { id: found.value.id, visitor: row.id });
347 said = done.ok ? "Removed." : done.message;
348 visitorCursor = Math.min(visitorCursor, Math.max(0, rows.length - 2));
349 }
350 }
351 return undefined;
352 }
353 if (key === "R" && !kitting && !reporting && !planning && !previewing && !visiting && found?.ok === true) {
293354 reporting = true;
294355 return undefined;
295356 }
296 if (key === "w" && !kitting && !reporting && !planning && !previewing && found?.ok === true) {
357 if (key === "w" && !kitting && !reporting && !planning && !previewing && !visiting && found?.ok === true) {
297358 planning = true;
298359 return undefined;
299360 }
300 if (key === "k" && !kitting && !reporting && !planning && !previewing && found?.ok === true) {
361 if (key === "k" && !kitting && !reporting && !planning && !previewing && !visiting && found?.ok === true) {
301362 kitting = true;
302363 return undefined;
303364 }
@@ -317,8 +378,10 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
317378 return undefined;
318379 }
319380 if (key === "\x1b") {
320 if (previewing || kitting || reporting || planning) previewing = kitting = reporting = planning = false;
321 else {
381 if (previewing || kitting || reporting || planning || visiting) {
382 previewing = undefined;
383 kitting = reporting = planning = visiting = false;
384 } else {
322385 reading = undefined;
323386 cursor = 0;
324387 }
src/visitors.ts added +33
@@ -0,0 +1,33 @@
1 /**
2 * The open house sign-in sheet's visitors: a name and an email, as the agent
3 * types them in from the paper. Plain data in, plain data out; the boundary
4 * is `validVisitor`, the rows live in `visitor` (src/db.ts).
5 */
6
7 export const VISITORS_MAX = 200;
8 export const VISITOR_NAME_MAX = 80;
9 export const VISITOR_EMAIL_MAX = 120;
10
11 export interface Visitor {
12 readonly id: number;
13 readonly name: string;
14 readonly email: string;
15 }
16
17 type Checked = { ok: true; value: { name: string; email: string } } | { ok: false; message: string };
18
19 const oneLine = (v: unknown): string =>
20 String(v ?? "")
21 .replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ")
22 .replace(/\s+/g, " ")
23 .trim();
24
25 export function validVisitor(raw: { name?: unknown; email?: unknown }): Checked {
26 const name = oneLine(raw.name);
27 const email = oneLine(raw.email);
28 if (name === "" || name.length > VISITOR_NAME_MAX)
29 return { ok: false, message: `A visitor's name is 1 to ${VISITOR_NAME_MAX} characters.` };
30 if (email.length > VISITOR_EMAIL_MAX || !/^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/.test(email))
31 return { ok: false, message: "A visitor's email looks like [email protected]." };
32 return { ok: true, value: { name, email } };
33 }
src/walkthrough.test.ts +1 −1
@@ -216,7 +216,7 @@ test("every step of the section works, with the labels it names", async () => {
216216 hold.set("open1_end", "15:00");
217217 expect((await post(`${mine}/facts`, hold)).status).toBe(303);
218218 const afterOpen = await (await get(mine)).text();
219 expect(afterOpen).toContain("0 of 8 approved");
219 expect(afterOpen).toContain("0 of 9 approved");
220220 expect(await (await get(`${mine}/flyer`)).text()).toContain("OPEN HOUSE: Sunday, March 7, 1–3 pm.");
221221 expect(await (await get(`${mine}/email`)).text()).toContain("OPEN HOUSE: Sunday, March 7, 1–3 pm.");
222222 const bad = new FormData();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs