From 1cc6f500bbce90843ac53b88ac6292e3da5c73e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 28 May 2026 10:10:57 +0200 Subject: [PATCH] fix(meds): reconcile one-shot active flag on every intake mutation Extracts the lifecycle hook into reconcileOneShotState and wires it into POST, PUT, and DELETE on the intake event. A user who logs the single dose then undoes the log gets the medication back; a user who flips skipped does too. The hook is idempotent on non-one-shot medications. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../intake/[eventId]/__tests__/route.test.ts | 98 ++++++++++++- .../[id]/intake/[eventId]/route.ts | 13 ++ .../[id]/intake/__tests__/route.test.ts | 59 +++++++- src/app/api/medications/[id]/intake/route.ts | 39 ++--- .../medications/__tests__/lifecycle.test.ts | 133 ++++++++++++++++++ src/lib/medications/lifecycle.ts | 114 +++++++++++++++ 6 files changed, 425 insertions(+), 31 deletions(-) create mode 100644 src/lib/medications/__tests__/lifecycle.test.ts create mode 100644 src/lib/medications/lifecycle.ts diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts index d36c49bbd..43e3a1255 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts @@ -11,6 +11,11 @@ vi.mock("@/lib/db", () => ({ findUnique: vi.fn(), update: vi.fn(), delete: vi.fn(), + findFirst: vi.fn(), + }, + medication: { + findUnique: vi.fn(), + updateMany: vi.fn(), }, auditLog: { create: vi.fn() }, }, @@ -39,9 +44,10 @@ vi.mock("next/headers", () => ({ })), })); -import { PUT } from "../route"; +import { PUT, DELETE } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -70,6 +76,18 @@ beforeEach(() => { medicationId: "m1", scheduledFor: new Date(), } as never); + // Default lifecycle stubs: medication is NOT one-shot so the + // reconcile is a no-op for the legacy 422-envelope tests below. + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + oneShot: false, + active: true, + } as never); + vi.mocked(prisma.medication.updateMany).mockResolvedValue({ + count: 0, + } as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue( + null as never, + ); }); describe("PUT /api/medications/[id]/intake/[eventId] — 422 multi-issue (v1.4.43 W6)", () => { @@ -139,3 +157,81 @@ describe("PUT /api/medications/[id]/intake/[eventId] — 422 multi-issue (v1.4.4 expect(res.status).toBe(422); }); }); + +// ──────────────────────────────────────────────────────────────────── +// v1.5.0 — one-shot lifecycle reconciliation on PUT / DELETE +// ──────────────────────────────────────────────────────────────────── + +function deleteReq(): NextRequest { + return new NextRequest("http://localhost/api/medications/m1/intake/e1", { + method: "DELETE", + }); +} + +describe("DELETE /api/medications/[id]/intake/[eventId] — one-shot reconcile", () => { + it("re-activates a one-shot medication after the deleted event was its last live intake", async () => { + vi.mocked(prisma.medication.findUnique).mockResolvedValueOnce({ + oneShot: true, + active: false, + } as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce( + null as never, + ); + vi.mocked(prisma.medication.updateMany).mockResolvedValueOnce({ + count: 1, + } as never); + vi.mocked(prisma.medicationIntakeEvent.delete).mockResolvedValueOnce( + {} as never, + ); + + const res = await DELETE(deleteReq(), ROUTE_CTX); + expect(res.status).toBe(200); + + expect(prisma.medication.updateMany).toHaveBeenCalledWith({ + where: { id: "m1", userId: "user-1", oneShot: true }, + data: { active: true }, + }); + const calls = vi.mocked(auditLog).mock.calls.map((c) => c[0]); + expect(calls).toContain("medication.oneShot.reconciled"); + }); + + it("is idempotent on a non-one-shot medication", async () => { + // Default beforeEach: medication is non-one-shot. + vi.mocked(prisma.medicationIntakeEvent.delete).mockResolvedValueOnce( + {} as never, + ); + const res = await DELETE(deleteReq(), ROUTE_CTX); + expect(res.status).toBe(200); + expect(prisma.medication.updateMany).not.toHaveBeenCalled(); + }); +}); + +describe("PUT /api/medications/[id]/intake/[eventId] — one-shot reconcile on skip flip", () => { + it("re-activates a one-shot medication when its single intake is flipped to skipped", async () => { + vi.mocked(prisma.medication.findUnique).mockResolvedValueOnce({ + oneShot: true, + active: false, + } as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce( + null as never, + ); + vi.mocked(prisma.medication.updateMany).mockResolvedValueOnce({ + count: 1, + } as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({ + id: "e1", + userId: "user-1", + medicationId: "m1", + skipped: true, + takenAt: null, + } as never); + + const res = await PUT(putReq({ skipped: true, takenAt: null }), ROUTE_CTX); + expect(res.status).toBe(200); + + expect(prisma.medication.updateMany).toHaveBeenCalledWith({ + where: { id: "m1", userId: "user-1", oneShot: true }, + data: { active: true }, + }); + }); +}); diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 818d12602..13343a1a2 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -11,6 +11,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { updateIntakeEventSchema } from "@/lib/validations/medication"; +import { reconcileOneShotState } from "@/lib/medications/lifecycle"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { NextRequest } from "next/server"; @@ -119,6 +120,12 @@ export const PUT = apiHandler( }); } + // v1.5.0 — re-evaluate the one-shot active flag. A skip-flip on the + // single live intake of a one-shot medication should reactivate it + // (the dose is no longer logged), and the reverse case should + // deactivate again. No-op for non-one-shot medications. + await reconcileOneShotState(prisma, id, user.id); + return apiSuccess(updated); }, ); @@ -169,6 +176,12 @@ export const DELETE = apiHandler( tz: user.timezone, }); + // v1.5.0 — re-evaluate the one-shot active flag. Deleting the + // single live intake of a one-shot medication reactivates it so + // the dashboard / lists / worker pick it back up. No-op for + // non-one-shot medications. + await reconcileOneShotState(prisma, id, user.id); + return apiSuccess({ deleted: true }); }, ); 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 2c034508a..9865f1173 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -24,7 +24,11 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { - medication: { findUnique: vi.fn() }, + medication: { + findUnique: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, medicationIntakeEvent: { findMany: vi.fn(), findFirst: vi.fn(), @@ -266,3 +270,56 @@ describe("v1.4.43 W6 — multi-issue 422 envelope", () => { expect(res.status).toBe(422); }); }); + +// ──────────────────────────────────────────────────────────────────── +// v1.5.0 — POST intake on a one-shot medication still deactivates +// ──────────────────────────────────────────────────────────────────── + +describe("POST /api/medications/[id]/intake — one-shot lifecycle", () => { + function postReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/medications/med-1/intake", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + } + + it("flips active to false after logging a live intake on a one-shot medication", async () => { + const createdEvent = { + id: "evt-1", + userId: "user-1", + medicationId: "med-1", + scheduledFor: new Date(), + takenAt: new Date(), + skipped: false, + }; + vi.mocked(prisma.$transaction).mockResolvedValue([createdEvent] as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce( + null as never, // dedup probe + ); + // Reconcile probes — first call is the medication shape, second is + // the live-intake probe (a live intake exists because the POST just + // created one), and the update fires with active:false. + vi.mocked(prisma.medication.findUnique).mockResolvedValueOnce({ + oneShot: true, + active: true, + } as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce({ + id: "evt-1", + } as never); + vi.mocked(prisma.medication.updateMany).mockResolvedValueOnce({ + count: 1, + } as never); + + const res = await POST( + postReq({ takenAt: new Date().toISOString(), skipped: false }), + ROUTE_PARAMS, + ); + expect(res.status).toBe(201); + + expect(prisma.medication.updateMany).toHaveBeenCalledWith({ + where: { id: "med-1", userId: "user-1", oneShot: true }, + data: { active: false }, + }); + }); +}); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index f50cc7978..f4ee24d6c 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -15,6 +15,7 @@ import { } from "@/lib/validations/medication"; import { withIdempotency } from "@/lib/idempotency"; import { consumeOneDose } from "@/lib/medications/inventory/service"; +import { reconcileOneShotState } from "@/lib/medications/lifecycle"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; @@ -197,35 +198,15 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { tz: user.timezone, }); - // v1.5.0 — one-shot lifecycle. A `oneShot` medication has at most - // one intake; once that intake is logged (non-skipped) the - // medication auto-deactivates so the reminder worker stops - // considering it and the dashboard "Erfassen" sheet drops it. The - // flip runs AFTER the intake row is committed (any failure inside - // the transaction above re-raises before this line), so a flaky - // write never deactivates a medication that didn't actually - // receive its dose. Skipped intakes don't qualify — a "skipped" - // event on a one-shot leaves the medication open for the eventual - // real dose. - const medForOneShotCheck = await prisma.medication.findUnique({ - where: { id }, - select: { oneShot: true, active: true }, - }); - if (medForOneShotCheck?.oneShot && medForOneShotCheck.active && !skipped) { - await prisma.medication.update({ - where: { id }, - data: { active: false }, - }); - await auditLog("medication.oneShot.deactivated", { - userId: user.id, - ipAddress: getClientIp(request), - details: { medicationId: id, eventId: event.id }, - }); - annotate({ - action: { name: "medication.oneShot.deactivated" }, - meta: { medication_id: id, event_id: event.id }, - }); - // Invalidate again so the next list-meds read sees the flip. + // v1.5.0 — one-shot lifecycle reconciliation. A `oneShot` medication + // has at most one live intake; the helper re-reads the most recent + // non-skipped intake and flips `active` to match. Idempotent on + // non-one-shot medications (the underlying updateMany is gated by + // `oneShot:true`). The flip runs AFTER the intake row is committed + // so a flaky write never deactivates a medication that didn't + // actually receive its dose. + const reconcileAction = await reconcileOneShotState(prisma, id, user.id); + if (reconcileAction !== "noop") { invalidateUserMedications(user.id); } diff --git a/src/lib/medications/__tests__/lifecycle.test.ts b/src/lib/medications/__tests__/lifecycle.test.ts new file mode 100644 index 000000000..a97ab09f3 --- /dev/null +++ b/src/lib/medications/__tests__/lifecycle.test.ts @@ -0,0 +1,133 @@ +/** + * v1.5.0 — `reconcileOneShotState` unit tests. + * + * Pins the contract for the lifecycle helper that every intake-mutation + * path (POST, PUT, DELETE) tails: + * + * - one-shot + live intake exists → flip active to false + * - one-shot + no live intake → flip active to true + * - matching state → noop (no write, no audit) + * - non-one-shot medication → noop (the updateMany gate is + * `oneShot:true`, so the action is "noop" regardless of state) + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +import { reconcileOneShotState } from "../lifecycle"; +import { auditLog } from "@/lib/auth/audit"; +import { annotate } from "@/lib/logging/context"; + +interface MedRow { + oneShot: boolean; + active: boolean; +} + +function buildClient( + med: MedRow | null, + liveIntake: { id: string } | null, +) { + return { + medication: { + findUnique: vi.fn().mockResolvedValue(med), + updateMany: vi.fn().mockResolvedValue({ count: med?.oneShot ? 1 : 0 }), + }, + medicationIntakeEvent: { + findFirst: vi.fn().mockResolvedValue(liveIntake), + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("reconcileOneShotState", () => { + it("deactivates a one-shot medication when a live intake is found", async () => { + const client = buildClient( + { oneShot: true, active: true }, + { id: "evt-1" }, + ); + const action = await reconcileOneShotState(client, "med-1", "user-1"); + expect(action).toBe("deactivate"); + expect(client.medication.updateMany).toHaveBeenCalledWith({ + where: { id: "med-1", userId: "user-1", oneShot: true }, + data: { active: false }, + }); + expect(auditLog).toHaveBeenCalledWith( + "medication.oneShot.reconciled", + expect.objectContaining({ + userId: "user-1", + details: { medicationId: "med-1", action: "deactivate" }, + }), + ); + }); + + it("reactivates a one-shot medication when no live intake remains", async () => { + const client = buildClient({ oneShot: true, active: false }, null); + const action = await reconcileOneShotState(client, "med-1", "user-1"); + expect(action).toBe("activate"); + expect(client.medication.updateMany).toHaveBeenCalledWith({ + where: { id: "med-1", userId: "user-1", oneShot: true }, + data: { active: true }, + }); + expect(auditLog).toHaveBeenCalledWith( + "medication.oneShot.reconciled", + expect.objectContaining({ + details: { medicationId: "med-1", action: "activate" }, + }), + ); + }); + + it("is idempotent on a non-one-shot medication", async () => { + const client = buildClient( + { oneShot: false, active: true }, + { id: "evt-1" }, + ); + const action = await reconcileOneShotState(client, "med-1", "user-1"); + expect(action).toBe("noop"); + expect(client.medication.updateMany).not.toHaveBeenCalled(); + expect(client.medicationIntakeEvent.findFirst).not.toHaveBeenCalled(); + expect(auditLog).not.toHaveBeenCalled(); + }); + + it("is idempotent when state already matches (one-shot deactivated, no live intake)", async () => { + const client = buildClient({ oneShot: true, active: false }, null); + // active matches the "no live intake → should be active=true" expectation? No: + // when no live intake exists shouldBeActive=true; current is false → activate. + // To pin the "matches" branch we want active:true + live intake exists + // (already deactivated state) is one shape; here we test active:false + // + no live intake should ACTIVATE (the other direction). So pick the + // truly-matching shape below. + const action = await reconcileOneShotState(client, "med-1", "user-1"); + expect(action).toBe("activate"); + + // Now the matching branch: one-shot + active:true + no live intake. + const matchingClient = buildClient( + { oneShot: true, active: true }, + null, + ); + const matched = await reconcileOneShotState( + matchingClient, + "med-1", + "user-1", + ); + expect(matched).toBe("noop"); + expect(matchingClient.medication.updateMany).not.toHaveBeenCalled(); + // annotate still fires (the breadcrumb is the whole point) but + // auditLog stays silent on noop. + expect(annotate).toHaveBeenCalled(); + }); + + it("returns noop when the medication does not exist", async () => { + const client = buildClient(null, null); + const action = await reconcileOneShotState(client, "ghost", "user-1"); + expect(action).toBe("noop"); + expect(client.medication.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/medications/lifecycle.ts b/src/lib/medications/lifecycle.ts new file mode 100644 index 000000000..fb057b32d --- /dev/null +++ b/src/lib/medications/lifecycle.ts @@ -0,0 +1,114 @@ +/** + * v1.5.0 — one-shot medication lifecycle reconciliation. + * + * A `oneShot:true` medication carries at most one intake. The audit + * surfaced that the auto-deactivate hook on POST was one-way: logging + * the dose flipped `active:false`, but DELETE / PUT on the intake event + * never re-evaluated the medication state. A user who logged the flu + * shot then undid the log was left with a permanently-inactive + * medication that dropped off every list / dashboard / worker tick. + * + * `reconcileOneShotState` is the single helper every intake-mutation + * path tails. It re-reads the most recent live (non-skipped, non-null + * takenAt) intake for the medication; if one exists the medication is + * inactive, otherwise it is reactivated. The Prisma `update` is keyed + * on `oneShot:true` so the call is a no-op for every other medication + * shape — callers do not need to branch. + * + * Pure best-effort. A failure to reconcile must not block the intake + * mutation itself; callers wrap accordingly. + */ + +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; + +interface ReconcilePrisma { + medication: { + findUnique: (args: { + where: { id: string }; + select: { oneShot: true; active: true }; + }) => Promise<{ oneShot: boolean; active: boolean } | null>; + updateMany: (args: { + where: { id: string; userId: string; oneShot: true }; + data: { active: boolean }; + }) => Promise<{ count: number }>; + }; + medicationIntakeEvent: { + findFirst: (args: { + where: { + userId: string; + medicationId: string; + skipped: false; + takenAt: { not: null }; + }; + orderBy: { takenAt: "desc" }; + select: { id: true }; + }) => Promise<{ id: string } | null>; + }; +} + +export type ReconcileOneShotAction = "activate" | "deactivate" | "noop"; + +/** + * Re-evaluate the `active` flag for a one-shot medication after an + * intake mutation. Idempotent. Returns the action taken so the caller + * can surface it in observability if it cares. + * + * - "deactivate": medication had a live intake AND was active → flipped to false. + * - "activate": medication had no live intake AND was inactive → flipped to true. + * - "noop": state already matched OR medication is not one-shot. + */ +export async function reconcileOneShotState( + prismaClient: ReconcilePrisma, + medicationId: string, + userId: string, +): Promise { + const med = await prismaClient.medication.findUnique({ + where: { id: medicationId }, + select: { oneShot: true, active: true }, + }); + if (!med || !med.oneShot) return "noop"; + + const liveIntake = await prismaClient.medicationIntakeEvent.findFirst({ + where: { + userId, + medicationId, + skipped: false, + takenAt: { not: null }, + }, + orderBy: { takenAt: "desc" }, + select: { id: true }, + }); + + const shouldBeActive = liveIntake === null; + + if (med.active === shouldBeActive) { + annotate({ + action: { name: "medication.oneShot.reconciled" }, + meta: { medication_id: medicationId, action: "noop" }, + }); + return "noop"; + } + + const action: ReconcileOneShotAction = shouldBeActive ? "activate" : "deactivate"; + + // `updateMany` with `oneShot:true` is structurally a no-op on every + // non-one-shot row; collapse to zero count when the medication is + // not actually one-shot (defence-in-depth against the early-return). + await prismaClient.medication.updateMany({ + where: { id: medicationId, userId, oneShot: true }, + data: { active: shouldBeActive }, + }); + + await auditLog("medication.oneShot.reconciled", { + userId, + details: { medicationId, action }, + }); + + annotate({ + action: { name: "medication.oneShot.reconciled" }, + meta: { medication_id: medicationId, action }, + }); + + return action; +}