Let a delegate add a medication, record a side effect and mark a dose

Four medication verbs resolve the record: adding a medication with its
nested schedule, recording a side effect, and both forms of marking a dose.
Editing and deleting a medication stay refused under a switch, which is the
sharpest asymmetry in the feature — a delegate can create a medication and
can never correct the typo in its dose afterwards.

The side-effect rate bucket keys on the acting account rather than the
record. That was the condition on which the verb was admitted: on the
record key a delegate could exhaust the owner's allowance and lock them out
of their own log, and could collect a fresh one by switching to another
record. Everyone who has not switched lands in the bucket they always did.

Both intake routes tell the owner who marked their dose. Everything else
downstream stays addressed to the owner and is correct there: the
cross-device sync wakes their devices, the web-push clear closes their
pending reminder and recounts their badge, the compliance rollup recomputes
their day.

Two validation breadcrumbs move to auditLog(), the only writer that stamps
the acting account.
This commit is contained in:
Marc-André Bombeck committed 2026-08-03 12:11:10 +02:00
1 parent 095aba095e
commit 9ac7d5af7c
6 files changed
+92 -59

No files matched your search

@@ -295,26 +295,21 @@ describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
await GET(makeRequest("status=junk"), ROUTE_PARAMS);
await POST(postReq({ takenAt: "junk" }), ROUTE_PARAMS);
await new Promise((r) => setTimeout(r, 5));
// v1.36.0 — the GET's breadcrumb goes through the `auditLog` helper (the
// only writer that stamps `actorUserId`); the POST's still writes the row
// directly, because that arm cannot be reached under a switch.
// v1.36.x — both breadcrumbs go through the `auditLog` helper, the only
// writer that stamps `actorUserId`. The POST arm is a delegable write now,
// so a row it files without the stamp would read as the owner's own.
expect(auditLog).toHaveBeenCalledWith(
"medications.intake.list.validation-failed",
expect.objectContaining({ userId: "user-1" }),
);
expect(prisma.auditLog.create).toHaveBeenCalled();
const actions = vi
.mocked(prisma.auditLog.create)
.mock.calls.map(
(c) => (c[0] as { data: { action: string } }).data.action,
);
expect(actions).toContain("medications.intake.create.validation-failed");
expect(auditLog).toHaveBeenCalledWith(
"medications.intake.create.validation-failed",
expect.objectContaining({ userId: "user-1" }),
);
});
it("POST does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down"));
const res = await POST(postReq({ takenAt: "junk" }), ROUTE_PARAMS);
expect(res.status).toBe(422);
});
+23 -16
View File
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -29,6 +29,7 @@ import { reconcileOneShotState } from "@/lib/medications/lifecycle";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import {
applyCanonicalSlotWrite,
@@ -47,7 +48,10 @@ export const POST = apiHandler(
);
async function postIntake(request: NextRequest, { params }: RouteParams) {
const { user, authMethod } = await requireAuth();
// v1.36.x — a delegated write, the per-medication form of the same verb.
// `user` is the record the dose belongs to; `actor` is whoever pressed the
// button, and is used for nothing but the owner's notification below.
const { user, actor, authMethod } = await requireRecordAuth("write");
// v1.32.8 (iOS #64) — write provenance derived from the transport, never
// client-asserted: a Bearer/native call stores `API`, a cookie/browser call
// stores `WEB`. The other `IntakeSource` values are producer-owned by OTHER
@@ -92,20 +96,13 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.create.validation-failed",
details: JSON.stringify({
issues: auditIssues,
medicationId: id,
}),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
// v1.36.x — through `auditLog()`, the only writer that stamps the actor.
void auditLog("medications.intake.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues, medicationId: id },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
@@ -533,6 +530,16 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
originDeviceToken: request.headers.get("x-device-id"),
});
// 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({
ownerId: user.id,
actorId: actor.id,
medicationId: id,
status: skipped ? "skipped" : "taken",
});
return apiSuccess(event, 201);
}
@@ -10,16 +10,17 @@
* occurredAt?, notes? }. Category is verified against the
* authoritative entry → category mapping; mismatch → 422.
*
* Auth: cookie-session via requireAuth(); the medication is verified
* to belong to the caller before any read/write.
* Rate-limit: 30/min/user on the POST path.
* Auth: a session via requireRecordAuth(); the medication is verified to
* belong to the RECORD before any read/write, so a delegate reaches the
* owner's medications and none of their own.
* Rate-limit: 30/min on the POST path, keyed on the ACTOR (see the call site).
* Audit-log every mutation with the affected row id.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
@@ -97,17 +98,28 @@ export const GET = apiHandler(
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.36.x — a delegated write. `user` is the record the entry lands under;
// `actor` is whoever is typing, and the two differ only under a switch.
const { user, actor } = await requireRecordAuth("write");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
// Per-user POST rate-limit — 30/min comfortably absorbs a session
// Per-caller POST rate-limit — 30/min comfortably absorbs a session
// of bulk back-fill (e.g. "log yesterday's symptoms") while cutting
// off automated abuse.
//
// v1.36.x — the bucket keys on the ACTOR, not on the resolved record, and
// this is the one condition on which delegating this verb was admitted. On
// the record key a delegate could exhaust the owner's allowance and lock
// them out of their own log, and could collect a fresh one by switching to
// another record. On the actor key they burn their own, once, wherever
// they are. `medications/compliance` set the same precedent on the read
// side. `actor.id` equals `user.id` for everyone who has not switched, so
// the bucket a self-writer lands in is byte-identical to before.
const rl = await checkRateLimit(
`medication-side-effect:post:${user.id}`,
`medication-side-effect:post:${actor.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
@@ -773,20 +773,18 @@ describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
const res = await POST(postReq({ status: "broken" }));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe(
// v1.36.x — through the helper, same reason as the GET arm above: the
// POST is a delegable write and only `auditLog` stamps the delegate.
expect(auditLog).toHaveBeenCalledTimes(1);
expect(auditLog).toHaveBeenCalledWith(
"medications.intake.update.validation-failed",
expect.objectContaining({ userId: "user-1" }),
);
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down"));
const res = await POST(postReq({ status: "broken" }));
expect(res.status).toBe(422);
});
+29 -13
View File
@@ -13,7 +13,7 @@
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -51,6 +51,7 @@ import {
} from "@/lib/validations/medication";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
const querySchema = z.object({
@@ -217,7 +218,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.36.x — a delegated write, the canonical slot form. Everything the
// handler does downstream addresses the RECORD and stays correct: the
// cross-device intake sync wakes the OWNER's iOS devices, the web-clear
// closes the OWNER's pending dose reminder and rewrites the OWNER's badge,
// and the compliance rollup recomputes the OWNER's day. None of them can
// address the delegate, and none of them should — the delegate's feedback is
// the response they are already awaiting. What the owner gets instead is the
// notification at the end of this handler.
const { user, actor } = await requireRecordAuth("write");
const { data: body, error } = await safeJson(request, {
maxBytes: 64 * 1024,
@@ -237,17 +246,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.update.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
// v1.36.x — through `auditLog()`, the only writer that stamps the actor.
void auditLog("medications.intake.update.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
@@ -551,5 +556,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
})();
}
// v1.36.x — "somebody else marked your dose". The helper refuses on self and
// 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({
ownerId: user.id,
actorId: actor.id,
medicationId: existing.medicationId,
status,
});
return apiSuccess(updated);
});
+7 -2
View File
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -93,7 +93,12 @@ async function respondWithExistingMirror(
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.36.x — a delegated write, and the asymmetric one: a delegate can ADD a
// medication with its whole nested schedule, and can never edit or delete
// it afterwards — `PUT /api/medications/[id]` and the DELETE beside it stay
// on `requireAuth()` and refuse under a switch. Adding is help; rewriting
// the owner's cabinet is not.
const { user } = await requireRecordAuth("write");
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,