From 9ac7d5af7c9131635f1d8487134ccaca4ae73559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 12:11:10 +0200 Subject: [PATCH] Let a delegate add a medication, record a side effect and mark a dose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../[id]/intake/__tests__/route.test.ts | 21 ++++------ src/app/api/medications/[id]/intake/route.ts | 39 ++++++++++------- .../medications/[id]/side-effects/route.ts | 26 ++++++++---- .../intake/__tests__/route.test.ts | 14 +++---- src/app/api/medications/intake/route.ts | 42 +++++++++++++------ src/app/api/medications/route.ts | 9 +++- 6 files changed, 92 insertions(+), 59 deletions(-) diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index b76584056..2555f8d7b 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -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); }); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 26ac314e5..446647ebf 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -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); } diff --git a/src/app/api/medications/[id]/side-effects/route.ts b/src/app/api/medications/[id]/side-effects/route.ts index db2fafd08..4dac28ec5 100644 --- a/src/app/api/medications/[id]/side-effects/route.ts +++ b/src/app/api/medications/[id]/side-effects/route.ts @@ -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, ); diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index fd2841b4d..5ca9c9025 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -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); }); diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index deb226425..11b3735b1 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -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); }); diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 5f9ecd6ae..e80675d2f 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -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,