Public: anyone can read this; the crew can change it.
The weekly seller update says how many showings the home had this week and what buyers' agents said #110
Check · Review · Land · Live — Its check is red.
mason/listing-launch:mason/103-the-weekly-seller-update-says-how-many-s → main 1 commit fork
4 files
changed +130
−9
README.md +5 −1
| @@ -154,7 +154,11 @@ to make a listing of your own. | ||
| 154 | 154 | weekly email to the seller, written in the agent's name from what the app |
| 155 | 155 | knows — the address and status, every asset approved in the last seven |
| 156 | 156 | days (who and when), what the launch week has in the next seven days, the |
| 157 | open house dates and the property page link. Edit it, read the Fair Housing | |
| 157 | open house dates and the property page link, and a short showings | |
| 158 | paragraph: how many showings this week, how many buyers were Interested or | |
| 159 | Offer likely, and what their agents said, never naming them ("No showings this | |
| 160 | week" when you logged none). Logging or removing a showing rewrites an unedited | |
| 161 | draft and clears its approval. Edit it, read the Fair Housing | |
| 158 | 162 | check, then press **Approve** like any asset; until then it wears "Draft — not |
| 159 | 163 | approved". It follows approvals while it is an unedited draft, and editing |
| 160 | 164 | the facts or the status clears its approval. An assistant cannot approve it. |
src/actions.ts +34 −6
| @@ -49,7 +49,7 @@ import { | ||
| 49 | 49 | write as writePhoto, |
| 50 | 50 | } from "./photos.ts"; |
| 51 | 51 | import { headshotFrom } from "./agent.ts"; |
| 52 | import { sellerUpdate } from "./update.ts"; | |
| 52 | import { sellerUpdate, withoutShowings } from "./update.ts"; | |
| 53 | 53 | import { type PriceEntry, type PriceKind } from "./market.ts"; |
| 54 | 54 | import { dayOf, validShowing, SHOWINGS_MAX, type Showing } from "./showings.ts"; |
| 55 | 55 | import { validVisitor, VISITORS_MAX, type Visitor } from "./visitors.ts"; |
| @@ -405,7 +405,7 @@ function writeAssets( | ||
| 405 | 405 | } |
| 406 | 406 | } |
| 407 | 407 | if (only.includes("email_update")) { |
| 408 | const body = sellerUpdate(launched(db, listing.id)!, new Date()); | |
| 408 | const body = sellerUpdate(launched(db, listing.id)!, new Date(), undefined, loggedShowings(db, listing.id)); | |
| 409 | 409 | if (by !== undefined) keepWords(db, listing.id, "email_update", body, by.who, by.why); |
| 410 | 410 | put.run(listing.id, "email_update", body); |
| 411 | 411 | } |
| @@ -422,13 +422,34 @@ export function redraftUpdate(db: Database, id: number): void { | ||
| 422 | 422 | ) |
| 423 | 423 | .get(id); |
| 424 | 424 | if (update === null) return; |
| 425 | const body = sellerUpdate(launched(db, id)!, new Date()); | |
| 425 | const body = sellerUpdate(launched(db, id)!, new Date(), undefined, loggedShowings(db, id)); | |
| 426 | 426 | if (body !== update.body) |
| 427 | 427 | db.query( |
| 428 | 428 | "UPDATE asset SET body = ?2, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE listing_id = ?1 AND kind = 'email_update'", |
| 429 | 429 | ).run(id, body); |
| 430 | 430 | } |
| 431 | 431 | |
| 432 | /** The showings the agent logged, for the seller update's words alone. */ | |
| 433 | const loggedShowings = (db: Database, id: number): Showing[] => | |
| 434 | db.query<Showing, [number]>("SELECT id, date, agent, interest, feedback FROM showing WHERE listing_id = ?1 ORDER BY date DESC, id DESC").all(id); | |
| 435 | ||
| 436 | /** | |
| 437 | * A showing logged or removed changes what the seller is told: an unedited | |
| 438 | * update is written again and loses its approval; one a person edited keeps its words. | |
| 439 | */ | |
| 440 | function showingsChanged(db: Database, id: number): void { | |
| 441 | const update = db | |
| 442 | .query<{ body: string }, [number]>("SELECT body FROM asset WHERE listing_id = ?1 AND kind = 'email_update' AND edited = 0") | |
| 443 | .get(id); | |
| 444 | if (update === null) return; | |
| 445 | const body = sellerUpdate(launched(db, id)!, new Date(), undefined, loggedShowings(db, id)); | |
| 446 | if (body === update.body) return; | |
| 447 | db.query( | |
| 448 | `UPDATE asset SET body = ?2, approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL, | |
| 449 | updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE listing_id = ?1 AND kind = 'email_update'`, | |
| 450 | ).run(id, body); | |
| 451 | } | |
| 452 | ||
| 432 | 453 | /** A listing launched before the seller update existed gets its draft, once; the ids drafted. */ |
| 433 | 454 | export function draftMissingUpdates(db: Database): number[] { |
| 434 | 455 | const ids = db |
| @@ -440,7 +461,7 @@ export function draftMissingUpdates(db: Database): number[] { | ||
| 440 | 461 | for (const id of ids) |
| 441 | 462 | db.query("INSERT INTO asset (listing_id, kind, body, edited) VALUES (?1, 'email_update', ?2, 0)").run( |
| 442 | 463 | id, |
| 443 | sellerUpdate(launched(db, id)!, new Date()), | |
| 464 | sellerUpdate(launched(db, id)!, new Date(), undefined, loggedShowings(db, id)), | |
| 444 | 465 | ); |
| 445 | 466 | return ids; |
| 446 | 467 | } |
| @@ -663,11 +684,16 @@ export const getListing = ( | ||
| 663 | 684 | db: Database, |
| 664 | 685 | who: Who, |
| 665 | 686 | input: { id: number }, |
| 666 | ): Result<Launched> => | |
| 667 | listingFor(db, who, "read", input.id, { | |
| 687 | ): Result<Launched> => { | |
| 688 | const found = listingFor(db, who, "read", input.id, { | |
| 668 | 689 | signIn: "Sign in to see this listing.", |
| 669 | 690 | no: "You may not see this listing.", |
| 670 | 691 | }); |
| 692 | // The seller update speaks for the showings the agent logged: readers see it without that paragraph. | |
| 693 | if (!found.ok || canEditListing(who, found.value)) return found; | |
| 694 | const assets = found.value.assets.map((a) => (a.kind === "email_update" ? { ...a, text: withoutShowings(a.text), check: checkWords(withoutShowings(a.text)) } : a)); | |
| 695 | return ok({ ...found.value, assets }); | |
| 696 | }; | |
| 671 | 697 | |
| 672 | 698 | /** |
| 673 | 699 | * A listing as its property page shows it: whoever may read it, once its MLS |
| @@ -1437,6 +1463,7 @@ function putShowing(db: Database, who: Who, input: ShowingInput): Result<{ id: n | ||
| 1437 | 1463 | "INSERT INTO showing (listing_id, date, agent, interest, feedback) VALUES (?1, ?2, ?3, ?4, ?5) RETURNING id", |
| 1438 | 1464 | ) |
| 1439 | 1465 | .get(found.value.id, v.date, v.agent, v.interest, v.feedback)!; |
| 1466 | showingsChanged(db, found.value.id); | |
| 1440 | 1467 | emit(db, "listing.changed", { id: found.value.id }); |
| 1441 | 1468 | return ok({ id: found.value.id, showing: row.id }); |
| 1442 | 1469 | } |
| @@ -1452,6 +1479,7 @@ function dropShowing(db: Database, who: Who, input: { id: number; showing: numbe | ||
| 1452 | 1479 | found.value.id, |
| 1453 | 1480 | ); |
| 1454 | 1481 | if (gone.changes === 0) return fail("not_found", "No such showing."); |
| 1482 | showingsChanged(db, found.value.id); | |
| 1455 | 1483 | emit(db, "listing.changed", { id: found.value.id }); |
| 1456 | 1484 | return ok({ id: found.value.id }); |
| 1457 | 1485 | } |
src/update.test.ts +65 −1
| @@ -8,7 +8,7 @@ import { expect, test } from "bun:test"; | ||
| 8 | 8 | import { mkdtempSync } from "node:fs"; |
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | import { approveAsset, editAsset, getListing, launchListing, restoreAsset, setStatus, updateFacts } from "./actions.ts"; | |
| 11 | import { addShowing, approveAsset, editAsset, removeShowing, getListing, launchListing, restoreAsset, setStatus, updateFacts } from "./actions.ts"; | |
| 12 | 12 | import { openDb } from "./db.ts"; |
| 13 | 13 | import { check } from "./fairhousing.ts"; |
| 14 | 14 | import { rpc } from "./mcp.ts"; |
| @@ -174,3 +174,67 @@ test("editing or restoring an approved asset drops it from an unedited draft", ( | ||
| 174 | 174 | expect(listed()).not.toContain("- MLS description:"); |
| 175 | 175 | |
| 176 | 176 | }); |
| 177 | ||
| 178 | const today = () => new Date().toISOString().slice(0, 10); | |
| 179 | ||
| 180 | test("the update counts the week's showings and the keen ones, and says each line without the buyer's agent", () => { | |
| 181 | const { db, id } = launch(); | |
| 182 | expect(update(db, id).text).toContain("No showings this week"); | |
| 183 | const log = (date: string, agent: string, interest: string, feedback: string) => | |
| 184 | expect(addShowing(db, me, { id, date, agent, interest, feedback }).ok).toBe(true); | |
| 185 | log(today(), "Dana Whitcombe", "offer_likely", "Loved the kitchen"); | |
| 186 | log(day(-2), "Priya Natarajan", "interested", "Wants a second look"); | |
| 187 | log(day(-3), "Omar Feld", "not_interested", "Too far from work"); | |
| 188 | log(day(-12), "Old Agent", "interested", "Back in the spring"); | |
| 189 | const text = update(db, id).text; | |
| 190 | expect(text).toContain("3 showings this week, 2 of them Interested or Offer likely."); | |
| 191 | expect(text).toContain("- Loved the kitchen"); | |
| 192 | expect(text).toContain("- Too far from work"); | |
| 193 | expect(text).not.toContain("Back in the spring"); | |
| 194 | for (const name of ["Dana", "Whitcombe", "Priya", "Omar", "Old Agent"]) expect(text).not.toContain(name); | |
| 195 | expect(update(db, id).check.clear).toBe(true); | |
| 196 | }); | |
| 197 | ||
| 198 | test("a showing older than seven days alone leaves the week empty", () => { | |
| 199 | const { db, id } = launch(); | |
| 200 | addShowing(db, me, { id, date: day(-8), agent: "A", interest: "interested", feedback: "Old news" }); | |
| 201 | const text = update(db, id).text; | |
| 202 | expect(text).toContain("No showings this week"); | |
| 203 | expect(text).not.toContain("Old news"); | |
| 204 | }); | |
| 205 | ||
| 206 | test("logging or removing a showing rewrites an unedited update and clears its approval", () => { | |
| 207 | const { db, id } = launch(); | |
| 208 | expect(approve(db, id, "email_update").ok).toBe(true); | |
| 209 | expect(update(db, id).approval).not.toBeNull(); | |
| 210 | const made = addShowing(db, me, { id, agent: "A", interest: "maybe", feedback: "Nice yard" }); | |
| 211 | if (!made.ok) throw new Error(made.message); | |
| 212 | expect(update(db, id).approval).toBeNull(); | |
| 213 | expect(update(db, id).text).toContain("1 showing this week, 0 of them"); | |
| 214 | expect(approve(db, id, "email_update").ok).toBe(true); | |
| 215 | expect(removeShowing(db, me, { id, showing: made.value.showing }).ok).toBe(true); | |
| 216 | expect(update(db, id).approval).toBeNull(); | |
| 217 | expect(update(db, id).text).toContain("No showings this week"); | |
| 218 | }); | |
| 219 | ||
| 220 | test("showings stay off every public surface", () => { | |
| 221 | const { db, id } = launch(); | |
| 222 | addShowing(db, me, { id, agent: "Dana Whitcombe", interest: "interested", feedback: "Secret remark" }); | |
| 223 | const l = read(db, id); | |
| 224 | for (const a of l.assets.filter((x) => x.kind !== "email_update")) { | |
| 225 | expect(a.text).not.toContain("Secret remark"); | |
| 226 | expect(a.text).not.toContain("Whitcombe"); | |
| 227 | } | |
| 228 | }); | |
| 229 | ||
| 230 | test("a reader of the listing gets the update without the showings paragraph, the agent keeps it", () => { | |
| 231 | const { db, id } = launch(); | |
| 232 | addShowing(db, me, { id, agent: "Dana Whitcombe", interest: "interested", feedback: "Secret remark" }); | |
| 233 | expect(update(db, id).text).toContain("Secret remark"); | |
| 234 | const seen = getListing(db, { role: "anon" }, { id }); | |
| 235 | if (!seen.ok) throw new Error(seen.message); | |
| 236 | const text = seen.value.assets.find((a) => a.kind === "email_update")!.text; | |
| 237 | expect(text).not.toContain("Secret remark"); | |
| 238 | expect(text).not.toContain("showing"); | |
| 239 | expect(text).toContain("What went out this week:"); | |
| 240 | }); |
src/update.ts +26 −1
| @@ -14,11 +14,34 @@ import { marketLines } from "./market.ts"; | ||
| 14 | 14 | import { propertyPath, type Launched } from "./listing.ts"; |
| 15 | 15 | import { approvedLine, oneLine } from "./report.ts"; |
| 16 | 16 | import { addDays, dayName, scheduleRows } from "./schedule.ts"; |
| 17 | import { INTEREST_LABELS, type Showing } from "./showings.ts"; | |
| 17 | 18 | |
| 18 | 19 | /** The days a week looks back over and forward to. */ |
| 19 | 20 | const WEEK = 7; |
| 20 | 21 | |
| 21 | export function sellerUpdate(l: Launched, now: Date, origin = publicOrigin): string { | |
| 22 | /** | |
| 23 | * The showings paragraph: the last seven days' count, how many buyers were | |
| 24 | * Interested or Offer likely, and each feedback line as written. The buyer's | |
| 25 | * agent is never named; the rows are the agent's alone, only these words go out. | |
| 26 | */ | |
| 27 | export function showingLines(showings: readonly Showing[], today: string): string[] { | |
| 28 | const from = addDays(today, 1 - WEEK); | |
| 29 | const week = showings.filter((s) => s.date >= from && s.date <= today); | |
| 30 | if (week.length === 0) return ["No showings this week."]; | |
| 31 | const keen = week.filter((s) => s.interest === "interested" || s.interest === "offer_likely").length; | |
| 32 | const said = week.filter((s) => oneLine(s.feedback) !== "").map((s) => `- ${oneLine(s.feedback)} (${INTEREST_LABELS[s.interest]})`); | |
| 33 | return [ | |
| 34 | `${week.length} ${week.length === 1 ? "showing" : "showings"} this week, ${keen} of them Interested or Offer likely.`, | |
| 35 | ...(said.length === 0 ? [] : ["What buyers' agents said:", ...said]), | |
| 36 | ]; | |
| 37 | } | |
| 38 | ||
| 39 | const SHOWINGS_PARAGRAPH = /^(?:No showings this week\.|\d+ showings? this week, [^\n]*)\n(?:What buyers' agents said:\n(?:- [^\n]*\n)*)?\n/m; | |
| 40 | ||
| 41 | /** The update as a reader of the listing may have it: the showings paragraph is the agent's alone. */ | |
| 42 | export const withoutShowings = (text: string): string => text.replace(SHOWINGS_PARAGRAPH, ""); | |
| 43 | ||
| 44 | export function sellerUpdate(l: Launched, now: Date, origin = publicOrigin, showings: readonly Showing[] = []): string { | |
| 22 | 45 | const today = now.toISOString().slice(0, 10); |
| 23 | 46 | const since = new Date(now.getTime() - WEEK * 86_400_000).toISOString(); |
| 24 | 47 | const sent = l.assets |
| @@ -41,6 +64,8 @@ export function sellerUpdate(l: Launched, now: Date, origin = publicOrigin): str | ||
| 41 | 64 | "", |
| 42 | 65 | ...marketLines(l, today), |
| 43 | 66 | "", |
| 67 | ...showingLines(showings, today), | |
| 68 | "", | |
| 44 | 69 | "What went out this week:", |
| 45 | 70 | ...(sent.length === 0 ? ["- Nothing new was approved this week."] : sent), |
| 46 | 71 | "", |