Public: anyone can read this; the crew can change it.

The agent can keep a private note on a deal that never reaches a client #308

Merged opened this

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

Merged

mason/deal-clock:mason/306-the-agent-can-keep-a-private-note-on-a-d → main 3 commits fork

  • Merged. mason/306-the-agent-can-keep-a-private-note-on-a-d was merged into main as a4d9f16.

Review

Sign in to review this change.
18 files changed +268 −17
README.md +4 −1
@@ -175,7 +175,10 @@ that should stay; nothing changes until you confirm. In the terminal press `V`
175175 on a deal: it asks for the new date and the reason, prints the same list, and
176176 takes `yes` to move them all or the numbers of the dates to keep where they are.
177177 Every date also takes a short working note only
178 you see. A deal that falls through is marked with **Fell through…** and moves to
178 you see. Each deal also has a **Private note** box (up to 500 characters)
179 that never reaches a draft, the client timeline or anything printed; in the
180 terminal, open the deal and press `K` to see it and write a new one. A deal
181 that falls through is marked with **Fell through…** and moves to
179182 the Closed list; **Reopen** brings it back. Closing a deal is marking its
180183 Closing date done: it steps off the clock, and a month later a drafted "How is
181184 the new home?" check-in waits in Review, and a year after, a "Happy home
contract/openapi.json +17 −2
@@ -1024,6 +1024,11 @@
10241024 "maximum": 1000,
10251025 "description": "The agent's commission rate in hundredths of a percent (250 is 2.5%), 0 to 1000; null while none is set. Never on a client page."
10261026 },
1027 "note": {
1028 "type": "string",
1029 "maxLength": 500,
1030 "description": "The agent's private note on the deal; empty when none. Never sent to a client."
1031 },
10271032 "closing_time": {
10281033 "type": [
10291034 "string",
@@ -1535,7 +1540,7 @@
15351540 }
15361541 },
15371542 "patch": {
1538 "summary": "Correct a deal's address, sale price, earnest money amount, closing time, closing place or side (buyer, listing or both); a field left out stays as it is, and one sent as null is cleared (not the side). Dates move one at a time, with a reason (POST /deadlines/:id/move).",
1543 "summary": "Correct a deal's address, sale price, earnest money amount, closing time, closing place or side (buyer, listing or both), or save a private note (up to 500 characters, empty to clear it; 400 with field note otherwise; internal, never in a draft, timeline, status update or handout); a field left out stays as it is, and one sent as null is cleared (not the side). Dates move one at a time, with a reason (POST /deadlines/:id/move).",
15391544 "operationId": "patchApiDealsId",
15401545 "parameters": [
15411546 {
@@ -1607,6 +1612,11 @@
16071612 "listing",
16081613 "both"
16091614 ]
1615 },
1616 "note": {
1617 "type": "string",
1618 "maxLength": 500,
1619 "description": "The agent's private note on the deal; empty clears it. Never reaches a client."
16101620 }
16111621 }
16121622 }
@@ -1615,7 +1625,7 @@
16151625 },
16161626 "responses": {
16171627 "200": {
1618 "description": "Correct a deal's address, sale price, earnest money amount, closing time, closing place or side (buyer, listing or both); a field left out stays as it is, and one sent as null is cleared (not the side). Dates move one at a time, with a reason (POST /deadlines/:id/move).",
1628 "description": "Correct a deal's address, sale price, earnest money amount, closing time, closing place or side (buyer, listing or both), or save a private note (up to 500 characters, empty to clear it; 400 with field note otherwise; internal, never in a draft, timeline, status update or handout); a field left out stays as it is, and one sent as null is cleared (not the side). Dates move one at a time, with a reason (POST /deadlines/:id/move).",
16191629 "content": {
16201630 "application/json": {
16211631 "schema": {
@@ -3328,6 +3338,11 @@
33283338 "maximum": 1000,
33293339 "description": "The agent's commission rate in hundredths of a percent (250 is 2.5%), 0 to 1000; null while none is set. Never on a client page."
33303340 },
3341 "note": {
3342 "type": "string",
3343 "maxLength": 500,
3344 "description": "The agent's private note on the deal; empty when none. Never sent to a client."
3345 },
33313346 "closing_time": {
33323347 "type": [
33333348 "string",
docs/TECHNICAL.md +2
@@ -72,6 +72,8 @@ it; an unsent draft is redrafted, a sent one keeps its approved words. Set it wi
7272 `set_reminder_lead`, and `l` on a date in the terminal. Every door reads it as
7373 `lead_days` on the date.
7474
75 A deal's private note (`deal.note`, up to `DEAL_NOTE_MAX` = 500 characters, line breaks kept, other control characters refused, empty clears it) is `setDealNote` (`src/deals.ts`, audited as `deal.note`), through the deal page's box `POST /deals/:id/note`, `PATCH /api/deals/:id` with `{"note": "…"}` (400 with field `note`), MCP `set_deal_note`, and `K` on the open deal in the terminal (the deal header shows it; an empty line clears it; the write emits `deal.changed`, so web viewers update live). It is shown on the deal page and the terminal's deal view only, travels in the export as `private_note` and back through import, and is read by no client-facing or printed place (`src/dealnote.test.ts` pins that with a distinctive word). It uses the existing `deal` `update` cell; no contract cell changed.
76
7577 A date's working note (`deadline.note`, up to `NOTE_MAX` = 200 characters, one line, empty
7678 clears it) is the agent's own: `setNote` (`src/deals.ts`, audited as `date.note` against the
7779 deal) through the sheet form `POST /deadlines/:id/note` (a refusal is a 422 that keeps what was
src/activity.ts +1
@@ -31,6 +31,7 @@ const WORDS: Readonly<Record<string, string>> = {
3131 "paper.add": "added a paper to a date's checklist",
3232 "paper.tick": "ticked a paper in or out",
3333 "paper.remove": "removed a paper from a date's checklist",
34 "deal.note": "saved the private note on a deal",
3435 "deal.amend": "amended the contract dates",
3536 "deal.fell": "marked a deal fell through",
3637 "deal.reopen": "brought a deal that fell through back onto the clock",
src/db.ts +2
@@ -586,6 +586,8 @@ export const migrations: string[] = [
586586 CREATE INDEX IF NOT EXISTS paper_deadline ON paper (deadline_id)`,
587587 // A move that put an earlier move back: it offers no undo of its own, so undoing is single level.
588588 `ALTER TABLE deadline_move ADD COLUMN undo INTEGER NOT NULL DEFAULT 0`,
589 // The agent's private note on a deal: for their eyes only, never in a draft, timeline or handout.
590 `ALTER TABLE deal ADD COLUMN note TEXT NOT NULL DEFAULT '' CHECK (length(note) <= 500)`,
589591 ];
590592
591593 export function openDb(dir = dataDir): Database {
src/dealnote.test.ts added +124
@@ -0,0 +1,124 @@
1 /** A deal's private note: saved at every door, seen on the deal page, and in no client-facing or printed place. */
2 import { afterAll, expect, test } from "bun:test";
3 import { mkdtempSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { addDays, today, DEFAULT_ZONE } from "./dates.ts";
7 import { sheet as csv } from "./csv.ts";
8 import { calendar } from "./ics.ts";
9 import { agent } from "./env.ts";
10 import { drafts, statusDrafts } from "./reminders.ts";
11 import { forgetRationing } from "./security.ts";
12 import { serve } from "./server.ts";
13 import { sign } from "./who.ts";
14 import type { DealView } from "./deals.ts";
15
16 const KEY = "a-key-only-the-forge-holds";
17 const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-dealnote-")), identityKey: KEY });
18 const base = `http://${server.hostname}:${server.port}`;
19 afterAll(() => server.stop(true));
20 const as = (role: string, name: string) => {
21 const id = `${name}|${name}|${role}|${Date.now() + 600_000}`;
22 return { "x-forge-identity": id, "x-forge-signature": sign(id, KEY) };
23 };
24 const me = as("user", "alice");
25 const json = { ...me, "content-type": "application/json" };
26 const SECRET = "QUIETCLOSING-<b>xyz";
27
28 async function fresh() {
29 forgetRationing();
30 const made = await fetch(base + "/api/deals", {
31 method: "POST",
32 headers: json,
33 body: JSON.stringify({ address: "9 Quiet Way", open_anyway: true, buyer: "B Buyer", seller: "S Seller", contract_date: addDays(today(DEFAULT_ZONE), -20), closing_date: addDays(today(DEFAULT_ZONE), 3) }),
34 });
35 return (await made.json()) as DealView;
36 }
37 const read = async (id: number) => (await (await fetch(`${base}/api/deals/${id}`, { headers: me })).json()) as DealView;
38 const patch = (id: number, body: unknown, headers: Record<string, string> = json) =>
39 fetch(`${base}/api/deals/${id}`, { method: "PATCH", headers, body: JSON.stringify(body) });
40
41 test("PATCH saves, clears and bounds the private note, refuses control characters, and audits deal.note", async () => {
42 const deal = await fresh();
43 expect(deal.note).toBe("");
44 const saved = await patch(deal.id, { note: "seller wants a quiet closing" });
45 expect(saved.status).toBe(200);
46 expect(await saved.json()).toMatchObject({ note: "seller wants a quiet closing", address: "9 Quiet Way" });
47 expect((await read(deal.id)).note).toBe("seller wants a quiet closing");
48 expect((await patch(deal.id, { note: "line one\nline two" })).status).toBe(200);
49 expect((await patch(deal.id, { note: "x".repeat(501) })).status).toBe(400);
50 expect((await patch(deal.id, { note: "bell\u0007" })).status).toBe(400);
51 expect((await patch(deal.id, { note: "esc\u001b[31m" })).status).toBe(400);
52 expect((await patch(deal.id, { note: 5 })).status).toBe(400);
53 expect((await patch(deal.id, { note: "x".repeat(500) })).status).toBe(200);
54 const both = await patch(deal.id, { note: "with address", address: "10 Quiet Way" });
55 expect(await both.json()).toMatchObject({ note: "with address", address: "10 Quiet Way" });
56 expect((await patch(deal.id, { note: "" })).status).toBe(200);
57 expect((await read(deal.id)).note).toBe("");
58 expect((await patch(deal.id, { note: "x" }, { "content-type": "application/json" })).status).toBeGreaterThanOrEqual(401);
59 const audit = await (await fetch(`${base}/api/audit`, { headers: as("admin", "root") })).text();
60 expect(audit).toContain("deal.note");
61 });
62
63 test("the form saves without JavaScript, escapes the note, and refuses a long one", async () => {
64 const deal = await fresh();
65 const form = (note: string, headers = me) =>
66 fetch(`${base}/deals/${deal.id}/note`, { method: "POST", headers: { ...headers, "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ note }), redirect: "manual" });
67 const page = async () => (await fetch(`${base}/deals/${deal.id}`, { headers: me })).text();
68 expect(await page()).toContain(`action="/deals/${deal.id}/note"`);
69 expect((await form(SECRET)).status).toBe(303);
70 const html = await page();
71 expect(html).toContain("QUIETCLOSING-&lt;b&gt;xyz");
72 expect(html).not.toContain(SECRET);
73 await form("y".repeat(501));
74 expect(await page()).not.toContain("y".repeat(501));
75 const guest = await form("nope", {});
76 expect(guest.status).toBe(303);
77 expect(guest.headers.get("location")).toContain("/deals/");
78 expect(await page()).toContain("QUIETCLOSING");
79 // A signed-in person who is not the deal's author is let by the table's `update` cell for a user, as for the address.
80 expect((await form("by another", as("user", "mallory"))).status).toBe(303);
81 });
82
83 test("the note is on the deal page and in no client-facing, printed or shared place", async () => {
84 const deal = await fresh();
85 const WORD = "ZEBRAWORD";
86 expect((await patch(deal.id, { note: `seller wants a quiet closing ${WORD}` })).status).toBe(200);
87 const get = async (path: string) => (await fetch(base + path, { headers: me })).text();
88 expect(await get(`/deals/${deal.id}`)).toContain(WORD);
89 const full = await read(deal.id);
90 expect(full.note).toContain(WORD);
91 for (const path of [`/deals/${deal.id}/timeline`, `/deals/${deal.id}/file`, `/deals/${deal.id}/ics`, `/deals/${deal.id}/sheets`, "/week", "/"])
92 expect(await get(path), path).not.toContain(WORD);
93 expect(csv([full])).not.toContain(WORD);
94 expect(calendar(full, "x.example")).not.toContain(WORD);
95 expect(JSON.stringify(drafts(full, agent))).not.toContain(WORD);
96 expect(JSON.stringify(statusDrafts(full, agent))).not.toContain(WORD);
97 const status = await (await fetch(`${base}/api/deals/${deal.id}/status`, { headers: me })).text();
98 expect(status).not.toContain(WORD);
99 });
100
101 test("the MCP tool saves the private note with the same rule", async () => {
102 const deal = await fresh();
103 const call = async (note: string) =>
104 JSON.stringify(await (await fetch(base + "/mcp", { method: "POST", headers: json, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "set_deal_note", arguments: { id: deal.id, note } } }) })).json());
105 expect(await call("kept quiet")).toContain("kept quiet");
106 expect((await read(deal.id)).note).toBe("kept quiet");
107 expect(await call("z".repeat(501))).toContain("500 characters");
108 });
109
110 test("the note survives the export and import round trip", async () => {
111 const deal = await fresh();
112 expect((await patch(deal.id, { note: "carry me over" })).status).toBe(200);
113 const file = await (await fetch(`${base}/api/deals/export`, { headers: me })).json() as { deals: { address: string; private_note: string }[] };
114 expect(file.deals.find((d) => d.address === "9 Quiet Way" && d.private_note === "carry me over")).toBeDefined();
115 const other = as("user", "bea");
116 forgetRationing();
117 const sent = await fetch(`${base}/api/deals/import`, { method: "POST", headers: { ...other, "content-type": "application/json" }, body: JSON.stringify(file) });
118 expect(sent.status).toBe(201);
119 const mine = (await (await fetch(`${base}/api/deals`, { headers: other })).json()) as DealView[];
120 expect(mine.some((d) => d.note === "carry me over")).toBe(true);
121 const bad = { ...file, deals: [{ ...file.deals[0], private_note: "bad\u0007" }] };
122 forgetRationing();
123 expect((await fetch(`${base}/api/deals/import`, { method: "POST", headers: { ...other, "content-type": "application/json" }, body: JSON.stringify(bad) })).status).toBe(422);
124 });
src/deals.ts +27
@@ -68,6 +68,8 @@ export interface Deal {
6868 readonly closing_place: string | null;
6969 /** Which side the agent represents on this deal. */
7070 readonly side: Side;
71 /** The agent's private note on the deal; empty when none. Never on a client page, draft or handout. */
72 readonly note: string;
7173 }
7274 export interface Party {
7375 readonly id: number;
@@ -1019,6 +1021,31 @@ export function setNote(
10191021 return within(db, who, NOTE_WHAT, dealId, ok(reread(db, input.id)));
10201022 }
10211023
1024 /** The longest private note on a deal. */
1025 export const DEAL_NOTE_MAX = 500;
1026 /** What the audit trail calls saving a deal's private note. */
1027 export const DEAL_NOTE_WHAT = "deal.note";
1028
1029 /** A private note: text with line breaks kept, up to DEAL_NOTE_MAX characters; any other control character refuses it. Empty clears. */
1030 export function dealNoteText(value: unknown): string | undefined {
1031 if (typeof value !== "string") return undefined;
1032 const text = value.replace(/\r\n?/g, "\n").trim();
1033 if (/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/.test(text)) return undefined;
1034 return text.length <= DEAL_NOTE_MAX ? text : undefined;
1035 }
1036
1037 /** Save the agent's private note on a deal: for their eyes only, empty to clear it. */
1038 export function setDealNote(db: Database, who: Who, input: { id: number; note: unknown }): Result<DealView> {
1039 const found = dealFor(db, who, "update", input.id);
1040 const dealId = found.ok ? found.value.id : undefined;
1041 if (!found.ok) return within(db, who, DEAL_NOTE_WHAT, dealId, found);
1042 const note = dealNoteText(input.note);
1043 if (note === undefined)
1044 return within(db, who, DEAL_NOTE_WHAT, dealId, fail("invalid", `The note is up to ${DEAL_NOTE_MAX} characters, with no control characters.`, "note"));
1045 db.query("UPDATE deal SET note = ?1 WHERE id = ?2").run(note, found.value.id);
1046 return within(db, who, DEAL_NOTE_WHAT, dealId, ok(withClosingConflicts(db, view(db, { ...found.value, note }))));
1047 }
1048
10221049 /** Whether `value` is a lead time: a whole number of days, 1 to 30. */
10231050 const isLead = (value: unknown): value is number =>
10241051 typeof value === "number" && Number.isInteger(value) && value >= LEAD_MIN && value <= LEAD_MAX;
src/export.ts +1
@@ -39,6 +39,7 @@ const dealOf = (deal: DealView): Record<string, unknown> => ({
3939 sale_price: deal.sale_price,
4040 earnest_amount: deal.earnest_amount,
4141 commission_rate: deal.commission_rate,
42 private_note: deal.note,
4243 opened_by: deal.author_name,
4344 created_at: deal.created_at,
4445 ...stateOf(deal),
src/import.ts +8 −4
@@ -14,7 +14,7 @@ import { sideOf } from "./side.ts";
1414 import { KIND, KINDS, isContactRole, isInternal, leadOf, leadOfLabel, type Kind, type PartyRole } from "./deadlines.ts";
1515 import { appointmentPlace, appointmentTime, isAppointment } from "./appointment.ts";
1616 import { closingPlace, closingTime } from "./closing.ts";
17 import { NOTE_MAX, REASON_MAX, insertDeal, line, partyText, plan, sameAddress, type NewDeal, type Plan, type Reach } from "./deals.ts";
17 import { DEAL_NOTE_MAX, NOTE_MAX, dealNoteText, REASON_MAX, insertDeal, line, partyText, plan, sameAddress, type NewDeal, type Plan, type Reach } from "./deals.ts";
1818 import { EXPORT_MAX, EXPORT_VERSION } from "./export.ts";
1919 import { PAPERS_MAX, PAPER_MAX, insertPaper, paperName } from "./papers.ts";
2020 import { emit } from "./live.ts";
@@ -61,6 +61,7 @@ export interface DealIn {
6161 plan: Plan;
6262 closingTime: string | null;
6363 closingPlace: string | null;
64 note: string;
6465 fell: { at: string; reason: string; by: string } | null;
6566 parties: PartyIn[];
6667 dates: DateIn[];
@@ -176,6 +177,9 @@ function readDeal(raw: unknown, n: number): Result<DealIn> {
176177 const place = closingPlace(d.closing_place);
177178 if (!place.ok) return refuse(n, place.message, "closing_place");
178179
180 const note = d.private_note === undefined || d.private_note === null ? "" : dealNoteText(d.private_note);
181 if (note === undefined) return refuse(n, `the private note is up to ${DEAL_NOTE_MAX} characters, with no control characters.`, "private_note");
182
179183 let fell: DealIn["fell"] = null;
180184 if (d.state === "fell_through") {
181185 const at = stamp(d.fell_at);
@@ -186,7 +190,7 @@ function readDeal(raw: unknown, n: number): Result<DealIn> {
186190 } else if (d.state !== undefined && d.state !== "open" && d.state !== "closed") {
187191 return refuse(n, "state is open, closed or fell_through.", "state");
188192 }
189 return ok({ plan: planned.value, closingTime: time.value, closingPlace: place.value, fell, parties, dates });
193 return ok({ plan: planned.value, closingTime: time.value, closingPlace: place.value, note, fell, parties, dates });
190194 }
191195
192196 /** The whole file, checked: the deals ready to write, or the one refusal that names where. Nothing is read from a database here. */
@@ -232,8 +236,8 @@ function write(db: Database, who: Who & { role: "user" | "admin" }, deals: reado
232236 // The file's own dates and parties stand in place of the standard ones.
233237 db.query("DELETE FROM deadline WHERE deal_id = ?1").run(deal.id);
234238 db.query("DELETE FROM party WHERE deal_id = ?1").run(deal.id);
235 db.query("UPDATE deal SET closing_time = ?1, closing_place = ?2, fell_at = ?3, fell_reason = ?4, fell_by = ?5 WHERE id = ?6").run(
236 d.closingTime, d.closingPlace, d.fell?.at ?? null, d.fell?.reason ?? null, d.fell?.by ?? null, deal.id,
239 db.query("UPDATE deal SET closing_time = ?1, closing_place = ?2, fell_at = ?3, fell_reason = ?4, fell_by = ?5, note = ?7 WHERE id = ?6").run(
240 d.closingTime, d.closingPlace, d.fell?.at ?? null, d.fell?.reason ?? null, d.fell?.by ?? null, deal.id, d.note,
237241 );
238242 for (const p of d.parties)
239243 db.query("INSERT INTO party (deal_id, role, name, contact, email, phone, reach) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)").run(deal.id, p.role, p.name, p.contact, p.email, p.phone, p.reach);
src/mcp.ts +11
@@ -33,6 +33,7 @@ import {
3333 sendStatusUpdate,
3434 statusUpdates,
3535 setNote,
36 setDealNote,
3637 setAppointment,
3738 setReminderLead,
3839 setOwes,
@@ -202,6 +203,16 @@ export const tools: readonly Tool[] = [
202203 return moved.ok ? ok({ ...moved.value, conflicts: conflictsOf(db, who, moved.value.deal_id) }) : moved;
203204 },
204205 },
206 {
207 name: "set_deal_note",
208 description: "Save the private note on a deal: up to 500 characters, line breaks kept, empty to clear it. Internal to the agent: it never reaches a client timeline, reminder, status update or handout.",
209 inputSchema: {
210 type: "object",
211 properties: { id: { type: "integer" }, note: { type: "string", maxLength: 500 } },
212 required: ["id", "note"],
213 },
214 run: (db, who, args) => setDealNote(db, who, { id: Number(args.id), note: args.note }),
215 },
205216 {
206217 name: "set_date_note",
207218 description: "Save the working note on a date: where it stands in a few words, one line of up to 200 characters, empty to clear it. Internal to the agent: it never reaches a client timeline, reminder or status update.",
src/migrations.pin.json +2 −1
@@ -61,5 +61,6 @@
6161 "fafa7c66cff02dd250f2bed223947161b54f286b868fdd07c07c8c6d8b4556e3",
6262 "8d0b71567ca7fc8e0927bfb1465c84a30b6702a8460145f7640e612b829530f5",
6363 "c82fe9b3aea33d99a2c65ffb4179901d5aecde6b2ef759531b94ff12035a719a",
64 "d7c7a79baf6dcd397af5c223839a821db0f0b744f6f02358ea4ba38532ae148b"
64 "d7c7a79baf6dcd397af5c223839a821db0f0b744f6f02358ea4ba38532ae148b",
65 "9d29fe2875d6dff02bf7b79a6afbea525f86bca59756a28378e979916a8cfc27"
6566 ]
src/migrations.test.ts +1 −1
@@ -13,7 +13,7 @@ test("every migration is pinned by position; a pull may only append", () => {
1313 });
1414
1515 test("a database made before the Profile line gets the column and reads the agent", () => {
16 const before = migrations.filter((m) => !/profile ADD COLUMN line|commission_rate|profile ADD COLUMN zone|ADD COLUMN cash|'anniversary'|deadline ADD COLUMN (time|place)|party ADD COLUMN reach|reminder_hold ADD COLUMN back_on|TABLE IF NOT EXISTS paper|deadline_move ADD COLUMN undo/.test(m));
16 const before = migrations.filter((m) => !/profile ADD COLUMN line|commission_rate|profile ADD COLUMN zone|ADD COLUMN cash|'anniversary'|deadline ADD COLUMN (time|place)|party ADD COLUMN reach|reminder_hold ADD COLUMN back_on|TABLE IF NOT EXISTS paper|deal ADD COLUMN note|deadline_move ADD COLUMN undo/.test(m));
1717 const db = new Database(":memory:");
1818 db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))");
1919 before.forEach((sql, i) => {
src/pages/deal.ts +9
@@ -289,6 +289,14 @@ export function dealPage(input: {
289289 </form>
290290 </details>`
291291 : "";
292 const privateNote = may
293 ? `<form class="stack" method="post" action="${path}/note">
294 <label>Private note <span class="muted small">(only you see this; it never goes to a client)</span><textarea class="input" name="note" rows="3" maxlength="500">${escape(deal.note)}</textarea></label>
295 <button class="button button-quiet button-small">Save note</button>
296 </form>`
297 : deal.note === ""
298 ? ""
299 : `<p class="small"><b>Private note</b> ${escape(deal.note)}</p>`;
292300 const editClosing = may ? closingForm(deal, path, input.closing) : "";
293301 const editSide = may
294302 ? `<form class="side-edit" method="post" action="${path}/side">
@@ -384,6 +392,7 @@ export function dealPage(input: {
384392 ${editAddress}
385393 ${editFigures}
386394 ${editClosing}
395 ${privateNote}
387396 </div>
388397 <div class="row"><a class="button button-quiet" href="${path}/timeline">Client timeline</a> <a class="button button-quiet" href="${path}/file">Print broker file</a> <a class="button button-quiet" href="${path}/ics" download>Add to calendar</a></div>
389398 </section>
src/partycontact.test.ts +1 −1
@@ -15,7 +15,7 @@ const dealHtml = (contact: string) => bare(
1515 dealPage({
1616 who,
1717 on: "2026-10-10",
18 deal: { id: 1, address: "A", closed: false, contract_date: "2026-10-01", closing: "2026-11-01", closing_time: null, closing_place: null, business_days: 0, parties: [{ id: 1, deal_id: 1, role: "lender", name: "P", contact, email: "", phone: "" }], deadlines: [], conflicts: [], closingConflicts: [], sent: [] } as unknown as DealView,
18 deal: { id: 1, address: "A", note: "", closed: false, contract_date: "2026-10-01", closing: "2026-11-01", closing_time: null, closing_place: null, business_days: 0, parties: [{ id: 1, deal_id: 1, role: "lender", name: "P", contact, email: "", phone: "" }], deadlines: [], conflicts: [], closingConflicts: [], sent: [] } as unknown as DealView,
1919 }));
2020
2121 for (const [page, render] of [["people", peopleHtml], ["deal", dealHtml]] as const) {
src/routes.ts +26 −3
@@ -87,6 +87,9 @@ import {
8787 releaseReminder,
8888 undoDecline,
8989 updateDeal,
90 setDealNote,
91 dealNoteText,
92 DEAL_NOTE_MAX,
9093 type NewDeal,
9194 } from "./deals.ts";
9295 import { activitySheet, sheet } from "./csv.ts";
@@ -317,6 +320,7 @@ export const DEAL: Schema = {
317320 sale_price: { ...FIGURE, description: "The sale price in whole dollars; null while none is set." },
318321 earnest_amount: { ...FIGURE, description: "The earnest money amount in whole dollars; null while none is set." },
319322 commission_rate: RATE,
323 note: { type: "string", maxLength: 500, description: "The agent's private note on the deal; empty when none. Never sent to a client." },
320324 closing_time: CLOSING_TIME,
321325 closing_place: CLOSING_PLACE,
322326 business_days: { enum: [0, 1] },
@@ -1171,6 +1175,17 @@ export const routes: readonly Route[] = [
11711175 return html(dealPage({ who: c.who, ...headerFor(c), agent: agentFor(c), deal: found.value, on: dayOf(agentFor(c)), problem: { value: f.address, message: saved.message } }), STATUS.invalid);
11721176 },
11731177 },
1178 {
1179 method: "POST",
1180 path: "/deals/:id/note",
1181 handle: async (c) => {
1182 const f = await form(c);
1183 if (f instanceof Response) return f;
1184 const id = Number(c.params.id);
1185 const saved = setDealNote(c.db, c.who, { id, note: f.note ?? "" });
1186 return back(saved.ok ? (saved.value.note === "" ? "Private note cleared." : "Private note saved.") : saved.message, `/deals/${id}`);
1187 },
1188 },
11741189 {
11751190 method: "POST",
11761191 path: "/deals/:id/side",
@@ -1733,11 +1748,19 @@ export const routes: readonly Route[] = [
17331748 const body = await readJson(c.request);
17341749 if (body === undefined) return json({ error: "too_large" }, 413);
17351750 const f = fields(body);
1736 return answer(updateDeal(c.db, c.who, { id: Number(c.params.id), address: f.address, salePrice: f.sale_price, earnestAmount: f.earnest_amount, commissionRate: f.commission_rate, closingTime: f.closing_time, closingPlace: f.closing_place, side: f.side }));
1751 const id = Number(c.params.id);
1752 // A private note outside its rule is refused before anything else in the request is applied.
1753 if ("note" in f && dealNoteText(f.note) === undefined)
1754 return json({ error: "invalid", message: `The note is up to ${DEAL_NOTE_MAX} characters, with no control characters.`, field: "note" }, 400);
1755 const others = Object.keys(f).some((k) => k !== "note");
1756 if ("note" in f && !others) return answer(setDealNote(c.db, c.who, { id, note: f.note }));
1757 const updated = updateDeal(c.db, c.who, { id, address: f.address, salePrice: f.sale_price, earnestAmount: f.earnest_amount, commissionRate: f.commission_rate, closingTime: f.closing_time, closingPlace: f.closing_place, side: f.side });
1758 if (!updated.ok || !("note" in f)) return answer(updated);
1759 return answer(setDealNote(c.db, c.who, { id, note: f.note }));
17371760 },
17381761 api: {
1739 summary: "Correct a deal's address, sale price, earnest money amount, closing time, closing place or side (buyer, listing or both); a field left out stays as it is, and one sent as null is cleared (not the side). Dates move one at a time, with a reason (POST /deadlines/:id/move).",
1740 body: { type: "object", properties: { address: { type: "string", minLength: 3, maxLength: 200 }, sale_price: FIGURE, earnest_amount: FIGURE, commission_rate: RATE, closing_time: CLOSING_TIME, closing_place: CLOSING_PLACE, side: { enum: [...SIDES] } } },
1762 summary: "Correct a deal's address, sale price, earnest money amount, closing time, closing place or side (buyer, listing or both), or save a private note (up to 500 characters, empty to clear it; 400 with field note otherwise; internal, never in a draft, timeline, status update or handout); a field left out stays as it is, and one sent as null is cleared (not the side). Dates move one at a time, with a reason (POST /deadlines/:id/move).",
1763 body: { type: "object", properties: { address: { type: "string", minLength: 3, maxLength: 200 }, sale_price: FIGURE, earnest_amount: FIGURE, commission_rate: RATE, closing_time: CLOSING_TIME, closing_place: CLOSING_PLACE, side: { enum: [...SIDES] }, note: { type: "string", maxLength: 500, description: "The agent's private note on the deal; empty clears it. Never reaches a client." } } },
17411764 answer: DEAL,
17421765 },
17431766 },
src/server.test.ts +1
@@ -187,6 +187,7 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
187187 "reopen_deal",
188188 "waive_date",
189189 "move_date",
190 "set_deal_note",
190191 "set_date_note",
191192 "set_appointment",
192193 "set_reminder_lead",
src/terminal.test.ts +18
@@ -1228,3 +1228,21 @@ test("a guest is not asked to type papers", () => {
12281228 t.press("i");
12291229 expect(lines(t).join("\n")).not.toContain("Papers on");
12301230 });
1231
1232 test("K keeps a private note on the open deal, shows it in the deal header, clears it with an empty line, and a guest is not offered it", () => {
1233 const t = terminal(db, alice, context);
1234 goTo(t, "Closing — 12 Elm St");
1235 press(t, "o", "K", "Seller wants a quiet closing\r");
1236 expect(lines(t).join("\n")).toContain("Private note: Seller wants a quiet closing");
1237 expect(deal().note).toBe("Seller wants a quiet closing");
1238 expect(db.query("SELECT count(*) AS n FROM audit WHERE what = 'deal.note'").get()).toEqual({ n: 1 });
1239 press(t, "K");
1240 expect(lines(t).join("\n")).toContain("now: Seller wants a quiet closing");
1241 press(t, "\r");
1242 expect(lines(t).join("\n")).not.toContain("Private note:");
1243 expect(deal().note).toBe("");
1244 const guest = terminal(db, { role: "anon" }, context);
1245 press(guest, "K", "sneaky\r");
1246 expect(lines(guest).join("\n")).not.toContain("Private note");
1247 expect(deal().note).toBe("");
1248 });
src/terminal.ts +13 −4
@@ -12,7 +12,7 @@
1212 * file a deal's broker file: figures, parties, every date and message (f on a deal)
1313 * activity who approved, declined or changed what, newest first (L, admins); m only messages, o older, y newer
1414 * profile your name, brokerage, phone, email, own line and time zone (I); e edits them, U goes back to the defaults
15 * deal one deal's dates and parties; a adds a date, p a party, A corrects the address, C sets the closing time and place, M amends the contract, V moves the closing and shifts the dates that hang on it (previewed first), S drafts a status update for the party under the cursor, D removes it, F marks it fell through (or brings it back), Y changes the side (buyer, listing or both)
15 * deal one deal's dates and parties; a adds a date, p a party, A corrects the address, C sets the closing time and place, M amends the contract, V moves the closing and shifts the dates that hang on it (previewed first), S drafts a status update for the party under the cursor, D removes it, F marks it fell through (or brings it back), Y changes the side (buyer, listing or both), K keeps a private note on the deal (empty clears it)
1616 *
1717 * On a date (dates or deal view): x marks it done, W waives it (the buyer gave it up), u reopens it or un-waives it, m moves it
1818 * (a new date, then the reason), U puts it back after your own move, N moves one flagged "not a business day" to the next business day, l sets how many days before it reminds, @ sets an inspection's or walkthrough's time and place, i lists its papers and adds, ticks in or out, removes or suggests one (x asks once when papers are open). o or enter opens its deal; n opens a new
@@ -59,6 +59,7 @@ import {
5959 withConflicts,
6060 removeDeadline,
6161 setNote,
62 setDealNote,
6263 setAppointment,
6364 setReminderLead,
6465 removeParty,
@@ -132,6 +133,9 @@ export interface Terminal {
132133 readonly done: boolean;
133134 }
134135
136 /** A private note on one line for the terminal: line breaks shown as a slash. */
137 const oneLine = (note: string): string => note.replace(/\n+/g, " / ");
138
135139 export function terminal(
136140 db: Database,
137141 me: Who,
@@ -324,7 +328,7 @@ export function terminal(
324328
325329 function onDeal(key: string): boolean {
326330 const deal = openDeal();
327 if (deal === undefined || !["a", "p", "D", "e", "X", "A", "C", "M", "V", "S", "F", "Y"].includes(key)) return false;
331 if (deal === undefined || !["a", "p", "D", "e", "X", "A", "C", "M", "V", "S", "F", "Y", "K"].includes(key)) return false;
328332 const party = deal.parties[cursor - deal.deadlines.length];
329333 const date = deal.deadlines[cursor];
330334 if (key === "S") {
@@ -360,6 +364,11 @@ export function terminal(
360364 if (side === undefined || (typed ?? "").trim() === "") return side === undefined ? SIDE_REFUSAL : "Kept.";
361365 return report(updateDeal(db, me, { id: deal.id, side }), `Side is now ${SIDE_CHOICE[side]}.`);
362366 });
367 if (key === "K")
368 ask(deal, [`Private note (empty clears it${deal.note === "" ? "" : `; now: ${oneLine(deal.note)}`})`], ([note]) => {
369 const set = setDealNote(db, me, { id: deal.id, note });
370 return report(set, () => (set.ok && set.value.note === "" ? "Private note cleared." : "Private note saved."));
371 });
363372 if (key === "A")
364373 ask(deal, [`Address (now ${deal.address})`], ([address]) =>
365374 report(updateDeal(db, me, { id: deal.id, address }), `Address is now ${address!.trim()}.`),
@@ -1010,7 +1019,7 @@ export function terminal(
10101019 const deal = openDeal();
10111020 if (deal === undefined) lines.push(dim("That deal is gone."));
10121021 else {
1013 lines.push(bold(deal.address), ...(deal.fell_through ? [bold(`Fell through on ${spoken(fellOn(deal))}: ${deal.fell_reason ?? ""}`)] : []), ...(deal.closed ? [bold(`Closed on ${spoken(closedOn(deal))}`)] : []), dim(`${SIDE_LABEL[deal.side]}${deal.cash === 1 ? " · Cash" : ""} · under contract ${spoken(deal.contract_date)} · closes ${spoken(deal.closing)}, ${relative(deal.closing, day)}`), ...(deal.closing_time === null && deal.closing_place === null ? [] : [dim(closingLine(deal))]), "");
1022 lines.push(bold(deal.address), ...(deal.fell_through ? [bold(`Fell through on ${spoken(fellOn(deal))}: ${deal.fell_reason ?? ""}`)] : []), ...(deal.closed ? [bold(`Closed on ${spoken(closedOn(deal))}`)] : []), dim(`${SIDE_LABEL[deal.side]}${deal.cash === 1 ? " · Cash" : ""} · under contract ${spoken(deal.contract_date)} · closes ${spoken(deal.closing)}, ${relative(deal.closing, day)}`), ...(deal.closing_time === null && deal.closing_place === null ? [] : [dim(closingLine(deal))]), ...(deal.note === "" ? [] : [dim(`Private note: ${oneLine(deal.note)}`)]), "");
10141023 deal.deadlines.forEach((date, i) => lines.push(dateLine({ deal, date }, i, false)));
10151024 if (deal.closingConflicts.length > 0) lines.push("", tint("overdue", clashText(deal.closingConflicts)));
10161025 if (deal.conflicts.length > 0) lines.push("", ...deal.conflicts.map((c) => tint("overdue", `! ${conflictLine(c)}`)));
@@ -1066,7 +1075,7 @@ export function terminal(
10661075 : view === "timeline" || view === "file"
10671076 ? "b back to the deal · d dates · q quit"
10681077 : view === "deal"
1069 ? "j/k move · t client timeline · f broker file · s messages · x done · W waive · u reopen/un-waive · m move · U undo move · N next business day · l remind lead · T note · @ time and place (inspection, walkthrough) · a add date · p add party · e edit party · A edit address · C closing time and place · M amend the contract · V closing moved · S status update to the party · X remove date/party · D remove deal · b back · q quit"
1078 ? "j/k move · t client timeline · f broker file · s messages · x done · W waive · u reopen/un-waive · m move · U undo move · N next business day · l remind lead · T note · @ time and place (inspection, walkthrough) · a add date · p add party · e edit party · A edit address · K private note · C closing time and place · M amend the contract · V closing moved · S status update to the party · X remove date/party · D remove deal · b back · q quit"
10701079 : `j/k move · x done · W waive · u reopen · m move · U undo move · N next business day · l lead · T note · @ time and place · o open deal · n new deal · c check dates · / find · R restore samples · O bring deals back · h ${showDone ? "hide" : "show"} done · w this week · r reminders · P people · I profile · E export · L activity · q quit`;
10711080 lines.push(dim(me.role === "anon" ? `${keys} · sign in with a key the forge knows to change anything` : keys));
10721081 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs