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
Check · Review · Land · Live done — Landed and live in production.
mason/deal-clock:mason/154-the-api-adds-a-date-to-a-deal-like-the-p → main 2 commits fork 1 thread · 1 unresolved
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` | ||
| 43 | 43 | same; a query over 100 characters or with control characters is refused (400, |
| 44 | 44 | `{error:"invalid", field:"q"}`). |
| 45 | 45 | |
| 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 | ||
| 46 | 53 | 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. |
| 47 | 54 | |
| 48 | 55 | 1. **Look at the clock.** You see four lanes: 77 Harbor View Dr, Unit 4B |
contract/openapi.json +130
| @@ -2131,6 +2131,136 @@ | ||
| 2131 | 2131 | } |
| 2132 | 2132 | } |
| 2133 | 2133 | }, |
| 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 | }, | |
| 2134 | 2264 | "/api/deals/{id}/deadlines/{deadline}": { |
| 2135 | 2265 | "patch": { |
| 2136 | 2266 | "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"; | ||
| 11 | 11 | import { getAudit, listAudit, mayRestore, restoreSamples } from "./actions.ts"; |
| 12 | 12 | import { record } from "./audit.ts"; |
| 13 | 13 | import { |
| 14 | addDeadline, | |
| 14 | 15 | createDeal, |
| 15 | 16 | deleteDeal, |
| 16 | 17 | getDeal, |
| @@ -244,3 +245,18 @@ test("the broker file, at GET /deals/:id/file, answers as deal read says, as eve | ||
| 244 | 245 | const anon = await route.handle({ request: new Request(url), url, db, who: { role: "anon" }, params: { id: String(id) } }); |
| 245 | 246 | expect(anon.status).toBe(401); |
| 246 | 247 | }); |
| 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( | ||
| 1015 | 1015 | export function addDeadline( |
| 1016 | 1016 | db: Database, |
| 1017 | 1017 | 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 }, | |
| 1019 | 1019 | ): Result<Deadline> { |
| 1020 | 1020 | const deal = dealFor(db, who, "update", input.dealId); |
| 1021 | 1021 | const dealId = deal.ok ? deal.value.id : undefined; |
| 1022 | 1022 | if (!deal.ok) return within(db, who, "date.add", Number.isInteger(input.dealId) ? input.dealId : undefined, deal); |
| 1023 | 1023 | const label = line(input.label, 1, 100); |
| 1024 | 1024 | 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")); | |
| 1026 | 1026 | 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")); | |
| 1028 | 1028 | 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")); | |
| 1030 | 1030 | const what = input.what === undefined || input.what === "" ? "" : line(input.what, 1, 200); |
| 1031 | 1031 | if (what === undefined) |
| 1032 | 1032 | 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")); | |
| 1033 | 1036 | const made = db |
| 1034 | 1037 | .query<Deadline, [number, string, string, string, string, number]>( |
| 1035 | 1038 | "INSERT INTO deadline (deal_id, kind, label, due, owes, what, lead_days) VALUES (?1, 'custom', ?2, ?3, ?4, ?5, ?6) RETURNING *", |
| 1036 | 1039 | ) |
| 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))!; | |
| 1038 | 1041 | return within(db, who, "date.add", dealId, ok(made)); |
| 1039 | 1042 | } |
| 1040 | 1043 |
src/routes.ts +31 −1
| @@ -20,7 +20,7 @@ import { | ||
| 20 | 20 | type Failure, |
| 21 | 21 | type Result, |
| 22 | 22 | } from "./actions.ts"; |
| 23 | import { DEFAULT_LENGTHS, PARTY_LABEL } from "./deadlines.ts"; | |
| 23 | import { DEFAULT_LENGTHS, PARTY_LABEL, SUGGESTED_DATES } from "./deadlines.ts"; | |
| 24 | 24 | import { commit, description, env, standard, forgeUrl, publicOrigin, startedAt, title, version } from "./env.ts"; |
| 25 | 25 | import { |
| 26 | 26 | asset, |
| @@ -1641,6 +1641,36 @@ export const routes: readonly Route[] = [ | ||
| 1641 | 1641 | answer: DEAL, |
| 1642 | 1642 | }, |
| 1643 | 1643 | }, |
| 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 | }, | |
| 1644 | 1674 | { |
| 1645 | 1675 | method: "PATCH", |
| 1646 | 1676 | 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", | ||
| 56 | 56 | expect(add("repair request deadline").lead_days).toBe(DEFAULT_LEAD); |
| 57 | 57 | expect(add("HOA docs").lead_days).toBe(DEFAULT_LEAD); |
| 58 | 58 | }); |
| 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 () => { | |
| 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 | }); |
new above
offline
Assert the exact refusal status, not just not 201, and add the contract.test.ts row for this door.