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

The API adds a date to a deal, like the page and MCP do, and /openapi.json lists the common labels (HOA documents review, survey, repair request deadline…) on its body #167

Merged opened this

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

Merged

mason/deal-clock:mason/154-the-api-adds-a-date-to-a-deal-like-the-p → main 2 commits fork 1 thread · 1 unresolved

  • Merged. mason/154-the-api-adds-a-date-to-a-deal-like-the-p was merged into main as 1b8c4f9.

Review

Sign in to review this change.
6 files changed +233 −6
README.md +7
@@ -43,6 +43,13 @@ a deal by name the same way: over MCP, `list_deals` takes an optional `query`
4343 same; a query over 100 characters or with control characters is refused (400,
4444 `{error:"invalid", field:"q"}`).
4545
46 To add a date over the API, `POST /api/deals/:id/deadlines` with `label` (1 to
47 100 characters), `due` (YYYY-MM-DD), `owes` (a party role) and optionally `what`
48 and `lead_days` (1 to 30). It answers 201 with the date, or 400 naming the
49 field. A label typed exactly as a suggestion (`/openapi.json` lists them) sets
50 that date's reminder lead unless `lead_days` is given. It is the deal update
51 cell, like the page and MCP `add_date`.
52
4653 Signed out, `/` shows what deal-clock does, three short points, a still picture of the clock drawn from a made-up deal, and one **Sign in** button; no real deal is read.
4754
4855 1. **Look at the clock.** You see four lanes: 77 Harbor View Dr, Unit 4B
contract/openapi.json +130
@@ -2131,6 +2131,136 @@
21312131 }
21322132 }
21332133 },
2134 "/api/deals/{id}/deadlines": {
2135 "post": {
2136 "summary": "Add a date to a deal, saying which party owes it. A label typed exactly as a common one sets its reminder lead unless lead_days is given; any other label is fine and starts from the default lead. A field that is wrong is a 400 naming it.",
2137 "operationId": "postApiDealsIdDeadlines",
2138 "parameters": [
2139 {
2140 "name": "id",
2141 "in": "path",
2142 "required": true,
2143 "schema": {
2144 "type": "integer"
2145 }
2146 }
2147 ],
2148 "requestBody": {
2149 "required": true,
2150 "content": {
2151 "application/json": {
2152 "schema": {
2153 "type": "object",
2154 "properties": {
2155 "label": {
2156 "type": "string",
2157 "minLength": 1,
2158 "maxLength": 100,
2159 "description": "What the date is. Common labels: Seller disclosures due; HOA documents review ends; Survey due; Repair request deadline; Home warranty ordered; Lead paint inspection ends."
2160 },
2161 "due": {
2162 "type": "string",
2163 "format": "date"
2164 },
2165 "owes": {
2166 "enum": [
2167 "buyer",
2168 "seller",
2169 "lender",
2170 "title",
2171 "other_agent"
2172 ]
2173 },
2174 "what": {
2175 "type": "string",
2176 "maxLength": 200,
2177 "description": "What is owed, optional."
2178 },
2179 "lead_days": {
2180 "type": "integer",
2181 "minimum": 1,
2182 "maximum": 30
2183 }
2184 },
2185 "required": [
2186 "label",
2187 "due",
2188 "owes"
2189 ]
2190 }
2191 }
2192 }
2193 },
2194 "responses": {
2195 "201": {
2196 "description": "Add a date to a deal, saying which party owes it. A label typed exactly as a common one sets its reminder lead unless lead_days is given; any other label is fine and starts from the default lead. A field that is wrong is a 400 naming it.",
2197 "content": {
2198 "application/json": {
2199 "schema": {
2200 "type": "object",
2201 "properties": {
2202 "id": {
2203 "type": "integer"
2204 },
2205 "deal_id": {
2206 "type": "integer"
2207 },
2208 "kind": {
2209 "type": "string"
2210 },
2211 "label": {
2212 "type": "string"
2213 },
2214 "due": {
2215 "type": "string",
2216 "format": "date"
2217 },
2218 "owes": {
2219 "enum": [
2220 "buyer",
2221 "seller",
2222 "lender",
2223 "title",
2224 "other_agent"
2225 ]
2226 },
2227 "what": {
2228 "type": "string"
2229 },
2230 "lead_days": {
2231 "type": "integer"
2232 }
2233 },
2234 "required": [
2235 "id",
2236 "deal_id",
2237 "label",
2238 "due",
2239 "owes",
2240 "lead_days"
2241 ]
2242 }
2243 }
2244 }
2245 },
2246 "default": {
2247 "description": "{ error, message }: why not",
2248 "content": {
2249 "application/json": {
2250 "schema": {
2251 "$ref": "#/components/schemas/Error"
2252 }
2253 }
2254 }
2255 }
2256 },
2257 "x-contract": {
2258 "resource": "deal",
2259 "verb": "update",
2260 "least": "user"
2261 }
2262 }
2263 },
21342264 "/api/deals/{id}/deadlines/{deadline}": {
21352265 "patch": {
21362266 "summary": "Change a date: lead_days sets how many days before it is due its first reminder goes out (a whole number, 1 to 30; 400 with field lead_days otherwise; an unsent draft is drafted again for the new day, a sent one keeps its words), and owes changes which party owes it (one of the party roles; 422 otherwise, 409 for a date already done or waived). Today's and later reminders are drafted to the new party; ones already sent stay as they were. note sets the agent's working note on the date, one line of up to 200 characters, empty to clear it (400 with field note otherwise); it is internal and never reaches a client timeline, reminder or status update. Send any of them.",
src/contract.test.ts +16
@@ -11,6 +11,7 @@ import { join } from "node:path";
1111 import { getAudit, listAudit, mayRestore, restoreSamples } from "./actions.ts";
1212 import { record } from "./audit.ts";
1313 import {
14 addDeadline,
1415 createDeal,
1516 deleteDeal,
1617 getDeal,
@@ -244,3 +245,18 @@ test("the broker file, at GET /deals/:id/file, answers as deal read says, as eve
244245 const anon = await route.handle({ request: new Request(url), url, db, who: { role: "anon" }, params: { id: String(id) } });
245246 expect(anon.status).toBe(401);
246247 });
248
249 test("adding a date, at POST /api/deals/:id/deadlines, the page and add_date, answers as deal update says, as every identity", () => {
250 expect(routes.some((r) => r.method === "POST" && r.path === "/api/deals/:id/deadlines")).toBe(true);
251 for (const identity of identities(contract)) {
252 const who = whoIs(identity);
253 const { mine, theirs } = actingAs(contract, identity);
254 for (const row of [mine, theirs]) {
255 const id = dealBy(String(row["author_id"]));
256 const said = `add date as ${identity} on ${String(row["author_id"])}`;
257 const allowed = permit(who, "deal", "update", row) === "yes";
258 expect([said, addDeadline(db, who, { dealId: id, label: "Survey due", due: "2026-11-10", owes: "buyer" }).ok]).toEqual([said, allowed]);
259 }
260 }
261 expect(addDeadline(db, { role: "anon" }, { dealId: dealBy("u1"), label: "X", due: "2026-11-10", owes: "buyer" }).ok).toBe(false);
262 });
src/deals.ts +8 −5
@@ -1015,26 +1015,29 @@ export function amendDeal(
10151015 export function addDeadline(
10161016 db: Database,
10171017 who: Who,
1018 input: { dealId: number; label: unknown; due: unknown; owes: unknown; what?: unknown },
1018 input: { dealId: number; label: unknown; due: unknown; owes: unknown; what?: unknown; leadDays?: unknown },
10191019 ): Result<Deadline> {
10201020 const deal = dealFor(db, who, "update", input.dealId);
10211021 const dealId = deal.ok ? deal.value.id : undefined;
10221022 if (!deal.ok) return within(db, who, "date.add", Number.isInteger(input.dealId) ? input.dealId : undefined, deal);
10231023 const label = line(input.label, 1, 100);
10241024 if (label === undefined)
1025 return within(db, who, "date.add", dealId, fail("invalid", "Name the date (up to 100 characters)."));
1025 return within(db, who, "date.add", dealId, fail("invalid", "Name the date (up to 100 characters).", "label"));
10261026 if (!isDate(input.due))
1027 return within(db, who, "date.add", dealId, fail("invalid", "The date is a date (YYYY-MM-DD)."));
1027 return within(db, who, "date.add", dealId, fail("invalid", "The date is a date (YYYY-MM-DD).", "due"));
10281028 if (!isPartyRole(input.owes))
1029 return within(db, who, "date.add", dealId, fail("invalid", "Say which party owes it."));
1029 return within(db, who, "date.add", dealId, fail("invalid", "Say which party owes it.", "owes"));
10301030 const what = input.what === undefined || input.what === "" ? "" : line(input.what, 1, 200);
10311031 if (what === undefined)
10321032 return within(db, who, "date.add", dealId, fail("invalid", "What is owed is up to 200 characters."));
1033 const given = input.leadDays !== undefined && input.leadDays !== null && input.leadDays !== "";
1034 if (given && !isLead(input.leadDays))
1035 return within(db, who, "date.add", dealId, fail("invalid", `Days before is a whole number from ${LEAD_MIN} to ${LEAD_MAX}.`, "lead_days"));
10331036 const made = db
10341037 .query<Deadline, [number, string, string, string, string, number]>(
10351038 "INSERT INTO deadline (deal_id, kind, label, due, owes, what, lead_days) VALUES (?1, 'custom', ?2, ?3, ?4, ?5, ?6) RETURNING *",
10361039 )
1037 .get(deal.value.id, label, input.due, input.owes, what, leadOfLabel(label))!;
1040 .get(deal.value.id, label, input.due, input.owes, what, given ? (input.leadDays as number) : leadOfLabel(label))!;
10381041 return within(db, who, "date.add", dealId, ok(made));
10391042 }
10401043
src/routes.ts +31 −1
@@ -20,7 +20,7 @@ import {
2020 type Failure,
2121 type Result,
2222 } from "./actions.ts";
23 import { DEFAULT_LENGTHS, PARTY_LABEL } from "./deadlines.ts";
23 import { DEFAULT_LENGTHS, PARTY_LABEL, SUGGESTED_DATES } from "./deadlines.ts";
2424 import { commit, description, env, standard, forgeUrl, publicOrigin, startedAt, title, version } from "./env.ts";
2525 import {
2626 asset,
@@ -1641,6 +1641,36 @@ export const routes: readonly Route[] = [
16411641 answer: DEAL,
16421642 },
16431643 },
1644 {
1645 method: "POST",
1646 path: "/api/deals/:id/deadlines",
1647 handle: async (c) => {
1648 const body = await readJson(c.request);
1649 if (body === undefined) return json({ error: "too_large" }, 413);
1650 const f = fields(body);
1651 const added = addDeadline(c.db, c.who, { dealId: Number(c.params.id), label: f.label, due: f.due, owes: f.owes, what: f.what, leadDays: f.lead_days });
1652 // A field that is wrong is a 400 naming it; a deal that is not there or not yours keeps its status.
1653 if (!added.ok && added.field !== undefined) return json({ error: added.error, message: added.message, field: added.field }, 400);
1654 return answer(added, 201);
1655 },
1656 api: {
1657 summary: "Add a date to a deal, saying which party owes it. A label typed exactly as a common one sets its reminder lead unless lead_days is given; any other label is fine and starts from the default lead. A field that is wrong is a 400 naming it.",
1658 status: 201,
1659 cell: { resource: "deal", verb: "update" },
1660 body: {
1661 type: "object",
1662 properties: {
1663 label: { type: "string", minLength: 1, maxLength: 100, description: `What the date is. Common labels: ${SUGGESTED_DATES.map((s) => s.label).join("; ")}.` },
1664 due: { type: "string", format: "date" },
1665 owes: ROLE,
1666 what: { type: "string", maxLength: 200, description: "What is owed, optional." },
1667 lead_days: { type: "integer", minimum: 1, maximum: 30 },
1668 },
1669 required: ["label", "due", "owes"],
1670 },
1671 answer: { type: "object", properties: { id: { type: "integer" }, deal_id: { type: "integer" }, kind: { type: "string" }, label: { type: "string" }, due: { type: "string", format: "date" }, owes: ROLE, what: { type: "string" }, lead_days: { type: "integer" } }, required: ["id", "deal_id", "label", "due", "owes", "lead_days"] },
1672 },
1673 },
16441674 {
16451675 method: "PATCH",
16461676 path: "/api/deals/:id/deadlines/:deadline",
src/suggested.test.ts +41
@@ -56,3 +56,44 @@ test("an exact suggested label gets its lead; a typed label keeps the default",
5656 expect(add("repair request deadline").lead_days).toBe(DEFAULT_LEAD);
5757 expect(add("HOA docs").lead_days).toBe(DEFAULT_LEAD);
5858 });
59
60 const post = (path: string, body: unknown, who: Record<string, string> = headers) =>
61 fetch(base + path, { method: "POST", headers: { ...who, "content-type": "application/json" }, body: JSON.stringify(body) });
62 const opened = async (): Promise<number> => {
63 const made = await post("/api/deals", { address: "3 Api Lane", open_anyway: true, buyer: "A", seller: "B", contract_date: "2026-10-01", closing_date: "2026-12-01" });
64 return ((await made.json()) as { id: number }).id;
65 };
66
67 test("the API adds a date: a suggested label gets its lead, a custom one the default, lead_days wins", async () => {
68 const id = await opened();
69 const date = { due: "2026-11-20", owes: "buyer" };
70 const hoa = SUGGESTED_DATES[1]!;
71 const a = await post(`/api/deals/${id}/deadlines`, { ...date, label: hoa.label });
72 expect(a.status).toBe(201);
73 expect(((await a.json()) as { lead_days: number }).lead_days).toBe(hoa.lead);
74 const b = await post(`/api/deals/${id}/deadlines`, { ...date, label: "Custom thing" });
75 expect(((await b.json()) as { lead_days: number }).lead_days).toBe(DEFAULT_LEAD);
76 const c = await post(`/api/deals/${id}/deadlines`, { ...date, label: hoa.label, lead_days: 9 });
77 expect(((await c.json()) as { lead_days: number }).lead_days).toBe(9);
78 const bad = await post(`/api/deals/${id}/deadlines`, { ...date, label: "X", lead_days: 99 });
79 expect(bad.status).toBe(400);
80 expect(((await bad.json()) as { field: string }).field).toBe("lead_days");
81 });
82
83 test("the API refuses an empty or 101-character label, a bad date, and a signed-out caller", async () => {
84 const id = await opened();
85 const date = { due: "2026-11-20", owes: "buyer" };
86 for (const label of ["", "x".repeat(101)]) {
87 const r = await post(`/api/deals/${id}/deadlines`, { ...date, label });
88 expect(r.status).toBe(400);
89 expect(((await r.json()) as { field: string }).field).toBe("label");
90 }
91 expect((await post(`/api/deals/${id}/deadlines`, { label: "X", due: "soon", owes: "buyer" })).status).toBe(400);
92 expect((await post(`/api/deals/${id}/deadlines`, { label: "X", ...date }, {})).status).toBe(401);
93 });
94
95 test("/openapi.json lists the suggested labels on the add-a-date body", async () => {

Assert the exact refusal status, not just not 201, and add the contract.test.ts row for this door.

96 const doc = (await (await fetch(`${base}/openapi.json`, { headers })).json()) as { paths: Record<string, { post?: { requestBody: { content: { "application/json": { schema: { properties: { label: { description: string } } } } } } } }> };
97 const text = doc.paths["/api/deals/{id}/deadlines"]!.post!.requestBody.content["application/json"].schema.properties.label.description;
98 for (const s of SUGGESTED_DATES) expect(text).toContain(s.label);
99 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs