Name the intake action the route actually writes, and stop awaiting the delegate notice

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-03 20:34:53 +02:00
1 parent a0fc404fb8
commit 837ebcfeea
4 files changed
+103 -2

No files matched your search

+8 -1
View File
@@ -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,
+8 -1
View File
@@ -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,
@@ -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([]);
});
});
+12
View File
@@ -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 });
}