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
Check · Review · Land · Live done — Landed and live in production.
mason/deal-clock:mason/287-each-person-can-have-a-preferred-way-to → main 2 commits fork
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. | ||
| 87 | 87 | timeline and at the foot of every new reminder. (Only an admin can save |
| 88 | 88 | it; **Use defaults** goes back to the sample agent.) |
| 89 | 89 | |
| 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 | ||
| 90 | 101 | ## Finding a deal |
| 91 | 102 | |
| 92 | 103 | 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 @@ | ||
| 1096 | 1096 | "type": "string", |
| 1097 | 1097 | "deprecated": true, |
| 1098 | 1098 | "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." | |
| 1099 | 1108 | } |
| 1100 | 1109 | } |
| 1101 | 1110 | } |
| @@ -1699,6 +1708,15 @@ | ||
| 1699 | 1708 | "deprecated": true, |
| 1700 | 1709 | "description": "Deprecated: the email, else the phone. Read email and phone instead; this reads for one more release.", |
| 1701 | 1710 | "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." | |
| 1702 | 1720 | } |
| 1703 | 1721 | }, |
| 1704 | 1722 | "required": [ |
| @@ -1743,6 +1761,15 @@ | ||
| 1743 | 1761 | "type": "string", |
| 1744 | 1762 | "deprecated": true, |
| 1745 | 1763 | "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." | |
| 1746 | 1773 | } |
| 1747 | 1774 | } |
| 1748 | 1775 | } |
| @@ -3339,6 +3366,15 @@ | ||
| 3339 | 3366 | "type": "string", |
| 3340 | 3367 | "deprecated": true, |
| 3341 | 3368 | "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." | |
| 3342 | 3378 | } |
| 3343 | 3379 | } |
| 3344 | 3380 | } |
docs/TECHNICAL.md +5 −1
| @@ -121,7 +121,11 @@ reminder and its party's email and phone into links (`outboxes`): `mailto:` | ||
| 121 | 121 | otherwise ("No email or phone on file"). The approval is recorded once whatever |
| 122 | 122 | the number of links. A party has an optional `email` (up to 200 characters) and |
| 123 | 123 | 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 | |
| 125 | 129 | email else the phone, and is still accepted when neither new field is sent (sorted |
| 126 | 130 | into whichever it looks like); it goes in the next release. The migration split |
| 127 | 131 | 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[] = [ | ||
| 572 | 572 | // An inspection or a final walkthrough is an appointment: an optional time and place, like the closing's. |
| 573 | 573 | `ALTER TABLE deadline ADD COLUMN time TEXT CHECK (time IS NULL OR time GLOB '[0-2][0-9]:[0-5][0-9]')`, |
| 574 | 574 | `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'))`, | |
| 575 | 577 | ]; |
| 576 | 578 | |
| 577 | 579 | export function openDb(dir = dataDir): Database { |
src/deals.ts +30 −12
| @@ -79,6 +79,8 @@ export interface Party { | ||
| 79 | 79 | readonly email: string; |
| 80 | 80 | /** Up to 30 digits, spaces and + - ( ) . ; '' when none. */ |
| 81 | 81 | readonly phone: string; |
| 82 | /** How they like to be reached; '' when no preference. */ | |
| 83 | readonly reach: Reach; | |
| 82 | 84 | } |
| 83 | 85 | export interface Declined { |
| 84 | 86 | readonly id: number; |
| @@ -248,6 +250,19 @@ export const SUBJECT_MAX = 150; | ||
| 248 | 250 | export const EMAIL_MAX = 200; |
| 249 | 251 | /** The longest free-text contact an old row may keep. */ |
| 250 | 252 | 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 | ||
| 251 | 266 | /** The longest a party's phone may be. */ |
| 252 | 267 | export const PHONE_MAX = 30; |
| 253 | 268 | export const REASON_MAX = 200; |
| @@ -1152,9 +1167,12 @@ export function addDeadline( | ||
| 1152 | 1167 | * email or phone only when neither is given, and is sorted into whichever it |
| 1153 | 1168 | * looks like. |
| 1154 | 1169 | */ |
| 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 }> { | |
| 1156 | 1171 | const name = line(input.name, 1, 100); |
| 1157 | 1172 | 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."); | |
| 1158 | 1176 | const given = (v: unknown): v is string => v !== undefined && v !== null && v !== ""; |
| 1159 | 1177 | let email = ""; |
| 1160 | 1178 | let phone = ""; |
| @@ -1164,7 +1182,7 @@ export function partyText(input: { name: unknown; email?: unknown; phone?: unkno | ||
| 1164 | 1182 | if (validEmail(old)) email = old; |
| 1165 | 1183 | else if (validPhone(old)) phone = old; |
| 1166 | 1184 | 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 | |
| 1168 | 1186 | } else { |
| 1169 | 1187 | if (given(input.email)) { |
| 1170 | 1188 | const v = line(input.email, 1, EMAIL_MAX); |
| @@ -1177,13 +1195,13 @@ export function partyText(input: { name: unknown; email?: unknown; phone?: unkno | ||
| 1177 | 1195 | phone = v; |
| 1178 | 1196 | } |
| 1179 | 1197 | } |
| 1180 | return ok({ name, email, phone, contact: email || phone }); | |
| 1198 | return ok({ name, email, phone, contact: email || phone, reach }); | |
| 1181 | 1199 | } |
| 1182 | 1200 | |
| 1183 | 1201 | export function addParty( |
| 1184 | 1202 | db: Database, |
| 1185 | 1203 | 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 }, | |
| 1187 | 1205 | ): Result<Party> { |
| 1188 | 1206 | const deal = dealFor(db, who, "update", input.dealId); |
| 1189 | 1207 | const dealId = deal.ok ? deal.value.id : undefined; |
| @@ -1193,10 +1211,10 @@ export function addParty( | ||
| 1193 | 1211 | const text = partyText(input); |
| 1194 | 1212 | if (!text.ok) return within(db, who, "party.add", dealId, text); |
| 1195 | 1213 | 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 *", | |
| 1198 | 1216 | ) |
| 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 ?? "")!; | |
| 1200 | 1218 | return within(db, who, "party.add", dealId, ok(made)); |
| 1201 | 1219 | } |
| 1202 | 1220 | |
| @@ -1223,21 +1241,21 @@ function partyFor( | ||
| 1223 | 1241 | return { dealId: deal.value.id, found: ok({ deal: deal.value, party }) }; |
| 1224 | 1242 | } |
| 1225 | 1243 | |
| 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. */ | |
| 1227 | 1245 | export function editParty( |
| 1228 | 1246 | db: Database, |
| 1229 | 1247 | 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 }, | |
| 1231 | 1249 | ): Result<Party> { |
| 1232 | 1250 | const { dealId, found } = partyFor(db, who, input.id, input.dealId); |
| 1233 | 1251 | if (!found.ok) return within(db, who, "party.edit", dealId, found); |
| 1234 | 1252 | const text = partyText(input); |
| 1235 | 1253 | if (!text.ok) return within(db, who, "party.edit", dealId, text); |
| 1236 | 1254 | 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 *", | |
| 1239 | 1257 | ) |
| 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)!; | |
| 1241 | 1259 | return within(db, who, "party.edit", dealId, ok(made)); |
| 1242 | 1260 | } |
| 1243 | 1261 |
src/export.test.ts +1 −1
| @@ -86,7 +86,7 @@ test("the file holds every field, with moves, notes, state, parties and messages | ||
| 86 | 86 | const mine = file.deals.find((d) => d.id === dealId)!; |
| 87 | 87 | expect(mine.address).toBe("9 Export Way"); |
| 88 | 88 | 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: "" }); | |
| 90 | 90 | const date = mine.dates.find((d) => d.kind === "inspection")!; |
| 91 | 91 | expect(date.note).toBe("Call the inspector"); |
| 92 | 92 | 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> => ({ | ||
| 42 | 42 | opened_by: deal.author_name, |
| 43 | 43 | created_at: deal.created_at, |
| 44 | 44 | ...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 })), | |
| 46 | 46 | dates: deal.deadlines.map((d) => ({ |
| 47 | 47 | label: d.label, |
| 48 | 48 | kind: d.kind, |
src/import.ts +4 −3
| @@ -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 } from "./deals.ts"; | |
| 17 | import { NOTE_MAX, 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 { emit } from "./live.ts"; |
| 20 | 20 | import { audited, fail, ok, refuse as denied, type Result } from "./result.ts"; |
| @@ -52,6 +52,7 @@ interface PartyIn { | ||
| 52 | 52 | email: string; |
| 53 | 53 | phone: string; |
| 54 | 54 | contact: string; |
| 55 | reach: Reach; | |
| 55 | 56 | } |
| 56 | 57 | /** One deal of the file, checked and ready to write. */ |
| 57 | 58 | export interface DealIn { |
| @@ -92,7 +93,7 @@ function readDeal(raw: unknown, n: number): Result<DealIn> { | ||
| 92 | 93 | if (!isContactRole(o.role)) return refuse(n, `party ${i + 1} has no valid role.`, `parties[${i}].role`); |
| 93 | 94 | const text = partyText(o); |
| 94 | 95 | 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 ?? "" }); | |
| 96 | 97 | } |
| 97 | 98 | |
| 98 | 99 | const dates: DateIn[] = []; |
| @@ -222,7 +223,7 @@ function write(db: Database, who: Who & { role: "user" | "admin" }, deals: reado | ||
| 222 | 223 | d.closingTime, d.closingPlace, d.fell?.at ?? null, d.fell?.reason ?? null, d.fell?.by ?? null, deal.id, |
| 223 | 224 | ); |
| 224 | 225 | 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); | |
| 226 | 227 | for (const x of d.dates) { |
| 227 | 228 | const lead = x.kind === "custom" ? leadOfLabel(x.label) : leadOf(x.kind); |
| 228 | 229 | const what = x.kind === "custom" ? "" : KIND[x.kind].what; |
src/mcp.ts +4 −2
| @@ -480,11 +480,12 @@ export const tools: readonly Tool[] = [ | ||
| 480 | 480 | email: { type: "string", maxLength: 200 }, |
| 481 | 481 | phone: { type: "string", maxLength: 30, description: "Digits, spaces and + - ( ) . only." }, |
| 482 | 482 | 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." }, | |
| 483 | 484 | }, |
| 484 | 485 | required: ["deal_id", "role", "name"], |
| 485 | 486 | }, |
| 486 | 487 | 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 }), | |
| 488 | 489 | }, |
| 489 | 490 | { |
| 490 | 491 | name: "edit_party", |
| @@ -498,11 +499,12 @@ export const tools: readonly Tool[] = [ | ||
| 498 | 499 | email: { type: "string", maxLength: 200 }, |
| 499 | 500 | phone: { type: "string", maxLength: 30, description: "Digits, spaces and + - ( ) . only." }, |
| 500 | 501 | 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." }, | |
| 501 | 503 | }, |
| 502 | 504 | required: ["deal_id", "party_id", "name"], |
| 503 | 505 | }, |
| 504 | 506 | 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 }), | |
| 506 | 508 | }, |
| 507 | 509 | { |
| 508 | 510 | name: "remove_party", |
src/migrations.pin.json +2 −1
| @@ -57,5 +57,6 @@ | ||
| 57 | 57 | "7e011fb3dab2123101aa981ef9050d8f8d2c34fb3ba137ecc832f86d464b98a2", |
| 58 | 58 | "429a2adfb36ff61def2d1979556a9db87f4758663fa9c56b97e599b3bc13b773", |
| 59 | 59 | "c5df0d263e04d4e07f9df383beb57f033966af540bb228d9bbbd7b57f3620408", |
| 60 | "4a52d9de76ede6d34d831d9bc6fc1d3d3a501a5d3652bf63695a9de49f2a7d39" | |
| 60 | "4a52d9de76ede6d34d831d9bc6fc1d3d3a501a5d3652bf63695a9de49f2a7d39", | |
| 61 | "fafa7c66cff02dd250f2bed223947161b54f286b868fdd07c07c8c6d8b4556e3" | |
| 61 | 62 | ] |
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)/.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)); | |
| 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 +10 −5
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | 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"; | |
| 9 | 9 | import { matchKnown, type Known } from "../people.ts"; |
| 10 | 10 | import { agent as defaults, standard } from "../env.ts"; |
| 11 | 11 | import { CONTACT_ROLES, DEFAULT_LENGTHS, PARTY_LABEL, PARTY_ROLES, suggestedFor } from "../deadlines.ts"; |
| @@ -43,16 +43,21 @@ export interface PartyProblem { | ||
| 43 | 43 | readonly name: string; |
| 44 | 44 | readonly email: string; |
| 45 | 45 | readonly phone: string; |
| 46 | readonly reach?: string; | |
| 46 | 47 | readonly message: string; |
| 47 | 48 | } |
| 48 | 49 | |
| 49 | 50 | /** 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 => | |
| 51 | 55 | `${message === undefined ? "" : `<p class="conflict" role="alert">${escape(message)}</p>`} |
| 52 | 56 | <div class="pair"> |
| 53 | 57 | <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> |
| 54 | 58 | <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)}`; | |
| 56 | 61 | |
| 57 | 62 | /** A party's email and phone as links, each on its own. */ |
| 58 | 63 | const partyLinks = (p: Party): string => { |
| @@ -67,7 +72,7 @@ const partyControls = (p: Party, to: string, problem?: PartyProblem): string => | ||
| 67 | 72 | <form class="stack" method="post" action="/parties/${p.id}/edit"> |
| 68 | 73 | <input type="hidden" name="to" value="${escape(to)}"> |
| 69 | 74 | <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)} | |
| 71 | 76 | ${p.email === "" && p.phone === "" && p.contact !== "" ? `<input type="hidden" name="contact" value="${escape(p.contact)}">` : ""} |
| 72 | 77 | <button class="button">Save</button> |
| 73 | 78 | </form> |
| @@ -361,7 +366,7 @@ export function dealPage(input: { | ||
| 361 | 366 | <label>Role<select class="input" name="role">${roleOptions(add?.role ?? "lender", CONTACT_ROLES)}</select></label> |
| 362 | 367 | <label>Name<input class="input" name="name" required maxlength="100" value="${escape(add?.name ?? "")}"></label> |
| 363 | 368 | </div> |
| 364 | ${contactFields(add?.email ?? "", add?.phone ?? "", add?.message)} | |
| 369 | ${contactFields(add?.email ?? "", add?.phone ?? "", add?.message, add?.reach ?? "")} | |
| 365 | 370 | <button class="button">Add party</button> |
| 366 | 371 | </form> |
| 367 | 372 | </section>` |
src/pages/people.ts +2
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import { relative, spoken } from "../dates.ts"; |
| 8 | 8 | import { escape } from "../http.ts"; |
| 9 | import { reachLine } from "../deals.ts"; | |
| 9 | 10 | import { nudgeLine, type Person } from "../people.ts"; |
| 10 | 11 | import type { Who } from "../who.ts"; |
| 11 | 12 | import type { Agent } from "../reminders.ts"; |
| @@ -24,6 +25,7 @@ const lastHeard = (l: Person["last_sent"]): string => | ||
| 24 | 25 | const person = (p: Person, on: string): string => `<section class="card person"> |
| 25 | 26 | <h2>${escape(p.name)}</h2> |
| 26 | 27 | <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>`} | |
| 27 | 29 | ${p.nudge === null ? "" : `<p class="person-nudge">${escape(nudgeLine(p.nudge, on))}</p>`} |
| 28 | 30 | ${lastHeard(p.last_sent)} |
| 29 | 31 | ${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 @@ | ||
| 8 | 8 | |
| 9 | 9 | import type { Agent } from "../reminders.ts"; |
| 10 | 10 | 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"; | |
| 12 | 12 | import { spoken } from "../dates.ts"; |
| 13 | 13 | import { PARTY_LABEL } from "../deadlines.ts"; |
| 14 | 14 | import { asset, escape } from "../http.ts"; |
| @@ -122,7 +122,7 @@ function form( | ||
| 122 | 122 | <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> |
| 123 | 123 | <p><a href="/deals/${draft.deal_id}">${escape(draft.address)}</a></p> |
| 124 | 124 | <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> | |
| 126 | 126 | <form class="move" method="post" action="${REVIEW_PATH}/send">${hidden(keys)} |
| 127 | 127 | ${draft.contact === "" ? '<button class="button" hidden tabindex="-1" aria-hidden="true">Send</button>' : ""} |
| 128 | 128 | ${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 | ||
| 111 | 111 | expect(html).toContain('href="/people"'); |
| 112 | 112 | expect(html).not.toContain("<b>x</b>"); |
| 113 | 113 | }); |
| 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 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | 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"; | |
| 9 | 9 | import { isOpen, PARTY_LABEL, type PartyRole } from "./deadlines.ts"; |
| 10 | 10 | |
| 11 | 11 | export interface Owed { |
| @@ -34,6 +34,8 @@ export interface Person { | ||
| 34 | 34 | readonly contact: string; |
| 35 | 35 | readonly email: string; |
| 36 | 36 | readonly phone: string; |
| 37 | /** How they like to be reached; '' when no preference. */ | |
| 38 | readonly reach: Reach; | |
| 37 | 39 | /** The roles they hold across deals, as words, in the order first met. */ |
| 38 | 40 | readonly roles: readonly string[]; |
| 39 | 41 | /** Open dates they owe, soonest first, then by address. */ |
| @@ -81,7 +83,7 @@ export const personKey = (p: { name: string; contact: string; email?: string; ph | ||
| 81 | 83 | type Last = (LastSent & { at: string; id: number }) | null; |
| 82 | 84 | 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); |
| 83 | 85 | |
| 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 } | |
| 85 | 87 | |
| 86 | 88 | /** Two parties are one person when they share an email or a phone, even if one row has only the phone and another both. */ |
| 87 | 89 | export function people(deals: readonly DealView[], on: string): Person[] { |
| @@ -94,6 +96,8 @@ export function people(deals: readonly DealView[], on: string): Person[] { | ||
| 94 | 96 | into.email = from.email; |
| 95 | 97 | into.phone = from.phone; |
| 96 | 98 | } |
| 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; } | |
| 97 | 101 | into.last = newer(into.last, from.last); |
| 98 | 102 | for (const r of from.roles) into.roles.add(r); |
| 99 | 103 | 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[] { | ||
| 101 | 105 | for (const deal of deals) { |
| 102 | 106 | for (const party of deal.parties) { |
| 103 | 107 | 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 }; | |
| 105 | 109 | let one = fresh; |
| 106 | 110 | for (const key of keys) { |
| 107 | 111 | const found = byKey.get(key); |
| @@ -120,6 +124,7 @@ export function people(deals: readonly DealView[], on: string): Person[] { | ||
| 120 | 124 | one.email = party.email.trim(); |
| 121 | 125 | one.phone = party.phone.trim(); |
| 122 | 126 | } |
| 127 | if (party.reach && party.id > one.reachId) { one.reach = party.reach; one.reachId = party.id; } | |
| 123 | 128 | one.roles.add(party.role); |
| 124 | 129 | // Only sent messages are in `deal.sent`; declined and held ones live apart. |
| 125 | 130 | for (const m of deal.sent) { |
| @@ -149,6 +154,7 @@ export function people(deals: readonly DealView[], on: string): Person[] { | ||
| 149 | 154 | contact: p.contact, |
| 150 | 155 | email: p.email, |
| 151 | 156 | phone: p.phone, |
| 157 | reach: p.reach, | |
| 152 | 158 | roles: [...p.roles].map((r) => PARTY_LABEL[r]), |
| 153 | 159 | last_sent, |
| 154 | 160 | 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 | ||
| 15 | 15 | import { dollars } from "./money.ts"; |
| 16 | 16 | import { memoizer, print } from "./memo.ts"; |
| 17 | 17 | 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"; | |
| 19 | 19 | |
| 20 | 20 | export interface Agent { |
| 21 | 21 | readonly name: string; |
| @@ -44,6 +44,8 @@ export interface Draft { | ||
| 44 | 44 | readonly contact: string; |
| 45 | 45 | readonly email: string; |
| 46 | 46 | 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; | |
| 47 | 49 | readonly moment: Moment; |
| 48 | 50 | /** Days before the due date the "before" reminder goes out: the date's own lead time. */ |
| 49 | 51 | readonly lead_days: number; |
| @@ -242,6 +244,7 @@ export function draftsFor(deal: DealView, deadline: DeadlineView, agent: Agent): | ||
| 242 | 244 | contact: party.contact, |
| 243 | 245 | email: party.email, |
| 244 | 246 | phone: party.phone, |
| 247 | reach: party.reach, | |
| 245 | 248 | moment, |
| 246 | 249 | lead_days: deadline.lead_days, |
| 247 | 250 | move_id: key, |
| @@ -293,6 +296,7 @@ export function checkinDrafts(deal: DealView, agent: Agent): Draft[] { | ||
| 293 | 296 | contact: party.contact, |
| 294 | 297 | email: party.email, |
| 295 | 298 | phone: party.phone, |
| 299 | reach: party.reach, | |
| 296 | 300 | moment: "checkin" as const, |
| 297 | 301 | lead_days: closing.lead_days, |
| 298 | 302 | move_id: 0, |
| @@ -342,6 +346,7 @@ export function anniversaryDrafts(deal: DealView, agent: Agent): Draft[] { | ||
| 342 | 346 | contact: party.contact, |
| 343 | 347 | email: party.email, |
| 344 | 348 | phone: party.phone, |
| 349 | reach: party.reach, | |
| 345 | 350 | moment: "anniversary" as const, |
| 346 | 351 | lead_days: closing.lead_days, |
| 347 | 352 | move_id: year, |
| @@ -433,6 +438,7 @@ export function statusDrafts(deal: DealView, agent: Agent): Draft[] { | ||
| 433 | 438 | contact: party.contact, |
| 434 | 439 | email: party.email, |
| 435 | 440 | phone: party.phone, |
| 441 | reach: party.reach, | |
| 436 | 442 | moment: "status" as const, |
| 437 | 443 | lead_days: closing.lead_days, |
| 438 | 444 | move_id: stand, |
src/routes.ts +7 −6
| @@ -229,9 +229,10 @@ const ROLE: Schema = { enum: ["buyer", "seller", "lender", "title", "other_agent | ||
| 229 | 229 | /** Who owes a date: a party's role, or the agent for their own listing-agreement date. */ |
| 230 | 230 | const OWER: Schema = { enum: ["buyer", "seller", "lender", "title", "other_agent", "agent"] }; |
| 231 | 231 | 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." }; | |
| 232 | 233 | const PARTY: Schema = { |
| 233 | 234 | 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 }, | |
| 235 | 236 | }; |
| 236 | 237 | const BOOK: Schema = { |
| 237 | 238 | type: "object", |
| @@ -703,7 +704,7 @@ const dealOfParty = (c: Context, party: number): number | undefined => | ||
| 703 | 704 | function partyRefused(c: Context, dealId: number, id: number | "new", f: Record<string, string>, message: string): Response { |
| 704 | 705 | const found = getDeal(c.db, c.who, { id: dealId }); |
| 705 | 706 | 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 }; | |
| 707 | 708 | return html(dealPage({ who: c.who, ...headerFor(c), agent: agentFor(c), deal: found.value, on: dayOf(agentFor(c)), party }), STATUS.invalid); |
| 708 | 709 | } |
| 709 | 710 | |
| @@ -1051,7 +1052,7 @@ export const routes: readonly Route[] = [ | ||
| 1051 | 1052 | const f = await form(c); |
| 1052 | 1053 | if (f instanceof Response) return f; |
| 1053 | 1054 | 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 }); | |
| 1055 | 1056 | return added.ok || added.error !== "invalid" ? done(added, backTo(`/deals/${id}`), "Party added.") : partyRefused(c, id, "new", f, added.message); |
| 1056 | 1057 | }, |
| 1057 | 1058 | }, |
| @@ -1370,7 +1371,7 @@ export const routes: readonly Route[] = [ | ||
| 1370 | 1371 | const f = await form(c); |
| 1371 | 1372 | if (f instanceof Response) return f; |
| 1372 | 1373 | 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 }); | |
| 1374 | 1375 | if (edited.ok || edited.error !== "invalid") return done(edited, backTo(f.to), "Party corrected."); |
| 1375 | 1376 | const deal = dealOfParty(c, id); |
| 1376 | 1377 | 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[] = [ | ||
| 1659 | 1660 | if (body === undefined) return json({ error: "too_large" }, 413); |
| 1660 | 1661 | const f = fields(body); |
| 1661 | 1662 | 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 }), | |
| 1663 | 1664 | ); |
| 1664 | 1665 | }, |
| 1665 | 1666 | api: { |
| 1666 | 1667 | 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.", |
| 1667 | 1668 | body: { |
| 1668 | 1669 | 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 }, | |
| 1670 | 1671 | required: ["name"], |
| 1671 | 1672 | }, |
| 1672 | 1673 | answer: PARTY, |
src/sample.ts +6 −1
| @@ -14,7 +14,7 @@ import { zoneOf } from "./profile.ts"; | ||
| 14 | 14 | import { type PartyRole } from "./deadlines.ts"; |
| 15 | 15 | import { BRIEF_LENGTHS, validEmail } from "./env.ts"; |
| 16 | 16 | import { APPOINTMENT_KINDS } from "./appointment.ts"; |
| 17 | import { insertDeal } from "./deals.ts"; | |
| 17 | import { insertDeal, type Reach } from "./deals.ts"; | |
| 18 | 18 | import type { Side } from "./side.ts"; |
| 19 | 19 | |
| 20 | 20 | export const SAMPLE = { id: "sample", name: "Sample data" }; |
| @@ -40,6 +40,8 @@ interface Sample { | ||
| 40 | 40 | readonly closingAt?: readonly [string, string]; |
| 41 | 41 | /** Role, name, email or phone, and a phone besides when the first is an email. */ |
| 42 | 42 | 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])[]; | |
| 43 | 45 | readonly done?: readonly string[]; |
| 44 | 46 | /** Kinds the buyer waived on purpose. */ |
| 45 | 47 | readonly waived?: readonly string[]; |
| @@ -68,6 +70,7 @@ const SAMPLES: readonly Sample[] = [ | ||
| 68 | 70 | ["title", "Marcus Lee, Bayside Title", "[email protected]"], |
| 69 | 71 | ["other_agent", "Jordan Ellis, Crestline Homes", "(555) 014-2290"], |
| 70 | 72 | ], |
| 73 | reach: [["Maya & Tom Okafor", "text"], ["Priya Nair, Coastline Mortgage", "email"], ["Jordan Ellis, Crestline Homes", "call"]], | |
| 71 | 74 | done: ["earnest", "inspection", "title", "appraisal"], |
| 72 | 75 | sent: [ |
| 73 | 76 | "inspection", |
| @@ -91,6 +94,7 @@ const SAMPLES: readonly Sample[] = [ | ||
| 91 | 94 | ["title", "Bayside Title", "[email protected]"], |
| 92 | 95 | ["other_agent", "Alicia Moreno, Moreno & Co.", "[email protected]"], |
| 93 | 96 | ], |
| 97 | reach: [["Samuel Reyes", "call"], ["Owen Brandt, First Keel Bank", "email"]], | |
| 94 | 98 | done: ["earnest"], |
| 95 | 99 | notes: [["inspection", "Inspector booked for Friday 9am; seller will leave the attic open"]], |
| 96 | 100 | 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 { | ||
| 428 | 432 | const phone = second ?? (email === "" ? first : ""); |
| 429 | 433 | 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); |
| 430 | 434 | } |
| 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); | |
| 431 | 436 | // The anniversary sample was finished on the day a year back, whichever business day its due falls on. |
| 432 | 437 | const finished = s.anniversary === undefined ? addDays(on, -1) : addDays(addDays(on, at.closing), -1); |
| 433 | 438 | 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", () => | ||
| 84 | 84 | expect(lines(t)).toContain("12 Elm St"); |
| 85 | 85 | press(t, "a", "HOA review ends\r", `${addDays(today(DEFAULT_ZONE), 5)}\r`, "buyer\r", "\r"); |
| 86 | 86 | 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"); | |
| 88 | 88 | expect(deal().parties.some((p) => p.role === "lender" && p.email === "[email protected]" && p.phone === "(555) 010-0166")).toBe(true); |
| 89 | 89 | expect(lines(t).join("\n")).toContain("[email protected]"); |
| 90 | 90 | 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 | ||
| 226 | 226 | const rows = read().deadlines.length; |
| 227 | 227 | for (let i = 0; i < rows + 2; i++) t.press("j"); |
| 228 | 228 | 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"); | |
| 230 | 230 | expect(read().parties.find((p) => p.id === lender.value.id)?.email).toBe("[email protected]"); |
| 231 | 231 | expect(under(t)).toContain("[email protected]"); |
| 232 | 232 | // 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, ", () => | ||
| 1039 | 1039 | guest.press("G"); |
| 1040 | 1040 | expect(lines(guest).join("\n")).not.toContain("10. Set up your Profile"); |
| 1041 | 1041 | }); |
| 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 @@ | ||
| 6 | 6 | * dates every open date of every deal, soonest first (h shows done ones) |
| 7 | 7 | * week the open dates overdue or due in the next seven days, by day |
| 8 | 8 | * 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) | |
| 10 | 10 | * tour the five-minute tour on the sample deals, step by step (G) |
| 11 | 11 | * timeline a deal's dates as a client would get them (t on a deal) |
| 12 | 12 | * file a deal's broker file: figures, parties, every date and message (f on a deal) |
| @@ -33,6 +33,7 @@ import { | ||
| 33 | 33 | addDeadline, |
| 34 | 34 | addParty, |
| 35 | 35 | editParty, |
| 36 | reachLine, | |
| 36 | 37 | updateDeal, |
| 37 | 38 | BODY_MAX, |
| 38 | 39 | canChange, |
| @@ -331,8 +332,8 @@ export function terminal( | ||
| 331 | 332 | if (key === "e") { |
| 332 | 333 | if (party === undefined) said = "Move to a party to edit it."; |
| 333 | 334 | 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}.`), | |
| 336 | 337 | ); |
| 337 | 338 | } |
| 338 | 339 | if (key === "X") { |
| @@ -352,8 +353,8 @@ export function terminal( | ||
| 352 | 353 | report(addDeadline(db, me, { dealId: deal.id, label, due, owes, what }), () => withConflicts(db, me, deal.id, `Added ${label}.`)), |
| 353 | 354 | ); |
| 354 | 355 | 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}.`), | |
| 357 | 358 | ); |
| 358 | 359 | if (key === "D") { |
| 359 | 360 | prompt = { |
| @@ -830,7 +831,7 @@ export function terminal( | ||
| 830 | 831 | const all = narrowPeople(people(listed.value, day), found); |
| 831 | 832 | if (all.length === 0) lines.push(dim(found === "" ? "No one yet: add a deal and its parties." : `No one matches “${found}”.`)); |
| 832 | 833 | 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)}`}`)}`); | |
| 834 | 835 | if (p.nudge !== null) lines.push(` ${tint("soon", nudgeLine(p.nudge, day))}`); |
| 835 | 836 | 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}`)); |
| 836 | 837 | if (p.owes.length === 0) lines.push(dim(" Nothing open")); |
| @@ -938,7 +939,7 @@ export function terminal( | ||
| 938 | 939 | lines.push("", bold("Parties")); |
| 939 | 940 | deal.parties.forEach((p, i) => { |
| 940 | 941 | 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)}`)}`); | |
| 942 | 943 | }); |
| 943 | 944 | if (!deal.closed && !deal.fell_through) { |
| 944 | 945 | const { shown, rest } = coming(deal, day, agentNow()); |
| @@ -954,7 +955,7 @@ export function terminal( | ||
| 954 | 955 | if (rows.length === 0) lines.push(dim(showHeld ? "Nothing is held." : "Nothing goes out today.")); |
| 955 | 956 | rows.forEach(({ draft }, i) => { |
| 956 | 957 | 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)}`}`; | |
| 958 | 959 | lines.push(`${i === cursor ? "▸" : " "} ${state.padEnd(16)} ${i === cursor ? bold(text) : text}`); |
| 959 | 960 | if (reading === i) { |
| 960 | 961 | const words = edited(draft); |
new above
offline