Public: anyone can read this; the crew can change it.
Mark a listing under contract or sold, and write the 'Just sold' post and email from it #45
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/36-mark-a-listing-under-contract-or-sold-an → main 2 commits fork 2 threads · 2 unresolved
19 files
changed +693
−41
README.md +16 −4
| @@ -38,7 +38,17 @@ to make a listing of your own. | ||
| 38 | 38 | post a closing line, such as "Open Sunday, March 7, 1–3 pm", all in the |
| 39 | 39 | listing's tone. Clearing the three boxes takes it off again. The times are |
| 40 | 40 | kept as you type them: no time zone is applied. |
| 41 | 8. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with | |
| 41 | 8. **Mark it sold.** On your listing, pick **Sold** in the status box beside | |
| 42 | the tone, add the price it sold at if you want it said, and press **Set | |
| 43 | status**. Two more assets appear, a status post under Social and a status | |
| 44 | email under Email ("Just sold — 9 Walkthrough Way"), written in the | |
| 45 | listing's tone and checked like the rest; each needs your approval, so it | |
| 46 | says "0 of 8 approved". The other assets and your approvals stay. The | |
| 47 | flyer wears a "Sold" band, and the list on the first screen shows the | |
| 48 | status beside the approval count. *88 Meridian Street* is already sold, so | |
| 49 | you can see it. **Under contract** does the same with its own words, and | |
| 50 | **Active** takes the two away again. | |
| 51 | 9. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with | |
| 42 | 52 | the photo, price, facts, description and your name. Print it or save it |
| 43 | 53 | as a PDF. |
| 44 | 54 | |
| @@ -176,7 +186,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 176 | 186 | | ---------------------------------- | -------------------------------------------------------------- | |
| 177 | 187 | | `src/actions.ts` | listings (launch, edit an asset, retone, change facts, remove), notes, the audit trail | |
| 178 | 188 | | `src/listing.ts` | what a listing is, and the check at the boundary that one arrived | |
| 179 | | `src/generate.ts` | `generate(listing)`: the five assets from the facts, in three tones | | |
| 189 | | `src/generate.ts` | `generate(listing)`: the six assets from the facts (eight once it is under contract or sold), in three tones | | |
| 180 | 190 | | `src/postlength.ts` | a social post's length by what X, Instagram and Facebook count | |
| 181 | 191 | | `src/fairhousing.ts` | the Fair Housing rules: phrase, context, class, reason, rewrite | |
| 182 | 192 | | `src/photos.ts` | photos as files beside the database, known by their bytes | |
| @@ -259,11 +269,13 @@ curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' - | ||
| 259 | 269 | curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"facts":{"price":649000}}' |
| 260 | 270 | # an open house (up to two; local date and 24-hour times as typed; [] clears) |
| 261 | 271 | 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"}]}}' |
| 272 | # where it stands: active, under_contract or sold (sold_price optional, whole dollars); adds or removes the status post and email | |
| 273 | curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"status":"sold","sold_price":752000}' | |
| 262 | 274 | curl -X DELETE https://<app>/api/listings/1 |
| 263 | 275 | |
| 264 | 276 | |
| 265 | 277 | As an MCP server, for Claude Code or Claude Desktop — `list_listings`, |
| 266 | `get_listing`, `launch_listing`, `edit_asset`, `retone_listing`, `update_facts`, | |
| 278 | `get_listing`, `launch_listing`, `edit_asset`, `retone_listing`, `set_status`, `update_facts`, | |
| 267 | 279 | `update_photos` (order and removals; uploads are on the page only), `delete_listing`, |
| 268 | 280 | `check_fair_housing`, and the notes' `list_notes`, `add_note`, |
| 269 | 281 | `delete_note`: |
| @@ -290,7 +302,7 @@ a real pty over the same data, as the account at the keyboard. Open a | ||
| 290 | 302 | listing with `enter` to read every asset with its Fair Housing findings; |
| 291 | 303 | `j`/`k` move between assets, `e` edits the one under the cursor (type `\n` |
| 292 | 304 | for a line break, `enter` saves and checks it again), `t` rewrites them all |
| 293 | in the next tone, `x` removes a listing of your own. `tab` switches to the | |
| 305 | in the next tone, `s` sets the status (`sold 752000`, `under contract`, `active`), `x` removes a listing of your own. `tab` switches to the | |
| 294 | 306 | notes. Launching a listing, with its photos, is the page's alone. |
| 295 | 307 | |
| 296 | 308 | No runtime dependencies: Bun's own `bun:sqlite`, `Bun.serve` and `node:` |
contract/openapi.json +60 −5
| @@ -517,7 +517,7 @@ | ||
| 517 | 517 | } |
| 518 | 518 | }, |
| 519 | 519 | "patch": { |
| 520 | "summary": "Edit one asset's words, withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, or change the facts ({ facts }), which writes every asset again and clears every approval.", | |
| 520 | "summary": "Edit one asset's words, 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 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.", | |
| 521 | 521 | "operationId": "patchApiListingsId", |
| 522 | 522 | "parameters": [ |
| 523 | 523 | { |
| @@ -543,8 +543,10 @@ | ||
| 543 | 543 | "social_short", |
| 544 | 544 | "social_medium", |
| 545 | 545 | "social_carousel", |
| 546 | "social_status", | |
| 546 | 547 | "flyer", |
| 547 | "email" | |
| 548 | "email", | |
| 549 | "email_status" | |
| 548 | 550 | ] |
| 549 | 551 | }, |
| 550 | 552 | "text": { |
| @@ -559,6 +561,21 @@ | ||
| 559 | 561 | "bold" |
| 560 | 562 | ] |
| 561 | 563 | }, |
| 564 | "status": { | |
| 565 | "enum": [ | |
| 566 | "active", | |
| 567 | "under_contract", | |
| 568 | "sold" | |
| 569 | ], | |
| 570 | "description": "Mark the listing active, under_contract or sold; the status post and email are written or removed, nothing else changes." | |
| 571 | }, | |
| 572 | "sold_price": { | |
| 573 | "type": [ | |
| 574 | "integer", | |
| 575 | "null" | |
| 576 | ], | |
| 577 | "description": "With status sold: the price it sold at, in whole dollars; leave out to say nothing." | |
| 578 | }, | |
| 562 | 579 | "facts": { |
| 563 | 580 | "type": "object", |
| 564 | 581 | "description": "Launch fields to change; the ones left out keep their value.", |
| @@ -707,7 +724,7 @@ | ||
| 707 | 724 | }, |
| 708 | 725 | "responses": { |
| 709 | 726 | "200": { |
| 710 | "description": "Edit one asset's words, withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, or change the facts ({ facts }), which writes every asset again and clears every approval.", | |
| 727 | "description": "Edit one asset's words, 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 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.", | |
| 711 | 728 | "content": { |
| 712 | 729 | "application/json": { |
| 713 | 730 | "schema": { |
| @@ -1158,6 +1175,21 @@ | ||
| 1158 | 1175 | "null" |
| 1159 | 1176 | ] |
| 1160 | 1177 | }, |
| 1178 | "status": { | |
| 1179 | "enum": [ | |
| 1180 | "active", | |
| 1181 | "under_contract", | |
| 1182 | "sold" | |
| 1183 | ], | |
| 1184 | "description": "Where the listing stands; under_contract and sold add a status post and a status email." | |
| 1185 | }, | |
| 1186 | "sold_price": { | |
| 1187 | "type": [ | |
| 1188 | "integer", | |
| 1189 | "null" | |
| 1190 | ], | |
| 1191 | "description": "Whole dollars; only a sold listing has one, and only if the agent said." | |
| 1192 | }, | |
| 1161 | 1193 | "created_at": { |
| 1162 | 1194 | "type": "string", |
| 1163 | 1195 | "format": "date-time" |
| @@ -1177,8 +1209,10 @@ | ||
| 1177 | 1209 | "social_short", |
| 1178 | 1210 | "social_medium", |
| 1179 | 1211 | "social_carousel", |
| 1212 | "social_status", | |
| 1180 | 1213 | "flyer", |
| 1181 | "email" | |
| 1214 | "email", | |
| 1215 | "email_status" | |
| 1182 | 1216 | ] |
| 1183 | 1217 | }, |
| 1184 | 1218 | "label": { |
| @@ -1326,6 +1360,8 @@ | ||
| 1326 | 1360 | "open_houses", |
| 1327 | 1361 | "photos", |
| 1328 | 1362 | "headshot", |
| 1363 | "status", | |
| 1364 | "sold_price", | |
| 1329 | 1365 | "created_at", |
| 1330 | 1366 | "updated_at", |
| 1331 | 1367 | "assets" |
| @@ -1466,6 +1502,21 @@ | ||
| 1466 | 1502 | "null" |
| 1467 | 1503 | ] |
| 1468 | 1504 | }, |
| 1505 | "status": { | |
| 1506 | "enum": [ | |
| 1507 | "active", | |
| 1508 | "under_contract", | |
| 1509 | "sold" | |
| 1510 | ], | |
| 1511 | "description": "Where the listing stands; under_contract and sold add a status post and a status email." | |
| 1512 | }, | |
| 1513 | "sold_price": { | |
| 1514 | "type": [ | |
| 1515 | "integer", | |
| 1516 | "null" | |
| 1517 | ], | |
| 1518 | "description": "Whole dollars; only a sold listing has one, and only if the agent said." | |
| 1519 | }, | |
| 1469 | 1520 | "created_at": { |
| 1470 | 1521 | "type": "string", |
| 1471 | 1522 | "format": "date-time" |
| @@ -1490,8 +1541,10 @@ | ||
| 1490 | 1541 | "social_short", |
| 1491 | 1542 | "social_medium", |
| 1492 | 1543 | "social_carousel", |
| 1544 | "social_status", | |
| 1493 | 1545 | "flyer", |
| 1494 | "email" | |
| 1546 | "email", | |
| 1547 | "email_status" | |
| 1495 | 1548 | ] |
| 1496 | 1549 | }, |
| 1497 | 1550 | "approved": { |
| @@ -1527,6 +1580,8 @@ | ||
| 1527 | 1580 | "open_houses", |
| 1528 | 1581 | "photos", |
| 1529 | 1582 | "headshot", |
| 1583 | "status", | |
| 1584 | "sold_price", | |
| 1530 | 1585 | "created_at", |
| 1531 | 1586 | "updated_at", |
| 1532 | 1587 | "flags", |
docs/design/areas/status.md added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | # Listing status | |
| 2 | ||
| 3 | New component classes, all built from existing tokens: | |
| 4 | ||
| 5 | - `status-pill` — "Under contract" or "Sold at $752,000" beside the approval pill on the home list and the listing head; `status-sold` is green, `status-under_contract` amber. | |
| 6 | - `flyer-status` — the band across the top of the flyer, same colours. | |
| 7 | - `status-form` — the status choice, sold price and button in the listing's tools. |
src/actions.ts +65 −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, TONES } from "./generate.ts"; | |
| 13 | import { generate, KINDS, STATUS_KINDS, TONES, type Kind, type Status } from "./generate.ts"; | |
| 14 | 14 | import { |
| 15 | 15 | agentOf, |
| 16 | 16 | ASSET_MAX, |
| @@ -21,6 +21,7 @@ import { | ||
| 21 | 21 | isTone, |
| 22 | 22 | touchesOpenHouses, |
| 23 | 23 | validate, |
| 24 | validateStatus, | |
| 24 | 25 | type AssetRow, |
| 25 | 26 | type Caller, |
| 26 | 27 | type Launched, |
| @@ -343,8 +344,12 @@ function launched(db: Database, id: number): Launched | undefined { | ||
| 343 | 344 | return { ...fromRow(row), assets: assetsOf(assets) }; |
| 344 | 345 | } |
| 345 | 346 | |
| 346 | /** Write every asset fresh from the facts, as generated (not edited). */ | |
| 347 | function writeAssets(db: Database, listing: Listing): void { | |
| 347 | /** | |
| 348 | * Write assets fresh from the facts, as generated (not edited): every kind by | |
| 349 | * default, or only `only`. A kind the listing has no words for now (a status | |
| 350 | * post once it is active again) is removed. | |
| 351 | */ | |
| 352 | function writeAssets(db: Database, listing: Listing, only: readonly Kind[] = KINDS): void { | |
| 348 | 353 | const words = generate({ facts: listing, agent: agentOf(listing) }); |
| 349 | 354 | const put = db.query( |
| 350 | 355 | `INSERT INTO asset (listing_id, kind, body, edited) VALUES (?1, ?2, ?3, 0) |
| @@ -352,7 +357,12 @@ function writeAssets(db: Database, listing: Listing): void { | ||
| 352 | 357 | approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL, |
| 353 | 358 | updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`, |
| 354 | 359 | ); |
| 355 | for (const kind of KINDS) put.run(listing.id, kind, words[kind]); | |
| 360 | const drop = db.query("DELETE FROM asset WHERE listing_id = ?1 AND kind = ?2"); | |
| 361 | for (const kind of only) { | |
| 362 | const body = words[kind]; | |
| 363 | if (body === undefined) drop.run(listing.id, kind); | |
| 364 | else put.run(listing.id, kind, body); | |
| 365 | } | |
| 356 | 366 | } |
| 357 | 367 | |
| 358 | 368 | /** Insert a listing and its assets as one, for whoever is named. */ |
| @@ -396,6 +406,23 @@ export function insertListing( | ||
| 396 | 406 | })(); |
| 397 | 407 | } |
| 398 | 408 | |
| 409 | /** Where a listing stands, for whoever is named (the sample planting, not a door): the status assets written, nothing else touched. */ | |
| 410 | export function markStatus( | |
| 411 | db: Database, | |
| 412 | id: number, | |
| 413 | stand: { status: Status; sold_price: number | null }, | |
| 414 | ): Launched { | |
| 415 | return db.transaction(() => { | |
| 416 | const row = db | |
| 417 | .query<ListingRow, [string, number | null, number]>( | |
| 418 | "UPDATE listing SET status = ?1, sold_price = ?2, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?3 RETURNING *", | |
| 419 | ) | |
| 420 | .get(stand.status, stand.sold_price, id)!; | |
| 421 | writeAssets(db, fromRow(row), STATUS_KINDS); | |
| 422 | return launched(db, id)!; | |
| 423 | })(); | |
| 424 | } | |
| 425 | ||
| 399 | 426 | /** A listing in the list: the Fair Housing flags and which assets are signed off. */ |
| 400 | 427 | export type ListingSummary = Listing & { |
| 401 | 428 | flags: number; |
| @@ -837,6 +864,40 @@ function changeFacts( | ||
| 837 | 864 | return ok(launched(db, now.id)!); |
| 838 | 865 | } |
| 839 | 866 | |
| 867 | /** | |
| 868 | * Mark a listing active, under contract or sold (with the price it sold at, | |
| 869 | * if the agent says). Under contract and sold add a status post and a status | |
| 870 | * email in the listing's tone, each needing its own approval; active takes | |
| 871 | * them away. The other assets, their edits and their approvals are left | |
| 872 | * alone. Saying what is already so changes nothing. | |
| 873 | */ | |
| 874 | export const setStatus = ( | |
| 875 | db: Database, | |
| 876 | who: Who, | |
| 877 | input: { id: number; status: unknown; sold_price?: unknown }, | |
| 878 | ): Result<Launched> => | |
| 879 | audited(db, who, "listing", "update", input.id, changeStatus(db, who, input)); | |
| 880 | ||
| 881 | function changeStatus( | |
| 882 | db: Database, | |
| 883 | who: Who, | |
| 884 | input: { id: number; status: unknown; sold_price?: unknown }, | |
| 885 | ): Result<Launched> { | |
| 886 | const found = listingFor(db, who, "update", input.id, { | |
| 887 | signIn: "Sign in to edit this listing.", | |
| 888 | no: "Only the listing's agent or an admin can edit it.", | |
| 889 | }); | |
| 890 | if (!found.ok) return found; | |
| 891 | const stand = validateStatus({ status: input.status, sold_price: input.sold_price }); | |
| 892 | if (!stand.ok) return fail("invalid", stand.message); | |
| 893 | const now = found.value; | |
| 894 | if (stand.value.status === now.status && stand.value.sold_price === now.sold_price) | |
| 895 | return ok(now); | |
| 896 | const marked = markStatus(db, now.id, stand.value); | |
| 897 | emit(db, "listing.changed", { id: now.id }); | |
| 898 | return ok(marked); | |
| 899 | } | |
| 900 | ||
| 840 | 901 | /** The assets that show the photos: what they show changed, so their approval goes. */ |
| 841 | 902 | const PHOTO_ASSETS = ["flyer", "email"] as const; |
| 842 | 903 |
src/db.ts +3
| @@ -92,6 +92,9 @@ export const migrations: string[] = [ | ||
| 92 | 92 | `ALTER TABLE asset ADD COLUMN approved_via TEXT`, |
| 93 | 93 | // Up to two open houses, as typed: a JSON list of { date, start, end }, local time, never converted. |
| 94 | 94 | `ALTER TABLE listing ADD COLUMN open_houses TEXT NOT NULL DEFAULT '[]'`, |
| 95 | // Where the listing stands: active, under_contract or sold, and the price it sold at if the agent said. | |
| 96 | `ALTER TABLE listing ADD COLUMN status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'under_contract', 'sold'))`, | |
| 97 | `ALTER TABLE listing ADD COLUMN sold_price INTEGER`, | |
| 95 | 98 | ]; |
| 96 | 99 | |
| 97 | 100 | export function openDb(dir = dataDir): Database { |
src/generate.test.ts +5 −5
| @@ -10,7 +10,7 @@ import { | ||
| 10 | 10 | factsLine, |
| 11 | 11 | flyerParts, |
| 12 | 12 | generate, |
| 13 | KINDS, | |
| 13 | CORE_KINDS, | |
| 14 | 14 | lotOf, |
| 15 | 15 | street, |
| 16 | 16 | TONES, |
| @@ -25,8 +25,8 @@ const facts = (tone: Facts["tone"]): Facts => ({ ...SAMPLE, tone }); | ||
| 25 | 25 | test("all five assets, every text filled, in every tone", () => { |
| 26 | 26 | for (const tone of TONES) { |
| 27 | 27 | const words = generate({ facts: facts(tone), agent }); |
| 28 | expect(Object.keys(words).sort()).toEqual([...KINDS].sort()); | |
| 29 | for (const kind of KINDS) | |
| 28 | expect(Object.keys(words).sort()).toEqual([...CORE_KINDS].sort()); | |
| 29 | for (const kind of CORE_KINDS) | |
| 30 | 30 | expect([tone, kind, words[kind].length > 40]).toEqual([tone, kind, true]); |
| 31 | 31 | } |
| 32 | 32 | }); |
| @@ -72,7 +72,7 @@ test("each asset says what the agent typed, and only that", () => { | ||
| 72 | 72 | test("the templates never say what the Fair Housing check flags", () => { |
| 73 | 73 | for (const tone of TONES) { |
| 74 | 74 | const words = generate({ facts: facts(tone), agent }); |
| 75 | for (const kind of KINDS) | |
| 75 | for (const kind of CORE_KINDS) | |
| 76 | 76 | expect([tone, kind, check(words[kind]).findings]).toEqual([tone, kind, []]); |
| 77 | 77 | } |
| 78 | 78 | }); |
| @@ -99,7 +99,7 @@ test("every asset reads like a person wrote it, in every tone", () => { | ||
| 99 | 99 | for (const [name, c] of cases) |
| 100 | 100 | for (const tone of TONES) { |
| 101 | 101 | const words = generate({ facts: { ...c, tone }, agent }); |
| 102 | for (const kind of KINDS) | |
| 102 | for (const kind of CORE_KINDS) | |
| 103 | 103 | for (const re of bad) expect([name, tone, kind, re.test(words[kind])]).toEqual([name, tone, kind, false]); |
| 104 | 104 | } |
| 105 | 105 | }); |
src/generate.ts +87 −2
| @@ -13,13 +13,18 @@ | ||
| 13 | 13 | export const TONES = ["classic", "warm", "bold"] as const; |
| 14 | 14 | export type Tone = (typeof TONES)[number]; |
| 15 | 15 | |
| 16 | /** Every asset a listing always has; the status ones come and go with its status. */ | |
| 17 | export const CORE_KINDS = ["mls", "social_short", "social_medium", "social_carousel", "flyer", "email"] as const; | |
| 18 | ||
| 16 | 19 | export const KINDS = [ |
| 17 | 20 | "mls", |
| 18 | 21 | "social_short", |
| 19 | 22 | "social_medium", |
| 20 | 23 | "social_carousel", |
| 24 | "social_status", | |
| 21 | 25 | "flyer", |
| 22 | 26 | "email", |
| 27 | "email_status", | |
| 23 | 28 | ] as const; |
| 24 | 29 | export type Kind = (typeof KINDS)[number]; |
| 25 | 30 | |
| @@ -29,8 +34,23 @@ export const LABELS: Readonly<Record<Kind, string>> = { | ||
| 29 | 34 | social_short: "Short post", |
| 30 | 35 | social_medium: "Medium post", |
| 31 | 36 | social_carousel: "Carousel captions", |
| 37 | social_status: "Status post", | |
| 32 | 38 | flyer: "Flyer copy", |
| 33 | 39 | email: "Email blast", |
| 40 | email_status: "Status email", | |
| 41 | }; | |
| 42 | ||
| 43 | /** The assets that exist only while a listing is under contract or sold. */ | |
| 44 | export const STATUS_KINDS = ["social_status", "email_status"] as const satisfies readonly Kind[]; | |
| 45 | ||
| 46 | export const STATUSES = ["active", "under_contract", "sold"] as const; | |
| 47 | export type Status = (typeof STATUSES)[number]; | |
| 48 | ||
| 49 | /** What a person calls each status. */ | |
| 50 | export const STATUS_LABELS: Readonly<Record<Status, string>> = { | |
| 51 | active: "Active", | |
| 52 | under_contract: "Under contract", | |
| 53 | sold: "Sold", | |
| 34 | 54 | }; |
| 35 | 55 | |
| 36 | 56 | /** One open house, as typed: the local date (YYYY-MM-DD) and 24-hour times (HH:MM). Never converted. */ |
| @@ -51,6 +71,10 @@ export interface Facts { | ||
| 51 | 71 | readonly features: readonly string[]; |
| 52 | 72 | readonly tone: Tone; |
| 53 | 73 | readonly open_houses?: readonly OpenHouse[]; |
| 74 | /** Absent means active. */ | |
| 75 | readonly status?: Status; | |
| 76 | /** Whole dollars, only when sold, and only if the agent chose to say. */ | |
| 77 | readonly sold_price?: number | null; | |
| 54 | 78 | } |
| 55 | 79 | |
| 56 | 80 | export interface Agent { |
| @@ -282,12 +306,72 @@ function email(f: Facts, a: Agent): string { | ||
| 282 | 306 | return `Subject: ${subject}\n\n${when === null ? "" : `${when}\n\n`}${open}\n\nThe facts: ${plural(f.beds, "bedroom")}, ${plural(f.baths, "bath")}, ${count(f.sqft)} square feet${lotPhrase(f, " on ")}, built in ${f.year}.\n\nHighlights:\n${list}\n\n${close}\n\n${a.name}\n${a.brokerage} · License ${a.license}\n${a.phone} · ${a.email}`; |
| 283 | 307 | } |
| 284 | 308 | |
| 285 | /** All five assets (six texts: social is three posts), for one listing. */ | |
| 309 | /** The status announcement's opening, in the listing's tone: "Just sold" or "Under contract". */ | |
| 310 | function statusPost(f: Facts, a: Agent): string { | |
| 311 | const where = street(f.address); | |
| 312 | const sold = f.status === "sold"; | |
| 313 | const price = sold && f.sold_price != null ? ` at ${money(f.sold_price)}` : ""; | |
| 314 | const line = sold | |
| 315 | ? { | |
| 316 | classic: `Just sold${price}: ${where}. Thank you to everyone who made it happen.`, | |
| 317 | warm: `Just sold${price}: ${where}! It was a joy to help these owners, and I am grateful for everyone who made it happen.`, | |
| 318 | bold: `JUST SOLD${price.toUpperCase()}: ${where}. Another one done.`, | |
| 319 | }[f.tone] | |
| 320 | : { | |
| 321 | classic: `Under contract: ${where}. Thank you to everyone who has made it happen so far.`, | |
| 322 | warm: `Good news: ${where} is under contract! Thank you to everyone who helped get it here.`, | |
| 323 | bold: `UNDER CONTRACT: ${where}. Moving fast.`, | |
| 324 | }[f.tone]; | |
| 325 | const close = sold | |
| 326 | ? { | |
| 327 | classic: "Thinking about selling? Message me for a conversation about your home.", | |
| 328 | warm: "Thinking of selling? I would love to talk about your home. Send me a message.", | |
| 329 | bold: "Want results like this? Message me today.", | |
| 330 | }[f.tone] | |
| 331 | : { | |
| 332 | classic: "Message me about what is coming next.", | |
| 333 | warm: "Send me a message if you would like to hear about what comes next.", | |
| 334 | bold: "More coming soon. Message me.", | |
| 335 | }[f.tone]; | |
| 336 | return `${line} ${close} ${a.name}, ${a.brokerage}. ${sold ? "#JustSold" : "#UnderContract"}`; | |
| 337 | } | |
| 338 | ||
| 339 | function statusEmail(f: Facts, a: Agent): string { | |
| 340 | const where = street(f.address); | |
| 341 | const sold = f.status === "sold"; | |
| 342 | const subject = sold ? `Just sold — ${where}` : `Under contract — ${where}`; | |
| 343 | const price = sold && f.sold_price != null ? ` It sold at ${money(f.sold_price)}.` : ""; | |
| 344 | const open = sold | |
| 345 | ? { | |
| 346 | classic: `I am pleased to share that ${f.address} has sold.${price}`, | |
| 347 | warm: `I have happy news to share: ${f.address} has sold!${price}`, | |
| 348 | bold: `${f.address} is SOLD.${price}`, | |
| 349 | }[f.tone] | |
| 350 | : { | |
| 351 | classic: `I am pleased to share that ${f.address} is now under contract.`, | |
| 352 | warm: `I have happy news to share: ${f.address} is now under contract!`, | |
| 353 | bold: `${f.address} is under contract.`, | |
| 354 | }[f.tone]; | |
| 355 | const thanks = { | |
| 356 | classic: "Thank you to the buyers, the owners and everyone who worked on it.", | |
| 357 | warm: "Thank you to the buyers, the owners and everyone who helped along the way. It was a pleasure.", | |
| 358 | bold: "Thanks to everyone who made it happen.", | |
| 359 | }[f.tone]; | |
| 360 | const close = { | |
| 361 | classic: "If you are considering selling, reply to this email and I will be glad to talk through your options.", | |
| 362 | warm: "If you or someone you know is thinking about selling, just reply to this email and we can talk it through.", | |
| 363 | bold: "Thinking of selling? Reply now and let us talk about your home.", | |
| 364 | }[f.tone]; | |
| 365 | return `Subject: ${subject}\n\n${open}\n\n${thanks}\n\n${close}\n\n${a.name}\n${a.brokerage} · License ${a.license}\n${a.phone} · ${a.email}`; | |
| 366 | } | |
| 367 | ||
| 368 | /** A listing's assets: the six, and the two status ones when it is under contract or sold. */ | |
| 286 | 369 | export function generate(listing: { |
| 287 | 370 | readonly facts: Facts; |
| 288 | 371 | readonly agent: Agent; |
| 289 | }): Record<Kind, string> { | |
| 372 | }): Partial<Record<Kind, string>> & Record<(typeof CORE_KINDS)[number], string> { | |
| 290 | 373 | const { facts: f, agent: a } = listing; |
| 374 | const announced = f.status === "under_contract" || f.status === "sold"; | |
| 291 | 375 | return { |
| 292 | 376 | mls: mls(f), |
| 293 | 377 | social_short: socialShort(f, a), |
| @@ -295,6 +379,7 @@ export function generate(listing: { | ||
| 295 | 379 | social_carousel: socialCarousel(f, a), |
| 296 | 380 | flyer: flyer(f), |
| 297 | 381 | email: email(f, a), |
| 382 | ...(announced ? { social_status: statusPost(f, a), email_status: statusEmail(f, a) } : {}), | |
| 298 | 383 | }; |
| 299 | 384 | } |
| 300 | 385 |
src/listing.ts +25
| @@ -9,11 +9,13 @@ import { check, type Check } from "./fairhousing.ts"; | ||
| 9 | 9 | import { |
| 10 | 10 | KINDS, |
| 11 | 11 | LABELS, |
| 12 | STATUSES, | |
| 12 | 13 | TONES, |
| 13 | 14 | type Agent, |
| 14 | 15 | type Facts, |
| 15 | 16 | type Kind, |
| 16 | 17 | type OpenHouse, |
| 18 | type Status, | |
| 17 | 19 | type Tone, |
| 18 | 20 | } from "./generate.ts"; |
| 19 | 21 | |
| @@ -38,6 +40,9 @@ export interface ListingInput extends Facts { | ||
| 38 | 40 | } |
| 39 | 41 | |
| 40 | 42 | export interface Listing extends ListingInput { |
| 43 | readonly status: Status; | |
| 44 | /** Whole dollars; only a sold listing has one, and only if the agent said. */ | |
| 45 | readonly sold_price: number | null; | |
| 41 | 46 | readonly id: number; |
| 42 | 47 | readonly author_id: string; |
| 43 | 48 | readonly author_name: string; |
| @@ -102,6 +107,8 @@ export interface ListingRow { | ||
| 102 | 107 | readonly tone: Tone; |
| 103 | 108 | readonly palette: Palette; |
| 104 | 109 | readonly open_houses: string; |
| 110 | readonly status: Status; | |
| 111 | readonly sold_price: number | null; | |
| 105 | 112 | readonly agent_name: string; |
| 106 | 113 | readonly brokerage: string; |
| 107 | 114 | readonly license: string; |
| @@ -162,6 +169,8 @@ export const isKind = (v: unknown): v is Kind => | ||
| 162 | 169 | typeof v === "string" && (KINDS as readonly string[]).includes(v); |
| 163 | 170 | export const isTone = (v: unknown): v is Tone => |
| 164 | 171 | typeof v === "string" && (TONES as readonly string[]).includes(v); |
| 172 | export const isStatus = (v: unknown): v is Status => | |
| 173 | typeof v === "string" && (STATUSES as readonly string[]).includes(v); | |
| 165 | 174 | export const isPalette = (v: unknown): v is Palette => |
| 166 | 175 | typeof v === "string" && (PALETTES as readonly string[]).includes(v); |
| 167 | 176 | |
| @@ -326,6 +335,22 @@ export function validate( | ||
| 326 | 335 | }; |
| 327 | 336 | } |
| 328 | 337 | |
| 338 | /** Where a listing stands, checked: a status, and the price it sold at only if it sold (blank says nothing). */ | |
| 339 | export function validateStatus( | |
| 340 | raw: Readonly<Record<string, unknown>>, | |
| 341 | ): Checked<{ status: Status; sold_price: number | null }> { | |
| 342 | if (!isStatus(raw.status)) | |
| 343 | return { ok: false, message: `The status is one of ${STATUSES.join(", ")}.` }; | |
| 344 | const said = line(raw.sold_price); | |
| 345 | if (said === "") return { ok: true, value: { status: raw.status, sold_price: null } }; | |
| 346 | if (raw.status !== "sold") | |
| 347 | return { ok: false, message: "A sold price goes with the status sold." }; | |
| 348 | const price = number(said); | |
| 349 | if (!Number.isInteger(price) || price < 1000 || price > 1_000_000_000) | |
| 350 | return { ok: false, message: "The sold price is whole dollars, from $1,000." }; | |
| 351 | return { ok: true, value: { status: "sold", sold_price: price } }; | |
| 352 | } | |
| 353 | ||
| 329 | 354 | /** An edited asset's words: kept as typed, within bounds. */ |
| 330 | 355 | export function assetText(v: unknown): Checked<string> { |
| 331 | 356 | const t = String(v ?? "") |
src/mcp.ts +18 −1
| @@ -20,11 +20,12 @@ import { | ||
| 20 | 20 | listListings, |
| 21 | 21 | listNotes, |
| 22 | 22 | retoneListing, |
| 23 | setStatus, | |
| 23 | 24 | updateFacts, |
| 24 | 25 | updatePhotos, |
| 25 | 26 | type Result, |
| 26 | 27 | } from "./actions.ts"; |
| 27 | import { KINDS, TONES } from "./generate.ts"; | |
| 28 | import { KINDS, STATUSES, TONES } from "./generate.ts"; | |
| 28 | 29 | import { title } from "./env.ts"; |
| 29 | 30 | import { fields } from "./http.ts"; |
| 30 | 31 | import type { Who } from "./who.ts"; |
| @@ -218,6 +219,22 @@ export const tools: readonly Tool[] = [ | ||
| 218 | 219 | run: (db, who, args) => |
| 219 | 220 | retoneListing(db, who, { id: Number(args.id), tone: args.tone }), |
| 220 | 221 | }, |
| 222 | { | |
| 223 | name: "set_status", | |
| 224 | description: | |
| 225 | "Mark a listing active, under_contract or sold (with sold_price in whole dollars, optional). 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.", | |
| 226 | inputSchema: { | |
| 227 | type: "object", | |
| 228 | properties: { | |
| 229 | id: { type: "integer" }, | |
| 230 | status: { enum: [...STATUSES] }, | |
| 231 | sold_price: { type: "integer" }, | |
| 232 | }, | |
| 233 | required: ["id", "status"], | |
| 234 | }, | |
| 235 | run: (db, who, args) => | |
| 236 | setStatus(db, who, { id: Number(args.id), status: args.status, sold_price: args.sold_price }), | |
| 237 | }, | |
| 221 | 238 | { |
| 222 | 239 | name: "update_facts", |
| 223 | 240 | description: |
src/openhouse.test.ts +4 −4
| @@ -10,7 +10,7 @@ import { join } from "node:path"; | ||
| 10 | 10 | import { approveAsset, getListing, launchListing, updateFacts } from "./actions.ts"; |
| 11 | 11 | import { openDb } from "./db.ts"; |
| 12 | 12 | import { check } from "./fairhousing.ts"; |
| 13 | import { generate, openHouseLine, timeRange, TONES, KINDS, flyerParts, type OpenHouse } from "./generate.ts"; | |
| 13 | import { generate, openHouseLine, timeRange, TONES, CORE_KINDS, flyerParts, type OpenHouse } from "./generate.ts"; | |
| 14 | 14 | import { agentOf, validate } from "./listing.ts"; |
| 15 | 15 | import { rpc } from "./mcp.ts"; |
| 16 | 16 | import { SAMPLE } from "./sample.ts"; |
| @@ -65,14 +65,14 @@ test("every asset carries the line when set, in every tone, and none when cleare | ||
| 65 | 65 | const facts = { ...SAMPLE, tone, open_houses: [sunday] }; |
| 66 | 66 | const line = openHouseLine(facts)!; |
| 67 | 67 | const words = generate({ facts, agent: agentOf(SAMPLE) }); |
| 68 | for (const kind of KINDS) expect([tone, kind, words[kind].includes(line)]).toEqual([tone, kind, true]); | |
| 68 | for (const kind of CORE_KINDS) expect([tone, kind, words[kind].includes(line)]).toEqual([tone, kind, true]); | |
| 69 | 69 | expect(words.mls.split("\n").at(-1)).toBe(line); |
| 70 | 70 | expect(words.email.split("\n\n")[1]).toBe(line); |
| 71 | 71 | expect(flyerParts(words.flyer, line).open).toBe(line); |
| 72 | 72 | expect(flyerParts(words.flyer, line).body).not.toContain("Sunday"); |
| 73 | for (const kind of KINDS) expect(check(words[kind]).clear).toBe(true); | |
| 73 | for (const kind of CORE_KINDS) expect(check(words[kind]).clear).toBe(true); | |
| 74 | 74 | const none = generate({ facts: { ...facts, open_houses: [] }, agent: agentOf(SAMPLE) }); |
| 75 | for (const kind of KINDS) expect([tone, kind, /open|Sunday/i.test(none[kind])]).toEqual([tone, kind, false]); | |
| 75 | for (const kind of CORE_KINDS) expect([tone, kind, /open|Sunday/i.test(none[kind])]).toEqual([tone, kind, false]); | |
| 76 | 76 | } |
| 77 | 77 | }); |
| 78 | 78 |
src/pages/home.ts +2 −2
| @@ -18,7 +18,7 @@ import { PHOTOS_MAX } from "../photos.ts"; | ||
| 18 | 18 | import { SAMPLE } from "../sample.ts"; |
| 19 | 19 | import type { Who } from "../who.ts"; |
| 20 | 20 | import { ago, layout, toast } from "./layout.ts"; |
| 21 | import { approvalPill, photoUrl } from "./listing.ts"; | |
| 21 | import { approvalPill, photoUrl, statusPill } from "./listing.ts"; | |
| 22 | 22 | |
| 23 | 23 | const TONE_SAYS: Readonly<Record<Tone, string>> = { |
| 24 | 24 | classic: "Measured and precise. Lets the facts carry it.", |
| @@ -179,7 +179,7 @@ function list(listings: readonly ListingSummary[]): string { | ||
| 179 | 179 | <span class="card-body"> |
| 180 | 180 | <b>${escape(l.address)}</b> |
| 181 | 181 | <span>${money(l.price)} · ${l.beds} bd · ${l.baths} ba · ${count(l.sqft)} sq ft</span> |
| 182 | <span class="row card-meta">${approvalPill(l.approvals.filter((a) => a.approved).length, l.approvals.length)}${status(l.flags)}<small class="muted">${escape(l.author_name)} · ${ago(l.updated_at)}</small></span> | |
| 182 | <span class="row card-meta">${approvalPill(l.approvals.filter((a) => a.approved).length, l.approvals.length)}${statusPill(l)}${status(l.flags)}<small class="muted">${escape(l.author_name)} · ${ago(l.updated_at)}</small></span> | |
| 183 | 183 | </span> |
| 184 | 184 | </a> |
| 185 | 185 | </li>`; |
src/pages/listing.ts +28 −4
| @@ -11,6 +11,8 @@ import { | ||
| 11 | 11 | flyerParts, |
| 12 | 12 | openHouseLine, |
| 13 | 13 | money, |
| 14 | STATUS_LABELS, | |
| 15 | STATUSES, | |
| 14 | 16 | TONES, |
| 15 | 17 | } from "../generate.ts"; |
| 16 | 18 | import { asset as assetHref, escape } from "../http.ts"; |
| @@ -89,6 +91,25 @@ const approvedCount = (l: Launched): number => | ||
| 89 | 91 | export const approvalPill = (approved: number, total: number): string => |
| 90 | 92 | `<span class="fh-pill${approved === total ? " fh-ok" : ""}">${approvalLabel(approved, total)}</span>`; |
| 91 | 93 | |
| 94 | /** "Under contract" or "Sold at $752,000" beside the approval pill; nothing for an active listing. */ | |
| 95 | export const statusPill = (l: Pick<Listing, "status" | "sold_price">): string => | |
| 96 | l.status === "active" | |
| 97 | ? "" | |
| 98 | : `<span class="pill status-pill status-${l.status}">${escape(statusLine(l))}</span>`; | |
| 99 | ||
| 100 | const statusLine = (l: Pick<Listing, "status" | "sold_price">): string => | |
| 101 | l.status === "sold" && l.sold_price !== null ? `Sold at ${money(l.sold_price)}` : STATUS_LABELS[l.status]; | |
| 102 | ||
| 103 | /** The agent's way to say where the listing stands: a choice, the price it sold at, one button. */ | |
| 104 | const statusForm = (l: Listing): string => | |
| 105 | `<form class="row status-form" method="post" action="/listings/${l.id}/status"> | |
| 106 | <label class="sr" for="status">Status</label> | |
| 107 | <select class="input select" id="status" name="status">${STATUSES.map((s) => `<option value="${s}"${s === l.status ? " selected" : ""}>${STATUS_LABELS[s]}</option>`).join("")}</select> | |
| 108 | <label class="sr" for="sold_price">Sold price, optional</label> | |
| 109 | <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}"> | |
| 110 | <button class="button button-quiet" title="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> | |
| 111 | </form>`; | |
| 112 | ||
| 92 | 113 | /** The band an unapproved flyer or email wears, on screen and on paper; nothing once approved. */ |
| 93 | 114 | export const draftBand = (l: Launched, kind: "flyer" | "email"): string => |
| 94 | 115 | l.assets.find((a) => a.kind === kind)?.approval == null |
| @@ -136,9 +157,9 @@ function editor(l: Launched, a: Asset, editable: boolean): string { | ||
| 136 | 157 | |
| 137 | 158 | const TABS: readonly { id: string; label: string; kinds: readonly string[] }[] = [ |
| 138 | 159 | { id: "mls", label: "MLS", kinds: ["mls"] }, |
| 139 | { id: "social", label: "Social", kinds: ["social_short", "social_medium", "social_carousel"] }, | |
| 160 | { id: "social", label: "Social", kinds: ["social_short", "social_medium", "social_carousel", "social_status"] }, | |
| 140 | 161 | { id: "flyer", label: "Flyer", kinds: ["flyer"] }, |
| 141 | { id: "email", label: "Email", kinds: ["email"] }, | |
| 162 | { id: "email", label: "Email", kinds: ["email", "email_status"] }, | |
| 142 | 163 | ]; |
| 143 | 164 | |
| 144 | 165 | /** The tab that holds an asset, or a tab named outright; the first otherwise. */ |
| @@ -164,7 +185,7 @@ export function listingPage(input: { | ||
| 164 | 185 | ? `<form class="row tone" method="post" action="/listings/${l.id}/tone"> |
| 165 | 186 | <label class="sr" for="tone">Tone</label> |
| 166 | 187 | <select class="input select" id="tone" name="tone">${TONES.map((t) => `<option value="${t}"${t === l.tone ? " selected" : ""}>${t[0]!.toUpperCase()}${t.slice(1)}</option>`).join("")}</select> |
| 167 | <button class="button button-quiet" title="Writes all five again in this tone; your edits are replaced." aria-label="Rewrite all five assets in this tone">Rewrite all</button> | |
| 188 | <button class="button button-quiet" title="Writes every asset again in this tone; your edits are replaced." aria-label="Rewrite all assets in this tone">Rewrite all</button> | |
| 168 | 189 | </form>` |
| 169 | 190 | : `<span class="pill">${escape(l.tone)} tone</span>`; |
| 170 | 191 | return layout( |
| @@ -180,8 +201,10 @@ export function listingPage(input: { | ||
| 180 | 201 | </div> |
| 181 | 202 | <div class="listing-tools"> |
| 182 | 203 | ${approvalPill(approvedCount(l), l.assets.length)} |
| 204 | ${statusPill(l)} | |
| 183 | 205 | ${n === 0 ? `<span class="fh-pill fh-ok">Fair Housing: clear</span>` : `<span class="fh-pill fh-warn">Fair Housing: ${n} to review</span>`} |
| 184 | 206 | ${tone} |
| 207 | ${editable ? statusForm(l) : ""} | |
| 185 | 208 | ${editable ? `<a class="button" href="/listings/${l.id}/facts">Edit facts</a>` : ""} |
| 186 | 209 | <a class="button" href="/listings/${l.id}/flyer">Print flyer</a> |
| 187 | 210 | <a class="button button-quiet" href="/?from=${l.id}">Start a new one from this</a> |
| @@ -224,9 +247,10 @@ export function flyer(l: Launched): string { | ||
| 224 | 247 | const [hero, ...more] = l.photos; |
| 225 | 248 | return `<article class="flyer palette-${l.palette}"> |
| 226 | 249 | ${draftBand(l, "flyer")} |
| 250 | ${l.status === "active" ? "" : `<p class="flyer-status status-${l.status}" role="note">${escape(statusLine(l))}</p>`} | |
| 227 | 251 | <div class="flyer-hero"> |
| 228 | 252 | ${hero === undefined ? `<div class="flyer-hero-empty brand-fill"></div>` : `<img src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="1200" height="800">`} |
| 229 | <p class="flyer-badge brand-fill">Just listed</p> | |
| 253 | <p class="flyer-badge brand-fill">${l.status === "sold" ? "Just sold" : l.status === "under_contract" ? "Under contract" : "Just listed"}</p> | |
| 230 | 254 | </div> |
| 231 | 255 | <header class="flyer-head"> |
| 232 | 256 | <div> |
src/routes.ts +20 −2
| @@ -31,6 +31,7 @@ import { | ||
| 31 | 31 | listListings, |
| 32 | 32 | listNotes, |
| 33 | 33 | retoneListing, |
| 34 | setStatus, | |
| 34 | 35 | updateFacts, |
| 35 | 36 | updatePhotos, |
| 36 | 37 | updateNote, |
| @@ -39,7 +40,7 @@ import { | ||
| 39 | 40 | type Result, |
| 40 | 41 | type Upload, |
| 41 | 42 | } from "./actions.ts"; |
| 42 | import { KINDS, TONES } from "./generate.ts"; | |
| 43 | import { KINDS, STATUSES, TONES } from "./generate.ts"; | |
| 43 | 44 | import { ASSET_MAX, FEATURES_MAX, FEATURES_MIN, PALETTES } from "./listing.ts"; |
| 44 | 45 | import { commit, env, forgeUrl, startedAt, version } from "./env.ts"; |
| 45 | 46 | import { |
| @@ -207,6 +208,8 @@ const LISTING_FIELDS = { | ||
| 207 | 208 | ...FACTS, |
| 208 | 209 | photos: { type: "array", items: { type: "string" }, maxItems: PHOTOS_MAX }, |
| 209 | 210 | headshot: { type: ["string", "null"] }, |
| 211 | status: { enum: [...STATUSES], description: "Where the listing stands; under_contract and sold add a status post and a status email." }, | |
| 212 | sold_price: { type: ["integer", "null"], description: "Whole dollars; only a sold listing has one, and only if the agent said." }, | |
| 210 | 213 | created_at: { type: "string", format: "date-time" }, |
| 211 | 214 | updated_at: { type: "string", format: "date-time" }, |
| 212 | 215 | }; |
| @@ -282,6 +285,8 @@ const LISTING_CHANGE: Schema = { | ||
| 282 | 285 | kind: { enum: [...KINDS] }, |
| 283 | 286 | text: { type: "string", minLength: 1, maxLength: ASSET_MAX }, |
| 284 | 287 | tone: { enum: [...TONES] }, |
| 288 | status: { enum: [...STATUSES], description: "Mark the listing active, under_contract or sold; the status post and email are written or removed, nothing else changes." }, | |
| 289 | sold_price: { type: ["integer", "null"], description: "With status sold: the price it sold at, in whole dollars; leave out to say nothing." }, | |
| 285 | 290 | facts: { |
| 286 | 291 | type: "object", |
| 287 | 292 | description: "Launch fields to change; the ones left out keep their value.", |
| @@ -627,6 +632,17 @@ export const routes: readonly Route[] = [ | ||
| 627 | 632 | return back(done.ok ? "Rewritten. Read it through again." : done.message, `/listings/${id}`); |
| 628 | 633 | }, |
| 629 | 634 | }, |
| 635 | { | |
| 636 | method: "POST", | |
| 637 | path: "/listings/:id/status", | |
| 638 | handle: async (c) => { | |
| 639 | const form = await readForm(c.request); | |
| 640 | if (form === undefined) return text("too large", 413); | |
| 641 | const id = Number(c.params.id); | |
| 642 | const done = setStatus(c.db, c.who, { id, status: form.get("status"), sold_price: form.get("sold_price") }); | |
| 643 | return back(done.ok ? "Status saved." : done.message, `/listings/${id}`); | |
| 644 | }, | |
| 645 | }, | |
| 630 | 646 | { |
| 631 | 647 | method: "POST", |
| 632 | 648 | path: "/listings/:id/delete", |
| @@ -807,6 +823,8 @@ export const routes: readonly Route[] = [ | ||
| 807 | 823 | ? updatePhotos(c.db, c.who, { id, ...photoChange(fields(body.photos)) }) |
| 808 | 824 | : typeof body.facts === "object" && body.facts !== null && !Array.isArray(body.facts) |
| 809 | 825 | ? updateFacts(c.db, c.who, { id, fields: fields(body.facts) }) |
| 826 | : body.status !== undefined && body.kind === undefined | |
| 827 | ? setStatus(c.db, c.who, { id, status: body.status, sold_price: body.sold_price }) | |
| 810 | 828 | : body.tone !== undefined && body.kind === undefined |
| 811 | 829 | ? retoneListing(c.db, c.who, { id, tone: body.tone }) |
| 812 | 830 | : body.approved !== undefined && body.text === undefined |
| @@ -814,7 +832,7 @@ export const routes: readonly Route[] = [ | ||
| 814 | 832 | : editAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), text: body.text }), |
| 815 | 833 | ); |
| 816 | 834 | }, |
| 817 | api: { summary: "Edit one asset's words, withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, or change the facts ({ facts }), which writes every asset again and clears every approval.", body: LISTING_CHANGE, answer: LISTING }, | |
| 835 | api: { summary: "Edit one asset's words, 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 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 }, | |
| 818 | 836 | }, |
| 819 | 837 | { |
| 820 | 838 | method: "DELETE", |
src/sample.ts +17 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import type { Database } from "bun:sqlite"; |
| 9 | import { insertListing } from "./actions.ts"; | |
| 9 | import { insertListing, markStatus } from "./actions.ts"; | |
| 10 | 10 | import type { ListingInput } from "./listing.ts"; |
| 11 | 11 | |
| 12 | 12 | export const SAMPLE: ListingInput = { |
| @@ -107,6 +107,8 @@ interface Sample { | ||
| 107 | 107 | readonly listing: ListingInput; |
| 108 | 108 | readonly photos: readonly string[]; |
| 109 | 109 | readonly headshot: string; |
| 110 | /** Where the sample stands; absent means active. */ | |
| 111 | readonly sold_at?: number; | |
| 110 | 112 | } |
| 111 | 113 | |
| 112 | 114 | const SAMPLES: readonly Sample[] = [ |
| @@ -116,6 +118,8 @@ const SAMPLES: readonly Sample[] = [ | ||
| 116 | 118 | listing: SAMPLE_CONDO, |
| 117 | 119 | photos: ["sample-condo-front.svg", "sample-condo-living.svg"], |
| 118 | 120 | headshot: "sample-agent-2.svg", |
| 121 | // Sold, so the meeting sees the band, the pill and the "Just sold" post and email. | |
| 122 | sold_at: 752000, | |
| 119 | 123 | }, |
| 120 | 124 | { |
| 121 | 125 | name: "sample-house", |
| @@ -133,5 +137,17 @@ export function seed(db: Database): void { | ||
| 133 | 137 | if (done.changes === 0) continue; |
| 134 | 138 | insertListing(db, { id: "sample", name: "Sample listing" }, s.listing, s.photos, s.headshot); |
| 135 | 139 | } |
| 140 | // A sample planted before statuses existed is marked sold once too; after that it is the owner's to change. | |
| 141 | for (const s of SAMPLES) { | |
| 142 | if (s.sold_at === undefined) continue; | |
| 143 | const first = db.query("INSERT OR IGNORE INTO seeded (name) VALUES (?1)").run(`${s.name}-sold`); | |
| 144 | if (first.changes === 0) continue; | |
| 145 | const row = db | |
| 146 | .query<{ id: number }, [string]>( | |
| 147 | "SELECT id FROM listing WHERE author_id = 'sample' AND address = ?1 AND status = 'active'", | |
| 148 | ) | |
| 149 | .get(s.listing.address); | |
| 150 | if (row !== null) markStatus(db, row.id, { status: "sold", sold_price: s.sold_at }); | |
| 151 | } | |
| 136 | 152 | })(); |
| 137 | 153 | } |
src/server.test.ts +1
| @@ -152,6 +152,7 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 152 | 152 | "use_suggestion", |
| 153 | 153 | "approve_asset", |
| 154 | 154 | "retone_listing", |
| 155 | "set_status", | |
| 155 | 156 | "update_facts", |
| 156 | 157 | "update_photos", |
| 157 | 158 | "delete_listing", |
src/status.test.ts added +256
| @@ -0,0 +1,256 @@ | ||
| 1 | /** | |
| 2 | * Status: a listing is active, under contract or sold. Validated at the | |
| 3 | * boundary, it adds a status post and a status email (checked, each needing | |
| 4 | * its own approval) without touching the other assets, and it is reachable | |
| 5 | * from the page, the API, the MCP and the terminal through one action. | |
| 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, listListings, retoneListing, setStatus, updateFacts } from "./actions.ts"; | |
| 12 | import { openDb } from "./db.ts"; | |
| 13 | import { check } from "./fairhousing.ts"; | |
| 14 | import { generate, STATUS_KINDS, TONES } from "./generate.ts"; | |
| 15 | import { agentOf, validateStatus } from "./listing.ts"; | |
| 16 | import { rpc } from "./mcp.ts"; | |
| 17 | import { flyer, listingPage, statusPill } from "./pages/listing.ts"; | |
| 18 | import { SAMPLE, SAMPLE_CONDO, SAMPLE_HOUSE } from "./sample.ts"; | |
| 19 | import { seed } from "./sample.ts"; | |
| 20 | import { session } from "./tty.ts"; | |
| 21 | import type { Who } from "./who.ts"; | |
| 22 | ||
| 23 | const me: Who = { role: "user", id: "u1", name: "me" }; | |
| 24 | const other: Who = { role: "user", id: "u2", name: "other" }; | |
| 25 | const admin: Who = { role: "admin", id: "root", name: "root" }; | |
| 26 | const plain = (s: string): string => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); | |
| 27 | ||
| 28 | const launch = () => { | |
| 29 | const db = openDb(mkdtempSync(join(tmpdir(), "app-status-"))); | |
| 30 | const made = launchListing(db, me, { fields: SAMPLE }); | |
| 31 | if (!made.ok) throw new Error(made.message); | |
| 32 | return { db, id: made.value.id }; | |
| 33 | }; | |
| 34 | const get = (db: ReturnType<typeof openDb>, id: number) => { | |
| 35 | const found = getListing(db, me, { id }); | |
| 36 | if (!found.ok) throw new Error(found.message); | |
| 37 | return found.value; | |
| 38 | }; | |
| 39 | ||
| 40 | test("validation: a status, and a sold price only with sold", () => { | |
| 41 | expect(validateStatus({ status: "active" })).toEqual({ ok: true, value: { status: "active", sold_price: null } }); | |
| 42 | expect(validateStatus({ status: "sold", sold_price: "$752,000" })).toEqual({ ok: true, value: { status: "sold", sold_price: 752000 } }); | |
| 43 | expect(validateStatus({ status: "sold", sold_price: "" })).toEqual({ ok: true, value: { status: "sold", sold_price: null } }); | |
| 44 | expect(validateStatus({ status: "under_contract", sold_price: null })).toMatchObject({ ok: true }); | |
| 45 | for (const bad of [ | |
| 46 | { status: "pending" }, | |
| 47 | { status: undefined }, | |
| 48 | { status: "Sold" }, | |
| 49 | { status: "under_contract", sold_price: 700000 }, | |
| 50 | { status: "sold", sold_price: "a lot" }, | |
| 51 | { status: "sold", sold_price: 12 }, | |
| 52 | { status: "sold", sold_price: 2_000_000_000 }, | |
| 53 | { status: "sold", sold_price: -5 }, | |
| 54 | ]) | |
| 55 | expect([bad, validateStatus(bad).ok]).toEqual([bad, false]); | |
| 56 | }); | |
| 57 | ||
| 58 | test("the status words: in every tone, clear of Fair Housing, naming the street and the price", () => { | |
| 59 | const agent = agentOf(SAMPLE); | |
| 60 | for (const tone of TONES) | |
| 61 | for (const base of [SAMPLE, SAMPLE_CONDO, { ...SAMPLE_HOUSE, features: SAMPLE.features }]) | |
| 62 | for (const [status, sold_price] of [["under_contract", null], ["sold", null], ["sold", 700000]] as const) { | |
| 63 | const words = generate({ facts: { ...base, tone, status, sold_price }, agent }); | |
| 64 | for (const kind of STATUS_KINDS) { | |
| 65 | const text = words[kind]!; | |
| 66 | expect([tone, status, kind, check(text).findings]).toEqual([tone, status, kind, []]); | |
| 67 | expect(text).toContain(base.address.split(",")[0]!); | |
| 68 | expect(text).not.toMatch(/undefined|NaN| {2}/); | |
| 69 | expect(text.includes("$700,000")).toBe(sold_price !== null); | |
| 70 | } | |
| 71 | expect(words.email_status).toStartWith(`Subject: ${status === "sold" ? "Just sold" : "Under contract"} — ${base.address.split(",")[0]}\n\n`); | |
| 72 | } | |
| 73 | expect(generate({ facts: SAMPLE, agent }).social_status).toBeUndefined(); | |
| 74 | expect(generate({ facts: { ...SAMPLE, status: "active" }, agent }).email_status).toBeUndefined(); | |
| 75 | }); | |
| 76 | ||
| 77 | test("setting the status adds the two assets, each needing its own approval, and leaves the rest alone", () => { | |
| 78 | const { db, id } = launch(); | |
| 79 | expect(get(db, id)).toMatchObject({ status: "active", sold_price: null }); | |
| 80 | expect(get(db, id).assets).toHaveLength(6); | |
| 81 | const mls = get(db, id).assets.find((a) => a.kind === "mls")!; | |
| 82 | expect(editAsset(db, me, { id, kind: "mls", text: "My own words about the house." }).ok).toBe(true); | |
| 83 | const fresh = get(db, id).assets.find((a) => a.kind === "mls")!; | |
| 84 | expect(approveAsset(db, me, { id, kind: "mls", door: "page", seen: fresh.updated_at }).ok).toBe(true); | |
| 85 | expect(mls.text).not.toBe("My own words about the house."); | |
| 86 | ||
| 87 | const sold = setStatus(db, me, { id, status: "sold", sold_price: "752000" }); | |
| 88 | if (!sold.ok) throw new Error(sold.message); | |
| 89 | expect(sold.value).toMatchObject({ status: "sold", sold_price: 752000 }); | |
| 90 | expect(sold.value.assets.map((a) => a.kind)).toEqual(["mls", "social_short", "social_medium", "social_carousel", "social_status", "flyer", "email", "email_status"]); | |
| 91 | const mine = sold.value.assets.find((a) => a.kind === "mls")!; | |
| 92 | expect(mine.text).toBe("My own words about the house."); | |
| 93 | expect(mine.approval).not.toBeNull(); | |
| 94 | for (const kind of STATUS_KINDS) { | |
| 95 | const a = sold.value.assets.find((x) => x.kind === kind)!; | |
| 96 | expect([kind, a.approval, a.check.clear]).toEqual([kind, null, true]); | |
| 97 | expect(a.text).toContain("$752,000"); | |
| 98 | } | |
| 99 | expect(sold.value.assets.find((a) => a.kind === "email_status")!.text).toStartWith("Subject: Just sold — 412 Alder Lane"); | |
| 100 | ||
| 101 | // The count grows to include them. | |
| 102 | const summary = listListings(db, me); | |
| 103 | expect(summary.ok && summary.value[0]!.approvals).toHaveLength(8); | |
| 104 | expect(summary.ok && summary.value[0]!.approvals.filter((a) => a.approved)).toHaveLength(1); | |
| 105 | ||
| 106 | // Each is approved on its own. | |
| 107 | const post = sold.value.assets.find((a) => a.kind === "social_status")!; | |
| 108 | const approved = approveAsset(db, me, { id, kind: "social_status", door: "page", seen: post.updated_at }); | |
| 109 | expect(approved.ok && approved.value.assets.filter((a) => a.approval !== null).map((a) => a.kind)).toEqual(["mls", "social_status"]); | |
| 110 | ||
| 111 | // Saying what is already so changes nothing, approvals included. | |
| 112 | expect(setStatus(db, me, { id, status: "sold", sold_price: 752000 }).ok).toBe(true); | |
| 113 | expect(get(db, id).assets.filter((a) => a.approval !== null)).toHaveLength(2); | |
| 114 | ||
| 115 | // A price changed rewrites the announcement (its approval goes); the rest stay. | |
| 116 | const repriced = setStatus(db, me, { id, status: "sold", sold_price: 760000 }); | |
| 117 | expect(repriced.ok && repriced.value.assets.filter((a) => a.approval !== null).map((a) => a.kind)).toEqual(["mls"]); | |
| 118 | ||
| 119 | // Facts and tone rewrite the status words with the rest. | |
| 120 | const toned = retoneListing(db, me, { id, tone: "bold" }); | |
| 121 | expect(toned.ok && toned.value.assets.find((a) => a.kind === "social_status")!.text).toStartWith("JUST SOLD AT $760,000"); | |
| 122 | const facts = updateFacts(db, me, { id, fields: { address: "9 Elm Street, Portland, OR 97214" } }); | |
| 123 | expect(facts.ok && facts.value.assets.find((a) => a.kind === "email_status")!.text).toContain("9 Elm Street"); | |
| 124 | expect(facts.ok && facts.value.status).toBe("sold"); | |
| 125 | ||
| 126 | // Back to active takes them away; to under contract, they say so. | |
| 127 | const under = setStatus(db, me, { id, status: "under_contract" }); | |
| 128 | expect(under.ok && under.value).toMatchObject({ status: "under_contract", sold_price: null }); | |
| 129 | expect(under.ok && under.value.assets.find((a) => a.kind === "email_status")!.text).toStartWith("Subject: Under contract — 9 Elm Street"); | |
| 130 | const back = setStatus(db, me, { id, status: "active" }); | |
| 131 | expect(back.ok && back.value.assets).toHaveLength(6); | |
| 132 | }); | |
| 133 | ||
| 134 | test("who may: the listing's agent or an admin, nobody else, and a bad status changes nothing", () => { | |
| 135 | const { db, id } = launch(); | |
| 136 | expect(setStatus(db, other, { id, status: "sold" })).toMatchObject({ ok: false, error: "forbidden" }); | |
| 137 | expect(setStatus(db, { role: "anon" } as Who, { id, status: "sold" })).toMatchObject({ ok: false, error: "signin_required" }); | |
| 138 | expect(setStatus(db, me, { id, status: "pending" })).toMatchObject({ ok: false, error: "invalid" }); | |
| 139 | expect(setStatus(db, me, { id, status: "under_contract", sold_price: 5000 })).toMatchObject({ ok: false, error: "invalid" }); | |
| 140 | expect(setStatus(db, me, { id: 999, status: "sold" })).toMatchObject({ ok: false, error: "not_found" }); | |
| 141 | expect(get(db, id).status).toBe("active"); | |
| 142 | expect(setStatus(db, admin, { id, status: "sold" })).toMatchObject({ ok: true, value: { status: "sold" } }); | |
| 143 | // Refused attempts are in the trail. | |
| 144 | const refused = db.query("SELECT count(*) AS n FROM audit WHERE resource = 'listing' AND allowed = 0").get() as { n: number }; | |
| 145 | expect(refused.n).toBeGreaterThanOrEqual(2); | |
| 146 | }); | |
| 147 | ||
| 148 | test("the pill, the flyer band and the sample", () => { | |
| 149 | const { db, id } = launch(); | |
| 150 | const active = get(db, id); | |
| 151 | expect(statusPill(active)).toBe(""); | |
| 152 | expect(flyer(active)).not.toContain("flyer-status"); | |
| 153 | expect(flyer(active)).toContain("Just listed"); | |
| 154 | const sold = setStatus(db, me, { id, status: "sold", sold_price: 752000 }); | |
| 155 | if (!sold.ok) throw new Error(sold.message); | |
| 156 | expect(statusPill(sold.value)).toContain("Sold at $752,000"); | |
| 157 | expect(flyer(sold.value)).toContain('<p class="flyer-status status-sold" role="note">Sold at $752,000</p>'); | |
| 158 | expect(flyer(sold.value)).toContain("Just sold"); | |
| 159 | const under = setStatus(db, me, { id, status: "under_contract" }); | |
| 160 | if (!under.ok) throw new Error(under.message); | |
| 161 | expect(flyer(under.value)).toContain('<p class="flyer-status status-under_contract" role="note">Under contract</p>'); | |
| 162 | // The listing page: status form for the agent only, the new assets in their tabs. | |
| 163 | const page = listingPage({ who: me, listing: sold.value, editable: true }); | |
| 164 | expect(page).toContain(`action="/listings/${id}/status"`); | |
| 165 | expect(page).toContain('<option value="sold" selected>'); | |
| 166 | expect(page).toContain("0 of 8 approved"); | |
| 167 | expect(page).toContain('id="social_status"'); | |
| 168 | expect(page).toContain('id="email_status"'); | |
| 169 | expect(listingPage({ who: other, listing: sold.value, editable: false })).not.toContain("/status"); | |
| 170 | ||
| 171 | // One sample is sold, with its words clear. | |
| 172 | const seeded = openDb(mkdtempSync(join(tmpdir(), "app-status-seed-"))); | |
| 173 | seed(seeded); | |
| 174 | const listed = listListings(seeded, me); | |
| 175 | const sample = listed.ok ? listed.value.filter((l) => l.status === "sold") : []; | |
| 176 | expect(sample.map((l) => l.address)).toEqual([SAMPLE_CONDO.address]); | |
| 177 | expect(sample[0]!.sold_price).toBe(752000); | |
| 178 | expect(sample[0]!.approvals).toHaveLength(8); | |
| 179 | expect(sample[0]!.flags).toBe(0); | |
| 180 | ||
| 181 | // A database seeded before statuses existed gets the sold sample once, and keeps a later change. | |
| 182 | const meridian = sample[0]!.id; | |
| 183 | seeded.run("DELETE FROM asset WHERE listing_id = ?1 AND kind IN ('social_status', 'email_status')", [meridian]); | |
| 184 | seeded.run("UPDATE listing SET status = 'active', sold_price = NULL WHERE id = ?1", [meridian]); | |
| 185 | seeded.run("DELETE FROM seeded WHERE name = 'sample-condo-sold'"); | |
| 186 | seed(seeded); | |
| 187 | expect(get(seeded, meridian)).toMatchObject({ status: "sold", sold_price: 752000 }); | |
| 188 | expect(get(seeded, meridian).assets).toHaveLength(8); | |
| 189 | expect(setStatus(seeded, admin, { id: meridian, status: "active" }).ok).toBe(true); | |
| 190 | seed(seeded); | |
| 191 | expect(get(seeded, meridian).status).toBe("active"); | |
| 192 | }); | |
| 193 | ||
| 194 | test("MCP and the terminal set it through the same action", () => { | |
| 195 | const { db, id } = launch(); | |
| 196 | const call = rpc(db, me, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "set_status", arguments: { id, status: "under_contract" } } }).body as { result: { content: { text: string }[] } }; | |
| 197 | expect(call.result.content[0]!.text).toContain("Under contract — 412 Alder Lane"); | |
| 198 | const bad = rpc(db, other, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "set_status", arguments: { id, status: "sold" } } }).body as { result: { isError?: boolean } }; | |
| 199 | expect(bad.result.isError).toBe(true); | |
| 200 | expect(get(db, id).status).toBe("under_contract"); | |
| 201 | ||
| 202 | const tty = session(db, me); | |
| 203 | tty.press("\r"); | |
| 204 | expect(plain(tty.screen())).toContain("under contract"); | |
| 205 | tty.press("s"); | |
| 206 | expect(plain(tty.screen())).toContain("Status:"); | |
| 207 | for (let i = 0; i < 30; i++) tty.press("\x7f"); | |
| 208 | for (const key of "sold 752000") tty.press(key); | |
| 209 | tty.press("\r"); | |
| 210 | expect(plain(tty.screen())).toContain("Status saved: sold 752000"); | |
| 211 | expect(get(db, id)).toMatchObject({ status: "sold", sold_price: 752000 }); | |
| 212 | tty.press("s"); | |
| 213 | for (let i = 0; i < 30; i++) tty.press("\x7f"); | |
| 214 | for (const key of "maybe") tty.press(key); | |
| 215 | tty.press("\r"); | |
| 216 | expect(plain(tty.screen())).toContain("status is one of"); | |
| 217 | expect(get(db, id).status).toBe("sold"); | |
| 218 | }); | |
| 219 | ||
| 220 | test("the page and the API", async () => { | |
| 221 | const { serve } = await import("./server.ts"); | |
| 222 | const { sign } = await import("./who.ts"); | |
| 223 | const KEY = "a-key-only-the-forge-holds"; | |
| 224 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-status-api-")), identityKey: KEY }); | |
| 225 | try { | |
| 226 | const base = `http://${server.hostname}:${server.port}`; | |
| 227 | const mint = (name: string) => { | |
| 228 | const identity = `${name}|${name}|user|${Date.now() + 600_000}`; | |
| 229 | return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; | |
| 230 | }; | |
| 231 | const headers = { ...mint("status-agent"), "content-type": "application/json" }; | |
| 232 | const made = (await (await fetch(`${base}/api/listings`, { method: "POST", headers, body: JSON.stringify(SAMPLE) })).json()) as { id: number }; | |
| 233 | const patch = (body: unknown, h: Record<string, string> = headers) => | |
| 234 | fetch(`${base}/api/listings/${made.id}`, { method: "PATCH", headers: h, body: JSON.stringify(body) }); | |
| 235 | const sold = await patch({ status: "sold", sold_price: 752000 }); | |
| 236 | expect(sold.status).toBe(200); | |
| 237 | const body = (await sold.json()) as { status: string; sold_price: number; assets: { kind: string }[] }; | |
| 238 | expect(body).toMatchObject({ status: "sold", sold_price: 752000 }); | |
| 239 | expect(body.assets).toHaveLength(8); | |
| 240 | expect((await patch({ status: "pending" })).status).toBe(422); | |
| 241 | expect((await patch({ status: "active" }, { ...mint("someone-else"), "content-type": "application/json" })).status).toBe(403); | |
| 242 | ||
| 243 | const form = new FormData(); | |
| 244 | form.set("status", "under_contract"); | |
| 245 | form.set("sold_price", ""); | |
| 246 | const posted = await fetch(`${base}/listings/${made.id}/status`, { method: "POST", headers: mint("status-agent"), body: form, redirect: "manual" }); | |
| 247 | expect(posted.status).toBe(303); | |
| 248 | const page = await (await fetch(`${base}/listings/${made.id}`, { headers: mint("status-agent") })).text(); | |
| 249 | expect(page).toContain('<option value="under_contract" selected>'); | |
| 250 | const home = await (await fetch(`${base}/`, { headers: mint("status-agent") })).text(); | |
| 251 | expect(home).toContain("status-pill status-under_contract"); | |
| 252 | expect(await (await fetch(`${base}/openapi.json`)).text()).toContain("sold_price"); | |
| 253 | } finally { | |
| 254 | server.stop(true); | |
| 255 | } | |
| 256 | }); |
src/tty.ts +22 −6
| @@ -29,6 +29,7 @@ import { | ||
| 29 | 29 | listListings, |
| 30 | 30 | listNotes, |
| 31 | 31 | retoneListing, |
| 32 | setStatus, | |
| 32 | 33 | updateFacts, |
| 33 | 34 | useSuggestion, |
| 34 | 35 | } from "./actions.ts"; |
| @@ -69,6 +70,14 @@ export const openFromLine = (line: string): { date: string; start: string; end: | ||
| 69 | 70 | return { date: m?.[1] ?? r, start: m?.[2] ?? "", end: m?.[3] ?? "" }; |
| 70 | 71 | }); |
| 71 | 72 | |
| 73 | /** The status in the one-line box: "sold 752000", "sold", "under contract" or "active". */ | |
| 74 | export const statusToLine = (l: { status: string; sold_price: number | null }): string => | |
| 75 | `${l.status.replace("_", " ")}${l.sold_price === null ? "" : ` ${l.sold_price}`}`; | |
| 76 | export const statusFromLine = (line: string): { status: string; sold_price: string } => { | |
| 77 | const m = /^\s*([a-z_ ]*?)\s*([\d$,.]*)\s*$/i.exec(line); | |
| 78 | return { status: (m?.[1] ?? line).toLowerCase().replace(/\s+/g, "_"), sold_price: m?.[2] ?? "" }; | |
| 79 | }; | |
| 80 | ||
| 72 | 81 | /** In the one-line edit box, `\n` typed as two characters is a line break. */ |
| 73 | 82 | export const toLine = (text: string): string => text.replace(/\n/g, "\\n"); |
| 74 | 83 | export const fromLine = (line: string): string => line.replace(/\\n/g, "\n"); |
| @@ -80,7 +89,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 80 | 89 | let previewing = false; |
| 81 | 90 | let cursor = 0; |
| 82 | 91 | /** What the one-line input is for: a new note, or an asset's new words. */ |
| 83 | let typing: { for: "note" } | { for: "asset"; kind: Kind } | { for: "open" } | undefined; | |
| 92 | let typing: { for: "note" } | { for: "asset"; kind: Kind } | { for: "open" } | { for: "status" } | undefined; | |
| 84 | 93 | let typed = ""; |
| 85 | 94 | let said = ""; |
| 86 | 95 | /** The short post over X that `a` was pressed on once: a second `a` approves it knowing so. */ |
| @@ -119,7 +128,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 119 | 128 | } |
| 120 | 129 | const signed = l.assets.filter((a) => a.approval !== null).length; |
| 121 | 130 | const whenLine = openHouseLine(l); |
| 122 | const head = [bold(`${l.address} · ${money(l.price)}`), dim(`${l.tone} tone · ${approvalLabel(signed, l.assets.length)}`), ...(whenLine === null ? [] : [whenLine]), ""]; | |
| 131 | const head = [bold(`${l.address} · ${money(l.price)}`), dim(`${l.tone} tone · ${approvalLabel(signed, l.assets.length)}${l.status === "active" ? "" : ` · ${statusToLine(l)}`}`), ...(whenLine === null ? [] : [whenLine]), ""]; | |
| 123 | 132 | // Scroll so the asset under the cursor starts the page. |
| 124 | 133 | const body = l.assets.slice(cursor).flatMap((a, i) => [ |
| 125 | 134 | `${i === 0 ? "▸" : " "} ${i === 0 ? bold(a.label) : a.label} ${a.check.clear ? green("Fair Housing: clear") : amber(`Fair Housing: ${a.check.findings.length} to review`)}${a.edited ? dim(" edited") : ""}${a.approval === null ? "" : green(` approved by ${a.approval.by_name}`)}`, |
| @@ -145,7 +154,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 145 | 154 | if (previewing) help = "esc back to the assets · q quit"; |
| 146 | 155 | else help = |
| 147 | 156 | found?.ok === true && canEditListing(me, found.value) |
| 148 | ? "j/k asset · e edit · o open house · a approve/withdraw · u use suggested wording · t next tone · p preview email · esc back · q quit" | |
| 157 | ? "j/k asset · e edit · o open house · s status · a approve/withdraw · u use suggested wording · t next tone · p preview email · esc back · q quit" | |
| 149 | 158 | : "j/k asset · p preview email · esc back · q quit"; |
| 150 | 159 | } else if (view === "listings") { |
| 151 | 160 | const rows = listings(); |
| @@ -154,7 +163,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 154 | 163 | const done = l.approvals.filter((a) => a.approved).length; |
| 155 | 164 | const ready = approvalLabel(done, l.approvals.length); |
| 156 | 165 | const flag = l.flags === 0 ? green("clear") : amber(`${l.flags} to review`); |
| 157 | const text = `${l.address} · ${money(l.price)}`; | |
| 166 | const text = `${l.address} · ${money(l.price)}${l.status === "active" ? "" : ` · ${statusToLine(l)}`}`; | |
| 158 | 167 | lines.push( |
| 159 | 168 | `${i === cursor ? "▸" : " "} ${i === cursor ? bold(text) : text} ${done === l.approvals.length ? green(ready) : dim(ready)} ${flag} ${dim(l.author_name)}`, |
| 160 | 169 | ); |
| @@ -177,7 +186,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 177 | 186 | } |
| 178 | 187 | lines.push(""); |
| 179 | 188 | if (typing !== undefined) { |
| 180 | const label = typing.for === "note" ? "New note" : 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)`; | |
| 189 | const label = typing.for === "note" ? "New note" : typing.for === "status" ? "Status: 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)`; | |
| 181 | 190 | // The tail of what is typed, so the cursor stays on the screen. |
| 182 | 191 | const room = Math.max(20, size.columns - 4); |
| 183 | 192 | lines.push(dim(label), `${typed.length > room ? `…${typed.slice(-room + 1)}` : typed}${esc}7m ${esc}0m`); |
| @@ -196,6 +205,10 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 196 | 205 | } else if (typing.for === "open") { |
| 197 | 206 | const done = updateFacts(db, me, { id: reading!, fields: { open_houses: openFromLine(typed) } }); |
| 198 | 207 | said = done.ok ? "Open house saved. Every asset is written again: read it through." : done.message; |
| 208 | } else if (typing.for === "status") { | |
| 209 | const { status, sold_price } = statusFromLine(typed); | |
| 210 | const done = setStatus(db, me, { id: reading!, status, sold_price }); | |
| 211 | said = done.ok ? `Status saved: ${statusToLine(done.value)}. Approve any new status words.` : done.message; | |
| 199 | 212 | } else { |
| 200 | 213 | const kind = typing.kind; |
| 201 | 214 | const edited = editAsset(db, me, { id: reading!, kind, text: fromLine(typed) }); |
| @@ -243,7 +256,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 243 | 256 | if (previewing) return undefined; |
| 244 | 257 | if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1)); |
| 245 | 258 | if (up) cursor = Math.max(cursor - 1, 0); |
| 246 | if (found?.ok === true && (key === "e" || key === "t" || key === "a" || key === "u" || key === "o")) { | |
| 259 | if (found?.ok === true && (key === "e" || key === "t" || key === "a" || key === "u" || key === "o" || key === "s")) { | |
| 247 | 260 | if (!canEditListing(me, found.value)) |
| 248 | 261 | said = me.role === "anon" ? "Sign in to edit this listing." : "Only the listing's agent or an admin can edit it."; |
| 249 | 262 | else if (key === "u") { |
| @@ -271,6 +284,9 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 271 | 284 | : done.message; |
| 272 | 285 | } |
| 273 | 286 | } |
| 287 | } else if (key === "s") { | |
| 288 | typing = { for: "status" }; | |
| 289 | typed = statusToLine(found.value); | |
| 274 | 290 | } else if (key === "o") { |
| 275 | 291 | typing = { for: "open" }; |
| 276 | 292 | typed = openToLine(found.value.open_houses); |
src/walkthrough.test.ts +28 −1
| @@ -209,7 +209,34 @@ test("every step of the section works, with the labels it names", async () => { | ||
| 209 | 209 | expect((await post(`${mine}/facts`, clear)).status).toBe(303); |
| 210 | 210 | expect(await (await get(`${mine}/flyer`)).text()).not.toContain("flyer-open"); |
| 211 | 211 | |
| 212 | // 8. Print the flyer. | |
| 212 | // 8. Mark it sold: the status post and email, eight to approve, the band, the list. | |
| 213 | expect(section).toContain("**Mark it sold.**"); | |
| 214 | expect(section).toContain("0 of 8 approved"); | |
| 215 | expect(section).toContain("88 Meridian Street"); | |
| 216 | expect(afterTone).toContain('name="status"'); | |
| 217 | expect(afterTone).toContain('<option value="sold">'); | |
| 218 | expect(sampleHtml).not.toContain('name="status"'); | |
| 219 | const sell = new FormData(); | |
| 220 | sell.set("status", "sold"); | |
| 221 | sell.set("sold_price", "640000"); | |
| 222 | expect((await post(`${mine}/status`, sell)).status).toBe(303); | |
| 223 | const afterSold = await (await get(mine)).text(); | |
| 224 | expect(afterSold).toContain("0 of 8 approved"); | |
| 225 | expect(afterSold).toContain("Sold at $640,000"); | |
| 226 | expect(afterSold).toContain("Just sold — 9 Walkthrough Way"); | |
| 227 | expect(await (await get(`${mine}/flyer`)).text()).toContain('<p class="flyer-status status-sold" role="note">Sold at $640,000</p>'); | |
| 228 | const homeSold = await (await get("/")).text(); | |
| 229 | expect(homeSold).toContain("Sold at $640,000"); | |
| 230 | expect(homeSold).toContain("0 of 8 approved"); | |
| 231 | expect(homeSold).toContain("Sold at $752,000"); | |
| 232 | const meridian = await (await get(`/listings/${idOf(homeSold, "88 Meridian Street")}`)).text(); | |
| 233 | expect(meridian).toContain("Just sold — 88 Meridian Street"); | |
| 234 | const active = new FormData(); | |
| 235 | active.set("status", "active"); | |
| 236 | expect((await post(`${mine}/status`, active)).status).toBe(303); | |
| 237 | expect(await (await get(mine)).text()).toContain("0 of 6 approved"); | |
| 238 | ||
| 239 | // 9. Print the flyer. | |
| 213 | 240 | expect(afterTone).toMatch(control("Print flyer")); |
| 214 | 241 | const flyer = await get(`${mine}/flyer`); |
| 215 | 242 | expect(flyer.status).toBe(200); |
static/app.css +29
| @@ -943,3 +943,32 @@ main:focus { | ||
| 943 | 943 | break-inside: avoid; |
| 944 | 944 | } |
| 945 | 945 | } |
| 946 | ||
| 947 | /* Where a listing stands: a pill beside the approval pill, and a band across the top of the flyer. */ | |
| 948 | .status-pill { | |
| 949 | font-weight: 600; | |
| 950 | white-space: nowrap; | |
| 951 | } | |
| 952 | .status-pill.status-sold, | |
| 953 | .flyer-status.status-sold { | |
| 954 | color: var(); | |
| 955 | background: var(); | |
| 956 | } | |
| 957 | .status-pill.status-under_contract, | |
| 958 | .flyer-status.status-under_contract { | |
| 959 | color: var(); | |
| 960 | background: var(); | |
| 961 | } | |
| 962 | .flyer-status { | |
| 963 | margin: 0; | |
| 964 | padding: var() var(); | |
| 965 | text-align: center; | |
| 966 | font-weight: 700; | |
| 967 | letter-spacing: 0.12em; | |
| 968 | text-transform: uppercase; | |
| 969 | -webkit-print-color-adjust: exact; | |
| 970 | print-color-adjust: exact; | |
| 971 | } | |
| 972 | .status-form { | |
| 973 | flex-wrap: wrap; | |
| 974 | } |
This doc comment now sits above the status helpers instead of
toLine/fromLine. Move it back next to them.