Public: anyone can read this; the crew can change it.
Put a listing back on the market when a deal falls through, and get a 'Back on market' post and email to approve #61
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/59-put-a-listing-back-on-the-market-when-a → main 3 commits fork 1 thread · 1 unresolved
7 files
changed +185
−13
README.md +7 −3
| @@ -61,9 +61,13 @@ to make a listing of your own. | ||
| 61 | 61 | flyer wears a "Sold" band, and the list on the first screen shows the |
| 62 | 62 | status beside the approval count. *88 Meridian Street* is already sold, so |
| 63 | 63 | you can see it. **Under contract** does the same with its own words, and |
| 64 | **Active** takes the two away again. **Coming soon** teases a home before it | |
| 65 | is listed: a post and an email ("Coming soon — 412 Alder Lane", the price, | |
| 66 | the facts, "Message me to be first to see it"), never a date you did not give. | |
| 64 | **Active** takes the two away again, except from under contract, where it | |
| 65 | reads **Back on market**: the deal fell through, the listing is active, and | |
| 66 | a "Back on market" post and email (address, price, open house if set, your | |
| 67 | contact) wait for approval; from sold or coming soon it just takes them | |
| 68 | away, and saying Active on an active listing changes nothing. | |
| 69 | **Coming soon** teases a home before it is listed: a post and an email | |
| 70 | ("Coming soon — 412 Alder Lane", the price, the facts, "Message me to be first to see it"), never a date you did not give. | |
| 67 | 71 | **Lower the price.** In **Edit facts**, a lower price adds a price |
| 68 | 72 | improvement post (Social) and email ("New price — 412 Alder Lane, now |
| 69 | 73 | $849,000, was $899,000") in the listing's tone, each Fair Housing checked |
src/actions.ts +12 −4
| @@ -10,7 +10,7 @@ import type { Database } from "bun:sqlite"; | ||
| 10 | 10 | import { record, type AuditRow } from "./audit.ts"; |
| 11 | 11 | import { permit } from "./contract.ts"; |
| 12 | 12 | import { check, type Check } from "./fairhousing.ts"; |
| 13 | import { generate, KINDS, STATUS_KINDS, TONES, type Kind, type Status } from "./generate.ts"; | |
| 13 | import { generate, isBackOnMarket, KINDS, STATUS_KINDS, TONES, type Kind, type Status } from "./generate.ts"; | |
| 14 | 14 | import { |
| 15 | 15 | agentOf, |
| 16 | 16 | ASSET_MAX, |
| @@ -357,8 +357,14 @@ function writeAssets( | ||
| 357 | 357 | listing: Listing, |
| 358 | 358 | only: readonly Kind[] = KINDS, |
| 359 | 359 | by?: { who: Who; why: string }, |
| 360 | backOnMarket?: boolean, | |
| 360 | 361 | ): void { |
| 361 | const words = generate({ facts: listing, agent: agentOf(listing) }); | |
| 362 | // An active listing that already carries status words is back on the market; a re-save keeps them. | |
| 363 | const back = | |
| 364 | backOnMarket ?? | |
| 365 | (listing.status === "active" && | |
| 366 | db.query("SELECT 1 FROM asset WHERE listing_id = ?1 AND kind = 'social_status'").get(listing.id) !== null); | |
| 367 | const words = generate({ facts: { ...listing, back_on_market: back }, agent: agentOf(listing) }); | |
| 362 | 368 | const put = db.query( |
| 363 | 369 | `INSERT INTO asset (listing_id, kind, body, edited) VALUES (?1, ?2, ?3, 0) |
| 364 | 370 | ON CONFLICT (listing_id, kind) DO UPDATE SET body = excluded.body, edited = 0, |
| @@ -447,12 +453,13 @@ export function markStatus( | ||
| 447 | 453 | stand: { status: Status; sold_price: number | null }, |
| 448 | 454 | ): Launched { |
| 449 | 455 | return db.transaction(() => { |
| 456 | const before = db.query<{ status: Status }, [number]>("SELECT status FROM listing WHERE id = ?1").get(id)!; | |
| 450 | 457 | const row = db |
| 451 | 458 | .query<ListingRow, [string, number | null, number]>( |
| 452 | 459 | "UPDATE listing SET status = ?1, sold_price = ?2, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?3 RETURNING *", |
| 453 | 460 | ) |
| 454 | 461 | .get(stand.status, stand.sold_price, id)!; |
| 455 | writeAssets(db, fromRow(row), STATUS_KINDS); | |
| 462 | writeAssets(db, fromRow(row), STATUS_KINDS, undefined, isBackOnMarket(before.status, stand.status)); | |
| 456 | 463 | return launched(db, id)!; |
| 457 | 464 | })(); |
| 458 | 465 | } |
| @@ -1034,7 +1041,8 @@ function changeFacts( | ||
| 1034 | 1041 | * Mark a listing coming soon, active, under contract or sold (with the price it |
| 1035 | 1042 | * sold at, if the agent says). Coming soon, under contract and sold add a |
| 1036 | 1043 | * status post and a status email in the listing's tone, each needing its own |
| 1037 | * approval; active takes them away. The other assets, their edits and their approvals are left | |
| 1044 | * approval; active takes them away, except from under contract, where it | |
| 1045 | * writes "Back on market" words in their place. The other assets, their edits and their approvals are left | |
| 1038 | 1046 | * alone. Saying what is already so changes nothing. |
| 1039 | 1047 | */ |
| 1040 | 1048 | export const setStatus = ( |
src/backonmarket.test.ts added +121
| @@ -0,0 +1,121 @@ | ||
| 1 | /** | |
| 2 | * Back on market: a deal that falls through (under contract → active) writes a | |
| 3 | * "Back on market" post and email, unapproved; active → active writes nothing; | |
| 4 | * sold → active takes the status words away. | |
| 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, retoneListing, setStatus } from "./actions.ts"; | |
| 11 | import { openDb } from "./db.ts"; | |
| 12 | import { check } from "./fairhousing.ts"; | |
| 13 | import { generate, STATUS_KINDS, TONES } from "./generate.ts"; | |
| 14 | import { agentOf } from "./listing.ts"; | |
| 15 | import { rpc } from "./mcp.ts"; | |
| 16 | import { statusForm } from "./pages/listing.ts"; | |
| 17 | import { postLength } from "./postlength.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 | ||
| 23 | const launch = () => { | |
| 24 | const db = openDb(mkdtempSync(join(tmpdir(), "app-bom-"))); | |
| 25 | const made = launchListing(db, me, { fields: SAMPLE }); | |
| 26 | if (!made.ok) throw new Error(made.message); | |
| 27 | return { db, id: made.value.id }; | |
| 28 | }; | |
| 29 | const get = (db: ReturnType<typeof openDb>, id: number) => { | |
| 30 | const found = getListing(db, me, { id }); | |
| 31 | if (!found.ok) throw new Error(found.message); | |
| 32 | return found.value; | |
| 33 | }; | |
| 34 | const body = (db: ReturnType<typeof openDb>, id: number, kind: string) => get(db, id).assets.find((a) => a.kind === kind)?.text; | |
| 35 | ||
| 36 | test("the words: every tone, clear of Fair Housing, with address, price, contact and open house", () => { | |
| 37 | const agent = agentOf(SAMPLE); | |
| 38 | const open_houses = [{ date: "2027-03-09", start: "13:00", end: "15:00" }]; | |
| 39 | for (const tone of TONES) | |
| 40 | for (const open of [[], open_houses]) { | |
| 41 | const words = generate({ facts: { ...SAMPLE, tone, open_houses: open, status: "active", back_on_market: true }, agent }); | |
| 42 | for (const kind of STATUS_KINDS) { | |
| 43 | const text = words[kind]!; | |
| 44 | expect(check(text).findings).toEqual([]); | |
| 45 | expect(text).toContain("412 Alder Lane"); | |
| 46 | expect(text).toContain(`$${SAMPLE.price.toLocaleString("en-US")}`); | |
| 47 | expect(text).toContain(agent.phone); | |
| 48 | expect(text).not.toMatch(/undefined|NaN| {2}/); | |
| 49 | expect(/open house|Open|OPEN/.test(text)).toBe(open.length > 0); | |
| 50 | } | |
| 51 | expect(postLength(words.social_status!).fits).toContain("instagram"); | |
| 52 | expect(words.email_status).toStartWith("Subject: Back on market — 412 Alder Lane"); | |
| 53 | } | |
| 54 | expect(generate({ facts: { ...SAMPLE, status: "active" }, agent }).social_status).toBeUndefined(); | |
| 55 | }); | |
| 56 | ||
| 57 | test("under contract → active writes Back on market copy, approval cleared; the rest stays", () => { | |
| 58 | const { db, id } = launch(); | |
| 59 | setStatus(db, me, { id, status: "under_contract" }); | |
| 60 | approveAsset(db, me, { id, kind: "social_status", door: "page", seen: get(db, id).assets.find((a) => a.kind === "social_status")!.updated_at }); | |
| 61 | const r = setStatus(db, me, { id, status: "active" }); | |
| 62 | expect(r.ok).toBe(true); | |
| 63 | const l = get(db, id); | |
| 64 | expect(l.status).toBe("active"); | |
| 65 | expect(l.assets.find((a) => a.kind === "social_status")!.text).toContain("#BackOnMarket"); | |
| 66 | expect(body(db, id, "email_status")).toContain("Back on market"); | |
| 67 | expect(l.assets.filter((a) => STATUS_KINDS.includes(a.kind as never)).every((a) => a.approval == null)).toBe(true); | |
| 68 | }); | |
| 69 | ||
| 70 | test("active → active writes nothing new and keeps approvals; a retone keeps the words", () => { | |
| 71 | const { db, id } = launch(); | |
| 72 | setStatus(db, me, { id, status: "under_contract" }); | |
| 73 | setStatus(db, me, { id, status: "active" }); | |
| 74 | approveAsset(db, me, { id, kind: "social_status", door: "page", seen: get(db, id).assets.find((a) => a.kind === "social_status")!.updated_at }); | |
| 75 | setStatus(db, me, { id, status: "active" }); | |
| 76 | expect(get(db, id).assets.find((a) => a.kind === "social_status")!.approval).not.toBeNull(); | |
| 77 | retoneListing(db, me, { id, tone: "bold" }); | |
| 78 | expect(body(db, id, "social_status")).toContain("BACK ON MARKET"); | |
| 79 | const plain = launch(); | |
| 80 | setStatus(plain.db, me, { id: plain.id, status: "active" }); | |
| 81 | expect(body(plain.db, plain.id, "social_status")).toBeUndefined(); | |
| 82 | }); | |
| 83 | ||
| 84 | test("sold → active, and coming soon → active, take the status words away", () => { | |
| 85 | for (const from of ["sold", "coming_soon"] as const) { | |
| 86 | const { db, id } = launch(); | |
| 87 | setStatus(db, me, { id, status: from }); | |
| 88 | setStatus(db, me, { id, status: "active" }); | |
| 89 | expect(get(db, id).assets).toHaveLength(6); | |
| 90 | } | |
| 91 | }); | |
| 92 | ||
| 93 | test("the home list: back on market is Active and Needs approval until both are approved", () => { | |
| 94 | const { db, id } = launch(); | |
| 95 | setStatus(db, me, { id, status: "under_contract" }); | |
| 96 | setStatus(db, me, { id, status: "active" }); | |
| 97 | const first = (status: string) => { | |
| 98 | const r = rpc(db, me, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "list_listings", arguments: { status } } }); | |
| 99 | return JSON.stringify(r.body); | |
| 100 | }; | |
| 101 | expect(first("active")).toContain("412 Alder Lane"); | |
| 102 | expect(first("needs-approval")).toContain("412 Alder Lane"); | |
| 103 | }); | |
| 104 | ||
| 105 | test("over MCP the same change works and an agent still cannot approve it", () => { | |
| 106 | const { db, id } = launch(); | |
| 107 | const call = (name: string, args: object) => | |
| 108 | JSON.stringify(rpc(db, me, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }).body); | |
| 109 | call("set_status", { id, status: "under_contract" }); | |
| 110 | call("set_status", { id, status: "active" }); | |
| 111 | expect(body(db, id, "social_status")).toContain("#BackOnMarket"); | |
| 112 | expect(call("approve_asset", { id, kind: "social_status", approved: true })).toContain("person_required"); | |
| 113 | expect(get(db, id).assets.find((a) => a.kind === "social_status")!.approval).toBeNull(); | |
| 114 | }); | |
| 115 | ||
| 116 | test("the page offers Back on market on an under-contract listing only", () => { | |
| 117 | const { db, id } = launch(); | |
| 118 | expect(statusForm(get(db, id))).not.toContain("Back on market"); | |
| 119 | setStatus(db, me, { id, status: "under_contract" }); | |
| 120 | expect(statusForm(get(db, id))).toContain('>Back on market</option>'); | |
| 121 | }); |
src/generate.ts +36 −2
| @@ -44,7 +44,7 @@ export const LABELS: Readonly<Record<Kind, string>> = { | ||
| 44 | 44 | email_price: "Price improvement email", |
| 45 | 45 | }; |
| 46 | 46 | |
| 47 | /** The assets that exist only while a listing is coming soon, under contract or sold. */ | |
| 47 | /** The assets that exist only while a listing is coming soon, under contract, sold, or back on the market. */ | |
| 48 | 48 | export const STATUS_KINDS = ["social_status", "email_status"] as const satisfies readonly Kind[]; |
| 49 | 49 | |
| 50 | 50 | /** The assets that exist only while the price stands below what it was. */ |
| @@ -61,6 +61,9 @@ export const STATUS_LABELS: Readonly<Record<Status, string>> = { | ||
| 61 | 61 | sold: "Sold", |
| 62 | 62 | }; |
| 63 | 63 | |
| 64 | /** The one transition that announces itself on an active listing: a deal that fell through. */ | |
| 65 | export const isBackOnMarket = (from: Status, to: Status): boolean => from === "under_contract" && to === "active"; | |
| 66 | ||
| 64 | 67 | /** "Sold at $752,000" for a sold listing with a price; the status's label otherwise. */ |
| 65 | 68 | export const statusLine = (l: { status: Status; sold_price: number | null }): string => |
| 66 | 69 | l.status === "sold" && l.sold_price !== null ? `Sold at ${money(l.sold_price)}` : STATUS_LABELS[l.status]; |
| @@ -87,6 +90,8 @@ export interface Facts { | ||
| 87 | 90 | readonly status?: Status; |
| 88 | 91 | /** Whole dollars, only when sold, and only if the agent chose to say. */ |
| 89 | 92 | readonly sold_price?: number | null; |
| 93 | /** True on an active listing whose deal fell through: it gets "Back on market" words. */ | |
| 94 | readonly back_on_market?: boolean; | |
| 90 | 95 | /** Whole dollars: the price before it was lowered; absent or null when it has not been. */ |
| 91 | 96 | readonly was_price?: number | null; |
| 92 | 97 | } |
| @@ -349,9 +354,37 @@ function comingSoonEmail(f: Facts, a: Agent, where: string): string { | ||
| 349 | 354 | return `Subject: Coming soon — ${where}\n\n${open}\n\n${teaserFacts(f)}.\n\n${comingSoonInvite(f.tone)}\n\n${a.name}\n${a.brokerage} · License ${a.license}\n${a.phone} · ${a.email}`; |
| 350 | 355 | } |
| 351 | 356 | |
| 357 | const backOnMarketClose = (tone: Tone): string => | |
| 358 | ({ | |
| 359 | classic: "Message me to arrange a private showing.", | |
| 360 | warm: "I would love to show you around. Send me a message to set up a visit.", | |
| 361 | bold: "Don't miss it this time. Message me today.", | |
| 362 | })[tone]; | |
| 363 | ||
| 364 | function backOnMarketPost(f: Facts, a: Agent, where: string): string { | |
| 365 | const line = { | |
| 366 | classic: `Back on the market: ${where}, ${money(f.price)}.`, | |
| 367 | warm: `Good news: ${where} is back on the market at ${money(f.price)}!`, | |
| 368 | bold: `BACK ON MARKET: ${where}. ${money(f.price)}.`, | |
| 369 | }[f.tone]; | |
| 370 | const when = openHouseLine(f); | |
| 371 | return `${line}${when === null ? "" : ` ${when}`} ${backOnMarketClose(f.tone)} ${a.name}, ${a.brokerage}, ${a.phone}. #BackOnMarket`; | |
| 372 | } | |
| 373 | ||
| 374 | function backOnMarketEmail(f: Facts, a: Agent, where: string): string { | |
| 375 | const open = { | |
| 376 | classic: `I am pleased to share that ${f.address} is back on the market, offered at ${money(f.price)}.`, | |
| 377 | warm: `I have news to share: ${f.address} is back on the market at ${money(f.price)}, and it could be yours.`, | |
| 378 | bold: `${f.address} is BACK ON THE MARKET at ${money(f.price)}.`, | |
| 379 | }[f.tone]; | |
| 380 | const when = openHouseLine(f); | |
| 381 | return `Subject: Back on market — ${where}, ${money(f.price)}\n\n${when === null ? "" : `${when}\n\n`}${open}\n\n${backOnMarketClose(f.tone)}\n\n${a.name}\n${a.brokerage} · License ${a.license}\n${a.phone} · ${a.email}`; | |
| 382 | } | |
| 383 | ||
| 352 | 384 | /** The status announcement's opening, in the listing's tone: "Just sold" or "Under contract". */ |
| 353 | 385 | function statusPost(f: Facts, a: Agent): string { |
| 354 | 386 | const where = street(f.address); |
| 387 | if (f.status === "active") return backOnMarketPost(f, a, where); | |
| 355 | 388 | if (f.status === "coming_soon") return comingSoonPost(f, a, where); |
| 356 | 389 | const sold = f.status === "sold"; |
| 357 | 390 | const price = sold && f.sold_price != null ? ` at ${money(f.sold_price)}` : ""; |
| @@ -382,6 +415,7 @@ function statusPost(f: Facts, a: Agent): string { | ||
| 382 | 415 | |
| 383 | 416 | function statusEmail(f: Facts, a: Agent): string { |
| 384 | 417 | const where = street(f.address); |
| 418 | if (f.status === "active") return backOnMarketEmail(f, a, where); | |
| 385 | 419 | if (f.status === "coming_soon") return comingSoonEmail(f, a, where); |
| 386 | 420 | const sold = f.status === "sold"; |
| 387 | 421 | const subject = sold ? `Just sold — ${where}` : `Under contract — ${where}`; |
| @@ -454,7 +488,7 @@ export function generate(listing: { | ||
| 454 | 488 | readonly agent: Agent; |
| 455 | 489 | }): Partial<Record<Kind, string>> & Record<(typeof CORE_KINDS)[number], string> { |
| 456 | 490 | const { facts: f, agent: a } = listing; |
| 457 | const announced = f.status === "coming_soon" || f.status === "under_contract" || f.status === "sold"; | |
| 491 | const announced = f.status === "coming_soon" || f.status === "under_contract" || f.status === "sold" || (f.status === "active" && f.back_on_market === true); | |
| 458 | 492 | const cut = priceCut(f); |
| 459 | 493 | return { |
| 460 | 494 | mls: mls(f), |
src/mcp.ts +1 −1
| @@ -260,7 +260,7 @@ export const tools: readonly Tool[] = [ | ||
| 260 | 260 | { |
| 261 | 261 | name: "set_status", |
| 262 | 262 | description: |
| 263 | "Mark a listing coming_soon, active, under_contract or sold (with sold_price in whole dollars, optional). Coming soon, under contract and sold add a status post and a status email (\"Just sold — 412 Alder Lane\") in the listing's tone, each Fair Housing checked and each needing a person's approval; active removes them. The other assets and their approvals are left alone. Only the listing's agent or an admin can.", | |
| 263 | "Mark a listing coming_soon, active, under_contract or sold (with sold_price in whole dollars, optional). Coming soon, under contract and sold add a status post and a status email (\"Just sold — 412 Alder Lane\") in the listing's tone, each Fair Housing checked and each needing a person's approval; active removes them, except from under_contract, where it writes \"Back on market\" words instead. The other assets and their approvals are left alone. Only the listing's agent or an admin can.", | |
| 264 | 264 | inputSchema: { |
| 265 | 265 | type: "object", |
| 266 | 266 | properties: { |
src/pages/listing.ts +2 −2
| @@ -100,10 +100,10 @@ export const statusPill = (l: Pick<Listing, "status" | "sold_price">): string => | ||
| 100 | 100 | : `<span class="pill status-pill status-${l.status}">${escape(statusLine(l))}</span>`; |
| 101 | 101 | |
| 102 | 102 | /** The agent's way to say where the listing stands: a choice, the price it sold at, one button. */ |
| 103 | const statusForm = (l: Listing): string => | |
| 103 | export const statusForm = (l: Listing): string => | |
| 104 | 104 | `<form class="row status-form" method="post" action="/listings/${l.id}/status"> |
| 105 | 105 | <label class="sr" for="status">Status</label> |
| 106 | <select class="input select" id="status" name="status">${STATUSES.map((s) => `<option value="${s}"${s === l.status ? " selected" : ""}>${STATUS_LABELS[s]}</option>`).join("")}</select> | |
| 106 | <select class="input select" id="status" name="status">${STATUSES.map((s) => `<option value="${s}"${s === l.status ? " selected" : ""}>${s === "active" && l.status === "under_contract" ? "Back on market" : STATUS_LABELS[s]}</option>`).join("")}</select> | |
| 107 | 107 | <label class="sr" for="sold_price">Sold price, optional</label> |
| 108 | 108 | <input class="input" id="sold_price" name="sold_price" inputmode="numeric" maxlength="14" placeholder="Sold price (optional)" value="${l.sold_price === null ? "" : l.sold_price}"> |
| 109 | 109 | <button class="button button-quiet" title="Coming soon, under contract and sold add a status post and a status email; your other assets and approvals stay." aria-label="Set status for this listing">Set status</button> |
src/status.test.ts +6 −1
| @@ -127,8 +127,13 @@ test("setting the status adds the two assets, each needing its own approval, and | ||
| 127 | 127 | const under = setStatus(db, me, { id, status: "under_contract" }); |
| 128 | 128 | expect(under.ok && under.value).toMatchObject({ status: "under_contract", sold_price: null }); |
| 129 | 129 | expect(under.ok && under.value.assets.find((a) => a.kind === "email_status")!.text).toStartWith("Subject: Under contract — 9 Elm Street"); |
| 130 | // Active from under contract is Back on market (see backonmarket.test.ts); from sold it takes them away. | |
| 130 | 131 | const back = setStatus(db, me, { id, status: "active" }); |
| 131 | expect(back.ok && back.value.assets).toHaveLength(6); | |
| 132 | expect(back.ok && back.value.assets.find((a) => a.kind === "email_status")!.text).toStartWith("Subject: Back on market — 9 Elm Street"); | |
| 133 | const resold = setStatus(db, me, { id, status: "sold" }); | |
| 134 | expect(resold.ok).toBe(true); | |
| 135 | const away = setStatus(db, me, { id, status: "active" }); | |
| 136 | expect(away.ok && away.value.assets).toHaveLength(6); | |
| 132 | 137 | }); |
| 133 | 138 | |
| 134 | 139 | test("who may: the listing's agent or an admin, nobody else, and a bad status changes nothing", () => { |
Unreachable: active already returned above. Revert to
const sold = f.status === "sold";here and in statusPost.