fix(medications): restore the containers the in-use clock wrongly wrote off

Migration 0276 reverses the rows that reached EXPIRED only through the
post-opening clock on a container that never had one. It moves a row back to
IN_USE when an opening date is recorded and ACTIVE when none is, and sets
`expires_at` from the printed expiry — which for these rows is usually
nothing at all, because they recorded none.

The predicate is exact rather than approximate: nothing but the printed
expiry and the in-use clock can write EXPIRED, so a clock-less container
whose printed expiry is absent or still ahead can only have got there through
the clock. Pens and ampoules keep their EXPIRED, so does anything whose
printed date genuinely lapsed, and so does a drained container. No date is
invented and no printed expiry is fabricated.

The integration test seeds the before-state, applies the migration SQL from
disk, and asserts both what moves and what stays — the testcontainer starts
empty, so the migration running at boot proves nothing on its own.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 18:00:07 +02:00
1 parent a1dbd9c831
commit bb1e01e456
2 files changed
+305

No files matched your search

@@ -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;
@@ -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<string> {
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);
});
});