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
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/64-after-an-open-house-get-a-thanks-for-sto → main 4 commits fork 2 threads · 2 unresolved
16 files
changed +556
−45
README.md +15 −2
| @@ -10,7 +10,7 @@ to make a listing of your own. | ||
| 10 | 10 | 1. **Open the sample.** On the first screen, under "Launched", press the |
| 11 | 11 | card for *412 Alder Lane, Portland*. Its page shows the price, the |
| 12 | 12 | 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 | |
| 14 | 14 | (sample)", so you see what a launch looks like at the end. (Birchwood |
| 15 | 15 | Court stays at 0, and your own copy starts at 0.) |
| 16 | 16 | 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. | ||
| 57 | 57 | times, your name and phone in your colour, and fifteen blank rows for name, |
| 58 | 58 | email, phone and whether the visitor works with an agent. Print it; nothing |
| 59 | 59 | 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. | |
| 60 | 72 | 9. **Mark it sold.** On your listing, pick **Sold** in the status box beside |
| 61 | 73 | the tone, add the price it sold at if you want it said, and press **Set |
| 62 | 74 | 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. | ||
| 294 | 306 | | `src/fairhousing.ts` | the Fair Housing rules: phrase, context, class, reason, rewrite | |
| 295 | 307 | | `src/photos.ts` | photos as files beside the database, known by their bytes | |
| 296 | 308 | | `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 | | |
| 297 | 310 | | `contract.json` | who may do what: roles, and the least role each verb needs | |
| 298 | 311 | | `src/contract.ts`, `src/can.ts` | the table, read at boot; the forge's evaluator, verbatim | |
| 299 | 312 | | `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; | ||
| 406 | 419 | `j`/`k` move between assets, `e` edits the one under the cursor (type `\n` |
| 407 | 420 | for a line break, `enter` saves and checks it again), `t` rewrites them all |
| 408 | 421 | 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. | |
| 410 | 423 | |
| 411 | 424 | No runtime dependencies: Bun's own `bun:sqlite`, `Bun.serve` and `node:` |
| 412 | 425 | modules. The typefaces are the system's (a Palatino/Iowan serif for |
contract/openapi.json +6 −3
| @@ -648,7 +648,8 @@ | ||
| 648 | 648 | "flyer", |
| 649 | 649 | "email", |
| 650 | 650 | "email_status", |
| 651 | "email_price" | |
| 651 | "email_price", | |
| 652 | "email_followup" | |
| 652 | 653 | ] |
| 653 | 654 | }, |
| 654 | 655 | "text": { |
| @@ -1336,7 +1337,8 @@ | ||
| 1336 | 1337 | "flyer", |
| 1337 | 1338 | "email", |
| 1338 | 1339 | "email_status", |
| 1339 | "email_price" | |
| 1340 | "email_price", | |
| 1341 | "email_followup" | |
| 1340 | 1342 | ] |
| 1341 | 1343 | }, |
| 1342 | 1344 | "label": { |
| @@ -1679,7 +1681,8 @@ | ||
| 1679 | 1681 | "flyer", |
| 1680 | 1682 | "email", |
| 1681 | 1683 | "email_status", |
| 1682 | "email_price" | |
| 1684 | "email_price", | |
| 1685 | "email_followup" | |
| 1683 | 1686 | ] |
| 1684 | 1687 | }, |
| 1685 | 1688 | "approved": { |
src/actions.ts +60
| @@ -47,6 +47,7 @@ import { | ||
| 47 | 47 | write as writePhoto, |
| 48 | 48 | } from "./photos.ts"; |
| 49 | 49 | import { headshotFrom } from "./agent.ts"; |
| 50 | import { validVisitor, VISITORS_MAX, type Visitor } from "./visitors.ts"; | |
| 50 | 51 | import type { Who } from "./who.ts"; |
| 51 | 52 | |
| 52 | 53 | export interface Note { |
| @@ -1038,6 +1039,8 @@ function changeFacts( | ||
| 1038 | 1039 | const was = v.price < now.price ? (now.was_price ?? now.price) : now.was_price !== null && v.price < now.was_price ? now.was_price : null; |
| 1039 | 1040 | db.query("UPDATE listing SET was_price = ?1 WHERE id = ?2").run(was, now.id); |
| 1040 | 1041 | 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); | |
| 1041 | 1044 | writeAssets(db, { ...now, ...v, was_price: was }, KINDS, { who, why: "facts changed" }); |
| 1042 | 1045 | })(); |
| 1043 | 1046 | emit(db, "listing.changed", { id: now.id }); |
| @@ -1213,6 +1216,63 @@ function removeListing( | ||
| 1213 | 1216 | return ok({ id: found.value.id }); |
| 1214 | 1217 | } |
| 1215 | 1218 | |
| 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)); | |
| 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 | ||
| 1216 | 1276 | /** The Fair Housing check on any words: no row, no door, nothing kept. */ |
| 1217 | 1277 | export const checkWords = (text: string): Check => |
| 1218 | 1278 | check(text.slice(0, ASSET_MAX)); |
src/db.ts +10
| @@ -167,6 +167,16 @@ export const migrations: string[] = [ | ||
| 167 | 167 | WHERE author_id = 'sample' AND photos = '["sample-condo-front.svg","sample-condo-living.svg"]'`, |
| 168 | 168 | `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') |
| 169 | 169 | 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)`, | |
| 170 | 180 | ]; |
| 171 | 181 | |
| 172 | 182 | 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("<img src=x onerror=alert(1)>"); | |
| 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("<b>Sam</b>"); | |
| 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 = [ | ||
| 27 | 27 | "email", |
| 28 | 28 | "email_status", |
| 29 | 29 | "email_price", |
| 30 | "email_followup", | |
| 30 | 31 | ] as const; |
| 31 | 32 | export type Kind = (typeof KINDS)[number]; |
| 32 | 33 | |
| @@ -42,6 +43,7 @@ export const LABELS: Readonly<Record<Kind, string>> = { | ||
| 42 | 43 | email_status: "Status email", |
| 43 | 44 | social_price: "Price improvement post", |
| 44 | 45 | email_price: "Price improvement email", |
| 46 | email_followup: "Open house follow-up email", | |
| 45 | 47 | }; |
| 46 | 48 | |
| 47 | 49 | /** 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 { | ||
| 489 | 491 | 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}`; |
| 490 | 492 | } |
| 491 | 493 | |
| 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. */ | |
| 493 | 516 | export function generate(listing: { |
| 494 | 517 | readonly facts: Facts; |
| 495 | 518 | readonly agent: Agent; |
| @@ -506,6 +529,7 @@ export function generate(listing: { | ||
| 506 | 529 | email: email(f, a), |
| 507 | 530 | ...(announced ? { social_status: statusPost(f, a), email_status: statusEmail(f, a) } : {}), |
| 508 | 531 | ...(cut ? { social_price: pricePost(f, a), email_price: priceEmail(f, a) } : {}), |
| 532 | ...(openHouseLine(f) === null ? {} : { email_followup: followUpEmail(f, a) }), | |
| 509 | 533 | }; |
| 510 | 534 | } |
| 511 | 535 |
src/openhouse.test.ts +7 −3
| @@ -103,7 +103,7 @@ test("adding, changing and clearing it writes the assets again and clears approv | ||
| 103 | 103 | if (set.ok) expect(set.value.assets.every((a) => a.approval === null)).toBe(true); |
| 104 | 104 | |
| 105 | 105 | // A change to something else keeps it. |
| 106 | expect(approve()).toBe(6); | |
| 106 | expect(approve()).toBe(7); | |
| 107 | 107 | expect(updateFacts(db, me, { id, fields: { price: 650000 } })).toMatchObject({ ok: true, value: { open_houses: [sunday] } }); |
| 108 | 108 | |
| 109 | 109 | 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 | ||
| 111 | 111 | expect(text("mls")).toContain("Sunday, March 14, 1–3 pm"); |
| 112 | 112 | expect(text("mls")).not.toContain("March 7"); |
| 113 | 113 | |
| 114 | expect(approve()).toBe(8); | |
| 114 | expect(approve()).toBe(9); | |
| 115 | 115 | const cleared = updateFacts(db, me, { id, fields: { open_houses: [] } }); |
| 116 | 116 | if (!cleared.ok) throw new Error(cleared.message); |
| 117 | 117 | 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 | ||
| 174 | 174 | expect(launch.status).toBe(201); |
| 175 | 175 | const made = (await launch.json()) as { id: number; open_houses: unknown[]; assets: { text: string }[] }; |
| 176 | 176 | 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); | |
| 178 | 182 | const patch = (facts: unknown) => fetch(`${base}/api/listings/${made.id}`, { method: "PATCH", headers, body: JSON.stringify({ facts }) }); |
| 179 | 183 | expect((await patch({ open_houses: [{ ...sunday, end: "12:00" }] })).status).toBe(422); |
| 180 | 184 | 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 @@ | ||
| 8 | 8 | import { factItems, money } from "../generate.ts"; |
| 9 | 9 | import { escape } from "../http.ts"; |
| 10 | 10 | import { altOf, propertyPath, type Launched } from "../listing.ts"; |
| 11 | import type { Visitor } from "../visitors.ts"; | |
| 11 | 12 | import type { Who } from "../who.ts"; |
| 12 | 13 | import { layout } from "./layout.ts"; |
| 13 | 14 | import { draftBand, photoUrl } from "./listing.ts"; |
| @@ -31,14 +32,14 @@ export function emailParts( | ||
| 31 | 32 | } |
| 32 | 33 | |
| 33 | 34 | /** 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 { | |
| 35 | 36 | const { subject, paragraphs } = emailParts( |
| 36 | l.assets.find((a) => a.kind === "email")?.text ?? "", | |
| 37 | l.assets.find((a) => a.kind === kind)?.text ?? "", | |
| 37 | 38 | l.agent_name, |
| 38 | 39 | ); |
| 39 | 40 | const hero = l.photos[0]; |
| 40 | 41 | return `<article class="email palette-${escape(l.palette)}" data-email> |
| 41 | ${draftBand(l, "email")} | |
| 42 | ${draftBand(l, kind)} | |
| 42 | 43 | <p class="email-subject"><span class="muted">Subject</span> <span data-subject>${escape(subject)}</span></p> |
| 43 | 44 | <div class="email-card"> |
| 44 | 45 | ${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 { | ||
| 60 | 61 | </article>`; |
| 61 | 62 | } |
| 62 | 63 | |
| 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 { | |
| 64 | 97 | 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`; | |
| 65 | 101 | return layout( |
| 66 | 102 | `<div class="print-bar row"> |
| 67 | 103 | <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>`) : ""} | |
| 68 | 105 | <button type="button" class="button" data-copy-email hidden>Copy as email</button> |
| 69 | 106 | <button type="button" class="button button-quiet" data-copy-subject hidden>Copy subject</button> |
| 70 | 107 | <span class="muted" data-copy-note role="status">Paste it into your mail tool; the photos load from this page.</span> |
| 71 | 108 | </div> |
| 72 | ${email(l, input.origin)}`, | |
| 109 | ${email(l, input.origin, kind)} | |
| 110 | ${kind === "email_followup" && input.visitors !== undefined ? visitorList(l, input.visitors) : ""}`, | |
| 73 | 111 | 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}` }, | |
| 75 | 113 | ); |
| 76 | 114 | } |
src/pages/listing.ts +3 −3
| @@ -112,7 +112,7 @@ export const statusForm = (l: Listing): string => | ||
| 112 | 112 | </form>`; |
| 113 | 113 | |
| 114 | 114 | /** 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 => | |
| 116 | 116 | l.assets.find((a) => a.kind === kind)?.approval == null |
| 117 | 117 | ? `<p class="draft-band" role="note">Draft — not approved</p>` |
| 118 | 118 | : ""; |
| @@ -188,7 +188,7 @@ const TABS: readonly { id: string; label: string; kinds: readonly string[] }[] = | ||
| 188 | 188 | { id: "mls", label: "MLS", kinds: ["mls"] }, |
| 189 | 189 | { id: "social", label: "Social", kinds: ["social_short", "social_medium", "social_carousel", "social_status", "social_price"] }, |
| 190 | 190 | { 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"] }, | |
| 192 | 192 | ]; |
| 193 | 193 | |
| 194 | 194 | /** The tab that holds an asset, or a tab named outright; the first otherwise. */ |
| @@ -263,7 +263,7 @@ export function listingPage(input: { | ||
| 263 | 263 | (t) => `<div class="tabpanel" role="tabpanel" id="panel-${t.id}" aria-labelledby="tab-${t.id}"${t.id === open ? "" : " hidden"}> |
| 264 | 264 | ${t.id === "flyer" ? `<div class="flyer-preview"><a href="/listings/${l.id}/flyer" aria-label="Open the printable flyer">${flyer(l, origin)}</a></div>` : ""} |
| 265 | 265 | ${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>` : ""} | |
| 267 | 267 | ${l.assets |
| 268 | 268 | .filter((a) => t.kinds.includes(a.kind)) |
| 269 | 269 | .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 = {}) => | ||
| 20 | 20 | |
| 21 | 21 | const rows = (html: string) => [...html.matchAll(/<li class="report-row[^"]*">/g)].length; |
| 22 | 22 | |
| 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 () => { | |
| 24 | 24 | const ids = [...(await (await call("/", "pat")).text()).matchAll(/href="\/listings\/(\d+)"/g)].map((m) => m[1]); |
| 25 | 25 | let id = ""; |
| 26 | 26 | for (const i of new Set(ids)) if ((await (await call(`/listings/${i}/report`)).text()).includes("412 Alder Lane")) id = i; |
| 27 | 27 | const r = await call(`/listings/${id}/report`); |
| 28 | 28 | expect(r.status).toBe(200); |
| 29 | 29 | 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); | |
| 32 | 32 | expect(html).not.toContain("Draft — not approved yet"); |
| 33 | 33 | expect(html).toContain(`/homes/${id}`); |
| 34 | 34 | expect(html).toContain("412 Alder Lane"); |
src/routes.ts +37 −4
| @@ -24,7 +24,10 @@ import { | ||
| 24 | 24 | editAsset, |
| 25 | 25 | getAudit, |
| 26 | 26 | getHome, |
| 27 | addVisitor, | |
| 27 | 28 | getListing, |
| 29 | listVisitors, | |
| 30 | removeVisitor, | |
| 28 | 31 | getNote, |
| 29 | 32 | launchListing, |
| 30 | 33 | mayLaunch, |
| @@ -653,10 +656,40 @@ export const routes: readonly Route[] = [ | ||
| 653 | 656 | { |
| 654 | 657 | method: "GET", |
| 655 | 658 | 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 |
||
| 691 | return back(done.ok ? "Removed." : done.message, `/listings/${id}/email?which=followup`); | |
| 692 | }, | |
| 660 | 693 | }, |
| 661 | 694 | { |
| 662 | 695 | method: "GET", |
src/sample.test.ts +2 −2
| @@ -58,11 +58,11 @@ const approvedOf = (db: ReturnType<typeof fresh>, like: string) => { | ||
| 58 | 58 | return r.value.assets.filter((a) => a.approval !== null); |
| 59 | 59 | }; |
| 60 | 60 | |
| 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", () => { | |
| 62 | 62 | const db = fresh(); |
| 63 | 63 | seed(db); |
| 64 | 64 | const alder = approvedOf(db, "412 Alder%"); |
| 65 | expect(alder).toHaveLength(6); | |
| 65 | expect(alder).toHaveLength(7); | |
| 66 | 66 | for (const a of alder) expect(a.approval).toMatchObject({ by_name: "Morgan Reyes (sample)", via: "sample" }); |
| 67 | 67 | expect(approvedOf(db, "27 Birchwood%")).toHaveLength(0); |
| 68 | 68 | 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", () => { | ||
| 329 | 329 | tty.press("\r"); |
| 330 | 330 | expect(plain(tty.screen())).toContain("w launch week"); |
| 331 | 331 | }); |
| 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 @@ | ||
| 8 | 8 | * words are checked again as they are saved), t writes them all again in |
| 9 | 9 | * the next tone, a approves the asset or withdraws it, v puts back its |
| 10 | 10 | * 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 | |
| 12 | 15 | * the launch kit, the file the page offers to download; R shows the seller |
| 13 | 16 | * report, approved words only, as the page prints it; w shows the launch week, |
| 14 | 17 | * 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"; | ||
| 23 | 26 | import { FILTERS, parseFind, type Find } from "./find.ts"; |
| 24 | 27 | import { |
| 25 | 28 | addNote, |
| 29 | addVisitor, | |
| 26 | 30 | approveAsset, |
| 27 | 31 | canDelete, |
| 28 | 32 | canEditListing, |
| @@ -33,7 +37,9 @@ import { | ||
| 33 | 37 | getListing, |
| 34 | 38 | listListings, |
| 35 | 39 | listNotes, |
| 40 | listVisitors, | |
| 36 | 41 | listVersions, |
| 42 | removeVisitor, | |
| 37 | 43 | restoreAsset, |
| 38 | 44 | retoneListing, |
| 39 | 45 | setStatus, |
| @@ -95,8 +101,11 @@ export const fromLine = (line: string): string => line.replace(/\\n/g, "\n"); | ||
| 95 | 101 | export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }) { |
| 96 | 102 | let view: "listings" | "notes" = "listings"; |
| 97 | 103 | 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; | |
| 100 | 109 | /** The launch kit, as the file would read. */ |
| 101 | 110 | let kitting = false; |
| 102 | 111 | /** The seller report, as the page prints it. */ |
| @@ -107,7 +116,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 107 | 116 | let versioning: Kind | undefined; |
| 108 | 117 | let cursor = 0; |
| 109 | 118 | /** 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; | |
| 111 | 120 | let typed = ""; |
| 112 | 121 | /** The filter and the words the listings view is narrowed to. */ |
| 113 | 122 | let found: Find = { status: "all", q: "" }; |
| @@ -128,6 +137,11 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 128 | 137 | return found === undefined ? undefined : scrub(found); |
| 129 | 138 | }; |
| 130 | 139 | |
| 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 | ||
| 131 | 145 | function readLines(): string[] { |
| 132 | 146 | const found = open(); |
| 133 | 147 | if (found === undefined) return []; |
| @@ -144,9 +158,19 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 144 | 158 | ...(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)}`)), |
| 145 | 159 | ]; |
| 146 | 160 | } |
| 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 | } | |
| 147 | 169 | 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; | |
| 150 | 174 | return [ |
| 151 | 175 | ...(approved ? [] : [amber("DRAFT — not approved"), ""]), |
| 152 | 176 | bold(`Subject: ${subject}`), "", |
| @@ -155,6 +179,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 155 | 179 | ...paragraphs.flatMap((p) => [...p.split("\n"), ""]), |
| 156 | 180 | bold(l.agent_name), `${l.brokerage} · License ${l.license}`, `${l.agent_phone} · ${l.agent_email}`, "", |
| 157 | 181 | dim("Equal Housing Opportunity."), |
| 182 | ...(sheet?.ok === true ? ["", bold(`Would go to (${sheet.value.length})`), ...sheet.value.map((v) => `${v.name} ${dim(v.email)}`)] : []), | |
| 158 | 183 | ]; |
| 159 | 184 | } |
| 160 | 185 | 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 } | ||
| 182 | 207 | if (reading !== undefined) { |
| 183 | 208 | lines.push(...readLines()); |
| 184 | 209 | 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"; | |
| 186 | 212 | else if (versioning !== undefined) help = "1-5 put that version back · esc back to the assets · q quit"; |
| 187 | 213 | else help = |
| 188 | 214 | 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"; | |
| 191 | 217 | } else if (view === "listings") { |
| 192 | 218 | const rows = listings(); |
| 193 | 219 | 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 } | ||
| 220 | 246 | } |
| 221 | 247 | lines.push(""); |
| 222 | 248 | 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)`; | |
| 224 | 250 | // The tail of what is typed, so the cursor stays on the screen. |
| 225 | 251 | const room = Math.max(20, size.columns - 4); |
| 226 | 252 | 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 } | ||
| 236 | 262 | const added = addNote(db, me, { body: typed }); |
| 237 | 263 | if (!added.ok) said = added.message; |
| 238 | 264 | 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; | |
| 239 | 269 | } else if (typing.for === "search") { |
| 240 | 270 | found = parseFind(found.status, typed); |
| 241 | 271 | cursor = 0; |
| @@ -285,19 +315,50 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 285 | 315 | if (reading !== undefined) { |
| 286 | 316 | const found = open(); |
| 287 | 317 | 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"; | |
| 290 | 322 | return undefined; |
| 291 | 323 | } |
| 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) { | |
| 293 | 354 | reporting = true; |
| 294 | 355 | return undefined; |
| 295 | 356 | } |
| 296 | if (key === "w" && !kitting && !reporting && !planning && !previewing && found?.ok === true) { | |
| 357 | if (key === "w" && !kitting && !reporting && !planning && !previewing && !visiting && found?.ok === true) { | |
| 297 | 358 | planning = true; |
| 298 | 359 | return undefined; |
| 299 | 360 | } |
| 300 | if (key === "k" && !kitting && !reporting && !planning && !previewing && found?.ok === true) { | |
| 361 | if (key === "k" && !kitting && !reporting && !planning && !previewing && !visiting && found?.ok === true) { | |
| 301 | 362 | kitting = true; |
| 302 | 363 | return undefined; |
| 303 | 364 | } |
| @@ -317,8 +378,10 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 317 | 378 | return undefined; |
| 318 | 379 | } |
| 319 | 380 | 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 { | |
| 322 | 385 | reading = undefined; |
| 323 | 386 | cursor = 0; |
| 324 | 387 | } |
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 () => { | ||
| 216 | 216 | hold.set("open1_end", "15:00"); |
| 217 | 217 | expect((await post(`${mine}/facts`, hold)).status).toBe(303); |
| 218 | 218 | const afterOpen = await (await get(mine)).text(); |
| 219 | expect(afterOpen).toContain("0 of 8 approved"); | |
| 219 | expect(afterOpen).toContain("0 of 9 approved"); | |
| 220 | 220 | expect(await (await get(`${mine}/flyer`)).text()).toContain("OPEN HOUSE: Sunday, March 7, 1–3 pm."); |
| 221 | 221 | expect(await (await get(`${mine}/email`)).text()).toContain("OPEN HOUSE: Sunday, March 7, 1–3 pm."); |
| 222 | 222 | const bad = new FormData(); |
Visitor rows are never cleared when the open house is removed; delete them in updateFacts when open_houses becomes empty, with a test.