diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts index 1d02714a6..6fcc4895b 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts @@ -26,7 +26,10 @@ import { safeJson, } from "@/lib/api-response"; import { updateInventoryItemSchema } from "@/lib/validations/medication"; -import { computeExpiresAt } from "@/lib/medications/inventory/state-machine"; +import { + computeExpiresAt, + computeInventoryState, +} from "@/lib/medications/inventory/state-machine"; type RouteParams = { params: Promise<{ id: string; itemId: string }> }; @@ -88,6 +91,27 @@ export const PATCH = apiHandler( const nextExpiresAt = computeExpiresAt(nextFirstUseAt, nextPrintedExpiry); + // Re-run the canonical state machine over the composed next-row + // view. The clause-by-clause updates above set the state ad-hoc + // (`ACTIVE → IN_USE` on first use, `* → USED_UP` on mark-as-used), + // but a back-dated `markAsFirstUseAt` whose 30-day window already + // elapsed should land at EXPIRED, not IN_USE. The state machine is + // pure and idempotent — running it once more with the composed view + // collapses every edge case onto the same decision tree the intake + // hook and the daily expire cron already share. USED_UP is terminal + // (dosesRemaining === 0 ⇒ USED_UP wins at clause 1), so the manual + // override remains sticky. + nextState = computeInventoryState( + { + state: nextState, + dosesTotal: existing.dosesTotal, + dosesRemaining: nextDosesRemaining, + firstUseAt: nextFirstUseAt, + printedExpiry: nextPrintedExpiry, + }, + Date.now(), + ); + const updated = await prisma.medicationInventoryItem.update({ where: { id: itemId }, data: { diff --git a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts index 6838b9f25..24a7a8d59 100644 --- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts @@ -268,6 +268,42 @@ describe("PATCH /api/medications/[id]/inventory/[itemId]", () => { }); }); + it("flips ACTIVE → EXPIRED when markAsFirstUseAt is more than 30 days back-dated", async () => { + // Regression for the W19b state-machine bypass: composing the next + // state by hand sent ACTIVE → IN_USE on any first-use stamp, but a + // back-dated stamp whose 30-day window already lapsed must land at + // EXPIRED. Re-running `computeInventoryState` after the composed + // view closes the gap. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue( + existingActive as never, + ); + vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({ + ...existingActive, + state: "EXPIRED", + } as never); + + // 45 days in the past — well past the 30-day in-use window. + const backdated = new Date( + Date.now() - 45 * 24 * 60 * 60 * 1000, + ).toISOString(); + const res = await PATCH( + jsonReq( + "http://localhost/api/medications/med-1/inventory/inv-1", + { markAsFirstUseAt: backdated }, + "PATCH", + ), + { params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) }, + ); + expect(res.status).toBe(200); + const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock + .calls[0][0]; + expect(updateCall.data).toMatchObject({ + state: "EXPIRED", + firstUseAt: new Date(backdated), + }); + }); + it("rejects 404 when the item belongs to another user", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({