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,