Public: anyone can read this; the crew can change it.
Lower a listing's price and get a 'Price improvement' post and email to approve #46
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/44-lower-a-listing-s-price-and-get-a-price → main 4 commits fork 1 thread · 1 unresolved
13 files
changed +298
−21
README.md +7 −1
| @@ -48,6 +48,12 @@ to make a listing of your own. | ||
| 48 | 48 | status beside the approval count. *88 Meridian Street* is already sold, so |
| 49 | 49 | you can see it. **Under contract** does the same with its own words, and |
| 50 | 50 | **Active** takes the two away again. |
| 51 | **Lower the price.** In **Edit facts**, a lower price adds a price | |
| 52 | improvement post (Social) and email ("New price — 412 Alder Lane, now | |
| 53 | $849,000, was $899,000") in the listing's tone, each Fair Housing checked | |
| 54 | and needing its own approval. A raise or no change adds nothing; raising | |
| 55 | the price back to the original, or above it, takes them away. The same | |
| 56 | goes in the terminal (press `r` on the listing) and for other tools. | |
| 51 | 57 | 9. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with |
| 52 | 58 | the photo, price, facts, description and your name. Print it or save it |
| 53 | 59 | as a PDF. |
| @@ -186,7 +192,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 186 | 192 | | ---------------------------------- | -------------------------------------------------------------- | |
| 187 | 193 | | `src/actions.ts` | listings (launch, edit an asset, retone, change facts, remove), notes, the audit trail | |
| 188 | 194 | | `src/listing.ts` | what a listing is, and the check at the boundary that one arrived | |
| 189 | | `src/generate.ts` | `generate(listing)`: the six assets from the facts (eight once it is under contract or sold), in three tones | | |
| 195 | | `src/generate.ts` | `generate(listing)`: the six assets from the facts (more once it is under contract, sold or lowered in price), in three tones | | |
| 190 | 196 | | `src/postlength.ts` | a social post's length by what X, Instagram and Facebook count | |
| 191 | 197 | | `src/fairhousing.ts` | the Fair Housing rules: phrase, context, class, reason, rewrite | |
| 192 | 198 | | `src/photos.ts` | photos as files beside the database, known by their bytes | |
contract/openapi.json +25 −3
| @@ -544,9 +544,11 @@ | ||
| 544 | 544 | "social_medium", |
| 545 | 545 | "social_carousel", |
| 546 | 546 | "social_status", |
| 547 | "social_price", | |
| 547 | 548 | "flyer", |
| 548 | 549 | "email", |
| 549 | "email_status" | |
| 550 | "email_status", | |
| 551 | "email_price" | |
| 550 | 552 | ] |
| 551 | 553 | }, |
| 552 | 554 | "text": { |
| @@ -1190,6 +1192,13 @@ | ||
| 1190 | 1192 | ], |
| 1191 | 1193 | "description": "Whole dollars; only a sold listing has one, and only if the agent said." |
| 1192 | 1194 | }, |
| 1195 | "was_price": { | |
| 1196 | "type": [ | |
| 1197 | "integer", | |
| 1198 | "null" | |
| 1199 | ], | |
| 1200 | "description": "Whole dollars: the price before it was lowered; set while the price stands below it, and then a price improvement post and email exist." | |
| 1201 | }, | |
| 1193 | 1202 | "created_at": { |
| 1194 | 1203 | "type": "string", |
| 1195 | 1204 | "format": "date-time" |
| @@ -1210,9 +1219,11 @@ | ||
| 1210 | 1219 | "social_medium", |
| 1211 | 1220 | "social_carousel", |
| 1212 | 1221 | "social_status", |
| 1222 | "social_price", | |
| 1213 | 1223 | "flyer", |
| 1214 | 1224 | "email", |
| 1215 | "email_status" | |
| 1225 | "email_status", | |
| 1226 | "email_price" | |
| 1216 | 1227 | ] |
| 1217 | 1228 | }, |
| 1218 | 1229 | "label": { |
| @@ -1362,6 +1373,7 @@ | ||
| 1362 | 1373 | "headshot", |
| 1363 | 1374 | "status", |
| 1364 | 1375 | "sold_price", |
| 1376 | "was_price", | |
| 1365 | 1377 | "created_at", |
| 1366 | 1378 | "updated_at", |
| 1367 | 1379 | "assets" |
| @@ -1517,6 +1529,13 @@ | ||
| 1517 | 1529 | ], |
| 1518 | 1530 | "description": "Whole dollars; only a sold listing has one, and only if the agent said." |
| 1519 | 1531 | }, |
| 1532 | "was_price": { | |
| 1533 | "type": [ | |
| 1534 | "integer", | |
| 1535 | "null" | |
| 1536 | ], | |
| 1537 | "description": "Whole dollars: the price before it was lowered; set while the price stands below it, and then a price improvement post and email exist." | |
| 1538 | }, | |
| 1520 | 1539 | "created_at": { |
| 1521 | 1540 | "type": "string", |
| 1522 | 1541 | "format": "date-time" |
| @@ -1542,9 +1561,11 @@ | ||
| 1542 | 1561 | "social_medium", |
| 1543 | 1562 | "social_carousel", |
| 1544 | 1563 | "social_status", |
| 1564 | "social_price", | |
| 1545 | 1565 | "flyer", |
| 1546 | 1566 | "email", |
| 1547 | "email_status" | |
| 1567 | "email_status", | |
| 1568 | "email_price" | |
| 1548 | 1569 | ] |
| 1549 | 1570 | }, |
| 1550 | 1571 | "approved": { |
| @@ -1582,6 +1603,7 @@ | ||
| 1582 | 1603 | "headshot", |
| 1583 | 1604 | "status", |
| 1584 | 1605 | "sold_price", |
| 1606 | "was_price", | |
| 1585 | 1607 | "created_at", |
| 1586 | 1608 | "updated_at", |
| 1587 | 1609 | "flags", |
src/actions.ts +4 −1
| @@ -857,8 +857,11 @@ function changeFacts( | ||
| 857 | 857 | JSON.stringify(v.features), v.tone, v.palette, v.agent_name, v.brokerage, |
| 858 | 858 | v.license, v.agent_phone, v.agent_email, JSON.stringify(v.open_houses), now.id, |
| 859 | 859 | ); |
| 860 | // A cut remembers the price it came from; a raise to or above it forgets. | |
| 861 | const was = v.price < now.price ? (now.was_price ?? now.price) : now.was_price !== null && v.price < now.was_price ? now.was_price : null; | |
| 862 | db.query("UPDATE listing SET was_price = ?1 WHERE id = ?2").run(was, now.id); | |
| 860 | 863 | touch(db, now.id); |
| 861 | writeAssets(db, { ...now, ...v }); | |
| 864 | writeAssets(db, { ...now, ...v, was_price: was }); | |
| 862 | 865 | })(); |
| 863 | 866 | emit(db, "listing.changed", { id: now.id }); |
| 864 | 867 | return ok(launched(db, now.id)!); |
src/audit.test.ts +3 −3
| @@ -33,9 +33,9 @@ const as = ( | ||
| 33 | 33 | "x-forge-signature": sign(identity, KEY), |
| 34 | 34 | }; |
| 35 | 35 | }; |
| 36 | const alice = as("u1", "alice"); | |
| 37 | const bob = as("u2", "bob"); | |
| 38 | const root = as("u0", "root", "admin"); | |
| 36 | const alice = as("audit-u1", "alice"); | |
| 37 | const bob = as("audit-u2", "bob"); | |
| 38 | const root = as("audit-u0", "root", "admin"); | |
| 39 | 39 | const call = ( |
| 40 | 40 | path: string, |
| 41 | 41 | init: RequestInit = {}, |
src/db.ts +2
| @@ -95,6 +95,8 @@ export const migrations: string[] = [ | ||
| 95 | 95 | // Where the listing stands: active, under_contract or sold, and the price it sold at if the agent said. |
| 96 | 96 | `ALTER TABLE listing ADD COLUMN status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'under_contract', 'sold'))`, |
| 97 | 97 | `ALTER TABLE listing ADD COLUMN sold_price INTEGER`, |
| 98 | // The price before it was lowered, while it stands below it; the price improvement assets are written from it. | |
| 99 | `ALTER TABLE listing ADD COLUMN was_price INTEGER`, | |
| 98 | 100 | ]; |
| 99 | 101 | |
| 100 | 102 | export function openDb(dir = dataDir): Database { |
src/generate.ts +50 −1
| @@ -22,9 +22,11 @@ export const KINDS = [ | ||
| 22 | 22 | "social_medium", |
| 23 | 23 | "social_carousel", |
| 24 | 24 | "social_status", |
| 25 | "social_price", | |
| 25 | 26 | "flyer", |
| 26 | 27 | "email", |
| 27 | 28 | "email_status", |
| 29 | "email_price", | |
| 28 | 30 | ] as const; |
| 29 | 31 | export type Kind = (typeof KINDS)[number]; |
| 30 | 32 | |
| @@ -38,11 +40,16 @@ export const LABELS: Readonly<Record<Kind, string>> = { | ||
| 38 | 40 | flyer: "Flyer copy", |
| 39 | 41 | email: "Email blast", |
| 40 | 42 | email_status: "Status email", |
| 43 | social_price: "Price improvement post", | |
| 44 | email_price: "Price improvement email", | |
| 41 | 45 | }; |
| 42 | 46 | |
| 43 | 47 | /** The assets that exist only while a listing is under contract or sold. */ |
| 44 | 48 | export const STATUS_KINDS = ["social_status", "email_status"] as const satisfies readonly Kind[]; |
| 45 | 49 | |
| 50 | /** The assets that exist only while the price stands below what it was. */ | |
| 51 | export const PRICE_KINDS = ["social_price", "email_price"] as const satisfies readonly Kind[]; | |
| 52 | ||
| 46 | 53 | export const STATUSES = ["active", "under_contract", "sold"] as const; |
| 47 | 54 | export type Status = (typeof STATUSES)[number]; |
| 48 | 55 | |
| @@ -75,6 +82,8 @@ export interface Facts { | ||
| 75 | 82 | readonly status?: Status; |
| 76 | 83 | /** Whole dollars, only when sold, and only if the agent chose to say. */ |
| 77 | 84 | readonly sold_price?: number | null; |
| 85 | /** Whole dollars: the price before it was lowered; absent or null when it has not been. */ | |
| 86 | readonly was_price?: number | null; | |
| 78 | 87 | } |
| 79 | 88 | |
| 80 | 89 | export interface Agent { |
| @@ -365,13 +374,52 @@ function statusEmail(f: Facts, a: Agent): string { | ||
| 365 | 374 | 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 | 375 | } |
| 367 | 376 | |
| 368 | /** A listing's assets: the six, and the two status ones when it is under contract or sold. */ | |
| 377 | /** True when the price now stands below the one it was lowered from. */ | |
| 378 | export const priceCut = (f: Pick<Facts, "price" | "was_price">): boolean => | |
| 379 | f.was_price != null && f.was_price > f.price; | |
| 380 | ||
| 381 | function pricePost(f: Facts, a: Agent): string { | |
| 382 | const where = street(f.address); | |
| 383 | const was = money(f.was_price!); | |
| 384 | const now = money(f.price); | |
| 385 | const line = { | |
| 386 | classic: `New price: ${where}, now ${now}, was ${was}.`, | |
| 387 | warm: `Good news: ${where} has a new price! It is now ${now}, down from ${was}.`, | |
| 388 | bold: `PRICE IMPROVEMENT: ${where}. NOW ${now}. WAS ${was}.`, | |
| 389 | }[f.tone]; | |
| 390 | const close = { | |
| 391 | classic: "Message me to arrange a private showing.", | |
| 392 | warm: "I would love to show you around. Send me a message to set up a visit.", | |
| 393 | bold: "Now is the time. Message me today.", | |
| 394 | }[f.tone]; | |
| 395 | return `${line} ${close} ${a.name}, ${a.brokerage}. #PriceImprovement #NewPrice`; | |
| 396 | } | |
| 397 | ||
| 398 | function priceEmail(f: Facts, a: Agent): string { | |
| 399 | const where = street(f.address); | |
| 400 | const was = money(f.was_price!); | |
| 401 | const now = money(f.price); | |
| 402 | const open = { | |
| 403 | classic: `I am pleased to share that the price of ${f.address} has been improved. It is now ${now}, was ${was}.`, | |
| 404 | warm: `I have good news to share: ${f.address} has a new price! It is now ${now}, down from ${was}.`, | |
| 405 | bold: `${f.address} just got a new price: NOW ${now}, WAS ${was}.`, | |
| 406 | }[f.tone]; | |
| 407 | const close = { | |
| 408 | classic: "To arrange a private showing or request the disclosure package, reply to this email or call me directly.", | |
| 409 | warm: "If you or someone you know would like to see it, just reply to this email and I will set up a time.", | |
| 410 | bold: "Want in? Reply now and I will get you on the showing schedule.", | |
| 411 | }[f.tone]; | |
| 412 | return `Subject: New price — ${where}, now ${now}, was ${was}\n\n${open}\n\nThe facts: ${plural(f.beds, "bedroom")}, ${plural(f.baths, "bath")}, ${count(f.sqft)} square feet, built in ${f.year}.\n\n${close}\n\n${a.name}\n${a.brokerage} · License ${a.license}\n${a.phone} · ${a.email}`; | |
| 413 | } | |
| 414 | ||
| 415 | /** A listing's assets: the six, the two status ones when it is under contract or sold, the two price ones when the price was lowered. */ | |
| 369 | 416 | export function generate(listing: { |
| 370 | 417 | readonly facts: Facts; |
| 371 | 418 | readonly agent: Agent; |
| 372 | 419 | }): Partial<Record<Kind, string>> & Record<(typeof CORE_KINDS)[number], string> { |
| 373 | 420 | const { facts: f, agent: a } = listing; |
| 374 | 421 | const announced = f.status === "under_contract" || f.status === "sold"; |
| 422 | const cut = priceCut(f); | |
| 375 | 423 | return { |
| 376 | 424 | mls: mls(f), |
| 377 | 425 | social_short: socialShort(f, a), |
| @@ -380,6 +428,7 @@ export function generate(listing: { | ||
| 380 | 428 | flyer: flyer(f), |
| 381 | 429 | email: email(f, a), |
| 382 | 430 | ...(announced ? { social_status: statusPost(f, a), email_status: statusEmail(f, a) } : {}), |
| 431 | ...(cut ? { social_price: pricePost(f, a), email_price: priceEmail(f, a) } : {}), | |
| 383 | 432 | }; |
| 384 | 433 | } |
| 385 | 434 |
src/listing.ts +3
| @@ -43,6 +43,8 @@ export interface Listing extends ListingInput { | ||
| 43 | 43 | readonly status: Status; |
| 44 | 44 | /** Whole dollars; only a sold listing has one, and only if the agent said. */ |
| 45 | 45 | readonly sold_price: number | null; |
| 46 | /** Whole dollars: what the price was before it was lowered; null when it has not been. */ | |
| 47 | readonly was_price: number | null; | |
| 46 | 48 | readonly id: number; |
| 47 | 49 | readonly author_id: string; |
| 48 | 50 | readonly author_name: string; |
| @@ -109,6 +111,7 @@ export interface ListingRow { | ||
| 109 | 111 | readonly open_houses: string; |
| 110 | 112 | readonly status: Status; |
| 111 | 113 | readonly sold_price: number | null; |
| 114 | readonly was_price: number | null; | |
| 112 | 115 | readonly agent_name: string; |
| 113 | 116 | readonly brokerage: string; |
| 114 | 117 | readonly license: string; |
src/openhouse.test.ts +1 −1
| @@ -111,7 +111,7 @@ test("adding, changing and clearing it writes the assets again and clears approv | ||
| 111 | 111 | expect(text("mls")).toContain("Sunday, March 14, 1–3 pm"); |
| 112 | 112 | expect(text("mls")).not.toContain("March 7"); |
| 113 | 113 | |
| 114 | expect(approve()).toBe(6); | |
| 114 | expect(approve()).toBe(8); | |
| 115 | 115 | const cleared = updateFacts(db, me, { id, fields: { open_houses: [] } }); |
| 116 | 116 | if (!cleared.ok) throw new Error(cleared.message); |
| 117 | 117 | expect(cleared.value.open_houses).toEqual([]); |
src/pages/listing.ts +2 −2
| @@ -157,9 +157,9 @@ function editor(l: Launched, a: Asset, editable: boolean): string { | ||
| 157 | 157 | |
| 158 | 158 | const TABS: readonly { id: string; label: string; kinds: readonly string[] }[] = [ |
| 159 | 159 | { id: "mls", label: "MLS", kinds: ["mls"] }, |
| 160 | { id: "social", label: "Social", kinds: ["social_short", "social_medium", "social_carousel", "social_status"] }, | |
| 160 | { id: "social", label: "Social", kinds: ["social_short", "social_medium", "social_carousel", "social_status", "social_price"] }, | |
| 161 | 161 | { id: "flyer", label: "Flyer", kinds: ["flyer"] }, |
| 162 | { id: "email", label: "Email", kinds: ["email", "email_status"] }, | |
| 162 | { id: "email", label: "Email", kinds: ["email", "email_status", "email_price"] }, | |
| 163 | 163 | ]; |
| 164 | 164 | |
| 165 | 165 | /** The tab that holds an asset, or a tab named outright; the first otherwise. */ |
src/priceimprovement.test.ts added +181
| @@ -0,0 +1,181 @@ | ||
| 1 | /** | |
| 2 | * Price improvement: lowering the price adds a post and an email naming the | |
| 3 | * old and new price, each checked and needing its own approval; a raise or no | |
| 4 | * change adds nothing, and a raise back to the old price removes them. | |
| 5 | */ | |
| 6 | import { expect, test } from "bun:test"; | |
| 7 | import { mkdtempSync } from "node:fs"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { approveAsset, getListing, launchListing, listListings, updateFacts } from "./actions.ts"; | |
| 11 | import { openDb } from "./db.ts"; | |
| 12 | import { check } from "./fairhousing.ts"; | |
| 13 | import { generate, PRICE_KINDS, TONES } from "./generate.ts"; | |
| 14 | import { agentOf } from "./listing.ts"; | |
| 15 | import { rpc } from "./mcp.ts"; | |
| 16 | import { listingPage } from "./pages/listing.ts"; | |
| 17 | import { SAMPLE } from "./sample.ts"; | |
| 18 | import { session } from "./tty.ts"; | |
| 19 | import type { Who } from "./who.ts"; | |
| 20 | ||
| 21 | const me: Who = { role: "user", id: "u1", name: "me" }; | |
| 22 | ||
| 23 | const launch = () => { | |
| 24 | const db = openDb(mkdtempSync(join(tmpdir(), "app-price-"))); | |
| 25 | const made = launchListing(db, me, { fields: { ...SAMPLE, price: 899000 } }); | |
| 26 | if (!made.ok) throw new Error(made.message); | |
| 27 | return { db, id: made.value.id }; | |
| 28 | }; | |
| 29 | const kinds = (db: ReturnType<typeof openDb>, id: number) => { | |
| 30 | const found = getListing(db, me, { id }); | |
| 31 | if (!found.ok) throw new Error(found.message); | |
| 32 | return found.value; | |
| 33 | }; | |
| 34 | const price = (db: ReturnType<typeof openDb>, id: number, p: number) => { | |
| 35 | const done = updateFacts(db, me, { id, fields: { price: p } }); | |
| 36 | if (!done.ok) throw new Error(done.message); | |
| 37 | return done.value; | |
| 38 | }; | |
| 39 | ||
| 40 | test("the words: both name the street, the new and the old price, clear in every tone", () => { | |
| 41 | const agent = agentOf(SAMPLE); | |
| 42 | for (const tone of TONES) { | |
| 43 | const words = generate({ facts: { ...SAMPLE, tone, price: 849000, was_price: 899000 }, agent }); | |
| 44 | for (const kind of PRICE_KINDS) { | |
| 45 | const text = words[kind]!; | |
| 46 | expect(text).toContain("$849,000"); | |
| 47 | expect(text).toContain("$899,000"); | |
| 48 | expect(check(text).clear).toBe(true); | |
| 49 | } | |
| 50 | } | |
| 51 | expect(generate({ facts: { ...SAMPLE, tone: "classic", price: 849000, was_price: 899000 }, agent }).email_price).toStartWith( | |
| 52 | `Subject: New price — ${SAMPLE.address.split(",")[0]}, now $849,000, was $899,000`, | |
| 53 | ); | |
| 54 | for (const was of [undefined, null, 849000, 800000]) | |
| 55 | expect(generate({ facts: { ...SAMPLE, price: 849000, was_price: was }, agent }).social_price).toBeUndefined(); | |
| 56 | }); | |
| 57 | ||
| 58 | test("lowering adds both assets with old and new price; raising back removes them", () => { | |
| 59 | const { db, id } = launch(); | |
| 60 | expect(kinds(db, id).assets.map((a) => a.kind)).not.toContain("social_price"); | |
| 61 | const cut = price(db, id, 849000); | |
| 62 | expect(cut.was_price).toBe(899000); | |
| 63 | for (const kind of PRICE_KINDS) { | |
| 64 | const a = cut.assets.find((x) => x.kind === kind)!; | |
| 65 | expect(a.text).toContain("$849,000"); | |
| 66 | expect(a.text).toContain("$899,000"); | |
| 67 | expect(a.approval).toBeNull(); | |
| 68 | } | |
| 69 | // a second cut keeps the original | |
| 70 | expect(price(db, id, 829000).assets.find((a) => a.kind === "social_price")!.text).toContain("$899,000"); | |
| 71 | // a raise that stays below the original keeps them; to the original removes them | |
| 72 | expect(price(db, id, 840000).assets.map((a) => a.kind)).toContain("email_price"); | |
| 73 | const back = price(db, id, 899000); | |
| 74 | expect(back.was_price).toBeNull(); | |
| 75 | expect(back.assets.map((a) => a.kind)).not.toContain("social_price"); | |
| 76 | expect(back.assets.map((a) => a.kind)).not.toContain("email_price"); | |
| 77 | expect(price(db, id, 950000).assets.map((a) => a.kind)).not.toContain("email_price"); | |
| 78 | }); | |
| 79 | ||
| 80 | test("a raise or no change adds nothing", () => { | |
| 81 | const { db, id } = launch(); | |
| 82 | expect(price(db, id, 950000).assets.map((a) => a.kind)).not.toContain("social_price"); | |
| 83 | expect(price(db, id, 950000).was_price).toBeNull(); | |
| 84 | }); | |
| 85 | ||
| 86 | test("each needs its own approval and counts toward ready to send", () => { | |
| 87 | const { db, id } = launch(); | |
| 88 | const cut = price(db, id, 849000); | |
| 89 | const post = cut.assets.find((a) => a.kind === "social_price")!; | |
| 90 | const done = approveAsset(db, me, { id, kind: "social_price", door: "page", seen: post.updated_at }); | |
| 91 | expect(done.ok && done.value.assets.filter((a) => a.approval !== null).map((a) => a.kind)).toEqual(["social_price"]); | |
| 92 | const row = listListings(db, me, {}); | |
| 93 | expect(row.ok && row.value.find((l) => l.id === id)!.approvals.length).toBe(cut.assets.length); | |
| 94 | expect(row.ok && row.value.find((l) => l.id === id)!.approvals.filter((a) => a.approved).length).toBe(1); | |
| 95 | }); | |
| 96 | ||
| 97 | test("the page shows both with their length; the MCP cannot approve them", () => { | |
| 98 | const { db, id } = launch(); | |
| 99 | const cut = price(db, id, 849000); | |
| 100 | const page = listingPage({ who: me, listing: cut, editable: true }); | |
| 101 | expect(page).toContain('id="social_price"'); | |
| 102 | expect(page).toContain('id="email_price"'); | |
| 103 | expect(page).toMatch(/characters[^<]*Instagram/); | |
| 104 | for (const kind of PRICE_KINDS) { | |
| 105 | const res = rpc(db, me, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "approve_asset", arguments: { id, kind } } }); | |
| 106 | expect(JSON.stringify(res.body)).toMatch(/person|isError|refus|cannot/i); | |
| 107 | expect(kinds(db, id).assets.find((a) => a.kind === kind)!.approval).toBeNull(); | |
| 108 | } | |
| 109 | }); | |
| 110 | ||
| 111 | const plain = (s: string): string => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); | |
| 112 | ||
| 113 | test("the terminal lowers the price with r and shows the new assets", () => { | |
| 114 | const { db, id } = launch(); | |
| 115 | const tty = session(db, me); | |
| 116 | tty.press("\r"); | |
| 117 | tty.press("r"); | |
| 118 | expect(plain(tty.screen())).toContain("899000"); | |
| 119 | for (let i = 0; i < 10; i++) tty.press("\x7f"); | |
| 120 | for (const key of "849000") tty.press(key); | |
| 121 | tty.press("\r"); | |
| 122 | expect(plain(tty.screen())).toContain("down from $899,000"); | |
| 123 | const now = kinds(db, id); | |
| 124 | expect(now.price).toBe(849000); | |
| 125 | expect(now.was_price).toBe(899000); | |
| 126 | expect(now.assets.map((a) => a.kind)).toContain("social_price"); | |
| 127 | expect(plain(tty.screen())).toContain("0 of 8 approved"); | |
| 128 | tty.press("r"); | |
| 129 | for (let i = 0; i < 10; i++) tty.press("\x7f"); | |
| 130 | for (const key of "lots") tty.press(key); | |
| 131 | tty.press("\r"); | |
| 132 | expect(kinds(db, id).price).toBe(849000); | |
| 133 | }); | |
| 134 | ||
| 135 | test("the MCP update_facts lowers the price", () => { | |
| 136 | const { db, id } = launch(); | |
| 137 | const res = rpc(db, me, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "update_facts", arguments: { id, facts: { price: 849000 } } } }).body as { result: { content: { text: string }[] } }; | |
| 138 | expect(res.result.content[0]!.text).toContain("social_price"); | |
| 139 | const now = kinds(db, id); | |
| 140 | expect(now.was_price).toBe(899000); | |
| 141 | expect(now.assets.map((a) => a.kind)).toContain("email_price"); | |
| 142 | }); | |
| 143 | ||
| 144 | test("the JSON API PATCH and the Edit facts form lower the price", async () => { | |
| 145 | const { serve } = await import("./server.ts"); | |
| 146 | const { sign } = await import("./who.ts"); | |
| 147 | const KEY = "a-key-only-the-forge-holds"; | |
| 148 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-price-api-")), identityKey: KEY }); | |
| 149 | try { | |
| 150 | const base = `http://${server.hostname}:${server.port}`; | |
| 151 | const identity = `price-api|agent|user|${Date.now() + 600_000}`; | |
| 152 | const auth = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; | |
| 153 | const mk = async () => { | |
| 154 | const r = await fetch(`${base}/api/listings`, { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ ...SAMPLE, price: 899000 }) }); | |
| 155 | return ((await r.json()) as { id: number }).id; | |
| 156 | }; | |
| 157 | type Out = { price: number; was_price: number | null; assets: { kind: string; text: string }[] }; | |
| 158 | ||
| 159 | const apiId = await mk(); | |
| 160 | const patched = await fetch(`${base}/api/listings/${apiId}`, { method: "PATCH", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ facts: { price: 849000 } }) }); | |
| 161 | expect(patched.status).toBe(200); | |
| 162 | const out = (await patched.json()) as Out; | |
| 163 | expect(out.was_price).toBe(899000); | |
| 164 | expect(out.assets.find((a) => a.kind === "social_price")!.text).toContain("$899,000"); | |
| 165 | expect(out.assets.map((a) => a.kind)).toContain("email_price"); | |
| 166 | ||
| 167 | const formId = await mk(); | |
| 168 | const form = new FormData(); | |
| 169 | for (const k of ["address", "beds", "baths", "sqft", "lot", "year", "tone", "palette", "agent_name", "brokerage", "license", "agent_phone", "agent_email"] as const) | |
| 170 | form.set(k, String(SAMPLE[k])); | |
| 171 | form.set("features", SAMPLE.features.join("\n")); | |
| 172 | form.set("price", "849000"); | |
| 173 | const posted = await fetch(`${base}/listings/${formId}/facts`, { method: "POST", headers: auth, body: form, redirect: "manual" }); | |
| 174 | expect(posted.status).toBe(303); | |
| 175 | const read = (await (await fetch(`${base}/api/listings/${formId}`, { headers: auth })).json()) as Out; | |
| 176 | expect(read.was_price).toBe(899000); | |
| 177 | expect(read.assets.map((a) => a.kind)).toEqual(expect.arrayContaining(["social_price", "email_price"])); | |
| 178 | } finally { | |
| 179 | server.stop(true); | |
| 180 | } | |
| 181 | }); |
src/routes.ts +1
| @@ -210,6 +210,7 @@ const LISTING_FIELDS = { | ||
| 210 | 210 | headshot: { type: ["string", "null"] }, |
| 211 | 211 | status: { enum: [...STATUSES], description: "Where the listing stands; under_contract and sold add a status post and a status email." }, |
| 212 | 212 | sold_price: { type: ["integer", "null"], description: "Whole dollars; only a sold listing has one, and only if the agent said." }, |
| 213 | was_price: { type: ["integer", "null"], description: "Whole dollars: the price before it was lowered; set while the price stands below it, and then a price improvement post and email exist." }, | |
| 213 | 214 | created_at: { type: "string", format: "date-time" }, |
| 214 | 215 | updated_at: { type: "string", format: "date-time" }, |
| 215 | 216 | }; |
src/tty.ts +14 −4
| @@ -89,7 +89,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 89 | 89 | let previewing = false; |
| 90 | 90 | let cursor = 0; |
| 91 | 91 | /** What the one-line input is for: a new note, or an asset's new words. */ |
| 92 | let typing: { for: "note" } | { for: "asset"; kind: Kind } | { for: "open" } | { for: "status" } | undefined; | |
| 92 | let typing: { for: "note" } | { for: "asset"; kind: Kind } | { for: "open" } | { for: "status" } | { for: "price" } | undefined; | |
| 93 | 93 | let typed = ""; |
| 94 | 94 | let said = ""; |
| 95 | 95 | /** The short post over X that `a` was pressed on once: a second `a` approves it knowing so. */ |
| @@ -154,7 +154,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 154 | 154 | if (previewing) help = "esc back to the assets · q quit"; |
| 155 | 155 | else help = |
| 156 | 156 | found?.ok === true && canEditListing(me, found.value) |
| 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" | |
| 157 | ? "j/k asset · e edit · o open house · r price · s status · a approve/withdraw · u use suggested wording · t next tone · p preview email · esc back · q quit" | |
| 158 | 158 | : "j/k asset · p preview email · esc back · q quit"; |
| 159 | 159 | } else if (view === "listings") { |
| 160 | 160 | const rows = listings(); |
| @@ -186,7 +186,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 186 | 186 | } |
| 187 | 187 | lines.push(""); |
| 188 | 188 | if (typing !== undefined) { |
| 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)`; | |
| 189 | const label = typing.for === "note" ? "New note" : typing.for === "price" ? "Price in whole dollars, like 849000; a lower price adds a price improvement post and email (enter saves, esc drops)" : typing.for === "status" ? "Status: 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)`; | |
| 190 | 190 | // The tail of what is typed, so the cursor stays on the screen. |
| 191 | 191 | const room = Math.max(20, size.columns - 4); |
| 192 | 192 | lines.push(dim(label), `${typed.length > room ? `…${typed.slice(-room + 1)}` : typed}${esc}7m ${esc}0m`); |
| @@ -205,6 +205,13 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 205 | 205 | } else if (typing.for === "open") { |
| 206 | 206 | const done = updateFacts(db, me, { id: reading!, fields: { open_houses: openFromLine(typed) } }); |
| 207 | 207 | said = done.ok ? "Open house saved. Every asset is written again: read it through." : done.message; |
| 208 | } else if (typing.for === "price") { | |
| 209 | const done = updateFacts(db, me, { id: reading!, fields: { price: typed } }); | |
| 210 | said = !done.ok | |
| 211 | ? done.message | |
| 212 | : done.value.was_price === null | |
| 213 | ? "Price saved. Every asset is written again: read it through." | |
| 214 | : `Price saved, down from ${money(done.value.was_price)}. A price improvement post and email are ready to approve.`; | |
| 208 | 215 | } else if (typing.for === "status") { |
| 209 | 216 | const { status, sold_price } = statusFromLine(typed); |
| 210 | 217 | const done = setStatus(db, me, { id: reading!, status, sold_price }); |
| @@ -256,7 +263,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 256 | 263 | if (previewing) return undefined; |
| 257 | 264 | if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1)); |
| 258 | 265 | if (up) cursor = Math.max(cursor - 1, 0); |
| 259 | if (found?.ok === true && (key === "e" || key === "t" || key === "a" || key === "u" || key === "o" || key === "s")) { | |
| 266 | if (found?.ok === true && (key === "e" || key === "t" || key === "a" || key === "u" || key === "o" || key === "s" || key === "r")) { | |
| 260 | 267 | if (!canEditListing(me, found.value)) |
| 261 | 268 | said = me.role === "anon" ? "Sign in to edit this listing." : "Only the listing's agent or an admin can edit it."; |
| 262 | 269 | else if (key === "u") { |
| @@ -287,6 +294,9 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 287 | 294 | } else if (key === "s") { |
| 288 | 295 | typing = { for: "status" }; |
| 289 | 296 | typed = statusToLine(found.value); |
| 297 | } else if (key === "r") { | |
| 298 | typing = { for: "price" }; | |
| 299 | typed = String(found.value.price); | |
| 290 | 300 | } else if (key === "o") { |
| 291 | 301 | typing = { for: "open" }; |
| 292 | 302 | typed = openToLine(found.value.open_houses); |
src/walkthrough.test.ts +5 −5
| @@ -169,7 +169,7 @@ test("every step of the section works, with the labels it names", async () => { | ||
| 169 | 169 | expect((await post(`${mine}/facts`, drop)).status).toBe(303); |
| 170 | 170 | const afterDrop = await (await get(mine)).text(); |
| 171 | 171 | expect(afterDrop).toContain("$649,000"); |
| 172 | expect(afterDrop).toContain("0 of 6 approved"); | |
| 172 | expect(afterDrop).toContain("0 of 8 approved"); | |
| 173 | 173 | expect(await (await get(`${mine}/flyer`)).text()).toContain("$649,000"); |
| 174 | 174 | |
| 175 | 175 | // 7. Add an open house: Edit facts, the band on the flyer, the line in the email, approvals cleared. |
| @@ -187,7 +187,7 @@ test("every step of the section works, with the labels it names", async () => { | ||
| 187 | 187 | approveOpen.set("seen", /name="seen" value="([^"]+)"/.exec(beforeOpen)![1]!.replaceAll("&", "&")); |
| 188 | 188 | approveOpen.set("text", textareaOf(beforeOpen)); |
| 189 | 189 | await post(`${mine}/assets/mls/approve`, approveOpen); |
| 190 | expect(await (await get(mine)).text()).toContain("1 of 6 approved"); | |
| 190 | expect(await (await get(mine)).text()).toContain("1 of 8 approved"); | |
| 191 | 191 | const hold = new FormData(); |
| 192 | 192 | for (const [k, v] of drop.entries()) hold.set(k, v); |
| 193 | 193 | hold.set("tone", "bold"); |
| @@ -196,7 +196,7 @@ test("every step of the section works, with the labels it names", async () => { | ||
| 196 | 196 | hold.set("open1_end", "15:00"); |
| 197 | 197 | expect((await post(`${mine}/facts`, hold)).status).toBe(303); |
| 198 | 198 | const afterOpen = await (await get(mine)).text(); |
| 199 | expect(afterOpen).toContain("0 of 6 approved"); | |
| 199 | expect(afterOpen).toContain("0 of 8 approved"); | |
| 200 | 200 | expect(await (await get(`${mine}/flyer`)).text()).toContain("OPEN HOUSE: Sunday, March 7, 1–3 pm."); |
| 201 | 201 | expect(await (await get(`${mine}/email`)).text()).toContain("OPEN HOUSE: Sunday, March 7, 1–3 pm."); |
| 202 | 202 | const bad = new FormData(); |
| @@ -221,7 +221,7 @@ test("every step of the section works, with the labels it names", async () => { | ||
| 221 | 221 | sell.set("sold_price", "640000"); |
| 222 | 222 | expect((await post(`${mine}/status`, sell)).status).toBe(303); |
| 223 | 223 | const afterSold = await (await get(mine)).text(); |
| 224 | expect(afterSold).toContain("0 of 8 approved"); | |
| 224 | expect(afterSold).toContain("0 of 10 approved"); | |
| 225 | 225 | expect(afterSold).toContain("Sold at $640,000"); |
| 226 | 226 | expect(afterSold).toContain("Just sold — 9 Walkthrough Way"); |
| 227 | 227 | expect(await (await get(`${mine}/flyer`)).text()).toContain('<p class="flyer-status status-sold" role="note">Sold at $640,000</p>'); |
| @@ -234,7 +234,7 @@ test("every step of the section works, with the labels it names", async () => { | ||
| 234 | 234 | const active = new FormData(); |
| 235 | 235 | active.set("status", "active"); |
| 236 | 236 | expect((await post(`${mine}/status`, active)).status).toBe(303); |
| 237 | expect(await (await get(mine)).text()).toContain("0 of 6 approved"); | |
| 237 | expect(await (await get(mine)).text()).toContain("0 of 8 approved"); | |
| 238 | 238 | |
| 239 | 239 | // 9. Print the flyer. |
| 240 | 240 | expect(afterTone).toMatch(control("Print flyer")); |
The terminal cannot change the price (src/tty.ts has no price edit), so this sentence is untrue today. Add the tty price edit with a test, or drop the claim.