Public: anyone can read this; the crew can change it.
Log each showing and the buyer agent's feedback on the listing, and see the count on the home list #89
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/81-log-each-showing-and-the-buyer-agent-s-f → main 2 commits fork 2 threads · 2 unresolved
18 files
changed +945
−19
README.md +14 −1
| @@ -269,6 +269,18 @@ list cannot drift from the app. | ||
| 269 | 269 | Print it, or save it as a PDF. "Rewrite all" writes every asset again in |
| 270 | 270 | another tone (your edits are replaced, so it asks with a tooltip first). |
| 271 | 271 | |
| 272 | **Showings.** After each showing, write it down on the listing page: the day | |
| 273 | (today unless you change it; never a future one), the buyer's agent if you | |
| 274 | know them, how interested they were (Not interested, Maybe, Interested, Offer | |
| 275 | likely) and one line of what they said. It is a plain form, newest first, and | |
| 276 | "Remove" takes one back out. When the seller asks "how is it showing?", the | |
| 277 | log answers, and the home list shows "5 showings" beside the listing. The | |
| 278 | feedback gets the same Fair Housing check as anything you type, with the | |
| 279 | finding beside the line. Only you (or an admin) see the log: buyer's agents' | |
| 280 | names stay off the property page, the email and the flyer, and go when the | |
| 281 | listing does. Assistants can add one too (`POST /api/listings/:id/showings`, | |
| 282 | or `log_showing` over MCP); a refused field is named in the 422. | |
| 283 | ||
| 272 | 284 | Three sample listings are there from the start so the screen is |
| 273 | 285 | never empty; "Start a new one from this" copies any listing into the form. |
| 274 | 286 | |
| @@ -332,6 +344,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 332 | 344 | | `src/fairhousing.ts` | the Fair Housing rules: phrase, context, class, reason, rewrite | |
| 333 | 345 | | `src/photos.ts` | photos as files beside the database, known by their bytes | |
| 334 | 346 | | `src/sample.ts` | the sample listing, planted once | |
| 347 | | `src/showings.ts` | the showings the agent logged: the boundary for a day, a buyer's agent, an interest and a line | | |
| 335 | 348 | | `src/visitors.ts` | the open house sign-in sheet's visitors: the boundary for a name and an email | |
| 336 | 349 | | `contract.json` | who may do what: roles, and the least role each verb needs | |
| 337 | 350 | | `src/contract.ts`, `src/can.ts` | the table, read at boot; the forge's evaluator, verbatim | |
| @@ -445,7 +458,7 @@ listing with `enter` to read every asset with its Fair Housing findings; | ||
| 445 | 458 | `j`/`k` move between assets, `e` edits the one under the cursor (type `\n` |
| 446 | 459 | for a line break, `enter` saves and checks it again), `t` rewrites them all |
| 447 | 460 | in the next tone, `s` sets the status (`sold 752000`, `under contract`, `coming soon`, |
| 448 | `withdrawn`, `expired`, `active`), `p` previews the email as the client reads it, `P` the open house follow-up with the sign-in visitors it would go to, `V` lists those visitors (`a` adds one as `Name, email`, `d` removes the one under the cursor; only the listing's agent or an admin, a guest sees none), `x` removes a listing of your own. `tab` switches to the notes. Launching a listing, with its photos, is the page's alone. | |
| 461 | `withdrawn`, `expired`, `active`), `p` previews the email as the client reads it, `P` the open house follow-up with the sign-in visitors it would go to, `V` lists those visitors (`a` adds one as `Name, email`, `d` removes the one under the cursor; only the listing's agent or an admin, a guest sees none), `S` lists the showings you logged (`a` logs one as `date; buyer's agent; interest; what they said`, with the date empty for today and the interest as not interested, maybe, interested or offer likely; `d` removes the one under the cursor; only the listing's agent or an admin, a guest sees none), `x` removes a listing of your own. `tab` switches to the notes. Launching a listing, with its photos, is the page's alone. | |
| 449 | 462 | |
| 450 | 463 | No runtime dependencies: Bun's own `bun:sqlite`, `Bun.serve` and `node:` |
| 451 | 464 | modules. The typefaces are the system's (a Palatino/Iowan serif for |
contract/openapi.json +203 −3
| @@ -577,7 +577,7 @@ | ||
| 577 | 577 | }, |
| 578 | 578 | "/api/listings/{id}": { |
| 579 | 579 | "get": { |
| 580 | "summary": "One listing, with its assets and each one's Fair Housing check.", | |
| 580 | "summary": "One listing, with its assets and each one's Fair Housing check; for the listing's agent or an admin, also its logged showings, newest first.", | |
| 581 | 581 | "operationId": "getApiListingsId", |
| 582 | 582 | "parameters": [ |
| 583 | 583 | { |
| @@ -591,7 +591,7 @@ | ||
| 591 | 591 | ], |
| 592 | 592 | "responses": { |
| 593 | 593 | "200": { |
| 594 | "description": "One listing, with its assets and each one's Fair Housing check.", | |
| 594 | "description": "One listing, with its assets and each one's Fair Housing check; for the listing's agent or an admin, also its logged showings, newest first.", | |
| 595 | 595 | "content": { |
| 596 | 596 | "application/json": { |
| 597 | 597 | "schema": { |
| @@ -921,6 +921,157 @@ | ||
| 921 | 921 | } |
| 922 | 922 | } |
| 923 | 923 | }, |
| 924 | "/api/listings/{id}/showings": { | |
| 925 | "post": { | |
| 926 | "summary": "Log a showing: the day, the buyer's agent, how interested they were and one line of feedback. Only the listing's agent or an admin; a refused field is named in the 422.", | |
| 927 | "operationId": "postApiListingsIdShowings", | |
| 928 | "parameters": [ | |
| 929 | { | |
| 930 | "name": "id", | |
| 931 | "in": "path", | |
| 932 | "required": true, | |
| 933 | "schema": { | |
| 934 | "type": "integer" | |
| 935 | } | |
| 936 | } | |
| 937 | ], | |
| 938 | "requestBody": { | |
| 939 | "required": true, | |
| 940 | "content": { | |
| 941 | "application/json": { | |
| 942 | "schema": { | |
| 943 | "type": "object", | |
| 944 | "properties": { | |
| 945 | "date": { | |
| 946 | "type": "string", | |
| 947 | "description": "YYYY-MM-DD, not in the future; today when left out." | |
| 948 | }, | |
| 949 | "agent": { | |
| 950 | "type": "string", | |
| 951 | "maxLength": 80, | |
| 952 | "description": "The buyer's agent, optional." | |
| 953 | }, | |
| 954 | "interest": { | |
| 955 | "enum": [ | |
| 956 | "not_interested", | |
| 957 | "maybe", | |
| 958 | "interested", | |
| 959 | "offer_likely" | |
| 960 | ] | |
| 961 | }, | |
| 962 | "feedback": { | |
| 963 | "type": "string", | |
| 964 | "maxLength": 280, | |
| 965 | "description": "One line, plain text." | |
| 966 | } | |
| 967 | }, | |
| 968 | "required": [ | |
| 969 | "interest" | |
| 970 | ] | |
| 971 | } | |
| 972 | } | |
| 973 | } | |
| 974 | }, | |
| 975 | "responses": { | |
| 976 | "201": { | |
| 977 | "description": "Log a showing: the day, the buyer's agent, how interested they were and one line of feedback. Only the listing's agent or an admin; a refused field is named in the 422.", | |
| 978 | "content": { | |
| 979 | "application/json": { | |
| 980 | "schema": { | |
| 981 | "type": "object", | |
| 982 | "properties": { | |
| 983 | "id": { | |
| 984 | "type": "integer" | |
| 985 | }, | |
| 986 | "showing": { | |
| 987 | "type": "integer" | |
| 988 | } | |
| 989 | }, | |
| 990 | "required": [ | |
| 991 | "id", | |
| 992 | "showing" | |
| 993 | ] | |
| 994 | } | |
| 995 | } | |
| 996 | } | |
| 997 | }, | |
| 998 | "default": { | |
| 999 | "description": "{ error, message }: why not", | |
| 1000 | "content": { | |
| 1001 | "application/json": { | |
| 1002 | "schema": { | |
| 1003 | "$ref": "#/components/schemas/Error" | |
| 1004 | } | |
| 1005 | } | |
| 1006 | } | |
| 1007 | } | |
| 1008 | }, | |
| 1009 | "x-contract": { | |
| 1010 | "resource": "listing", | |
| 1011 | "verb": "update", | |
| 1012 | "least": "author" | |
| 1013 | } | |
| 1014 | } | |
| 1015 | }, | |
| 1016 | "/api/listings/{id}/showings/{showing}": { | |
| 1017 | "delete": { | |
| 1018 | "summary": "Remove a showing the agent logged.", | |
| 1019 | "operationId": "deleteApiListingsIdShowingsShowing", | |
| 1020 | "parameters": [ | |
| 1021 | { | |
| 1022 | "name": "id", | |
| 1023 | "in": "path", | |
| 1024 | "required": true, | |
| 1025 | "schema": { | |
| 1026 | "type": "integer" | |
| 1027 | } | |
| 1028 | }, | |
| 1029 | { | |
| 1030 | "name": "showing", | |
| 1031 | "in": "path", | |
| 1032 | "required": true, | |
| 1033 | "schema": { | |
| 1034 | "type": "integer" | |
| 1035 | } | |
| 1036 | } | |
| 1037 | ], | |
| 1038 | "responses": { | |
| 1039 | "200": { | |
| 1040 | "description": "Remove a showing the agent logged.", | |
| 1041 | "content": { | |
| 1042 | "application/json": { | |
| 1043 | "schema": { | |
| 1044 | "type": "object", | |
| 1045 | "properties": { | |
| 1046 | "id": { | |
| 1047 | "type": "integer" | |
| 1048 | } | |
| 1049 | }, | |
| 1050 | "required": [ | |
| 1051 | "id" | |
| 1052 | ] | |
| 1053 | } | |
| 1054 | } | |
| 1055 | } | |
| 1056 | }, | |
| 1057 | "default": { | |
| 1058 | "description": "{ error, message }: why not", | |
| 1059 | "content": { | |
| 1060 | "application/json": { | |
| 1061 | "schema": { | |
| 1062 | "$ref": "#/components/schemas/Error" | |
| 1063 | } | |
| 1064 | } | |
| 1065 | } | |
| 1066 | } | |
| 1067 | }, | |
| 1068 | "x-contract": { | |
| 1069 | "resource": "listing", | |
| 1070 | "verb": "update", | |
| 1071 | "least": "author" | |
| 1072 | } | |
| 1073 | } | |
| 1074 | }, | |
| 924 | 1075 | "/api/audit": { |
| 925 | 1076 | "get": { |
| 926 | 1077 | "summary": "The audit trail, newest first; `before` pages back by id.", |
| @@ -1329,6 +1480,50 @@ | ||
| 1329 | 1480 | "type": "string", |
| 1330 | 1481 | "format": "date-time" |
| 1331 | 1482 | }, |
| 1483 | "showings": { | |
| 1484 | "type": "array", | |
| 1485 | "description": "The logged showings, newest first; only for the listing's agent or an admin.", | |
| 1486 | "items": { | |
| 1487 | "type": "object", | |
| 1488 | "properties": { | |
| 1489 | "id": { | |
| 1490 | "type": "integer" | |
| 1491 | }, | |
| 1492 | "date": { | |
| 1493 | "type": "string", | |
| 1494 | "description": "YYYY-MM-DD." | |
| 1495 | }, | |
| 1496 | "agent": { | |
| 1497 | "type": "string", | |
| 1498 | "maxLength": 80 | |
| 1499 | }, | |
| 1500 | "interest": { | |
| 1501 | "enum": [ | |
| 1502 | "not_interested", | |
| 1503 | "maybe", | |
| 1504 | "interested", | |
| 1505 | "offer_likely" | |
| 1506 | ] | |
| 1507 | }, | |
| 1508 | "feedback": { | |
| 1509 | "type": "string", | |
| 1510 | "maxLength": 280 | |
| 1511 | }, | |
| 1512 | "check": { | |
| 1513 | "type": "object", | |
| 1514 | "description": "The Fair Housing check on the feedback." | |
| 1515 | } | |
| 1516 | }, | |
| 1517 | "required": [ | |
| 1518 | "id", | |
| 1519 | "date", | |
| 1520 | "agent", | |
| 1521 | "interest", | |
| 1522 | "feedback", | |
| 1523 | "check" | |
| 1524 | ] | |
| 1525 | } | |
| 1526 | }, | |
| 1332 | 1527 | "assets": { |
| 1333 | 1528 | "type": "array", |
| 1334 | 1529 | "items": { |
| @@ -1677,6 +1872,10 @@ | ||
| 1677 | 1872 | "type": "integer", |
| 1678 | 1873 | "description": "Phrases the Fair Housing check flags across every asset." |
| 1679 | 1874 | }, |
| 1875 | "showings": { | |
| 1876 | "type": "integer", | |
| 1877 | "description": "How many showings the agent logged; 0 for anyone who may not update the listing." | |
| 1878 | }, | |
| 1680 | 1879 | "approvals": { |
| 1681 | 1880 | "type": "array", |
| 1682 | 1881 | "description": "Each asset and whether somebody has signed off its words.", |
| @@ -1740,7 +1939,8 @@ | ||
| 1740 | 1939 | "created_at", |
| 1741 | 1940 | "updated_at", |
| 1742 | 1941 | "flags", |
| 1743 | "approvals" | |
| 1942 | "approvals", | |
| 1943 | "showings" | |
| 1744 | 1944 | ] |
| 1745 | 1945 | }, |
| 1746 | 1946 | "AuditRow": { |
src/actions.ts +69 −2
| @@ -50,6 +50,7 @@ import { | ||
| 50 | 50 | } from "./photos.ts"; |
| 51 | 51 | import { headshotFrom } from "./agent.ts"; |
| 52 | 52 | import { sellerUpdate } from "./update.ts"; |
| 53 | import { validShowing, SHOWINGS_MAX, type Showing } from "./showings.ts"; | |
| 53 | 54 | import { validVisitor, VISITORS_MAX, type Visitor } from "./visitors.ts"; |
| 54 | 55 | import type { Who } from "./who.ts"; |
| 55 | 56 | |
| @@ -72,12 +73,13 @@ export type Failure = | ||
| 72 | 73 | | "duplicate_address"; |
| 73 | 74 | export type Result<T> = |
| 74 | 75 | | { ok: true; value: T } |
| 75 | | { ok: false; error: Failure; message: string; existing?: Earlier }; | |
| 76 | | { ok: false; error: Failure; message: string; existing?: Earlier; field?: string }; | |
| 76 | 77 | const ok = <T>(value: T): Result<T> => ({ ok: true, value }); |
| 77 | const fail = <T>(error: Failure, message: string): Result<T> => ({ | |
| 78 | const fail = <T>(error: Failure, message: string, field?: string): Result<T> => ({ | |
| 78 | 79 | ok: false, |
| 79 | 80 | error, |
| 80 | 81 | message, |
| 82 | ...(field === undefined ? {} : { field }), | |
| 81 | 83 | }); |
| 82 | 84 | |
| 83 | 85 | export const BODY_MAX = 500; |
| @@ -551,6 +553,8 @@ export function markStatus( | ||
| 551 | 553 | export type ListingSummary = Listing & { |
| 552 | 554 | flags: number; |
| 553 | 555 | approvals: { kind: string; approved: boolean }[]; |
| 556 | /** How many showings the agent logged; counted only for whoever may update the listing, else 0. */ | |
| 557 | showings: number; | |
| 554 | 558 | }; |
| 555 | 559 | |
| 556 | 560 | export function listListings( |
| @@ -583,6 +587,7 @@ export function listListings( | ||
| 583 | 587 | const bodies = db.query<{ kind: string; body: string; approved_at: string | null }, [number]>( |
| 584 | 588 | "SELECT kind, body, approved_at FROM asset WHERE listing_id = ?1 ORDER BY rowid", |
| 585 | 589 | ); |
| 590 | const logged = db.query<{ n: number }, [number]>("SELECT count(*) AS n FROM showing WHERE listing_id = ?1"); | |
| 586 | 591 | return ok( |
| 587 | 592 | rows |
| 588 | 593 | .filter((row) => matchesText(row, wanted.q)) |
| @@ -590,6 +595,7 @@ export function listListings( | ||
| 590 | 595 | const assets = bodies.all(row.id); |
| 591 | 596 | return { |
| 592 | 597 | ...fromRow(row), |
| 598 | showings: permit(who, "listing", "update", row) === "yes" ? logged.get(row.id)!.n : 0, | |
| 593 | 599 | flags: assets.reduce((n, a) => n + check(a.body).findings.length, 0), |
| 594 | 600 | approvals: assets.map((a) => ({ kind: a.kind, approved: a.approved_at !== null })), |
| 595 | 601 | }; |
| @@ -1354,6 +1360,67 @@ function dropVisitor(db: Database, who: Who, input: { id: number; visitor: numbe | ||
| 1354 | 1360 | return ok({ id: found.value.id }); |
| 1355 | 1361 | } |
| 1356 | 1362 | |
| 1363 | /* | |
| 1364 | * Showings. After each one the agent writes down who came and what they said. | |
| 1365 | * Third-party names: read and changed only through the listing's update door | |
| 1366 | * (its author or an admin), never with the listing, and never on the public | |
| 1367 | * property page, the email or the flyer. | |
| 1368 | */ | |
| 1369 | ||
| 1370 | /** A logged showing with the Fair Housing check run on its feedback, as typed words are. */ | |
| 1371 | export type LoggedShowing = Showing & { check: Check }; | |
| 1372 | ||
| 1373 | const showingsFor = (db: Database, who: Who, id: number, signIn: string): Result<Launched> => | |
| 1374 | listingFor(db, who, "update", id, { signIn, no: "Only the listing's agent or an admin can see the showings." }); | |
| 1375 | ||
| 1376 | export function listShowings(db: Database, who: Who, input: { id: number }): Result<LoggedShowing[]> { | |
| 1377 | const found = showingsFor(db, who, input.id, "Sign in to see the showings."); | |
| 1378 | if (!found.ok) return found; | |
| 1379 | return ok( | |
| 1380 | db | |
| 1381 | .query<Showing, [number]>("SELECT id, date, agent, interest, feedback FROM showing WHERE listing_id = ?1 ORDER BY date DESC, id DESC") | |
| 1382 | .all(found.value.id) | |
| 1383 | .map((s) => ({ ...s, check: check(s.feedback) })), | |
| 1384 | ); | |
| 1385 | } | |
| 1386 | ||
| 1387 | type ShowingInput = { id: number; date?: unknown; agent?: unknown; interest?: unknown; feedback?: unknown; now?: Date }; | |
| 1388 | ||
| 1389 | export const addShowing = (db: Database, who: Who, input: ShowingInput): Result<{ id: number; showing: number }> => | |
| 1390 | audited(db, who, "listing", "update", input.id, putShowing(db, who, input)); | |
| 1391 | ||
| 1392 | function putShowing(db: Database, who: Who, input: ShowingInput): Result<{ id: number; showing: number }> { | |
| 1393 | const found = showingsFor(db, who, input.id, "Sign in to log a showing."); | |
| 1394 | if (!found.ok) return found; | |
| 1395 | const valid = validShowing(input, input.now); | |
| 1396 | if (!valid.ok) return fail("invalid", valid.message, valid.field); | |
| 1397 | const have = db.query<{ n: number }, [number]>("SELECT count(*) AS n FROM showing WHERE listing_id = ?1").get(found.value.id)!.n; | |
| 1398 | if (have >= SHOWINGS_MAX) return fail("invalid", `At most ${SHOWINGS_MAX} showings on one listing.`); | |
| 1399 | const v = valid.value; | |
| 1400 | const row = db | |
| 1401 | .query<{ id: number }, [number, string, string, string, string]>( | |
| 1402 | "INSERT INTO showing (listing_id, date, agent, interest, feedback) VALUES (?1, ?2, ?3, ?4, ?5) RETURNING id", | |
| 1403 | ) | |
| 1404 | .get(found.value.id, v.date, v.agent, v.interest, v.feedback)!; | |
| 1405 | emit(db, "listing.changed", { id: found.value.id }); | |
| 1406 | return ok({ id: found.value.id, showing: row.id }); | |
| 1407 | } | |
| 1408 | ||
| 1409 | export const removeShowing = (db: Database, who: Who, input: { id: number; showing: number }): Result<{ id: number }> => | |
| 1410 | audited(db, who, "listing", "update", input.id, dropShowing(db, who, input)); | |
| 1411 | ||
| 1412 | function dropShowing(db: Database, who: Who, input: { id: number; showing: number }): Result<{ id: number }> { | |
| 1413 | const found = showingsFor(db, who, input.id, "Sign in to remove a showing."); | |
| 1414 | if (!found.ok) return found; | |
| 1415 | const gone = db.query("DELETE FROM showing WHERE id = ?1 AND listing_id = ?2").run( | |
| 1416 | Number.isInteger(input.showing) ? input.showing : -1, | |
| 1417 | found.value.id, | |
| 1418 | ); | |
| 1419 | if (gone.changes === 0) return fail("not_found", "No such showing."); | |
| 1420 | emit(db, "listing.changed", { id: found.value.id }); | |
| 1421 | return ok({ id: found.value.id }); | |
| 1422 | } | |
| 1423 | ||
| 1357 | 1424 | /** The Fair Housing check on any words: no row, no door, nothing kept. */ |
| 1358 | 1425 | export const checkWords = (text: string): Check => |
| 1359 | 1426 | check(text.slice(0, ASSET_MAX)); |
src/contract.test.ts +37
| @@ -20,6 +20,9 @@ import { | ||
| 20 | 20 | getNote, |
| 21 | 21 | insertListing, |
| 22 | 22 | launchListing, |
| 23 | addShowing, | |
| 24 | listShowings, | |
| 25 | removeShowing, | |
| 23 | 26 | listVersions, |
| 24 | 27 | restoreAsset, |
| 25 | 28 | updatePhotos, |
| @@ -312,3 +315,37 @@ test("restoring an earlier version answers as the listing's update cell says, at | ||
| 312 | 315 | } |
| 313 | 316 | expect(asked).toBeGreaterThan(0); |
| 314 | 317 | }); |
| 318 | ||
| 319 | /** Showings are `listing:update`: the actions and the tool let through exactly whom the table's update cell does. */ | |
| 320 | test("logging, listing and removing a showing answers as the listing's update cell says, at the actions and the tool", () => { | |
| 321 | const root = { role: "admin" as const, id: "root", name: "root" }; | |
| 322 | let asked = 0; | |
| 323 | for (const cell of table(contract)) { | |
| 324 | if (cell.resource !== "listing" || cell.verb !== "update") continue; | |
| 325 | const who = whoIs(cell.as); | |
| 326 | const { mine, theirs } = actingAs(contract, cell.as); | |
| 327 | const attempts = (authorId: string) => { | |
| 328 | const id = listingBy(authorId); | |
| 329 | const planted = addShowing(db, root, { id, interest: "maybe" }); | |
| 330 | const showing = planted.ok ? planted.value.showing : -1; | |
| 331 | const tool = !JSON.stringify( | |
| 332 | rpc(db, who, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "log_showing", arguments: { id, interest: "maybe" } } }).body, | |
| 333 | ).includes('"isError":true'); | |
| 334 | return [ | |
| 335 | addShowing(db, who, { id, interest: "maybe" }).ok, | |
| 336 | tool, | |
| 337 | listShowings(db, who, { id }).ok, | |
| 338 | removeShowing(db, who, { id, showing }).ok, | |
| 339 | ]; | |
| 340 | }; | |
| 341 | const onMine = attempts(String(mine["author_id"])); | |
| 342 | const onTheirs = attempts(String(theirs["author_id"])); | |
| 343 | const said = `showings as ${cell.as}`; | |
| 344 | asked++; | |
| 345 | const all = (v: boolean) => [v, v, v, v]; | |
| 346 | if (cell.answer === "yes") expect([said, onMine, onTheirs]).toEqual([said, all(true), all(true)]); | |
| 347 | else if (cell.answer === "no") expect([said, onMine, onTheirs]).toEqual([said, all(false), all(false)]); | |
| 348 | else expect([said, onMine, onTheirs]).toEqual([said, all(true), all(false)]); | |
| 349 | } | |
| 350 | expect(asked).toBeGreaterThan(0); | |
| 351 | }); |
src/db.ts +12
| @@ -224,6 +224,18 @@ export const migrations: string[] = [ | ||
| 224 | 224 | DROP TABLE asset_version_old; |
| 225 | 225 | DROP TABLE asset_old; |
| 226 | 226 | DROP TABLE listing_old;`, |
| 227 | // Each showing the agent logged: the day, the buyer's agent, how interested, one line of feedback. Third-party | |
| 228 | // names, read only through the listing's update door, never with the listing. They go with their listing. | |
| 229 | `CREATE TABLE IF NOT EXISTS showing ( | |
| 230 | id INTEGER PRIMARY KEY, | |
| 231 | listing_id INTEGER NOT NULL REFERENCES listing (id) ON DELETE CASCADE, | |
| 232 | date TEXT NOT NULL CHECK (length(date) = 10), | |
| 233 | agent TEXT NOT NULL CHECK (length(agent) <= 80), | |
| 234 | interest TEXT NOT NULL CHECK (interest IN ('not_interested', 'maybe', 'interested', 'offer_likely')), | |
| 235 | feedback TEXT NOT NULL CHECK (length(feedback) <= 280), | |
| 236 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) | |
| 237 | ); | |
| 238 | CREATE INDEX IF NOT EXISTS showing_listing ON showing (listing_id, date, id)`, | |
| 227 | 239 | ]; |
| 228 | 240 | |
| 229 | 241 | export function openDb(dir = dataDir): Database { |
src/mcp.ts +20
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | 10 | import { FILTERS, Q_MAX } from "./find.ts"; |
| 11 | 11 | import { |
| 12 | addShowing, | |
| 12 | 13 | addNote, |
| 13 | 14 | approveAsset, |
| 14 | 15 | checkWords, |
| @@ -29,6 +30,7 @@ import { | ||
| 29 | 30 | updatePhotos, |
| 30 | 31 | type Result, |
| 31 | 32 | } from "./actions.ts"; |
| 33 | import { INTERESTS, SHOWING_AGENT_MAX, SHOWING_FEEDBACK_MAX } from "./showings.ts"; | |
| 32 | 34 | import { KINDS, STATUSES, TONES } from "./generate.ts"; |
| 33 | 35 | import { title } from "./env.ts"; |
| 34 | 36 | import { fields } from "./http.ts"; |
| @@ -121,6 +123,24 @@ export const tools: readonly Tool[] = [ | ||
| 121 | 123 | }, |
| 122 | 124 | run: (db, who, args) => getListing(db, who, { id: Number(args.id) }), |
| 123 | 125 | }, |
| 126 | { | |
| 127 | name: "log_showing", | |
| 128 | description: | |
| 129 | "Log a showing on a listing: the day (YYYY-MM-DD, not in the future; today when left out), the buyer's agent (optional, up to 80 characters), how interested the buyers were and one line of what they said (up to 280 characters, plain text, Fair Housing checked when read). Only the listing's agent or an admin can; showings never reach the property page, the email or the flyer.", | |
| 130 | inputSchema: { | |
| 131 | type: "object", | |
| 132 | properties: { | |
| 133 | id: { type: "integer" }, | |
| 134 | date: { type: "string", description: "YYYY-MM-DD." }, | |
| 135 | agent: { type: "string", maxLength: SHOWING_AGENT_MAX }, | |
| 136 | interest: { enum: [...INTERESTS] }, | |
| 137 | feedback: { type: "string", maxLength: SHOWING_FEEDBACK_MAX }, | |
| 138 | }, | |
| 139 | required: ["id", "interest"], | |
| 140 | }, | |
| 141 | run: (db, who, args) => | |
| 142 | addShowing(db, who, { id: Number(args.id), date: args.date, agent: args.agent, interest: args.interest, feedback: args.feedback }), | |
| 143 | }, | |
| 124 | 144 | { |
| 125 | 145 | name: "launch_kit", |
| 126 | 146 | description: |
src/pages/home.ts +1 −1
| @@ -236,7 +236,7 @@ function list(listings: readonly ListingSummary[], wanted: Find): string { | ||
| 236 | 236 | <span class="card-body"> |
| 237 | 237 | <b>${escape(l.address)}</b> |
| 238 | 238 | <span>${money(l.price)} · ${l.beds} bd · ${l.baths} ba · ${count(l.sqft)} sq ft</span> |
| 239 | <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> | |
| 239 | <span class="row card-meta">${approvalPill(l.approvals.filter((a) => a.approved).length, l.approvals.length)}${statusPill(l)}${status(l.flags)}${l.showings === 0 ? "" : `<span class="pill">${l.showings} ${l.showings === 1 ? "showing" : "showings"}</span>`}<small class="muted">${escape(l.author_name)} · ${ago(l.updated_at)}</small></span> | |
| 240 | 240 | </span> |
| 241 | 241 | </a> |
| 242 | 242 | </li>`; |
src/pages/listing.ts +6 −1
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | * flyer, which is also a page of its own for printing (`flyerPage`). |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import type { Version } from "../actions.ts"; | |
| 7 | import type { LoggedShowing, Version } from "../actions.ts"; | |
| 8 | 8 | import type { Check, Finding } from "../fairhousing.ts"; |
| 9 | 9 | import { classHref } from "./fairhousing.ts"; |
| 10 | 10 | import { |
| @@ -24,7 +24,9 @@ import { altOf, approvalLabel, ASSET_MAX, propertyPath, type Asset, type Launche | ||
| 24 | 24 | import { isPost, lengthLine, meantForX, overXNote } from "../postlength.ts"; |
| 25 | 25 | import { isSample } from "../photos.ts"; |
| 26 | 26 | import type { Who } from "../who.ts"; |
| 27 | import { dayOf } from "../showings.ts"; | |
| 27 | 28 | import { ago, layout, toast } from "./layout.ts"; |
| 29 | import { showingsSection } from "./showings.ts"; | |
| 28 | 30 | |
| 29 | 31 | /** Where a listing's photo is served: a sample from static/, an upload from its listing. */ |
| 30 | 32 | export const photoUrl = (listing: Pick<Listing, "id">, name: string): string => |
| @@ -211,6 +213,8 @@ export function listingPage(input: { | ||
| 211 | 213 | versions?: Readonly<Record<string, readonly Version[]>>; |
| 212 | 214 | tab?: string | null; |
| 213 | 215 | notice?: string | undefined; |
| 216 | /** The logged showings: given only to whoever may update the listing. */ | |
| 217 | showings?: readonly LoggedShowing[]; | |
| 214 | 218 | }): string { |
| 215 | 219 | const { who, listing: l, editable, notice } = input; |
| 216 | 220 | const origin = input.origin ?? ""; |
| @@ -274,6 +278,7 @@ export function listingPage(input: { | ||
| 274 | 278 | </div>`, |
| 275 | 279 | ).join("")} |
| 276 | 280 | </div> |
| 281 | ${input.showings === undefined ? "" : showingsSection(l.id, input.showings, dayOf(new Date()))} | |
| 277 | 282 | ${ |
| 278 | 283 | editable |
| 279 | 284 | ? `<form class="danger-zone" method="post" action="/listings/${l.id}/delete"> |
src/pages/showings.ts added +43
| @@ -0,0 +1,43 @@ | ||
| 1 | /** | |
| 2 | * The Showings section of the agent's listing page: a form to log one and the | |
| 3 | * log, newest first, each line Fair Housing checked like typed words. Only the | |
| 4 | * listing's agent or an admin is ever handed these; the property page, the | |
| 5 | * email and the flyer never see them. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { LoggedShowing } from "../actions.ts"; | |
| 9 | import { escape } from "../http.ts"; | |
| 10 | import { INTEREST_LABELS, INTERESTS, SHOWING_AGENT_MAX, SHOWING_FEEDBACK_MAX } from "../showings.ts"; | |
| 11 | ||
| 12 | const day = (date: string): string => | |
| 13 | new Date(`${date}T00:00:00Z`).toLocaleDateString("en", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); | |
| 14 | ||
| 15 | function entry(listingId: number, s: LoggedShowing): string { | |
| 16 | const flagged = s.check.findings; | |
| 17 | return `<li class="showing"> | |
| 18 | <p><b>${escape(day(s.date))}</b> · ${escape(INTEREST_LABELS[s.interest])}${s.agent === "" ? "" : ` · ${escape(s.agent)}`}</p> | |
| 19 | ${s.feedback === "" ? "" : `<p>${escape(s.feedback)}</p>`} | |
| 20 | ${flagged.length === 0 ? "" : `<ul class="fh-list" aria-label="Phrases to review">${flagged.map((f) => `<li><span class="fh-tag">${f.level === "note" ? "note" : escape(f.touches)}</span> ${escape(f.line)}</li>`).join("")}</ul>`} | |
| 21 | <form method="post" action="/listings/${listingId}/showings/${s.id}/delete"> | |
| 22 | <button class="button button-quiet" aria-label="Remove the showing on ${escape(day(s.date))}">Remove</button> | |
| 23 | </form> | |
| 24 | </li>`; | |
| 25 | } | |
| 26 | ||
| 27 | export function showingsSection(listingId: number, showings: readonly LoggedShowing[], today: string): string { | |
| 28 | return `<section class="showings" id="showings" aria-labelledby="showings-title"> | |
| 29 | <h3 id="showings-title">Showings <small class="muted">${showings.length === 0 ? "none yet" : `${showings.length} logged`} · only you see these</small></h3> | |
| 30 | <form class="row" method="post" action="/listings/${listingId}/showings"> | |
| 31 | <label class="sr" for="showing-date">Date</label> | |
| 32 | <input class="input" id="showing-date" type="date" name="date" value="${escape(today)}" max="${escape(today)}" required> | |
| 33 | <label class="sr" for="showing-agent">Buyer's agent, optional</label> | |
| 34 | <input class="input" id="showing-agent" name="agent" maxlength="${SHOWING_AGENT_MAX}" placeholder="Buyer's agent (optional)" autocomplete="off"> | |
| 35 | <label class="sr" for="showing-interest">Interest</label> | |
| 36 | <select class="input select" id="showing-interest" name="interest">${INTERESTS.map((i) => `<option value="${i}"${i === "maybe" ? " selected" : ""}>${INTEREST_LABELS[i]}</option>`).join("")}</select> | |
| 37 | <label class="sr" for="showing-feedback">What they said</label> | |
| 38 | <input class="input" id="showing-feedback" name="feedback" maxlength="${SHOWING_FEEDBACK_MAX}" placeholder="What they said, one line" autocomplete="off"> | |
| 39 | <button class="button">Log showing</button> | |
| 40 | </form> | |
| 41 | ${showings.length === 0 ? "" : `<ul class="showing-list">${showings.map((s) => entry(listingId, s)).join("")}</ul>`} | |
| 42 | </section>`; | |
| 43 | } |
src/routes.ts +97 −3
| @@ -24,9 +24,12 @@ import { | ||
| 24 | 24 | editAsset, |
| 25 | 25 | getAudit, |
| 26 | 26 | getHome, |
| 27 | addShowing, | |
| 27 | 28 | addVisitor, |
| 28 | 29 | getListing, |
| 30 | listShowings, | |
| 29 | 31 | listVisitors, |
| 32 | removeShowing, | |
| 30 | 33 | removeVisitor, |
| 31 | 34 | getNote, |
| 32 | 35 | launchListing, |
| @@ -72,6 +75,7 @@ import { draftOf, home, type Draft } from "./pages/home.ts"; | ||
| 72 | 75 | import { layout } from "./pages/layout.ts"; |
| 73 | 76 | import { fairHousingPage } from "./pages/fairhousing.ts"; |
| 74 | 77 | import { factsPage } from "./pages/facts.ts"; |
| 78 | import { INTERESTS, SHOWING_AGENT_MAX, SHOWING_FEEDBACK_MAX } from "./showings.ts"; | |
| 75 | 79 | import { emailPage } from "./pages/email.ts"; |
| 76 | 80 | import { publicOrigin } from "./env.ts"; |
| 77 | 81 | import { propertyPage } from "./pages/property.ts"; |
| @@ -242,6 +246,10 @@ export const LISTING_SUMMARY: Schema = { | ||
| 242 | 246 | type: "integer", |
| 243 | 247 | description: "Phrases the Fair Housing check flags across every asset.", |
| 244 | 248 | }, |
| 249 | showings: { | |
| 250 | type: "integer", | |
| 251 | description: "How many showings the agent logged; 0 for anyone who may not update the listing.", | |
| 252 | }, | |
| 245 | 253 | approvals: { |
| 246 | 254 | type: "array", |
| 247 | 255 | description: "Each asset and whether somebody has signed off its words.", |
| @@ -252,12 +260,39 @@ export const LISTING_SUMMARY: Schema = { | ||
| 252 | 260 | }, |
| 253 | 261 | }, |
| 254 | 262 | }, |
| 255 | required: [...LISTING_REQUIRED, "flags", "approvals"], | |
| 263 | required: [...LISTING_REQUIRED, "flags", "approvals", "showings"], | |
| 264 | }; | |
| 265 | const SHOWING: Schema = { | |
| 266 | type: "object", | |
| 267 | properties: { | |
| 268 | id: { type: "integer" }, | |
| 269 | date: { type: "string", description: "YYYY-MM-DD." }, | |
| 270 | agent: { type: "string", maxLength: SHOWING_AGENT_MAX }, | |
| 271 | interest: { enum: [...INTERESTS] }, | |
| 272 | feedback: { type: "string", maxLength: SHOWING_FEEDBACK_MAX }, | |
| 273 | check: { type: "object", description: "The Fair Housing check on the feedback." }, | |
| 274 | }, | |
| 275 | required: ["id", "date", "agent", "interest", "feedback", "check"], | |
| 276 | }; | |
| 277 | const SHOWING_BODY: Schema = { | |
| 278 | type: "object", | |
| 279 | properties: { | |
| 280 | date: { type: "string", description: "YYYY-MM-DD, not in the future; today when left out." }, | |
| 281 | agent: { type: "string", maxLength: SHOWING_AGENT_MAX, description: "The buyer's agent, optional." }, | |
| 282 | interest: { enum: [...INTERESTS] }, | |
| 283 | feedback: { type: "string", maxLength: SHOWING_FEEDBACK_MAX, description: "One line, plain text." }, | |
| 284 | }, | |
| 285 | required: ["interest"], | |
| 256 | 286 | }; |
| 257 | 287 | export const LISTING: Schema = { |
| 258 | 288 | type: "object", |
| 259 | 289 | properties: { |
| 260 | 290 | ...LISTING_FIELDS, |
| 291 | showings: { | |
| 292 | type: "array", | |
| 293 | description: "The logged showings, newest first; only for the listing's agent or an admin.", | |
| 294 | items: SHOWING, | |
| 295 | }, | |
| 261 | 296 | assets: { |
| 262 | 297 | type: "array", |
| 263 | 298 | items: { |
| @@ -360,6 +395,7 @@ const answer = <T>(result: Result<T>, status = 200): Response => | ||
| 360 | 395 | { |
| 361 | 396 | error: result.error, |
| 362 | 397 | message: result.message, |
| 398 | ...(result.field === undefined ? {} : { field: result.field }), | |
| 363 | 399 | ...(result.existing === undefined ? {} : { id: result.existing.id }), |
| 364 | 400 | }, |
| 365 | 401 | STATUS[result.error], |
| @@ -505,8 +541,10 @@ export const routes: readonly Route[] = [ | ||
| 505 | 541 | if (!found.ok) return page(c.who, found, () => ""); |
| 506 | 542 | const earlier = listVersions(c.db, c.who, { id: Number(c.params.id) }); |
| 507 | 543 | const versions = earlier.ok ? earlier.value : {}; |
| 544 | const showings = listShowings(c.db, c.who, { id: Number(c.params.id) }); | |
| 508 | 545 | return html( |
| 509 | 546 | listingPage({ |
| 547 | ...(showings.ok ? { showings: showings.value } : {}), | |
| 510 | 548 | who: c.who, |
| 511 | 549 | listing: found.value, |
| 512 | 550 | origin: publicOrigin, |
| @@ -672,6 +710,32 @@ export const routes: readonly Route[] = [ | ||
| 672 | 710 | ); |
| 673 | 711 | }, |
| 674 | 712 | }, |
| 713 | { | |
| 714 | method: "POST", | |
| 715 | path: "/listings/:id/showings", | |
| 716 | handle: async (c) => { | |
| 717 | const form = await readForm(c.request); | |
| 718 | if (form === undefined) return text("too large", 413); | |
| 719 | const id = Number(c.params.id); | |
| 720 | const done = addShowing(c.db, c.who, { | |
| 721 | id, | |
| 722 | date: form.get("date"), | |
| 723 | agent: form.get("agent"), | |
| 724 | interest: form.get("interest"), | |
| 725 | feedback: form.get("feedback"), | |
| 726 | }); | |
| 727 | return back(done.ok ? "Showing logged." : done.message, `/listings/${id}`); | |
| 728 | }, | |
| 729 | }, | |
| 730 | { | |
| 731 | method: "POST", | |
| 732 | path: "/listings/:id/showings/:showing/delete", | |
| 733 | handle: (c) => { | |
| 734 | const id = Number(c.params.id); | |
| 735 | const done = removeShowing(c.db, c.who, { id, showing: Number(c.params.showing) }); | |
| 736 | return back(done.ok ? "Showing removed." : done.message, `/listings/${id}`); | |
| 737 | }, | |
| 738 | }, | |
| 675 | 739 | { |
| 676 | 740 | method: "POST", |
| 677 | 741 | path: "/listings/:id/visitors", |
| @@ -991,8 +1055,38 @@ export const routes: readonly Route[] = [ | ||
| 991 | 1055 | { |
| 992 | 1056 | method: "GET", |
| 993 | 1057 | path: "/api/listings/:id", |
| 994 | handle: (c) => answer(getListing(c.db, c.who, { id: Number(c.params.id) })), | |
| 995 | api: { summary: "One listing, with its assets and each one's Fair Housing check.", answer: LISTING }, | |
| 1058 | handle: (c) => { | |
| 1059 | const id = Number(c.params.id); | |
| 1060 | const found = getListing(c.db, c.who, { id }); | |
| 1061 | const showings = found.ok ? listShowings(c.db, c.who, { id }) : undefined; | |
| 1062 | return answer(found.ok && showings?.ok ? { ok: true as const, value: { ...found.value, showings: showings.value } } : found); | |
| 1063 | }, | |
| 1064 | api: { summary: "One listing, with its assets and each one's Fair Housing check; for the listing's agent or an admin, also its logged showings, newest first.", answer: LISTING }, | |
| 1065 | }, | |
| 1066 | { | |
| 1067 | method: "POST", | |
| 1068 | path: "/api/listings/:id/showings", | |
| 1069 | handle: async (c) => { | |
| 1070 | const raw = await readJson(c.request); | |
| 1071 | if (raw === undefined) return json({ error: "too_large" }, 413); | |
| 1072 | const body = fields(raw); | |
| 1073 | return answer( | |
| 1074 | addShowing(c.db, c.who, { id: Number(c.params.id), date: body.date, agent: body.agent, interest: body.interest, feedback: body.feedback }), | |
| 1075 | 201, | |
| 1076 | ); | |
| 1077 | }, | |
| 1078 | api: { | |
| 1079 | summary: "Log a showing: the day, the buyer's agent, how interested they were and one line of feedback. Only the listing's agent or an admin; a refused field is named in the 422.", | |
| 1080 | body: SHOWING_BODY, | |
| 1081 | answer: { type: "object", properties: { id: { type: "integer" }, showing: { type: "integer" } }, required: ["id", "showing"] }, | |
| 1082 | status: 201, | |
| 1083 | }, | |
| 1084 | }, | |
| 1085 | { | |
| 1086 | method: "DELETE", | |
| 1087 | path: "/api/listings/:id/showings/:showing", | |
| 1088 | handle: (c) => answer(removeShowing(c.db, c.who, { id: Number(c.params.id), showing: Number(c.params.showing) })), | |
| 1089 | api: { summary: "Remove a showing the agent logged.", answer: { type: "object", properties: { id: { type: "integer" } }, required: ["id"] } }, | |
| 996 | 1090 | }, |
| 997 | 1091 | { |
| 998 | 1092 | method: "PATCH", |
src/sample.ts +32 −1
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | import type { Database } from "bun:sqlite"; |
| 9 | 9 | import { draftMissingUpdates, insertListing, markStatus, redraftUpdate } from "./actions.ts"; |
| 10 | 10 | import type { ListingInput } from "./listing.ts"; |
| 11 | import { dayOf, type Interest } from "./showings.ts"; | |
| 11 | 12 | |
| 12 | 13 | export const SAMPLE: ListingInput = { |
| 13 | 14 | address: "412 Alder Lane, Portland, OR 97214", |
| @@ -111,16 +112,29 @@ interface Sample { | ||
| 111 | 112 | readonly alt: readonly string[]; |
| 112 | 113 | /** Where the sample stands; absent means active. */ |
| 113 | 114 | readonly sold_at?: number; |
| 115 | /** The showings the agent logged: days ago, the buyer's agent, how interested, what they said. */ | |
| 116 | readonly showings: readonly (readonly [number, string, Interest, string])[]; | |
| 114 | 117 | } |
| 115 | 118 | |
| 116 | 119 | const SAMPLES: readonly Sample[] = [ |
| 117 | { name: "sample", listing: { ...SAMPLE, open_houses: [SAMPLE_OPEN_HOUSE] }, photos: SAMPLE_PHOTOS, headshot: SAMPLE_HEADSHOT, alt: ["Front of 412 Alder Lane with a covered porch", "Back garden with raised beds and a patio"] }, | |
| 120 | { name: "sample", listing: { ...SAMPLE, open_houses: [SAMPLE_OPEN_HOUSE] }, photos: SAMPLE_PHOTOS, headshot: SAMPLE_HEADSHOT, alt: ["Front of 412 Alder Lane with a covered porch", "Back garden with raised beds and a patio"], | |
| 121 | showings: [ | |
| 122 | [9, "Priya Nair, Larkspur Realty", "interested", "Loved the kitchen and the oak floors; wants a second look with her clients."], | |
| 123 | [5, "Tom Okafor, Eastside Homes", "maybe", "Garden is lovely; the one bathroom upstairs is tight for them."], | |
| 124 | [2, "Dana Whitcomb, Harbor & Pine", "offer_likely", "Second visit, measured the basement; expects to write an offer this week."], | |
| 125 | ], | |
| 126 | }, | |
| 118 | 127 | { |
| 119 | 128 | name: "sample-condo", |
| 120 | 129 | listing: SAMPLE_CONDO, |
| 121 | 130 | photos: ["sample-condo-front.svg", "sample-condo-living.svg"], |
| 122 | 131 | headshot: "sample-agent-2.svg", |
| 123 | 132 | alt: ["Building entrance with a glass lobby", "Living room with floor-to-ceiling windows"], |
| 133 | showings: [ | |
| 134 | [30, "Lena Brandt, Cascade Row", "interested", "Corner views won them over; asked about the HOA dues."], | |
| 135 | [21, "Marcus Hale, Puget Realty", "maybe", "Likes the rooftop; wants a second parking space."], | |
| 136 | [14, "", "offer_likely", "Walked it twice and asked for the disclosures."], | |
| 137 | ], | |
| 124 | 138 | // Sold, so the meeting sees the band, the pill and the "Just sold" post and email. |
| 125 | 139 | sold_at: 752000, |
| 126 | 140 | }, |
| @@ -130,6 +144,10 @@ const SAMPLES: readonly Sample[] = [ | ||
| 130 | 144 | photos: ["sample-house-front.svg", "sample-house-yard.svg"], |
| 131 | 145 | headshot: "sample-agent-3.svg", |
| 132 | 146 | alt: ["Brick front with a two-car garage", "Fenced yard with a mature oak"], |
| 147 | showings: [ | |
| 148 | [6, "Ruth Castellano, Treasure Valley Homes", "maybe", "Big yard and the bonus room; concerned about the busy street."], | |
| 149 | [3, "Ian Moreau, Gem State Homes", "not_interested", "Wanted a smaller lot and a single-level plan."], | |
| 150 | ], | |
| 133 | 151 | }, |
| 134 | 152 | ]; |
| 135 | 153 | |
| @@ -169,6 +187,19 @@ export function seed(db: Database): void { | ||
| 169 | 187 | .get(id)!; |
| 170 | 188 | if (row.name !== null && row.open === 0) signOff(db, id, row.name); |
| 171 | 189 | } |
| 190 | // Each sample's showings are logged once, for samples planted before showings existed too. | |
| 191 | for (const s of SAMPLES) { | |
| 192 | const first = db.query("INSERT OR IGNORE INTO seeded (name) VALUES (?1)").run(`${s.name}-showings`); | |
| 193 | if (first.changes === 0) continue; | |
| 194 | const row = db | |
| 195 | .query<{ id: number }, [string]>("SELECT id FROM listing WHERE author_id = 'sample' AND address = ?1") | |
| 196 | .get(s.listing.address); | |
| 197 | if (row === null) continue; | |
| 198 | for (const [ago, agent, interest, feedback] of s.showings) { | |
| 199 | const date = dayOf(new Date(Date.now() - ago * 86_400_000)); | |
| 200 | db.query("INSERT INTO showing (listing_id, date, agent, interest, feedback) VALUES (?1, ?2, ?3, ?4, ?5)").run(row.id, date, agent, interest, feedback); | |
| 201 | } | |
| 202 | } | |
| 172 | 203 | // A sample planted before statuses existed is marked sold once too; after that it is the owner's to change. |
| 173 | 204 | for (const s of SAMPLES) { |
| 174 | 205 | if (s.sold_at === undefined) continue; |
src/security.test.ts +5
| @@ -60,7 +60,12 @@ test("a path is a resource's door by the contract's api, and nothing else is", ( | ||
| 60 | 60 | resource: "audit", |
| 61 | 61 | verb: "list", |
| 62 | 62 | }); |
| 63 | // What hangs off a row is changed as an update of that row; reading it is no door. | |
| 64 | expect(doorOf("POST", "/api/notes/7/x")).toEqual({ resource: "note", verb: "update" }); | |
| 65 | expect(doorOf("DELETE", "/api/notes/7/x/3")).toEqual({ resource: "note", verb: "update" }); | |
| 63 | 66 | for (const [method, path] of [ |
| 67 | ["POST", "/api/notes/7/"], | |
| 68 | ["POST", "/api/notes//x"], | |
| 64 | 69 | ["PUT", "/api/notes"], |
| 65 | 70 | ["GET", "/api/notes/7/x"], |
| 66 | 71 | ["GET", "/api/notesy"], |
src/security.ts +3
| @@ -96,6 +96,9 @@ export function doorOf( | ||
| 96 | 96 | const verb = ROW_VERB[method]; |
| 97 | 97 | return verb === undefined ? undefined : { resource, verb }; |
| 98 | 98 | } |
| 99 | // Something that hangs off a row (its showings) is changed as an update of the row. | |
| 100 | if (rest?.includes("/") && !rest.startsWith("/") && !rest.endsWith("/") && (method === "POST" || method === "DELETE")) | |
New branch in doorOf: needs a test in contract/security tests, and the new doors need rows in src/contract.test.ts. |
||
| 101 | return { resource, verb: "update" }; | |
| 99 | 102 | } |
| 100 | 103 | return undefined; |
| 101 | 104 | } |
src/server.test.ts +1
| @@ -147,6 +147,7 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 147 | 147 | "delete_note", |
| 148 | 148 | "list_listings", |
| 149 | 149 | "get_listing", |
| 150 | "log_showing", | |
| 150 | 151 | "launch_kit", |
| 151 | 152 | "launch_listing", |
| 152 | 153 | "edit_asset", |
src/showings.test.ts added +240
| @@ -0,0 +1,240 @@ | ||
| 1 | /** | |
| 2 | * Showings: what the agent logged after each one. Validated at the boundary, | |
| 3 | * newest first, the listing's author alone, Fair Housing checked, and never on | |
| 4 | * the property page. | |
| 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 { addShowing, approveAsset, deleteListing, getListing, launchListing, listListings, listShowings, removeShowing } from "./actions.ts"; | |
| 11 | import { openDb } from "./db.ts"; | |
| 12 | import { rpc } from "./mcp.ts"; | |
| 13 | import { home } from "./pages/home.ts"; | |
| 14 | import { listingPage } from "./pages/listing.ts"; | |
| 15 | import { propertyPage } from "./pages/property.ts"; | |
| 16 | import { SAMPLE, seed } from "./sample.ts"; | |
| 17 | import { dayOf } from "./showings.ts"; | |
| 18 | import type { Who } from "./who.ts"; | |
| 19 | ||
| 20 | const me: Who = { role: "user", id: "u1", name: "me" }; | |
| 21 | const other: Who = { role: "user", id: "u2", name: "other" }; | |
| 22 | const anon: Who = { role: "anon" }; | |
| 23 | const now = new Date("2027-03-10T12:00:00Z"); | |
| 24 | ||
| 25 | const launch = () => { | |
| 26 | const db = openDb(mkdtempSync(join(tmpdir(), "app-showings-"))); | |
| 27 | const made = launchListing(db, me, { fields: SAMPLE }); | |
| 28 | if (!made.ok) throw new Error(made.message); | |
| 29 | const id = made.value.id; | |
| 30 | for (const a of made.value.assets) approveAsset(db, me, { id, kind: a.kind, door: "page", seen: a.updated_at }); | |
| 31 | return { db, id }; | |
| 32 | }; | |
| 33 | const shown = (db: ReturnType<typeof openDb>, id: number, who: Who = me) => { | |
| 34 | const r = listShowings(db, who, { id }); | |
| 35 | if (!r.ok) throw new Error(r.message); | |
| 36 | return r.value; | |
| 37 | }; | |
| 38 | ||
| 39 | test("add, newest first, remove", () => { | |
| 40 | const { db, id } = launch(); | |
| 41 | const a = addShowing(db, me, { id, date: "2027-03-01", agent: "Pat", interest: "maybe", feedback: "Liked the yard.", now }); | |
| 42 | addShowing(db, me, { id, date: "2027-03-05", interest: "offer_likely", feedback: "", now }); | |
| 43 | addShowing(db, me, { id, date: "2027-03-05", agent: "Sam", interest: "interested", feedback: "Second look.", now }); | |
| 44 | expect(shown(db, id).map((s) => [s.date, s.agent])).toEqual([["2027-03-05", "Sam"], ["2027-03-05", ""], ["2027-03-01", "Pat"]]); | |
| 45 | // A blank date is today. | |
| 46 | const today = addShowing(db, me, { id, interest: "maybe", feedback: "" }); | |
| 47 | expect(today.ok).toBe(true); | |
| 48 | expect(shown(db, id).some((x) => x.date === dayOf(new Date()))).toBe(true); | |
| 49 | if (!a.ok) throw new Error(a.message); | |
| 50 | expect(removeShowing(db, me, { id, showing: a.value.showing }).ok).toBe(true); | |
| 51 | expect(shown(db, id).length).toBe(3); | |
| 52 | const again = removeShowing(db, me, { id, showing: a.value.showing }); | |
| 53 | expect(!again.ok && again.error).toBe("not_found"); | |
| 54 | }); | |
| 55 | ||
| 56 | test("a future date, a long or hostile line, a bad interest are refused with the field named", () => { | |
| 57 | const { db, id } = launch(); | |
| 58 | const base = { id, date: "2027-03-01", interest: "maybe", feedback: "ok", now }; | |
| 59 | const refused: [Record<string, unknown>, string][] = [ | |
| 60 | [{ date: "2027-03-11" }, "date"], | |
| 61 | [{ date: "2027-02-30" }, "date"], | |
| 62 | [{ date: "yesterday" }, "date"], | |
| 63 | [{ agent: "x".repeat(81) }, "agent"], | |
| 64 | [{ interest: "love it" }, "interest"], | |
| 65 | [{ interest: undefined }, "interest"], | |
| 66 | [{ feedback: "x".repeat(281) }, "feedback"], | |
| 67 | [{ feedback: "two\nlines" }, "feedback"], | |
| 68 | [{ feedback: "bell\u0007" }, "feedback"], | |
| 69 | ]; | |
| 70 | for (const [change, field] of refused) { | |
| 71 | const r = addShowing(db, me, { ...base, ...change }); | |
| 72 | expect(r.ok).toBe(false); | |
| 73 | expect(!r.ok && r.error).toBe("invalid"); | |
| 74 | expect(!r.ok && r.field).toBe(field); | |
| 75 | } | |
| 76 | expect(shown(db, id)).toEqual([]); | |
| 77 | expect(addShowing(db, me, { ...base, feedback: "x".repeat(280), agent: "y".repeat(80) }).ok).toBe(true); | |
| 78 | }); | |
| 79 | ||
| 80 | test("hostile text is stored as typed and shown escaped; Fair Housing findings sit beside it", () => { | |
| 81 | const { db, id } = launch(); | |
| 82 | addShowing(db, me, { id, interest: "maybe", agent: "<img src=x onerror=alert(1)>", feedback: "<script>alert(1)</script> perfect for a young family" }); | |
| 83 | const listing = getListing(db, me, { id }); | |
| 84 | if (!listing.ok) throw new Error(listing.message); | |
| 85 | const logged = shown(db, id); | |
| 86 | expect(logged[0]!.check.findings.length).toBeGreaterThan(0); | |
| 87 | const html = listingPage({ who: me, listing: listing.value, editable: true, showings: logged }); | |
| 88 | expect(html).toContain("<script>"); | |
| 89 | expect(html).not.toContain("<script>alert(1)"); | |
| 90 | expect(html).not.toContain("<img src=x"); | |
| 91 | expect(html).toContain("fh-list"); | |
| 92 | expect(html).toContain("Showings"); | |
| 93 | }); | |
| 94 | ||
| 95 | test("only the author (or an admin) logs, reads or removes; contract unchanged", () => { | |
| 96 | const { db, id } = launch(); | |
| 97 | const mine = addShowing(db, me, { id, interest: "maybe", feedback: "" }); | |
| 98 | if (!mine.ok) throw new Error(mine.message); | |
| 99 | for (const who of [anon, other]) { | |
| 100 | const add = addShowing(db, who, { id, interest: "maybe", feedback: "" }); | |
| 101 | expect(add.ok).toBe(false); | |
| 102 | expect(!add.ok && ["signin_required", "forbidden", "not_found"].includes(add.error)).toBe(true); | |
| 103 | expect(listShowings(db, who, { id }).ok).toBe(false); | |
| 104 | expect(removeShowing(db, who, { id, showing: mine.value.showing }).ok).toBe(false); | |
| 105 | } | |
| 106 | expect(shown(db, id).length).toBe(1); | |
| 107 | expect(shown(db, id, { role: "admin", id: "a", name: "a" }).length).toBe(1); | |
| 108 | }); | |
| 109 | ||
| 110 | test("not on the property page, and gone with the listing", () => { | |
| 111 | const { db, id } = launch(); | |
| 112 | addShowing(db, me, { id, agent: "Hidden Agent", interest: "maybe", feedback: "secret feedback line" }); | |
| 113 | const listing = getListing(db, anon, { id }); | |
| 114 | if (!listing.ok) throw new Error(listing.message); | |
| 115 | const page = propertyPage({ who: anon, listing: listing.value }); | |
| 116 | expect(page).not.toContain("Hidden Agent"); | |
| 117 | expect(page).not.toContain("secret feedback"); | |
| 118 | expect(page).not.toContain("Showings"); | |
| 119 | expect(JSON.stringify(listing.value)).not.toContain("secret feedback"); | |
| 120 | expect(deleteListing(db, me, { id }).ok).toBe(true); | |
| 121 | expect(db.query<{ n: number }, []>("SELECT count(*) AS n FROM showing").get()!.n).toBe(0); | |
| 122 | }); | |
| 123 | ||
| 124 | test("the home list counts them for the author only", () => { | |
| 125 | const { db, id } = launch(); | |
| 126 | for (let i = 0; i < 5; i++) addShowing(db, me, { id, interest: "maybe", feedback: "" }); | |
| 127 | const count = (who: Who) => { | |
| 128 | const r = listListings(db, who, {}); | |
| 129 | if (!r.ok) throw new Error(r.message); | |
| 130 | return r.value.find((l) => l.id === id)!.showings; | |
| 131 | }; | |
| 132 | expect(count(me)).toBe(5); | |
| 133 | expect(count(other)).toBe(0); | |
| 134 | expect(count(anon)).toBe(0); | |
| 135 | }); | |
| 136 | ||
| 137 | test("the MCP tool logs with the same validation", async () => { | |
| 138 | const { db, id } = launch(); | |
| 139 | const call = async (args: Record<string, unknown>) => | |
| 140 | JSON.stringify(await rpc(db, me, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "log_showing", arguments: args } })); | |
| 141 | expect(await call({ id, interest: "interested", feedback: "Loved it." })).not.toContain("isError\":true"); | |
| 142 | expect(await call({ id, interest: "interested", date: "2999-01-01" })).toContain("future"); | |
| 143 | expect(shown(db, id).length).toBe(1); | |
| 144 | }); | |
| 145 | ||
| 146 | test("samples come with showings", () => { | |
| 147 | const db = openDb(mkdtempSync(join(tmpdir(), "app-showings-seed-"))); | |
| 148 | seed(db); | |
| 149 | const per = db.query<{ n: number }, []>("SELECT count(*) AS n FROM showing GROUP BY listing_id").all(); | |
| 150 | expect(per.length).toBe(3); | |
| 151 | for (const p of per) expect(p.n).toBeGreaterThanOrEqual(2); | |
| 152 | seed(db); | |
| 153 | expect(db.query<{ n: number }, []>("SELECT count(*) AS n FROM showing").get()!.n).toBe(per.reduce((a, p) => a + p.n, 0)); | |
| 154 | }); | |
| 155 | ||
| 156 | test("over HTTP: the form and the API log for the author, 422 names the field, strangers are refused", async () => { | |
| 157 | const { serve } = await import("./server.ts"); | |
| 158 | const { sign } = await import("./who.ts"); | |
| 159 | const KEY = "a-key-only-the-forge-holds"; | |
| 160 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-showings-http-")), identityKey: KEY }); | |
| 161 | try { | |
| 162 | const base = `http://${server.hostname}:${server.port}`; | |
| 163 | const as = (name: string) => { | |
| 164 | const identity = `${name}|${name}|user|${Date.now() + 600_000}`; | |
| 165 | return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY), "content-type": "application/json" }; | |
| 166 | }; | |
| 167 | const made = (await (await fetch(`${base}/api/listings`, { method: "POST", headers: as("agent"), body: JSON.stringify(SAMPLE) })).json()) as { id: number }; | |
| 168 | const url = `${base}/api/listings/${made.id}/showings`; | |
| 169 | const post = (who: Record<string, string>, body: unknown) => fetch(url, { method: "POST", headers: who, body: JSON.stringify(body) }); | |
| 170 | const added = await post(as("agent"), { interest: "interested", feedback: "Loved it." }); | |
| 171 | expect(added.status).toBe(201); | |
| 172 | const bad = await post(as("agent"), { interest: "interested", date: "2999-01-01" }); | |
| 173 | expect(bad.status).toBe(422); | |
| 174 | expect(((await bad.json()) as { field: string }).field).toBe("date"); | |
| 175 | expect((await post(as("intruder"), { interest: "maybe" })).status).toBeOneOf([403, 404]); | |
| 176 | expect((await post({ "content-type": "application/json" }, { interest: "maybe" })).status).toBeOneOf([401, 403]); | |
| 177 | const one = (await (await fetch(`${base}/api/listings/${made.id}`, { headers: as("agent") })).json()) as { showings: unknown[] }; | |
| 178 | expect(one.showings.length).toBe(1); | |
| 179 | const theirs = (await (await fetch(`${base}/api/listings/${made.id}`, { headers: as("intruder") })).json()) as { showings?: unknown[] }; | |
| 180 | expect(theirs.showings ?? []).toEqual([]); | |
| 181 | // The plain form post, no JavaScript. | |
| 182 | const { "content-type": _, ...bare } = as("agent"); | |
| 183 | const form = await fetch(`${base}/listings/${made.id}/showings`, { | |
| 184 | method: "POST", | |
| 185 | headers: { ...bare, "content-type": "application/x-www-form-urlencoded" }, | |
| 186 | body: "date=&agent=Pat&interest=maybe&feedback=Nice", | |
| 187 | redirect: "manual", | |
| 188 | }); | |
| 189 | expect(form.status).toBe(303); | |
| 190 | expect(await (await fetch(`${base}/listings/${made.id}`, { headers: as("agent") })).text()).toContain("Nice"); | |
| 191 | } finally { | |
| 192 | server.stop(true); | |
| 193 | } | |
| 194 | }); | |
| 195 | ||
| 196 | test("the home page shows the pill: \"1 showing\", \"5 showings\", for the author alone", () => { | |
| 197 | const { db, id } = launch(); | |
| 198 | const page = (who: Who) => { | |
| 199 | const r = listListings(db, who, {}); | |
| 200 | if (!r.ok) throw new Error(r.message); | |
| 201 | return home({ who, listings: r.value, draft: {} as never }); | |
| 202 | }; | |
| 203 | expect(page(me)).not.toContain("showing</span>"); | |
| 204 | addShowing(db, me, { id, interest: "maybe", feedback: "" }); | |
| 205 | expect(page(me)).toContain(">1 showing</span>"); | |
| 206 | for (let i = 0; i < 4; i++) addShowing(db, me, { id, interest: "maybe", feedback: "" }); | |
| 207 | expect(page(me)).toContain(">5 showings</span>"); | |
| 208 | expect(page(other)).not.toMatch(/\d showings?</); | |
| 209 | expect(page(anon)).not.toMatch(/\d showings?</); | |
| 210 | }); | |
| 211 | ||
| 212 | test("over HTTP: another user's form delete and API delete are refused and remove nothing", async () => { | |
| 213 | const { serve } = await import("./server.ts"); | |
| 214 | const { sign } = await import("./who.ts"); | |
| 215 | const KEY = "a-key-only-the-forge-holds"; | |
| 216 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-showings-del-")), identityKey: KEY }); | |
| 217 | try { | |
| 218 | const base = `http://${server.hostname}:${server.port}`; | |
| 219 | const as = (name: string) => { | |
| 220 | const identity = `${name}|${name}|user|${Date.now() + 600_000}`; | |
| 221 | return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY), "content-type": "application/json" }; | |
| 222 | }; | |
| 223 | const made = (await (await fetch(`${base}/api/listings`, { method: "POST", headers: as("agent"), body: JSON.stringify(SAMPLE) })).json()) as { id: number }; | |
| 224 | const logged = (await (await fetch(`${base}/api/listings/${made.id}/showings`, { method: "POST", headers: as("agent"), body: JSON.stringify({ interest: "maybe" }) })).json()) as { showing: number }; | |
| 225 | const apiUrl = `${base}/api/listings/${made.id}/showings/${logged.showing}`; | |
| 226 | const formUrl = `${base}/listings/${made.id}/showings/${logged.showing}/delete`; | |
| 227 | expect((await fetch(apiUrl, { method: "DELETE", headers: as("intruder") })).status).toBeOneOf([403, 404]); | |
| 228 | expect((await fetch(apiUrl, { method: "DELETE", headers: { "content-type": "application/json" } })).status).toBeOneOf([401, 403]); | |
| 229 | const formAs = (name: string) => ({ ...as(name), "content-type": "application/x-www-form-urlencoded" }); | |
| 230 | const refused = await fetch(formUrl, { method: "POST", headers: formAs("intruder"), body: "", redirect: "manual" }); | |
| 231 | const page = refused.status === 303 ? await (await fetch(`${base}/listings/${made.id}`, { headers: as("intruder") })).text() : ""; | |
| 232 | expect([303, 403, 404]).toContain(refused.status); | |
| 233 | expect(page).not.toContain("Showing removed."); | |
| 234 | const still = (await (await fetch(`${base}/api/listings/${made.id}`, { headers: as("agent") })).json()) as { showings: unknown[] }; | |
| 235 | expect(still.showings.length).toBe(1); | |
| 236 | expect((await fetch(apiUrl, { method: "DELETE", headers: as("agent") })).status).toBe(200); | |
| 237 | } finally { | |
| 238 | server.stop(true); | |
| 239 | } | |
| 240 | }); |
src/showings.ts added +62
| @@ -0,0 +1,62 @@ | ||
| 1 | /** | |
| 2 | * A showing the agent logged: the day, the buyer's agent, how interested the | |
| 3 | * buyers were and one line of what they said. Plain data in, plain data out; | |
| 4 | * the boundary is `validShowing`, the rows live in `showing` (src/db.ts). | |
| 5 | * They hold third-party names, so they belong to the agent's page alone. | |
| 6 | */ | |
| 7 | ||
| 8 | export const SHOWING_AGENT_MAX = 80; | |
| 9 | export const SHOWING_FEEDBACK_MAX = 280; | |
| 10 | export const SHOWINGS_MAX = 500; | |
| 11 | ||
| 12 | export const INTERESTS = ["not_interested", "maybe", "interested", "offer_likely"] as const; | |
| 13 | export type Interest = (typeof INTERESTS)[number]; | |
| 14 | ||
| 15 | export const INTEREST_LABELS: Readonly<Record<Interest, string>> = { | |
| 16 | not_interested: "Not interested", | |
| 17 | maybe: "Maybe", | |
| 18 | interested: "Interested", | |
| 19 | offer_likely: "Offer likely", | |
| 20 | }; | |
| 21 | ||
| 22 | export interface Showing { | |
| 23 | readonly id: number; | |
| 24 | readonly date: string; | |
| 25 | readonly agent: string; | |
| 26 | readonly interest: Interest; | |
| 27 | readonly feedback: string; | |
| 28 | } | |
| 29 | ||
| 30 | type Checked = { ok: true; value: Omit<Showing, "id"> } | { ok: false; field: string; message: string }; | |
| 31 | ||
| 32 | const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/; | |
| 33 | ||
| 34 | /** The day as YYYY-MM-DD, in UTC, the way the clock reads it. */ | |
| 35 | export const dayOf = (now: Date): string => now.toISOString().slice(0, 10); | |
| 36 | ||
| 37 | const realDay = (s: string): boolean => { | |
| 38 | if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false; | |
| 39 | const d = new Date(`${s}T00:00:00Z`); | |
| 40 | return !Number.isNaN(d.getTime()) && dayOf(d) === s; | |
| 41 | }; | |
| 42 | ||
| 43 | export function validShowing( | |
| 44 | raw: { date?: unknown; agent?: unknown; interest?: unknown; feedback?: unknown }, | |
| 45 | now: Date = new Date(), | |
| 46 | ): Checked { | |
| 47 | const text = (v: unknown): string => (typeof v === "string" ? v.trim() : v === undefined || v === null ? "" : String(v)); | |
| 48 | const today = dayOf(now); | |
| 49 | const date = text(raw.date) === "" ? today : text(raw.date); | |
| 50 | if (!realDay(date)) return { ok: false, field: "date", message: "The date looks like 2027-03-07." }; | |
| 51 | if (date > today) return { ok: false, field: "date", message: "A showing cannot be in the future." }; | |
| 52 | const agent = text(raw.agent); | |
| 53 | if (CONTROL.test(agent) || agent.length > SHOWING_AGENT_MAX) | |
| 54 | return { ok: false, field: "agent", message: `The buyer's agent is up to ${SHOWING_AGENT_MAX} characters, plain text.` }; | |
| 55 | const interest = text(raw.interest); | |
| 56 | if (!(INTERESTS as readonly string[]).includes(interest)) | |
| 57 | return { ok: false, field: "interest", message: "Interest is not_interested, maybe, interested or offer_likely." }; | |
| 58 | const feedback = text(raw.feedback); | |
| 59 | if (CONTROL.test(feedback) || feedback.length > SHOWING_FEEDBACK_MAX) | |
| 60 | return { ok: false, field: "feedback", message: `The feedback is one line of up to ${SHOWING_FEEDBACK_MAX} characters, plain text.` }; | |
| 61 | return { ok: true, value: { date, agent, interest: interest as Interest, feedback } }; | |
| 62 | } |
src/tty.test.ts +40
| @@ -425,3 +425,43 @@ test("the seller update is the last asset: it reads as a draft, and a approves i | ||
| 425 | 425 | tty.press("a"); |
| 426 | 426 | expect(plain(tty.screen())).toContain("Approval withdrawn."); |
| 427 | 427 | }); |
| 428 | ||
| 429 | test("S lists, logs and removes showings, for the agent only, and the page hears it", async () => { | |
| 430 | const { launchListing, listShowings } = await import("./actions.ts"); | |
| 431 | const db = openDb(mkdtempSync(join(tmpdir(), "app-tty-showings-"))); | |
| 432 | const made = launchListing(db, me, { fields: SAMPLE }); | |
| 433 | if (!made.ok) throw new Error(made.message); | |
| 434 | const id = made.value.id; | |
| 435 | const changes = (): number => db.query<{ n: number }, []>("SELECT count(*) AS n FROM event WHERE type = 'listing.changed'").get()!.n; | |
| 436 | const before = changes(); | |
| 437 | const tty = session(db, me); | |
| 438 | tty.press("\r"); | |
| 439 | tty.press("S"); | |
| 440 | expect(plain(tty.screen())).toContain("None yet."); | |
| 441 | tty.press("a"); | |
| 442 | for (const key of "; Pat Lee; offer likely; Loved the kitchen, perfect for a young family") tty.press(key); | |
| 443 | tty.press("\r"); | |
| 444 | expect(plain(tty.screen())).toContain("Logged."); | |
| 445 | tty.press(" "); | |
| 446 | const shown = plain(tty.screen()); | |
| 447 | expect(shown).toContain("Offer likely · Pat Lee"); | |
| 448 | expect(shown).toContain("Loved the kitchen"); | |
| 449 | expect(shown).toContain("!"); | |
| 450 | tty.press("a"); | |
| 451 | for (const key of "; ; maybe; "+"x".repeat(300)) tty.press(key); | |
| 452 | tty.press("\r"); | |
| 453 | expect(plain(tty.screen())).toContain("plain text"); | |
| 454 | tty.press(" "); | |
| 455 | tty.press("d"); | |
| 456 | expect(plain(tty.screen())).toContain("Removed."); | |
| 457 | const left = listShowings(db, me, { id }); | |
| 458 | expect(left.ok && left.value).toEqual([]); | |
| 459 | expect(changes() - before).toBe(2); | |
| 460 | for (const who of [other, { role: "anon" } as Who]) { | |
| 461 | const t = session(db, who); | |
| 462 | t.press("\r"); | |
| 463 | t.press("S"); | |
| 464 | t.press("a"); | |
| 465 | expect(plain(t.screen())).not.toContain("Showing: date"); | |
| 466 | } | |
| 467 | }); |
src/tty.ts +60 −7
| @@ -15,7 +15,9 @@ | ||
| 15 | 15 | * one as `Name, email`, d removes the one under the cursor, esc goes back; k shows |
| 16 | 16 | * the launch kit, the file the page offers to download; R shows the seller |
| 17 | 17 | * report, approved words only, as the page prints it; w shows the launch week, |
| 18 | * which asset goes out on which day, a draft marked and never as going out. | |
| 18 | * which asset goes out on which day, a draft marked and never as going out; S lists | |
| 19 | * the showings logged (the listing's agent or an admin; a guest sees none): a logs one as | |
| 20 | * `date; buyer's agent; interest; what they said`, d removes the one under the cursor. | |
| 19 | 21 | * - notes: a adds, d removes. |
| 20 | 22 | * |
| 21 | 23 | * j/k move, q leaves. Launching, with its photos, is the page's. Plain ANSI, |
| @@ -24,9 +26,11 @@ | ||
| 24 | 26 | */ |
| 25 | 27 | |
| 26 | 28 | import type { Database } from "bun:sqlite"; |
| 29 | import { INTEREST_LABELS } from "./showings.ts"; | |
| 27 | 30 | import { FILTERS, parseFind, type Find } from "./find.ts"; |
| 28 | 31 | import { |
| 29 | 32 | addNote, |
| 33 | addShowing, | |
| 30 | 34 | addVisitor, |
| 31 | 35 | approveAsset, |
| 32 | 36 | canDelete, |
| @@ -38,8 +42,10 @@ import { | ||
| 38 | 42 | getListing, |
| 39 | 43 | listListings, |
| 40 | 44 | listNotes, |
| 45 | listShowings, | |
| 41 | 46 | listVisitors, |
| 42 | 47 | listVersions, |
| 48 | removeShowing, | |
| 43 | 49 | removeVisitor, |
| 44 | 50 | restoreAsset, |
| 45 | 51 | retoneListing, |
| @@ -105,7 +111,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 105 | 111 | /** The email blast or the open house follow-up, laid out as the page's preview is, instead of the assets. */ |
| 106 | 112 | let previewing: "email" | "email_followup" | "email_reminder" | undefined; |
| 107 | 113 | /** The sign-in visitors of the open house, the cursor on one. */ |
| 108 | let visiting = false; | |
| 114 | let visiting: "visitors" | "showings" | false = false; | |
| 109 | 115 | let visitorCursor = 0; |
| 110 | 116 | /** The launch kit, as the file would read. */ |
| 111 | 117 | let kitting = false; |
| @@ -117,7 +123,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 117 | 123 | let versioning: Kind | undefined; |
| 118 | 124 | let cursor = 0; |
| 119 | 125 | /** What the one-line input is for: a new note, or an asset's new words. */ |
| 120 | let typing: { for: "note" } | { for: "asset"; kind: Kind } | { for: "open" } | { for: "status" } | { for: "price" } | { for: "search" } | { for: "visitor" } | undefined; | |
| 126 | let typing: { for: "note" } | { for: "asset"; kind: Kind } | { for: "open" } | { for: "status" } | { for: "price" } | { for: "search" } | { for: "visitor" } | { for: "showing" } | undefined; | |
| 121 | 127 | let typed = ""; |
| 122 | 128 | /** The filter and the words the listings view is narrowed to. */ |
| 123 | 129 | let found: Find = { status: "all", q: "" }; |
| @@ -159,6 +165,18 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 159 | 165 | ...(list.length === 0 ? [dim("None yet.")] : list.map((v, i) => `${i + 1}) ${dim(`${v.at.slice(0, 16).replace("T", " ")} · ${scrub(v.by_name)} · ${v.why}`)} ${scrub(v.text.split("\n")[0] ?? "").slice(0, 60)}`)), |
| 160 | 166 | ]; |
| 161 | 167 | } |
| 168 | if (visiting === "showings") { | |
| 169 | const logged = listShowings(db, me, { id: l.id }); | |
| 170 | if (!logged.ok) return [logged.message]; | |
| 171 | return [ | |
| 172 | bold(`Showings (${logged.value.length}): ${l.address}`), "", | |
| 173 | ...(logged.value.length === 0 ? [dim("None yet. Press a to log one.")] : logged.value.flatMap((x, i) => [ | |
| 174 | `${i === visitorCursor ? "▸" : " "} ${i === visitorCursor ? bold(x.date) : x.date} ${INTEREST_LABELS[x.interest]}${x.agent === "" ? "" : dim(` · ${scrub(x.agent)}`)}`, | |
| 175 | ...(x.feedback === "" ? [] : [` ${scrub(x.feedback)}`]), | |
| 176 | ...x.check.findings.map((f) => amber(` ! ${f.line}`)), | |
| 177 | ])), | |
| 178 | ]; | |
| 179 | } | |
| 162 | 180 | if (visiting) { |
| 163 | 181 | const sheet = visitors(l.id); |
| 164 | 182 | if (!sheet.ok) return [sheet.message]; |
| @@ -208,7 +226,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 208 | 226 | if (reading !== undefined) { |
| 209 | 227 | lines.push(...readLines()); |
| 210 | 228 | const found = open(); |
| 211 | if (visiting) help = "j/k move · a add (Name, email) · d remove · esc back to the assets · q quit"; | |
| 229 | if (visiting === "showings") help = "j/k move · a log one · d remove · esc back to the assets · q quit"; | |
| 230 | else if (visiting) help = "j/k move · a add (Name, email) · d remove · esc back to the assets · q quit"; | |
| 212 | 231 | else if (previewing || kitting || reporting || planning) help = "esc back to the assets · q quit"; |
| 213 | 232 | else if (versioning !== undefined) help = "1-5 put that version back · esc back to the assets · q quit"; |
| 214 | 233 | else help = |
| @@ -247,7 +266,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 247 | 266 | } |
| 248 | 267 | lines.push(""); |
| 249 | 268 | if (typing !== undefined) { |
| 250 | const label = typing.for === "note" ? "New note" : typing.for === "visitor" ? "Visitor: name, email (enter adds, esc drops)" : typing.for === "search" ? "Search by address (enter searches, empty clears, esc drops)" : typing.for === "price" ? "Price in whole dollars, like 849000; a lower price adds a price improvement post and email (enter saves, esc drops)" : typing.for === "status" ? "Status: coming soon, active, under contract, withdrawn, expired, or sold with an optional price, like sold 752000 (enter saves, esc drops)" : typing.for === "open" ? "Open house: date start-end, like 2027-03-09 13:00-15:00; separate two with ; (empty clears, enter saves, esc drops)" : `Edit (\\n breaks a line, enter saves, esc drops)`; | |
| 269 | const label = typing.for === "note" ? "New note" : typing.for === "showing" ? "Showing: date; buyer's agent; interest; what they said. Date may be empty for today; interest is not interested, maybe, interested or offer likely (enter logs, esc drops)" : typing.for === "visitor" ? "Visitor: name, email (enter adds, esc drops)" : typing.for === "search" ? "Search by address (enter searches, empty clears, esc drops)" : typing.for === "price" ? "Price in whole dollars, like 849000; a lower price adds a price improvement post and email (enter saves, esc drops)" : typing.for === "status" ? "Status: coming soon, active, under contract, withdrawn, expired, or sold with an optional price, like sold 752000 (enter saves, esc drops)" : typing.for === "open" ? "Open house: date start-end, like 2027-03-09 13:00-15:00; separate two with ; (empty clears, enter saves, esc drops)" : `Edit (\\n breaks a line, enter saves, esc drops)`; | |
| 251 | 270 | // The tail of what is typed, so the cursor stays on the screen. |
| 252 | 271 | const room = Math.max(20, size.columns - 4); |
| 253 | 272 | lines.push(dim(label), `${typed.length > room ? `…${typed.slice(-room + 1)}` : typed}${esc}7m ${esc}0m`); |
| @@ -267,6 +286,10 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 267 | 286 | const at = typed.lastIndexOf(","); |
| 268 | 287 | const done = addVisitor(db, me, { id: reading!, name: at < 0 ? typed : typed.slice(0, at), email: at < 0 ? "" : typed.slice(at + 1) }); |
| 269 | 288 | said = done.ok ? "Added." : done.message; |
| 289 | } else if (typing.for === "showing") { | |
| 290 | const [date, agent, interest, ...rest] = typed.split(";").map((x) => x.trim()); | |
| 291 | const done = addShowing(db, me, { id: reading!, date, agent, interest: (interest ?? "").toLowerCase().replace(/\s+/g, "_"), feedback: rest.join(";").trim() }); | |
| 292 | said = done.ok ? "Logged." : done.message; | |
| 270 | 293 | } else if (typing.for === "search") { |
| 271 | 294 | found = parseFind(found.status, typed); |
| 272 | 295 | cursor = 0; |
| @@ -325,10 +348,39 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 325 | 348 | return undefined; |
| 326 | 349 | } |
| 327 | 350 | if (key === "V" && idle && found?.ok === true) { |
| 328 | visiting = true; | |
| 351 | visiting = "visitors"; | |
| 329 | 352 | visitorCursor = 0; |
| 330 | 353 | return undefined; |
| 331 | 354 | } |
| 355 | if (key === "S" && idle && found?.ok === true) { | |
| 356 | visiting = "showings"; | |
| 357 | visitorCursor = 0; | |
| 358 | return undefined; | |
| 359 | } | |
| 360 | if (visiting === "showings" && found?.ok === true && key !== "\x1b") { | |
| 361 | const logged = listShowings(db, me, { id: found.value.id }); | |
| 362 | const rows = logged.ok ? logged.value : []; | |
| 363 | if (down) visitorCursor = Math.min(visitorCursor + 1, Math.max(0, rows.length - 1)); | |
| 364 | if (up) visitorCursor = Math.max(visitorCursor - 1, 0); | |
| 365 | if (key === "a") { | |
| 366 | if (!logged.ok) said = logged.message; | |
| 367 | else { | |
| 368 | typing = { for: "showing" }; | |
| 369 | typed = ""; | |
| 370 | } | |
| 371 | } | |
| 372 | if (key === "d") { | |
| 373 | const row = rows[visitorCursor]; | |
| 374 | if (!logged.ok) said = logged.message; | |
| 375 | else if (row === undefined) said = "Nothing to remove."; | |
| 376 | else { | |
| 377 | const done = removeShowing(db, me, { id: found.value.id, showing: row.id }); | |
| 378 | said = done.ok ? "Removed." : done.message; | |
| 379 | visitorCursor = Math.min(visitorCursor, Math.max(0, rows.length - 2)); | |
| 380 | } | |
| 381 | } | |
| 382 | return undefined; | |
| 383 | } | |
| 332 | 384 | if (visiting && found?.ok === true && key !== "\x1b") { |
| 333 | 385 | const sheet = visitors(found.value.id); |
| 334 | 386 | const rows = sheet.ok ? sheet.value : []; |
| @@ -383,7 +435,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 383 | 435 | if (key === "\x1b") { |
| 384 | 436 | if (previewing || kitting || reporting || planning || visiting) { |
| 385 | 437 | previewing = undefined; |
| 386 | kitting = reporting = planning = visiting = false; | |
| 438 | kitting = reporting = planning = false; | |
| 439 | visiting = false; | |
| 387 | 440 | } else { |
| 388 | 441 | reading = undefined; |
| 389 | 442 | cursor = 0; |
This pill (singular/plural, hidden at 0) has no test that renders it; add one that fails without this line.