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

Merged opened this

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

Merged

mason/listing-launch:mason/93-download-a-listing-s-open-house-visitors → main 1 commit fork

  • Merged. mason/93-download-a-listing-s-open-house-visitors was merged into main as dc98ade.

Review

Sign in to review this change.
6 files changed +110 −6
README.md +5 −1
@@ -68,7 +68,11 @@ to make a listing of your own.
6868 escaped, at most 200). The list is personal data: only the listing's agent
6969 or an admin can read or change it, through the listing's existing update
7070 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.
7276 It also gets an **Open tomorrow reminder**: a post (Social tab, with its
7377 length line) and an email ("Open tomorrow — 412 Alder Lane, 1–3 pm", Email
7478 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", () => {
169169 expect(updateFacts(db, me, { id, fields: { open_houses: [] } }).ok).toBe(true);
170170 expect(db.query("SELECT count(*) AS n FROM visitor WHERE listing_id = ?1").get(id)).toEqual({ n: 0 });
171171 });
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 {
4848 ].join("\n").trimEnd()}\n`;
4949 }
5050
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 => {
5353 const slug = l.address
5454 .normalize("NFKD")
5555 .toLowerCase()
@@ -57,5 +57,8 @@ export const kitFilename = (l: Pick<Launched, "address" | "id">): string => {
5757 .replace(/^-+|-+$/g, "")
5858 .slice(0, 60)
5959 .replace(/-+$/, "");
60 return `${slug === "" ? `listing-${l.id}` : slug}-launch-kit.txt`;
60 return slug === "" ? `listing-${l.id}` : slug;
6161 };
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 {
7979 return `<section class="visitors" aria-labelledby="visitors-title">
8080 <h3 id="visitors-title">Would go to (${visitors.length})</h3>
8181 <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>`}
8383 <form class="row" method="post" action="/listings/${l.id}/visitors">
8484 <label class="sr" for="visitor-name">Name</label>
8585 <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 {
5050 type Result,
5151 type Upload,
5252 } from "./actions.ts";
53 import { kit, kitFilename } from "./kit.ts";
53 import { addressSlug, kit, kitFilename } from "./kit.ts";
54 import { visitorsCsv } from "./visitors.ts";
5455 import { KINDS, STATUSES, TONES } from "./generate.ts";
5556 import { FILTERS, parseFind, Q_MAX } from "./find.ts";
5657 import { ASSET_MAX, FEATURES_MAX, FEATURES_MIN, PALETTES } from "./listing.ts";
@@ -727,6 +728,25 @@ export const routes: readonly Route[] = [
727728 },
728729 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" },
729730 },
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 },
730750 {
731751 method: "GET",
732752 path: "/listings/:id/email",
src/visitors.ts +14
@@ -31,3 +31,17 @@ export function validVisitor(raw: { name?: unknown; email?: unknown }): Checked
3131 return { ok: false, message: "A visitor's email looks like [email protected]." };
3232 return { ok: true, value: { name, email } };
3333 }
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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs