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

Merged opened this

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

Merged

mason/listing-launch:mason/81-log-each-showing-and-the-buyer-agent-s-f → main 2 commits fork 2 threads · 2 unresolved

  • Merged. mason/81-log-each-showing-and-the-buyer-agent-s-f was merged into main as a951a5c.

Review

Sign in to review this change.
18 files changed +945 −19
README.md +14 −1
@@ -269,6 +269,18 @@ list cannot drift from the app.
269269 Print it, or save it as a PDF. "Rewrite all" writes every asset again in
270270 another tone (your edits are replaced, so it asks with a tooltip first).
271271
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
272284 Three sample listings are there from the start so the screen is
273285 never empty; "Start a new one from this" copies any listing into the form.
274286
@@ -332,6 +344,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
332344 | `src/fairhousing.ts` | the Fair Housing rules: phrase, context, class, reason, rewrite |
333345 | `src/photos.ts` | photos as files beside the database, known by their bytes |
334346 | `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 |
335348 | `src/visitors.ts` | the open house sign-in sheet's visitors: the boundary for a name and an email |
336349 | `contract.json` | who may do what: roles, and the least role each verb needs |
337350 | `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;
445458 `j`/`k` move between assets, `e` edits the one under the cursor (type `\n`
446459 for a line break, `enter` saves and checks it again), `t` rewrites them all
447460 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.
449462
450463 No runtime dependencies: Bun's own `bun:sqlite`, `Bun.serve` and `node:`
451464 modules. The typefaces are the system's (a Palatino/Iowan serif for
contract/openapi.json +203 −3
@@ -577,7 +577,7 @@
577577 },
578578 "/api/listings/{id}": {
579579 "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.",
581581 "operationId": "getApiListingsId",
582582 "parameters": [
583583 {
@@ -591,7 +591,7 @@
591591 ],
592592 "responses": {
593593 "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.",
595595 "content": {
596596 "application/json": {
597597 "schema": {
@@ -921,6 +921,157 @@
921921 }
922922 }
923923 },
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 },
9241075 "/api/audit": {
9251076 "get": {
9261077 "summary": "The audit trail, newest first; `before` pages back by id.",
@@ -1329,6 +1480,50 @@
13291480 "type": "string",
13301481 "format": "date-time"
13311482 },
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 },
13321527 "assets": {
13331528 "type": "array",
13341529 "items": {
@@ -1677,6 +1872,10 @@
16771872 "type": "integer",
16781873 "description": "Phrases the Fair Housing check flags across every asset."
16791874 },
1875 "showings": {
1876 "type": "integer",
1877 "description": "How many showings the agent logged; 0 for anyone who may not update the listing."
1878 },
16801879 "approvals": {
16811880 "type": "array",
16821881 "description": "Each asset and whether somebody has signed off its words.",
@@ -1740,7 +1939,8 @@
17401939 "created_at",
17411940 "updated_at",
17421941 "flags",
1743 "approvals"
1942 "approvals",
1943 "showings"
17441944 ]
17451945 },
17461946 "AuditRow": {
src/actions.ts +69 −2
@@ -50,6 +50,7 @@ import {
5050 } from "./photos.ts";
5151 import { headshotFrom } from "./agent.ts";
5252 import { sellerUpdate } from "./update.ts";
53 import { validShowing, SHOWINGS_MAX, type Showing } from "./showings.ts";
5354 import { validVisitor, VISITORS_MAX, type Visitor } from "./visitors.ts";
5455 import type { Who } from "./who.ts";
5556
@@ -72,12 +73,13 @@ export type Failure =
7273 | "duplicate_address";
7374 export type Result<T> =
7475 | { ok: true; value: T }
75 | { ok: false; error: Failure; message: string; existing?: Earlier };
76 | { ok: false; error: Failure; message: string; existing?: Earlier; field?: string };
7677 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> => ({
7879 ok: false,
7980 error,
8081 message,
82 ...(field === undefined ? {} : { field }),
8183 });
8284
8385 export const BODY_MAX = 500;
@@ -551,6 +553,8 @@ export function markStatus(
551553 export type ListingSummary = Listing & {
552554 flags: number;
553555 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;
554558 };
555559
556560 export function listListings(
@@ -583,6 +587,7 @@ export function listListings(
583587 const bodies = db.query<{ kind: string; body: string; approved_at: string | null }, [number]>(
584588 "SELECT kind, body, approved_at FROM asset WHERE listing_id = ?1 ORDER BY rowid",
585589 );
590 const logged = db.query<{ n: number }, [number]>("SELECT count(*) AS n FROM showing WHERE listing_id = ?1");
586591 return ok(
587592 rows
588593 .filter((row) => matchesText(row, wanted.q))
@@ -590,6 +595,7 @@ export function listListings(
590595 const assets = bodies.all(row.id);
591596 return {
592597 ...fromRow(row),
598 showings: permit(who, "listing", "update", row) === "yes" ? logged.get(row.id)!.n : 0,
593599 flags: assets.reduce((n, a) => n + check(a.body).findings.length, 0),
594600 approvals: assets.map((a) => ({ kind: a.kind, approved: a.approved_at !== null })),
595601 };
@@ -1354,6 +1360,67 @@ function dropVisitor(db: Database, who: Who, input: { id: number; visitor: numbe
13541360 return ok({ id: found.value.id });
13551361 }
13561362
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
13571424 /** The Fair Housing check on any words: no row, no door, nothing kept. */
13581425 export const checkWords = (text: string): Check =>
13591426 check(text.slice(0, ASSET_MAX));
src/contract.test.ts +37
@@ -20,6 +20,9 @@ import {
2020 getNote,
2121 insertListing,
2222 launchListing,
23 addShowing,
24 listShowings,
25 removeShowing,
2326 listVersions,
2427 restoreAsset,
2528 updatePhotos,
@@ -312,3 +315,37 @@ test("restoring an earlier version answers as the listing's update cell says, at
312315 }
313316 expect(asked).toBeGreaterThan(0);
314317 });
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[] = [
224224 DROP TABLE asset_version_old;
225225 DROP TABLE asset_old;
226226 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)`,
227239 ];
228240
229241 export function openDb(dir = dataDir): Database {
src/mcp.ts +20
@@ -9,6 +9,7 @@
99 import type { Database } from "bun:sqlite";
1010 import { FILTERS, Q_MAX } from "./find.ts";
1111 import {
12 addShowing,
1213 addNote,
1314 approveAsset,
1415 checkWords,
@@ -29,6 +30,7 @@ import {
2930 updatePhotos,
3031 type Result,
3132 } from "./actions.ts";
33 import { INTERESTS, SHOWING_AGENT_MAX, SHOWING_FEEDBACK_MAX } from "./showings.ts";
3234 import { KINDS, STATUSES, TONES } from "./generate.ts";
3335 import { title } from "./env.ts";
3436 import { fields } from "./http.ts";
@@ -121,6 +123,24 @@ export const tools: readonly Tool[] = [
121123 },
122124 run: (db, who, args) => getListing(db, who, { id: Number(args.id) }),
123125 },
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 },
124144 {
125145 name: "launch_kit",
126146 description:
src/pages/home.ts +1 −1
@@ -236,7 +236,7 @@ function list(listings: readonly ListingSummary[], wanted: Find): string {
236236 <span class="card-body">
237237 <b>${escape(l.address)}</b>
238238 <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>

This pill (singular/plural, hidden at 0) has no test that renders it; add one that fails without this line.

240240 </span>
241241 </a>
242242 </li>`;
src/pages/listing.ts +6 −1
@@ -4,7 +4,7 @@
44 * flyer, which is also a page of its own for printing (`flyerPage`).
55 */
66
7 import type { Version } from "../actions.ts";
7 import type { LoggedShowing, Version } from "../actions.ts";
88 import type { Check, Finding } from "../fairhousing.ts";
99 import { classHref } from "./fairhousing.ts";
1010 import {
@@ -24,7 +24,9 @@ import { altOf, approvalLabel, ASSET_MAX, propertyPath, type Asset, type Launche
2424 import { isPost, lengthLine, meantForX, overXNote } from "../postlength.ts";
2525 import { isSample } from "../photos.ts";
2626 import type { Who } from "../who.ts";
27 import { dayOf } from "../showings.ts";
2728 import { ago, layout, toast } from "./layout.ts";
29 import { showingsSection } from "./showings.ts";
2830
2931 /** Where a listing's photo is served: a sample from static/, an upload from its listing. */
3032 export const photoUrl = (listing: Pick<Listing, "id">, name: string): string =>
@@ -211,6 +213,8 @@ export function listingPage(input: {
211213 versions?: Readonly<Record<string, readonly Version[]>>;
212214 tab?: string | null;
213215 notice?: string | undefined;
216 /** The logged showings: given only to whoever may update the listing. */
217 showings?: readonly LoggedShowing[];
214218 }): string {
215219 const { who, listing: l, editable, notice } = input;
216220 const origin = input.origin ?? "";
@@ -274,6 +278,7 @@ export function listingPage(input: {
274278 </div>`,
275279 ).join("")}
276280 </div>
281 ${input.showings === undefined ? "" : showingsSection(l.id, input.showings, dayOf(new Date()))}
277282 ${
278283 editable
279284 ? `<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 {
2424 editAsset,
2525 getAudit,
2626 getHome,
27 addShowing,
2728 addVisitor,
2829 getListing,
30 listShowings,
2931 listVisitors,
32 removeShowing,
3033 removeVisitor,
3134 getNote,
3235 launchListing,
@@ -72,6 +75,7 @@ import { draftOf, home, type Draft } from "./pages/home.ts";
7275 import { layout } from "./pages/layout.ts";
7376 import { fairHousingPage } from "./pages/fairhousing.ts";
7477 import { factsPage } from "./pages/facts.ts";
78 import { INTERESTS, SHOWING_AGENT_MAX, SHOWING_FEEDBACK_MAX } from "./showings.ts";
7579 import { emailPage } from "./pages/email.ts";
7680 import { publicOrigin } from "./env.ts";
7781 import { propertyPage } from "./pages/property.ts";
@@ -242,6 +246,10 @@ export const LISTING_SUMMARY: Schema = {
242246 type: "integer",
243247 description: "Phrases the Fair Housing check flags across every asset.",
244248 },
249 showings: {
250 type: "integer",
251 description: "How many showings the agent logged; 0 for anyone who may not update the listing.",
252 },
245253 approvals: {
246254 type: "array",
247255 description: "Each asset and whether somebody has signed off its words.",
@@ -252,12 +260,39 @@ export const LISTING_SUMMARY: Schema = {
252260 },
253261 },
254262 },
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"],
256286 };
257287 export const LISTING: Schema = {
258288 type: "object",
259289 properties: {
260290 ...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 },
261296 assets: {
262297 type: "array",
263298 items: {
@@ -360,6 +395,7 @@ const answer = <T>(result: Result<T>, status = 200): Response =>
360395 {
361396 error: result.error,
362397 message: result.message,
398 ...(result.field === undefined ? {} : { field: result.field }),
363399 ...(result.existing === undefined ? {} : { id: result.existing.id }),
364400 },
365401 STATUS[result.error],
@@ -505,8 +541,10 @@ export const routes: readonly Route[] = [
505541 if (!found.ok) return page(c.who, found, () => "");
506542 const earlier = listVersions(c.db, c.who, { id: Number(c.params.id) });
507543 const versions = earlier.ok ? earlier.value : {};
544 const showings = listShowings(c.db, c.who, { id: Number(c.params.id) });
508545 return html(
509546 listingPage({
547 ...(showings.ok ? { showings: showings.value } : {}),
510548 who: c.who,
511549 listing: found.value,
512550 origin: publicOrigin,
@@ -672,6 +710,32 @@ export const routes: readonly Route[] = [
672710 );
673711 },
674712 },
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 },
675739 {
676740 method: "POST",
677741 path: "/listings/:id/visitors",
@@ -991,8 +1055,38 @@ export const routes: readonly Route[] = [
9911055 {
9921056 method: "GET",
9931057 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"] } },
9961090 },
9971091 {
9981092 method: "PATCH",
src/sample.ts +32 −1
@@ -8,6 +8,7 @@
88 import type { Database } from "bun:sqlite";
99 import { draftMissingUpdates, insertListing, markStatus, redraftUpdate } from "./actions.ts";
1010 import type { ListingInput } from "./listing.ts";
11 import { dayOf, type Interest } from "./showings.ts";
1112
1213 export const SAMPLE: ListingInput = {
1314 address: "412 Alder Lane, Portland, OR 97214",
@@ -111,16 +112,29 @@ interface Sample {
111112 readonly alt: readonly string[];
112113 /** Where the sample stands; absent means active. */
113114 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])[];
114117 }
115118
116119 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 },
118127 {
119128 name: "sample-condo",
120129 listing: SAMPLE_CONDO,
121130 photos: ["sample-condo-front.svg", "sample-condo-living.svg"],
122131 headshot: "sample-agent-2.svg",
123132 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 ],
124138 // Sold, so the meeting sees the band, the pill and the "Just sold" post and email.
125139 sold_at: 752000,
126140 },
@@ -130,6 +144,10 @@ const SAMPLES: readonly Sample[] = [
130144 photos: ["sample-house-front.svg", "sample-house-yard.svg"],
131145 headshot: "sample-agent-3.svg",
132146 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 ],
133151 },
134152 ];
135153
@@ -169,6 +187,19 @@ export function seed(db: Database): void {
169187 .get(id)!;
170188 if (row.name !== null && row.open === 0) signOff(db, id, row.name);
171189 }
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 }
172203 // A sample planted before statuses existed is marked sold once too; after that it is the owner's to change.
173204 for (const s of SAMPLES) {
174205 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", (
6060 resource: "audit",
6161 verb: "list",
6262 });
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" });
6366 for (const [method, path] of [
67 ["POST", "/api/notes/7/"],
68 ["POST", "/api/notes//x"],
6469 ["PUT", "/api/notes"],
6570 ["GET", "/api/notes/7/x"],
6671 ["GET", "/api/notesy"],
src/security.ts +3
@@ -96,6 +96,9 @@ export function doorOf(
9696 const verb = ROW_VERB[method];
9797 return verb === undefined ? undefined : { resource, verb };
9898 }
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" };
99102 }
100103 return undefined;
101104 }
src/server.test.ts +1
@@ -147,6 +147,7 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
147147 "delete_note",
148148 "list_listings",
149149 "get_listing",
150 "log_showing",
150151 "launch_kit",
151152 "launch_listing",
152153 "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("&lt;script&gt;");
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
425425 tty.press("a");
426426 expect(plain(tty.screen())).toContain("Approval withdrawn.");
427427 });
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 @@
1515 * one as `Name, email`, d removes the one under the cursor, esc goes back; k shows
1616 * the launch kit, the file the page offers to download; R shows the seller
1717 * 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.
1921 * - notes: a adds, d removes.
2022 *
2123 * j/k move, q leaves. Launching, with its photos, is the page's. Plain ANSI,
@@ -24,9 +26,11 @@
2426 */
2527
2628 import type { Database } from "bun:sqlite";
29 import { INTEREST_LABELS } from "./showings.ts";
2730 import { FILTERS, parseFind, type Find } from "./find.ts";
2831 import {
2932 addNote,
33 addShowing,
3034 addVisitor,
3135 approveAsset,
3236 canDelete,
@@ -38,8 +42,10 @@ import {
3842 getListing,
3943 listListings,
4044 listNotes,
45 listShowings,
4146 listVisitors,
4247 listVersions,
48 removeShowing,
4349 removeVisitor,
4450 restoreAsset,
4551 retoneListing,
@@ -105,7 +111,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
105111 /** The email blast or the open house follow-up, laid out as the page's preview is, instead of the assets. */
106112 let previewing: "email" | "email_followup" | "email_reminder" | undefined;
107113 /** The sign-in visitors of the open house, the cursor on one. */
108 let visiting = false;
114 let visiting: "visitors" | "showings" | false = false;
109115 let visitorCursor = 0;
110116 /** The launch kit, as the file would read. */
111117 let kitting = false;
@@ -117,7 +123,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
117123 let versioning: Kind | undefined;
118124 let cursor = 0;
119125 /** 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;
121127 let typed = "";
122128 /** The filter and the words the listings view is narrowed to. */
123129 let found: Find = { status: "all", q: "" };
@@ -159,6 +165,18 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
159165 ...(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)}`)),
160166 ];
161167 }
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 }
162180 if (visiting) {
163181 const sheet = visitors(l.id);
164182 if (!sheet.ok) return [sheet.message];
@@ -208,7 +226,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
208226 if (reading !== undefined) {
209227 lines.push(...readLines());
210228 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";
212231 else if (previewing || kitting || reporting || planning) help = "esc back to the assets · q quit";
213232 else if (versioning !== undefined) help = "1-5 put that version back · esc back to the assets · q quit";
214233 else help =
@@ -247,7 +266,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
247266 }
248267 lines.push("");
249268 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)`;
251270 // The tail of what is typed, so the cursor stays on the screen.
252271 const room = Math.max(20, size.columns - 4);
253272 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 }
267286 const at = typed.lastIndexOf(",");
268287 const done = addVisitor(db, me, { id: reading!, name: at < 0 ? typed : typed.slice(0, at), email: at < 0 ? "" : typed.slice(at + 1) });
269288 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;
270293 } else if (typing.for === "search") {
271294 found = parseFind(found.status, typed);
272295 cursor = 0;
@@ -325,10 +348,39 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
325348 return undefined;
326349 }
327350 if (key === "V" && idle && found?.ok === true) {
328 visiting = true;
351 visiting = "visitors";
329352 visitorCursor = 0;
330353 return undefined;
331354 }
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 }
332384 if (visiting && found?.ok === true && key !== "\x1b") {
333385 const sheet = visitors(found.value.id);
334386 const rows = sheet.ok ? sheet.value : [];
@@ -383,7 +435,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
383435 if (key === "\x1b") {
384436 if (previewing || kitting || reporting || planning || visiting) {
385437 previewing = undefined;
386 kitting = reporting = planning = visiting = false;
438 kitting = reporting = planning = false;
439 visiting = false;
387440 } else {
388441 reading = undefined;
389442 cursor = 0;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs