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

Each person can have a preferred way to be reached, and the drafts and People page show it #294

Merged opened this

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

Merged

mason/deal-clock:mason/287-each-person-can-have-a-preferred-way-to → main 2 commits fork

  • Merged. mason/287-each-person-can-have-a-preferred-way-to was merged into main as ac329d6.

Review

Sign in to review this change.
23 files changed +459 −50
README.md +11
@@ -87,6 +87,17 @@ with a link to the page it names. In the terminal press `G` for the same steps.
8787 timeline and at the foot of every new reminder. (Only an admin can save
8888 it; **Use defaults** goes back to the sample agent.)
8989
90 ## How people like to be reached
91
92 A realtor knows who answers texts and who only reads email. When you add a
93 party, or open **Edit** beside one on the deal page, choose **Reaches best by**:
94 text, email or phone call (or no preference). **People** then shows "Reaches
95 best by text" beside the name, and so does each draft on the review page. It is
96 a note for you: the drafted words stay the same, and the choice travels in the
97 all-deals export. In the terminal, **p** (add a party) and **e** (correct one)
98 ask the same question, and the deal view, **P** and the reminders list show the
99 answer. The sample parties carry a mix.
100
90101 ## Finding a deal
91102
92103 Type part of an address, or of a buyer's, seller's or other party's name,
contract/openapi.json +36
@@ -1096,6 +1096,15 @@
10961096 "type": "string",
10971097 "deprecated": true,
10981098 "description": "Deprecated: the email, else the phone. Read email and phone instead; this reads for one more release."
1099 },
1100 "reach": {
1101 "enum": [
1102 "text",
1103 "email",
1104 "call",
1105 ""
1106 ],
1107 "description": "How the party likes to be reached; blank for no preference. On a correction, left out it stays."
10991108 }
11001109 }
11011110 }
@@ -1699,6 +1708,15 @@
16991708 "deprecated": true,
17001709 "description": "Deprecated: the email, else the phone. Read email and phone instead; this reads for one more release.",
17011710 "maxLength": 200
1711 },
1712 "reach": {
1713 "enum": [
1714 "text",
1715 "email",
1716 "call",
1717 ""
1718 ],
1719 "description": "How the party likes to be reached; blank for no preference. On a correction, left out it stays."
17021720 }
17031721 },
17041722 "required": [
@@ -1743,6 +1761,15 @@
17431761 "type": "string",
17441762 "deprecated": true,
17451763 "description": "Deprecated: the email, else the phone. Read email and phone instead; this reads for one more release."
1764 },
1765 "reach": {
1766 "enum": [
1767 "text",
1768 "email",
1769 "call",
1770 ""
1771 ],
1772 "description": "How the party likes to be reached; blank for no preference. On a correction, left out it stays."
17461773 }
17471774 }
17481775 }
@@ -3339,6 +3366,15 @@
33393366 "type": "string",
33403367 "deprecated": true,
33413368 "description": "Deprecated: the email, else the phone. Read email and phone instead; this reads for one more release."
3369 },
3370 "reach": {
3371 "enum": [
3372 "text",
3373 "email",
3374 "call",
3375 ""
3376 ],
3377 "description": "How the party likes to be reached; blank for no preference. On a correction, left out it stays."
33423378 }
33433379 }
33443380 }
docs/TECHNICAL.md +5 −1
@@ -121,7 +121,11 @@ reminder and its party's email and phone into links (`outboxes`): `mailto:`
121121 otherwise ("No email or phone on file"). The approval is recorded once whatever
122122 the number of links. A party has an optional `email` (up to 200 characters) and
123123 an optional `phone` (digits, spaces and `+ - ( ) .`, up to 30), each checked at
124 the boundary (422 otherwise). `contact` is **deprecated**: it still reads, as the
124 the boundary (422 otherwise). A party also has an optional `reach`, how they like to
125 be reached: `text`, `email` or `call` (blank for none; 422 for anything else). It is
126 read and set on the page forms, `POST /deals/:id/parties`, `PATCH /api/deals/:id/parties/:party`
127 the terminal's `p` and `e` (a blank answer clears it there, as "No preference" does on the page) and the `add_party`/`edit_party` tools (left out on a correction, it stays), shows on
128 People and the review page, and is in the export/import file; drafted words never use it. `contact` is **deprecated**: it still reads, as the
125129 email else the phone, and is still accepted when neither new field is sent (sorted
126130 into whichever it looks like); it goes in the next release. The migration split
127131 existing contacts the same way. The page shows it as **Open in Mail** / **Open in Messages**
src/db.ts +2
@@ -572,6 +572,8 @@ export const migrations: string[] = [
572572 // An inspection or a final walkthrough is an appointment: an optional time and place, like the closing's.
573573 `ALTER TABLE deadline ADD COLUMN time TEXT CHECK (time IS NULL OR time GLOB '[0-2][0-9]:[0-5][0-9]')`,
574574 `ALTER TABLE deadline ADD COLUMN place TEXT CHECK (place IS NULL OR length(place) BETWEEN 1 AND 200)`,
575 // How a party likes to be reached: '' for no preference, else text, email or call.
576 `ALTER TABLE party ADD COLUMN reach TEXT NOT NULL DEFAULT '' CHECK (reach IN ('', 'text', 'email', 'call'))`,
575577 ];
576578
577579 export function openDb(dir = dataDir): Database {
src/deals.ts +30 −12
@@ -79,6 +79,8 @@ export interface Party {
7979 readonly email: string;
8080 /** Up to 30 digits, spaces and + - ( ) . ; '' when none. */
8181 readonly phone: string;
82 /** How they like to be reached; '' when no preference. */
83 readonly reach: Reach;
8284 }
8385 export interface Declined {
8486 readonly id: number;
@@ -248,6 +250,19 @@ export const SUBJECT_MAX = 150;
248250 export const EMAIL_MAX = 200;
249251 /** The longest free-text contact an old row may keep. */
250252 const CONTACT_MAX = 120;
253 /** The ways a party may prefer to be reached; '' (none) is the default. */
254 export const REACHES = ["text", "email", "call"] as const;
255 export type Reach = "" | (typeof REACHES)[number];
256 /** The preference as a phrase after "Reaches best by". */
257 export const REACH_PHRASE: Readonly<Record<Exclude<Reach, "">, string>> = { text: "text", email: "email", call: "phone call" };
258 /** "Reaches best by text"; nothing when there is no preference. */
259 export const reachLine = (reach: Reach): string => (!reach ? "" : `Reaches best by ${REACH_PHRASE[reach]}`);
260 /** A preference as given, checked: absent or blank is none; anything outside the list is refused. */
261 export function reachOf(v: unknown): Reach | undefined {
262 if (v === undefined || v === null || v === "") return "";
263 return typeof v === "string" && (REACHES as readonly string[]).includes(v) ? (v as Reach) : undefined;
264 }
265
251266 /** The longest a party's phone may be. */
252267 export const PHONE_MAX = 30;
253268 export const REASON_MAX = 200;
@@ -1152,9 +1167,12 @@ export function addDeadline(
11521167 * email or phone only when neither is given, and is sorted into whichever it
11531168 * looks like.
11541169 */
1155 export function partyText(input: { name: unknown; email?: unknown; phone?: unknown; contact?: unknown }): Result<{ name: string; email: string; phone: string; contact: string }> {
1170 export function partyText(input: { name: unknown; email?: unknown; phone?: unknown; contact?: unknown; reach?: unknown }): Result<{ name: string; email: string; phone: string; contact: string; reach: Reach | undefined }> {
11561171 const name = line(input.name, 1, 100);
11571172 if (name === undefined) return fail("invalid", "A name is 1 to 100 characters.");
1173 // Left out, a preference stays as it was on an edit; blank clears it.
1174 const reach = input.reach === undefined ? undefined : reachOf(input.reach);
1175 if (input.reach !== undefined && reach === undefined) return fail("invalid", "A preferred way to be reached is text, email or call.");
11581176 const given = (v: unknown): v is string => v !== undefined && v !== null && v !== "";
11591177 let email = "";
11601178 let phone = "";
@@ -1164,7 +1182,7 @@ export function partyText(input: { name: unknown; email?: unknown; phone?: unkno
11641182 if (validEmail(old)) email = old;
11651183 else if (validPhone(old)) phone = old;
11661184 else if (old.length > CONTACT_MAX) return fail("invalid", "A contact is up to 120 characters.");
1167 else return ok({ name, email: "", phone: "", contact: old }); // an old free-text contact is kept as it was
1185 else return ok({ name, email: "", phone: "", contact: old, reach }); // an old free-text contact is kept as it was
11681186 } else {
11691187 if (given(input.email)) {
11701188 const v = line(input.email, 1, EMAIL_MAX);
@@ -1177,13 +1195,13 @@ export function partyText(input: { name: unknown; email?: unknown; phone?: unkno
11771195 phone = v;
11781196 }
11791197 }
1180 return ok({ name, email, phone, contact: email || phone });
1198 return ok({ name, email, phone, contact: email || phone, reach });
11811199 }
11821200
11831201 export function addParty(
11841202 db: Database,
11851203 who: Who,
1186 input: { dealId: number; role: unknown; name: unknown; email?: unknown; phone?: unknown; contact?: unknown },
1204 input: { dealId: number; role: unknown; name: unknown; email?: unknown; phone?: unknown; contact?: unknown; reach?: unknown },
11871205 ): Result<Party> {
11881206 const deal = dealFor(db, who, "update", input.dealId);
11891207 const dealId = deal.ok ? deal.value.id : undefined;
@@ -1193,10 +1211,10 @@ export function addParty(
11931211 const text = partyText(input);
11941212 if (!text.ok) return within(db, who, "party.add", dealId, text);
11951213 const made = db
1196 .query<Party, [number, string, string, string, string, string]>(
1197 "INSERT INTO party (deal_id, role, name, contact, email, phone) VALUES (?1, ?2, ?3, ?4, ?5, ?6) RETURNING *",
1214 .query<Party, [number, string, string, string, string, string, string]>(
1215 "INSERT INTO party (deal_id, role, name, contact, email, phone, reach) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) RETURNING *",
11981216 )
1199 .get(deal.value.id, input.role, text.value.name, text.value.contact, text.value.email, text.value.phone)!;
1217 .get(deal.value.id, input.role, text.value.name, text.value.contact, text.value.email, text.value.phone, text.value.reach ?? "")!;
12001218 return within(db, who, "party.add", dealId, ok(made));
12011219 }
12021220
@@ -1223,21 +1241,21 @@ function partyFor(
12231241 return { dealId: deal.value.id, found: ok({ deal: deal.value, party }) };
12241242 }
12251243
1226 /** Correct a party's name, email and phone. Their role stays: it decides which dates they are told about. */
1244 /** Correct a party's name, email, phone and preferred way to be reached (left out, the preference stays). Their role stays: it decides which dates they are told about. */
12271245 export function editParty(
12281246 db: Database,
12291247 who: Who,
1230 input: { id: number; dealId?: number; name: unknown; email?: unknown; phone?: unknown; contact?: unknown },
1248 input: { id: number; dealId?: number; name: unknown; email?: unknown; phone?: unknown; contact?: unknown; reach?: unknown },
12311249 ): Result<Party> {
12321250 const { dealId, found } = partyFor(db, who, input.id, input.dealId);
12331251 if (!found.ok) return within(db, who, "party.edit", dealId, found);
12341252 const text = partyText(input);
12351253 if (!text.ok) return within(db, who, "party.edit", dealId, text);
12361254 const made = db
1237 .query<Party, [string, string, string, string, number]>(
1238 "UPDATE party SET name = ?1, contact = ?2, email = ?3, phone = ?4 WHERE id = ?5 RETURNING *",
1255 .query<Party, [string, string, string, string, string | null, number]>(
1256 "UPDATE party SET name = ?1, contact = ?2, email = ?3, phone = ?4, reach = coalesce(?5, reach) WHERE id = ?6 RETURNING *",
12391257 )
1240 .get(text.value.name, text.value.contact, text.value.email, text.value.phone, input.id)!;
1258 .get(text.value.name, text.value.contact, text.value.email, text.value.phone, text.value.reach ?? null, input.id)!;
12411259 return within(db, who, "party.edit", dealId, ok(made));
12421260 }
12431261
src/export.test.ts +1 −1
@@ -86,7 +86,7 @@ test("the file holds every field, with moves, notes, state, parties and messages
8686 const mine = file.deals.find((d) => d.id === dealId)!;
8787 expect(mine.address).toBe("9 Export Way");
8888 expect(mine.state).toBe("open");
89 expect(mine.parties.find((p) => p.role === "buyer")).toEqual({ role: "buyer", name: "Bea Buyer", phone: "555 0100", email: "[email protected]" });
89 expect(mine.parties.find((p) => p.role === "buyer")).toEqual({ role: "buyer", name: "Bea Buyer", phone: "555 0100", email: "[email protected]", reach: "" });
9090 const date = mine.dates.find((d) => d.kind === "inspection")!;
9191 expect(date.note).toBe("Call the inspector");
9292 expect(date.moves[0]).toMatchObject({ to: "2026-10-20", reason: "Seller asked", by: "alice" });
src/export.ts +1 −1
@@ -42,7 +42,7 @@ const dealOf = (deal: DealView): Record<string, unknown> => ({
4242 opened_by: deal.author_name,
4343 created_at: deal.created_at,
4444 ...stateOf(deal),
45 parties: deal.parties.map((p) => ({ role: p.role, name: p.name, phone: p.phone, email: p.email })),
45 parties: deal.parties.map((p) => ({ role: p.role, name: p.name, phone: p.phone, email: p.email, reach: p.reach })),
4646 dates: deal.deadlines.map((d) => ({
4747 label: d.label,
4848 kind: d.kind,
src/import.ts +4 −3
@@ -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 } from "./deals.ts";
17 import { NOTE_MAX, 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 { emit } from "./live.ts";
2020 import { audited, fail, ok, refuse as denied, type Result } from "./result.ts";
@@ -52,6 +52,7 @@ interface PartyIn {
5252 email: string;
5353 phone: string;
5454 contact: string;
55 reach: Reach;
5556 }
5657 /** One deal of the file, checked and ready to write. */
5758 export interface DealIn {
@@ -92,7 +93,7 @@ function readDeal(raw: unknown, n: number): Result<DealIn> {
9293 if (!isContactRole(o.role)) return refuse(n, `party ${i + 1} has no valid role.`, `parties[${i}].role`);
9394 const text = partyText(o);
9495 if (!text.ok) return refuse(n, `party ${i + 1}: ${text.message}`, `parties[${i}]`);
95 parties.push({ role: o.role, ...text.value });
96 parties.push({ role: o.role, ...text.value, reach: text.value.reach ?? "" });
9697 }
9798
9899 const dates: DateIn[] = [];
@@ -222,7 +223,7 @@ function write(db: Database, who: Who & { role: "user" | "admin" }, deals: reado
222223 d.closingTime, d.closingPlace, d.fell?.at ?? null, d.fell?.reason ?? null, d.fell?.by ?? null, deal.id,
223224 );
224225 for (const p of d.parties)
225 db.query("INSERT INTO party (deal_id, role, name, contact, email, phone) VALUES (?1, ?2, ?3, ?4, ?5, ?6)").run(deal.id, p.role, p.name, p.contact, p.email, p.phone);
226 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);
226227 for (const x of d.dates) {
227228 const lead = x.kind === "custom" ? leadOfLabel(x.label) : leadOf(x.kind);
228229 const what = x.kind === "custom" ? "" : KIND[x.kind].what;
src/mcp.ts +4 −2
@@ -480,11 +480,12 @@ export const tools: readonly Tool[] = [
480480 email: { type: "string", maxLength: 200 },
481481 phone: { type: "string", maxLength: 30, description: "Digits, spaces and + - ( ) . only." },
482482 contact: { type: "string", maxLength: 200, description: "Deprecated: use email and phone. Read as whichever it looks like when neither is given." },
483 reach: { enum: ["text", "email", "call", ""], description: "How the party likes to be reached; blank for none. On edit, left out it stays." },
483484 },
484485 required: ["deal_id", "role", "name"],
485486 },
486487 run: (db, who, a) =>
487 addParty(db, who, { dealId: Number(a.deal_id), role: a.role, name: a.name, email: a.email, phone: a.phone, contact: a.contact }),
488 addParty(db, who, { dealId: Number(a.deal_id), role: a.role, name: a.name, email: a.email, phone: a.phone, contact: a.contact, reach: a.reach }),
488489 },
489490 {
490491 name: "edit_party",
@@ -498,11 +499,12 @@ export const tools: readonly Tool[] = [
498499 email: { type: "string", maxLength: 200 },
499500 phone: { type: "string", maxLength: 30, description: "Digits, spaces and + - ( ) . only." },
500501 contact: { type: "string", maxLength: 200, description: "Deprecated: use email and phone. Read as whichever it looks like when neither is given." },
502 reach: { enum: ["text", "email", "call", ""], description: "How the party likes to be reached; blank for none. On edit, left out it stays." },
501503 },
502504 required: ["deal_id", "party_id", "name"],
503505 },
504506 run: (db, who, a) =>
505 editParty(db, who, { dealId: Number(a.deal_id), id: Number(a.party_id), name: a.name, email: a.email, phone: a.phone, contact: a.contact }),
507 editParty(db, who, { dealId: Number(a.deal_id), id: Number(a.party_id), name: a.name, email: a.email, phone: a.phone, contact: a.contact, reach: a.reach }),
506508 },
507509 {
508510 name: "remove_party",
src/migrations.pin.json +2 −1
@@ -57,5 +57,6 @@
5757 "7e011fb3dab2123101aa981ef9050d8f8d2c34fb3ba137ecc832f86d464b98a2",
5858 "429a2adfb36ff61def2d1979556a9db87f4758663fa9c56b97e599b3bc13b773",
5959 "c5df0d263e04d4e07f9df383beb57f033966af540bb228d9bbbd7b57f3620408",
60 "4a52d9de76ede6d34d831d9bc6fc1d3d3a501a5d3652bf63695a9de49f2a7d39"
60 "4a52d9de76ede6d34d831d9bc6fc1d3d3a501a5d3652bf63695a9de49f2a7d39",
61 "fafa7c66cff02dd250f2bed223947161b54f286b868fdd07c07c8c6d8b4556e3"
6162 ]
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)/.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/.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 +10 −5
@@ -5,7 +5,7 @@
55 */
66
77 import { addDays, relative, short, spoken } from "../dates.ts";
8 import { canChange, canRemove, closedOn, EMAIL_MAX, fellOn, PHONE_MAX, REASON_MAX, type DealView, type Party, type Plan, type PlannedDate, type Sent } from "../deals.ts";
8 import { REACHES, REACH_PHRASE, canChange, canRemove, closedOn, EMAIL_MAX, fellOn, PHONE_MAX, REASON_MAX, type DealView, type Party, type Plan, type PlannedDate, type Sent } from "../deals.ts";
99 import { matchKnown, type Known } from "../people.ts";
1010 import { agent as defaults, standard } from "../env.ts";
1111 import { CONTACT_ROLES, DEFAULT_LENGTHS, PARTY_LABEL, PARTY_ROLES, suggestedFor } from "../deadlines.ts";
@@ -43,16 +43,21 @@ export interface PartyProblem {
4343 readonly name: string;
4444 readonly email: string;
4545 readonly phone: string;
46 readonly reach?: string;
4647 readonly message: string;
4748 }
4849
4950 /** The optional email and phone fields of a party form, with the refusal beside them. */
50 const contactFields = (email: string, phone: string, message?: string): string =>
51 const reachField = (reach: string): string =>
52 `<label>Reaches best by <span class="muted">(optional)</span><select class="input" name="reach"><option value="">No preference</option>${REACHES.map((r) => `<option value="${r}"${r === reach ? " selected" : ""}>${escape(REACH_PHRASE[r])}</option>`).join("")}</select></label>`;
53
54 const contactFields = (email: string, phone: string, message?: string, reach = ""): string =>
5155 `${message === undefined ? "" : `<p class="conflict" role="alert">${escape(message)}</p>`}
5256 <div class="pair">
5357 <label>Email <span class="muted">(optional)</span><input class="input" type="email" name="email" maxlength="${EMAIL_MAX}" autocomplete="off" value="${escape(email)}"${message === undefined ? "" : ' aria-invalid="true"'}></label>
5458 <label>Phone <span class="muted">(optional)</span><input class="input" type="tel" name="phone" maxlength="${PHONE_MAX}" autocomplete="off" value="${escape(phone)}"${message === undefined ? "" : ' aria-invalid="true"'}></label>
55 </div>`;
59 </div>
60 ${reachField(reach)}`;
5661
5762 /** A party's email and phone as links, each on its own. */
5863 const partyLinks = (p: Party): string => {
@@ -67,7 +72,7 @@ const partyControls = (p: Party, to: string, problem?: PartyProblem): string =>
6772 <form class="stack" method="post" action="/parties/${p.id}/edit">
6873 <input type="hidden" name="to" value="${escape(to)}">
6974 <label>Name<input class="input" name="name" required maxlength="100" value="${escape(problem?.name ?? p.name)}"></label>
70 ${contactFields(problem?.email ?? p.email, problem?.phone ?? p.phone, problem?.message)}
75 ${contactFields(problem?.email ?? p.email, problem?.phone ?? p.phone, problem?.message, problem?.reach ?? p.reach)}
7176 ${p.email === "" && p.phone === "" && p.contact !== "" ? `<input type="hidden" name="contact" value="${escape(p.contact)}">` : ""}
7277 <button class="button">Save</button>
7378 </form>
@@ -361,7 +366,7 @@ export function dealPage(input: {
361366 <label>Role<select class="input" name="role">${roleOptions(add?.role ?? "lender", CONTACT_ROLES)}</select></label>
362367 <label>Name<input class="input" name="name" required maxlength="100" value="${escape(add?.name ?? "")}"></label>
363368 </div>
364 ${contactFields(add?.email ?? "", add?.phone ?? "", add?.message)}
369 ${contactFields(add?.email ?? "", add?.phone ?? "", add?.message, add?.reach ?? "")}
365370 <button class="button">Add party</button>
366371 </form>
367372 </section>`
src/pages/people.ts +2
@@ -6,6 +6,7 @@
66
77 import { relative, spoken } from "../dates.ts";
88 import { escape } from "../http.ts";
9 import { reachLine } from "../deals.ts";
910 import { nudgeLine, type Person } from "../people.ts";
1011 import type { Who } from "../who.ts";
1112 import type { Agent } from "../reminders.ts";
@@ -24,6 +25,7 @@ const lastHeard = (l: Person["last_sent"]): string =>
2425 const person = (p: Person, on: string): string => `<section class="card person">
2526 <h2>${escape(p.name)}</h2>
2627 <p class="muted">${escape(p.roles.join(", "))}${[p.email, p.phone, ...(p.email === "" && p.phone === "" ? [p.contact] : [])].filter((v) => v !== "").map((v) => ` · ${partyContact(v)}`).join("")}</p>
28 ${!p.reach ? "" : `<p class="muted">${escape(reachLine(p.reach))}</p>`}
2729 ${p.nudge === null ? "" : `<p class="person-nudge">${escape(nudgeLine(p.nudge, on))}</p>`}
2830 ${lastHeard(p.last_sent)}
2931 ${p.owes.length === 0 ? `<p class="muted">Nothing open</p>` : `<ul class="week-list">${p.owes.map((o) => owedRow(o, on)).join("")}</ul>`}
src/pages/review.ts +2 −2
@@ -8,7 +8,7 @@
88
99 import type { Agent } from "../reminders.ts";
1010 import type { Next } from "../coming.ts";
11 import { BODY_MAX, EMAIL_MAX, PHONE_MAX, REASON_MAX, SUBJECT_MAX } from "../deals.ts";
11 import { BODY_MAX, EMAIL_MAX, PHONE_MAX, REASON_MAX, reachLine, SUBJECT_MAX } from "../deals.ts";
1212 import { spoken } from "../dates.ts";
1313 import { PARTY_LABEL } from "../deadlines.ts";
1414 import { asset, escape } from "../http.ts";
@@ -122,7 +122,7 @@ function form(
122122 <p class="muted small" id="review-keys">Keys, with the card focused: <kbd>S</kbd> send · <kbd>D</kbd> don't send · <kbd>T</kbd> tomorrow. Typing in a box never triggers them.</p>
123123 <p><a href="/deals/${draft.deal_id}">${escape(draft.address)}</a></p>
124124 <p><b>${escape(draft.label)}</b> <span class="muted">· ${escape(spoken(draft.due))} · ${escape(momentLabel(draft.moment, draft.lead_days))}${draft.held === null ? "" : ` · ${escape(heldFrom(draft.held))}`}</span></p>
125 <p>To <b>${escape(draft.party_name)}</b> <span class="muted">· ${escape(PARTY_LABEL[draft.party_role])}${[draft.email, draft.phone].filter((v) => v !== "").map((v) => ` · ${escape(v)}`).join("") || (draft.contact === "" ? "" : ` · ${escape(draft.contact)}`)}</span></p>
125 <p>To <b>${escape(draft.party_name)}</b> <span class="muted">· ${escape(PARTY_LABEL[draft.party_role])}${[draft.email, draft.phone].filter((v) => v !== "").map((v) => ` · ${escape(v)}`).join("") || (draft.contact === "" ? "" : ` · ${escape(draft.contact)}`)}${!draft.reach ? "" : ` · ${escape(reachLine(draft.reach))}`}</span></p>
126126 <form class="move" method="post" action="${REVIEW_PATH}/send">${hidden(keys)}
127127 ${draft.contact === "" ? '<button class="button" hidden tabindex="-1" aria-hidden="true">Send</button>' : ""}
128128 ${draft.contact === "" ? contactField(typed, problem("contact"), invalid("contact")) : ""}
src/people.test.ts +9
@@ -111,3 +111,12 @@ test("the People page shows the box, escapes the query and says No one matches w
111111 expect(html).toContain('href="/people"');
112112 expect(html).not.toContain("<b>x</b>");
113113 });
114
115 test("a stated preference on one deal is kept when the newest row for the same person states none", () => {
116 const mk = (id: number, pid: number, reach: string): DealView =>
117 ({ id, address: `${id} St`, closed: false, sent: [], deadlines: [], parties: [{ id: pid, deal_id: id, role: "lender", name: "Pat", contact: "[email protected]", email: "[email protected]", phone: "", reach }] }) as unknown as DealView;
118 expect(people([mk(1, 1, "text"), mk(2, 2, "")], on)[0]!.reach).toBe("text");
119 expect(people([mk(2, 2, ""), mk(1, 1, "text")], on)[0]!.reach).toBe("text");
120 expect(people([mk(1, 1, "text"), mk(2, 2, "call")], on)[0]!.reach).toBe("call");
121 expect(people([mk(1, 1, "")], on)[0]!.reach).toBe("");
122 });
src/people.ts +9 −3
@@ -5,7 +5,7 @@
55 */
66
77 import { daysBetween, relative, urgency, type Urgency } from "./dates.ts";
8 import type { DealView, Party } from "./deals.ts";
8 import type { DealView, Party, Reach } from "./deals.ts";
99 import { isOpen, PARTY_LABEL, type PartyRole } from "./deadlines.ts";
1010
1111 export interface Owed {
@@ -34,6 +34,8 @@ export interface Person {
3434 readonly contact: string;
3535 readonly email: string;
3636 readonly phone: string;
37 /** How they like to be reached; '' when no preference. */
38 readonly reach: Reach;
3739 /** The roles they hold across deals, as words, in the order first met. */
3840 readonly roles: readonly string[];
3941 /** Open dates they owe, soonest first, then by address. */
@@ -81,7 +83,7 @@ export const personKey = (p: { name: string; contact: string; email?: string; ph
8183 type Last = (LastSent & { at: string; id: number }) | null;
8284 const newer = (a: Last, b: Last): Last => (a === null ? b : b === null ? a : b.at > a.at || (b.at === a.at && b.id > a.id) ? b : a);
8385
84 interface One { name: string; newest: number; contact: string; email: string; phone: string; roles: Set<PartyRole>; owes: Map<number, Owed>; last: (LastSent & { at: string; id: number }) | null }
86 interface One { name: string; newest: number; contact: string; email: string; phone: string; reach: Reach; reachId: number; roles: Set<PartyRole>; owes: Map<number, Owed>; last: (LastSent & { at: string; id: number }) | null }
8587
8688 /** Two parties are one person when they share an email or a phone, even if one row has only the phone and another both. */
8789 export function people(deals: readonly DealView[], on: string): Person[] {
@@ -94,6 +96,8 @@ export function people(deals: readonly DealView[], on: string): Person[] {
9496 into.email = from.email;
9597 into.phone = from.phone;
9698 }
99 // A stated preference outlives a newer row that states none: the newest non-blank one stands.
100 if (from.reach !== "" && (into.reach === "" || from.reachId > into.reachId)) { into.reach = from.reach; into.reachId = from.reachId; }
97101 into.last = newer(into.last, from.last);
98102 for (const r of from.roles) into.roles.add(r);
99103 for (const [id, o] of from.owes) into.owes.set(id, o);
@@ -101,7 +105,7 @@ export function people(deals: readonly DealView[], on: string): Person[] {
101105 for (const deal of deals) {
102106 for (const party of deal.parties) {
103107 const keys = personKeys(party);
104 const fresh: One = { name: party.name, newest: -1, contact: party.contact.trim(), email: party.email.trim(), phone: party.phone.trim(), roles: new Set(), owes: new Map(), last: null };
108 const fresh: One = { name: party.name, newest: -1, contact: party.contact.trim(), email: party.email.trim(), phone: party.phone.trim(), reach: "", reachId: -1, roles: new Set(), owes: new Map(), last: null };
105109 let one = fresh;
106110 for (const key of keys) {
107111 const found = byKey.get(key);
@@ -120,6 +124,7 @@ export function people(deals: readonly DealView[], on: string): Person[] {
120124 one.email = party.email.trim();
121125 one.phone = party.phone.trim();
122126 }
127 if (party.reach && party.id > one.reachId) { one.reach = party.reach; one.reachId = party.id; }
123128 one.roles.add(party.role);
124129 // Only sent messages are in `deal.sent`; declined and held ones live apart.
125130 for (const m of deal.sent) {
@@ -149,6 +154,7 @@ export function people(deals: readonly DealView[], on: string): Person[] {
149154 contact: p.contact,
150155 email: p.email,
151156 phone: p.phone,
157 reach: p.reach,
152158 roles: [...p.roles].map((r) => PARTY_LABEL[r]),
153159 last_sent,
154160 owes,
src/reach.routes.test.ts added +133
@@ -0,0 +1,133 @@
1 /** The doors for a party's preferred way to be reached: the page forms, the JSON API and MCP each set it, refuse unknown values and keep it where it should be kept. */
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 { forgetRationing } from "./security.ts";
7 import { serve } from "./server.ts";
8 import { sign } from "./who.ts";
9
10 const KEY = "a-key-only-the-forge-holds";
11 const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-reach-")), identityKey: KEY });
12 const base = `http://${server.hostname}:${server.port}`;
13 afterAll(() => server.stop(true));
14
15 const identity = `u1|alice|user|${Date.now() + 600_000}`;
16 const alice = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
17 type Party = { id: number; name: string; reach: string };
18 type Deal = { id: number; address: string; parties: Party[] };
19 const deals = async (): Promise<Deal[]> => (await fetch(`${base}/api/deals`, { headers: alice })).json() as Promise<Deal[]>;
20 const dealId = async (): Promise<number> => (await deals())[0]!.id;
21 const party = async (id: number, name: string): Promise<Party | undefined> => (await deals()).find((d) => d.id === id)!.parties.find((p) => p.name === name);
22 const form = (fields: Record<string, string>): FormData => {
23 const f = new FormData();
24 for (const [k, v] of Object.entries(fields)) f.set(k, v);
25 return f;
26 };
27 const post = (path: string, fields: Record<string, string>, headers: Record<string, string> = alice) =>
28 fetch(`${base}${path}`, { method: "POST", body: form(fields), headers, redirect: "manual" });
29 const selected = (html: string, value: string): boolean => html.includes(`<option value="${value}" selected>`);
30
31 test("the add-party form sets it, and the deal page then shows the select pre-selected", async () => {
32 forgetRationing();
33 const id = await dealId();
34 const posted = await post(`/deals/${id}/parties`, { role: "lender", name: "Reach Text", email: "[email protected]", reach: "text" });
35 expect(posted.status).toBe(303);
36 const p = (await party(id, "Reach Text"))!;
37 expect(p.reach).toBe("text");
38 const html = await (await fetch(`${base}/deals/${id}`, { headers: alice })).text();
39 expect(html).toContain('name="reach"');
40 expect(html).toContain("No preference");
41 expect(selected(html, "text")).toBe(true);
42 const none = await post(`/deals/${id}/parties`, { role: "lender", name: "Reach None", email: "[email protected]", reach: "" });
43 expect(none.status).toBe(303);
44 expect((await party(id, "Reach None"))!.reach).toBe("");
45 });
46
47 test("the add-party form refuses an unknown value with 422, writes nothing and keeps what was typed", async () => {
48 forgetRationing();
49 const id = await dealId();
50 const res = await post(`/deals/${id}/parties`, { role: "lender", name: "Reach Bad", email: "[email protected]", reach: "pigeon" });
51 expect(res.status).toBe(422);
52 const html = await res.text();
53 expect(html).toContain("A preferred way to be reached is text, email or call.");
54 expect(html).toContain('value="Reach Bad"');
55 expect(await party(id, "Reach Bad")).toBeUndefined();
56 });
57
58 test("a post from another site or from nobody signed in changes nothing", async () => {
59 forgetRationing();
60 const id = await dealId();
61 const foreign = await post(`/deals/${id}/parties`, { role: "lender", name: "Reach Foreign", reach: "text" }, { ...alice, origin: "https://evil.example" });
62 expect(foreign.status).toBe(403);
63 const anon = await post(`/deals/${id}/parties`, { role: "lender", name: "Reach Anon", reach: "text" }, {});
64 expect([303, 401, 403]).toContain(anon.status);
65 expect(await party(id, "Reach Foreign")).toBeUndefined();
66 expect(await party(id, "Reach Anon")).toBeUndefined();
67 });
68
69 test("the edit form changes it, clears it when blank, and refuses an unknown value keeping the selection", async () => {
70 forgetRationing();
71 const id = await dealId();
72 await post(`/deals/${id}/parties`, { role: "title", name: "Reach Edit", email: "[email protected]", reach: "email" });
73 const p = (await party(id, "Reach Edit"))!;
74 expect(p.reach).toBe("email");
75 expect((await post(`/parties/${p.id}/edit`, { name: "Reach Edit", email: "[email protected]", reach: "call", to: `/deals/${id}` })).status).toBe(303);
76 expect((await party(id, "Reach Edit"))!.reach).toBe("call");
77 const bad = await post(`/parties/${p.id}/edit`, { name: "Reach Edit", email: "[email protected]", reach: "fax", to: `/deals/${id}` });
78 expect(bad.status).toBe(422);
79 expect(await bad.text()).toContain("A preferred way to be reached is text, email or call.");
80 expect((await party(id, "Reach Edit"))!.reach).toBe("call");
81 expect((await post(`/parties/${p.id}/edit`, { name: "Reach Edit", email: "[email protected]", reach: "", to: `/deals/${id}` })).status).toBe(303);
82 expect((await party(id, "Reach Edit"))!.reach).toBe("");
83 const foreign = await post(`/parties/${p.id}/edit`, { name: "Reach Edit", reach: "text", to: `/deals/${id}` }, { ...alice, origin: "https://evil.example" });
84 expect(foreign.status).toBe(403);
85 expect((await party(id, "Reach Edit"))!.reach).toBe("");
86 });
87
88 test("the edit form opens with the party's current choice selected", async () => {
89 forgetRationing();
90 const id = await dealId();
91 await post(`/deals/${id}/parties`, { role: "other_agent", name: "Reach Shown", email: "[email protected]", reach: "call" });
92 const html = await (await fetch(`${base}/deals/${id}`, { headers: alice })).text();
93 const at = html.indexOf('value="Reach Shown"');
94 expect(at).toBeGreaterThan(0);
95 expect(selected(html.slice(at, at + 1200), "call")).toBe(true);
96 });
97
98 const patch = (id: number, partyId: number, body: object) =>
99 fetch(`${base}/api/deals/${id}/parties/${partyId}`, { method: "PATCH", headers: { ...alice, "content-type": "application/json" }, body: JSON.stringify(body) });
100
101 test("PATCH sets it, keeps it when left out, clears it when blank and answers 422 for an unknown value", async () => {
102 forgetRationing();
103 const id = await dealId();
104 await post(`/deals/${id}/parties`, { role: "lender", name: "Reach Api", email: "[email protected]" });
105 const p = (await party(id, "Reach Api"))!;
106 expect((await patch(id, p.id, { name: "Reach Api", email: "[email protected]", reach: "text" })).status).toBe(200);
107 expect((await party(id, "Reach Api"))!.reach).toBe("text");
108 expect((await patch(id, p.id, { name: "Reach Api", email: "[email protected]" })).status).toBe(200);
109 expect((await party(id, "Reach Api"))!.reach).toBe("text");
110 expect((await patch(id, p.id, { name: "Reach Api", email: "[email protected]", reach: "owl" })).status).toBe(422);
111 expect((await party(id, "Reach Api"))!.reach).toBe("text");
112 expect((await patch(id, p.id, { name: "Reach Api", email: "[email protected]", reach: "" })).status).toBe(200);
113 expect((await party(id, "Reach Api"))!.reach).toBe("");
114 });
115
116 const tool = async (name: string, args: object): Promise<any> =>
117 (await (await fetch(`${base}/mcp`, { method: "POST", headers: { ...alice, "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) })).json()) as any;
118
119 test("the MCP add_party and edit_party tools take it and refuse unknown values", async () => {
120 forgetRationing();
121 const id = await dealId();
122 const added = await tool("add_party", { deal_id: id, role: "lender", name: "Reach Mcp", email: "[email protected]", reach: "email" });
123 expect(added.result?.isError).not.toBe(true);
124 const p = (await party(id, "Reach Mcp"))!;
125 expect(p.reach).toBe("email");
126 const bad = await tool("edit_party", { deal_id: id, party_id: p.id, name: "Reach Mcp", email: "[email protected]", reach: "owl" });
127 expect(bad.result?.isError).toBe(true);
128 expect((await party(id, "Reach Mcp"))!.reach).toBe("email");
129 await tool("edit_party", { deal_id: id, party_id: p.id, name: "Reach Mcp", email: "[email protected]", reach: "call" });
130 expect((await party(id, "Reach Mcp"))!.reach).toBe("call");
131 expect((await tool("add_party", { deal_id: id, role: "lender", name: "Reach Mcp Bad", reach: "owl" })).result?.isError).toBe(true);
132 expect(await party(id, "Reach Mcp Bad")).toBeUndefined();
133 });
src/reach.test.ts added +129
@@ -0,0 +1,129 @@
1 /** A party's preferred way to be reached: checked at the boundary, migrated, shown on People and the review page, and carried through export and import. */
2 import { expect, test } from "bun:test";
3 import { Database } from "bun:sqlite";
4 import { migrate, migrations, openDb } from "./db.ts";
5 import { addParty, editParty, getDeal, insertDeal, listDeals, plan, reachLine, type DealView } from "./deals.ts";
6 import { exportDeals } from "./export.ts";
7 import { importDeals } from "./import.ts";
8 import { people } from "./people.ts";
9 import { peoplePage } from "./pages/people.ts";
10 import { reviewPage } from "./pages/review.ts";
11 import { drafts } from "./reminders.ts";
12 import { seed } from "./sample.ts";
13 import type { Who } from "./who.ts";
14
15 const alice: Who = { role: "user", id: "u1", name: "alice" };
16 const bob: Who = { role: "user", id: "u2", name: "bob" };
17 const carol: Who = { role: "user", id: "u3", name: "carol" };
18 const must = <T>(r: { ok: true; value: T } | { ok: false; message: string }): T => {
19 if (!r.ok) throw new Error(r.message);
20 return r.value;
21 };
22 let n = 0;
23 const open = (db: ReturnType<typeof openDb>, who: Who & { id: string; name: string }) => {
24 const p = must(plan({ buyer: "Bea Buyer", seller: "Sam Seller", address: `${++n} Elm St`, contractDate: "2026-10-01", closingDate: "2026-11-20" }));
25 return insertDeal(db, { id: who.id, name: who.name }, p).id;
26 };
27 const AGENT = { name: "A", brokerage: "B", phone: "1", email: "[email protected]", line: "" };
28
29 test("a party's preference is stored, defaults to none, and an unknown value is refused", () => {
30 const db = openDb(":memory:");
31 const id = open(db, alice);
32 const plain = must(addParty(db, alice, { dealId: id, role: "lender", name: "Lee", email: "[email protected]" }));
33 expect(plain.reach).toBe("");
34 const texty = must(addParty(db, alice, { dealId: id, role: "title", name: "Tess", phone: "555 0100", reach: "text" }));
35 expect(texty.reach).toBe("text");
36 const bad = addParty(db, alice, { dealId: id, role: "title", name: "Z", reach: "pigeon" });
37 expect(bad.ok).toBe(false);
38 expect(editParty(db, alice, { id: texty.id, name: "Tess", phone: "555 0100", reach: "carrier" }).ok).toBe(false);
39 expect(must(getDeal(db, alice, { id })).parties.find((p) => p.id === texty.id)!.reach).toBe("text");
40 });
41
42 test("editing keeps the preference when it is left out, changes it, and clears it when blank", () => {
43 const db = openDb(":memory:");
44 const id = open(db, alice);
45 const p = must(addParty(db, alice, { dealId: id, role: "lender", name: "Lee", phone: "555 0100", reach: "call" }));
46 expect(must(editParty(db, alice, { id: p.id, name: "Lee", phone: "555 0100" })).reach).toBe("call");
47 expect(must(editParty(db, alice, { id: p.id, name: "Lee", phone: "555 0100", reach: "email" })).reach).toBe("email");
48 expect(must(editParty(db, alice, { id: p.id, name: "Lee", phone: "555 0100", reach: "" })).reach).toBe("");
49 });
50
51 test("someone signed out may not set it", () => {
52 const db = openDb(":memory:");
53 const id = open(db, alice);
54 expect(addParty(db, { role: "anon" } as Who, { dealId: id, role: "lender", name: "Lee", reach: "text" }).ok).toBe(false);
55 });
56
57 test("the migration adds the column to a database that has parties, losing none", () => {
58 const db = new Database(":memory:");
59 const last = migrations.length - 1;
60 expect(migrations[last]).toContain("party ADD COLUMN reach");
61 db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))");
62 migrations.slice(0, last).forEach((sql, i) => {
63 try { db.exec(sql); } catch (e) { if (!String(e).includes("duplicate column name")) throw e; }
64 db.exec(`INSERT INTO schema_version (version) VALUES (${i + 1})`);
65 });
66 db.exec("INSERT INTO deal (id, address, contract_date, author_id, author_name) VALUES (1, 'X', '2026-10-01', 'u1', 'alice')");
67 db.exec("INSERT INTO party (deal_id, role, name, contact) VALUES (1, 'buyer', 'Old Friend', '[email protected]')");
68 migrate(db);
69 const row = db.query<{ name: string; reach: string }, []>("SELECT name, reach FROM party").get()!;
70 expect(row).toEqual({ name: "Old Friend", reach: "" });
71 expect(() => db.exec("UPDATE party SET reach = 'fax'")).toThrow();
72 });
73
74 test("People shows the preference beside the name, and the draft's words do not change", () => {
75 const db = openDb(":memory:");
76 const id = open(db, alice);
77 const lee = must(addParty(db, alice, { dealId: id, role: "lender", name: "Lee", email: "[email protected]", reach: "text" }));
78 const deal = must(getDeal(db, alice, { id }));
79 const html = peoplePage({ who: alice, people: people([deal], "2026-10-10"), on: "2026-10-10" });
80 expect(html).toContain("Reaches best by text");
81 const without = must(editParty(db, alice, { id: lee.id, name: "Lee", email: "[email protected]", reach: "" }));
82 expect(without.reach).toBe("");
83 const after = must(getDeal(db, alice, { id }));
84 expect(peoplePage({ who: alice, people: people([after], "2026-10-10"), on: "2026-10-10" })).not.toContain("Reaches best");
85 const wording = (d: DealView) => drafts(d, AGENT).map((x) => [x.subject, x.body]);
86 const withPref = must(editParty(db, alice, { id: lee.id, name: "Lee", email: "[email protected]", reach: "call" }));
87 expect(withPref.reach).toBe("call");
88 expect(wording(must(getDeal(db, alice, { id })))).toEqual(wording(after));
89 });
90
91 test("the review page says how the party reaches best, on each draft", () => {
92 const db = openDb(":memory:");
93 const id = open(db, alice);
94 must(addParty(db, alice, { dealId: id, role: "lender", name: "Lee", email: "[email protected]", reach: "call" }));
95 const deal = must(getDeal(db, alice, { id }));
96 const draft = { ...drafts(deal, AGENT)[0]!, reach: "call" as const };
97 const html = reviewPage({ who: alice, queue: [draft], q: "", at: 0, done: 0 });
98 expect(html).toContain("Reaches best by phone call");
99 expect(reviewPage({ who: alice, queue: [{ ...draft, reach: "" }], q: "", at: 0, done: 0 })).not.toContain("Reaches best");
100 expect(reachLine("text")).toBe("Reaches best by text");
101 });
102
103 test("export carries the preference and import brings it back; an older file imports with none", () => {
104 const db = openDb(":memory:");
105 const id = open(db, alice);
106 must(addParty(db, alice, { dealId: id, role: "lender", name: "Lee", email: "[email protected]", reach: "email" }));
107 const file = exportDeals(must(listDeals(db, alice)), new Date("2026-10-10T00:00:00Z"));
108 const mine = file.deals.find((d) => d.address === `${n} Elm St`)!;
109 expect(mine.parties.find((p) => p.name === "Lee")).toMatchObject({ reach: "email" });
110 const lee = (d: ReturnType<typeof openDb>, w: Who = bob) => must(listDeals(d, w)).find((x) => x.address === `${n} Elm St` && x.author_id === w.id)!.parties;
111 const one = (f: unknown) => ({ ...(f as object), deals: (f as typeof file).deals.filter((d) => d.address === `${n} Elm St`) });
112 const other = openDb(":memory:");
113 must(importDeals(other, bob, one(JSON.parse(JSON.stringify(file)))));
114 expect(lee(other).find((p) => p.name === "Lee")!.reach).toBe("email");
115 const old = one(JSON.parse(JSON.stringify(file))) as typeof file;
116 for (const p of old.deals[0]!.parties) delete (p as { reach?: string }).reach;
117 const third = openDb(":memory:");
118 must(importDeals(third, carol, old));
119 expect(lee(third, carol).every((p) => p.reach === "")).toBe(true);
120 old.deals[0]!.parties[0] = { ...old.deals[0]!.parties[0]!, reach: "smoke" } as never;
121 expect(importDeals(openDb(":memory:"), carol, old).ok).toBe(false);
122 });
123
124 test("the sample deals carry a mix of preferences", () => {
125 const db = openDb(":memory:");
126 seed(db);
127 const seen = new Set(db.query<{ reach: string }, []>("SELECT reach FROM party").all().map((r) => r.reach));
128 expect(seen.has("text") && seen.has("email") && seen.has("call") && seen.has("")).toBe(true);
129 });
src/reminders.ts +7 −1
@@ -15,7 +15,7 @@ import { ANNIVERSARY_YEARS, anniversaryOn, anniversaryWords, checkinOn, checkinR
1515 import { dollars } from "./money.ts";
1616 import { memoizer, print } from "./memo.ts";
1717 import { DEFAULT_LEAD, isInternal, isOpen, KIND, notifiedOf, PARTY_LABEL, type PartyRole } from "./deadlines.ts";
18 import type { DealView, DeadlineView, Move, Moment, Party } from "./deals.ts";
18 import type { DealView, DeadlineView, Move, Moment, Party, Reach } from "./deals.ts";
1919
2020 export interface Agent {
2121 readonly name: string;
@@ -44,6 +44,8 @@ export interface Draft {
4444 readonly contact: string;
4545 readonly email: string;
4646 readonly phone: string;
47 /** How the party likes to be reached; '' when no preference. Shown beside the draft; never in its words. */
48 readonly reach: Reach;
4749 readonly moment: Moment;
4850 /** Days before the due date the "before" reminder goes out: the date's own lead time. */
4951 readonly lead_days: number;
@@ -242,6 +244,7 @@ export function draftsFor(deal: DealView, deadline: DeadlineView, agent: Agent):
242244 contact: party.contact,
243245 email: party.email,
244246 phone: party.phone,
247 reach: party.reach,
245248 moment,
246249 lead_days: deadline.lead_days,
247250 move_id: key,
@@ -293,6 +296,7 @@ export function checkinDrafts(deal: DealView, agent: Agent): Draft[] {
293296 contact: party.contact,
294297 email: party.email,
295298 phone: party.phone,
299 reach: party.reach,
296300 moment: "checkin" as const,
297301 lead_days: closing.lead_days,
298302 move_id: 0,
@@ -342,6 +346,7 @@ export function anniversaryDrafts(deal: DealView, agent: Agent): Draft[] {
342346 contact: party.contact,
343347 email: party.email,
344348 phone: party.phone,
349 reach: party.reach,
345350 moment: "anniversary" as const,
346351 lead_days: closing.lead_days,
347352 move_id: year,
@@ -433,6 +438,7 @@ export function statusDrafts(deal: DealView, agent: Agent): Draft[] {
433438 contact: party.contact,
434439 email: party.email,
435440 phone: party.phone,
441 reach: party.reach,
436442 moment: "status" as const,
437443 lead_days: closing.lead_days,
438444 move_id: stand,
src/routes.ts +7 −6
@@ -229,9 +229,10 @@ const ROLE: Schema = { enum: ["buyer", "seller", "lender", "title", "other_agent
229229 /** Who owes a date: a party's role, or the agent for their own listing-agreement date. */
230230 const OWER: Schema = { enum: ["buyer", "seller", "lender", "title", "other_agent", "agent"] };
231231 const CONTACT: Schema = { type: "string", deprecated: true, description: "Deprecated: the email, else the phone. Read email and phone instead; this reads for one more release." };
232 const REACH: Schema = { enum: ["text", "email", "call", ""], description: "How the party likes to be reached; blank for no preference. On a correction, left out it stays." };
232233 const PARTY: Schema = {
233234 type: "object",
234 properties: { id: { type: "integer" }, role: ROLE, name: { type: "string" }, email: { type: "string", maxLength: 200 }, phone: { type: "string", maxLength: 30 }, contact: CONTACT },
235 properties: { id: { type: "integer" }, role: ROLE, name: { type: "string" }, email: { type: "string", maxLength: 200 }, phone: { type: "string", maxLength: 30 }, contact: CONTACT, reach: REACH },
235236 };
236237 const BOOK: Schema = {
237238 type: "object",
@@ -703,7 +704,7 @@ const dealOfParty = (c: Context, party: number): number | undefined =>
703704 function partyRefused(c: Context, dealId: number, id: number | "new", f: Record<string, string>, message: string): Response {
704705 const found = getDeal(c.db, c.who, { id: dealId });
705706 if (!found.ok) return back(found.message, `/deals/${dealId}`);
706 const party = { id, role: f.role ?? "", name: f.name ?? "", email: f.email ?? "", phone: f.phone ?? "", message };
707 const party = { id, role: f.role ?? "", name: f.name ?? "", email: f.email ?? "", phone: f.phone ?? "", reach: f.reach ?? "", message };
707708 return html(dealPage({ who: c.who, ...headerFor(c), agent: agentFor(c), deal: found.value, on: dayOf(agentFor(c)), party }), STATUS.invalid);
708709 }
709710
@@ -1051,7 +1052,7 @@ export const routes: readonly Route[] = [
10511052 const f = await form(c);
10521053 if (f instanceof Response) return f;
10531054 const id = Number(c.params.id);
1054 const added = addParty(c.db, c.who, { dealId: id, role: f.role, name: f.name, email: f.email, phone: f.phone, contact: f.contact });
1055 const added = addParty(c.db, c.who, { dealId: id, role: f.role, name: f.name, email: f.email, phone: f.phone, contact: f.contact, reach: f.reach });
10551056 return added.ok || added.error !== "invalid" ? done(added, backTo(`/deals/${id}`), "Party added.") : partyRefused(c, id, "new", f, added.message);
10561057 },
10571058 },
@@ -1370,7 +1371,7 @@ export const routes: readonly Route[] = [
13701371 const f = await form(c);
13711372 if (f instanceof Response) return f;
13721373 const id = Number(c.params.id);
1373 const edited = editParty(c.db, c.who, { id, name: f.name, email: f.email, phone: f.phone, contact: f.contact });
1374 const edited = editParty(c.db, c.who, { id, name: f.name, email: f.email, phone: f.phone, contact: f.contact, reach: f.reach });
13741375 if (edited.ok || edited.error !== "invalid") return done(edited, backTo(f.to), "Party corrected.");
13751376 const deal = dealOfParty(c, id);
13761377 return deal === undefined ? back(edited.message, backTo(f.to)) : partyRefused(c, deal, id, f, edited.message);
@@ -1659,14 +1660,14 @@ export const routes: readonly Route[] = [
16591660 if (body === undefined) return json({ error: "too_large" }, 413);
16601661 const f = fields(body);
16611662 return answer(
1662 editParty(c.db, c.who, { id: Number(c.params.party), dealId: Number(c.params.id), name: f.name, email: f.email, phone: f.phone, contact: f.contact }),
1663 editParty(c.db, c.who, { id: Number(c.params.party), dealId: Number(c.params.id), name: f.name, email: f.email, phone: f.phone, contact: f.contact, reach: f.reach }),
16631664 );
16641665 },
16651666 api: {
16661667 summary: "Correct a party's name, email and phone (all are rewritten; a missing email or phone clears it; 422 for an email that is not [email protected] up to 200 characters, or a phone of digits, spaces and + - ( ) . up to 30). The deprecated contact is still read when neither email nor phone is sent. Their role stays.",
16671668 body: {
16681669 type: "object",
1669 properties: { name: { type: "string", minLength: 1, maxLength: 100 }, email: { type: "string", maxLength: 200 }, phone: { type: "string", maxLength: 30 }, contact: { ...CONTACT, maxLength: 200 } },
1670 properties: { name: { type: "string", minLength: 1, maxLength: 100 }, email: { type: "string", maxLength: 200 }, phone: { type: "string", maxLength: 30 }, contact: { ...CONTACT, maxLength: 200 }, reach: REACH },
16701671 required: ["name"],
16711672 },
16721673 answer: PARTY,
src/sample.ts +6 −1
@@ -14,7 +14,7 @@ import { zoneOf } from "./profile.ts";
1414 import { type PartyRole } from "./deadlines.ts";
1515 import { BRIEF_LENGTHS, validEmail } from "./env.ts";
1616 import { APPOINTMENT_KINDS } from "./appointment.ts";
17 import { insertDeal } from "./deals.ts";
17 import { insertDeal, type Reach } from "./deals.ts";
1818 import type { Side } from "./side.ts";
1919
2020 export const SAMPLE = { id: "sample", name: "Sample data" };
@@ -40,6 +40,8 @@ interface Sample {
4040 readonly closingAt?: readonly [string, string];
4141 /** Role, name, email or phone, and a phone besides when the first is an email. */
4242 readonly parties: readonly (readonly [PartyRole, string, string] | readonly [PartyRole, string, string, string])[];
43 /** How a party prefers to be reached: party name, then text, email or call. */
44 readonly reach?: readonly (readonly [string, Reach])[];
4345 readonly done?: readonly string[];
4446 /** Kinds the buyer waived on purpose. */
4547 readonly waived?: readonly string[];
@@ -68,6 +70,7 @@ const SAMPLES: readonly Sample[] = [
6870 ["title", "Marcus Lee, Bayside Title", "[email protected]"],
6971 ["other_agent", "Jordan Ellis, Crestline Homes", "(555) 014-2290"],
7072 ],
73 reach: [["Maya & Tom Okafor", "text"], ["Priya Nair, Coastline Mortgage", "email"], ["Jordan Ellis, Crestline Homes", "call"]],
7174 done: ["earnest", "inspection", "title", "appraisal"],
7275 sent: [
7376 "inspection",
@@ -91,6 +94,7 @@ const SAMPLES: readonly Sample[] = [
9194 ["title", "Bayside Title", "[email protected]"],
9295 ["other_agent", "Alicia Moreno, Moreno & Co.", "[email protected]"],
9396 ],
97 reach: [["Samuel Reyes", "call"], ["Owen Brandt, First Keel Bank", "email"]],
9498 done: ["earnest"],
9599 notes: [["inspection", "Inspector booked for Friday 9am; seller will leave the attic open"]],
96100 custom: [["HOA documents review ends", 9, "buyer", "Read the HOA budget, rules and minutes; object in writing if needed"]],
@@ -428,6 +432,7 @@ function writeSamples(db: Database, on: string): void {
428432 const phone = second ?? (email === "" ? first : "");
429433 db.query("INSERT INTO party (deal_id, role, name, contact, email, phone) VALUES (?1, ?2, ?3, ?4, ?5, ?6)").run(deal.id, role, name, email || phone, email, phone);
430434 }
435 for (const [name, reach] of s.reach ?? []) db.query("UPDATE party SET reach = ?1 WHERE deal_id = ?2 AND name = ?3").run(reach, deal.id, name);
431436 // The anniversary sample was finished on the day a year back, whichever business day its due falls on.
432437 const finished = s.anniversary === undefined ? addDays(on, -1) : addDays(addDays(on, at.closing), -1);
433438 for (const kind of s.done ?? []) {
src/terminal.test.ts +39 −2
@@ -84,7 +84,7 @@ test("o opens a deal; a adds a date, p a party; D removes it after a yes", () =>
8484 expect(lines(t)).toContain("12 Elm St");
8585 press(t, "a", "HOA review ends\r", `${addDays(today(DEFAULT_ZONE), 5)}\r`, "buyer\r", "\r");
8686 expect(deal().deadlines.some((d) => d.label === "HOA review ends" && d.kind === "custom")).toBe(true);
87 press(t, "p", "lender\r", "Lee Lender\r", "[email protected]\r", "(555) 010-0166\r");
87 press(t, "p", "lender\r", "Lee Lender\r", "[email protected]\r", "(555) 010-0166\r", "\r");
8888 expect(deal().parties.some((p) => p.role === "lender" && p.email === "[email protected]" && p.phone === "(555) 010-0166")).toBe(true);
8989 expect(lines(t).join("\n")).toContain("[email protected]");
9090 press(t, "D", "no\r");
@@ -226,7 +226,7 @@ test("e edits the party under the cursor; X removes a party or a custom date aft
226226 const rows = read().deadlines.length;
227227 for (let i = 0; i < rows + 2; i++) t.press("j");
228228 expect(under(t)).toContain("Lee Lender");
229 press(t, "e", "Lee Lender\r", "[email protected]\r", "\r");
229 press(t, "e", "Lee Lender\r", "[email protected]\r", "\r", "\r");
230230 expect(read().parties.find((p) => p.id === lender.value.id)?.email).toBe("[email protected]");
231231 expect(under(t)).toContain("[email protected]");
232232 // X asks first; anything but yes keeps it.
@@ -1039,3 +1039,40 @@ test("G lists the ten tour steps from src/tour.ts, a guest is refused, ", () =>
10391039 guest.press("G");
10401040 expect(lines(guest).join("\n")).not.toContain("10. Set up your Profile");
10411041 });
1042
1043 test("p and e set how a party likes to be reached, an unknown value is refused, and P and the party list show it", () => {
1044 const made2 = createDeal(db, alice, { openAnyway: true, address: "31 Reach Way", buyer: "Rae Buyer", seller: "Sid Seller", contractDate: today(DEFAULT_ZONE), closingDate: addDays(today(DEFAULT_ZONE), 45) });
1045 if (!made2.ok) throw new Error(made2.message);
1046 const read = () => {
1047 const got = getDeal(db, alice, { id: made2.value.id });
1048 if (!got.ok) throw new Error(got.message);
1049 return got.value;
1050 };
1051 const t = terminal(db, alice, context);
1052 goTo(t, "Closing — 31 Reach Way");
1053 t.press("o");
1054 press(t, "p", "lender\r", "Tex Lender\r", "[email protected]\r", "\r", "text\r");
1055 const tex = read().parties.find((p) => p.name === "Tex Lender")!;
1056 expect(tex.reach).toBe("text");
1057 expect(lines(t).join("\n")).toContain("Reaches best by text");
1058 // An unknown value is refused and nothing is added.
1059 press(t, "p", "lender\r", "Pia Lender\r", "[email protected]\r", "\r", "owl\r");
1060 expect(read().parties.some((p) => p.name === "Pia Lender")).toBe(false);
1061 expect(lines(t).join("\n")).toContain("text, email or call");
1062 // Editing: a value changes it, a blank clears it, an unknown one leaves it.
1063 for (let i = 0; i < 20 && !under(t).includes("Tex Lender"); i++) t.press("j");
1064 expect(under(t)).toContain("Tex Lender");
1065 press(t, "e", "Tex Lender\r", "[email protected]\r", "\r", "call\r");
1066 expect(read().parties.find((p) => p.id === tex.id)!.reach).toBe("call");
1067 press(t, "e", "Tex Lender\r", "[email protected]\r", "\r", "owl\r");
1068 expect(read().parties.find((p) => p.id === tex.id)!.reach).toBe("call");
1069 press(t, "e", "Tex Lender\r", "[email protected]\r", "\r", "email\r");
1070 t.press("P");
1071 expect(lines(t).join("\n")).toContain("Reaches best by email");
1072 const back = terminal(db, alice, context);
1073 goTo(back, "Closing — 31 Reach Way");
1074 back.press("o");
1075 for (let i = 0; i < 20 && !under(back).includes("Tex Lender"); i++) back.press("j");
1076 press(back, "e", "Tex Lender\r", "[email protected]\r", "\r", "\r");
1077 expect(read().parties.find((p) => p.id === tex.id)!.reach).toBe("");
1078 });
src/terminal.ts +9 −8
@@ -6,7 +6,7 @@
66 * dates every open date of every deal, soonest first (h shows done ones)
77 * week the open dates overdue or due in the next seven days, by day
88 * reminders what goes out today; enter reads a draft, e edits its words, s sends it; h holds it until tomorrow, H lists the held ones and B brings one back
9 * people each person on the deals with every open date they owe (P)
9 * people each person on the deals with every open date they owe and how they like to be reached (P)
1010 * tour the five-minute tour on the sample deals, step by step (G)
1111 * timeline a deal's dates as a client would get them (t on a deal)
1212 * file a deal's broker file: figures, parties, every date and message (f on a deal)
@@ -33,6 +33,7 @@ import {
3333 addDeadline,
3434 addParty,
3535 editParty,
36 reachLine,
3637 updateDeal,
3738 BODY_MAX,
3839 canChange,
@@ -331,8 +332,8 @@ export function terminal(
331332 if (key === "e") {
332333 if (party === undefined) said = "Move to a party to edit it.";
333334 else
334 ask(deal, [`Name (now ${party.name})`, `Email (now ${party.email === "" ? "none" : party.email}; blank clears)`, `Phone (now ${party.phone === "" ? "none" : party.phone}; blank clears)`], ([name, email, phone]) =>
335 report(editParty(db, me, { id: party.id, name, email, phone }), `Corrected ${name}.`),
335 ask(deal, [`Name (now ${party.name})`, `Email (now ${party.email === "" ? "none" : party.email}; blank clears)`, `Phone (now ${party.phone === "" ? "none" : party.phone}; blank clears)`, `Reaches best by (text, email, call; now ${party.reach === "" ? "none" : party.reach}; blank clears)`], ([name, email, phone, reach]) =>
336 report(editParty(db, me, { id: party.id, name, email, phone, reach }), `Corrected ${name}.`),
336337 );
337338 }
338339 if (key === "X") {
@@ -352,8 +353,8 @@ export function terminal(
352353 report(addDeadline(db, me, { dealId: deal.id, label, due, owes, what }), () => withConflicts(db, me, deal.id, `Added ${label}.`)),
353354 );
354355 if (key === "p")
355 ask(deal, [`Role (${CONTACT_ROLES.join(", ")})`, "Name", "Email (optional)", "Phone (optional)"], ([role, name, email, phone]) =>
356 report(addParty(db, me, { dealId: deal.id, role, name, email, phone }), `Added ${name}.`),
356 ask(deal, [`Role (${CONTACT_ROLES.join(", ")})`, "Name", "Email (optional)", "Phone (optional)", "Reaches best by (text, email, call; blank for none)"], ([role, name, email, phone, reach]) =>
357 report(addParty(db, me, { dealId: deal.id, role, name, email, phone, reach }), `Added ${name}.`),
357358 );
358359 if (key === "D") {
359360 prompt = {
@@ -830,7 +831,7 @@ export function terminal(
830831 const all = narrowPeople(people(listed.value, day), found);
831832 if (all.length === 0) lines.push(dim(found === "" ? "No one yet: add a deal and its parties." : `No one matches “${found}”.`));
832833 for (const p of all) {
833 lines.push(`${bold(p.name)} ${dim(`${p.roles.join(", ")}${reachedAt(p) === "" ? "" : ` · ${reachedAt(p)}`}`)}`);
834 lines.push(`${bold(p.name)} ${dim(`${p.roles.join(", ")}${reachedAt(p) === "" ? "" : ` · ${reachedAt(p)}`}${reachLine(p.reach) === "" ? "" : ` · ${reachLine(p.reach)}`}`)}`);
834835 if (p.nudge !== null) lines.push(` ${tint("soon", nudgeLine(p.nudge, day))}`);
835836 lines.push(dim(p.last_sent === null ? " Nothing sent yet" : ` Last heard ${spoken(p.last_sent.sent_on)} · ${p.last_sent.label}, ${p.last_sent.address}`));
836837 if (p.owes.length === 0) lines.push(dim(" Nothing open"));
@@ -938,7 +939,7 @@ export function terminal(
938939 lines.push("", bold("Parties"));
939940 deal.parties.forEach((p, i) => {
940941 const at = deal.deadlines.length + i === cursor;
941 lines.push(`${at ? "▸" : " "} ${PARTY_LABEL[p.role].padEnd(14)} ${at ? bold(p.name) : p.name}${reachedAt(p) === "" ? "" : dim(` ${reachedAt(p)}`)}`);
942 lines.push(`${at ? "▸" : " "} ${PARTY_LABEL[p.role].padEnd(14)} ${at ? bold(p.name) : p.name}${reachedAt(p) === "" ? "" : dim(` ${reachedAt(p)}`)}${reachLine(p.reach) === "" ? "" : dim(` ${reachLine(p.reach)}`)}`);
942943 });
943944 if (!deal.closed && !deal.fell_through) {
944945 const { shown, rest } = coming(deal, day, agentNow());
@@ -954,7 +955,7 @@ export function terminal(
954955 if (rows.length === 0) lines.push(dim(showHeld ? "Nothing is held." : "Nothing goes out today."));
955956 rows.forEach(({ draft }, i) => {
956957 const state = showHeld && draft.held !== null ? tint("soon", `back ${spoken(draft.held.until)}`) : draft.sent_at !== null ? dim("sent") : draft.declined !== null ? dim("not sent") : draft.held !== null ? tint("overdue", heldFrom(draft.held).toLowerCase()) : draft.on < day ? tint("overdue", "late") : tint("soon", "to send");
957 const text = `${draft.party_name} (${PARTY_LABEL[draft.party_role].toLowerCase()}) · ${draft.label}, ${draft.address} · ${momentLabel(draft.moment, draft.lead_days).toLowerCase()}`;
958 const text = `${draft.party_name} (${PARTY_LABEL[draft.party_role].toLowerCase()}) · ${draft.label}, ${draft.address} · ${momentLabel(draft.moment, draft.lead_days).toLowerCase()}${reachLine(draft.reach) === "" ? "" : ` · ${reachLine(draft.reach)}`}`;
958959 lines.push(`${i === cursor ? "▸" : " "} ${state.padEnd(16)} ${i === cursor ? bold(text) : text}`);
959960 if (reading === i) {
960961 const words = edited(draft);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs