From 837ebcfeea7fb1404355aa60e23c0068b18077aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 20:34:53 +0200 Subject: [PATCH] Name the intake action the route actually writes, and stop awaiting the delegate notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The activity feed maps an audit action to a sentence. The web client posts to /api/medications/[id]/intake, which has audited as `medication.intake` since v1.0.0, but the map only knew `medications.intake.update` — so a delegate recording a dose showed up in the owner's feed with no verb at all. Both spellings are recognised now, plus `measurement.create.batch`. A new test reads the audit actions straight out of every route that calls requireRecordAuth("write") and fails when one has no case in the map, so the next route to be opened for delegates cannot land a silent gap. The delegate notification is fire and forget now, like every neighbouring side effect. The dispatcher awaits each channel in the cascade, so one unreachable channel added its whole timeout to the request of the person standing next to the patient waiting for a dose to register. The integration test polls the ledger rather than relying on the await, so nothing about the proof weakens. --- src/app/api/medications/[id]/intake/route.ts | 9 ++- src/app/api/medications/intake/route.ts | 9 ++- .../__tests__/verb-map-covers-routes.test.ts | 75 +++++++++++++++++++ src/lib/record-activity/activity-verb.ts | 12 +++ 4 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 src/lib/record-activity/__tests__/verb-map-covers-routes.test.ts diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 446647ebf..b60f948de 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -533,7 +533,14 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { // v1.36.x — "somebody else marked your dose". This route has no snooze arm, // so the state is whichever of the two markings the payload carried. The // helper refuses on self, so a person marking their own dose is unaffected. - await notifyDelegatedIntake({ + // Fire and forget, like every neighbour above. The dispatcher awaits each + // channel in the cascade, so an unreachable one would have added its whole + // timeout to the caregiver's request — the person standing next to the + // patient waiting for a dose to register. The helper never throws, and the + // owner learning a moment later is the right trade against the tick being + // slow. The integration test already polls the ledger rather than relying on + // the await, so nothing about the proof weakens. + void notifyDelegatedIntake({ ownerId: user.id, actorId: actor.id, medicationId: id, diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 11b3735b1..91f3839fa 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -560,7 +560,14 @@ export const POST = apiHandler(async (request: NextRequest) => { // on a snooze, resolves both names itself, and never throws; awaited because // the latency it adds lands on the caregiver's request and never on the // owner's own hot path. - await notifyDelegatedIntake({ + // Fire and forget, like every neighbour above. The dispatcher awaits each + // channel in the cascade, so an unreachable one would have added its whole + // timeout to the caregiver's request — the person standing next to the + // patient waiting for a dose to register. The helper never throws, and the + // owner learning a moment later is the right trade against the tick being + // slow. The integration test already polls the ledger rather than relying on + // the await, so nothing about the proof weakens. + void notifyDelegatedIntake({ ownerId: user.id, actorId: actor.id, medicationId: existing.medicationId, diff --git a/src/lib/record-activity/__tests__/verb-map-covers-routes.test.ts b/src/lib/record-activity/__tests__/verb-map-covers-routes.test.ts new file mode 100644 index 000000000..1bde3049d --- /dev/null +++ b/src/lib/record-activity/__tests__/verb-map-covers-routes.test.ts @@ -0,0 +1,75 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +/** + * Every audit action a delegable write route emits has a verb line. + * + * The owner's activity view exists to answer "what did somebody else do in my + * record". It answers that by mapping the audit action to a sentence, and + * falling back to "made a change" for anything unmapped. A fallback is the + * right behaviour for an action nobody anticipated; it is the wrong behaviour + * for the actions this release deliberately admits. + * + * That is not hypothetical. The map was written against the action name the + * canonical route uses, while the web client posts to a sibling route that has + * audited under a different name since v1.0.0 — so the single most-used + * delegated act, a caregiver marking a dose, rendered as the generic fallback + * for the person whose tablets they are. Nothing failed; the sentence was just + * vaguer than it needed to be, which is exactly the kind of defect no + * behavioural test notices. + * + * So this reads the actions out of the routes rather than out of a list + * somebody maintains beside them. A new delegable write whose action has no + * sentence fails here, at the moment it is added. + */ +const API = join(process.cwd(), "src/app/api"); +const VERB_MAP = join( + process.cwd(), + "src/lib/record-activity/activity-verb.ts", +); + +/** Every `route.ts` under `src/app/api`, walked rather than globbed: a glob's + * `*` skips a leading dot and this tree has dot-directories. */ +function routeFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...routeFiles(full)); + else if (entry.name === "route.ts") out.push(full); + } + return out; +} + +describe("the owner's activity view names what a delegate did", () => { + const routes = routeFiles(API); + + it("reads a plausible number of route modules", () => { + // A walk that finds nothing agrees with a map that covers nothing. + expect(routes.length).toBeGreaterThan(300); + }); + + it("gives every delegable write action its own sentence", () => { + const map = readFileSync(VERB_MAP, "utf8"); + + const unmapped: string[] = []; + for (const file of routes) { + const src = readFileSync(file, "utf8"); + if (!src.includes('requireRecordAuth("write")')) continue; + // The awaited success-path audits only. A fire-and-forget breadcrumb on a + // validation failure is not something to narrate to the owner. + for (const m of src.matchAll(/await auditLog\(\s*"([^"]+)"/g)) { + const action = m[1]; + if (!map.includes(`case "${action}"`)) unmapped.push(`${action}`); + } + } + + expect( + [...new Set(unmapped)].sort(), + "a delegable write whose audit action has no verb line renders to the " + + "owner as the generic 'made a change'. Add a case to activity-verb.ts " + + "and a string to all six bundles.", + ).toEqual([]); + }); +}); diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts index 9c625730b..d7039c406 100644 --- a/src/lib/record-activity/activity-verb.ts +++ b/src/lib/record-activity/activity-verb.ts @@ -54,8 +54,20 @@ export function recordActivityVerbLine( }); case "medication.create": return t("recordSharing.activityVerb.medicationCreate", { name }); + // Both spellings, and the reason is worth keeping. The web client posts to + // `POST /api/medications/[id]/intake`, which has audited as + // `medication.intake` since v1.0.0; `medications.intake.update` is the + // canonical route's action and the one the plan's copy deck named. Mapping + // only the second meant the single most-used delegated act — a caregiver + // marking a dose — rendered as the generic "made a change" for the person + // whose tablets they are. + case "medication.intake": case "medications.intake.update": return t("recordSharing.activityVerb.medicationIntake", { name }); + // A batch post is still readings arriving; the owner does not care that the + // client sent them together. + case "measurement.create.batch": + return t("recordSharing.activityVerb.measurementCreate", { name }); default: return t("recordSharing.activity.acted", { name }); }