diff --git a/prisma/migrations/0276_inventory_expiry_container_type_repair/migration.sql b/prisma/migrations/0276_inventory_expiry_container_type_repair/migration.sql new file mode 100644 index 000000000..9424627b3 --- /dev/null +++ b/prisma/migrations/0276_inventory_expiry_container_type_repair/migration.sql @@ -0,0 +1,44 @@ +-- Restore supply containers that the in-use clock wrote off although they +-- never carried one. +-- +-- Until this release the state machine started a 30-day post-opening clock +-- for every container as soon as `first_use_at` was set, including the +-- containers whose units stay individually sealed. Pressing the first tablet +-- out of a blister therefore condemned the rest of the pack a month later: +-- the row went EXPIRED, its units dropped out of the available pool, and the +-- supply card read "0 of 0 doses" beside a count of expired units. +-- +-- The rows to restore are the ones that can ONLY have reached EXPIRED through +-- that clock: +-- * `container_type` is one of the kinds that has no post-opening clock +-- (everything except PEN and AMPOULE — see IN_USE_CLOCK_CONTAINER_TYPES +-- in src/lib/medications/inventory/state-machine.ts), +-- * the printed expiry is absent or still ahead of now, so the printed +-- label cannot be what expired the row, +-- * units are left, so USED_UP is not the answer either. +-- +-- Nothing else can have set EXPIRED: the API cannot write that state +-- directly, the daily cron only flips rows whose `expires_at` has lapsed, +-- and the consumption hook derives the state from the same two clocks. +-- +-- Deliberately untouched: +-- * PEN and AMPOULE rows — their clock is real and their EXPIRED is earned; +-- * any row whose printed expiry has genuinely lapsed, whatever its kind; +-- * rows with no units left (their state is a USED_UP question, not this +-- one); +-- * every other column. No date is invented and no printed expiry is +-- fabricated: a row that recorded no expiry keeps recording none, which +-- is why `expires_at` is set from `printed_expiry` rather than computed. +UPDATE "medication_inventory_items" +SET + "state" = CASE + WHEN "first_use_at" IS NOT NULL THEN 'IN_USE'::"medication_inventory_state" + ELSE 'ACTIVE'::"medication_inventory_state" + END, + -- The only deadline a clock-less container can carry is its printed one. + "expires_at" = "printed_expiry", + "updated_at" = NOW() +WHERE "state" = 'EXPIRED' + AND "container_type" NOT IN ('PEN', 'AMPOULE') + AND ("printed_expiry" IS NULL OR "printed_expiry" > NOW()) + AND "doses_remaining" > 0; diff --git a/tests/integration/inventory-expiry-container-type-repair.test.ts b/tests/integration/inventory-expiry-container-type-repair.test.ts new file mode 100644 index 000000000..216703aee --- /dev/null +++ b/tests/integration/inventory-expiry-container-type-repair.test.ts @@ -0,0 +1,261 @@ +/** + * Integration coverage for `0276_inventory_expiry_container_type_repair`. + * + * The repair reverses the rows the in-use clock wrote off although their + * container never carried one. A unit test cannot prove it: the predicate is + * SQL, it reads an enum column and compares a timestamp against `NOW()`, and + * the testcontainer starts empty so the migration itself has nothing to touch + * when it runs at boot. This test seeds the before-state, applies the EXACT + * migration SQL from disk, and asserts what moved, what did not, and that a + * second application changes nothing further. + * + * Requires Docker / OrbStack; driven by CI's integration job (and + * `pnpm test:integration` locally). + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import type { + MedicationContainerType, + MedicationInventoryState, + PrismaClient, +} from "@/generated/prisma/client"; + +const REPAIR_SQL = readFileSync( + resolve( + process.cwd(), + "prisma/migrations/0276_inventory_expiry_container_type_repair/migration.sql", + ), + "utf8", +); + +const DAY_MS = 24 * 60 * 60 * 1000; +const USER_ID = "inventory-repair-user"; + +interface SeedInput { + containerType: MedicationContainerType; + state: MedicationInventoryState; + unitsRemaining?: number; + firstUseAt?: Date | null; + printedExpiry?: Date | null; + expiresAt?: Date | null; +} + +async function seedItem( + prisma: PrismaClient, + medicationId: string, + input: SeedInput, +): Promise { + const row = await prisma.medicationInventoryItem.create({ + data: { + userId: USER_ID, + medicationId, + state: input.state, + containerType: input.containerType, + unitsTotal: 60, + unitsRemaining: input.unitsRemaining ?? 54, + firstUseAt: input.firstUseAt ?? null, + printedExpiry: input.printedExpiry ?? null, + expiresAt: input.expiresAt ?? null, + }, + select: { id: true }, + }); + return row.id; +} + +async function readItem(prisma: PrismaClient, id: string) { + const row = await prisma.medicationInventoryItem.findUniqueOrThrow({ + where: { id }, + select: { + state: true, + expiresAt: true, + printedExpiry: true, + firstUseAt: true, + unitsRemaining: true, + }, + }); + return row; +} + +describe("0276 inventory expiry container-type repair — integration", () => { + let medicationId: string; + + beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + await prisma.user.create({ + data: { + id: USER_ID, + username: "inventory-repair", + email: "inventory-repair@example.test", + }, + }); + const med = await prisma.medication.create({ + data: { + userId: USER_ID, + name: "Tablets", + dose: "10mg", + deliveryForm: "ORAL", + }, + select: { id: true }, + }); + medicationId = med.id; + }); + + it("restores a wrongly expired blister to IN_USE and clears its deadline", async () => { + const prisma = getPrismaClient(); + const openedAt = new Date(Date.now() - 45 * DAY_MS); + const id = await seedItem(prisma, medicationId, { + containerType: "BLISTER", + state: "EXPIRED", + firstUseAt: openedAt, + printedExpiry: null, + // What the old `computeExpiresAt` persisted: firstUseAt + 30 days. + expiresAt: new Date(openedAt.getTime() + 30 * DAY_MS), + }); + + await prisma.$executeRawUnsafe(REPAIR_SQL); + + const row = await readItem(prisma, id); + expect(row.state).toBe("IN_USE"); + // No printed expiry was recorded, so none is invented. + expect(row.expiresAt).toBeNull(); + expect(row.printedExpiry).toBeNull(); + expect(row.firstUseAt).toEqual(openedAt); + expect(Number(row.unitsRemaining)).toBe(54); + }); + + it("restores an unopened clock-less container to ACTIVE", async () => { + const prisma = getPrismaClient(); + const id = await seedItem(prisma, medicationId, { + containerType: "OTHER", + state: "EXPIRED", + firstUseAt: null, + printedExpiry: null, + }); + + await prisma.$executeRawUnsafe(REPAIR_SQL); + + expect((await readItem(prisma, id)).state).toBe("ACTIVE"); + }); + + it("keeps the printed expiry as the row's deadline when one is recorded", async () => { + const prisma = getPrismaClient(); + const printed = new Date(Date.now() + 200 * DAY_MS); + const openedAt = new Date(Date.now() - 45 * DAY_MS); + const id = await seedItem(prisma, medicationId, { + containerType: "BOTTLE", + state: "EXPIRED", + firstUseAt: openedAt, + printedExpiry: printed, + expiresAt: new Date(openedAt.getTime() + 30 * DAY_MS), + }); + + await prisma.$executeRawUnsafe(REPAIR_SQL); + + const row = await readItem(prisma, id); + expect(row.state).toBe("IN_USE"); + expect(row.expiresAt).toEqual(printed); + }); + + it("leaves an expired pen alone — its clock is real", async () => { + const prisma = getPrismaClient(); + const openedAt = new Date(Date.now() - 45 * DAY_MS); + const expiresAt = new Date(openedAt.getTime() + 30 * DAY_MS); + const id = await seedItem(prisma, medicationId, { + containerType: "PEN", + state: "EXPIRED", + firstUseAt: openedAt, + expiresAt, + }); + + await prisma.$executeRawUnsafe(REPAIR_SQL); + + const row = await readItem(prisma, id); + expect(row.state).toBe("EXPIRED"); + expect(row.expiresAt).toEqual(expiresAt); + }); + + it("leaves an expired ampoule alone — its clock is real", async () => { + const prisma = getPrismaClient(); + const id = await seedItem(prisma, medicationId, { + containerType: "AMPOULE", + state: "EXPIRED", + firstUseAt: new Date(Date.now() - 45 * DAY_MS), + }); + + await prisma.$executeRawUnsafe(REPAIR_SQL); + + expect((await readItem(prisma, id)).state).toBe("EXPIRED"); + }); + + it("leaves a container whose printed expiry genuinely lapsed alone", async () => { + const prisma = getPrismaClient(); + const printed = new Date(Date.now() - 3 * DAY_MS); + const id = await seedItem(prisma, medicationId, { + containerType: "BLISTER", + state: "EXPIRED", + firstUseAt: new Date(Date.now() - 45 * DAY_MS), + printedExpiry: printed, + expiresAt: printed, + }); + + await prisma.$executeRawUnsafe(REPAIR_SQL); + + const row = await readItem(prisma, id); + expect(row.state).toBe("EXPIRED"); + expect(row.expiresAt).toEqual(printed); + }); + + it("leaves a drained container alone — nothing to give back", async () => { + const prisma = getPrismaClient(); + const id = await seedItem(prisma, medicationId, { + containerType: "BLISTER", + state: "EXPIRED", + unitsRemaining: 0, + firstUseAt: new Date(Date.now() - 45 * DAY_MS), + }); + + await prisma.$executeRawUnsafe(REPAIR_SQL); + + expect((await readItem(prisma, id)).state).toBe("EXPIRED"); + }); + + it.each(["ACTIVE", "IN_USE", "USED_UP"] as const)( + "leaves a %s row untouched", + async (state) => { + const prisma = getPrismaClient(); + const expiresAt = new Date(Date.now() + 10 * DAY_MS); + const id = await seedItem(prisma, medicationId, { + containerType: "BLISTER", + state, + unitsRemaining: state === "USED_UP" ? 0 : 54, + expiresAt, + }); + + await prisma.$executeRawUnsafe(REPAIR_SQL); + + const row = await readItem(prisma, id); + expect(row.state).toBe(state); + expect(row.expiresAt).toEqual(expiresAt); + }, + ); + + it("is idempotent — a second application changes nothing", async () => { + const prisma = getPrismaClient(); + const id = await seedItem(prisma, medicationId, { + containerType: "BLISTER", + state: "EXPIRED", + firstUseAt: new Date(Date.now() - 45 * DAY_MS), + }); + + await prisma.$executeRawUnsafe(REPAIR_SQL); + const first = await readItem(prisma, id); + await prisma.$executeRawUnsafe(REPAIR_SQL); + const second = await readItem(prisma, id); + + expect(second).toEqual(first); + }); +});