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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 10:23:28 +02:00
1 parent c7e839f97a
commit 1cc6f500bb
6 files changed
+425 -31

No files matched your search

@@ -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 },
});
});
});
@@ -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 });
},
);
@@ -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 },
});
});
});
+10 -29
View File
@@ -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);
}
@@ -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();
});
});
+114
View File
@@ -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<ReconcileOneShotAction> {
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;
}