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 }); }