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

Merged opened this

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

Merged

mason/listing-launch:mason/36-mark-a-listing-under-contract-or-sold-an → main 2 commits fork 2 threads · 2 unresolved

  • Merged. mason/36-mark-a-listing-under-contract-or-sold-an was merged into main as ef5f1e0.

Review

Sign in to review this change.
19 files changed +693 −41
README.md +16 −4
@@ -38,7 +38,17 @@ to make a listing of your own.
3838 post a closing line, such as "Open Sunday, March 7, 1–3 pm", all in the
3939 listing's tone. Clearing the three boxes takes it off again. The times are
4040 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
4252 the photo, price, facts, description and your name. Print it or save it
4353 as a PDF.
4454
@@ -176,7 +186,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
176186 | ---------------------------------- | -------------------------------------------------------------- |
177187 | `src/actions.ts` | listings (launch, edit an asset, retone, change facts, remove), notes, the audit trail |
178188 | `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 |
180190 | `src/postlength.ts` | a social post's length by what X, Instagram and Facebook count |
181191 | `src/fairhousing.ts` | the Fair Housing rules: phrase, context, class, reason, rewrite |
182192 | `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' -
259269 curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"facts":{"price":649000}}'
260270 # an open house (up to two; local date and 24-hour times as typed; [] clears)
261271 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}'
262274 curl -X DELETE https://<app>/api/listings/1
263275 ```
264276
265277 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`,
267279 `update_photos` (order and removals; uploads are on the page only), `delete_listing`,
268280 `check_fair_housing`, and the notes' `list_notes`, `add_note`,
269281 `delete_note`:
@@ -290,7 +302,7 @@ a real pty over the same data, as the account at the keyboard. Open a
290302 listing with `enter` to read every asset with its Fair Housing findings;
291303 `j`/`k` move between assets, `e` edits the one under the cursor (type `\n`
292304 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
294306 notes. Launching a listing, with its photos, is the page's alone.
295307
296308 No runtime dependencies: Bun's own `bun:sqlite`, `Bun.serve` and `node:`
contract/openapi.json +60 −5
@@ -517,7 +517,7 @@
517517 }
518518 },
519519 "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.",
521521 "operationId": "patchApiListingsId",
522522 "parameters": [
523523 {
@@ -543,8 +543,10 @@
543543 "social_short",
544544 "social_medium",
545545 "social_carousel",
546 "social_status",
546547 "flyer",
547 "email"
548 "email",
549 "email_status"
548550 ]
549551 },
550552 "text": {
@@ -559,6 +561,21 @@
559561 "bold"
560562 ]
561563 },
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 },
562579 "facts": {
563580 "type": "object",
564581 "description": "Launch fields to change; the ones left out keep their value.",
@@ -707,7 +724,7 @@
707724 },
708725 "responses": {
709726 "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.",
711728 "content": {
712729 "application/json": {
713730 "schema": {
@@ -1158,6 +1175,21 @@
11581175 "null"
11591176 ]
11601177 },
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 },
11611193 "created_at": {
11621194 "type": "string",
11631195 "format": "date-time"
@@ -1177,8 +1209,10 @@
11771209 "social_short",
11781210 "social_medium",
11791211 "social_carousel",
1212 "social_status",
11801213 "flyer",
1181 "email"
1214 "email",
1215 "email_status"
11821216 ]
11831217 },
11841218 "label": {
@@ -1326,6 +1360,8 @@
13261360 "open_houses",
13271361 "photos",
13281362 "headshot",
1363 "status",
1364 "sold_price",
13291365 "created_at",
13301366 "updated_at",
13311367 "assets"
@@ -1466,6 +1502,21 @@
14661502 "null"
14671503 ]
14681504 },
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 },
14691520 "created_at": {
14701521 "type": "string",
14711522 "format": "date-time"
@@ -1490,8 +1541,10 @@
14901541 "social_short",
14911542 "social_medium",
14921543 "social_carousel",
1544 "social_status",
14931545 "flyer",
1494 "email"
1546 "email",
1547 "email_status"
14951548 ]
14961549 },
14971550 "approved": {
@@ -1527,6 +1580,8 @@
15271580 "open_houses",
15281581 "photos",
15291582 "headshot",
1583 "status",
1584 "sold_price",
15301585 "created_at",
15311586 "updated_at",
15321587 "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";
1010 import { record, type AuditRow } from "./audit.ts";
1111 import { permit } from "./contract.ts";
1212 import { check, type Check } from "./fairhousing.ts";
13 import { generate, KINDS, TONES } from "./generate.ts";
13 import { generate, KINDS, STATUS_KINDS, TONES, type Kind, type Status } from "./generate.ts";
1414 import {
1515 agentOf,
1616 ASSET_MAX,
@@ -21,6 +21,7 @@ import {
2121 isTone,
2222 touchesOpenHouses,
2323 validate,
24 validateStatus,
2425 type AssetRow,
2526 type Caller,
2627 type Launched,
@@ -343,8 +344,12 @@ function launched(db: Database, id: number): Launched | undefined {
343344 return { ...fromRow(row), assets: assetsOf(assets) };
344345 }
345346
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 {
348353 const words = generate({ facts: listing, agent: agentOf(listing) });
349354 const put = db.query(
350355 `INSERT INTO asset (listing_id, kind, body, edited) VALUES (?1, ?2, ?3, 0)
@@ -352,7 +357,12 @@ function writeAssets(db: Database, listing: Listing): void {
352357 approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL,
353358 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`,
354359 );
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 }
356366 }
357367
358368 /** Insert a listing and its assets as one, for whoever is named. */
@@ -396,6 +406,23 @@ export function insertListing(
396406 })();
397407 }
398408
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
399426 /** A listing in the list: the Fair Housing flags and which assets are signed off. */
400427 export type ListingSummary = Listing & {
401428 flags: number;
@@ -837,6 +864,40 @@ function changeFacts(
837864 return ok(launched(db, now.id)!);
838865 }
839866
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
840901 /** The assets that show the photos: what they show changed, so their approval goes. */
841902 const PHOTO_ASSETS = ["flyer", "email"] as const;
842903
src/db.ts +3
@@ -92,6 +92,9 @@ export const migrations: string[] = [
9292 `ALTER TABLE asset ADD COLUMN approved_via TEXT`,
9393 // Up to two open houses, as typed: a JSON list of { date, start, end }, local time, never converted.
9494 `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`,
9598 ];
9699
97100 export function openDb(dir = dataDir): Database {
src/generate.test.ts +5 −5
@@ -10,7 +10,7 @@ import {
1010 factsLine,
1111 flyerParts,
1212 generate,
13 KINDS,
13 CORE_KINDS,
1414 lotOf,
1515 street,
1616 TONES,
@@ -25,8 +25,8 @@ const facts = (tone: Facts["tone"]): Facts => ({ ...SAMPLE, tone });
2525 test("all five assets, every text filled, in every tone", () => {
2626 for (const tone of TONES) {
2727 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)
3030 expect([tone, kind, words[kind].length > 40]).toEqual([tone, kind, true]);
3131 }
3232 });
@@ -72,7 +72,7 @@ test("each asset says what the agent typed, and only that", () => {
7272 test("the templates never say what the Fair Housing check flags", () => {
7373 for (const tone of TONES) {
7474 const words = generate({ facts: facts(tone), agent });
75 for (const kind of KINDS)
75 for (const kind of CORE_KINDS)
7676 expect([tone, kind, check(words[kind]).findings]).toEqual([tone, kind, []]);
7777 }
7878 });
@@ -99,7 +99,7 @@ test("every asset reads like a person wrote it, in every tone", () => {
9999 for (const [name, c] of cases)
100100 for (const tone of TONES) {
101101 const words = generate({ facts: { ...c, tone }, agent });
102 for (const kind of KINDS)
102 for (const kind of CORE_KINDS)
103103 for (const re of bad) expect([name, tone, kind, re.test(words[kind])]).toEqual([name, tone, kind, false]);
104104 }
105105 });
src/generate.ts +87 −2
@@ -13,13 +13,18 @@
1313 export const TONES = ["classic", "warm", "bold"] as const;
1414 export type Tone = (typeof TONES)[number];
1515
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
1619 export const KINDS = [
1720 "mls",
1821 "social_short",
1922 "social_medium",
2023 "social_carousel",
24 "social_status",
2125 "flyer",
2226 "email",
27 "email_status",
2328 ] as const;
2429 export type Kind = (typeof KINDS)[number];
2530
@@ -29,8 +34,23 @@ export const LABELS: Readonly<Record<Kind, string>> = {
2934 social_short: "Short post",
3035 social_medium: "Medium post",
3136 social_carousel: "Carousel captions",
37 social_status: "Status post",
3238 flyer: "Flyer copy",
3339 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",
3454 };
3555
3656 /** 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 {
5171 readonly features: readonly string[];
5272 readonly tone: Tone;
5373 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;
5478 }
5579
5680 export interface Agent {
@@ -282,12 +306,72 @@ function email(f: Facts, a: Agent): string {
282306 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}`;
283307 }
284308
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. */
286369 export function generate(listing: {
287370 readonly facts: Facts;
288371 readonly agent: Agent;
289 }): Record<Kind, string> {
372 }): Partial<Record<Kind, string>> & Record<(typeof CORE_KINDS)[number], string> {
290373 const { facts: f, agent: a } = listing;
374 const announced = f.status === "under_contract" || f.status === "sold";
291375 return {
292376 mls: mls(f),
293377 social_short: socialShort(f, a),
@@ -295,6 +379,7 @@ export function generate(listing: {
295379 social_carousel: socialCarousel(f, a),
296380 flyer: flyer(f),
297381 email: email(f, a),
382 ...(announced ? { social_status: statusPost(f, a), email_status: statusEmail(f, a) } : {}),
298383 };
299384 }
300385
src/listing.ts +25
@@ -9,11 +9,13 @@ import { check, type Check } from "./fairhousing.ts";
99 import {
1010 KINDS,
1111 LABELS,
12 STATUSES,
1213 TONES,
1314 type Agent,
1415 type Facts,
1516 type Kind,
1617 type OpenHouse,
18 type Status,
1719 type Tone,
1820 } from "./generate.ts";
1921
@@ -38,6 +40,9 @@ export interface ListingInput extends Facts {
3840 }
3941
4042 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;
4146 readonly id: number;
4247 readonly author_id: string;
4348 readonly author_name: string;
@@ -102,6 +107,8 @@ export interface ListingRow {
102107 readonly tone: Tone;
103108 readonly palette: Palette;
104109 readonly open_houses: string;
110 readonly status: Status;
111 readonly sold_price: number | null;
105112 readonly agent_name: string;
106113 readonly brokerage: string;
107114 readonly license: string;
@@ -162,6 +169,8 @@ export const isKind = (v: unknown): v is Kind =>
162169 typeof v === "string" && (KINDS as readonly string[]).includes(v);
163170 export const isTone = (v: unknown): v is Tone =>
164171 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);
165174 export const isPalette = (v: unknown): v is Palette =>
166175 typeof v === "string" && (PALETTES as readonly string[]).includes(v);
167176
@@ -326,6 +335,22 @@ export function validate(
326335 };
327336 }
328337
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
329354 /** An edited asset's words: kept as typed, within bounds. */
330355 export function assetText(v: unknown): Checked<string> {
331356 const t = String(v ?? "")
src/mcp.ts +18 −1
@@ -20,11 +20,12 @@ import {
2020 listListings,
2121 listNotes,
2222 retoneListing,
23 setStatus,
2324 updateFacts,
2425 updatePhotos,
2526 type Result,
2627 } from "./actions.ts";
27 import { KINDS, TONES } from "./generate.ts";
28 import { KINDS, STATUSES, TONES } from "./generate.ts";
2829 import { title } from "./env.ts";
2930 import { fields } from "./http.ts";
3031 import type { Who } from "./who.ts";
@@ -218,6 +219,22 @@ export const tools: readonly Tool[] = [
218219 run: (db, who, args) =>
219220 retoneListing(db, who, { id: Number(args.id), tone: args.tone }),
220221 },
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 },
221238 {
222239 name: "update_facts",
223240 description:
src/openhouse.test.ts +4 −4
@@ -10,7 +10,7 @@ import { join } from "node:path";
1010 import { approveAsset, getListing, launchListing, updateFacts } from "./actions.ts";
1111 import { openDb } from "./db.ts";
1212 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";
1414 import { agentOf, validate } from "./listing.ts";
1515 import { rpc } from "./mcp.ts";
1616 import { SAMPLE } from "./sample.ts";
@@ -65,14 +65,14 @@ test("every asset carries the line when set, in every tone, and none when cleare
6565 const facts = { ...SAMPLE, tone, open_houses: [sunday] };
6666 const line = openHouseLine(facts)!;
6767 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]);
6969 expect(words.mls.split("\n").at(-1)).toBe(line);
7070 expect(words.email.split("\n\n")[1]).toBe(line);
7171 expect(flyerParts(words.flyer, line).open).toBe(line);
7272 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);
7474 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]);
7676 }
7777 });
7878
src/pages/home.ts +2 −2
@@ -18,7 +18,7 @@ import { PHOTOS_MAX } from "../photos.ts";
1818 import { SAMPLE } from "../sample.ts";
1919 import type { Who } from "../who.ts";
2020 import { ago, layout, toast } from "./layout.ts";
21 import { approvalPill, photoUrl } from "./listing.ts";
21 import { approvalPill, photoUrl, statusPill } from "./listing.ts";
2222
2323 const TONE_SAYS: Readonly<Record<Tone, string>> = {
2424 classic: "Measured and precise. Lets the facts carry it.",
@@ -179,7 +179,7 @@ function list(listings: readonly ListingSummary[]): string {
179179 <span class="card-body">
180180 <b>${escape(l.address)}</b>
181181 <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>
183183 </span>
184184 </a>
185185 </li>`;
src/pages/listing.ts +28 −4
@@ -11,6 +11,8 @@ import {
1111 flyerParts,
1212 openHouseLine,
1313 money,
14 STATUS_LABELS,
15 STATUSES,
1416 TONES,
1517 } from "../generate.ts";
1618 import { asset as assetHref, escape } from "../http.ts";
@@ -89,6 +91,25 @@ const approvedCount = (l: Launched): number =>
8991 export const approvalPill = (approved: number, total: number): string =>
9092 `<span class="fh-pill${approved === total ? " fh-ok" : ""}">${approvalLabel(approved, total)}</span>`;
9193
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
92113 /** The band an unapproved flyer or email wears, on screen and on paper; nothing once approved. */
93114 export const draftBand = (l: Launched, kind: "flyer" | "email"): string =>
94115 l.assets.find((a) => a.kind === kind)?.approval == null
@@ -136,9 +157,9 @@ function editor(l: Launched, a: Asset, editable: boolean): string {
136157
137158 const TABS: readonly { id: string; label: string; kinds: readonly string[] }[] = [
138159 { 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"] },
140161 { id: "flyer", label: "Flyer", kinds: ["flyer"] },
141 { id: "email", label: "Email", kinds: ["email"] },
162 { id: "email", label: "Email", kinds: ["email", "email_status"] },
142163 ];
143164
144165 /** The tab that holds an asset, or a tab named outright; the first otherwise. */
@@ -164,7 +185,7 @@ export function listingPage(input: {
164185 ? `<form class="row tone" method="post" action="/listings/${l.id}/tone">
165186 <label class="sr" for="tone">Tone</label>
166187 <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>
168189 </form>`
169190 : `<span class="pill">${escape(l.tone)} tone</span>`;
170191 return layout(
@@ -180,8 +201,10 @@ export function listingPage(input: {
180201 </div>
181202 <div class="listing-tools">
182203 ${approvalPill(approvedCount(l), l.assets.length)}
204 ${statusPill(l)}
183205 ${n === 0 ? `<span class="fh-pill fh-ok">Fair Housing: clear</span>` : `<span class="fh-pill fh-warn">Fair Housing: ${n} to review</span>`}
184206 ${tone}
207 ${editable ? statusForm(l) : ""}
185208 ${editable ? `<a class="button" href="/listings/${l.id}/facts">Edit facts</a>` : ""}
186209 <a class="button" href="/listings/${l.id}/flyer">Print flyer</a>
187210 <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 {
224247 const [hero, ...more] = l.photos;
225248 return `<article class="flyer palette-${l.palette}">
226249 ${draftBand(l, "flyer")}
250 ${l.status === "active" ? "" : `<p class="flyer-status status-${l.status}" role="note">${escape(statusLine(l))}</p>`}
227251 <div class="flyer-hero">
228252 ${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>
230254 </div>
231255 <header class="flyer-head">
232256 <div>
src/routes.ts +20 −2
@@ -31,6 +31,7 @@ import {
3131 listListings,
3232 listNotes,
3333 retoneListing,
34 setStatus,
3435 updateFacts,
3536 updatePhotos,
3637 updateNote,
@@ -39,7 +40,7 @@ import {
3940 type Result,
4041 type Upload,
4142 } from "./actions.ts";
42 import { KINDS, TONES } from "./generate.ts";
43 import { KINDS, STATUSES, TONES } from "./generate.ts";
4344 import { ASSET_MAX, FEATURES_MAX, FEATURES_MIN, PALETTES } from "./listing.ts";
4445 import { commit, env, forgeUrl, startedAt, version } from "./env.ts";
4546 import {
@@ -207,6 +208,8 @@ const LISTING_FIELDS = {
207208 ...FACTS,
208209 photos: { type: "array", items: { type: "string" }, maxItems: PHOTOS_MAX },
209210 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." },
210213 created_at: { type: "string", format: "date-time" },
211214 updated_at: { type: "string", format: "date-time" },
212215 };
@@ -282,6 +285,8 @@ const LISTING_CHANGE: Schema = {
282285 kind: { enum: [...KINDS] },
283286 text: { type: "string", minLength: 1, maxLength: ASSET_MAX },
284287 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." },
285290 facts: {
286291 type: "object",
287292 description: "Launch fields to change; the ones left out keep their value.",
@@ -627,6 +632,17 @@ export const routes: readonly Route[] = [
627632 return back(done.ok ? "Rewritten. Read it through again." : done.message, `/listings/${id}`);
628633 },
629634 },
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 },
630646 {
631647 method: "POST",
632648 path: "/listings/:id/delete",
@@ -807,6 +823,8 @@ export const routes: readonly Route[] = [
807823 ? updatePhotos(c.db, c.who, { id, ...photoChange(fields(body.photos)) })
808824 : typeof body.facts === "object" && body.facts !== null && !Array.isArray(body.facts)
809825 ? 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 })
810828 : body.tone !== undefined && body.kind === undefined
811829 ? retoneListing(c.db, c.who, { id, tone: body.tone })
812830 : body.approved !== undefined && body.text === undefined
@@ -814,7 +832,7 @@ export const routes: readonly Route[] = [
814832 : editAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), text: body.text }),
815833 );
816834 },
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 },
818836 },
819837 {
820838 method: "DELETE",
src/sample.ts +17 −1
@@ -6,7 +6,7 @@
66 */
77
88 import type { Database } from "bun:sqlite";
9 import { insertListing } from "./actions.ts";
9 import { insertListing, markStatus } from "./actions.ts";
1010 import type { ListingInput } from "./listing.ts";
1111
1212 export const SAMPLE: ListingInput = {
@@ -107,6 +107,8 @@ interface Sample {
107107 readonly listing: ListingInput;
108108 readonly photos: readonly string[];
109109 readonly headshot: string;
110 /** Where the sample stands; absent means active. */
111 readonly sold_at?: number;
110112 }
111113
112114 const SAMPLES: readonly Sample[] = [
@@ -116,6 +118,8 @@ const SAMPLES: readonly Sample[] = [
116118 listing: SAMPLE_CONDO,
117119 photos: ["sample-condo-front.svg", "sample-condo-living.svg"],
118120 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,
119123 },
120124 {
121125 name: "sample-house",
@@ -133,5 +137,17 @@ export function seed(db: Database): void {
133137 if (done.changes === 0) continue;
134138 insertListing(db, { id: "sample", name: "Sample listing" }, s.listing, s.photos, s.headshot);
135139 }
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 }
136152 })();
137153 }
src/server.test.ts +1
@@ -152,6 +152,7 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
152152 "use_suggestion",
153153 "approve_asset",
154154 "retone_listing",
155 "set_status",
155156 "update_facts",
156157 "update_photos",
157158 "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 {
2929 listListings,
3030 listNotes,
3131 retoneListing,
32 setStatus,
3233 updateFacts,
3334 useSuggestion,
3435 } from "./actions.ts";
@@ -69,6 +70,14 @@ export const openFromLine = (line: string): { date: string; start: string; end:
6970 return { date: m?.[1] ?? r, start: m?.[2] ?? "", end: m?.[3] ?? "" };
7071 });
7172
73 /** The status in the one-line box: "sold 752000", "sold", "under contract" or "active". */
outdated

