Public: anyone can read this; the crew can change it.
Download a listing's open house visitors as a CSV file the agent can import into their CRM #111
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/93-download-a-listing-s-open-house-visitors → main 1 commit fork
6 files
changed +110
−6
README.md +5 −1
| @@ -68,7 +68,11 @@ to make a listing of your own. | ||
| 68 | 68 | escaped, at most 200). The list is personal data: only the listing's agent |
| 69 | 69 | or an admin can read or change it, through the listing's existing update |
| 70 | 70 | cell, so nobody gains a permission. The app sends nothing; copy the |
| 71 | email into your mail tool. | |
| 71 | email into your mail tool, or **Download visitors (CSV)** | |
| 72 | (`/listings/<id>/visitors.csv`, author only) to import them into your CRM: | |
| 73 | `Name,Email,Open house,Listing`, every field quoted, and a field starting | |
| 74 | with `=`, `+`, `-`, `@`, tab or return gets a leading `'` so a spreadsheet | |
| 75 | never runs it. | |
| 72 | 76 | It also gets an **Open tomorrow reminder**: a post (Social tab, with its |
| 73 | 77 | length line) and an email ("Open tomorrow — 412 Alder Lane, 1–3 pm", Email |
| 74 | 78 | tab, "Preview reminder"; `O` in the terminal), written in the listing's tone from the open house |
src/followup.test.ts +63
| @@ -169,3 +169,66 @@ test("the visitors go when the open house does", () => { | ||
| 169 | 169 | expect(updateFacts(db, me, { id, fields: { open_houses: [] } }).ok).toBe(true); |
| 170 | 170 | expect(db.query("SELECT count(*) AS n FROM visitor WHERE listing_id = ?1").get(id)).toEqual({ n: 0 }); |
| 171 | 171 | }); |
| 172 | ||
| 173 | test("the visitors CSV: quoted per RFC 4180, formulas defused, author only", async () => { | |
| 174 | const { visitorsCsv } = await import("./visitors.ts"); | |
| 175 | const csv = visitorsCsv( | |
| 176 | { address: '1 "Main" St', open_houses: [sunday] }, | |
| 177 | [ | |
| 178 | { name: '=HYPERLINK("http://x","a")', email: "[email protected]" }, | |
| 179 | { name: "@evil, Jo", email: "[email protected]" }, | |
| 180 | ], | |
| 181 | ); | |
| 182 | const lines = csv.split("\r\n"); | |
| 183 | expect(lines[0]).toBe('"Name","Email","Open house","Listing"'); | |
| 184 | expect(lines[1]).toBe(`"'=HYPERLINK(""http://x"",""a"")","[email protected]","2027-03-07 13:00-15:00","1 ""Main"" St"`); | |
| 185 | expect(lines[2]).toStartWith(`"'@evil, Jo","'[email protected]"`); | |
| 186 | expect(lines.length).toBe(4); | |
| 187 | }); | |
| 188 | ||
| 189 | test("the visitors CSV route gives the file to the author, the not-for-you answer to everyone else", async () => { | |
| 190 | const { serve } = await import("./server.ts"); | |
| 191 | const { sign } = await import("./who.ts"); | |
| 192 | const KEY = "a-key-only-the-forge-holds"; | |
| 193 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "csv-")), identityKey: KEY }); | |
| 194 | const base = `http://${server.hostname}:${server.port}`; | |
| 195 | const as = (id: string) => { | |
| 196 | const identity = `${id}|Pat|user|${Date.now() + 600_000}`; | |
| 197 | return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; | |
| 198 | }; | |
| 199 | try { | |
| 200 | const form = new FormData(); | |
| 201 | form.set("address", "412 Alder Lane"); | |
| 202 | for (const [k, v] of Object.entries({ beds: 3, baths: 2, sqft: 1800, lot: "0.2 acre", year: 1999, price: 500000, | |
| 203 | tone: "classic", palette: "navy", agent_phone: "+1 (503) 555-0100", open1_date: "2027-03-07", open1_start: "13:00", open1_end: "15:00", | |
| 204 | features: "a\nb\nc\nd\ne", agent_name: "Pat", brokerage: "Bay Realty", license: "L1", agent_email: "[email protected]" })) | |
| 205 | form.set(k, String(v)); | |
| 206 | const made = await fetch(`${base}/listings`, { method: "POST", body: form, redirect: "manual", headers: as("author") }); | |
| 207 | if (!/\/listings\/\d+/.test(made.headers.get("location") ?? "")) throw new Error(`${made.status} ${made.headers.get("location")} ${made.headers.get("set-cookie")}`); | |
| 208 | const id = /\/listings\/(\d+)/.exec(made.headers.get("location")!)![1]; | |
| 209 | const add = new URLSearchParams({ name: '=cmd|"x"', email: "[email protected]" }); | |
| 210 | const added = await fetch(`${base}/listings/${id}/visitors`, { method: "POST", body: add, redirect: "manual", headers: as("author") }); | |
| 211 | expect(added.status).toBe(303); | |
| 212 | ||
| 213 | const r = await fetch(`${base}/listings/${id}/visitors.csv`, { headers: as("author") }); | |
| 214 | expect(r.status).toBe(200); | |
| 215 | expect(r.headers.get("content-type")).toBe("text/csv; charset=utf-8"); | |
| 216 | expect(r.headers.get("content-disposition")).toBe('attachment; filename="412-alder-lane-visitors.csv"'); | |
| 217 | expect(await r.text()).toBe( | |
| 218 | '"Name","Email","Open house","Listing"\r\n"\'=cmd|""x""","[email protected]","2027-03-07 13:00-15:00","412 Alder Lane"\r\n', | |
| 219 | ); | |
| 220 | const page = await (await fetch(`${base}/listings/${id}/email?which=followup`, { headers: as("author") })).text(); | |
| 221 | expect(page).toContain(`href="/listings/${id}/visitors.csv"`); | |
| 222 | ||
| 223 | for (const h of [as("stranger"), {}]) { | |
| 224 | const no = await fetch(`${base}/listings/${id}/visitors.csv`, { headers: h }); | |
| 225 | expect([401, 403, 404]).toContain(no.status); | |
| 226 | expect(await no.text()).not.toContain("[email protected]"); | |
| 227 | } | |
| 228 | const prop = await (await fetch(`${base}/listings/${id}`, { headers: as("author") })).text(); | |
| 229 | expect(prop).not.toContain("[email protected]"); | |
| 230 | expect((await fetch(`${base}/listings/${id}/kit.txt`, { headers: as("author") }).then((x) => x.text()))).not.toContain("[email protected]"); | |
| 231 | } finally { | |
| 232 | server.stop(true); | |
| 233 | } | |
| 234 | }); |
src/kit.ts +6 −3
| @@ -48,8 +48,8 @@ export function kit(l: Launched, origin = publicOrigin): string { | ||
| 48 | 48 | ].join("\n").trimEnd()}\n`; |
| 49 | 49 | } |
| 50 | 50 | |
| 51 | /** "412-alder-lane-launch-kit.txt": the address in `[a-z0-9-]`, never empty. */ | |
| 52 | export const kitFilename = (l: Pick<Launched, "address" | "id">): string => { | |
| 51 | /** "412-alder-lane": the address in `[a-z0-9-]`, never empty. */ | |
| 52 | export const addressSlug = (l: Pick<Launched, "address" | "id">): string => { | |
| 53 | 53 | const slug = l.address |
| 54 | 54 | .normalize("NFKD") |
| 55 | 55 | .toLowerCase() |
| @@ -57,5 +57,8 @@ export const kitFilename = (l: Pick<Launched, "address" | "id">): string => { | ||
| 57 | 57 | .replace(/^-+|-+$/g, "") |
| 58 | 58 | .slice(0, 60) |
| 59 | 59 | .replace(/-+$/, ""); |
| 60 | return `${slug === "" ? `listing-${l.id}` : slug}-launch-kit.txt`; | |
| 60 | return slug === "" ? `listing-${l.id}` : slug; | |
| 61 | 61 | }; |
| 62 | ||
| 63 | /** "412-alder-lane-launch-kit.txt" */ | |
| 64 | export const kitFilename = (l: Pick<Launched, "address" | "id">): string => `${addressSlug(l)}-launch-kit.txt`; |
src/pages/email.ts +1 −1
| @@ -79,7 +79,7 @@ function visitorList(l: Launched, visitors: readonly Visitor[]): string { | ||
| 79 | 79 | return `<section class="visitors" aria-labelledby="visitors-title"> |
| 80 | 80 | <h3 id="visitors-title">Would go to (${visitors.length})</h3> |
| 81 | 81 | <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> |
| 82 | ${visitors.length === 0 ? `<p class="muted">Nobody yet. Add the names from the paper sheet.</p>` : `<ul class="visitor-list">${rows}</ul>`} | |
| 82 | ${visitors.length === 0 ? `<p class="muted">Nobody yet. Add the names from the paper sheet.</p>` : `<ul class="visitor-list">${rows}</ul><p><a href="/listings/${l.id}/visitors.csv" download>Download visitors (CSV)</a></p>`} | |
| 83 | 83 | <form class="row" method="post" action="/listings/${l.id}/visitors"> |
| 84 | 84 | <label class="sr" for="visitor-name">Name</label> |
| 85 | 85 | <input class="input" id="visitor-name" name="name" maxlength="80" required autocomplete="off" placeholder="Name"> |
src/routes.ts +21 −1
| @@ -50,7 +50,8 @@ import { | ||
| 50 | 50 | type Result, |
| 51 | 51 | type Upload, |
| 52 | 52 | } from "./actions.ts"; |
| 53 | import { kit, kitFilename } from "./kit.ts"; | |
| 53 | import { addressSlug, kit, kitFilename } from "./kit.ts"; | |
| 54 | import { visitorsCsv } from "./visitors.ts"; | |
| 54 | 55 | import { KINDS, STATUSES, TONES } from "./generate.ts"; |
| 55 | 56 | import { FILTERS, parseFind, Q_MAX } from "./find.ts"; |
| 56 | 57 | import { ASSET_MAX, FEATURES_MAX, FEATURES_MIN, PALETTES } from "./listing.ts"; |
| @@ -727,6 +728,25 @@ export const routes: readonly Route[] = [ | ||
| 727 | 728 | }, |
| 728 | 729 | api: { summary: "The listing's launch kit: every asset in one plain-text file, each marked approved or draft, with its open Fair Housing findings. Sent as an attachment.", answer: { type: "string" }, mime: "text/plain" }, |
| 729 | 730 | }, |
| 731 | { | |
| 732 | method: "GET", | |
| 733 | path: "/listings/:id/visitors.csv", | |
| 734 | handle: (c) => { | |
| 735 | const id = Number(c.params.id); | |
| 736 | const found = getListing(c.db, c.who, { id }); | |
| 737 | if (!found.ok) return text(found.message, STATUS[found.error]); | |
| 738 | const visitors = listVisitors(c.db, c.who, { id }); | |
| 739 | if (!visitors.ok) return text(visitors.message, STATUS[visitors.error]); | |
| 740 | return new Response(visitorsCsv(found.value, visitors.value), { | |
| 741 | headers: { | |
| 742 | "content-type": "text/csv; charset=utf-8", | |
| 743 | "content-disposition": `attachment; filename="${addressSlug(found.value)}-visitors.csv"`, | |
| 744 | "cache-control": "no-store", | |
| 745 | ...SECURITY, | |
| 746 | }, | |
| 747 | }); | |
| 748 | }, | |
| 749 | }, | |
| 730 | 750 | { |
| 731 | 751 | method: "GET", |
| 732 | 752 | path: "/listings/:id/email", |
src/visitors.ts +14
| @@ -31,3 +31,17 @@ export function validVisitor(raw: { name?: unknown; email?: unknown }): Checked | ||
| 31 | 31 | return { ok: false, message: "A visitor's email looks like [email protected]." }; |
| 32 | 32 | return { ok: true, value: { name, email } }; |
| 33 | 33 | } |
| 34 | ||
| 35 | /** One RFC 4180 field, always quoted; a leading formula character is defused with `'`. */ | |
| 36 | const field = (v: string): string => `"${(/^[=+\-@\t\r]/.test(v) ? `'${v}` : v).replace(/"/g, '""')}"`; | |
| 37 | ||
| 38 | /** The visitors as a CSV a CRM imports: `Name,Email,Open house,Listing`, CRLF lines, one row per visitor. */ | |
| 39 | export function visitorsCsv( | |
| 40 | listing: { readonly address: string; readonly open_houses: readonly { date: string; start: string; end: string }[] }, | |
| 41 | visitors: readonly Pick<Visitor, "name" | "email">[], | |
| 42 | ): string { | |
| 43 | const open = listing.open_houses.map((o) => `${o.date} ${o.start}-${o.end}`).join("; "); | |
| 44 | const rows = [["Name", "Email", "Open house", "Listing"].map(field).join(",")]; | |
| 45 | for (const v of visitors) rows.push([v.name, v.email, open, listing.address].map(field).join(",")); | |
| 46 | return rows.join("\r\n") + "\r\n"; | |
| 47 | } |