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
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/68-the-day-before-an-open-house-get-an-open → main 2 commits fork
14 files
changed +271
−35
README.md +9
| @@ -69,6 +69,15 @@ to make a listing of your own. | ||
| 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 | 71 | 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. | |
| 72 | 81 | 9. **Mark it sold.** On your listing, pick **Sold** in the status box beside |
| 73 | 82 | the tone, add the price it sold at if you want it said, and press **Set |
| 74 | 83 | status**. Two more assets appear, a status post under Social and a status |
contract/openapi.json +9 −3
| @@ -650,7 +650,9 @@ | ||
| 650 | 650 | "email", |
| 651 | 651 | "email_status", |
| 652 | 652 | "email_price", |
| 653 | "email_followup" | |
| 653 | "email_followup", | |
| 654 | "social_reminder", | |
| 655 | "email_reminder" | |
| 654 | 656 | ] |
| 655 | 657 | }, |
| 656 | 658 | "text": { |
| @@ -1343,7 +1345,9 @@ | ||
| 1343 | 1345 | "email", |
| 1344 | 1346 | "email_status", |
| 1345 | 1347 | "email_price", |
| 1346 | "email_followup" | |
| 1348 | "email_followup", | |
| 1349 | "social_reminder", | |
| 1350 | "email_reminder" | |
| 1347 | 1351 | ] |
| 1348 | 1352 | }, |
| 1349 | 1353 | "label": { |
| @@ -1689,7 +1693,9 @@ | ||
| 1689 | 1693 | "email", |
| 1690 | 1694 | "email_status", |
| 1691 | 1695 | "email_price", |
| 1692 | "email_followup" | |
| 1696 | "email_followup", | |
| 1697 | "social_reminder", | |
| 1698 | "email_reminder" | |
| 1693 | 1699 | ] |
| 1694 | 1700 | }, |
| 1695 | 1701 | "approved": { |
src/actions.ts +10 −3
| @@ -11,7 +11,7 @@ import { record, type AuditRow } from "./audit.ts"; | ||
| 11 | 11 | import { permit } from "./contract.ts"; |
| 12 | 12 | import { earlierAt, type Earlier } from "./duplicate.ts"; |
| 13 | 13 | 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"; | |
| 15 | 15 | import { |
| 16 | 16 | agentOf, |
| 17 | 17 | ASSET_MAX, |
| @@ -30,7 +30,9 @@ import { | ||
| 30 | 30 | type Listing, |
| 31 | 31 | type ListingInput, |
| 32 | 32 | type ListingRow, |
| 33 | propertyPath, | |
| 33 | 34 | } from "./listing.ts"; |
| 35 | import { publicOrigin } from "./env.ts"; | |
| 34 | 36 | import { matchesStatus, matchesText, parseFind } from "./find.ts"; |
| 35 | 37 | import { kit } from "./kit.ts"; |
| 36 | 38 | import { emit } from "./live.ts"; |
| @@ -369,7 +371,7 @@ function writeAssets( | ||
| 369 | 371 | backOnMarket ?? |
| 370 | 372 | (listing.status === "active" && |
| 371 | 373 | 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) }); | |
| 373 | 375 | const put = db.query( |
| 374 | 376 | `INSERT INTO asset (listing_id, kind, body, edited) VALUES (?1, ?2, ?3, 0) |
| 375 | 377 | ON CONFLICT (listing_id, kind) DO UPDATE SET body = excluded.body, edited = 0, |
| @@ -465,12 +467,17 @@ export function markStatus( | ||
| 465 | 467 | ) |
| 466 | 468 | .get(stand.status, stand.sold_price, id)!; |
| 467 | 469 | // 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); | |
| 468 | 472 | const kinds = |
| 469 | 473 | isOffMarket(before.status) === isOffMarket(stand.status) |
| 470 | ? STATUS_KINDS | |
| 474 | ? remind | |
| 475 | ? [...STATUS_KINDS, ...REMINDER_KINDS] | |
| 476 | : STATUS_KINDS | |
| 471 | 477 | : KINDS.filter( |
| 472 | 478 | (k) => |
| 473 | 479 | (STATUS_KINDS as readonly string[]).includes(k) || |
| 480 | (remind && (REMINDER_KINDS as readonly string[]).includes(k)) || | |
| 474 | 481 | db.query("SELECT 1 FROM asset WHERE listing_id = ?1 AND kind = ?2 AND edited = 0").get(id, k) !== null, |
| 475 | 482 | ); |
| 476 | 483 | writeAssets(db, fromRow(row), kinds, undefined, isBackOnMarket(before.status, stand.status)); |
src/generate.ts +55 −2
| @@ -28,6 +28,8 @@ export const KINDS = [ | ||
| 28 | 28 | "email_status", |
| 29 | 29 | "email_price", |
| 30 | 30 | "email_followup", |
| 31 | "social_reminder", | |
| 32 | "email_reminder", | |
| 31 | 33 | ] as const; |
| 32 | 34 | export type Kind = (typeof KINDS)[number]; |
| 33 | 35 | |
| @@ -44,11 +46,19 @@ export const LABELS: Readonly<Record<Kind, string>> = { | ||
| 44 | 46 | social_price: "Price improvement post", |
| 45 | 47 | email_price: "Price improvement email", |
| 46 | 48 | email_followup: "Open house follow-up email", |
| 49 | social_reminder: "Open tomorrow post", | |
| 50 | email_reminder: "Open tomorrow email", | |
| 47 | 51 | }; |
| 48 | 52 | |
| 49 | 53 | /** The assets that exist only while a listing is coming soon, under contract, sold, or back on the market. */ |
| 50 | 54 | export const STATUS_KINDS = ["social_status", "email_status"] as const satisfies readonly Kind[]; |
| 51 | 55 | |
| 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 | ||
| 52 | 62 | /** The assets that exist only while the price stands below what it was. */ |
| 53 | 63 | export const PRICE_KINDS = ["social_price", "email_price"] as const satisfies readonly Kind[]; |
| 54 | 64 | |
| @@ -536,12 +546,54 @@ function followUpEmail(f: Facts, a: Agent): string { | ||
| 536 | 546 | 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}`; |
| 537 | 547 | } |
| 538 | 548 | |
| 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. */ | |
| 540 | 590 | export function generate(listing: { |
| 541 | 591 | readonly facts: Facts; |
| 542 | 592 | readonly agent: Agent; |
| 593 | /** The property page's absolute URL, for the reminders to carry. */ | |
| 594 | readonly link?: string; | |
| 543 | 595 | }): 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; | |
| 545 | 597 | const announced = f.status === "coming_soon" || f.status === "under_contract" || f.status === "sold" || (f.status === "active" && f.back_on_market === true); |
| 546 | 598 | const cut = priceCut(f); |
| 547 | 599 | return { |
| @@ -554,6 +606,7 @@ export function generate(listing: { | ||
| 554 | 606 | ...(announced ? { social_status: statusPost(f, a), email_status: statusEmail(f, a) } : {}), |
| 555 | 607 | ...(cut ? { social_price: pricePost(f, a), email_price: priceEmail(f, a) } : {}), |
| 556 | 608 | ...(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) }), | |
| 557 | 610 | }; |
| 558 | 611 | } |
| 559 | 612 |
src/openhouse.test.ts +2 −2
| @@ -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(7); | |
| 106 | expect(approve()).toBe(9); | |
| 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(9); | |
| 114 | expect(approve()).toBe(11); | |
| 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([]); |
src/pages/email.ts +16 −7
| @@ -31,8 +31,13 @@ export function emailParts( | ||
| 31 | 31 | return { subject, paragraphs }; |
| 32 | 32 | } |
| 33 | 33 | |
| 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 | ||
| 34 | 39 | /** 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 { | |
| 36 | 41 | const { subject, paragraphs } = emailParts( |
| 37 | 42 | l.assets.find((a) => a.kind === kind)?.text ?? "", |
| 38 | 43 | l.agent_name, |
| @@ -89,19 +94,23 @@ export function emailPage(input: { | ||
| 89 | 94 | who: Who; |
| 90 | 95 | listing: Launched; |
| 91 | 96 | 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; | |
| 94 | 99 | /** The sign-in sheet's visitors, for the follow-up; absent for anyone but the listing's author. */ |
| 95 | 100 | visitors?: readonly Visitor[]; |
| 96 | 101 | }): string { |
| 97 | 102 | 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"); | |
| 100 | 107 | const path = `/listings/${l.id}/email`; |
| 101 | 108 | return layout( |
| 102 | 109 | `<div class="print-bar row"> |
| 103 | 110 | <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>` : ""} | |
| 105 | 114 | <button type="button" class="button" data-copy-email hidden>Copy as email</button> |
| 106 | 115 | <button type="button" class="button button-quiet" data-copy-subject hidden>Copy subject</button> |
| 107 | 116 | <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: { | ||
| 109 | 118 | ${email(l, input.origin, kind)} |
| 110 | 119 | ${kind === "email_followup" && input.visitors !== undefined ? visitorList(l, input.visitors) : ""}`, |
| 111 | 120 | 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}` }, | |
| 113 | 122 | ); |
| 114 | 123 | } |
src/pages/listing.ts +4 −4
| @@ -113,7 +113,7 @@ export const statusForm = (l: Listing): string => | ||
| 113 | 113 | </form>`; |
| 114 | 114 | |
| 115 | 115 | /** 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 => | |
| 117 | 117 | l.assets.find((a) => a.kind === kind)?.approval == null |
| 118 | 118 | ? `<p class="draft-band" role="note">Draft — not approved</p>` |
| 119 | 119 | : ""; |
| @@ -187,9 +187,9 @@ function editor(l: Launched, a: Asset, editable: boolean, versions: readonly Ver | ||
| 187 | 187 | |
| 188 | 188 | const TABS: readonly { id: string; label: string; kinds: readonly string[] }[] = [ |
| 189 | 189 | { 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"] }, | |
| 191 | 191 | { 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"] }, | |
| 193 | 193 | ]; |
| 194 | 194 | |
| 195 | 195 | /** The tab that holds an asset, or a tab named outright; the first otherwise. */ |
| @@ -264,7 +264,7 @@ export function listingPage(input: { | ||
| 264 | 264 | (t) => `<div class="tabpanel" role="tabpanel" id="panel-${t.id}" aria-labelledby="tab-${t.id}"${t.id === open ? "" : " hidden"}> |
| 265 | 265 | ${t.id === "flyer" ? `<div class="flyer-preview"><a href="/listings/${l.id}/flyer" aria-label="Open the printable flyer">${flyer(l, origin)}</a></div>` : ""} |
| 266 | 266 | ${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>` : ""} | |
| 268 | 268 | ${l.assets |
| 269 | 269 | .filter((a) => t.kinds.includes(a.kind)) |
| 270 | 270 | .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("<img src=x onerror=alert(1)>"); | |
| 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 | ||
| 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(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); | |
| 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 +3 −2
| @@ -658,14 +658,15 @@ export const routes: readonly Route[] = [ | ||
| 658 | 658 | path: "/listings/:id/email", |
| 659 | 659 | handle: (c) => { |
| 660 | 660 | 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"; | |
| 662 | 663 | const visitors = follow ? listVisitors(c.db, c.who, { id }) : undefined; |
| 663 | 664 | return page(c.who, getListing(c.db, c.who, { id }), (listing) => |
| 664 | 665 | emailPage({ |
| 665 | 666 | who: c.who, |
| 666 | 667 | listing, |
| 667 | 668 | origin: publicOrigin, |
| 668 | kind: follow ? "email_followup" : "email", | |
| 669 | kind: follow ? "email_followup" : which === "reminder" ? "email_reminder" : "email", | |
| 669 | 670 | ...(visitors?.ok ? { visitors: visitors.value } : {}), |
| 670 | 671 | }), |
| 671 | 672 | ); |
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 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", () => { | |
| 62 | 62 | const db = fresh(); |
| 63 | 63 | seed(db); |
| 64 | 64 | const alder = approvedOf(db, "412 Alder%"); |
| 65 | expect(alder).toHaveLength(7); | |
| 65 | expect(alder).toHaveLength(9); | |
| 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 +22
| @@ -388,3 +388,25 @@ test("P previews the follow-up with its visitors; V lists, adds and removes them | ||
| 388 | 388 | expect(db.query("SELECT count(*) AS n FROM visitor WHERE listing_id = ?1").get(id)).toEqual({ n: 0 }); |
| 389 | 389 | } |
| 390 | 390 | }); |
| 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 @@ | ||
| 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 | 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 | |
| 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 | |
| 13 | 14 | * those visitors (the listing's agent or an admin; a guest sees none): a adds |
| 14 | 15 | * one as `Name, email`, d removes the one under the cursor, esc goes back; k shows |
| 15 | 16 | * 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 } | ||
| 102 | 103 | let view: "listings" | "notes" = "listings"; |
| 103 | 104 | let reading: number | undefined; |
| 104 | 105 | /** 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; | |
| 106 | 107 | /** The sign-in visitors of the open house, the cursor on one. */ |
| 107 | 108 | let visiting = false; |
| 108 | 109 | let visitorCursor = 0; |
| @@ -212,8 +213,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 212 | 213 | else if (versioning !== undefined) help = "1-5 put that version back · esc back to the assets · q quit"; |
| 213 | 214 | else help = |
| 214 | 215 | 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"; | |
| 217 | 218 | } else if (view === "listings") { |
| 218 | 219 | const rows = listings(); |
| 219 | 220 | 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 } | ||
| 316 | 317 | const found = open(); |
| 317 | 318 | const assets = found?.ok === true ? found.value.assets : []; |
| 318 | 319 | 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; | |
| 322 | 325 | return undefined; |
| 323 | 326 | } |
| 324 | 327 | 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 () => { | ||
| 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 9 approved"); | |
| 219 | expect(afterOpen).toContain("0 of 11 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(); |