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()