Public: anyone can read this; the crew can change it.
Take a listing off the market: mark it Withdrawn or Expired, and its property page says it is no longer available #78
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/76-take-a-listing-off-the-market-mark-it-wi → main 7 commits fork 1 thread · 1 unresolved
20 files
changed +435
−49
README.md +9 −3
| @@ -83,6 +83,12 @@ to make a listing of your own. | ||
| 83 | 83 | a "Back on market" post and email (address, price, open house if set, your |
| 84 | 84 | contact) wait for approval; from sold or coming soon it just takes them |
| 85 | 85 | away, and saying Active on an active listing changes nothing. |
| 86 | **Withdrawn** and **Expired** take a listing off the market without a sale: | |
| 87 | its property page answers with the address and "This home is no longer on | |
| 88 | the market" and shows no price, words, photos or open house; its launch | |
| 89 | week and calendar file list nothing; open house dates drop off the flyer, | |
| 90 | email and posts. Pick **Back on market** to relist it, the same way as from | |
| 91 | under contract. | |
| 86 | 92 | **Coming soon** teases a home before it is listed: a post and an email |
| 87 | 93 | ("Coming soon — 412 Alder Lane", the price, the facts, "Message me to be first to see it"), never a date you did not give. |
| 88 | 94 | **Lower the price.** In **Edit facts**, a lower price adds a price |
| @@ -92,7 +98,7 @@ to make a listing of your own. | ||
| 92 | 98 | the price back to the original, or above it, takes them away. The same |
| 93 | 99 | goes in the terminal (press `r` on the listing) and for other tools. |
| 94 | 100 | **Find one.** Above the list, **All · Active · Under contract · Sold · |
| 95 | Coming soon · Needs approval** are links with their counts, and the | |
| 101 | Coming soon · Off market · Needs approval** are links with their counts, and the | |
| 96 | 102 | search box matches the address, any case, together with the filter. |
| 97 | 103 | 10. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with |
| 98 | 104 | the photo, price, facts, description and your name. Print it or save it |
| @@ -385,7 +391,7 @@ curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' - | ||
| 385 | 391 | curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"facts":{"price":649000}}' |
| 386 | 392 | # an open house (up to two; local date and 24-hour times as typed; [] clears) |
| 387 | 393 | curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"facts":{"open_houses":[{"date":"2027-03-07","start":"13:00","end":"15:00"}]}}' |
| 388 | # where it stands: active, coming_soon, under_contract or sold (sold_price optional, whole dollars); adds or removes the status post and email | |
| 394 | # where it stands: active, coming_soon, under_contract, sold, withdrawn or expired (sold_price optional, whole dollars); adds or removes the status post and email | |
| 389 | 395 | curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"status":"sold","sold_price":752000}' |
| 390 | 396 | curl -X DELETE https://<app>/api/listings/1 |
| 391 | 397 | |
| @@ -419,7 +425,7 @@ listing with `enter` to read every asset with its Fair Housing findings; | ||
| 419 | 425 | `j`/`k` move between assets, `e` edits the one under the cursor (type `\n` |
| 420 | 426 | for a line break, `enter` saves and checks it again), `t` rewrites them all |
| 421 | 427 | in the next tone, `s` sets the status (`sold 752000`, `under contract`, `coming soon`, |
| 422 | `active`), `p` previews the email as the client reads it, `P` the open house follow-up with the sign-in visitors it would go to, `V` lists those visitors (`a` adds one as `Name, email`, `d` removes the one under the cursor; only the listing's agent or an admin, a guest sees none), `x` removes a listing of your own. `tab` switches to the notes. Launching a listing, with its photos, is the page's alone. | |
| 428 | `withdrawn`, `expired`, `active`), `p` previews the email as the client reads it, `P` the open house follow-up with the sign-in visitors it would go to, `V` lists those visitors (`a` adds one as `Name, email`, `d` removes the one under the cursor; only the listing's agent or an admin, a guest sees none), `x` removes a listing of your own. `tab` switches to the notes. Launching a listing, with its photos, is the page's alone. | |
| 423 | 429 | |
| 424 | 430 | No runtime dependencies: Bun's own `bun:sqlite`, `Bun.serve` and `node:` |
| 425 | 431 | modules. The typefaces are the system's (a Palatino/Iowan serif for |
contract/openapi.json +16 −9
| @@ -359,9 +359,10 @@ | ||
| 359 | 359 | "under_contract", |
| 360 | 360 | "sold", |
| 361 | 361 | "coming_soon", |
| 362 | "off-market", | |
| 362 | 363 | "needs-approval" |
| 363 | 364 | ], |
| 364 | "description": "Only listings that are coming_soon, active, under_contract, sold or needs-approval; anything else is all." | |
| 365 | "description": "Only listings that are coming_soon, active, under_contract, sold, off-market or needs-approval; anything else is all." | |
| 365 | 366 | } |
| 366 | 367 | }, |
| 367 | 368 | { |
| @@ -617,7 +618,7 @@ | ||
| 617 | 618 | } |
| 618 | 619 | }, |
| 619 | 620 | "patch": { |
| 620 | "summary": "Edit one asset's words, put an earlier version back ({ kind, restore }), withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it coming soon, active, under contract or sold ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", | |
| 621 | "summary": "Edit one asset's words, put an earlier version back ({ kind, restore }), withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it coming soon, active, under contract, sold, withdrawn or expired ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", | |
| 621 | 622 | "operationId": "patchApiListingsId", |
| 622 | 623 | "parameters": [ |
| 623 | 624 | { |
| @@ -669,9 +670,11 @@ | ||
| 669 | 670 | "coming_soon", |
| 670 | 671 | "active", |
| 671 | 672 | "under_contract", |
| 672 | "sold" | |
| 673 | "sold", | |
| 674 | "withdrawn", | |
| 675 | "expired" | |
| 673 | 676 | ], |
| 674 | "description": "Mark the listing coming_soon, active, under_contract or sold; the status post and email are written or removed, nothing else changes." | |
| 677 | "description": "Mark the listing coming_soon, active, under_contract, sold, withdrawn or expired; the status post and email are written or removed, nothing else changes." | |
| 675 | 678 | }, |
| 676 | 679 | "sold_price": { |
| 677 | 680 | "type": [ |
| @@ -839,7 +842,7 @@ | ||
| 839 | 842 | }, |
| 840 | 843 | "responses": { |
| 841 | 844 | "200": { |
| 842 | "description": "Edit one asset's words, put an earlier version back ({ kind, restore }), withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it coming soon, active, under contract or sold ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", | |
| 845 | "description": "Edit one asset's words, put an earlier version back ({ kind, restore }), withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it coming soon, active, under contract, sold, withdrawn or expired ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", | |
| 843 | 846 | "content": { |
| 844 | 847 | "application/json": { |
| 845 | 848 | "schema": { |
| @@ -1295,9 +1298,11 @@ | ||
| 1295 | 1298 | "coming_soon", |
| 1296 | 1299 | "active", |
| 1297 | 1300 | "under_contract", |
| 1298 | "sold" | |
| 1301 | "sold", | |
| 1302 | "withdrawn", | |
| 1303 | "expired" | |
| 1299 | 1304 | ], |
| 1300 | "description": "Where the listing stands; coming_soon, under_contract and sold add a status post and a status email." | |
| 1305 | "description": "Where the listing stands; coming_soon, under_contract and sold add a status post and a status email; withdrawn and expired take the listing off the market." | |
| 1301 | 1306 | }, |
| 1302 | 1307 | "sold_price": { |
| 1303 | 1308 | "type": [ |
| @@ -1634,9 +1639,11 @@ | ||
| 1634 | 1639 | "coming_soon", |
| 1635 | 1640 | "active", |
| 1636 | 1641 | "under_contract", |
| 1637 | "sold" | |
| 1642 | "sold", | |
| 1643 | "withdrawn", | |
| 1644 | "expired" | |
| 1638 | 1645 | ], |
| 1639 | "description": "Where the listing stands; coming_soon, under_contract and sold add a status post and a status email." | |
| 1646 | "description": "Where the listing stands; coming_soon, under_contract and sold add a status post and a status email; withdrawn and expired take the listing off the market." | |
| 1640 | 1647 | }, |
| 1641 | 1648 | "sold_price": { |
| 1642 | 1649 | "type": [ |
src/actions.ts +25 −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, KINDS, STATUS_KINDS, TONES, type Kind, type Status } from "./generate.ts"; | |
| 14 | import { generate, isBackOnMarket, isOffMarket, KINDS, STATUS_KINDS, TONES, withoutOpenHouse, type Kind, type Status } from "./generate.ts"; | |
| 15 | 15 | import { |
| 16 | 16 | agentOf, |
| 17 | 17 | ASSET_MAX, |
| @@ -464,7 +464,29 @@ export function markStatus( | ||
| 464 | 464 | "UPDATE listing SET status = ?1, sold_price = ?2, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?3 RETURNING *", |
| 465 | 465 | ) |
| 466 | 466 | .get(stand.status, stand.sold_price, id)!; |
| 467 | writeAssets(db, fromRow(row), STATUS_KINDS, undefined, isBackOnMarket(before.status, stand.status)); | |
| 467 | // Leaving or re-entering the market changes the open house lines in the words: the assets nobody edited are written again. | |
| 468 | const kinds = | |
| 469 | isOffMarket(before.status) === isOffMarket(stand.status) | |
| 470 | ? STATUS_KINDS | |
| 471 | : KINDS.filter( | |
| 472 | (k) => | |
| 473 | (STATUS_KINDS as readonly string[]).includes(k) || | |
| 474 | db.query("SELECT 1 FROM asset WHERE listing_id = ?1 AND kind = ?2 AND edited = 0").get(id, k) !== null, | |
| 475 | ); | |
| 476 | writeAssets(db, fromRow(row), kinds, undefined, isBackOnMarket(before.status, stand.status)); | |
| 477 | // Words a person edited keep everything they wrote but the open house, which an off-market listing no longer has. | |
| 478 | if (isOffMarket(stand.status) && !isOffMarket(before.status)) { | |
| 479 | const facts = { ...fromRow(row), status: "active" as const }; | |
| 480 | const edited = db.query<{ kind: string; body: string }, [number]>("SELECT kind, body FROM asset WHERE listing_id = ?1 AND edited = 1").all(id); | |
| 481 | const put = db.query( | |
| 482 | `UPDATE asset SET body = ?3, approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL, | |
| 483 | updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE listing_id = ?1 AND kind = ?2`, | |
| 484 | ); | |
| 485 | for (const a of edited) { | |
| 486 | const body = withoutOpenHouse(a.body, facts); | |
| 487 | if (body !== a.body) put.run(id, a.kind, body); | |
| 488 | } | |
| 489 | } | |
| 468 | 490 | return launched(db, id)!; |
| 469 | 491 | })(); |
| 470 | 492 | } |
| @@ -559,7 +581,7 @@ export const getListing = ( | ||
| 559 | 581 | */ |
| 560 | 582 | export const getHome = (db: Database, who: Who, input: { id: number }): Result<Launched> => { |
| 561 | 583 | const found = getListing(db, who, input); |
| 562 | if (found.ok && publicDescription(found.value) === null && !canEditListing(who, found.value)) | |
| 584 | if (found.ok && !isOffMarket(found.value.status) && publicDescription(found.value) === null && !canEditListing(who, found.value)) | |
| 563 | 585 | return fail("not_found", "There is no such home."); |
| 564 | 586 | return found; |
| 565 | 587 | }; |
src/db.ts +68
| @@ -177,6 +177,53 @@ export const migrations: string[] = [ | ||
| 177 | 177 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| 178 | 178 | ); |
| 179 | 179 | CREATE INDEX IF NOT EXISTS visitor_listing ON visitor (listing_id, id)`, |
| 180 | // Withdrawn and Expired join the statuses: the table is rebuilt again, holding assets, their versions and the visitors aside. | |
| 181 | `CREATE TABLE listing_old AS SELECT * FROM listing; | |
| 182 | CREATE TABLE asset_old AS SELECT * FROM asset; | |
| 183 | CREATE TABLE asset_version_old AS SELECT * FROM asset_version; | |
| 184 | CREATE TABLE visitor_old AS SELECT * FROM visitor; | |
| 185 | DROP TABLE listing; | |
| 186 | CREATE TABLE listing ( | |
| 187 | id INTEGER PRIMARY KEY, | |
| 188 | author_id TEXT NOT NULL, | |
| 189 | author_name TEXT NOT NULL, | |
| 190 | address TEXT NOT NULL CHECK (length(address) BETWEEN 5 AND 120), | |
| 191 | beds INTEGER NOT NULL, | |
| 192 | baths REAL NOT NULL, | |
| 193 | sqft INTEGER NOT NULL, | |
| 194 | lot TEXT NOT NULL, | |
| 195 | year INTEGER NOT NULL, | |
| 196 | price INTEGER NOT NULL, | |
| 197 | features TEXT NOT NULL, | |
| 198 | tone TEXT NOT NULL CHECK (tone IN ('classic', 'warm', 'bold')), | |
| 199 | palette TEXT NOT NULL, | |
| 200 | agent_name TEXT NOT NULL, | |
| 201 | brokerage TEXT NOT NULL, | |
| 202 | license TEXT NOT NULL, | |
| 203 | agent_phone TEXT NOT NULL, | |
| 204 | agent_email TEXT NOT NULL, | |
| 205 | photos TEXT NOT NULL DEFAULT '[]', | |
| 206 | headshot TEXT, | |
| 207 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), | |
| 208 | updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), | |
| 209 | open_houses TEXT NOT NULL DEFAULT '[]', | |
| 210 | status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('coming_soon', 'active', 'under_contract', 'sold', 'withdrawn', 'expired')), | |
| 211 | sold_price INTEGER, | |
| 212 | was_price INTEGER, | |
| 213 | photo_alt TEXT NOT NULL DEFAULT '{}' | |
| 214 | ); | |
| 215 | INSERT INTO listing (id, author_id, author_name, address, beds, baths, sqft, lot, year, price, features, tone, palette, agent_name, brokerage, license, agent_phone, agent_email, photos, headshot, created_at, updated_at, open_houses, status, sold_price, was_price, photo_alt) SELECT id, author_id, author_name, address, beds, baths, sqft, lot, year, price, features, tone, palette, agent_name, brokerage, license, agent_phone, agent_email, photos, headshot, created_at, updated_at, open_houses, status, sold_price, was_price, photo_alt FROM listing_old; | |
| 216 | CREATE INDEX listing_author ON listing (author_id); | |
| 217 | INSERT INTO asset (listing_id, kind, body, edited, updated_at, approved_by_id, approved_by_name, approved_at, approved_via) | |
| 218 | SELECT listing_id, kind, body, edited, updated_at, approved_by_id, approved_by_name, approved_at, approved_via FROM asset_old; | |
| 219 | INSERT INTO asset_version (id, listing_id, kind, body, replaced_at, by_id, by_name, reason) | |
| 220 | SELECT id, listing_id, kind, body, replaced_at, by_id, by_name, reason FROM asset_version_old; | |
| 221 | INSERT INTO visitor (id, listing_id, name, email, created_at) | |
| 222 | SELECT id, listing_id, name, email, created_at FROM visitor_old; | |
| 223 | DROP TABLE visitor_old; | |
| 224 | DROP TABLE asset_version_old; | |
| 225 | DROP TABLE asset_old; | |
| 226 | DROP TABLE listing_old;`, | |
| 180 | 227 | ]; |
| 181 | 228 | |
| 182 | 229 | export function openDb(dir = dataDir): Database { |
| @@ -189,6 +236,17 @@ export function openDb(dir = dataDir): Database { | ||
| 189 | 236 | return db; |
| 190 | 237 | } |
| 191 | 238 | |
| 239 | function hasTable(db: Database, table: string): boolean { | |
| 240 | return db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1").get(table) !== null; | |
| 241 | } | |
| 242 | ||
| 243 | function hasColumn(db: Database, table: string, column: string): boolean { | |
| 244 | return db | |
| 245 | .query<{ name: string }, []>(`PRAGMA table_info(${table})`) | |
| 246 | .all() | |
| 247 | .some((c) => c.name === column); | |
| 248 | } | |
| 249 | ||
| 192 | 250 | /** Apply the migrations past the recorded version, all or none. Returns the version. */ |
| 193 | 251 | export function migrate(db: Database): number { |
| 194 | 252 | db.exec( |
| @@ -201,6 +259,16 @@ export function migrate(db: Database): number { | ||
| 201 | 259 | [] |
| 202 | 260 | >("SELECT coalesce(max(version), 0) AS version FROM schema_version") |
| 203 | 261 | .get()!.version; |
| 262 | // A database migrated while the status rebuild sat before the visitor table and the photo_alt column | |
| 263 | // (an earlier draft of this list) has skipped them; put them back before the later steps rely on them. | |
| 264 | const visitor = migrations.findIndex((sql) => sql.includes("CREATE TABLE IF NOT EXISTS visitor")); | |
| 265 | if (visitor >= 0 && current > visitor && !hasTable(db, "visitor")) { | |
| 266 | db.exec(migrations[visitor]!); | |
| 267 | } | |
| 268 | const photoAlt = migrations.findIndex((sql) => sql.includes("ADD COLUMN photo_alt")); | |
| 269 | if (photoAlt >= 0 && current > photoAlt && !hasColumn(db, "listing", "photo_alt")) { | |
| 270 | db.exec(migrations[photoAlt]!); | |
| 271 | } | |
| 204 | 272 | migrations.slice(current).forEach((sql, i) => { |
| 205 | 273 | db.exec(sql); |
| 206 | 274 | db.query("INSERT INTO schema_version (version) VALUES (?1)").run( |
src/find.test.ts +1 −1
| @@ -23,5 +23,5 @@ test("each filter, search, and both together", () => { | ||
| 23 | 23 | expect(f("needs-approval")).toEqual(["2 Elm Court"]); |
| 24 | 24 | expect(f("all", "OAK")).toEqual(["1 Oak Lane", "3 Oak Road"]); |
| 25 | 25 | expect(f("active", "oak")).toEqual(["1 Oak Lane"]); |
| 26 | expect(counts(rows, "oak")).toEqual({ all: 2, active: 1, under_contract: 1, sold: 0, coming_soon: 0, "needs-approval": 0 }); | |
| 26 | expect(counts(rows, "oak")).toEqual({ all: 2, active: 1, under_contract: 1, sold: 0, coming_soon: 0, "off-market": 0, "needs-approval": 0 }); | |
| 27 | 27 | }); |
src/find.ts +13 −5
| @@ -4,10 +4,16 @@ | ||
| 4 | 4 | * or search text is read, and the match is done in memory, never in SQL text. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { STATUSES, type Status } from "./generate.ts"; | |
| 7 | import { isOffMarket, STATUSES, type Status } from "./generate.ts"; | |
| 8 | 8 | |
| 9 | 9 | /** The filters, in the order the list shows them; Coming soon follows the statuses that were there first. */ |
| 10 | export const FILTERS = ["all", ...STATUSES.filter((s) => s !== "coming_soon"), "coming_soon", "needs-approval"] as const; | |
| 10 | export const FILTERS = [ | |
| 11 | "all", | |
| 12 | ...STATUSES.filter((s) => s !== "coming_soon" && !isOffMarket(s)), | |
| 13 | "coming_soon", | |
| 14 | "off-market", | |
| 15 | "needs-approval", | |
| 16 | ] as const; | |
| 11 | 17 | export type Filter = (typeof FILTERS)[number]; |
| 12 | 18 | |
| 13 | 19 | export const Q_MAX = 100; |
| @@ -36,9 +42,11 @@ interface Findable { | ||
| 36 | 42 | export const matchesStatus = (l: Findable, status: Filter): boolean => |
| 37 | 43 | status === "all" |
| 38 | 44 | ? true |
| 39 | : status === "needs-approval" | |
| 40 | ? l.approvals.some((a) => !a.approved) | |
| 41 | : l.status === status; | |
| 45 | : status === "off-market" | |
| 46 | ? isOffMarket(l.status) | |
| 47 | : status === "needs-approval" | |
| 48 | ? l.approvals.some((a) => !a.approved) | |
| 49 | : l.status === status; | |
| 42 | 50 | |
| 43 | 51 | export const matchesText = (l: Findable, q: string): boolean => |
| 44 | 52 | q === "" || l.address.toLowerCase().includes(q.toLowerCase()); |
src/generate.ts +28 −4
| @@ -52,7 +52,7 @@ export const STATUS_KINDS = ["social_status", "email_status"] as const satisfies | ||
| 52 | 52 | /** The assets that exist only while the price stands below what it was. */ |
| 53 | 53 | export const PRICE_KINDS = ["social_price", "email_price"] as const satisfies readonly Kind[]; |
| 54 | 54 | |
| 55 | export const STATUSES = ["coming_soon", "active", "under_contract", "sold"] as const; | |
| 55 | export const STATUSES = ["coming_soon", "active", "under_contract", "sold", "withdrawn", "expired"] as const; | |
| 56 | 56 | export type Status = (typeof STATUSES)[number]; |
| 57 | 57 | |
| 58 | 58 | /** What a person calls each status. */ |
| @@ -61,10 +61,16 @@ export const STATUS_LABELS: Readonly<Record<Status, string>> = { | ||
| 61 | 61 | active: "Active", |
| 62 | 62 | under_contract: "Under contract", |
| 63 | 63 | sold: "Sold", |
| 64 | withdrawn: "Withdrawn", | |
| 65 | expired: "Expired", | |
| 64 | 66 | }; |
| 65 | 67 | |
| 66 | /** The one transition that announces itself on an active listing: a deal that fell through. */ | |
| 67 | export const isBackOnMarket = (from: Status, to: Status): boolean => from === "under_contract" && to === "active"; | |
| 68 | /** Whether the listing left the market without selling: nothing public advertises it. */ | |
| 69 | export const isOffMarket = (status: Status): boolean => status === "withdrawn" || status === "expired"; | |
| 70 | ||
| 71 | /** The transitions that announce themselves on an active listing: a deal that fell through, or a listing put back after it left the market. */ | |
| 72 | export const isBackOnMarket = (from: Status, to: Status): boolean => | |
| 73 | (from === "under_contract" || isOffMarket(from)) && to === "active"; | |
| 68 | 74 | |
| 69 | 75 | /** "Sold at $752,000" for a sold listing with a price; the status's label otherwise. */ |
| 70 | 76 | export const statusLine = (l: { status: Status; sold_price: number | null }): string => |
| @@ -213,7 +219,7 @@ export const whenShort = (o: OpenHouse): string => { | ||
| 213 | 219 | |
| 214 | 220 | /** The open house as a person would say it, in the listing's tone; null when there is none. */ |
| 215 | 221 | export const openHouseLine = (f: Facts): string | null => { |
| 216 | const when = (f.open_houses ?? []).map(whenOf).join(" and "); | |
| 222 | const when = isOffMarket(f.status) ? "" : (f.open_houses ?? []).map(whenOf).join(" and "); | |
| 217 | 223 | if (when === "") return null; |
| 218 | 224 | return { |
| 219 | 225 | classic: `Open ${when}.`, |
| @@ -222,6 +228,24 @@ export const openHouseLine = (f: Facts): string | null => { | ||
| 222 | 228 | }[f.tone]; |
| 223 | 229 | }; |
| 224 | 230 | |
| 231 | /** | |
| 232 | * A person's edited words with the open house taken out: the generated line | |
| 233 | * (in any tone) and any other line that names one of the open house times. | |
| 234 | * Everything else they wrote stays as written. | |
| 235 | */ | |
| 236 | export function withoutOpenHouse(text: string, f: Facts): string { | |
| 237 | const whens = (f.open_houses ?? []).map(whenOf); | |
| 238 | if (whens.length === 0) return text; | |
| 239 | const lines = TONES.map((tone) => openHouseLine({ ...f, status: "active", tone })); | |
| 240 | let out = text; | |
| 241 | for (const line of lines) if (line !== null) out = out.split(line).join(""); | |
| 242 | out = out | |
| 243 | .split("\n") | |
| 244 | .filter((l) => !whens.some((w) => l.includes(w))) | |
| 245 | .join("\n"); | |
| 246 | return out.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").replace(/ {2,}/g, " ").trim(); | |
| 247 | } | |
| 248 | ||
| 225 | 249 | const signature = (a: Agent): string => |
| 226 | 250 | `${a.name} · ${a.brokerage} · Lic. ${a.license}`; |
| 227 | 251 |
src/listing.ts +2
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { check, type Check } from "./fairhousing.ts"; |
| 9 | 9 | import { |
| 10 | isOffMarket, | |
| 10 | 11 | KINDS, |
| 11 | 12 | LABELS, |
| 12 | 13 | STATUSES, |
| @@ -387,6 +388,7 @@ export const propertyLine = (l: Launched, origin: string): string => | ||
| 387 | 388 | |
| 388 | 389 | /** The MLS description as buyers may read it: its words once approved, else null. */ |
| 389 | 390 | export const publicDescription = (l: Launched): string | null => { |
| 391 | if (isOffMarket(l.status)) return null; | |
| 390 | 392 | const mls = l.assets.find((a) => a.kind === "mls"); |
| 391 | 393 | return mls === undefined || mls.approval === null ? null : mls.text; |
| 392 | 394 | }; |
src/mcp.ts +2 −2
| @@ -103,7 +103,7 @@ export const tools: readonly Tool[] = [ | ||
| 103 | 103 | type: "object", |
| 104 | 104 | properties: { |
| 105 | 105 | limit: { type: "integer", minimum: 1, maximum: 500 }, |
| 106 | status: { enum: [...FILTERS], description: "Only listings that are coming_soon, active, under_contract, sold or needs-approval; anything else is all." }, | |
| 106 | status: { enum: [...FILTERS], description: "Only listings that are coming_soon, active, under_contract, sold, off-market or needs-approval; anything else is all." }, | |
| 107 | 107 | q: { type: "string", maxLength: Q_MAX, description: "Only listings whose address contains this, any case." }, |
| 108 | 108 | }, |
| 109 | 109 | }, |
| @@ -261,7 +261,7 @@ export const tools: readonly Tool[] = [ | ||
| 261 | 261 | { |
| 262 | 262 | name: "set_status", |
| 263 | 263 | description: |
| 264 | "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 | "Mark a listing coming_soon, active, under_contract, sold, withdrawn or expired (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; withdrawn and expired take the listing off the market (its property page says so and shows nothing else; its launch week lists nothing); active removes them, except from under_contract, withdrawn or expired, 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.", | |
| 265 | 265 | inputSchema: { |
| 266 | 266 | type: "object", |
| 267 | 267 | properties: { |
src/migrate-photo-alt.test.ts added +53
| @@ -0,0 +1,53 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { Database } from "bun:sqlite"; | |
| 3 | import { migrate, migrations } from "./db"; | |
| 4 | ||
| 5 | test("a database that rebuilt listing before photo_alt existed gets the column back", () => { | |
| 6 | const i = migrations.findIndex((sql) => sql.includes("ADD COLUMN photo_alt")); | |
| 7 | const rebuild = migrations.find((sql, k) => k > i && sql.includes("CREATE TABLE listing_old"))! | |
| 8 | .replace(/, photo_alt/g, "") | |
| 9 | .replace("was_price INTEGER,\n photo_alt TEXT NOT NULL DEFAULT '{}'", "was_price INTEGER"); | |
| 10 | const db = new Database(":memory:"); | |
| 11 | db.exec("PRAGMA foreign_keys = ON"); | |
| 12 | db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT 'x')"); | |
| 13 | [...migrations.slice(0, i), migrations.find((sql) => sql.includes("TABLE IF NOT EXISTS visitor"))!, rebuild].forEach((sql, k) => { | |
| 14 | db.exec(sql); | |
| 15 | db.query("INSERT INTO schema_version (version) VALUES (?1)").run(k + 1); | |
| 16 | }); | |
| 17 | expect(migrate(db)).toBe(migrations.length); | |
| 18 | const cols = db.query<{ name: string }, []>("PRAGMA table_info(listing)").all(); | |
| 19 | expect(cols.some((c) => c.name === "photo_alt")).toBe(true); | |
| 20 | }); | |
| 21 | ||
| 22 | test("the status rebuild keeps the open house visitors", () => { | |
| 23 | const rebuild = migrations.findIndex((sql) => sql.includes("CREATE TABLE listing_old") && sql.includes("'withdrawn'")); | |
| 24 | const db = new Database(":memory:"); | |
| 25 | db.exec("PRAGMA foreign_keys = ON"); | |
| 26 | db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT 'x')"); | |
| 27 | migrations.slice(0, rebuild).forEach((sql, k) => { | |
| 28 | db.exec(sql); | |
| 29 | db.query("INSERT INTO schema_version (version) VALUES (?1)").run(k + 1); | |
| 30 | }); | |
| 31 | db.exec(`INSERT INTO listing (author_id, author_name, address, beds, baths, sqft, lot, year, price, features, tone, palette, agent_name, brokerage, license, agent_phone, agent_email, photos) | |
| 32 | VALUES ('pat', 'n', '1 Old Road, Town', 1, 1, 1, 'l', 1999, 1, '[]', 'warm', 'teal', 'a', 'b', 'c', 'd', 'e', '[]')`); | |
| 33 | db.exec("INSERT INTO visitor (listing_id, name, email) VALUES (1, 'Sam', '[email protected]')"); | |
| 34 | migrate(db); | |
| 35 | expect(db.query("SELECT name FROM visitor WHERE listing_id = 1").all()).toEqual([{ name: "Sam" }]); | |
| 36 | }); | |
| 37 | ||
| 38 | test("a database that rebuilt listing before the visitor table existed gets the table back", () => { | |
| 39 | const visitor = migrations.findIndex((sql) => sql.includes("TABLE IF NOT EXISTS visitor")); | |
| 40 | const rebuild = migrations | |
| 41 | .find((sql, k) => k > visitor && sql.includes("CREATE TABLE listing_old"))! | |
| 42 | .replace(" CREATE TABLE visitor_old AS SELECT * FROM visitor;\n", "") | |
| 43 | .replace(/ INSERT INTO visitor[\s\S]*?DROP TABLE visitor_old;\n/, ""); | |
| 44 | const db = new Database(":memory:"); | |
| 45 | db.exec("PRAGMA foreign_keys = ON"); | |
| 46 | db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT 'x')"); | |
| 47 | [...migrations.slice(0, visitor), rebuild].forEach((sql, k) => { | |
| 48 | db.exec(sql); | |
| 49 | db.query("INSERT INTO schema_version (version) VALUES (?1)").run(k + 1); | |
| 50 | }); | |
| 51 | expect(migrate(db)).toBe(migrations.length); | |
| 52 | expect(db.query("SELECT count(*) AS n FROM visitor").get()).toEqual({ n: 0 }); | |
| 53 | }); |
src/offmarket.test.ts added +172
| @@ -0,0 +1,172 @@ | ||
| 1 | /** | |
| 2 | * Off the market: Withdrawn and Expired are statuses the agent can set; the | |
| 3 | * property page stops advertising, the launch week empties, open houses drop | |
| 4 | * off the words, the list has an Off market filter, and Back on market works | |
| 5 | * from either. | |
| 6 | */ | |
| 7 | import { expect, test } from "bun:test"; | |
| 8 | import { mkdtempSync } from "node:fs"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { approveAsset, editAsset, getListing, launchListing, setStatus } from "./actions.ts"; | |
| 12 | import { openDb } from "./db.ts"; | |
| 13 | import { counts, matchesStatus, parseFind } from "./find.ts"; | |
| 14 | import { generate } from "./generate.ts"; | |
| 15 | import { agentOf, validateStatus } from "./listing.ts"; | |
| 16 | import { rpc } from "./mcp.ts"; | |
| 17 | import { propertyPage } from "./pages/property.ts"; | |
| 18 | import { statusForm, statusPill } from "./pages/listing.ts"; | |
| 19 | import { scheduleIcs, scheduleRows } from "./schedule.ts"; | |
| 20 | import { SAMPLE } from "./sample.ts"; | |
| 21 | import { statusFromLine, statusToLine } from "./tty.ts"; | |
| 22 | import type { Who } from "./who.ts"; | |
| 23 | ||
| 24 | const me: Who = { role: "user", id: "u1", name: "me" }; | |
| 25 | const anon: Who = { role: "anon" }; | |
| 26 | const open_houses = [{ date: "2027-03-09", start: "13:00", end: "15:00" }]; | |
| 27 | ||
| 28 | const launch = () => { | |
| 29 | const db = openDb(mkdtempSync(join(tmpdir(), "app-off-"))); | |
| 30 | const made = launchListing(db, me, { fields: { ...SAMPLE, open_houses } }); | |
| 31 | if (!made.ok) throw new Error(made.message); | |
| 32 | const id = made.value.id; | |
| 33 | for (const a of made.value.assets) approveAsset(db, me, { id, kind: a.kind, door: "page", seen: a.updated_at }); | |
| 34 | return { db, id }; | |
| 35 | }; | |
| 36 | const get = (db: ReturnType<typeof openDb>, id: number, who: Who = me) => { | |
| 37 | const found = getListing(db, who, { id }); | |
| 38 | if (!found.ok) throw new Error(found.message); | |
| 39 | return found.value; | |
| 40 | }; | |
| 41 | ||
| 42 | test("the status check accepts withdrawn and expired and refuses the rest plainly", () => { | |
| 43 | for (const status of ["withdrawn", "expired"]) expect(validateStatus({ status })).toEqual({ ok: true, value: { status, sold_price: null } }); | |
| 44 | const bad = validateStatus({ status: "pending" }); | |
| 45 | expect(bad.ok).toBe(false); | |
| 46 | if (!bad.ok) expect(bad.message).toContain("withdrawn, expired"); | |
| 47 | expect(validateStatus({ status: "withdrawn", sold_price: "5" }).ok).toBe(false); | |
| 48 | }); | |
| 49 | ||
| 50 | test("a withdrawn or expired property page says so and shows no approved words, price or open house", () => { | |
| 51 | for (const status of ["withdrawn", "expired"] as const) { | |
| 52 | const { db, id } = launch(); | |
| 53 | const on = propertyPage({ who: anon, listing: get(db, id) }); | |
| 54 | expect(on).toContain("$689,000"); | |
| 55 | expect(on).toContain("Open house"); | |
| 56 | expect(setStatus(db, me, { id, status }).ok).toBe(true); | |
| 57 | const html = propertyPage({ who: anon, listing: get(db, id) }); | |
| 58 | expect(html).toContain("412 Alder Lane"); | |
| 59 | expect(html).toContain("This home is no longer on the market"); | |
| 60 | expect(html).toContain(status === "withdrawn" ? "Withdrawn" : "Expired"); | |
| 61 | expect(html).not.toContain("$689,000"); | |
| 62 | expect(html).not.toContain("Open house"); | |
| 63 | expect(html).not.toContain("home-words\">" + get(db, id).assets.find((a) => a.kind === "mls")!.text.slice(0, 20)); | |
| 64 | expect(html).not.toContain("home-facts"); | |
| 65 | expect(html).not.toContain("home-hero"); | |
| 66 | } | |
| 67 | }); | |
| 68 | ||
| 69 | test("the page route answers 200 to a stranger, even for a listing never approved", async () => { | |
| 70 | const { serve } = await import("./server.ts"); | |
| 71 | const { sign } = await import("./who.ts"); | |
| 72 | const KEY = "a-key-only-the-forge-holds"; | |
| 73 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-off-http-")), identityKey: KEY }); | |
| 74 | try { | |
| 75 | const base = `http://${server.hostname}:${server.port}`; | |
| 76 | const identity = `agent|agent|user|${Date.now() + 600_000}`; | |
| 77 | const headers = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY), "content-type": "application/json" }; | |
| 78 | const made = (await (await fetch(`${base}/api/listings`, { method: "POST", headers, body: JSON.stringify(SAMPLE) })).json()) as { id: number }; | |
| 79 | expect((await fetch(`${base}/homes/${made.id}`)).status).toBe(404); | |
| 80 | const set = await fetch(`${base}/api/listings/${made.id}`, { method: "PATCH", headers, body: JSON.stringify({ status: "expired" }) }); | |
| 81 | expect(set.status).toBe(200); | |
| 82 | const res = await fetch(`${base}/homes/${made.id}`); | |
| 83 | expect(res.status).toBe(200); | |
| 84 | const html = await res.text(); | |
| 85 | expect(html).toContain("This home is no longer on the market"); | |
| 86 | expect(html).not.toContain("$689,000"); | |
| 87 | expect(await (await fetch(`${base}/listings/${made.id}/schedule`, { headers })).text()).toContain("nothing further goes out"); | |
| 88 | } finally { | |
| 89 | server.stop(true); | |
| 90 | } | |
| 91 | }); | |
| 92 | ||
| 93 | test("the launch week and the calendar list nothing, and open houses leave the words", () => { | |
| 94 | const { db, id } = launch(); | |
| 95 | expect(scheduleRows(get(db, id)).length).toBeGreaterThan(0); | |
| 96 | for (const status of ["withdrawn", "expired"] as const) { | |
| 97 | setStatus(db, me, { id, status }); | |
| 98 | const l = get(db, id); | |
| 99 | expect(scheduleRows(l)).toEqual([]); | |
| 100 | expect(scheduleIcs(l, "https://x.test")).not.toContain("BEGIN:VEVENT"); | |
| 101 | for (const a of l.assets) expect(a.text).not.toMatch(/open house|Open Mar|OPEN HOUSE/i); | |
| 102 | const words = generate({ facts: { ...l, open_houses }, agent: agentOf(l) }); | |
| 103 | for (const text of Object.values(words)) expect(text).not.toMatch(/open house|OPEN HOUSE/i); | |
| 104 | expect(l.assets.some((a) => a.kind === "social_status")).toBe(false); | |
| 105 | } | |
| 106 | }); | |
| 107 | ||
| 108 | test("the list has an Off market filter, and the pill reads Withdrawn or Expired", () => { | |
| 109 | expect(parseFind("off-market", "").status).toBe("off-market"); | |
| 110 | const rows = (["active", "withdrawn", "expired", "sold"] as const).map((status) => ({ address: status, status, approvals: [] })); | |
| 111 | expect(rows.filter((r) => matchesStatus(r, "off-market")).map((r) => r.status)).toEqual(["withdrawn", "expired"]); | |
| 112 | expect(counts(rows, "")["off-market"]).toBe(2); | |
| 113 | expect(statusPill({ status: "withdrawn", sold_price: null })).toContain("Withdrawn"); | |
| 114 | expect(statusPill({ status: "expired", sold_price: null })).toContain("Expired"); | |
| 115 | }); | |
| 116 | ||
| 117 | test("Back on market works from withdrawn and expired as from under contract", () => { | |
| 118 | for (const status of ["withdrawn", "expired"] as const) { | |
| 119 | const { db, id } = launch(); | |
| 120 | setStatus(db, me, { id, status }); | |
| 121 | expect(statusForm(get(db, id))).toContain(">Back on market</option>"); | |
| 122 | expect(setStatus(db, me, { id, status: "active" }).ok).toBe(true); | |
| 123 | const l = get(db, id); | |
| 124 | expect(l.assets.find((a) => a.kind === "social_status")!.text).toContain("#BackOnMarket"); | |
| 125 | expect(l.assets.some((a) => a.kind === "email_status")).toBe(true); | |
| 126 | expect(scheduleRows(l).length).toBeGreaterThan(0); | |
| 127 | expect(l.assets.find((a) => a.kind === "mls")!.text).toContain("open house"); | |
| 128 | } | |
| 129 | }); | |
| 130 | ||
| 131 | test("the terminal's line and MCP set the same statuses", () => { | |
| 132 | expect(statusFromLine("withdrawn")).toEqual({ status: "withdrawn", sold_price: "" }); | |
| 133 | expect(statusToLine({ status: "expired", sold_price: null })).toBe("expired"); | |
| 134 | const { db, id } = launch(); | |
| 135 | const call = (name: string, args: object) => | |
| 136 | JSON.stringify(rpc(db, me, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }).body); | |
| 137 | expect(call("set_status", { id, status: "withdrawn" })).toContain("withdrawn"); | |
| 138 | expect(get(db, id).status).toBe("withdrawn"); | |
| 139 | expect(call("list_listings", { status: "off-market" })).toContain("412 Alder Lane"); | |
| 140 | expect(call("set_status", { id, status: "pending" })).toContain("invalid"); | |
| 141 | }); | |
| 142 | ||
| 143 | test("existing rows keep their statuses and assets across the migration", () => { | |
| 144 | const dir = mkdtempSync(join(tmpdir(), "app-off-mig-")); | |
| 145 | const db = openDb(dir); | |
| 146 | const made = launchListing(db, me, { fields: SAMPLE }); | |
| 147 | if (!made.ok) throw new Error(made.message); | |
| 148 | setStatus(db, me, { id: made.value.id, status: "sold", sold_price: 700000 }); | |
| 149 | db.close(); | |
| 150 | const again = openDb(dir); | |
| 151 | const l = get(again, made.value.id); | |
| 152 | expect(l).toMatchObject({ status: "sold", sold_price: 700000 }); | |
| 153 | expect(l.assets.length).toBe(8); | |
| 154 | }); | |
| 155 | ||
| 156 | test("a person's edited flyer, email and post lose their open house line but keep their other words", () => { | |
| 157 | const { db, id } = launch(); | |
| 158 | for (const kind of ["flyer", "email", "social_short"] as const) { | |
| 159 | const text = get(db, id).assets.find((a) => a.kind === kind)!.text; | |
| 160 | expect(text).toMatch(/open/i); | |
| 161 | const mine = `My own opening words. ${text} Call me any time.\nSee you at the open house: Tuesday, March 9, 1–3 pm.`; | |
| 162 | expect(editAsset(db, me, { id, kind, text: mine }).ok).toBe(true); | |
| 163 | } | |
| 164 | setStatus(db, me, { id, status: "withdrawn" }); | |
| 165 | for (const kind of ["flyer", "email", "social_short"] as const) { | |
| 166 | const a = get(db, id).assets.find((x) => x.kind === kind)!; | |
| 167 | expect(a.text).not.toMatch(/March 9|open house/i); | |
| 168 | expect(a.text).toContain("My own opening words."); | |
| 169 | expect(a.text).toContain("Call me any time."); | |
| 170 | expect(a.approval).toBeNull(); | |
| 171 | } | |
| 172 | }); |
src/pages/card.ts +2 −1
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | * asset text. The agent saves it as a PDF or a screenshot. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { factItems, money, priceCut, whenShort } from "../generate.ts"; | |
| 8 | import { factItems, money, priceCut, STATUS_LABELS, whenShort } from "../generate.ts"; | |
| 9 | 9 | import { escape } from "../http.ts"; |
| 10 | 10 | import { altOf, type Launched } from "../listing.ts"; |
| 11 | 11 | import type { Who } from "../who.ts"; |
| @@ -16,6 +16,7 @@ import { draftBand, photoUrl } from "./listing.ts"; | ||
| 16 | 16 | export function cardHeadline(l: Launched): string { |
| 17 | 17 | if (l.status === "sold") return "Sold"; |
| 18 | 18 | if (l.status === "under_contract") return "Under contract"; |
| 19 | if (l.status === "withdrawn" || l.status === "expired") return STATUS_LABELS[l.status]; | |
| 19 | 20 | if (priceCut(l)) return "New price"; |
| 20 | 21 | const open = l.status === "active" ? l.open_houses[0] : undefined; |
| 21 | 22 | if (open !== undefined) return `Open house ${whenShort(open)}`; |
src/pages/home.ts +1
| @@ -196,6 +196,7 @@ const FILTER_LABELS: Readonly<Record<Filter, string>> = { | ||
| 196 | 196 | active: STATUS_LABELS.active, |
| 197 | 197 | under_contract: STATUS_LABELS.under_contract, |
| 198 | 198 | sold: STATUS_LABELS.sold, |
| 199 | "off-market": "Off market", | |
| 199 | 200 | "needs-approval": "Needs approval", |
| 200 | 201 | }; |
| 201 | 202 |
src/pages/listing.ts +3 −2
| @@ -13,6 +13,7 @@ import { | ||
| 13 | 13 | flyerParts, |
| 14 | 14 | openHouseLine, |
| 15 | 15 | money, |
| 16 | isOffMarket, | |
| 16 | 17 | statusLine, |
| 17 | 18 | STATUS_LABELS, |
| 18 | 19 | STATUSES, |
| @@ -105,10 +106,10 @@ export const statusPill = (l: Pick<Listing, "status" | "sold_price">): string => | ||
| 105 | 106 | export const statusForm = (l: Listing): string => |
| 106 | 107 | `<form class="row status-form" method="post" action="/listings/${l.id}/status"> |
| 107 | 108 | <label class="sr" for="status">Status</label> |
| 108 | <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> | |
| 109 | <select class="input select" id="status" name="status">${STATUSES.map((s) => `<option value="${s}"${s === l.status ? " selected" : ""}>${s === "active" && (l.status === "under_contract" || isOffMarket(l.status)) ? "Back on market" : STATUS_LABELS[s]}</option>`).join("")}</select> | |
| 109 | 110 | <label class="sr" for="sold_price">Sold price, optional</label> |
| 110 | 111 | <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}"> |
| 111 | <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> | |
| 112 | <button class="button button-quiet" title="Coming soon, under contract and sold add a status post and a status email; withdrawn and expired take the listing off the market; your other assets and approvals stay." aria-label="Set status for this listing">Set status</button> | |
| 112 | 113 | </form>`; |
| 113 | 114 | |
| 114 | 115 | /** The band an unapproved flyer, email or square picture (its first social post) wears, on screen and on paper; nothing once approved. */ |
src/pages/property.ts +29 −10
| @@ -5,15 +5,42 @@ | ||
| 5 | 5 | * approval, sees a notice that it is not public yet. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { factItems, money, statusLine, whenOf } from "../generate.ts"; | |
| 8 | import { factItems, isOffMarket, money, statusLine, whenOf } from "../generate.ts"; | |
| 9 | 9 | import { escape } from "../http.ts"; |
| 10 | 10 | import { altOf, propertyPath, publicDescription, type Launched } from "../listing.ts"; |
| 11 | 11 | import type { Who } from "../who.ts"; |
| 12 | 12 | import { layout } from "./layout.ts"; |
| 13 | 13 | import { photoUrl } from "./listing.ts"; |
| 14 | 14 | |
| 15 | /** Where a withdrawn or expired listing's address leads: the address and the agent, and no price, words or open house. */ | |
| 16 | function offMarketPage(who: Who, l: Launched): string { | |
| 17 | return layout( | |
| 18 | `<article class="home palette-${escape(l.palette)}"> | |
| 19 | <header class="home-head"> | |
| 20 | <h2>${escape(l.address)}</h2> | |
| 21 | <p><span class="pill status-pill status-${l.status}">${escape(statusLine(l))}</span></p> | |
| 22 | </header> | |
| 23 | <p class="home-words">This home is no longer on the market.</p> | |
| 24 | ${agentFooter(l)} | |
| 25 | </article>`, | |
| 26 | who, | |
| 27 | { path: propertyPath(l), heading: l.address }, | |
| 28 | ); | |
| 29 | } | |
| 30 | ||
| 31 | const agentFooter = (l: Launched): string => `<footer class="home-agent card"> | |
| 32 | ${l.headshot === null ? "" : `<img class="home-face" src="${escape(photoUrl(l, l.headshot))}" alt="Photo of ${escape(l.agent_name)}" width="72" height="72">`} | |
| 33 | <div> | |
| 34 | <p class="home-name brand">${escape(l.agent_name)}</p> | |
| 35 | <p>${escape(l.brokerage)} · License ${escape(l.license)}</p> | |
| 36 | <p><a href="tel:${escape(l.agent_phone.replace(/[^0-9+]/g, ""))}">${escape(l.agent_phone)}</a></p> | |
| 37 | <p><a href="mailto:${escape(l.agent_email)}">${escape(l.agent_email)}</a></p> | |
| 38 | </div> | |
| 39 | </footer>`; | |
| 40 | ||
| 15 | 41 | export function propertyPage(input: { who: Who; listing: Launched }): string { |
| 16 | 42 | const { who, listing: l } = input; |
| 43 | if (isOffMarket(l.status)) return offMarketPage(who, l); | |
| 17 | 44 | const words = publicDescription(l); |
| 18 | 45 | const [hero, ...more] = l.photos; |
| 19 | 46 | const status = l.status === "active" ? "Active" : statusLine(l); |
| @@ -32,15 +59,7 @@ export function propertyPage(input: { who: Who; listing: Launched }): string { | ||
| 32 | 59 | ${l.open_houses.length === 0 || l.status !== "active" ? "" : `<p class="home-open">Open house: ${l.open_houses.map((o) => escape(whenOf(o))).join(" and ")}</p>`} |
| 33 | 60 | ${words === null ? "" : `<p class="home-words">${escape(words).replace(/\n/g, "<br>")}</p>`} |
| 34 | 61 | ${more.length === 0 ? "" : `<ul class="strip">${more.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="${escape(altOf(l, p, `Photo ${i + 2} of ${l.address}`))}" width="240" height="160" loading="lazy"></li>`).join("")}</ul>`} |
| 35 | <footer class="home-agent card"> | |
| 36 | ${l.headshot === null ? "" : `<img class="home-face" src="${escape(photoUrl(l, l.headshot))}" alt="Photo of ${escape(l.agent_name)}" width="72" height="72">`} | |
| 37 | <div> | |
| 38 | <p class="home-name brand">${escape(l.agent_name)}</p> | |
| 39 | <p>${escape(l.brokerage)} · License ${escape(l.license)}</p> | |
| 40 | <p><a href="tel:${escape(l.agent_phone.replace(/[^0-9+]/g, ""))}">${escape(l.agent_phone)}</a></p> | |
| 41 | <p><a href="mailto:${escape(l.agent_email)}">${escape(l.agent_email)}</a></p> | |
| 42 | </div> | |
| 43 | </footer> | |
| 62 | ${agentFooter(l)} | |
| 44 | 63 | <p class="muted"><small>Equal Housing Opportunity.</small></p> |
| 45 | 64 | </article>`, |
| 46 | 65 | who, |
src/pages/schedule.ts +2 −1
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * Draft and is never shown as going out. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { isOffMarket, STATUS_LABELS } from "../generate.ts"; | |
| 7 | 8 | import { escape } from "../http.ts"; |
| 8 | 9 | import type { Launched } from "../listing.ts"; |
| 9 | 10 | import { dayName, scheduleDays, type ScheduleRow } from "../schedule.ts"; |
| @@ -33,7 +34,7 @@ export function schedulePage(input: { who: Who; listing: Launched }): string { | ||
| 33 | 34 | <header> |
| 34 | 35 | <p class="muted">Launch week · ${escape(l.agent_name)}, ${escape(l.brokerage)}</p> |
| 35 | 36 | <h2>${escape(l.address)}</h2> |
| 36 | <p class="muted">${signed} of ${total} steps use words a person approved. Nothing is sent from here.</p> | |
| 37 | <p class="muted">${isOffMarket(l.status) ? `This listing is ${escape(STATUS_LABELS[l.status].toLowerCase())}: nothing further goes out.` : `${signed} of ${total} steps use words a person approved. Nothing is sent from here.`}</p> | |
| 37 | 38 | </header> |
| 38 | 39 | <ol class="schedule-days"> |
| 39 | 40 | ${days |
src/photoalt.test.ts +1 −1
| @@ -121,7 +121,7 @@ test("the migration keeps existing rows and describes samples already planted", | ||
| 121 | 121 | const old = new Database(":memory:"); |
| 122 | 122 | old.exec("PRAGMA foreign_keys = ON"); |
| 123 | 123 | old.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT '')"); |
| 124 | const last = migrations.length - 4; | |
| 124 | const last = migrations.findIndex((sql) => sql.includes("ADD COLUMN photo_alt")); | |
| 125 | 125 | migrations.slice(0, last).forEach((sql, i) => { |
| 126 | 126 | old.exec(sql); |
| 127 | 127 | old.query("INSERT INTO schema_version (version) VALUES (?1)").run(i + 1); |
src/routes.ts +4 −4
| @@ -227,7 +227,7 @@ const LISTING_FIELDS = { | ||
| 227 | 227 | ...FACTS, |
| 228 | 228 | photos: { type: "array", items: { type: "string" }, maxItems: PHOTOS_MAX }, |
| 229 | 229 | headshot: { type: ["string", "null"] }, |
| 230 | status: { enum: [...STATUSES], description: "Where the listing stands; coming_soon, under_contract and sold add a status post and a status email." }, | |
| 230 | status: { enum: [...STATUSES], description: "Where the listing stands; coming_soon, under_contract and sold add a status post and a status email; withdrawn and expired take the listing off the market." }, | |
| 231 | 231 | sold_price: { type: ["integer", "null"], description: "Whole dollars; only a sold listing has one, and only if the agent said." }, |
| 232 | 232 | was_price: { type: ["integer", "null"], description: "Whole dollars: the price before it was lowered; set while the price stands below it, and then a price improvement post and email exist." }, |
| 233 | 233 | created_at: { type: "string", format: "date-time" }, |
| @@ -305,7 +305,7 @@ const LISTING_CHANGE: Schema = { | ||
| 305 | 305 | kind: { enum: [...KINDS] }, |
| 306 | 306 | text: { type: "string", minLength: 1, maxLength: ASSET_MAX }, |
| 307 | 307 | tone: { enum: [...TONES] }, |
| 308 | status: { enum: [...STATUSES], description: "Mark the listing coming_soon, active, under_contract or sold; the status post and email are written or removed, nothing else changes." }, | |
| 308 | status: { enum: [...STATUSES], description: "Mark the listing coming_soon, active, under_contract, sold, withdrawn or expired; the status post and email are written or removed, nothing else changes." }, | |
| 309 | 309 | sold_price: { type: ["integer", "null"], description: "With status sold: the price it sold at, in whole dollars; leave out to say nothing." }, |
| 310 | 310 | facts: { |
| 311 | 311 | type: "object", |
| @@ -965,7 +965,7 @@ export const routes: readonly Route[] = [ | ||
| 965 | 965 | summary: "The listings, newest first, each with how many phrases the Fair Housing check flags. Filter by status and search the address.", |
| 966 | 966 | query: { |
| 967 | 967 | limit: LIMIT, |
| 968 | status: { enum: [...FILTERS], description: "Only listings that are coming_soon, active, under_contract, sold or needs-approval; anything else is all." }, | |
| 968 | status: { enum: [...FILTERS], description: "Only listings that are coming_soon, active, under_contract, sold, off-market or needs-approval; anything else is all." }, | |
| 969 | 969 | q: { type: "string", maxLength: Q_MAX, description: "Only listings whose address contains this, any case." }, |
| 970 | 970 | }, |
| 971 | 971 | answer: list(LISTING_SUMMARY), |
| @@ -1017,7 +1017,7 @@ export const routes: readonly Route[] = [ | ||
| 1017 | 1017 | : editAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), text: body.text }), |
| 1018 | 1018 | ); |
| 1019 | 1019 | }, |
| 1020 | api: { summary: "Edit one asset's words, put an earlier version back ({ kind, restore }), withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it coming soon, active, under contract or sold ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", body: LISTING_CHANGE, answer: LISTING }, | |
| 1020 | api: { summary: "Edit one asset's words, put an earlier version back ({ kind, restore }), withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it coming soon, active, under contract, sold, withdrawn or expired ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", body: LISTING_CHANGE, answer: LISTING }, | |
| 1021 | 1021 | }, |
| 1022 | 1022 | { |
| 1023 | 1023 | method: "DELETE", |
src/schedule.ts +2 −1
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { publicOrigin } from "./env.ts"; |
| 12 | import { STATUS_KINDS, PRICE_KINDS, type Kind } from "./generate.ts"; | |
| 12 | import { isOffMarket, STATUS_KINDS, PRICE_KINDS, type Kind } from "./generate.ts"; | |
| 13 | 13 | import type { Launched } from "./listing.ts"; |
| 14 | 14 | import { approvedLine, oneLine } from "./report.ts"; |
| 15 | 15 | |
| @@ -40,6 +40,7 @@ export const addDays = (day: string, n: number): string => { | ||
| 40 | 40 | export const launchDay = (l: Launched): string => l.created_at.slice(0, 10); |
| 41 | 41 | |
| 42 | 42 | export function scheduleRows(l: Launched): ScheduleRow[] { |
| 43 | if (isOffMarket(l.status)) return []; | |
| 43 | 44 | const launch = launchDay(l); |
| 44 | 45 | const row = (day: string, what: string, kind: Kind, start: string | null = null, end: string | null = null): ScheduleRow[] => { |
| 45 | 46 | const a = l.assets.find((x) => x.kind === kind); |
src/tty.ts +2 −2
| @@ -86,7 +86,7 @@ export const openFromLine = (line: string): { date: string; start: string; end: | ||
| 86 | 86 | return { date: m?.[1] ?? r, start: m?.[2] ?? "", end: m?.[3] ?? "" }; |
| 87 | 87 | }); |
| 88 | 88 | |
| 89 | /** The status in the one-line box: "sold 752000", "sold", "under contract", "coming soon" or "active". */ | |
| 89 | /** The status in the one-line box: "sold 752000", "sold", "under contract", "coming soon", "withdrawn", "expired" or "active". */ | |
| 90 | 90 | export const statusToLine = (l: { status: string; sold_price: number | null }): string => |
| 91 | 91 | `${l.status.replace("_", " ")}${l.sold_price === null ? "" : ` ${l.sold_price}`}`; |
| 92 | 92 | export const statusFromLine = (line: string): { status: string; sold_price: string } => { |
| @@ -246,7 +246,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 246 | 246 | } |
| 247 | 247 | lines.push(""); |
| 248 | 248 | if (typing !== undefined) { |
| 249 | const label = typing.for === "note" ? "New note" : typing.for === "visitor" ? "Visitor: name, email (enter adds, esc drops)" : typing.for === "search" ? "Search by address (enter searches, empty clears, esc drops)" : typing.for === "price" ? "Price in whole dollars, like 849000; a lower price adds a price improvement post and email (enter saves, esc drops)" : typing.for === "status" ? "Status: coming soon, active, under contract, or sold with an optional price, like sold 752000 (enter saves, esc drops)" : typing.for === "open" ? "Open house: date start-end, like 2027-03-09 13:00-15:00; separate two with ; (empty clears, enter saves, esc drops)" : `Edit (\\n breaks a line, enter saves, esc drops)`; | |
| 249 | const label = typing.for === "note" ? "New note" : typing.for === "visitor" ? "Visitor: name, email (enter adds, esc drops)" : typing.for === "search" ? "Search by address (enter searches, empty clears, esc drops)" : typing.for === "price" ? "Price in whole dollars, like 849000; a lower price adds a price improvement post and email (enter saves, esc drops)" : typing.for === "status" ? "Status: coming soon, active, under contract, withdrawn, expired, or sold with an optional price, like sold 752000 (enter saves, esc drops)" : typing.for === "open" ? "Open house: date start-end, like 2027-03-09 13:00-15:00; separate two with ; (empty clears, enter saves, esc drops)" : `Edit (\\n breaks a line, enter saves, esc drops)`; | |
| 250 | 250 | // The tail of what is typed, so the cursor stays on the screen. |
| 251 | 251 | const room = Math.max(20, size.columns - 4); |
| 252 | 252 | lines.push(dim(label), `${typed.length > room ? `…${typed.slice(-room + 1)}` : typed}${esc}7m ${esc}0m`); |