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

The day before an open house, get an 'Open tomorrow' reminder post and email to approve #85

Merged opened this

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

Merged

mason/listing-launch:mason/68-the-day-before-an-open-house-get-an-open → main 2 commits fork

  • Merged. mason/68-the-day-before-an-open-house-get-an-open was merged into main as a42d2d9.

Review

Sign in to review this change.
14 files changed +271 −35
README.md +9
@@ -69,6 +69,15 @@ to make a listing of your own.
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
7171 email into your mail tool.
72 It also gets an **Open tomorrow reminder**: a post (Social tab, with its
73 length line) and an email ("Open tomorrow — 412 Alder Lane, 1–3 pm", Email
74 tab, "Preview reminder"; `O` in the terminal), written in the listing's tone from the open house
75 times exactly as you typed them, with your contact, and the property page
76 link in the text of both. With two open houses the subject names both and
77 claims no "tomorrow". Like every asset they are unapproved drafts that count
78 in "0 of 9 approved", keep versions, are Fair Housing checked, are rewritten
79 (and unapproved) when the facts change, and go when the open house does or
80 when the listing is sold or leaves the market.
7281 9. **Mark it sold.** On your listing, pick **Sold** in the status box beside
7382 the tone, add the price it sold at if you want it said, and press **Set
7483 status**. Two more assets appear, a status post under Social and a status
contract/openapi.json +9 −3
@@ -650,7 +650,9 @@
650650 "email",
651651 "email_status",
652652 "email_price",
653 "email_followup"
653 "email_followup",
654 "social_reminder",
655 "email_reminder"
654656 ]
655657 },
656658 "text": {
@@ -1343,7 +1345,9 @@
13431345 "email",
13441346 "email_status",
13451347 "email_price",
1346 "email_followup"
1348 "email_followup",
1349 "social_reminder",
1350 "email_reminder"
13471351 ]
13481352 },
13491353 "label": {
@@ -1689,7 +1693,9 @@
16891693 "email",
16901694 "email_status",
16911695 "email_price",
1692 "email_followup"
1696 "email_followup",
1697 "social_reminder",
1698 "email_reminder"
16931699 ]
16941700 },
16951701 "approved": {
src/actions.ts +10 −3
@@ -11,7 +11,7 @@ import { record, type AuditRow } from "./audit.ts";
1111 import { permit } from "./contract.ts";
1212 import { earlierAt, type Earlier } from "./duplicate.ts";
1313 import { check, type Check } from "./fairhousing.ts";
14 import { generate, isBackOnMarket, isOffMarket, KINDS, STATUS_KINDS, TONES, withoutOpenHouse, type Kind, type Status } from "./generate.ts";
14 import { generate, isBackOnMarket, isOffMarket, KINDS, remindable, REMINDER_KINDS, STATUS_KINDS, TONES, withoutOpenHouse, type Kind, type Status } from "./generate.ts";
1515 import {
1616 agentOf,
1717 ASSET_MAX,
@@ -30,7 +30,9 @@ import {
3030 type Listing,
3131 type ListingInput,
3232 type ListingRow,
33 propertyPath,
3334 } from "./listing.ts";
35 import { publicOrigin } from "./env.ts";
3436 import { matchesStatus, matchesText, parseFind } from "./find.ts";
3537 import { kit } from "./kit.ts";
3638 import { emit } from "./live.ts";
@@ -369,7 +371,7 @@ function writeAssets(
369371 backOnMarket ??
370372 (listing.status === "active" &&
371373 db.query("SELECT 1 FROM asset WHERE listing_id = ?1 AND kind = 'social_status'").get(listing.id) !== null);
372 const words = generate({ facts: { ...listing, back_on_market: back }, agent: agentOf(listing) });
374 const words = generate({ facts: { ...listing, back_on_market: back }, agent: agentOf(listing), link: propertyPath(listing, publicOrigin) });
373375 const put = db.query(
374376 `INSERT INTO asset (listing_id, kind, body, edited) VALUES (?1, ?2, ?3, 0)
375377 ON CONFLICT (listing_id, kind) DO UPDATE SET body = excluded.body, edited = 0,
@@ -465,12 +467,17 @@ export function markStatus(
465467 )
466468 .get(stand.status, stand.sold_price, id)!;
467469 // Leaving or re-entering the market changes the open house lines in the words: the assets nobody edited are written again.
470 // The reminders come and go with the status too: sold or off the market has none.
471 const remind = remindable(before.status) !== remindable(stand.status);
468472 const kinds =
469473 isOffMarket(before.status) === isOffMarket(stand.status)
470 ? STATUS_KINDS
474 ? remind
475 ? [...STATUS_KINDS, ...REMINDER_KINDS]
476 : STATUS_KINDS
471477 : KINDS.filter(
472478 (k) =>
473479 (STATUS_KINDS as readonly string[]).includes(k) ||
480 (remind && (REMINDER_KINDS as readonly string[]).includes(k)) ||
474481 db.query("SELECT 1 FROM asset WHERE listing_id = ?1 AND kind = ?2 AND edited = 0").get(id, k) !== null,
475482 );
476483 writeAssets(db, fromRow(row), kinds, undefined, isBackOnMarket(before.status, stand.status));
src/generate.ts +55 −2
@@ -28,6 +28,8 @@ export const KINDS = [
2828 "email_status",
2929 "email_price",
3030 "email_followup",
31 "social_reminder",
32 "email_reminder",
3133 ] as const;
3234 export type Kind = (typeof KINDS)[number];
3335
@@ -44,11 +46,19 @@ export const LABELS: Readonly<Record<Kind, string>> = {
4446 social_price: "Price improvement post",
4547 email_price: "Price improvement email",
4648 email_followup: "Open house follow-up email",
49 social_reminder: "Open tomorrow post",
50 email_reminder: "Open tomorrow email",
4751 };
4852
4953 /** The assets that exist only while a listing is coming soon, under contract, sold, or back on the market. */
5054 export const STATUS_KINDS = ["social_status", "email_status"] as const satisfies readonly Kind[];
5155
56 /** The assets that exist only while an unsold, on-market listing has an open house: the reminder for the day before. */
57 export const REMINDER_KINDS = ["social_reminder", "email_reminder"] as const satisfies readonly Kind[];
58
59 /** Whether a status may carry the open house reminders: not sold, not off the market. */
60 export const remindable = (status: Status | undefined): boolean => status !== "sold" && !isOffMarket(status);
61
5262 /** The assets that exist only while the price stands below what it was. */
5363 export const PRICE_KINDS = ["social_price", "email_price"] as const satisfies readonly Kind[];
5464
@@ -536,12 +546,54 @@ function followUpEmail(f: Facts, a: Agent): string {
536546 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}`;
537547 }
538548
539 /** 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. */
549 /** The open houses as typed, for a reminder: "Sunday, March 7, 1–3 pm", joined with "and". */
550 const reminderWhen = (f: Facts): string => (f.open_houses ?? []).map(whenOf).join(" and ");
551
552 /** The reminder post for the day before an open house. */
553 function reminderPost(f: Facts, a: Agent, link?: string): string {
554 const where = street(f.address);
555 const when = reminderWhen(f);
556 const line = {
557 classic: `Open tomorrow: ${where}. ${when}.`,
558 warm: `See you tomorrow! Come by the open house at ${where}: ${when}.`,
559 bold: `OPEN TOMORROW: ${where}. ${when}.`,
560 }[f.tone];
561 const close = {
562 classic: `The home is offered at ${money(f.price)}. Message me with any questions.`,
563 warm: `It is offered at ${money(f.price)}, and I would love to show you around. Message me if you have questions.`,
564 bold: `${money(f.price)}. Be there. Message me.`,
565 }[f.tone];
566 return `${line} ${close} ${link === undefined ? "" : `${link} `}${a.name}, ${a.brokerage}. #OpenHouse`;
567 }
568
569 /** The reminder email for the day before an open house. */
570 function reminderEmail(f: Facts, a: Agent, link?: string): string {
571 const where = street(f.address);
572 const when = reminderWhen(f);
573 const times = (f.open_houses ?? []).map((o) => timeRange(o.start, o.end)).join(" and ");
574 const first = (f.open_houses ?? []).length === 1;
575 const subject = first ? `Open tomorrow — ${where}, ${times}` : `Open house — ${where}, ${(f.open_houses ?? []).map(whenShort).join(" and ")}`;
576 const open = {
577 classic: `I am pleased to remind you that ${f.address} is open tomorrow: ${when}.`,
578 warm: `Just a friendly reminder: ${f.address} is open tomorrow! Come by ${when}.`,
579 bold: `${f.address} is OPEN TOMORROW: ${when}.`,
580 }[f.tone];
581 const close = {
582 classic: "If you would like to bring someone, or have a question beforehand, reply to this email or call me directly.",
583 warm: "If you have any questions before you come, just reply to this email and I will be glad to help.",
584 bold: "Questions? Reply now. See you there.",
585 }[f.tone];
586 return `Subject: ${subject}\n\n${open}\n\nThe facts: ${plural(f.beds, "bedroom")}, ${plural(f.baths, "bath")}, ${count(f.sqft)} square feet, built in ${f.year}. It is offered at ${money(f.price)}.${link === undefined ? "" : `\n\nThe property page: ${link}`}\n\n${close}\n\n${a.name}\n${a.brokerage} · License ${a.license}\n${a.phone} · ${a.email}`;
587 }
588
589 /** 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, and the two reminders while there is one and the listing is not sold. */
540590 export function generate(listing: {
541591 readonly facts: Facts;
542592 readonly agent: Agent;
593 /** The property page's absolute URL, for the reminders to carry. */
594 readonly link?: string;
543595 }): Partial<Record<Kind, string>> & Record<(typeof CORE_KINDS)[number], string> {
544 const { facts: f, agent: a } = listing;
596 const { facts: f, agent: a, link } = listing;
545597 const announced = f.status === "coming_soon" || f.status === "under_contract" || f.status === "sold" || (f.status === "active" && f.back_on_market === true);
546598 const cut = priceCut(f);
547599 return {
@@ -554,6 +606,7 @@ export function generate(listing: {
554606 ...(announced ? { social_status: statusPost(f, a), email_status: statusEmail(f, a) } : {}),
555607 ...(cut ? { social_price: pricePost(f, a), email_price: priceEmail(f, a) } : {}),
556608 ...(openHouseLine(f) === null ? {} : { email_followup: followUpEmail(f, a) }),
609 ...(openHouseLine(f) === null || !remindable(f.status) ? {} : { social_reminder: reminderPost(f, a, link), email_reminder: reminderEmail(f, a, link) }),
557610 };
558611 }
559612
src/openhouse.test.ts +2 −2
@@ -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(7);
106 expect(approve()).toBe(9);
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(9);
114 expect(approve()).toBe(11);
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([]);
src/pages/email.ts +16 −7
@@ -31,8 +31,13 @@ export function emailParts(
3131 return { subject, paragraphs };
3232 }
3333
34 /** The emails a listing can preview: the blast and the two that come with an open house. */
35 export type EmailKind = "email" | "email_followup" | "email_reminder";
36 const WHICH = { email_followup: "followup", email_reminder: "reminder" } as const;
37 const HEADINGS: Record<EmailKind, string> = { email: "Email", email_followup: "Follow-up email", email_reminder: "Reminder email" };
38
3439 /** The message itself, one article: what is copied is what is shown. */
35 export function email(l: Launched, origin = "", kind: "email" | "email_followup" = "email"): string {
40 export function email(l: Launched, origin = "", kind: EmailKind = "email"): string {
3641 const { subject, paragraphs } = emailParts(
3742 l.assets.find((a) => a.kind === kind)?.text ?? "",
3843 l.agent_name,
@@ -89,19 +94,23 @@ export function emailPage(input: {
8994 who: Who;
9095 listing: Launched;
9196 origin?: string;
92 /** Which email: the blast, or the open house follow-up. */
93 kind?: "email" | "email_followup";
97 /** Which email: the blast, the open house follow-up, or the reminder. */
98 kind?: EmailKind;
9499 /** The sign-in sheet's visitors, for the follow-up; absent for anyone but the listing's author. */
95100 visitors?: readonly Visitor[];
96101 }): string {
97102 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");
103 const has = (k: EmailKind): boolean => l.assets.some((a) => a.kind === k);
104 const kind: EmailKind = input.kind !== undefined && has(input.kind) ? input.kind : "email";
105 const follow = has("email_followup");
106 const remind = has("email_reminder");
100107 const path = `/listings/${l.id}/email`;
101108 return layout(
102109 `<div class="print-bar row">
103110 <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>`) : ""}
111 ${kind === "email" ? "" : `<a href="${path}">Email blast</a>`}
112 ${follow && kind !== "email_followup" ? `<a href="${path}?which=followup">Open house follow-up</a>` : ""}
113 ${remind && kind !== "email_reminder" ? `<a href="${path}?which=reminder">Open tomorrow reminder</a>` : ""}
105114 <button type="button" class="button" data-copy-email hidden>Copy as email</button>
106115 <button type="button" class="button button-quiet" data-copy-subject hidden>Copy subject</button>
107116 <span class="muted" data-copy-note role="status">Paste it into your mail tool; the photos load from this page.</span>
@@ -109,6 +118,6 @@ export function emailPage(input: {
109118 ${email(l, input.origin, kind)}
110119 ${kind === "email_followup" && input.visitors !== undefined ? visitorList(l, input.visitors) : ""}`,
111120 who,
112 { path: kind === "email" ? path : `${path}?which=followup`, heading: `${kind === "email" ? "Email" : "Follow-up email"}: ${l.address}` },
121 { path: kind === "email" ? path : `${path}?which=${WHICH[kind]}`, heading: `${HEADINGS[kind]}: ${l.address}` },
113122 );
114123 }
src/pages/listing.ts +4 −4
@@ -113,7 +113,7 @@ export const statusForm = (l: Listing): string =>
113113 </form>`;
114114
115115 /** The band an unapproved flyer, email or square picture (its first social post) wears, on screen and on paper; nothing once approved. */
116 export const draftBand = (l: Launched, kind: "flyer" | "email" | "email_followup" | "social_short"): string =>
116 export const draftBand = (l: Launched, kind: "flyer" | "email" | "email_followup" | "email_reminder" | "social_short"): string =>
117117 l.assets.find((a) => a.kind === kind)?.approval == null
118118 ? `<p class="draft-band" role="note">Draft — not approved</p>`
119119 : "";
@@ -187,9 +187,9 @@ function editor(l: Launched, a: Asset, editable: boolean, versions: readonly Ver
187187
188188 const TABS: readonly { id: string; label: string; kinds: readonly string[] }[] = [
189189 { id: "mls", label: "MLS", kinds: ["mls"] },
190 { id: "social", label: "Social", kinds: ["social_short", "social_medium", "social_carousel", "social_status", "social_price"] },
190 { id: "social", label: "Social", kinds: ["social_short", "social_medium", "social_carousel", "social_status", "social_price", "social_reminder"] },
191191 { id: "flyer", label: "Flyer", kinds: ["flyer"] },
192 { id: "email", label: "Email", kinds: ["email", "email_status", "email_price", "email_followup"] },
192 { id: "email", label: "Email", kinds: ["email", "email_status", "email_price", "email_followup", "email_reminder"] },
193193 ];
194194
195195 /** The tab that holds an asset, or a tab named outright; the first otherwise. */
@@ -264,7 +264,7 @@ export function listingPage(input: {
264264 (t) => `<div class="tabpanel" role="tabpanel" id="panel-${t.id}" aria-labelledby="tab-${t.id}"${t.id === open ? "" : " hidden"}>
265265 ${t.id === "flyer" ? `<div class="flyer-preview"><a href="/listings/${l.id}/flyer" aria-label="Open the printable flyer">${flyer(l, origin)}</a></div>` : ""}
266266 ${t.id === "social" ? `<p><a class="button button-quiet" href="/listings/${l.id}/card">Square picture</a></p>` : ""}
267 ${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 ${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>` : ""}${l.assets.some((a) => a.kind === "email_reminder") ? ` <a class="button button-quiet" href="/listings/${l.id}/email?which=reminder">Preview reminder</a>` : ""}</p>` : ""}
268268 ${l.assets
269269 .filter((a) => t.kinds.includes(a.kind))
270270 .map((a) => editor(l, a, editable, input.versions?.[a.kind] ?? []))
src/reminder.test.ts added +126
@@ -0,0 +1,126 @@
1 /**
2 * The "Open tomorrow" reminder: a post and an email in the listing's tone,
3 * drafted with an open house (never for a sold listing), unapproved until a
4 * person signs them off, gone with the open house. The app sends nothing.
5 */
6 import { expect, test } from "bun:test";
7 import { mkdtempSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { approveAsset, getListing, launchListing, setStatus, updateFacts } from "./actions.ts";
11 import { openDb } from "./db.ts";
12 import { check } from "./fairhousing.ts";
13 import { generate, TONES } from "./generate.ts";
14 import { kit } from "./kit.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 sunday = { date: "2027-03-07", start: "13:00", end: "15:00" };
23
24 const launch = (fields: Record<string, unknown> = {}) => {
25 const db = openDb(mkdtempSync(join(tmpdir(), "app-reminder-")));
26 const made = launchListing(db, me, { fields: { ...SAMPLE, open_houses: [sunday], ...fields } });
27 if (!made.ok) throw new Error(made.message);
28 return { db, id: made.value.id };
29 };
30 const read = (db: ReturnType<typeof openDb>, id: number) => {
31 const found = getListing(db, me, { id });
32 if (!found.ok) throw new Error(found.message);
33 return found.value;
34 };
35 const kinds = (db: ReturnType<typeof openDb>, id: number) => read(db, id).assets.map((a) => a.kind).filter((k) => k.endsWith("_reminder"));
36
37 test("the words, in every tone: the times as typed, clear, and different from each other", () => {
38 const posts = new Set<string>();
39 const emails = new Set<string>();
40 for (const tone of TONES) {
41 const words = generate({ facts: { ...SAMPLE, tone, open_houses: [sunday] }, agent: agentOf(SAMPLE), link: "https://x.test/homes/7" });
42 const post = words.social_reminder!;
43 const mail = words.email_reminder!;
44 expect(post).toContain("Sunday, March 7, 1–3 pm");
45 expect(post).toContain(SAMPLE.agent_name);
46 expect(post).toContain("https://x.test/homes/7");
47 expect(mail).toContain("https://x.test/homes/7");
48 expect(mail).toStartWith("Subject: Open tomorrow — 412 Alder Lane, 1–3 pm\n");
49 expect(mail).toContain("Sunday, March 7, 1–3 pm");
50 expect(mail).toContain(SAMPLE.agent_phone);
51 expect(check(post).clear).toBe(true);
52 expect(check(mail).clear).toBe(true);
53 posts.add(post);
54 emails.add(mail);
55 }
56 expect([posts.size, emails.size]).toEqual([3, 3]);
57 const none = generate({ facts: { ...SAMPLE, open_houses: [] }, agent: agentOf(SAMPLE) });
58 expect(none.social_reminder).toBeUndefined();
59 expect(none.email_reminder).toBeUndefined();
60 });
61
62 test("two open houses are both named, as typed, and the subject claims no 'tomorrow'", () => {
63 const two = [sunday, { date: "2027-03-13", start: "11:00", end: "13:00" }];
64 const mail = generate({ facts: { ...SAMPLE, open_houses: two }, agent: agentOf(SAMPLE) }).email_reminder!;
65 expect(mail).toStartWith("Subject: Open house — 412 Alder Lane, Sun, Mar 7, 1–3 pm and Sat, Mar 13, 11 am–1 pm\n");
66 expect(mail).toContain("Saturday, March 13, 11 am–1 pm");
67 });
68
69 test("a listing with an open house gets both, unapproved, counted, with versions and a length line; one without gets none", () => {
70 const { db, id } = launch();
71 const l = read(db, id);
72 expect(kinds(db, id)).toEqual(["social_reminder", "email_reminder"]);
73 for (const kind of ["social_reminder", "email_reminder"]) expect(l.assets.find((a) => a.kind === kind)!.approval).toBeNull();
74 expect(l.assets.length).toBe(9);
75 expect(kit(l)).toContain("0 of 9 assets approved");
76 expect(kinds(...(Object.values(launch({ open_houses: [] })) as [ReturnType<typeof openDb>, number]))).toEqual([]);
77 });
78
79 test("approval, edits and clearing: facts rewrite and unapprove them; clearing the open house removes both", () => {
80 const { db, id } = launch();
81 const seen = read(db, id).assets.find((a) => a.kind === "email_reminder")!.updated_at;
82 expect(approveAsset(db, me, { id, kind: "email_reminder", approved: true, seen, door: "page" }).ok).toBe(true);
83 expect(read(db, id).assets.find((a) => a.kind === "email_reminder")!.approval).not.toBeNull();
84 expect(updateFacts(db, me, { id, fields: { price: SAMPLE.price + 1000 } }).ok).toBe(true);
85 expect(read(db, id).assets.find((a) => a.kind === "email_reminder")!.approval).toBeNull();
86 expect(updateFacts(db, me, { id, fields: { open_houses: [] } }).ok).toBe(true);
87 expect(kinds(db, id)).toEqual([]);
88 expect(updateFacts(db, me, { id, fields: { open_houses: [sunday] } }).ok).toBe(true);
89 expect(kinds(db, id)).toEqual(["social_reminder", "email_reminder"]);
90 });
91
92 test("a sold or off-market listing has none; reopening it writes them again", () => {
93 const { db, id } = launch();
94 expect(setStatus(db, me, { id, status: "sold" }).ok).toBe(true);
95 expect(kinds(db, id)).toEqual([]);
96 expect(setStatus(db, me, { id, status: "active" }).ok).toBe(true);
97 expect(kinds(db, id)).toEqual(["social_reminder", "email_reminder"]);
98 expect(setStatus(db, me, { id, status: "withdrawn" }).ok).toBe(true);
99 expect(kinds(db, id)).toEqual([]);
100 // Sold listings generate none, whatever the facts say.
101 expect(generate({ facts: { ...SAMPLE, status: "sold", open_houses: [sunday] }, agent: agentOf(SAMPLE) }).email_reminder).toBeUndefined();
102 });
103
104 test("MCP drafts them but never approves", () => {
105 const { db, id } = launch();
106 const res = rpc(db, me, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "approve_asset", arguments: { id, kind: "email_reminder" } } });
107 expect(JSON.stringify(res.body)).toMatch(/person|isError|refus|cannot/i);
108 expect(read(db, id).assets.find((a) => a.kind === "email_reminder")!.approval).toBeNull();
109 });
110
111 test("a hostile address is escaped in the subject on the preview page", () => {
112 const address = `<img src=x onerror=alert(1)>"&' Lane, Portland, OR 97214`;
113 const { db, id } = launch({ address });
114 const listing = read(db, id);
115 const subject = listing.assets.find((a) => a.kind === "email_reminder")!.text.split("\n")[0]!;
116 expect(subject).toContain("<img src=x");
117 const page = emailPage({ who: me, listing, kind: "email_reminder" });
118 expect(page).not.toContain("<img src=x");
119 expect(page).toContain("&lt;img src=x onerror=alert(1)&gt;");
120 expect(page).toContain("Draft — not approved");
121 });
122
123 test("the saved reminders carry the property page link", () => {
124 const { db, id } = launch();
125 for (const kind of ["social_reminder", "email_reminder"]) expect(read(db, id).assets.find((a) => a.kind === kind)!.text).toContain(`/homes/${id}`);
126 });
src/report.test.ts +2 −2
@@ -27,8 +27,8 @@ test("the Alder Lane sample reports seven approved rows by its sample agent", as
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(7);
31 expect(html.match(/Approved by [^<]*\(sample\) on \d{4}-\d{2}-\d{2}/g)?.length).toBe(7);
30 expect(rows(html)).toBe(9);
31 expect(html.match(/Approved by [^<]*\(sample\) on \d{4}-\d{2}-\d{2}/g)?.length).toBe(9);
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 +3 −2
@@ -658,14 +658,15 @@ export const routes: readonly Route[] = [
658658 path: "/listings/:id/email",
659659 handle: (c) => {
660660 const id = Number(c.params.id);
661 const follow = c.url.searchParams.get("which") === "followup";
661 const which = c.url.searchParams.get("which");
662 const follow = which === "followup";
662663 const visitors = follow ? listVisitors(c.db, c.who, { id }) : undefined;
663664 return page(c.who, getListing(c.db, c.who, { id }), (listing) =>
664665 emailPage({
665666 who: c.who,
666667 listing,
667668 origin: publicOrigin,
668 kind: follow ? "email_followup" : "email",
669 kind: follow ? "email_followup" : which === "reminder" ? "email_reminder" : "email",
669670 ...(visitors?.ok ? { visitors: visitors.value } : {}),
670671 }),
671672 );
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 seven assets approved by its sample agent; Birchwood has none", () => {
61 test("Alder Lane opens with all nine 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(7);
65 expect(alder).toHaveLength(9);
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 +22
@@ -388,3 +388,25 @@ test("P previews the follow-up with its visitors; V lists, adds and removes them
388388 expect(db.query("SELECT count(*) AS n FROM visitor WHERE listing_id = ?1").get(id)).toEqual({ n: 0 });
389389 }
390390 });
391
392 test("O previews the Open tomorrow reminder, link included; a listing without an open house says so", async () => {
393 const { launchListing } = await import("./actions.ts");
394 const db = openDb(mkdtempSync(join(tmpdir(), "app-tty-reminder-")));
395 const made = launchListing(db, me, { fields: { ...SAMPLE, open_houses: [{ date: "2027-03-07", start: "13:00", end: "15:00" }] } });
396 const bare = launchListing(db, me, { fields: { ...SAMPLE, address: "9 Elm Street, Portland, OR 97214", open_houses: [] } });
397 if (!made.ok || !bare.ok) throw new Error("launch");
398 const tty = session(db, me);
399 tty.press("j");
400 tty.press("\r");
401 tty.press("O");
402 const screen = plain(tty.screen());
403 expect(screen).toContain("DRAFT — not approved");
404 expect(screen).toContain("Subject: Open tomorrow");
405 expect(screen).toContain(`/homes/${made.value.id}`);
406 tty.press("\x1b");
407 tty.press("\x1b");
408 tty.press("k");
409 tty.press("\r");
410 tty.press("O");
411 expect(plain(tty.screen())).toContain("This listing has no open tomorrow reminder.");
412 });
src/tty.ts +10 −7
@@ -9,7 +9,8 @@
99 * the next tone, a approves the asset or withdraws it, v puts back its
1010 * earlier words, esc goes back; x
1111 * 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
12 * follow-up the same way, with the sign-in visitors it would go to; O previews
13 * the Open tomorrow reminder email; V lists
1314 * those visitors (the listing's agent or an admin; a guest sees none): a adds
1415 * one as `Name, email`, d removes the one under the cursor, esc goes back; k shows
1516 * the launch kit, the file the page offers to download; R shows the seller
@@ -102,7 +103,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
102103 let view: "listings" | "notes" = "listings";
103104 let reading: number | undefined;
104105 /** 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 let previewing: "email" | "email_followup" | "email_reminder" | undefined;
106107 /** The sign-in visitors of the open house, the cursor on one. */
107108 let visiting = false;
108109 let visitorCursor = 0;
@@ -212,8 +213,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
212213 else if (versioning !== undefined) help = "1-5 put that version back · esc back to the assets · q quit";
213214 else help =
214215 found?.ok === true && canEditListing(me, found.value)
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";
216 ? "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 · O preview reminder · V visitors · k launch kit · R seller report · w launch week · esc back · q quit"
217 : "j/↓↑ asset · p preview email · P preview follow-up · O preview reminder · k launch kit · R seller report · w launch week · esc back · q quit";
217218 } else if (view === "listings") {
218219 const rows = listings();
219220 lines.push(dim(`Showing ${found.status.replace(/[-_]/g, " ")}${found.q === "" ? "" : ` matching "${found.q}"`}`));
@@ -316,9 +317,11 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
316317 const found = open();
317318 const assets = found?.ok === true ? found.value.assets : [];
318319 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";
320 if ((key === "p" || key === "P" || key === "O") && idle && found?.ok === true) {
321 const which = key === "p" ? "email" : key === "P" ? "email_followup" : "email_reminder";
322 if (which === "email_followup" && !assets.some((a) => a.kind === which)) said = "This listing has no open house follow-up.";
323 else if (which === "email_reminder" && !assets.some((a) => a.kind === which)) said = "This listing has no open tomorrow reminder.";
324 else previewing = which;
322325 return undefined;
323326 }
324327 if (key === "V" && idle && found?.ok === true) {
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 9 approved");
219 expect(afterOpen).toContain("0 of 11 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