This doc comment now sits above the status helpers instead of toLine/fromLine. Move it back next to them.

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
7281 /** In the one-line edit box, `\n` typed as two characters is a line break. */
7382 export const toLine = (text: string): string => text.replace(/\n/g, "\\n");
7483 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 }
8089 let previewing = false;
8190 let cursor = 0;
8291 /** 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;
8493 let typed = "";
8594 let said = "";
8695 /** 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 }
119128 }
120129 const signed = l.assets.filter((a) => a.approval !== null).length;
121130 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]), ""];
123132 // Scroll so the asset under the cursor starts the page.
124133 const body = l.assets.slice(cursor).flatMap((a, i) => [
125134 `${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 }
145154 if (previewing) help = "esc back to the assets · q quit";
146155 else help =
147156 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"
149158 : "j/k asset · p preview email · esc back · q quit";
150159 } else if (view === "listings") {
151160 const rows = listings();
@@ -154,7 +163,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
154163 const done = l.approvals.filter((a) => a.approved).length;
155164 const ready = approvalLabel(done, l.approvals.length);
156165 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)}`}`;
158167 lines.push(
159168 `${i === cursor ? "▸" : " "} ${i === cursor ? bold(text) : text} ${done === l.approvals.length ? green(ready) : dim(ready)} ${flag} ${dim(l.author_name)}`,
160169 );
@@ -177,7 +186,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
177186 }
178187 lines.push("");
179188 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)`;
181190 // The tail of what is typed, so the cursor stays on the screen.
182191 const room = Math.max(20, size.columns - 4);
183192 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 }
196205 } else if (typing.for === "open") {
197206 const done = updateFacts(db, me, { id: reading!, fields: { open_houses: openFromLine(typed) } });
198207 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;
199212 } else {
200213 const kind = typing.kind;
201214 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 }
243256 if (previewing) return undefined;
244257 if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1));
245258 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")) {
247260 if (!canEditListing(me, found.value))
248261 said = me.role === "anon" ? "Sign in to edit this listing." : "Only the listing's agent or an admin can edit it.";
249262 else if (key === "u") {
@@ -271,6 +284,9 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
271284 : done.message;
272285 }
273286 }
287 } else if (key === "s") {
288 typing = { for: "status" };
289 typed = statusToLine(found.value);
274290 } else if (key === "o") {
275291 typing = { for: "open" };
276292 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 () => {
209209 expect((await post(`${mine}/facts`, clear)).status).toBe(303);
210210 expect(await (await get(`${mine}/flyer`)).text()).not.toContain("flyer-open");
211211
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.
213240 expect(afterTone).toMatch(control("Print flyer"));
214241 const flyer = await get(`${mine}/flyer`);
215242 expect(flyer.status).toBe(200);
static/app.css +29
@@ -943,3 +943,32 @@ main:focus {
943943 break-inside: avoid;
944944 }
945945 }
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(--color-ok-fg);
955 background: var(--color-ok-bg);
956 }
957 .status-pill.status-under_contract,
958 .flyer-status.status-under_contract {
959 color: var(--color-warn-fg);
960 background: var(--color-warn-bg);
961 }
962 .flyer-status {
963 margin: 0;
964 padding: var(--space-2) var(--space-3);
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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs