mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
fix(medications): every taken dose moves the stock, in units that match the product
Stock only decremented on the web card's take button — the status toggle, the phone sync, the Telegram buttons and the token API all recorded the dose and left the inventory untouched, which is why a fresh pack kept reading full. Consumption now stamps the intake row itself: every path that turns a row taken consumes through one module — open container first, then earliest expiry with auto-open, spilling into the next pack and flooring at zero — and every path that takes it back (undo, delete, a take turned skip, a converged duplicate) refunds exactly what the stamp recorded. Replays and pending sync echoes can never double-charge, and the nightly convergence pass refunds the rows it tombstones. A medication can declare units per dose, so two 2 mg tablets book as one 4 mg dose and stock reads in doses with the unit count alongside; containers carry a named type including tablet packs, hold up to a thousand units for inhalers, and the GLP-1 card reads the same stock as the inventory tab instead of a separate ledger. The inventory endpoints join the documented API.
This commit is contained in:
1 parent
dacdcccfd2
commit
7d3557a3fd
53 files changed
+4118
-641
No files matched your search
@@ -30,7 +30,8 @@ function fakeMedication(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
note: null,
|
||||
},
|
||||
],
|
||||
inventoryEvents: [],
|
||||
unitsPerDose: 1,
|
||||
inventoryItems: [],
|
||||
intakeEvents: [],
|
||||
...overrides,
|
||||
};
|
||||
@@ -134,4 +135,25 @@ describe("buildGlp1SnapshotBlock", () => {
|
||||
expect(out?.medications[0].genericName).toBe("Semaglutide");
|
||||
expect(out?.medications[0].currentDose?.unit).toBe("mg");
|
||||
});
|
||||
|
||||
it("derives pen inventory from the per-item entities (v1.16.10)", async () => {
|
||||
prismaMock.medication.findMany.mockResolvedValue([
|
||||
fakeMedication({
|
||||
unitsPerDose: 2,
|
||||
inventoryItems: [
|
||||
{ state: "IN_USE", unitsTotal: 4, unitsRemaining: 3 },
|
||||
{ state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 },
|
||||
{ state: "USED_UP", unitsTotal: 4, unitsRemaining: 0 },
|
||||
{ state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const out = await buildGlp1SnapshotBlock("user-1");
|
||||
// 2 usable containers; 7 pooled units / 2 units per dose = 3 doses.
|
||||
expect(out?.medications[0].penInventory).toEqual({
|
||||
pensRemaining: 2,
|
||||
dosesRemaining: 3,
|
||||
weeksOfSupplyApprox: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -266,7 +266,9 @@ export async function buildGlp1SnapshotBlock(
|
||||
include: {
|
||||
schedules: true,
|
||||
doseChanges: { orderBy: { effectiveFrom: "asc" } },
|
||||
inventoryEvents: { orderBy: { occurredAt: "asc" } },
|
||||
// v1.16.10 — pen inventory reads the per-item entities (the rows
|
||||
// the intake consumption hook moves), not the legacy event ledger.
|
||||
inventoryItems: { orderBy: { createdAt: "asc" } },
|
||||
intakeEvents: {
|
||||
where: { takenAt: { not: null } },
|
||||
orderBy: { takenAt: "desc" },
|
||||
@@ -368,16 +370,29 @@ export async function buildGlp1SnapshotBlock(
|
||||
|
||||
const nextInjection = predictNextInjection(schedule, lastInjection, now);
|
||||
|
||||
// Inventory math: running sum of all inventory events for this med.
|
||||
// Convert to "weeks of supply" via dosesPerUnit + the cadence so the
|
||||
// Coach can answer "how long until my next refill?".
|
||||
// Inventory math over the per-item entities (v1.16.10 — the same
|
||||
// rows the Bestand tab and the consumption hook move; the legacy
|
||||
// event ledger no longer receives writes). `pensRemaining` counts
|
||||
// usable containers; `dosesRemaining` pools the units and divides
|
||||
// by `unitsPerDose` so the Coach can answer "how long until my
|
||||
// next refill?".
|
||||
let dosesRemaining: number | null = null;
|
||||
let pensRemaining: number | null = null;
|
||||
let weeksOfSupplyApprox: number | null = null;
|
||||
if (med.inventoryEvents.length > 0 && med.dosesPerUnit) {
|
||||
const units = med.inventoryEvents.reduce((sum, ev) => sum + ev.delta, 0);
|
||||
pensRemaining = Math.max(0, units);
|
||||
dosesRemaining = pensRemaining * med.dosesPerUnit;
|
||||
if (med.inventoryItems.length > 0) {
|
||||
const usable = med.inventoryItems.filter(
|
||||
(item) =>
|
||||
(item.state === "ACTIVE" || item.state === "IN_USE") &&
|
||||
item.unitsRemaining > 0,
|
||||
);
|
||||
const unitsRemaining = usable.reduce(
|
||||
(sum, item) => sum + item.unitsRemaining,
|
||||
0,
|
||||
);
|
||||
pensRemaining = usable.length;
|
||||
dosesRemaining = Math.floor(
|
||||
unitsRemaining / Math.max(1, med.unitsPerDose),
|
||||
);
|
||||
const dosesPerWeek =
|
||||
schedule?.cadence === "weekly"
|
||||
? 1
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* v1.16.10 — inventory refunds in the intake-slot dedup pass.
|
||||
*
|
||||
* Collapsing a duplicate slot soft-deletes the loser rows. A loser that
|
||||
* carries a consumption stamp is a REAL taken row whose units left the
|
||||
* stock — tombstoning it without a refund would strand those units
|
||||
* forever (the row is no longer reachable through any user-facing
|
||||
* restore path). The dedup therefore refunds every stamped loser before
|
||||
* the tombstone; unstamped losers (pending / skipped / pre-stamp rows)
|
||||
* never consumed and are swept without a refund round-trip. The winner
|
||||
* keeps its own stamp untouched.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/medications/inventory/consumption", () => ({
|
||||
restoreForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { dedupeUserIntakeSlots } from "../intake-slot-dedup";
|
||||
import { restoreForIntake } from "@/lib/medications/inventory/consumption";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
const SCHEDULE = {
|
||||
id: "s1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "07:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
};
|
||||
|
||||
const MED = {
|
||||
id: "med-1",
|
||||
userId: "u1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules: [SCHEDULE],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: TZ,
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([MED] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue(
|
||||
{} as never,
|
||||
);
|
||||
});
|
||||
|
||||
describe("dedupeUserIntakeSlots — inventory refunds (v1.16.10)", () => {
|
||||
it("refunds a stamped loser before tombstoning; the winner keeps its stamp", async () => {
|
||||
// 07:00 CEST = 05:00Z. Two TAKEN rows for the same dose — the iOS
|
||||
// drift duplicate (+30 s) beside the server row. The fresher row
|
||||
// (higher syncVersion) wins; the stamped loser must be refunded.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "r-loser",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: new Date("2026-06-15T05:00:00.000Z"),
|
||||
takenAt: new Date("2026-06-15T05:01:00.000Z"),
|
||||
skipped: false,
|
||||
syncVersion: 0,
|
||||
createdAt: new Date("2026-06-15T05:01:00Z"),
|
||||
attributionSource: "AUTO",
|
||||
source: "WEB",
|
||||
inventoryConsumption: [{ itemId: "item-1", units: 2 }],
|
||||
},
|
||||
{
|
||||
id: "r-winner",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: new Date("2026-06-15T05:00:30.000Z"),
|
||||
takenAt: new Date("2026-06-15T05:01:30.000Z"),
|
||||
skipped: false,
|
||||
syncVersion: 3,
|
||||
createdAt: new Date("2026-06-15T05:01:30Z"),
|
||||
attributionSource: "AUTO",
|
||||
source: "API",
|
||||
inventoryConsumption: [{ itemId: "item-1", units: 2 }],
|
||||
},
|
||||
] as never);
|
||||
|
||||
const summary = await dedupeUserIntakeSlots("u1");
|
||||
expect(summary.slotsCollapsed).toBe(1);
|
||||
expect(summary.rowsSoftDeleted).toBe(1);
|
||||
|
||||
// Only the LOSER is refunded — the winner keeps its stamp.
|
||||
expect(restoreForIntake).toHaveBeenCalledTimes(1);
|
||||
expect(restoreForIntake).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "u1", eventId: "r-loser" }),
|
||||
);
|
||||
|
||||
// The refund runs before the loser tombstone sweep.
|
||||
const restoreOrder = vi.mocked(restoreForIntake).mock
|
||||
.invocationCallOrder[0];
|
||||
const sweepOrder = vi.mocked(prisma.medicationIntakeEvent.updateMany)
|
||||
.mock.invocationCallOrder[0];
|
||||
expect(restoreOrder).toBeLessThan(sweepOrder);
|
||||
});
|
||||
|
||||
it("ignores unstamped losers (a pending sibling never consumed)", async () => {
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "r-taken",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: new Date("2026-06-15T05:00:00.000Z"),
|
||||
takenAt: new Date("2026-06-15T05:01:00.000Z"),
|
||||
skipped: false,
|
||||
syncVersion: 1,
|
||||
createdAt: new Date("2026-06-15T05:01:00Z"),
|
||||
attributionSource: "AUTO",
|
||||
source: "WEB",
|
||||
inventoryConsumption: [{ itemId: "item-1", units: 1 }],
|
||||
},
|
||||
{
|
||||
id: "r-pending",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: new Date("2026-06-15T05:00:30.000Z"),
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
syncVersion: 0,
|
||||
createdAt: new Date("2026-06-15T04:00:00Z"),
|
||||
attributionSource: "AUTO",
|
||||
source: "REMINDER",
|
||||
inventoryConsumption: null,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const summary = await dedupeUserIntakeSlots("u1");
|
||||
expect(summary.slotsCollapsed).toBe(1);
|
||||
expect(summary.rowsSoftDeleted).toBe(1);
|
||||
// The taken row wins; the pending loser carries no stamp → no refund.
|
||||
expect(restoreForIntake).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { restoreForIntake } from "@/lib/medications/inventory/consumption";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
@@ -119,6 +120,12 @@ interface DedupIntakeRow {
|
||||
* takenAt`); a WEB or IMPORT ad-hoc row is a deliberate user shape.
|
||||
*/
|
||||
source: string;
|
||||
/**
|
||||
* v1.16.10 — inventory consumption stamp. A stamped LOSER row refunds
|
||||
* its consumed units before the tombstone (the winner keeps its own
|
||||
* stamp untouched), so collapsing a duplicate never strands stock.
|
||||
*/
|
||||
inventoryConsumption: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,6 +273,8 @@ export async function dedupeUserIntakeSlots(
|
||||
attributionSource: true,
|
||||
// v1.16.9 — write origin for the ad-hoc→pending convergence gate.
|
||||
source: true,
|
||||
// v1.16.10 — stamped losers refund their consumption on collapse.
|
||||
inventoryConsumption: true,
|
||||
},
|
||||
orderBy: { scheduledFor: "asc" },
|
||||
})) as DedupIntakeRow[];
|
||||
@@ -347,6 +356,20 @@ export async function dedupeUserIntakeSlots(
|
||||
const winner = pickWinner(slotRows);
|
||||
const losers = slotRows.filter((r) => r.id !== winner.id);
|
||||
|
||||
// v1.16.10 — a stamped loser (a real taken row losing to a pin)
|
||||
// refunds its consumed units before the tombstone; unstamped
|
||||
// losers (pending / skipped / pre-stamp rows) skip the round
|
||||
// trip. The winner keeps its own stamp untouched.
|
||||
for (const loser of losers) {
|
||||
if (loser.inventoryConsumption !== null) {
|
||||
await restoreForIntake({
|
||||
client: prisma,
|
||||
userId,
|
||||
eventId: loser.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Soft-delete every loser in one shot. Bump syncVersion so the
|
||||
// sync feed echoes a monotonic value and iOS drops the tombstone.
|
||||
const loserIds = losers.map((r) => r.id);
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
/**
|
||||
* v1.16.10 — consumption hook contract tests.
|
||||
*
|
||||
* Pins the exactly-once stamp semantics, the multi-unit FEFO selection
|
||||
* with spillover + auto-open, the floor-at-zero partial stamp, and the
|
||||
* symmetric restore (clamped refund, state re-derivation, stamp clear).
|
||||
* The fake client mirrors the Prisma surface the hook touches and
|
||||
* honours the orderBy specs the hook passes, so the selection order the
|
||||
* tests assert is the order production gets from Postgres.
|
||||
*
|
||||
* Timezone-stable: every instant is an explicit UTC ISO string and the
|
||||
* wall clock is pinned with fake timers.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { consumeForIntake, restoreForIntake } from "../consumption";
|
||||
|
||||
const NOW = new Date("2026-06-10T12:00:00.000Z");
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface FakeEvent {
|
||||
id: string;
|
||||
userId: string;
|
||||
medicationId: string;
|
||||
takenAt: Date | null;
|
||||
skipped: boolean;
|
||||
deletedAt: Date | null;
|
||||
inventoryConsumption: unknown;
|
||||
}
|
||||
|
||||
interface FakeItem {
|
||||
id: string;
|
||||
userId: string;
|
||||
medicationId: string;
|
||||
state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
|
||||
unitsTotal: number;
|
||||
unitsRemaining: number;
|
||||
firstUseAt: Date | null;
|
||||
expiresAt: Date | null;
|
||||
printedExpiry: Date | null;
|
||||
purchasedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface FakeState {
|
||||
events: FakeEvent[];
|
||||
items: FakeItem[];
|
||||
unitsPerDose: number;
|
||||
}
|
||||
|
||||
type OrderSpec = Record<
|
||||
string,
|
||||
"asc" | "desc" | { sort: "asc" | "desc"; nulls?: "first" | "last" }
|
||||
>;
|
||||
|
||||
/** Honour the hook's orderBy the way Postgres would. */
|
||||
function sortRows(rows: FakeItem[], orderBy: OrderSpec[]): FakeItem[] {
|
||||
return [...rows].sort((a, b) => {
|
||||
for (const spec of orderBy) {
|
||||
const [field, raw] = Object.entries(spec)[0];
|
||||
const dir = typeof raw === "string" ? raw : raw.sort;
|
||||
const nulls = typeof raw === "object" ? raw.nulls : undefined;
|
||||
const av = a[field as keyof FakeItem] as Date | null;
|
||||
const bv = b[field as keyof FakeItem] as Date | null;
|
||||
if (av === null && bv === null) continue;
|
||||
if (av === null) return nulls === "last" ? 1 : -1;
|
||||
if (bv === null) return nulls === "last" ? -1 : 1;
|
||||
const diff = av.getTime() - bv.getTime();
|
||||
if (diff !== 0) return dir === "asc" ? diff : -diff;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
function makeClient(state: FakeState) {
|
||||
return {
|
||||
medication: {
|
||||
findFirst: vi.fn(async () => ({ unitsPerDose: state.unitsPerDose })),
|
||||
},
|
||||
medicationIntakeEvent: {
|
||||
findFirst: vi.fn(
|
||||
async (args: { where: { id: string; userId: string } }) =>
|
||||
state.events.find(
|
||||
(e) => e.id === args.where.id && e.userId === args.where.userId,
|
||||
) ?? null,
|
||||
),
|
||||
update: vi.fn(
|
||||
async (args: {
|
||||
where: { id: string };
|
||||
data: Record<string, unknown>;
|
||||
}) => {
|
||||
const event = state.events.find((e) => e.id === args.where.id)!;
|
||||
if ("inventoryConsumption" in args.data) {
|
||||
event.inventoryConsumption =
|
||||
args.data.inventoryConsumption === Prisma.DbNull
|
||||
? null
|
||||
: args.data.inventoryConsumption;
|
||||
}
|
||||
return event;
|
||||
},
|
||||
),
|
||||
},
|
||||
medicationInventoryItem: {
|
||||
findMany: vi.fn(
|
||||
async (args: {
|
||||
where: {
|
||||
userId: string;
|
||||
medicationId: string;
|
||||
state: FakeItem["state"];
|
||||
unitsRemaining: { gt: number };
|
||||
};
|
||||
orderBy: OrderSpec[];
|
||||
}) =>
|
||||
sortRows(
|
||||
state.items.filter(
|
||||
(i) =>
|
||||
i.userId === args.where.userId &&
|
||||
i.medicationId === args.where.medicationId &&
|
||||
i.state === args.where.state &&
|
||||
i.unitsRemaining > args.where.unitsRemaining.gt,
|
||||
),
|
||||
args.orderBy,
|
||||
),
|
||||
),
|
||||
findFirst: vi.fn(
|
||||
async (args: { where: { id: string; userId: string } }) =>
|
||||
state.items.find(
|
||||
(i) => i.id === args.where.id && i.userId === args.where.userId,
|
||||
) ?? null,
|
||||
),
|
||||
update: vi.fn(
|
||||
async (args: {
|
||||
where: { id: string };
|
||||
data: Partial<FakeItem>;
|
||||
}) => {
|
||||
const item = state.items.find((i) => i.id === args.where.id)!;
|
||||
Object.assign(item, args.data);
|
||||
return item;
|
||||
},
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function takenEvent(overrides: Partial<FakeEvent> = {}): FakeEvent {
|
||||
return {
|
||||
id: "evt-1",
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
takenAt: NOW,
|
||||
skipped: false,
|
||||
deletedAt: null,
|
||||
inventoryConsumption: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function item(overrides: Partial<FakeItem> = {}): FakeItem {
|
||||
return {
|
||||
id: "item-1",
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
state: "ACTIVE",
|
||||
unitsTotal: 10,
|
||||
unitsRemaining: 10,
|
||||
firstUseAt: null,
|
||||
expiresAt: null,
|
||||
printedExpiry: null,
|
||||
purchasedAt: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function consumeArgs(client: ReturnType<typeof makeClient>) {
|
||||
return {
|
||||
client: client as never,
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
eventId: "evt-1",
|
||||
intakeAt: NOW,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("consumeForIntake", () => {
|
||||
it("consumes unitsPerDose units from the open container and stamps the event", async () => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 2,
|
||||
events: [takenEvent()],
|
||||
items: [
|
||||
item({
|
||||
id: "open",
|
||||
state: "IN_USE",
|
||||
unitsRemaining: 5,
|
||||
firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY),
|
||||
expiresAt: new Date(NOW.getTime() + 27 * MS_PER_DAY),
|
||||
}),
|
||||
],
|
||||
};
|
||||
const client = makeClient(state);
|
||||
await consumeForIntake(consumeArgs(client));
|
||||
|
||||
expect(state.items[0].unitsRemaining).toBe(3);
|
||||
expect(state.items[0].state).toBe("IN_USE");
|
||||
expect(state.events[0].inventoryConsumption).toEqual([
|
||||
{ itemId: "open", units: 2 },
|
||||
]);
|
||||
expect(annotate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: { name: "medication.inventory.consumed" },
|
||||
meta: expect.objectContaining({
|
||||
medication_id: "med-1",
|
||||
units: 2,
|
||||
item_ids: ["open"],
|
||||
auto_opened: 0,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers the IN_USE container over fresher ACTIVE stock", async () => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 1,
|
||||
events: [takenEvent()],
|
||||
items: [
|
||||
item({ id: "sealed", state: "ACTIVE", unitsRemaining: 10 }),
|
||||
item({
|
||||
id: "open",
|
||||
state: "IN_USE",
|
||||
unitsRemaining: 2,
|
||||
firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY),
|
||||
expiresAt: new Date(NOW.getTime() + 27 * MS_PER_DAY),
|
||||
}),
|
||||
],
|
||||
};
|
||||
const client = makeClient(state);
|
||||
await consumeForIntake(consumeArgs(client));
|
||||
|
||||
expect(state.items.find((i) => i.id === "open")!.unitsRemaining).toBe(1);
|
||||
expect(state.items.find((i) => i.id === "sealed")!.unitsRemaining).toBe(10);
|
||||
});
|
||||
|
||||
it("opens unopened stock first-expiry-first-out, null printed expiry last", async () => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 1,
|
||||
events: [takenEvent()],
|
||||
items: [
|
||||
item({
|
||||
id: "no-expiry",
|
||||
printedExpiry: null,
|
||||
createdAt: new Date("2026-05-01T00:00:00.000Z"),
|
||||
}),
|
||||
item({
|
||||
id: "later-expiry",
|
||||
printedExpiry: new Date("2027-06-01T00:00:00.000Z"),
|
||||
}),
|
||||
item({
|
||||
id: "sooner-expiry",
|
||||
printedExpiry: new Date("2026-09-01T00:00:00.000Z"),
|
||||
}),
|
||||
],
|
||||
};
|
||||
const client = makeClient(state);
|
||||
await consumeForIntake(consumeArgs(client));
|
||||
|
||||
expect(
|
||||
state.items.find((i) => i.id === "sooner-expiry")!.unitsRemaining,
|
||||
).toBe(9);
|
||||
expect(state.items.find((i) => i.id === "no-expiry")!.unitsRemaining).toBe(
|
||||
10,
|
||||
);
|
||||
expect(
|
||||
state.items.find((i) => i.id === "later-expiry")!.unitsRemaining,
|
||||
).toBe(10);
|
||||
});
|
||||
|
||||
it("auto-opens an ACTIVE container: firstUseAt = intakeAt, state IN_USE, expiresAt derived", async () => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 1,
|
||||
events: [takenEvent()],
|
||||
items: [item({ id: "sealed" })],
|
||||
};
|
||||
const client = makeClient(state);
|
||||
await consumeForIntake(consumeArgs(client));
|
||||
|
||||
const opened = state.items[0];
|
||||
expect(opened.firstUseAt).toEqual(NOW);
|
||||
expect(opened.state).toBe("IN_USE");
|
||||
expect(opened.expiresAt?.getTime()).toBe(
|
||||
NOW.getTime() + 30 * MS_PER_DAY,
|
||||
);
|
||||
expect(annotate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
meta: expect.objectContaining({ auto_opened: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("lands a backdated auto-open straight in EXPIRED when its window has already lapsed", async () => {
|
||||
const backdated = new Date(NOW.getTime() - 40 * MS_PER_DAY);
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 1,
|
||||
events: [takenEvent({ takenAt: backdated })],
|
||||
items: [item({ id: "sealed" })],
|
||||
};
|
||||
const client = makeClient(state);
|
||||
await consumeForIntake({
|
||||
...consumeArgs(client),
|
||||
intakeAt: backdated,
|
||||
});
|
||||
|
||||
const opened = state.items[0];
|
||||
expect(opened.firstUseAt).toEqual(backdated);
|
||||
// firstUseAt + 30 days lies in the past — the state machine says
|
||||
// EXPIRED at the wall clock, and that is the honest answer.
|
||||
expect(opened.state).toBe("EXPIRED");
|
||||
expect(opened.unitsRemaining).toBe(9);
|
||||
expect(state.events[0].inventoryConsumption).toEqual([
|
||||
{ itemId: "sealed", units: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("spills across containers when the open one runs dry", async () => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 3,
|
||||
events: [takenEvent()],
|
||||
items: [
|
||||
item({
|
||||
id: "open",
|
||||
state: "IN_USE",
|
||||
unitsRemaining: 1,
|
||||
firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY),
|
||||
expiresAt: new Date(NOW.getTime() + 27 * MS_PER_DAY),
|
||||
}),
|
||||
item({ id: "sealed", unitsRemaining: 4, unitsTotal: 4 }),
|
||||
],
|
||||
};
|
||||
const client = makeClient(state);
|
||||
await consumeForIntake(consumeArgs(client));
|
||||
|
||||
const open = state.items.find((i) => i.id === "open")!;
|
||||
const sealed = state.items.find((i) => i.id === "sealed")!;
|
||||
expect(open.unitsRemaining).toBe(0);
|
||||
expect(open.state).toBe("USED_UP");
|
||||
expect(sealed.unitsRemaining).toBe(2);
|
||||
expect(sealed.state).toBe("IN_USE");
|
||||
expect(state.events[0].inventoryConsumption).toEqual([
|
||||
{ itemId: "open", units: 1 },
|
||||
{ itemId: "sealed", units: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("floors at zero with a partial stamp when stock runs out — never negative, never an error", async () => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 4,
|
||||
events: [takenEvent()],
|
||||
items: [
|
||||
item({
|
||||
id: "open",
|
||||
state: "IN_USE",
|
||||
unitsRemaining: 1,
|
||||
firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY),
|
||||
expiresAt: new Date(NOW.getTime() + 27 * MS_PER_DAY),
|
||||
}),
|
||||
],
|
||||
};
|
||||
const client = makeClient(state);
|
||||
await consumeForIntake(consumeArgs(client));
|
||||
|
||||
expect(state.items[0].unitsRemaining).toBe(0);
|
||||
expect(state.items[0].state).toBe("USED_UP");
|
||||
expect(state.events[0].inventoryConsumption).toEqual([
|
||||
{ itemId: "open", units: 1 },
|
||||
]);
|
||||
expect(annotate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
meta: expect.objectContaining({ units: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("stamps an empty consumption when no tracked stock exists (exactly-once gate)", async () => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 1,
|
||||
events: [takenEvent()],
|
||||
items: [],
|
||||
};
|
||||
const client = makeClient(state);
|
||||
await consumeForIntake(consumeArgs(client));
|
||||
|
||||
expect(state.events[0].inventoryConsumption).toEqual([]);
|
||||
expect(client.medicationInventoryItem.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a no-op on an already-stamped row (replay never double-decrements)", async () => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 1,
|
||||
events: [
|
||||
takenEvent({ inventoryConsumption: [{ itemId: "open", units: 1 }] }),
|
||||
],
|
||||
items: [
|
||||
item({
|
||||
id: "open",
|
||||
state: "IN_USE",
|
||||
unitsRemaining: 3,
|
||||
firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY),
|
||||
}),
|
||||
],
|
||||
};
|
||||
const client = makeClient(state);
|
||||
await consumeForIntake(consumeArgs(client));
|
||||
await consumeForIntake(consumeArgs(client));
|
||||
|
||||
expect(state.items[0].unitsRemaining).toBe(3);
|
||||
expect(client.medicationInventoryItem.update).not.toHaveBeenCalled();
|
||||
expect(client.medicationIntakeEvent.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["pending", { takenAt: null }],
|
||||
["skipped", { skipped: true }],
|
||||
["tombstoned", { deletedAt: NOW }],
|
||||
])("never consumes for a %s row", async (_label, overrides) => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 1,
|
||||
events: [takenEvent(overrides as Partial<FakeEvent>)],
|
||||
items: [item({ id: "sealed" })],
|
||||
};
|
||||
const client = makeClient(state);
|
||||
await consumeForIntake(consumeArgs(client));
|
||||
|
||||
expect(state.items[0].unitsRemaining).toBe(10);
|
||||
expect(state.events[0].inventoryConsumption).toBeNull();
|
||||
});
|
||||
|
||||
it("swallows unexpected failures and annotates consume_error", async () => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 1,
|
||||
events: [takenEvent()],
|
||||
items: [item({ id: "sealed" })],
|
||||
};
|
||||
const client = makeClient(state);
|
||||
client.medicationInventoryItem.findMany.mockRejectedValue(
|
||||
new Error("connection lost"),
|
||||
);
|
||||
|
||||
await expect(consumeForIntake(consumeArgs(client))).resolves.toBeUndefined();
|
||||
expect(annotate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: { name: "medication.inventory.consume_error" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the caller's interactive transaction when the client carries one", async () => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 1,
|
||||
events: [takenEvent()],
|
||||
items: [item({ id: "sealed" })],
|
||||
};
|
||||
const inner = makeClient(state);
|
||||
const $transaction = vi.fn(async (fn: (tx: unknown) => Promise<void>) =>
|
||||
fn(inner),
|
||||
);
|
||||
await consumeForIntake({
|
||||
client: { ...inner, $transaction } as never,
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
eventId: "evt-1",
|
||||
intakeAt: NOW,
|
||||
});
|
||||
expect($transaction).toHaveBeenCalledTimes(1);
|
||||
expect(state.items[0].unitsRemaining).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreForIntake", () => {
|
||||
function restoreArgs(client: ReturnType<typeof makeClient>) {
|
||||
return { client: client as never, userId: "user-1", eventId: "evt-1" };
|
||||
}
|
||||
|
||||
it("refunds per stamp entry, re-derives the state and clears the stamp", async () => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 2,
|
||||
events: [
|
||||
takenEvent({
|
||||
inventoryConsumption: [{ itemId: "open", units: 2 }],
|
||||
}),
|
||||
],
|
||||
items: [
|
||||
item({
|
||||
id: "open",
|
||||
state: "USED_UP",
|
||||
unitsTotal: 4,
|
||||
unitsRemaining: 0,
|
||||
firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY),
|
||||
}),
|
||||
],
|
||||
};
|
||||
const client = makeClient(state);
|
||||
await restoreForIntake(restoreArgs(client));
|
||||
|
||||
expect(state.items[0].unitsRemaining).toBe(2);
|
||||
// USED_UP flips back to IN_USE — the window is still open.
|
||||
expect(state.items[0].state).toBe("IN_USE");
|
||||
expect(state.events[0].inventoryConsumption).toBeNull();
|
||||
expect(annotate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: { name: "medication.inventory.restored" },
|
||||
meta: expect.objectContaining({
|
||||
medication_id: "med-1",
|
||||
units: 2,
|
||||
item_ids: ["open"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps the refund to the item's capacity", async () => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 2,
|
||||
events: [
|
||||
takenEvent({
|
||||
inventoryConsumption: [{ itemId: "open", units: 2 }],
|
||||
}),
|
||||
],
|
||||
items: [
|
||||
item({
|
||||
id: "open",
|
||||
state: "IN_USE",
|
||||
unitsTotal: 4,
|
||||
// A stock correction raised the count since the take.
|
||||
unitsRemaining: 3,
|
||||
firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY),
|
||||
}),
|
||||
],
|
||||
};
|
||||
const client = makeClient(state);
|
||||
await restoreForIntake(restoreArgs(client));
|
||||
|
||||
expect(state.items[0].unitsRemaining).toBe(4);
|
||||
});
|
||||
|
||||
it("re-derives EXPIRED when the refunded container's window has lapsed meanwhile", async () => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 1,
|
||||
events: [
|
||||
takenEvent({
|
||||
inventoryConsumption: [{ itemId: "open", units: 1 }],
|
||||
}),
|
||||
],
|
||||
items: [
|
||||
item({
|
||||
id: "open",
|
||||
state: "USED_UP",
|
||||
unitsTotal: 4,
|
||||
unitsRemaining: 0,
|
||||
firstUseAt: new Date(NOW.getTime() - 35 * MS_PER_DAY),
|
||||
}),
|
||||
],
|
||||
};
|
||||
const client = makeClient(state);
|
||||
await restoreForIntake(restoreArgs(client));
|
||||
|
||||
expect(state.items[0].unitsRemaining).toBe(1);
|
||||
expect(state.items[0].state).toBe("EXPIRED");
|
||||
});
|
||||
|
||||
it("is a no-op when the row carries no stamp", async () => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 1,
|
||||
events: [takenEvent()],
|
||||
items: [item({ id: "open", unitsRemaining: 3 })],
|
||||
};
|
||||
const client = makeClient(state);
|
||||
await restoreForIntake(restoreArgs(client));
|
||||
|
||||
expect(state.items[0].unitsRemaining).toBe(3);
|
||||
expect(client.medicationInventoryItem.update).not.toHaveBeenCalled();
|
||||
expect(client.medicationIntakeEvent.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a stamped container that was deleted since, still clearing the stamp", async () => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 1,
|
||||
events: [
|
||||
takenEvent({
|
||||
inventoryConsumption: [
|
||||
{ itemId: "gone", units: 1 },
|
||||
{ itemId: "open", units: 1 },
|
||||
],
|
||||
}),
|
||||
],
|
||||
items: [
|
||||
item({
|
||||
id: "open",
|
||||
state: "IN_USE",
|
||||
unitsRemaining: 1,
|
||||
firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY),
|
||||
}),
|
||||
],
|
||||
};
|
||||
const client = makeClient(state);
|
||||
await restoreForIntake(restoreArgs(client));
|
||||
|
||||
expect(state.items[0].unitsRemaining).toBe(2);
|
||||
expect(state.events[0].inventoryConsumption).toBeNull();
|
||||
});
|
||||
|
||||
it("allows a re-take to consume afresh after a restore (skip → take cycle)", async () => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 1,
|
||||
events: [takenEvent()],
|
||||
items: [
|
||||
item({
|
||||
id: "open",
|
||||
state: "IN_USE",
|
||||
unitsRemaining: 4,
|
||||
unitsTotal: 4,
|
||||
firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY),
|
||||
}),
|
||||
],
|
||||
};
|
||||
const client = makeClient(state);
|
||||
|
||||
await consumeForIntake(consumeArgs(client));
|
||||
expect(state.items[0].unitsRemaining).toBe(3);
|
||||
|
||||
await restoreForIntake(restoreArgs(client));
|
||||
expect(state.items[0].unitsRemaining).toBe(4);
|
||||
expect(state.events[0].inventoryConsumption).toBeNull();
|
||||
|
||||
await consumeForIntake(consumeArgs(client));
|
||||
expect(state.items[0].unitsRemaining).toBe(3);
|
||||
expect(state.events[0].inventoryConsumption).toEqual([
|
||||
{ itemId: "open", units: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -14,118 +14,19 @@ vi.mock("@/lib/db", () => ({
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
buildCreateInventoryInput,
|
||||
consumeOneDose,
|
||||
expireStaleInUseItems,
|
||||
} from "../service";
|
||||
|
||||
const NOW = new Date("2026-05-14T12:00:00Z");
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("consumeOneDose", () => {
|
||||
it("returns null when no eligible pen exists for the medication", async () => {
|
||||
vi.mocked(prisma.medicationInventoryItem.findFirst).mockResolvedValueOnce(
|
||||
null,
|
||||
);
|
||||
vi.mocked(prisma.medicationInventoryItem.findFirst).mockResolvedValueOnce(
|
||||
null,
|
||||
);
|
||||
|
||||
const result = await consumeOneDose({
|
||||
userId: "u",
|
||||
medicationId: "m",
|
||||
intakeAt: NOW,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
expect(prisma.medicationInventoryItem.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flips an ACTIVE pen to IN_USE on first use", async () => {
|
||||
// No IN_USE pen, fall back to ACTIVE.
|
||||
vi.mocked(prisma.medicationInventoryItem.findFirst).mockResolvedValueOnce(
|
||||
null,
|
||||
);
|
||||
vi.mocked(prisma.medicationInventoryItem.findFirst).mockResolvedValueOnce({
|
||||
id: "inv-1",
|
||||
state: "ACTIVE",
|
||||
dosesTotal: 4,
|
||||
dosesRemaining: 4,
|
||||
firstUseAt: null,
|
||||
printedExpiry: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({} as never);
|
||||
|
||||
const result = await consumeOneDose({
|
||||
userId: "u",
|
||||
medicationId: "m",
|
||||
intakeAt: NOW,
|
||||
});
|
||||
expect(result).toEqual({ itemId: "inv-1", change: "first_use" });
|
||||
|
||||
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0][0];
|
||||
expect(updateCall.data).toMatchObject({
|
||||
state: "IN_USE",
|
||||
dosesRemaining: 3,
|
||||
firstUseAt: NOW,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the IN_USE pen with the earliest expiry", async () => {
|
||||
const firstUse = new Date(NOW.getTime() - 5 * MS_PER_DAY);
|
||||
vi.mocked(prisma.medicationInventoryItem.findFirst).mockResolvedValueOnce({
|
||||
id: "inv-active",
|
||||
state: "IN_USE",
|
||||
dosesTotal: 4,
|
||||
dosesRemaining: 3,
|
||||
firstUseAt: firstUse,
|
||||
printedExpiry: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({} as never);
|
||||
|
||||
const result = await consumeOneDose({
|
||||
userId: "u",
|
||||
medicationId: "m",
|
||||
intakeAt: NOW,
|
||||
});
|
||||
expect(result).toEqual({ itemId: "inv-active", change: "consumed" });
|
||||
expect(prisma.medicationInventoryItem.findFirst).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns 'depleted' when the final dose is consumed", async () => {
|
||||
const firstUse = new Date(NOW.getTime() - 5 * MS_PER_DAY);
|
||||
vi.mocked(prisma.medicationInventoryItem.findFirst).mockResolvedValueOnce({
|
||||
id: "inv-last",
|
||||
state: "IN_USE",
|
||||
dosesTotal: 4,
|
||||
dosesRemaining: 1,
|
||||
firstUseAt: firstUse,
|
||||
printedExpiry: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({} as never);
|
||||
|
||||
const result = await consumeOneDose({
|
||||
userId: "u",
|
||||
medicationId: "m",
|
||||
intakeAt: NOW,
|
||||
});
|
||||
expect(result).toEqual({ itemId: "inv-last", change: "depleted" });
|
||||
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0][0];
|
||||
expect(updateCall.data).toMatchObject({
|
||||
state: "USED_UP",
|
||||
dosesRemaining: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("expireStaleInUseItems", () => {
|
||||
it("transitions IN_USE rows whose deadline has lapsed via a single updateMany", async () => {
|
||||
// The selector `state = IN_USE AND expiresAt < now` already proves
|
||||
// the row must transition to EXPIRED (IN_USE implies dosesRemaining
|
||||
// the row must transition to EXPIRED (IN_USE implies unitsRemaining
|
||||
// > 0, and the expiresAt filter proves the deadline lapsed). Using
|
||||
// `updateMany` collapses N row round-trips into one.
|
||||
vi.mocked(prisma.medicationInventoryItem.updateMany).mockResolvedValue({
|
||||
@@ -174,15 +75,17 @@ describe("buildCreateInventoryInput", () => {
|
||||
const input = buildCreateInventoryInput({
|
||||
userId: "u",
|
||||
medicationId: "m",
|
||||
dosesTotal: 4,
|
||||
unitsTotal: 4,
|
||||
containerType: "PEN",
|
||||
printedExpiry: printed,
|
||||
purchasedAt: null,
|
||||
notes: null,
|
||||
});
|
||||
expect(input).toMatchObject({
|
||||
state: "ACTIVE",
|
||||
dosesTotal: 4,
|
||||
dosesRemaining: 4,
|
||||
containerType: "PEN",
|
||||
unitsTotal: 4,
|
||||
unitsRemaining: 4,
|
||||
firstUseAt: null,
|
||||
expiresAt: printed,
|
||||
});
|
||||
@@ -192,7 +95,8 @@ describe("buildCreateInventoryInput", () => {
|
||||
const input = buildCreateInventoryInput({
|
||||
userId: "u",
|
||||
medicationId: "m",
|
||||
dosesTotal: 4,
|
||||
unitsTotal: 4,
|
||||
containerType: "OTHER",
|
||||
printedExpiry: null,
|
||||
purchasedAt: null,
|
||||
notes: null,
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
computeExpiresAt,
|
||||
computeInventoryState,
|
||||
daysRemainingInUse,
|
||||
decrementDose,
|
||||
DEFAULT_IN_USE_WINDOW_DAYS,
|
||||
type InventoryItemView,
|
||||
} from "../state-machine";
|
||||
|
||||
@@ -16,8 +14,8 @@ const NOW_MS = NOW.getTime();
|
||||
function makeItem(overrides: Partial<InventoryItemView> = {}): InventoryItemView {
|
||||
return {
|
||||
state: "ACTIVE",
|
||||
dosesTotal: 4,
|
||||
dosesRemaining: 4,
|
||||
unitsTotal: 4,
|
||||
unitsRemaining: 4,
|
||||
firstUseAt: null,
|
||||
printedExpiry: null,
|
||||
...overrides,
|
||||
@@ -58,17 +56,17 @@ describe("computeInventoryState", () => {
|
||||
expect(computeInventoryState(item, NOW_MS)).toBe("EXPIRED");
|
||||
});
|
||||
|
||||
it("returns USED_UP when dosesRemaining is zero (terminal — outranks EXPIRED)", () => {
|
||||
it("returns USED_UP when unitsRemaining is zero (terminal — outranks EXPIRED)", () => {
|
||||
const item = makeItem({
|
||||
dosesRemaining: 0,
|
||||
unitsRemaining: 0,
|
||||
firstUseAt: new Date(NOW_MS - 31 * MS_PER_DAY),
|
||||
});
|
||||
expect(computeInventoryState(item, NOW_MS)).toBe("USED_UP");
|
||||
});
|
||||
|
||||
it("returns USED_UP even when printed expiry has lapsed (dosesRemaining wins)", () => {
|
||||
it("returns USED_UP even when printed expiry has lapsed (unitsRemaining wins)", () => {
|
||||
const item = makeItem({
|
||||
dosesRemaining: 0,
|
||||
unitsRemaining: 0,
|
||||
printedExpiry: new Date(NOW_MS - 5 * MS_PER_DAY),
|
||||
});
|
||||
expect(computeInventoryState(item, NOW_MS)).toBe("USED_UP");
|
||||
@@ -122,74 +120,6 @@ describe("computeExpiresAt", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("decrementDose", () => {
|
||||
it("first_use — flips ACTIVE → IN_USE and sets firstUseAt", () => {
|
||||
const item = makeItem();
|
||||
const result = decrementDose(item, NOW);
|
||||
|
||||
expect(result.change).toBe("first_use");
|
||||
expect(result.item.state).toBe("IN_USE");
|
||||
expect(result.item.firstUseAt).toEqual(NOW);
|
||||
expect(result.item.dosesRemaining).toBe(3);
|
||||
expect(result.item.expiresAt?.getTime()).toBe(
|
||||
NOW_MS + DEFAULT_IN_USE_WINDOW_DAYS * MS_PER_DAY,
|
||||
);
|
||||
});
|
||||
|
||||
it("consumed — IN_USE pen with doses left stays IN_USE", () => {
|
||||
const firstUse = new Date(NOW_MS - 3 * MS_PER_DAY);
|
||||
const item = makeItem({
|
||||
state: "IN_USE",
|
||||
dosesRemaining: 3,
|
||||
firstUseAt: firstUse,
|
||||
});
|
||||
const result = decrementDose(item, NOW);
|
||||
|
||||
expect(result.change).toBe("consumed");
|
||||
expect(result.item.state).toBe("IN_USE");
|
||||
expect(result.item.firstUseAt).toEqual(firstUse);
|
||||
expect(result.item.dosesRemaining).toBe(2);
|
||||
});
|
||||
|
||||
it("depleted — final dose flips state to USED_UP", () => {
|
||||
const firstUse = new Date(NOW_MS - 3 * MS_PER_DAY);
|
||||
const item = makeItem({
|
||||
state: "IN_USE",
|
||||
dosesRemaining: 1,
|
||||
firstUseAt: firstUse,
|
||||
});
|
||||
const result = decrementDose(item, NOW);
|
||||
|
||||
expect(result.change).toBe("depleted");
|
||||
expect(result.item.state).toBe("USED_UP");
|
||||
expect(result.item.dosesRemaining).toBe(0);
|
||||
});
|
||||
|
||||
it("does not mutate its input", () => {
|
||||
const firstUse = new Date(NOW_MS - 3 * MS_PER_DAY);
|
||||
const item = makeItem({
|
||||
state: "IN_USE",
|
||||
dosesRemaining: 3,
|
||||
firstUseAt: firstUse,
|
||||
});
|
||||
const snapshot = JSON.stringify(item);
|
||||
decrementDose(item, NOW);
|
||||
expect(JSON.stringify(item)).toBe(snapshot);
|
||||
});
|
||||
|
||||
it("clamps dosesRemaining at zero even if called on an already-empty pen", () => {
|
||||
const item = makeItem({
|
||||
state: "USED_UP",
|
||||
dosesRemaining: 0,
|
||||
firstUseAt: new Date(NOW_MS - 5 * MS_PER_DAY),
|
||||
});
|
||||
const result = decrementDose(item, NOW);
|
||||
|
||||
expect(result.item.dosesRemaining).toBe(0);
|
||||
expect(result.item.state).toBe("USED_UP");
|
||||
});
|
||||
});
|
||||
|
||||
describe("daysRemainingInUse", () => {
|
||||
it("returns null for an ACTIVE (unopened) pen", () => {
|
||||
expect(daysRemainingInUse(makeItem(), NOW_MS)).toBeNull();
|
||||
@@ -204,7 +134,7 @@ describe("daysRemainingInUse", () => {
|
||||
|
||||
it("returns null for a USED_UP pen", () => {
|
||||
const item = makeItem({
|
||||
dosesRemaining: 0,
|
||||
unitsRemaining: 0,
|
||||
firstUseAt: new Date(NOW_MS - 5 * MS_PER_DAY),
|
||||
});
|
||||
expect(daysRemainingInUse(item, NOW_MS)).toBeNull();
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* v1.16.10 — per-intake inventory consumption + restore.
|
||||
*
|
||||
* The single write path between intake events and inventory items.
|
||||
* Every seam that flips an intake event to TAKEN calls
|
||||
* `consumeForIntake`; every seam that takes a row OUT of taken
|
||||
* (skip toggle, edit, delete, bulk delete, slot-dedup tombstone)
|
||||
* calls `restoreForIntake`. No other module may write
|
||||
* `medicationInventoryItem` rows outside the inventory CRUD routes —
|
||||
* a convention test pins that.
|
||||
*
|
||||
* Exactly-once semantics hang off the consumption STAMP — the
|
||||
* `MedicationIntakeEvent.inventoryConsumption` Json column holding
|
||||
* `[{itemId, units}]`:
|
||||
*
|
||||
* - `consumeForIntake` is a no-op on a stamped row, so idempotent
|
||||
* replays (iOS sync, Telegram redelivery, double taps) can call it
|
||||
* freely and the stock only ever moves once per taken event.
|
||||
* - `restoreForIntake` refunds exactly what the stamp recorded and
|
||||
* clears it, so a later re-take consumes afresh. The stamp freezes
|
||||
* history: editing `Medication.unitsPerDose` after the fact never
|
||||
* changes what an already-taken dose consumed or refunds.
|
||||
*
|
||||
* Consumption is in UNITS (`Medication.unitsPerDose` per dose), pulled
|
||||
* from the open container first, then FEFO over unopened stock with
|
||||
* auto-open, spilling across containers, flooring at zero when the
|
||||
* tracked stock runs out (partial stamp — never negative, never an
|
||||
* error).
|
||||
*
|
||||
* Both functions are best-effort and NEVER throw: a failure annotates
|
||||
* (`medication.inventory.consume_error` / `.restore_error`) and
|
||||
* returns — the intake write is the source of truth, the inventory is
|
||||
* an opt-in companion that must never block a dose record.
|
||||
*/
|
||||
|
||||
import { Prisma, type PrismaClient } from "@/generated/prisma/client";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
computeExpiresAt,
|
||||
computeInventoryState,
|
||||
} from "./state-machine";
|
||||
|
||||
/** The Prisma surface the consumption hook needs. A bare transaction
|
||||
* client satisfies it; the base client additionally carries
|
||||
* `$transaction`, which the hook uses to keep the item updates and
|
||||
* the stamp write atomic. */
|
||||
type ConsumptionClient = Pick<
|
||||
PrismaClient,
|
||||
"medication" | "medicationIntakeEvent" | "medicationInventoryItem"
|
||||
> &
|
||||
Partial<Pick<PrismaClient, "$transaction">>;
|
||||
|
||||
type TxClient = Pick<
|
||||
PrismaClient,
|
||||
"medication" | "medicationIntakeEvent" | "medicationInventoryItem"
|
||||
>;
|
||||
|
||||
/** One consumed slice of the stamp: `units` taken from `itemId`. */
|
||||
export interface InventoryConsumptionEntry {
|
||||
itemId: string;
|
||||
units: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` atomically. When the caller hands us the base client we
|
||||
* open an interactive transaction; when it hands us a transaction
|
||||
* client (no `$transaction` on it) the caller's transaction already
|
||||
* provides the atomicity and we run inline.
|
||||
*/
|
||||
async function runAtomically<T>(
|
||||
client: ConsumptionClient,
|
||||
fn: (tx: TxClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (typeof client.$transaction === "function") {
|
||||
return client.$transaction((tx) => fn(tx as TxClient));
|
||||
}
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
/** Parse a stamp column value defensively — a malformed stamp refunds
|
||||
* nothing rather than throwing. */
|
||||
function parseStamp(value: Prisma.JsonValue | null): InventoryConsumptionEntry[] | null {
|
||||
if (value === null || !Array.isArray(value)) return null;
|
||||
const entries: InventoryConsumptionEntry[] = [];
|
||||
for (const raw of value) {
|
||||
if (
|
||||
typeof raw === "object" &&
|
||||
raw !== null &&
|
||||
!Array.isArray(raw) &&
|
||||
typeof (raw as { itemId?: unknown }).itemId === "string" &&
|
||||
typeof (raw as { units?: unknown }).units === "number" &&
|
||||
Number.isFinite((raw as { units: number }).units) &&
|
||||
(raw as { units: number }).units > 0
|
||||
) {
|
||||
entries.push({
|
||||
itemId: (raw as { itemId: string }).itemId,
|
||||
units: Math.floor((raw as { units: number }).units),
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume the medication's `unitsPerDose` units for one taken intake
|
||||
* event and stamp the event with what was consumed.
|
||||
*
|
||||
* No-op unless the event row is live (`deletedAt: null`), taken
|
||||
* (`takenAt` set), non-skipped, and UNSTAMPED — the stamp is the
|
||||
* exactly-once gate, so replays and re-posts never double-decrement.
|
||||
*
|
||||
* Container selection:
|
||||
* 1. the open container — IN_USE with `unitsRemaining > 0`, earliest
|
||||
* `expiresAt` first (createdAt tiebreak);
|
||||
* 2. then FEFO over unopened ACTIVE stock — `printedExpiry` asc with
|
||||
* nulls last, then `purchasedAt` asc (nulls last), then
|
||||
* `createdAt` asc. Pulling from an ACTIVE container auto-opens it
|
||||
* (`firstUseAt = intakeAt`, state via the state machine — a
|
||||
* backdated intake can land it straight in EXPIRED when the
|
||||
* 30-day window has already lapsed; that is correct).
|
||||
*
|
||||
* Consumption spills: when the current container holds fewer units
|
||||
* than still owed, it drains to zero and the next container covers the
|
||||
* rest. When the tracked stock runs out the consumption floors at
|
||||
* zero overall — the stamp records the partial amount, no error, no
|
||||
* negative counts.
|
||||
*/
|
||||
export async function consumeForIntake(input: {
|
||||
client: ConsumptionClient;
|
||||
userId: string;
|
||||
medicationId: string;
|
||||
eventId: string;
|
||||
intakeAt: Date;
|
||||
}): Promise<void> {
|
||||
const { client, userId, medicationId, eventId, intakeAt } = input;
|
||||
try {
|
||||
await runAtomically(client, async (tx) => {
|
||||
const event = await tx.medicationIntakeEvent.findFirst({
|
||||
where: { id: eventId, userId },
|
||||
select: {
|
||||
id: true,
|
||||
medicationId: true,
|
||||
takenAt: true,
|
||||
skipped: true,
|
||||
deletedAt: true,
|
||||
inventoryConsumption: true,
|
||||
},
|
||||
});
|
||||
if (
|
||||
!event ||
|
||||
event.deletedAt !== null ||
|
||||
event.takenAt === null ||
|
||||
event.skipped ||
|
||||
event.inventoryConsumption !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const medication = await tx.medication.findFirst({
|
||||
where: { id: medicationId, userId },
|
||||
select: { unitsPerDose: true },
|
||||
});
|
||||
if (!medication) return;
|
||||
const unitsPerDose = Math.max(1, medication.unitsPerDose);
|
||||
|
||||
// Candidate containers, in consumption order: the open container
|
||||
// first (earliest in-use deadline), then FEFO over unopened
|
||||
// stock. Both lists are loaded inside the transaction so the
|
||||
// updates apply to the rows just read.
|
||||
const inUse = await tx.medicationInventoryItem.findMany({
|
||||
where: {
|
||||
userId,
|
||||
medicationId,
|
||||
state: "IN_USE",
|
||||
unitsRemaining: { gt: 0 },
|
||||
},
|
||||
orderBy: [
|
||||
{ expiresAt: { sort: "asc", nulls: "last" } },
|
||||
{ createdAt: "asc" },
|
||||
],
|
||||
});
|
||||
const active = await tx.medicationInventoryItem.findMany({
|
||||
where: {
|
||||
userId,
|
||||
medicationId,
|
||||
state: "ACTIVE",
|
||||
unitsRemaining: { gt: 0 },
|
||||
},
|
||||
orderBy: [
|
||||
{ printedExpiry: { sort: "asc", nulls: "last" } },
|
||||
{ purchasedAt: { sort: "asc", nulls: "last" } },
|
||||
{ createdAt: "asc" },
|
||||
],
|
||||
});
|
||||
|
||||
const stamp: InventoryConsumptionEntry[] = [];
|
||||
const itemIds: string[] = [];
|
||||
let autoOpened = 0;
|
||||
let owed = unitsPerDose;
|
||||
// State derives at the WALL clock, not the intake instant: a
|
||||
// backdated take that auto-opens a container starts its in-use
|
||||
// window at the (past) intakeAt, so the container can land
|
||||
// straight in EXPIRED when that window has already lapsed by now
|
||||
// — correct, the daily cron would flip it on the next sweep
|
||||
// anyway.
|
||||
const nowMs = Date.now();
|
||||
|
||||
for (const item of [...inUse, ...active]) {
|
||||
if (owed <= 0) break;
|
||||
const take = Math.min(owed, item.unitsRemaining);
|
||||
if (take <= 0) continue;
|
||||
|
||||
const wasUnopened = item.firstUseAt === null;
|
||||
const nextFirstUseAt = item.firstUseAt ?? intakeAt;
|
||||
const nextUnitsRemaining = item.unitsRemaining - take;
|
||||
const nextExpiresAt = computeExpiresAt(
|
||||
nextFirstUseAt,
|
||||
item.printedExpiry,
|
||||
);
|
||||
const nextState = computeInventoryState(
|
||||
{
|
||||
state: item.state,
|
||||
unitsTotal: item.unitsTotal,
|
||||
unitsRemaining: nextUnitsRemaining,
|
||||
firstUseAt: nextFirstUseAt,
|
||||
printedExpiry: item.printedExpiry,
|
||||
},
|
||||
nowMs,
|
||||
);
|
||||
|
||||
await tx.medicationInventoryItem.update({
|
||||
where: { id: item.id },
|
||||
data: {
|
||||
state: nextState,
|
||||
unitsRemaining: nextUnitsRemaining,
|
||||
firstUseAt: nextFirstUseAt,
|
||||
expiresAt: nextExpiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
stamp.push({ itemId: item.id, units: take });
|
||||
itemIds.push(item.id);
|
||||
if (wasUnopened) autoOpened += 1;
|
||||
owed -= take;
|
||||
}
|
||||
|
||||
// Stamp the event even when nothing (or only part) could be
|
||||
// consumed: the stamp is the exactly-once gate, and a dose taken
|
||||
// with no tracked stock must not retro-consume containers
|
||||
// registered later when a sync replay revisits the row.
|
||||
await tx.medicationIntakeEvent.update({
|
||||
where: { id: event.id },
|
||||
data: { inventoryConsumption: stamp as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "medication.inventory.consumed" },
|
||||
meta: {
|
||||
medication_id: medicationId,
|
||||
units: unitsPerDose - owed,
|
||||
item_ids: itemIds,
|
||||
auto_opened: autoOpened,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
annotate({
|
||||
action: { name: "medication.inventory.consume_error" },
|
||||
meta: {
|
||||
medication_id: medicationId,
|
||||
event_id: eventId,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refund the consumption a stamped intake event recorded and clear
|
||||
* the stamp.
|
||||
*
|
||||
* No stamp = no-op (the row never consumed, or was already restored).
|
||||
* Each refund is clamped to the item's `unitsTotal` so a stock
|
||||
* correction in between can never overfill a container; the state
|
||||
* re-derives through the state machine, so a USED_UP container whose
|
||||
* units come back flips to IN_USE (or EXPIRED when its clock has
|
||||
* lapsed meanwhile). A stamped item that was deleted since is skipped.
|
||||
*/
|
||||
export async function restoreForIntake(input: {
|
||||
client: ConsumptionClient;
|
||||
userId: string;
|
||||
eventId: string;
|
||||
}): Promise<void> {
|
||||
const { client, userId, eventId } = input;
|
||||
try {
|
||||
await runAtomically(client, async (tx) => {
|
||||
const event = await tx.medicationIntakeEvent.findFirst({
|
||||
where: { id: eventId, userId },
|
||||
select: {
|
||||
id: true,
|
||||
medicationId: true,
|
||||
inventoryConsumption: true,
|
||||
},
|
||||
});
|
||||
if (!event) return;
|
||||
const stamp = parseStamp(event.inventoryConsumption);
|
||||
if (stamp === null || stamp.length === 0) {
|
||||
// Clear an empty/malformed stamp so a later re-take can
|
||||
// consume afresh; a NULL stamp is already clear.
|
||||
if (event.inventoryConsumption !== null) {
|
||||
await tx.medicationIntakeEvent.update({
|
||||
where: { id: event.id },
|
||||
data: { inventoryConsumption: Prisma.DbNull },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
let refunded = 0;
|
||||
const itemIds: string[] = [];
|
||||
for (const entry of stamp) {
|
||||
const item = await tx.medicationInventoryItem.findFirst({
|
||||
where: { id: entry.itemId, userId },
|
||||
});
|
||||
if (!item) continue; // container deleted since — nothing to refund.
|
||||
|
||||
const nextUnitsRemaining = Math.min(
|
||||
item.unitsTotal,
|
||||
item.unitsRemaining + entry.units,
|
||||
);
|
||||
const refundedHere = nextUnitsRemaining - item.unitsRemaining;
|
||||
const nextState = computeInventoryState(
|
||||
{
|
||||
state: item.state,
|
||||
unitsTotal: item.unitsTotal,
|
||||
unitsRemaining: nextUnitsRemaining,
|
||||
firstUseAt: item.firstUseAt,
|
||||
printedExpiry: item.printedExpiry,
|
||||
},
|
||||
nowMs,
|
||||
);
|
||||
await tx.medicationInventoryItem.update({
|
||||
where: { id: item.id },
|
||||
data: {
|
||||
state: nextState,
|
||||
unitsRemaining: nextUnitsRemaining,
|
||||
},
|
||||
});
|
||||
refunded += refundedHere;
|
||||
itemIds.push(item.id);
|
||||
}
|
||||
|
||||
await tx.medicationIntakeEvent.update({
|
||||
where: { id: event.id },
|
||||
data: { inventoryConsumption: Prisma.DbNull },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "medication.inventory.restored" },
|
||||
meta: {
|
||||
medication_id: event.medicationId,
|
||||
units: refunded,
|
||||
item_ids: itemIds,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
annotate({
|
||||
action: { name: "medication.inventory.restore_error" },
|
||||
meta: {
|
||||
event_id: eventId,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,97 +1,19 @@
|
||||
/**
|
||||
* v1.4.25 W19b — inventory persistence helpers.
|
||||
*
|
||||
* Thin layer on top of Prisma that the route handlers and the
|
||||
* intake hook compose. Keeps the state-machine math (pure) separate
|
||||
* from the I/O (here), so unit tests on the math don't need a DB.
|
||||
* Thin layer on top of Prisma that the route handlers compose. Keeps
|
||||
* the state-machine math (pure) separate from the I/O (here), so unit
|
||||
* tests on the math don't need a DB.
|
||||
*
|
||||
* v1.16.10 — the per-intake consumption hook moved to
|
||||
* `./consumption.ts` (multi-unit, FEFO, spillover, stamped on the
|
||||
* intake event); this module keeps the expire cron and the
|
||||
* create-input builder.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
computeExpiresAt,
|
||||
decrementDose,
|
||||
type DecrementOutcome,
|
||||
} from "./state-machine";
|
||||
|
||||
/**
|
||||
* Consume one dose from the user's active in-use pen for the given
|
||||
* medication. Called from the intake POST handler after the intake
|
||||
* event has been created.
|
||||
*
|
||||
* Selection policy:
|
||||
* 1. Prefer the IN_USE pen with the earliest `expiresAt` — that's
|
||||
* the pen the user is currently working through.
|
||||
* 2. Fall back to the oldest ACTIVE pen (FIFO consumption — the
|
||||
* pen that was purchased first gets opened first). The intake
|
||||
* flips it ACTIVE → IN_USE and sets firstUseAt.
|
||||
* 3. If no eligible pen exists, this is a no-op. The user just
|
||||
* logged an intake without a tracked pen — perfectly valid
|
||||
* since inventory is opt-in per the W19a doses-per-unit
|
||||
* design.
|
||||
*
|
||||
* The function intentionally only updates one pen per intake — if a
|
||||
* user double-doses (rare), they need to record two intake events
|
||||
* and the second one will pick up the same or a new pen.
|
||||
*/
|
||||
export async function consumeOneDose(input: {
|
||||
userId: string;
|
||||
medicationId: string;
|
||||
intakeAt: Date;
|
||||
}): Promise<{
|
||||
itemId: string;
|
||||
change: DecrementOutcome;
|
||||
} | null> {
|
||||
const { userId, medicationId, intakeAt } = input;
|
||||
|
||||
// Prefer the IN_USE pen with the earliest expiry; tie-break by
|
||||
// createdAt ascending.
|
||||
const inUsePen = await prisma.medicationInventoryItem.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
medicationId,
|
||||
state: "IN_USE",
|
||||
dosesRemaining: { gt: 0 },
|
||||
},
|
||||
orderBy: [{ expiresAt: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
|
||||
const target =
|
||||
inUsePen ??
|
||||
(await prisma.medicationInventoryItem.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
medicationId,
|
||||
state: "ACTIVE",
|
||||
dosesRemaining: { gt: 0 },
|
||||
},
|
||||
orderBy: [{ purchasedAt: "asc" }, { createdAt: "asc" }],
|
||||
}));
|
||||
|
||||
if (!target) return null;
|
||||
|
||||
const { item: nextValues, change } = decrementDose(
|
||||
{
|
||||
state: target.state,
|
||||
dosesTotal: target.dosesTotal,
|
||||
dosesRemaining: target.dosesRemaining,
|
||||
firstUseAt: target.firstUseAt,
|
||||
printedExpiry: target.printedExpiry,
|
||||
},
|
||||
intakeAt,
|
||||
);
|
||||
|
||||
await prisma.medicationInventoryItem.update({
|
||||
where: { id: target.id },
|
||||
data: {
|
||||
state: nextValues.state,
|
||||
dosesRemaining: nextValues.dosesRemaining,
|
||||
firstUseAt: nextValues.firstUseAt,
|
||||
expiresAt: nextValues.expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return { itemId: target.id, change };
|
||||
}
|
||||
import type { MedicationContainerType } from "@/generated/prisma/client";
|
||||
import { computeExpiresAt } from "./state-machine";
|
||||
|
||||
/**
|
||||
* Daily cron pass — flip any IN_USE rows whose 30-day window has
|
||||
@@ -111,10 +33,10 @@ export async function expireStaleInUseItems(input: {
|
||||
// selector + state-machine semantics line up here — every row that
|
||||
// matches `state = IN_USE AND expiresAt < now` is also a row that
|
||||
// `computeInventoryState` would classify EXPIRED (clause 2 or 3),
|
||||
// because IN_USE implies `dosesRemaining > 0` (decrementDose flips
|
||||
// an empty pen to USED_UP) and the `expiresAt` filter already
|
||||
// proves the printed-expiry-or-window deadline has lapsed. EXPIRED
|
||||
// rows are already terminal; USED_UP rows are off-window by
|
||||
// because IN_USE implies `unitsRemaining > 0` (the consumption hook
|
||||
// flips an empty container to USED_UP) and the `expiresAt` filter
|
||||
// already proves the printed-expiry-or-window deadline has lapsed.
|
||||
// EXPIRED rows are already terminal; USED_UP rows are off-window by
|
||||
// definition. Use a single `updateMany` instead of one update per
|
||||
// row — the daily cron sweep ran N round-trips for nothing.
|
||||
const result = await prisma.medicationInventoryItem.updateMany({
|
||||
@@ -136,7 +58,8 @@ export async function expireStaleInUseItems(input: {
|
||||
export function buildCreateInventoryInput(input: {
|
||||
userId: string;
|
||||
medicationId: string;
|
||||
dosesTotal: number;
|
||||
unitsTotal: number;
|
||||
containerType: MedicationContainerType;
|
||||
printedExpiry: Date | null;
|
||||
purchasedAt: Date | null;
|
||||
notes: string | null;
|
||||
@@ -145,8 +68,9 @@ export function buildCreateInventoryInput(input: {
|
||||
userId: input.userId,
|
||||
medicationId: input.medicationId,
|
||||
state: "ACTIVE" as const,
|
||||
dosesTotal: input.dosesTotal,
|
||||
dosesRemaining: input.dosesTotal,
|
||||
containerType: input.containerType,
|
||||
unitsTotal: input.unitsTotal,
|
||||
unitsRemaining: input.unitsTotal,
|
||||
firstUseAt: null,
|
||||
printedExpiry: input.printedExpiry,
|
||||
purchasedAt: input.purchasedAt,
|
||||
|
||||
@@ -2,16 +2,22 @@
|
||||
* v1.4.25 W19b — pen / vial inventory state machine.
|
||||
*
|
||||
* Pure functions over a minimal `InventoryItemView` shape — no DB
|
||||
* access, no Prisma imports. The route handlers and the daily expire
|
||||
* cron both compose this module with their own persistence layer.
|
||||
* access, no Prisma imports. The route handlers, the consumption hook
|
||||
* (`./consumption.ts`), and the daily expire cron all compose this
|
||||
* module with their own persistence layer.
|
||||
*
|
||||
* Decision tree (in evaluation order):
|
||||
* 1. dosesRemaining === 0 → USED_UP (terminal)
|
||||
* 1. unitsRemaining === 0 → USED_UP (terminal)
|
||||
* 2. printedExpiry && now > expiry → EXPIRED (printed label trumps clock)
|
||||
* 3. firstUseAt && now > firstUseAt + IN_USE_WINDOW_MS → EXPIRED
|
||||
* 4. firstUseAt is set → IN_USE
|
||||
* 5. otherwise → ACTIVE
|
||||
*
|
||||
* v1.16.10 — the item counts UNITS (tablets / ampoules / puffs), not
|
||||
* doses; `Medication.unitsPerDose` maps between the two. The state
|
||||
* 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*
|
||||
@@ -38,8 +44,8 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
*/
|
||||
export interface InventoryItemView {
|
||||
state: MedicationInventoryState;
|
||||
dosesTotal: number;
|
||||
dosesRemaining: number;
|
||||
unitsTotal: number;
|
||||
unitsRemaining: number;
|
||||
firstUseAt: Date | null;
|
||||
printedExpiry: Date | null;
|
||||
}
|
||||
@@ -49,26 +55,26 @@ export interface InventoryItemView {
|
||||
*
|
||||
* 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
|
||||
* without side-effects. The expire-cron and the intake-hook both use
|
||||
* this same evaluator; the only difference is what they do with the
|
||||
* result.
|
||||
* without side-effects. The expire-cron and the consumption hook both
|
||||
* use this same evaluator; the only difference is what they do with
|
||||
* the result.
|
||||
*/
|
||||
export function computeInventoryState(
|
||||
item: InventoryItemView,
|
||||
nowMs: number,
|
||||
inUseWindowDays: number = DEFAULT_IN_USE_WINDOW_DAYS,
|
||||
): MedicationInventoryState {
|
||||
// (1) Terminal — once a pen is used up, it stays used up. The
|
||||
// printed expiry on an empty pen is irrelevant; the EXPIRED state
|
||||
// exists to warn the user not to inject from a pen that's still
|
||||
// got doses but has gone stale, and an empty pen has neither
|
||||
// (1) Terminal — once a container is used up, it stays used up. The
|
||||
// printed expiry on an empty container is irrelevant; the EXPIRED
|
||||
// state exists to warn the user not to dose from a container that's
|
||||
// still got units but has gone stale, and an empty one has neither
|
||||
// problem.
|
||||
if (item.dosesRemaining <= 0) {
|
||||
if (item.unitsRemaining <= 0) {
|
||||
return "USED_UP";
|
||||
}
|
||||
|
||||
// (2) Printed expiry trumps the in-use clock. A pen whose carton
|
||||
// expiry has lapsed is EXPIRED even if it was never opened.
|
||||
// (2) Printed expiry trumps the in-use clock. A container whose
|
||||
// carton expiry has lapsed is EXPIRED even if it was never opened.
|
||||
if (item.printedExpiry && nowMs > item.printedExpiry.getTime()) {
|
||||
return "EXPIRED";
|
||||
}
|
||||
@@ -91,9 +97,9 @@ export function computeInventoryState(
|
||||
/**
|
||||
* Compute the `expiresAt` column. It's the min of the in-use
|
||||
* deadline (firstUseAt + window) and the printed expiry — whichever
|
||||
* lands first. If the pen 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.
|
||||
* 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.
|
||||
*/
|
||||
export function computeExpiresAt(
|
||||
firstUseAt: Date | null,
|
||||
@@ -112,93 +118,10 @@ export function computeExpiresAt(
|
||||
return inUseDeadline ?? printedExpiry ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of decrementing a dose. `first_use` fires when a pen
|
||||
* transitions from ACTIVE → IN_USE (this intake event was its first
|
||||
* use). `consumed` is the routine case. `depleted` fires on the
|
||||
* intake that brings dosesRemaining to zero.
|
||||
*/
|
||||
export type DecrementOutcome = "first_use" | "consumed" | "depleted";
|
||||
|
||||
/**
|
||||
* Result of `decrementDose`. The returned `item` is a fresh object —
|
||||
* the function does not mutate its input. The caller persists the
|
||||
* returned shape (typically via a `prisma.medicationInventoryItem.update`).
|
||||
*/
|
||||
export interface DecrementResult {
|
||||
item: {
|
||||
state: MedicationInventoryState;
|
||||
dosesRemaining: number;
|
||||
firstUseAt: Date | null;
|
||||
expiresAt: Date | null;
|
||||
};
|
||||
change: DecrementOutcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume one dose from a pen.
|
||||
*
|
||||
* If the pen is ACTIVE (never opened), this intake event sets
|
||||
* `firstUseAt = intakeAt` and starts the 30-day clock. If the pen
|
||||
* was already IN_USE, only `dosesRemaining` decrements. When the
|
||||
* last dose is taken, the state moves to USED_UP and `expiresAt`
|
||||
* is left untouched (the in-use window is no longer meaningful for
|
||||
* an empty pen, but persisting the deadline preserves the audit
|
||||
* trail).
|
||||
*
|
||||
* The function is defensive — if called on an EXPIRED or USED_UP
|
||||
* pen it still decrements (caller's job to gate). The state
|
||||
* recomputation respects whatever the final dose count + clock say.
|
||||
*/
|
||||
export function decrementDose(
|
||||
item: InventoryItemView,
|
||||
intakeAt: Date,
|
||||
inUseWindowDays: number = DEFAULT_IN_USE_WINDOW_DAYS,
|
||||
): DecrementResult {
|
||||
const wasFirstUse = item.firstUseAt === null;
|
||||
const nextFirstUseAt = item.firstUseAt ?? intakeAt;
|
||||
const nextDosesRemaining = Math.max(0, item.dosesRemaining - 1);
|
||||
const nextExpiresAt = computeExpiresAt(
|
||||
nextFirstUseAt,
|
||||
item.printedExpiry,
|
||||
inUseWindowDays,
|
||||
);
|
||||
|
||||
const projected: InventoryItemView = {
|
||||
state: item.state,
|
||||
dosesTotal: item.dosesTotal,
|
||||
dosesRemaining: nextDosesRemaining,
|
||||
firstUseAt: nextFirstUseAt,
|
||||
printedExpiry: item.printedExpiry,
|
||||
};
|
||||
const nextState = computeInventoryState(
|
||||
projected,
|
||||
intakeAt.getTime(),
|
||||
inUseWindowDays,
|
||||
);
|
||||
|
||||
const change: DecrementOutcome =
|
||||
nextDosesRemaining === 0
|
||||
? "depleted"
|
||||
: wasFirstUse
|
||||
? "first_use"
|
||||
: "consumed";
|
||||
|
||||
return {
|
||||
item: {
|
||||
state: nextState,
|
||||
dosesRemaining: nextDosesRemaining,
|
||||
firstUseAt: nextFirstUseAt,
|
||||
expiresAt: nextExpiresAt,
|
||||
},
|
||||
change,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-days remaining in the 30-day in-use window, rounded toward
|
||||
* zero. Returns null when the pen is not currently IN_USE (the UI
|
||||
* surface for ACTIVE / EXPIRED / USED_UP shows a different label).
|
||||
* 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
|
||||
@@ -229,7 +152,7 @@ export function daysRemainingInUse(
|
||||
// (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 && "dosesRemaining" in item) {
|
||||
if ("state" in item && "unitsRemaining" in item) {
|
||||
const state = computeInventoryState(
|
||||
{ ...item, firstUseAt },
|
||||
nowMs,
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
createMedicationSchema,
|
||||
updateMedicationSchema,
|
||||
intakeSchema,
|
||||
createInventoryItemSchema,
|
||||
updateInventoryItemSchema,
|
||||
MEDICATION_CATEGORY_VALUES,
|
||||
MEDICATION_CONTAINER_TYPE_VALUES,
|
||||
MEDICATION_TREATMENT_CLASS_VALUES,
|
||||
} from "@/lib/validations/medication";
|
||||
import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt";
|
||||
@@ -139,6 +142,12 @@ export const medicationResource = z
|
||||
.describe(
|
||||
"Doses per pen / vial for inventory tracking. NULL = inventory tracking off.",
|
||||
),
|
||||
unitsPerDose: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.",
|
||||
),
|
||||
active: z.boolean(),
|
||||
notificationsEnabled: z.boolean(),
|
||||
liveActivityEnabled: z
|
||||
@@ -236,6 +245,61 @@ const medicationDetailEntry = medicationResource
|
||||
"Detail variant of the medication resource enriched with the joined `category`. The base medication fields are inlined; see the `Medication` component for their semantics.",
|
||||
});
|
||||
|
||||
// v1.16.10 — per-container inventory entity (pen / blister pack /
|
||||
// bottle). Counts UNITS; the medication's `unitsPerDose` maps units to
|
||||
// doses. The intake consumption hook decrements `unitsRemaining` per
|
||||
// taken dose and stamps the intake event with what it consumed.
|
||||
const medicationInventoryItemResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
medicationId: z.string(),
|
||||
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).",
|
||||
),
|
||||
containerType: z
|
||||
.enum(MEDICATION_CONTAINER_TYPE_VALUES)
|
||||
.describe(
|
||||
"Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level classification; defaults to OTHER.",
|
||||
),
|
||||
unitsTotal: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"Units the container shipped with (tablets / ampoules / puffs; 1–1000). Dose-derived readouts divide by the medication's `unitsPerDose`.",
|
||||
),
|
||||
unitsRemaining: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"Units left in the container. Decremented by the intake consumption hook (FEFO with spillover across containers); refunded when a taken dose is skipped, edited away, or deleted.",
|
||||
),
|
||||
firstUseAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.describe(
|
||||
"Instant the container was first used. NULL until opened; starts the 30-day in-use clock.",
|
||||
),
|
||||
expiresAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.describe(
|
||||
"Persisted MIN(firstUseAt + 30 days, printedExpiry). NULL when neither clock has started.",
|
||||
),
|
||||
printedExpiry: z.iso.datetime({ offset: true }).nullable(),
|
||||
purchasedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
notes: z.string().nullable(),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
updatedAt: z.iso.datetime({ offset: true }),
|
||||
})
|
||||
.meta({
|
||||
id: "MedicationInventoryItem",
|
||||
description:
|
||||
"One supply container (pen / blister pack / bottle) of a medication. Counts UNITS — the medication's `unitsPerDose` maps units to doses. The intake write paths consume from the open container first, then first-expiry-first-out over unopened stock.",
|
||||
});
|
||||
|
||||
const medicationIntakeEventResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -792,6 +856,135 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/inventory": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
summary: "List a medication's supply containers",
|
||||
description:
|
||||
"Returns every inventory item (all states) for the medication, ordered by state, then `expiresAt`, then `createdAt`. Items count UNITS; divide by the medication's `unitsPerDose` for dose-level figures.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Inventory item list.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
items: z.array(medicationInventoryItemResource),
|
||||
meta: z.object({ total: z.number().int().nonnegative() }),
|
||||
}),
|
||||
"ListMedicationInventoryResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Medications"],
|
||||
summary: "Register a new supply container",
|
||||
description:
|
||||
"Creates an ACTIVE inventory item with `unitsRemaining = unitsTotal`. The request's `dosesTotal` field carries UNITS (1–1000). Rate-limited 30/min/user. Audits as `medication.inventory.create`.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: createInventoryItemSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Created inventory item.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationInventoryItemResource,
|
||||
"CreateMedicationInventoryItemResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/inventory/{itemId}": {
|
||||
patch: {
|
||||
tags: ["Medications"],
|
||||
summary: "Mutate a supply container",
|
||||
description:
|
||||
"Per-item operations: manual first-use (`markAsFirstUseAt`), used-up override (`markAsUsedUp`), printed-expiry correction, absolute remaining-unit correction (`dosesRemaining`, clamped to the item's capacity), notes. The canonical state machine re-derives the state after every mutation. Audits as `medication.inventory.update`.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string(), itemId: z.string() }),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: updateInventoryItemSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Updated inventory item.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationInventoryItemResource,
|
||||
"UpdateMedicationInventoryItemResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"Inventory item not found (or owned by another user / medication).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Medications"],
|
||||
summary: "Delete a supply container",
|
||||
description:
|
||||
"Hard-deletes the inventory item. The audit log captures the before-state (`medication.inventory.delete`) so a row can be reconstructed if needed. Consumption stamps on intake events that reference the item stay in place; a later restore skips the missing container.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string(), itemId: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Deletion succeeded.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ id: z.string(), deleted: z.boolean() }),
|
||||
"DeleteMedicationInventoryItemResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"Inventory item not found (or owned by another user / medication).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/extract": {
|
||||
post: {
|
||||
tags: ["Medications"],
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* v1.16.10 — inventory + units-per-dose validation bounds.
|
||||
*
|
||||
* Pins the raised unit caps (100 → 1000 on container capacity and the
|
||||
* stock correction; `dosesPerUnit` rides along) and the new
|
||||
* `unitsPerDose` (1–100) and `containerType` fields. The GLP-1 legacy
|
||||
* ledger delta deliberately KEEPS its ±100 bound — it counts pens, not
|
||||
* units.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createInventoryItemSchema,
|
||||
updateInventoryItemSchema,
|
||||
createMedicationSchema,
|
||||
updateMedicationSchema,
|
||||
glp1InventoryPostSchema,
|
||||
} from "../medication";
|
||||
|
||||
const MINIMAL_MEDICATION = {
|
||||
name: "Med",
|
||||
dose: "5 mg",
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("createInventoryItemSchema — unit cap 1000", () => {
|
||||
it("accepts 1000 units", () => {
|
||||
const r = createInventoryItemSchema.safeParse({ dosesTotal: 1000 });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects 1001 units", () => {
|
||||
const r = createInventoryItemSchema.safeParse({ dosesTotal: 1001 });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects zero", () => {
|
||||
const r = createInventoryItemSchema.safeParse({ dosesTotal: 0 });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts every container type and defaults to absent", () => {
|
||||
for (const ct of [
|
||||
"PEN",
|
||||
"AMPOULE",
|
||||
"BLISTER",
|
||||
"INHALER",
|
||||
"BOTTLE",
|
||||
"OTHER",
|
||||
]) {
|
||||
const r = createInventoryItemSchema.safeParse({
|
||||
dosesTotal: 4,
|
||||
containerType: ct,
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
}
|
||||
const r = createInventoryItemSchema.safeParse({ dosesTotal: 4 });
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.success && r.data.containerType).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects an unknown container type", () => {
|
||||
const r = createInventoryItemSchema.safeParse({
|
||||
dosesTotal: 4,
|
||||
containerType: "BAG",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateInventoryItemSchema — stock correction cap 1000", () => {
|
||||
it("accepts 1000", () => {
|
||||
const r = updateInventoryItemSchema.safeParse({ dosesRemaining: 1000 });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects 1001", () => {
|
||||
const r = updateInventoryItemSchema.safeParse({ dosesRemaining: 1001 });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("medication schemas — dosesPerUnit cap 1000 + unitsPerDose 1–100", () => {
|
||||
it("create accepts dosesPerUnit 1000 and rejects 1001", () => {
|
||||
expect(
|
||||
createMedicationSchema.safeParse({
|
||||
...MINIMAL_MEDICATION,
|
||||
dosesPerUnit: 1000,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
createMedicationSchema.safeParse({
|
||||
...MINIMAL_MEDICATION,
|
||||
dosesPerUnit: 1001,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("update accepts dosesPerUnit 1000 and rejects 1001", () => {
|
||||
expect(updateMedicationSchema.safeParse({ dosesPerUnit: 1000 }).success).toBe(
|
||||
true,
|
||||
);
|
||||
expect(updateMedicationSchema.safeParse({ dosesPerUnit: 1001 }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("create bounds unitsPerDose to 1–100", () => {
|
||||
expect(
|
||||
createMedicationSchema.safeParse({
|
||||
...MINIMAL_MEDICATION,
|
||||
unitsPerDose: 1,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
createMedicationSchema.safeParse({
|
||||
...MINIMAL_MEDICATION,
|
||||
unitsPerDose: 100,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
createMedicationSchema.safeParse({
|
||||
...MINIMAL_MEDICATION,
|
||||
unitsPerDose: 0,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
createMedicationSchema.safeParse({
|
||||
...MINIMAL_MEDICATION,
|
||||
unitsPerDose: 101,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
createMedicationSchema.safeParse({
|
||||
...MINIMAL_MEDICATION,
|
||||
unitsPerDose: 1.5,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("update bounds unitsPerDose to 1–100", () => {
|
||||
expect(updateMedicationSchema.safeParse({ unitsPerDose: 2 }).success).toBe(
|
||||
true,
|
||||
);
|
||||
expect(updateMedicationSchema.safeParse({ unitsPerDose: 0 }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
updateMedicationSchema.safeParse({ unitsPerDose: 101 }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("glp1InventoryPostSchema — the legacy pen-ledger delta keeps ±100", () => {
|
||||
it("accepts ±100 and rejects ±101", () => {
|
||||
expect(glp1InventoryPostSchema.safeParse({ delta: 100, reason: "purchased" }).success).toBe(true);
|
||||
expect(glp1InventoryPostSchema.safeParse({ delta: -100, reason: "damaged" }).success).toBe(true);
|
||||
expect(glp1InventoryPostSchema.safeParse({ delta: 101, reason: "purchased" }).success).toBe(false);
|
||||
expect(glp1InventoryPostSchema.safeParse({ delta: -101, reason: "damaged" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -456,8 +456,23 @@ export const createMedicationSchema = z
|
||||
category: z.enum(MEDICATION_CATEGORY_VALUES).optional(),
|
||||
/** v1.4.25 W4d — treatment-class discriminator (GENERIC | GLP1). */
|
||||
treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(),
|
||||
/** v1.4.25 W4d — doses per pen/vial for inventory tracking. */
|
||||
dosesPerUnit: z.number().int().min(1).max(100).optional(),
|
||||
/** v1.4.25 W4d — doses per pen/vial for inventory tracking.
|
||||
* v1.16.10 raises the cap to 1000 (large tablet packs). */
|
||||
dosesPerUnit: z.number().int().min(1).max(1000).optional(),
|
||||
/**
|
||||
* v1.16.10 — inventory units one dose consumes (2 × 2 mg tablets
|
||||
* for a 4 mg dose). Default 1. Dose-level readouts divide the
|
||||
* unit counts by this factor.
|
||||
*/
|
||||
unitsPerDose: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe(
|
||||
"Inventory units consumed per dose (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.",
|
||||
),
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(),
|
||||
/**
|
||||
@@ -528,7 +543,17 @@ export const updateMedicationSchema = z
|
||||
dose: z.string().min(1).max(50).optional(),
|
||||
category: z.enum(MEDICATION_CATEGORY_VALUES).optional(),
|
||||
treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(),
|
||||
dosesPerUnit: z.number().int().min(1).max(100).nullable().optional(),
|
||||
dosesPerUnit: z.number().int().min(1).max(1000).nullable().optional(),
|
||||
/** v1.16.10 — inventory units one dose consumes. Default 1. */
|
||||
unitsPerDose: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe(
|
||||
"Inventory units consumed per dose (e.g. 2 tablets of 2 mg for a 4 mg dose). The intake consumption hook decrements this many units per taken dose; already-stamped intake events keep their recorded consumption.",
|
||||
),
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(),
|
||||
/** v1.8.5 — per-medication injection-site tracking opt-in. */
|
||||
@@ -799,41 +824,103 @@ export type BulkDeleteIntakeEventsInput = z.infer<
|
||||
* USED_UP (manual override). EXPIRED is owned by the daily cron and
|
||||
* the intake hook, so the PATCH schema deliberately omits it.
|
||||
*/
|
||||
export const createInventoryItemSchema = z.object({
|
||||
dosesTotal: z.number().int().min(1).max(100),
|
||||
printedExpiry: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional(),
|
||||
purchasedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional(),
|
||||
notes: z.string().max(200).nullable().optional(),
|
||||
});
|
||||
/**
|
||||
* v1.16.10 — container kinds, mirrored from the Prisma
|
||||
* `MedicationContainerType` enum. Display-level classification only.
|
||||
*/
|
||||
export const MEDICATION_CONTAINER_TYPE_VALUES = [
|
||||
"PEN",
|
||||
"AMPOULE",
|
||||
"BLISTER",
|
||||
"INHALER",
|
||||
"BOTTLE",
|
||||
"OTHER",
|
||||
] as const;
|
||||
export type MedicationContainerTypeValue =
|
||||
(typeof MEDICATION_CONTAINER_TYPE_VALUES)[number];
|
||||
|
||||
export const updateInventoryItemSchema = z.object({
|
||||
markAsFirstUseAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
markAsUsedUp: z.boolean().optional(),
|
||||
printedExpiry: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional(),
|
||||
/**
|
||||
* v1.16.1 — stock correction (the Bestand tab's adjust / withdraw
|
||||
* flow). Sets the remaining-dose count directly; the route clamps to
|
||||
* `dosesTotal` and re-runs the canonical state machine (0 ⇒ USED_UP,
|
||||
* a raise out of 0 re-evaluates against the expiry clocks).
|
||||
*/
|
||||
dosesRemaining: z.number().int().min(0).max(100).optional(),
|
||||
notes: z.string().max(200).nullable().optional(),
|
||||
});
|
||||
export const createInventoryItemSchema = z
|
||||
.object({
|
||||
/** Units the container ships with. v1.16.10 raises the cap from
|
||||
* 100 to 1000 (large tablet packs); the wire field name stays
|
||||
* `dosesTotal` — it counts units, mapped to doses via
|
||||
* `Medication.unitsPerDose`. */
|
||||
dosesTotal: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(1000)
|
||||
.describe(
|
||||
"Units the container ships with (tablets / ampoules / puffs; 1–1000). Stored as `unitsTotal`; dose-derived readouts divide by the medication's `unitsPerDose`.",
|
||||
),
|
||||
/** v1.16.10 — container kind. Defaults to OTHER when absent. */
|
||||
containerType: z
|
||||
.enum(MEDICATION_CONTAINER_TYPE_VALUES)
|
||||
.optional()
|
||||
.describe(
|
||||
"Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level only; defaults to OTHER.",
|
||||
),
|
||||
printedExpiry: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional(),
|
||||
purchasedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional(),
|
||||
notes: z.string().max(200).nullable().optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "CreateMedicationInventoryItemRequest",
|
||||
description:
|
||||
"Register a new supply container (pen / blister pack / bottle). `dosesTotal` counts UNITS (1–1000); the item starts ACTIVE with `unitsRemaining = unitsTotal` and the intake consumption hook decrements it per taken dose.",
|
||||
});
|
||||
|
||||
export const updateInventoryItemSchema = z
|
||||
.object({
|
||||
markAsFirstUseAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.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.",
|
||||
),
|
||||
markAsUsedUp: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Terminal override: zero the remaining units and mark the container USED_UP (physically discarded).",
|
||||
),
|
||||
printedExpiry: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional(),
|
||||
/**
|
||||
* v1.16.1 — stock correction (the Bestand tab's adjust / withdraw
|
||||
* flow). Sets the remaining-unit count directly; the route clamps to
|
||||
* `unitsTotal` and re-runs the canonical state machine (0 ⇒ USED_UP,
|
||||
* a raise out of 0 re-evaluates against the expiry clocks).
|
||||
* v1.16.10 raises the cap to 1000 alongside the capacity cap.
|
||||
*/
|
||||
dosesRemaining: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(1000)
|
||||
.optional()
|
||||
.describe(
|
||||
"Absolute remaining-unit correction (0–1000). Clamped server-side to the item's `unitsTotal`; the canonical state machine re-derives the state (0 ⇒ USED_UP).",
|
||||
),
|
||||
notes: z.string().max(200).nullable().optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "UpdateMedicationInventoryItemRequest",
|
||||
description:
|
||||
"Per-item inventory mutation: manual first-use, used-up override, printed-expiry correction, absolute remaining-unit correction, notes. Every field is optional and commutative.",
|
||||
});
|
||||
|
||||
export type CreateInventoryItemInput = z.infer<
|
||||
typeof createInventoryItemSchema
|
||||
@@ -859,8 +946,9 @@ export type UpdateInventoryItemInput = z.infer<
|
||||
* - `effectiveFrom` is constrained to a ±5-year window around now —
|
||||
* a paper-record back-fill or a planned future step both fit, but
|
||||
* "1970" / "9999" do not.
|
||||
* - `delta` is a non-zero finite integer in [−100, 100] (the existing
|
||||
* inventory route caps `dosesTotal` at 100 doses per pen).
|
||||
* - `delta` is a non-zero finite integer in [−100, 100] — the legacy
|
||||
* ledger counts pens, and ±100 pens per correction stays plenty
|
||||
* (deliberately NOT raised with the v1.16.10 per-item unit cap).
|
||||
* - `reason` is a bounded string (the route logs it; raw blob bad).
|
||||
*/
|
||||
const MAX_DOSE_MG = 100;
|
||||
|
||||
Reference in new issue
Block a user