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

Merged opened this

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

Merged

mason/listing-launch:mason/59-put-a-listing-back-on-the-market-when-a → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/59-put-a-listing-back-on-the-market-when-a was merged into main as 0338cda.

Review

Sign in to review this change.
7 files changed +185 −13
README.md +7 −3
@@ -61,9 +61,13 @@ to make a listing of your own.
6161 flyer wears a "Sold" band, and the list on the first screen shows the
6262 status beside the approval count. *88 Meridian Street* is already sold, so
6363 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.
6771 **Lower the price.** In **Edit facts**, a lower price adds a price
6872 improvement post (Social) and email ("New price — 412 Alder Lane, now
6973 $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";
1010 import { record, type AuditRow } from "./audit.ts";
1111 import { permit } from "./contract.ts";
1212 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";
1414 import {
1515 agentOf,
1616 ASSET_MAX,
@@ -357,8 +357,14 @@ function writeAssets(
357357 listing: Listing,
358358 only: readonly Kind[] = KINDS,
359359 by?: { who: Who; why: string },
360 backOnMarket?: boolean,
360361 ): 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) });
362368 const put = db.query(
363369 `INSERT INTO asset (listing_id, kind, body, edited) VALUES (?1, ?2, ?3, 0)
364370 ON CONFLICT (listing_id, kind) DO UPDATE SET body = excluded.body, edited = 0,
@@ -447,12 +453,13 @@ export function markStatus(
447453 stand: { status: Status; sold_price: number | null },
448454 ): Launched {
449455 return db.transaction(() => {
456 const before = db.query<{ status: Status }, [number]>("SELECT status FROM listing WHERE id = ?1").get(id)!;
450457 const row = db
451458 .query<ListingRow, [string, number | null, number]>(
452459 "UPDATE listing SET status = ?1, sold_price = ?2, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?3 RETURNING *",
453460 )
454461 .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));
456463 return launched(db, id)!;
457464 })();
458465 }
@@ -1034,7 +1041,8 @@ function changeFacts(
10341041 * Mark a listing coming soon, active, under contract or sold (with the price it
10351042 * sold at, if the agent says). Coming soon, under contract and sold add a
10361043 * 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
10381046 * alone. Saying what is already so changes nothing.
10391047 */
10401048 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>> = {
4444 email_price: "Price improvement email",
4545 };
4646
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. */
4848 export const STATUS_KINDS = ["social_status", "email_status"] as const satisfies readonly Kind[];
4949
5050 /** 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>> = {
6161 sold: "Sold",
6262 };
6363
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
6467 /** "Sold at $752,000" for a sold listing with a price; the status's label otherwise. */
6568 export const statusLine = (l: { status: Status; sold_price: number | null }): string =>
6669 l.status === "sold" && l.sold_price !== null ? `Sold at ${money(l.sold_price)}` : STATUS_LABELS[l.status];
@@ -87,6 +90,8 @@ export interface Facts {
8790 readonly status?: Status;
8891 /** Whole dollars, only when sold, and only if the agent chose to say. */
8992 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;
9095 /** Whole dollars: the price before it was lowered; absent or null when it has not been. */
9196 readonly was_price?: number | null;
9297 }
@@ -349,9 +354,37 @@ function comingSoonEmail(f: Facts, a: Agent, where: string): string {
349354 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}`;
350355 }
351356
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
352384 /** The status announcement's opening, in the listing's tone: "Just sold" or "Under contract". */
353385 function statusPost(f: Facts, a: Agent): string {
354386 const where = street(f.address);
387 if (f.status === "active") return backOnMarketPost(f, a, where);
355388 if (f.status === "coming_soon") return comingSoonPost(f, a, where);
356389 const sold = f.status === "sold";
357390 const price = sold && f.sold_price != null ? ` at ${money(f.sold_price)}` : "";
@@ -382,6 +415,7 @@ function statusPost(f: Facts, a: Agent): string {
382415
383416 function statusEmail(f: Facts, a: Agent): string {
384417 const where = street(f.address);
418 if (f.status === "active") return backOnMarketEmail(f, a, where);
385419 if (f.status === "coming_soon") return comingSoonEmail(f, a, where);

Unreachable: active already returned above. Revert to const sold = f.status === "sold"; here and in statusPost.

386420 const sold = f.status === "sold";
387421 const subject = sold ? `Just sold — ${where}` : `Under contract — ${where}`;
@@ -454,7 +488,7 @@ export function generate(listing: {
454488 readonly agent: Agent;
455489 }): Partial<Record<Kind, string>> & Record<(typeof CORE_KINDS)[number], string> {
456490 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);
458492 const cut = priceCut(f);
459493 return {
460494 mls: mls(f),
src/mcp.ts +1 −1
@@ -260,7 +260,7 @@ export const tools: readonly Tool[] = [
260260 {
261261 name: "set_status",
262262 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.",
264264 inputSchema: {
265265 type: "object",
266266 properties: {
src/pages/listing.ts +2 −2
@@ -100,10 +100,10 @@ export const statusPill = (l: Pick<Listing, "status" | "sold_price">): string =>
100100 : `<span class="pill status-pill status-${l.status}">${escape(statusLine(l))}</span>`;
101101
102102 /** 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 =>
104104 `<form class="row status-form" method="post" action="/listings/${l.id}/status">
105105 <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>
107107 <label class="sr" for="sold_price">Sold price, optional</label>
108108 <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}">
109109 <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
127127 const under = setStatus(db, me, { id, status: "under_contract" });
128128 expect(under.ok && under.value).toMatchObject({ status: "under_contract", sold_price: null });
129129 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.
130131 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);
132137 });
133138
134139 test("who may: the listing's agent or an admin, nobody else, and a bad status changes nothing", () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs