From a1dbd9c8312521d38031ee9379b40b1d55855e58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 10:31:45 +0200 Subject: [PATCH] fix(medications): apply the post-opening clock only where a container has one A supply container went EXPIRED 30 days after its first use no matter what kind of container it was. That window exists because puncturing an injection pen exposes its contents and the product information caps how long the rest stays usable; a blister of tablets has no such limit, every tablet sits in its own sealed cavity, and pressing the first one out says nothing about the rest. The state machine never read `containerType`, so a tablet pack opened five weeks ago had its remaining stock written off. The clock now keys off the container kind. PEN and AMPOULE carry it: both are a single sealed reservoir that the first dose breaches. BLISTER, INHALER, BOTTLE and OTHER do not: their units stay individually sealed, or the arrangement is simply unknown, and a fixed 30 days would be a number nobody entered. The printed expiry still applies to every kind, so an unknown container is not an unbounded one. `computeExpiresAt` takes the kind too, so a clock-less container with no printed date now persists no `expiresAt` at all and the daily expire scan never sees it. The per-item PATCH gains a way back out: `markAsFirstUseAt` accepts null and clears the opening date, which is the correction a container needs after the intake hook auto-opened it from a back-dated dose. Also drops `daysRemainingInUse`. It had no caller anywhere in the tree and its docblock described a chip that does not exist. --- docs/api/openapi.yaml | 36 ++-- prisma/schema.prisma | 22 ++- .../[id]/inventory/[itemId]/route.ts | 24 ++- .../[id]/inventory/__tests__/route.test.ts | 74 +++++++ .../inventory/__tests__/consumption.test.ts | 27 +++ .../inventory/__tests__/state-machine.test.ts | 166 +++++++++------- src/lib/medications/inventory/consumption.ts | 10 +- src/lib/medications/inventory/service.ts | 15 +- .../medications/inventory/state-machine.ts | 181 ++++++++++-------- src/lib/openapi/routes/medications/schemas.ts | 8 +- src/lib/validations/medication/inventory.ts | 10 +- 11 files changed, 394 insertions(+), 179 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b87fd95c0..7be123662 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12943,8 +12943,9 @@ components: description: Units the container ships with (tablets / ampoules / puffs; 1–1000, fractional allowed for split-pill packs). Dose-derived readouts divide by the medication's `unitsPerDose`. containerType: - description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level only; - defaults to OTHER. + description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Defaults to OTHER. PEN and + AMPOULE are the sealed reservoirs the first dose breaches, so they carry a 30-day post-opening window; every + other kind expires only on its printed date. type: string enum: - PEN @@ -12991,11 +12992,15 @@ components: type: object properties: markAsFirstUseAt: - description: Manually start the 30-day in-use clock (the user opened the container without logging an intake). ACTIVE - flips to IN_USE; a backdated instant whose window already lapsed lands EXPIRED. - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: Opening date of the container (the user opened it without logging an intake). ACTIVE flips to IN_USE. + `null` clears it — the container reads as unopened again, which is how a wrongly auto-opened row is + corrected. For a PEN or an AMPOULE this also starts / stops the 30-day in-use clock; other container kinds + carry no such clock and expire only on their printed date. + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" markAsUsedUp: description: "Terminal override: zero the remaining units and mark the container USED_UP (physically discarded)." type: boolean @@ -20474,8 +20479,9 @@ components: - IN_USE - EXPIRED - USED_UP - description: Container lifecycle state. ACTIVE = unopened; IN_USE = opened (30-day in-use clock running); EXPIRED = - printed expiry or in-use window lapsed with units left; USED_UP = drained (terminal). + description: Container lifecycle state. ACTIVE = unopened; IN_USE = opened (on a PEN / AMPOULE the 30-day in-use clock + is running); EXPIRED = printed expiry lapsed, or the in-use window lapsed on a clock-bearing container, with + units left; USED_UP = drained (terminal). containerType: type: string enum: @@ -20485,8 +20491,10 @@ components: - INHALER - BOTTLE - OTHER - description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level - classification; defaults to OTHER. + description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER); defaults to OTHER. PEN and + AMPOULE are the sealed reservoirs the first dose breaches, so opening one starts a 30-day post-opening + window; BLISTER / INHALER / BOTTLE / OTHER hold individually sealed units and expire only on their printed + date. unitsTotal: anyOf: - type: number @@ -20509,14 +20517,16 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" - description: Instant the container was first used. NULL until opened; starts the 30-day in-use clock. + description: Instant the container was first used. NULL until opened. On a PEN / AMPOULE it starts the 30-day in-use + clock; on the other kinds it only records that the pack is open. expiresAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" - description: Persisted MIN(firstUseAt + 30 days, printedExpiry). NULL when neither clock has started. + description: Persisted MIN(firstUseAt + 30 days, printedExpiry) for a clock-bearing container (PEN / AMPOULE), else + printedExpiry alone. NULL when no deadline applies — an opened blister with no printed date has none. printedExpiry: anyOf: - type: string diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3cb06cee8..d7eb6d929 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2955,9 +2955,17 @@ enum MedicationInventoryState { } /// v1.16.10 — what kind of physical container an inventory item is. -/// Display-level only (no state-machine behaviour hangs off it); the -/// register dialog defaults it from the medication's delivery form +/// The register dialog defaults it from the medication's delivery form /// (INJECTION → PEN, ORAL → BLISTER, else OTHER). +/// +/// The kind decides whether opening the container starts a +/// post-opening degradation clock: PEN and AMPOULE are one sealed +/// reservoir that the first dose breaches, so they carry the 30-day +/// in-use window; BLISTER / INHALER / BOTTLE / OTHER hold individually +/// sealed units (or an unidentified arrangement) and expire only on +/// their printed date. See `IN_USE_CLOCK_CONTAINER_TYPES` in +/// `src/lib/medications/inventory/state-machine.ts` for the per-member +/// reasoning. enum MedicationContainerType { PEN AMPOULE @@ -2976,6 +2984,8 @@ model MedicationInventoryItem { state MedicationInventoryState @default(ACTIVE) /// v1.16.10 — container kind (pen / ampoule / blister pack / …). /// Backfilled PEN for items on INJECTION medications, OTHER otherwise. + /// Read by the state machine: PEN / AMPOULE carry the post-opening + /// clock, the rest expire only on `printedExpiry`. containerType MedicationContainerType @default(OTHER) @map("container_type") /// Units the container shipped with (v1.16.10 — renamed from /// `dosesTotal`; the DB column stays `doses_total`). A unit is one @@ -2989,10 +2999,12 @@ model MedicationInventoryItem { /// (v1.16.10 — renamed from `dosesRemaining`; column stays /// `doses_remaining`). v1.16.12 (#316) — Decimal for fractional dosing. unitsRemaining Decimal @map("doses_remaining") @db.Decimal(12, 4) - /// NULL until the pen is first opened. Sets the 30-day in-use - /// clock per EMA EPAR §6.3. + /// NULL until the container is first opened. On a PEN / AMPOULE it + /// also sets the 30-day in-use clock per EMA EPAR §6.3; on the other + /// kinds it records that the pack is open and nothing more. firstUseAt DateTime? @map("first_use_at") - /// Persisted = MIN(firstUseAt + 30 days, printedExpiry). Persisting + /// Persisted = MIN(firstUseAt + 30 days, printedExpiry) for a + /// clock-bearing container, else printedExpiry alone. Persisting /// rather than deriving keeps the daily "expire-stale" cron a /// single indexed scan. expiresAt DateTime? @map("expires_at") diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts index a31eae9ec..e371b526e 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts @@ -2,7 +2,8 @@ * v1.4.25 W19b — per-pen inventory item operations. * * PATCH /api/medications/[id]/inventory/[itemId] - * - mark-as-first-use (starts the 30-day clock manually) + * - set or clear the opening date (`markAsFirstUseAt`); for a pen + * or an ampoule it also starts / stops the in-use clock * - mark-as-used-up (terminal — operator override when a pen is * physically discarded but the dose ledger hasn't reached zero) * - update printed expiry (e.g. carton label correction) @@ -117,11 +118,17 @@ export const PATCH = apiHandler( let nextUnitsRemaining: number = Number(existing.unitsRemaining); let nextPrintedExpiry = existing.printedExpiry; - if (markAsFirstUseAt) { - // The clock can be set manually if the user opened the pen - // without logging an intake event. + if (markAsFirstUseAt !== undefined) { + // The opening date can be set manually if the user opened the + // container without logging an intake event — and cleared + // (null) when it was never opened at all. An auto-open the + // consumption hook wrote from a back-dated intake is the one + // the user most often has to take back, and until now nothing + // could unset it. nextFirstUseAt = markAsFirstUseAt; - if (nextState === "ACTIVE") nextState = "IN_USE"; + if (nextState === "ACTIVE" && markAsFirstUseAt !== null) { + nextState = "IN_USE"; + } } if (printedExpiry !== undefined) { @@ -145,7 +152,11 @@ export const PATCH = apiHandler( nextState = "USED_UP"; } - const nextExpiresAt = computeExpiresAt(nextFirstUseAt, nextPrintedExpiry); + const nextExpiresAt = computeExpiresAt( + nextFirstUseAt, + nextPrintedExpiry, + existing.containerType, + ); // Re-run the canonical state machine over the composed next-row // view. The clause-by-clause updates above set the state ad-hoc @@ -160,6 +171,7 @@ export const PATCH = apiHandler( nextState = computeInventoryState( { state: nextState, + containerType: existing.containerType, unitsTotal: Number(existing.unitsTotal), unitsRemaining: nextUnitsRemaining, firstUseAt: nextFirstUseAt, 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 bb4926240..33c877cd6 100644 --- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts @@ -269,6 +269,8 @@ describe("PATCH /api/medications/[id]/inventory/[itemId]", () => { userId: "user-1", medicationId: "med-1", state: "ACTIVE" as const, + // A pen — the container kind that carries the post-opening clock. + containerType: "PEN" as const, unitsTotal: 4, unitsRemaining: 4, firstUseAt: null, @@ -384,6 +386,78 @@ describe("PATCH /api/medications/[id]/inventory/[itemId]", () => { }); }); + it("leaves a back-dated first use on a blister IN_USE with no expiresAt", async () => { + // The same back-dated stamp on a tablet pack must not write the + // remaining tablets off: a blister carries no post-opening clock, + // so the only deadline it could have is a printed one, and this row + // has none. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({ + ...existingActive, + containerType: "BLISTER", + unitsTotal: 60, + unitsRemaining: 54, + } as never); + vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({ + ...existingActive, + state: "IN_USE", + } as never); + + 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: "IN_USE", + firstUseAt: new Date(backdated), + expiresAt: null, + }); + }); + + it("clears the opening date when markAsFirstUseAt is null", async () => { + // The correction path for a container the consumption hook + // auto-opened by mistake: unsetting the date returns the row to + // ACTIVE and drops any in-use deadline with it. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({ + ...existingActive, + state: "IN_USE", + firstUseAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000), + expiresAt: new Date(Date.now() + 25 * 24 * 60 * 60 * 1000), + } as never); + vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({ + ...existingActive, + state: "ACTIVE", + } as never); + + const res = await PATCH( + jsonReq( + "http://localhost/api/medications/med-1/inventory/inv-1", + { markAsFirstUseAt: null }, + "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: "ACTIVE", + firstUseAt: null, + expiresAt: null, + }); + }); + 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({ diff --git a/src/lib/medications/inventory/__tests__/consumption.test.ts b/src/lib/medications/inventory/__tests__/consumption.test.ts index 7ce6f7992..850103efa 100644 --- a/src/lib/medications/inventory/__tests__/consumption.test.ts +++ b/src/lib/medications/inventory/__tests__/consumption.test.ts @@ -39,6 +39,7 @@ interface FakeItem { userId: string; medicationId: string; state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP"; + containerType: "PEN" | "AMPOULE" | "BLISTER" | "INHALER" | "BOTTLE" | "OTHER"; unitsTotal: number; unitsRemaining: number; firstUseAt: Date | null; @@ -217,6 +218,9 @@ function item(overrides: Partial = {}): FakeItem { userId: "user-1", medicationId: "med-1", state: "ACTIVE", + // These fixtures model the pen case — the kind that carries the + // post-opening clock. The clock-less kinds get their own scenarios. + containerType: "PEN", unitsTotal: 10, unitsRemaining: 10, firstUseAt: null, @@ -419,6 +423,29 @@ describe("consumeForIntake", () => { ]); }); + it("leaves a backdated auto-open of a blister IN_USE with no expiresAt", async () => { + // Same backdated take against a tablet pack: every remaining + // tablet is still in its own sealed cavity, so opening the pack + // starts no clock and the cron gets no deadline to scan for. + const backdated = new Date(NOW.getTime() - 40 * MS_PER_DAY); + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent({ takenAt: backdated })], + items: [item({ id: "sealed", containerType: "BLISTER" })], + }; + const client = makeClient(state); + await consumeForIntake({ + ...consumeArgs(client), + intakeAt: backdated, + }); + + const opened = state.items[0]; + expect(opened.firstUseAt).toEqual(backdated); + expect(opened.state).toBe("IN_USE"); + expect(opened.expiresAt).toBeNull(); + expect(opened.unitsRemaining).toBe(9); + }); + it("spills across containers when the open one runs dry", async () => { const state: FakeState = { unitsPerDose: 3, diff --git a/src/lib/medications/inventory/__tests__/state-machine.test.ts b/src/lib/medications/inventory/__tests__/state-machine.test.ts index 928ee9869..d4d5ea9ad 100644 --- a/src/lib/medications/inventory/__tests__/state-machine.test.ts +++ b/src/lib/medications/inventory/__tests__/state-machine.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from "vitest"; import { + IN_USE_CLOCK_CONTAINER_TYPES, computeExpiresAt, computeInventoryState, - daysRemainingInUse, + hasInUseClock, type InventoryItemView, } from "../state-machine"; +import { MEDICATION_CONTAINER_TYPE_VALUES } from "@/lib/validations/medication/inventory"; const MS_PER_DAY = 24 * 60 * 60 * 1000; const NOW = new Date("2026-05-14T12:00:00Z"); @@ -16,6 +18,7 @@ function makeItem( ): InventoryItemView { return { state: "ACTIVE", + containerType: "PEN", unitsTotal: 4, unitsRemaining: 4, firstUseAt: null, @@ -24,6 +27,20 @@ function makeItem( }; } +describe("hasInUseClock", () => { + it("covers every container kind the enum declares", () => { + // A new enum member must be classified deliberately, not inherit a + // default. This pins the full partition. + const withClock = MEDICATION_CONTAINER_TYPE_VALUES.filter(hasInUseClock); + const withoutClock = MEDICATION_CONTAINER_TYPE_VALUES.filter( + (ct) => !hasInUseClock(ct), + ); + expect(withClock).toEqual(["PEN", "AMPOULE"]); + expect(withoutClock).toEqual(["BLISTER", "INHALER", "BOTTLE", "OTHER"]); + expect([...IN_USE_CLOCK_CONTAINER_TYPES]).toEqual(withClock); + }); +}); + describe("computeInventoryState", () => { it("returns ACTIVE for a fresh refrigerated pen", () => { expect(computeInventoryState(makeItem(), NOW_MS)).toBe("ACTIVE"); @@ -36,13 +53,21 @@ describe("computeInventoryState", () => { expect(computeInventoryState(item, NOW_MS)).toBe("IN_USE"); }); - it("returns EXPIRED when the in-use clock blew past 30 days", () => { + it("returns EXPIRED when the in-use clock blew past 30 days on a pen", () => { const item = makeItem({ firstUseAt: new Date(NOW_MS - 31 * MS_PER_DAY), }); expect(computeInventoryState(item, NOW_MS)).toBe("EXPIRED"); }); + it("returns EXPIRED when the in-use clock blew past 30 days on an ampoule", () => { + const item = makeItem({ + containerType: "AMPOULE", + firstUseAt: new Date(NOW_MS - 31 * MS_PER_DAY), + }); + expect(computeInventoryState(item, NOW_MS)).toBe("EXPIRED"); + }); + it("returns EXPIRED when printed expiry has lapsed (unopened pen)", () => { const item = makeItem({ printedExpiry: new Date(NOW_MS - 1 * MS_PER_DAY), @@ -89,99 +114,104 @@ describe("computeInventoryState", () => { }); expect(computeInventoryState(item, NOW_MS)).toBe("IN_USE"); }); + + describe("containers without a post-opening clock", () => { + // The reported defect: a blister of 54 tablets, no printed expiry, + // opened well over 30 days ago. Pressing out the first tablet must + // not write the other 53 off. + it("keeps a long-opened blister with no printed expiry IN_USE", () => { + const item = makeItem({ + containerType: "BLISTER", + unitsTotal: 60, + unitsRemaining: 54, + firstUseAt: new Date(NOW_MS - 45 * MS_PER_DAY), + printedExpiry: null, + }); + expect(computeInventoryState(item, NOW_MS)).toBe("IN_USE"); + }); + + // The counterpart the same fix must not weaken. + it("still expires an opened pen past its window", () => { + const item = makeItem({ + containerType: "PEN", + unitsTotal: 60, + unitsRemaining: 54, + firstUseAt: new Date(NOW_MS - 45 * MS_PER_DAY), + printedExpiry: null, + }); + expect(computeInventoryState(item, NOW_MS)).toBe("EXPIRED"); + }); + + it.each(["BLISTER", "INHALER", "BOTTLE", "OTHER"] as const)( + "keeps a long-opened %s IN_USE", + (containerType) => { + const item = makeItem({ + containerType, + firstUseAt: new Date(NOW_MS - 400 * MS_PER_DAY), + }); + expect(computeInventoryState(item, NOW_MS)).toBe("IN_USE"); + }, + ); + + it("still honours the printed expiry on a clock-less container", () => { + const item = makeItem({ + containerType: "BLISTER", + firstUseAt: new Date(NOW_MS - 45 * MS_PER_DAY), + printedExpiry: new Date(NOW_MS - 1 * MS_PER_DAY), + }); + expect(computeInventoryState(item, NOW_MS)).toBe("EXPIRED"); + }); + + it("keeps a clock-less container ACTIVE while its printed expiry is ahead", () => { + const item = makeItem({ + containerType: "BOTTLE", + printedExpiry: new Date(NOW_MS + 200 * MS_PER_DAY), + }); + expect(computeInventoryState(item, NOW_MS)).toBe("ACTIVE"); + }); + }); }); describe("computeExpiresAt", () => { it("returns null when neither firstUseAt nor printedExpiry is set", () => { - expect(computeExpiresAt(null, null)).toBeNull(); + expect(computeExpiresAt(null, null, "PEN")).toBeNull(); }); it("returns the printed expiry when only printedExpiry is set", () => { const printed = new Date("2027-01-15T00:00:00Z"); - expect(computeExpiresAt(null, printed)).toEqual(printed); + expect(computeExpiresAt(null, printed, "PEN")).toEqual(printed); }); - it("returns firstUseAt + 30 days when only firstUseAt is set", () => { + it("returns firstUseAt + 30 days when only firstUseAt is set on a pen", () => { const firstUse = new Date("2026-05-01T00:00:00Z"); - const result = computeExpiresAt(firstUse, null); + const result = computeExpiresAt(firstUse, null, "PEN"); expect(result?.getTime()).toBe(firstUse.getTime() + 30 * MS_PER_DAY); }); it("returns the in-use deadline when it lands before printed expiry", () => { const firstUse = new Date("2026-05-01T00:00:00Z"); const printed = new Date("2027-01-15T00:00:00Z"); - const result = computeExpiresAt(firstUse, printed); + const result = computeExpiresAt(firstUse, printed, "PEN"); expect(result?.getTime()).toBe(firstUse.getTime() + 30 * MS_PER_DAY); }); it("returns the printed expiry when it lands before the in-use deadline", () => { const firstUse = new Date("2026-05-01T00:00:00Z"); const printed = new Date("2026-05-10T00:00:00Z"); - const result = computeExpiresAt(firstUse, printed); + const result = computeExpiresAt(firstUse, printed, "PEN"); expect(result).toEqual(printed); }); -}); -describe("daysRemainingInUse", () => { - it("returns null for an ACTIVE (unopened) pen", () => { - expect(daysRemainingInUse(makeItem(), NOW_MS)).toBeNull(); + it("returns null for an opened blister with no printed expiry", () => { + // Nothing for the daily expire-cron's indexed scan to find — which + // is the point: there is no deadline to scan for. + const firstUse = new Date("2026-05-01T00:00:00Z"); + expect(computeExpiresAt(firstUse, null, "BLISTER")).toBeNull(); }); - it("returns null for an EXPIRED pen (clock blew)", () => { - const item = makeItem({ - firstUseAt: new Date(NOW_MS - 31 * MS_PER_DAY), - }); - expect(daysRemainingInUse(item, NOW_MS)).toBeNull(); - }); - - it("returns null for a USED_UP pen", () => { - const item = makeItem({ - unitsRemaining: 0, - firstUseAt: new Date(NOW_MS - 5 * MS_PER_DAY), - }); - expect(daysRemainingInUse(item, NOW_MS)).toBeNull(); - }); - - it("returns 25 days when the pen was opened 5 days ago", () => { - const item = makeItem({ - firstUseAt: new Date(NOW_MS - 5 * MS_PER_DAY), - }); - expect(daysRemainingInUse(item, NOW_MS)).toBe(25); - }); - - it("returns 0 on the last day of the window (still IN_USE)", () => { - // 29 days + 23h59m elapsed — under one day left, floor → 0. - const item = makeItem({ - firstUseAt: new Date(NOW_MS - 30 * MS_PER_DAY + 60_000), - }); - expect(daysRemainingInUse(item, NOW_MS)).toBe(0); - }); - - describe("thin-shape overload (v1.4.25 W21 Fix-N)", () => { - // The medication-detail card calls this with just `{ firstUseAt }` - // because it has already filtered the list to state === "IN_USE" - // before reaching the helper. The thin overload skips the - // state-machine gate. - it("accepts a thin `{ firstUseAt: Date }` shape", () => { - const firstUseAt = new Date(NOW_MS - 5 * MS_PER_DAY); - expect(daysRemainingInUse({ firstUseAt }, NOW_MS)).toBe(25); - }); - - it("accepts a thin `{ firstUseAt: string }` ISO shape", () => { - const firstUseAt = new Date(NOW_MS - 10 * MS_PER_DAY).toISOString(); - expect(daysRemainingInUse({ firstUseAt }, NOW_MS)).toBe(20); - }); - - it("returns null when the thin firstUseAt is null", () => { - expect(daysRemainingInUse({ firstUseAt: null }, NOW_MS)).toBeNull(); - }); - - it("skips the state-machine gate for the thin form", () => { - // 31 days past the window — the full-view path would return - // null here (state ≠ IN_USE). The thin form returns 0 because - // the caller has already gated on IN_USE. - const firstUseAt = new Date(NOW_MS - 31 * MS_PER_DAY); - expect(daysRemainingInUse({ firstUseAt }, NOW_MS)).toBe(0); - }); + it("returns the printed expiry alone for an opened blister that has one", () => { + const firstUse = new Date("2026-05-01T00:00:00Z"); + const printed = new Date("2027-01-15T00:00:00Z"); + expect(computeExpiresAt(firstUse, printed, "BLISTER")).toEqual(printed); }); }); diff --git a/src/lib/medications/inventory/consumption.ts b/src/lib/medications/inventory/consumption.ts index 913b02b1c..2d83e0082 100644 --- a/src/lib/medications/inventory/consumption.ts +++ b/src/lib/medications/inventory/consumption.ts @@ -250,10 +250,12 @@ export async function consumeForIntake(input: { const nextExpiresAt = computeExpiresAt( nextFirstUseAt, item.printedExpiry, + item.containerType, ); const nextState = computeInventoryState( { state: item.state, + containerType: item.containerType, unitsTotal: Number(item.unitsTotal), unitsRemaining: nextUnitsRemaining, firstUseAt: nextFirstUseAt, @@ -418,10 +420,15 @@ export async function consumeImportedIntakesBatch(input: { const nextFirstUseAt = item.firstUseAt ?? event.intakeAt; item.unitsRemaining -= take; item.firstUseAt = nextFirstUseAt; - item.expiresAt = computeExpiresAt(nextFirstUseAt, item.printedExpiry); + item.expiresAt = computeExpiresAt( + nextFirstUseAt, + item.printedExpiry, + item.containerType, + ); item.state = computeInventoryState( { state: item.state, + containerType: item.containerType, unitsTotal: Number(item.unitsTotal), unitsRemaining: item.unitsRemaining, firstUseAt: nextFirstUseAt, @@ -545,6 +552,7 @@ export async function restoreForIntake(input: { const nextState = computeInventoryState( { state: item.state, + containerType: item.containerType, unitsTotal: Number(item.unitsTotal), unitsRemaining: nextUnitsRemaining, firstUseAt: item.firstUseAt, diff --git a/src/lib/medications/inventory/service.ts b/src/lib/medications/inventory/service.ts index 2b77e2e34..0747283a9 100644 --- a/src/lib/medications/inventory/service.ts +++ b/src/lib/medications/inventory/service.ts @@ -22,8 +22,14 @@ import { } from "./summary"; /** - * Daily cron pass — flip any IN_USE rows whose 30-day window has - * elapsed into EXPIRED. Returns the count of transitions for logging. + * Daily cron pass — flip any IN_USE row whose persisted `expiresAt` has + * lapsed into EXPIRED. Returns the count of transitions for logging. + * + * `expiresAt` is written by `computeExpiresAt`, so the deadline is the + * printed expiry, the post-opening window, or the earlier of the two — + * and a container kind with no post-opening clock and no printed expiry + * carries no `expiresAt` at all, which is exactly how it stays out of + * this scan. * * Optionally scoped to a single user — useful for the per-user * trigger path. Cron with `userId: null` sweeps every user. @@ -158,7 +164,10 @@ export function buildCreateInventoryInput(input: { firstUseAt: null, printedExpiry: input.printedExpiry, purchasedAt: input.purchasedAt, - expiresAt: computeExpiresAt(null, input.printedExpiry), + // A freshly registered container is unopened, so the only deadline + // it can carry is the printed one — but pass the kind anyway so the + // helper stays the single place that decides. + expiresAt: computeExpiresAt(null, input.printedExpiry, input.containerType), manufacturer: input.manufacturer, doseStrength: input.doseStrength, // Encrypt the free-text note at rest; the plaintext column stays null. diff --git a/src/lib/medications/inventory/state-machine.ts b/src/lib/medications/inventory/state-machine.ts index 83d50980d..ed6fd92f5 100644 --- a/src/lib/medications/inventory/state-machine.ts +++ b/src/lib/medications/inventory/state-machine.ts @@ -1,5 +1,5 @@ /** - * v1.4.25 W19b — pen / vial inventory state machine. + * v1.4.25 W19b — container inventory state machine. * * Pure functions over a minimal `InventoryItemView` shape — no DB * access, no Prisma imports. The route handlers, the consumption hook @@ -9,7 +9,8 @@ * Decision tree (in evaluation order): * 1. unitsRemaining === 0 → USED_UP (terminal) * 2. printedExpiry && now > expiry → EXPIRED (printed label trumps clock) - * 3. firstUseAt && now > firstUseAt + IN_USE_WINDOW_MS → EXPIRED + * 3. firstUseAt && the container carries a post-opening clock && + * now > firstUseAt + IN_USE_WINDOW_MS → EXPIRED * 4. firstUseAt is set → IN_USE * 5. otherwise → ACTIVE * @@ -18,22 +19,85 @@ * machine is unit-agnostic — it only cares whether the count reached * zero. * - * The window length is fixed at 30 days per EMA EPAR §6.3 for - * Mounjaro KwikPen / Saxenda / Trulicity. Ozempic ships with a 56-day - * window per its own EPAR — that drug-specific override is *not* - * encoded here yet. The W19b scope deliberately uses the - * conservative 30-day default; a follow-up can read the - * drug-knowledge layer (`glp1-knowledge.ts`) and parametrise the - * window per Glp1DrugId. The state machine itself remains - * window-agnostic — it accepts the window length as input. + * ## Which containers carry a post-opening clock + * + * Clause 3 exists because breaching a sealed multi-dose reservoir + * exposes its contents: an injection pen's septum is punctured on the + * first dose and the product information caps how long the remainder + * stays usable (EMA EPAR §6.3). That fact is a property of the + * CONTAINER, not of every supply row, and applying it blindly wrote off + * good stock: pressing the first tablet out of a blister started a + * 30-day clock and a month later the untouched remainder was EXPIRED. + * + * So the clock now keys off `containerType` + * ({@link IN_USE_CLOCK_CONTAINER_TYPES}): + * + * - PEN — one sealed multi-dose reservoir, punctured on first use. + * The clock is the reason clause 3 exists. + * - AMPOULE — the member a punctured injectable reservoir (ampoule or + * multi-dose vial) lands on; there is no separate VIAL member. Same + * physical fact as the pen: the barrier is broken and the contents + * are exposed. + * - BLISTER — every tablet sits in its own sealed foil cavity. + * Pressing out tablet 1 does nothing to tablets 2..n. The carton + * expiry is the only expiry. + * - INHALER — a pressurised canister has no in-use limit at all, and + * the dry-powder devices that do state months, not 30 days. Any + * fixed default here would be a fabricated number. + * - BOTTLE — a bottle of tablets has no in-use limit. A bottle of + * suspension or drops does, but it is product-specific and the enum + * cannot tell the two apart. + * - OTHER — the backfill default for every row that never chose a + * kind. Nothing about it says a limit exists, so none is invented. + * + * For every clock-less kind the printed expiry (clause 2) still applies + * in full — an unknown container is not an unbounded one. What it does + * not get is a deadline nobody entered, because writing stock off on a + * guess hides supply the user physically holds and mutes the low-stock + * signal that would have told them to reorder. + * + * ## Window length + * + * The window is fixed at 30 days per EMA EPAR §6.3 for Mounjaro + * KwikPen / Saxenda. The per-product window is NOT read from the + * drug-knowledge layer (`glp1-knowledge.ts`) even though it carries one + * (`inUse.maxDays`: 14 for Trulicity, 56 for Ozempic): the only bridge + * from an inventory row to a `Glp1DrugId` is a fuzzy match on the + * free-text `Medication.name` (`findDrugIdByBrand`), and a mismatch + * there would shorten a window and write stock off — the exact failure + * this module just removed. The state machine stays window-agnostic: + * it accepts the window length as input, so a caller that can name the + * product with certainty can pass its own. */ -import type { MedicationInventoryState } from "@/generated/prisma/client"; +import type { + MedicationContainerType, + MedicationInventoryState, +} from "@/generated/prisma/client"; /** Default in-use window per EMA EPAR §6.3 for the strictest GLP-1 - * agonists (Mounjaro, Saxenda, Trulicity). 30 days. */ + * agonists (Mounjaro, Saxenda). 30 days. */ export const DEFAULT_IN_USE_WINDOW_DAYS = 30; +/** + * Container kinds whose first opening starts a degradation clock — the + * ones that are a single sealed reservoir breached by the first dose. + * Every other kind holds individually sealed units (or an unknown + * arrangement) and expires only on its printed date. See the module + * docblock for the per-member reasoning. + */ +export const IN_USE_CLOCK_CONTAINER_TYPES = [ + "PEN", + "AMPOULE", +] as const satisfies readonly MedicationContainerType[]; + +/** Does opening this container start the in-use window? */ +export function hasInUseClock(containerType: MedicationContainerType): boolean { + return (IN_USE_CLOCK_CONTAINER_TYPES as readonly string[]).includes( + containerType, + ); +} + const MS_PER_DAY = 24 * 60 * 60 * 1000; /** @@ -44,6 +108,7 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000; */ export interface InventoryItemView { state: MedicationInventoryState; + containerType: MedicationContainerType; unitsTotal: number; unitsRemaining: number; firstUseAt: Date | null; @@ -51,7 +116,7 @@ export interface InventoryItemView { } /** - * Resolve the canonical state for a pen / vial given the wall clock. + * Resolve the canonical state for a container given the wall clock. * * The function is pure — given the same input it always returns the * same state — so the caller can re-evaluate as often as it wants @@ -75,40 +140,52 @@ export function computeInventoryState( // (2) Printed expiry trumps the in-use clock. A container whose // carton expiry has lapsed is EXPIRED even if it was never opened. + // This clause applies to EVERY container kind. if (item.printedExpiry && nowMs > item.printedExpiry.getTime()) { return "EXPIRED"; } - // (3) In-use clock blew. firstUseAt + window < now ⇒ EXPIRED. + // (3) In-use clock blew — only for the kinds that have one. + // firstUseAt + window < now ⇒ EXPIRED. if (item.firstUseAt) { - const inUseDeadlineMs = - item.firstUseAt.getTime() + inUseWindowDays * MS_PER_DAY; - if (nowMs > inUseDeadlineMs) { - return "EXPIRED"; + if (hasInUseClock(item.containerType)) { + const inUseDeadlineMs = + item.firstUseAt.getTime() + inUseWindowDays * MS_PER_DAY; + if (nowMs > inUseDeadlineMs) { + return "EXPIRED"; + } } - // (4) Has been opened but still inside the window. + // (4) Has been opened. A clock-less container stays IN_USE for as + // long as it holds units — "opened" is a true statement about it, + // it just carries no deadline of its own. return "IN_USE"; } - // (5) Refrigerated, unopened. + // (5) Sealed, unopened. return "ACTIVE"; } /** - * Compute the `expiresAt` column. It's the min of the in-use - * deadline (firstUseAt + window) and the printed expiry — whichever - * lands first. If the container has never been opened we fall back to - * the printed expiry alone (in-use clock hasn't started). If neither - * is set we return null so the daily expire-cron has nothing to scan. + * Compute the `expiresAt` column — the instant the daily expire-cron + * scans for. + * + * For a container with a post-opening clock it is the min of the in-use + * deadline (firstUseAt + window) and the printed expiry, whichever + * lands first. For every other kind the printed expiry is the only + * deadline there is, so an opened blister with no printed date carries + * no `expiresAt` at all and the cron never sees it. If neither applies + * we return null. */ export function computeExpiresAt( firstUseAt: Date | null, printedExpiry: Date | null, + containerType: MedicationContainerType, inUseWindowDays: number = DEFAULT_IN_USE_WINDOW_DAYS, ): Date | null { - const inUseDeadline = firstUseAt - ? new Date(firstUseAt.getTime() + inUseWindowDays * MS_PER_DAY) - : null; + const inUseDeadline = + firstUseAt && hasInUseClock(containerType) + ? new Date(firstUseAt.getTime() + inUseWindowDays * MS_PER_DAY) + : null; if (inUseDeadline && printedExpiry) { return inUseDeadline.getTime() < printedExpiry.getTime() @@ -117,51 +194,3 @@ export function computeExpiresAt( } return inUseDeadline ?? printedExpiry ?? null; } - -/** - * Whole-days remaining in the 30-day in-use window, rounded toward - * zero. Returns null when the container is not currently IN_USE (the - * UI surface for ACTIVE / EXPIRED / USED_UP shows a different label). - * - * The day count is computed against the in-use deadline only — - * the printed expiry is its own separate countdown surfaced as the - * "Carton expires {date}" label. This split avoids the UI lying to - * the user when the printed expiry is more imminent than the - * in-use deadline (the EXPIRED transition is still correct via - * `computeInventoryState`, but the day-count chip shows the - * 30-day-clock number). - * - * v1.4.25 W21 Fix-N — widened to accept either the full - * `InventoryItemView` (server-side path: state-machine-gated, returns - * null for non-IN_USE) OR a thin `{ firstUseAt }` shape (caller - * already knows state is IN_USE so the gate is a no-op). The widening - * collapsed an earlier reimplementation in the now-retired web - * inventory surface; the helper stays because the GLP-1 details - * endpoint still computes weeksOfSupply for iOS. - */ -export function daysRemainingInUse( - item: InventoryItemView | { firstUseAt: Date | string | null }, - nowMs: number, - inUseWindowDays: number = DEFAULT_IN_USE_WINDOW_DAYS, -): number | null { - const firstUseRaw = item.firstUseAt; - if (!firstUseRaw) return null; - const firstUseAt = - firstUseRaw instanceof Date ? firstUseRaw : new Date(firstUseRaw); - // Only the full view triggers the state-machine gate; thin callers - // (the inventory disclosure UI on the medication card) supply just - // `firstUseAt` and have already filtered on state === "IN_USE" before - // reaching here. The gate is silently skipped for the thin form. - if ("state" in item && "unitsRemaining" in item) { - const state = computeInventoryState( - { ...item, firstUseAt }, - nowMs, - inUseWindowDays, - ); - if (state !== "IN_USE") return null; - } - const deadlineMs = firstUseAt.getTime() + inUseWindowDays * MS_PER_DAY; - const remainingMs = deadlineMs - nowMs; - if (remainingMs <= 0) return 0; - return Math.floor(remainingMs / MS_PER_DAY); -} diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index ec2d35972..1e3425278 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -290,12 +290,12 @@ export const medicationInventoryItemResource = z state: z .enum(["ACTIVE", "IN_USE", "EXPIRED", "USED_UP"]) .describe( - "Container lifecycle state. ACTIVE = unopened; IN_USE = opened (30-day in-use clock running); EXPIRED = printed expiry or in-use window lapsed with units left; USED_UP = drained (terminal).", + "Container lifecycle state. ACTIVE = unopened; IN_USE = opened (on a PEN / AMPOULE the 30-day in-use clock is running); EXPIRED = printed expiry lapsed, or the in-use window lapsed on a clock-bearing container, with units left; USED_UP = drained (terminal).", ), containerType: z .enum(MEDICATION_CONTAINER_TYPE_VALUES) .describe( - "Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level classification; defaults to OTHER.", + "Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER); defaults to OTHER. PEN and AMPOULE are the sealed reservoirs the first dose breaches, so opening one starts a 30-day post-opening window; BLISTER / INHALER / BOTTLE / OTHER hold individually sealed units and expire only on their printed date.", ), unitsTotal: z .number() @@ -313,13 +313,13 @@ export const medicationInventoryItemResource = z .datetime({ offset: true }) .nullable() .describe( - "Instant the container was first used. NULL until opened; starts the 30-day in-use clock.", + "Instant the container was first used. NULL until opened. On a PEN / AMPOULE it starts the 30-day in-use clock; on the other kinds it only records that the pack is open.", ), expiresAt: z.iso .datetime({ offset: true }) .nullable() .describe( - "Persisted MIN(firstUseAt + 30 days, printedExpiry). NULL when neither clock has started.", + "Persisted MIN(firstUseAt + 30 days, printedExpiry) for a clock-bearing container (PEN / AMPOULE), else printedExpiry alone. NULL when no deadline applies — an opened blister with no printed date has none.", ), printedExpiry: z.iso.datetime({ offset: true }).nullable(), purchasedAt: z.iso.datetime({ offset: true }).nullable(), diff --git a/src/lib/validations/medication/inventory.ts b/src/lib/validations/medication/inventory.ts index 1c33a83d0..acf0ff406 100644 --- a/src/lib/validations/medication/inventory.ts +++ b/src/lib/validations/medication/inventory.ts @@ -11,7 +11,10 @@ import { z } from "zod/v4"; */ /** * v1.16.10 — container kinds, mirrored from the Prisma - * `MedicationContainerType` enum. Display-level classification only. + * `MedicationContainerType` enum. Beyond labelling the row, the kind + * decides whether opening the container starts a post-opening clock — + * see `IN_USE_CLOCK_CONTAINER_TYPES` in + * `src/lib/medications/inventory/state-machine.ts`. */ export const MEDICATION_CONTAINER_TYPE_VALUES = [ "PEN", @@ -42,7 +45,7 @@ export const createInventoryItemSchema = z .enum(MEDICATION_CONTAINER_TYPE_VALUES) .optional() .describe( - "Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level only; defaults to OTHER.", + "Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Defaults to OTHER. PEN and AMPOULE are the sealed reservoirs the first dose breaches, so they carry a 30-day post-opening window; every other kind expires only on its printed date.", ), printedExpiry: z.iso .datetime({ offset: true }) @@ -85,9 +88,10 @@ export const updateInventoryItemSchema = z markAsFirstUseAt: z.iso .datetime({ offset: true }) .transform((s) => new Date(s)) + .nullable() .optional() .describe( - "Manually start the 30-day in-use clock (the user opened the container without logging an intake). ACTIVE flips to IN_USE; a backdated instant whose window already lapsed lands EXPIRED.", + "Opening date of the container (the user opened it without logging an intake). ACTIVE flips to IN_USE. `null` clears it — the container reads as unopened again, which is how a wrongly auto-opened row is corrected. For a PEN or an AMPOULE this also starts / stops the 30-day in-use clock; other container kinds carry no such clock and expire only on their printed date.", ), markAsUsedUp: z .boolean()