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
Check · Review · Land · Live done — Landed and live in production.
mason/deal-clock:mason/306-the-agent-can-keep-a-private-note-on-a-d → main 3 commits fork
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` | ||
| 175 | 175 | on a deal: it asks for the new date and the reason, prints the same list, and |
| 176 | 176 | takes `yes` to move them all or the numbers of the dates to keep where they are. |
| 177 | 177 | 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 | |
| 179 | 182 | the Closed list; **Reopen** brings it back. Closing a deal is marking its |
| 180 | 183 | Closing date done: it steps off the clock, and a month later a drafted "How is |
| 181 | 184 | the new home?" check-in waits in Review, and a year after, a "Happy home |
contract/openapi.json +17 −2
| @@ -1024,6 +1024,11 @@ | ||
| 1024 | 1024 | "maximum": 1000, |
| 1025 | 1025 | "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." |
| 1026 | 1026 | }, |
| 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 | }, | |
| 1027 | 1032 | "closing_time": { |
| 1028 | 1033 | "type": [ |
| 1029 | 1034 | "string", |
| @@ -1535,7 +1540,7 @@ | ||
| 1535 | 1540 | } |
| 1536 | 1541 | }, |
| 1537 | 1542 | "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).", | |
| 1539 | 1544 | "operationId": "patchApiDealsId", |
| 1540 | 1545 | "parameters": [ |
| 1541 | 1546 | { |
| @@ -1607,6 +1612,11 @@ | ||
| 1607 | 1612 | "listing", |
| 1608 | 1613 | "both" |
| 1609 | 1614 | ] |
| 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." | |
| 1610 | 1620 | } |
| 1611 | 1621 | } |
| 1612 | 1622 | } |
| @@ -1615,7 +1625,7 @@ | ||
| 1615 | 1625 | }, |
| 1616 | 1626 | "responses": { |
| 1617 | 1627 | "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).", | |
| 1619 | 1629 | "content": { |
| 1620 | 1630 | "application/json": { |
| 1621 | 1631 | "schema": { |
| @@ -3328,6 +3338,11 @@ | ||
| 3328 | 3338 | "maximum": 1000, |
| 3329 | 3339 | "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." |
| 3330 | 3340 | }, |
| 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 | }, | |
| 3331 | 3346 | "closing_time": { |
| 3332 | 3347 | "type": [ |
| 3333 | 3348 | "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 | ||
| 72 | 72 | `set_reminder_lead`, and `l` on a date in the terminal. Every door reads it as |
| 73 | 73 | `lead_days` on the date. |
| 74 | 74 | |
| 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 | ||
| 75 | 77 | A date's working note (`deadline.note`, up to `NOTE_MAX` = 200 characters, one line, empty |
| 76 | 78 | clears it) is the agent's own: `setNote` (`src/deals.ts`, audited as `date.note` against the |
| 77 | 79 | 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>> = { | ||
| 31 | 31 | "paper.add": "added a paper to a date's checklist", |
| 32 | 32 | "paper.tick": "ticked a paper in or out", |
| 33 | 33 | "paper.remove": "removed a paper from a date's checklist", |
| 34 | "deal.note": "saved the private note on a deal", | |
| 34 | 35 | "deal.amend": "amended the contract dates", |
| 35 | 36 | "deal.fell": "marked a deal fell through", |
| 36 | 37 | "deal.reopen": "brought a deal that fell through back onto the clock", |
src/db.ts +2
| @@ -586,6 +586,8 @@ export const migrations: string[] = [ | ||
| 586 | 586 | CREATE INDEX IF NOT EXISTS paper_deadline ON paper (deadline_id)`, |
| 587 | 587 | // A move that put an earlier move back: it offers no undo of its own, so undoing is single level. |
| 588 | 588 | `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)`, | |
| 589 | 591 | ]; |
| 590 | 592 | |
| 591 | 593 | 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-<b>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 { | ||
| 68 | 68 | readonly closing_place: string | null; |
| 69 | 69 | /** Which side the agent represents on this deal. */ |
| 70 | 70 | 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; | |
| 71 | 73 | } |
| 72 | 74 | export interface Party { |
| 73 | 75 | readonly id: number; |
| @@ -1019,6 +1021,31 @@ export function setNote( | ||
| 1019 | 1021 | return within(db, who, NOTE_WHAT, dealId, ok(reread(db, input.id))); |
| 1020 | 1022 | } |
| 1021 | 1023 | |
| 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 | ||
| 1022 | 1049 | /** Whether `value` is a lead time: a whole number of days, 1 to 30. */ |
| 1023 | 1050 | const isLead = (value: unknown): value is number => |
| 1024 | 1051 | 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> => ({ | ||
| 39 | 39 | sale_price: deal.sale_price, |
| 40 | 40 | earnest_amount: deal.earnest_amount, |
| 41 | 41 | commission_rate: deal.commission_rate, |
| 42 | private_note: deal.note, | |
| 42 | 43 | opened_by: deal.author_name, |
| 43 | 44 | created_at: deal.created_at, |
| 44 | 45 | ...stateOf(deal), |
src/import.ts +8 −4
| @@ -14,7 +14,7 @@ import { sideOf } from "./side.ts"; | ||
| 14 | 14 | import { KIND, KINDS, isContactRole, isInternal, leadOf, leadOfLabel, type Kind, type PartyRole } from "./deadlines.ts"; |
| 15 | 15 | import { appointmentPlace, appointmentTime, isAppointment } from "./appointment.ts"; |
| 16 | 16 | 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"; | |
| 18 | 18 | import { EXPORT_MAX, EXPORT_VERSION } from "./export.ts"; |
| 19 | 19 | import { PAPERS_MAX, PAPER_MAX, insertPaper, paperName } from "./papers.ts"; |
| 20 | 20 | import { emit } from "./live.ts"; |
| @@ -61,6 +61,7 @@ export interface DealIn { | ||
| 61 | 61 | plan: Plan; |
| 62 | 62 | closingTime: string | null; |
| 63 | 63 | closingPlace: string | null; |
| 64 | note: string; | |
| 64 | 65 | fell: { at: string; reason: string; by: string } | null; |
| 65 | 66 | parties: PartyIn[]; |
| 66 | 67 | dates: DateIn[]; |
| @@ -176,6 +177,9 @@ function readDeal(raw: unknown, n: number): Result<DealIn> { | ||
| 176 | 177 | const place = closingPlace(d.closing_place); |
| 177 | 178 | if (!place.ok) return refuse(n, place.message, "closing_place"); |
| 178 | 179 | |
| 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 | ||
| 179 | 183 | let fell: DealIn["fell"] = null; |
| 180 | 184 | if (d.state === "fell_through") { |
| 181 | 185 | const at = stamp(d.fell_at); |
| @@ -186,7 +190,7 @@ function readDeal(raw: unknown, n: number): Result<DealIn> { | ||
| 186 | 190 | } else if (d.state !== undefined && d.state !== "open" && d.state !== "closed") { |
| 187 | 191 | return refuse(n, "state is open, closed or fell_through.", "state"); |
| 188 | 192 | } |
| 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 }); | |
| 190 | 194 | } |
| 191 | 195 | |
| 192 | 196 | /** 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 | ||
| 232 | 236 | // The file's own dates and parties stand in place of the standard ones. |
| 233 | 237 | db.query("DELETE FROM deadline WHERE deal_id = ?1").run(deal.id); |
| 234 | 238 | 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, | |
| 237 | 241 | ); |
| 238 | 242 | for (const p of d.parties) |
| 239 | 243 | 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 { | ||
| 33 | 33 | sendStatusUpdate, |
| 34 | 34 | statusUpdates, |
| 35 | 35 | setNote, |
| 36 | setDealNote, | |
| 36 | 37 | setAppointment, |
| 37 | 38 | setReminderLead, |
| 38 | 39 | setOwes, |
| @@ -202,6 +203,16 @@ export const tools: readonly Tool[] = [ | ||
| 202 | 203 | return moved.ok ? ok({ ...moved.value, conflicts: conflictsOf(db, who, moved.value.deal_id) }) : moved; |
| 203 | 204 | }, |
| 204 | 205 | }, |
| 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 | }, | |
| 205 | 216 | { |
| 206 | 217 | name: "set_date_note", |
| 207 | 218 | 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 @@ | ||
| 61 | 61 | "fafa7c66cff02dd250f2bed223947161b54f286b868fdd07c07c8c6d8b4556e3", |
| 62 | 62 | "8d0b71567ca7fc8e0927bfb1465c84a30b6702a8460145f7640e612b829530f5", |
| 63 | 63 | "c82fe9b3aea33d99a2c65ffb4179901d5aecde6b2ef759531b94ff12035a719a", |
| 64 | "d7c7a79baf6dcd397af5c223839a821db0f0b744f6f02358ea4ba38532ae148b" | |
| 64 | "d7c7a79baf6dcd397af5c223839a821db0f0b744f6f02358ea4ba38532ae148b", | |
| 65 | "9d29fe2875d6dff02bf7b79a6afbea525f86bca59756a28378e979916a8cfc27" | |
| 65 | 66 | ] |
src/migrations.test.ts +1 −1
| @@ -13,7 +13,7 @@ test("every migration is pinned by position; a pull may only append", () => { | ||
| 13 | 13 | }); |
| 14 | 14 | |
| 15 | 15 | 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)); | |
| 17 | 17 | const db = new Database(":memory:"); |
| 18 | 18 | db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))"); |
| 19 | 19 | before.forEach((sql, i) => { |
src/pages/deal.ts +9
| @@ -289,6 +289,14 @@ export function dealPage(input: { | ||
| 289 | 289 | </form> |
| 290 | 290 | </details>` |
| 291 | 291 | : ""; |
| 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>`; | |
| 292 | 300 | const editClosing = may ? closingForm(deal, path, input.closing) : ""; |
| 293 | 301 | const editSide = may |
| 294 | 302 | ? `<form class="side-edit" method="post" action="${path}/side"> |
| @@ -384,6 +392,7 @@ export function dealPage(input: { | ||
| 384 | 392 | ${editAddress} |
| 385 | 393 | ${editFigures} |
| 386 | 394 | ${editClosing} |
| 395 | ${privateNote} | |
| 387 | 396 | </div> |
| 388 | 397 | <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> |
| 389 | 398 | </section> |
src/partycontact.test.ts +1 −1
| @@ -15,7 +15,7 @@ const dealHtml = (contact: string) => bare( | ||
| 15 | 15 | dealPage({ |
| 16 | 16 | who, |
| 17 | 17 | 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, | |
| 19 | 19 | })); |
| 20 | 20 | |
| 21 | 21 | for (const [page, render] of [["people", peopleHtml], ["deal", dealHtml]] as const) { |
src/routes.ts +26 −3
| @@ -87,6 +87,9 @@ import { | ||
| 87 | 87 | releaseReminder, |
| 88 | 88 | undoDecline, |
| 89 | 89 | updateDeal, |
| 90 | setDealNote, | |
| 91 | dealNoteText, | |
| 92 | DEAL_NOTE_MAX, | |
| 90 | 93 | type NewDeal, |
| 91 | 94 | } from "./deals.ts"; |
| 92 | 95 | import { activitySheet, sheet } from "./csv.ts"; |
| @@ -317,6 +320,7 @@ export const DEAL: Schema = { | ||
| 317 | 320 | sale_price: { ...FIGURE, description: "The sale price in whole dollars; null while none is set." }, |
| 318 | 321 | earnest_amount: { ...FIGURE, description: "The earnest money amount in whole dollars; null while none is set." }, |
| 319 | 322 | 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." }, | |
| 320 | 324 | closing_time: CLOSING_TIME, |
| 321 | 325 | closing_place: CLOSING_PLACE, |
| 322 | 326 | business_days: { enum: [0, 1] }, |
| @@ -1171,6 +1175,17 @@ export const routes: readonly Route[] = [ | ||
| 1171 | 1175 | 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); |
| 1172 | 1176 | }, |
| 1173 | 1177 | }, |
| 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 | }, | |
| 1174 | 1189 | { |
| 1175 | 1190 | method: "POST", |
| 1176 | 1191 | path: "/deals/:id/side", |
| @@ -1733,11 +1748,19 @@ export const routes: readonly Route[] = [ | ||
| 1733 | 1748 | const body = await readJson(c.request); |
| 1734 | 1749 | if (body === undefined) return json({ error: "too_large" }, 413); |
| 1735 | 1750 | 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 })); | |
| 1737 | 1760 | }, |
| 1738 | 1761 | 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." } } }, | |
| 1741 | 1764 | answer: DEAL, |
| 1742 | 1765 | }, |
| 1743 | 1766 | }, |
src/server.test.ts +1
| @@ -187,6 +187,7 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 187 | 187 | "reopen_deal", |
| 188 | 188 | "waive_date", |
| 189 | 189 | "move_date", |
| 190 | "set_deal_note", | |
| 190 | 191 | "set_date_note", |
| 191 | 192 | "set_appointment", |
| 192 | 193 | "set_reminder_lead", |
src/terminal.test.ts +18
| @@ -1228,3 +1228,21 @@ test("a guest is not asked to type papers", () => { | ||
| 1228 | 1228 | t.press("i"); |
| 1229 | 1229 | expect(lines(t).join("\n")).not.toContain("Papers on"); |
| 1230 | 1230 | }); |
| 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 @@ | ||
| 12 | 12 | * file a deal's broker file: figures, parties, every date and message (f on a deal) |
| 13 | 13 | * activity who approved, declined or changed what, newest first (L, admins); m only messages, o older, y newer |
| 14 | 14 | * 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) | |
| 16 | 16 | * |
| 17 | 17 | * 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 |
| 18 | 18 | * (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 { | ||
| 59 | 59 | withConflicts, |
| 60 | 60 | removeDeadline, |
| 61 | 61 | setNote, |
| 62 | setDealNote, | |
| 62 | 63 | setAppointment, |
| 63 | 64 | setReminderLead, |
| 64 | 65 | removeParty, |
| @@ -132,6 +133,9 @@ export interface Terminal { | ||
| 132 | 133 | readonly done: boolean; |
| 133 | 134 | } |
| 134 | 135 | |
| 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 | ||
| 135 | 139 | export function terminal( |
| 136 | 140 | db: Database, |
| 137 | 141 | me: Who, |
| @@ -324,7 +328,7 @@ export function terminal( | ||
| 324 | 328 | |
| 325 | 329 | function onDeal(key: string): boolean { |
| 326 | 330 | 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; | |
| 328 | 332 | const party = deal.parties[cursor - deal.deadlines.length]; |
| 329 | 333 | const date = deal.deadlines[cursor]; |
| 330 | 334 | if (key === "S") { |
| @@ -360,6 +364,11 @@ export function terminal( | ||
| 360 | 364 | if (side === undefined || (typed ?? "").trim() === "") return side === undefined ? SIDE_REFUSAL : "Kept."; |
| 361 | 365 | return report(updateDeal(db, me, { id: deal.id, side }), `Side is now ${SIDE_CHOICE[side]}.`); |
| 362 | 366 | }); |
| 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 | }); | |
| 363 | 372 | if (key === "A") |
| 364 | 373 | ask(deal, [`Address (now ${deal.address})`], ([address]) => |
| 365 | 374 | report(updateDeal(db, me, { id: deal.id, address }), `Address is now ${address!.trim()}.`), |
| @@ -1010,7 +1019,7 @@ export function terminal( | ||
| 1010 | 1019 | const deal = openDeal(); |
| 1011 | 1020 | if (deal === undefined) lines.push(dim("That deal is gone.")); |
| 1012 | 1021 | 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)}`)]), ""); | |
| 1014 | 1023 | deal.deadlines.forEach((date, i) => lines.push(dateLine({ deal, date }, i, false))); |
| 1015 | 1024 | if (deal.closingConflicts.length > 0) lines.push("", tint("overdue", clashText(deal.closingConflicts))); |
| 1016 | 1025 | if (deal.conflicts.length > 0) lines.push("", ...deal.conflicts.map((c) => tint("overdue", `! ${conflictLine(c)}`))); |
| @@ -1066,7 +1075,7 @@ export function terminal( | ||
| 1066 | 1075 | : view === "timeline" || view === "file" |
| 1067 | 1076 | ? "b back to the deal · d dates · q quit" |
| 1068 | 1077 | : 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" | |
| 1070 | 1079 | : `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`; |
| 1071 | 1080 | lines.push(dim(me.role === "anon" ? `${keys} · sign in with a key the forge knows to change anything` : keys)); |
| 1072 | 1081 | } |
new above
offline