feat(api): medication-inventory CRUD + intake-event hook for dose decrement

This commit is contained in:
Marc-André Bombeck committed 2026-05-14 18:46:49 +02:00
1 parent 4600c2385c
commit d34c30eab6
7 files changed
+1056 -2

No files matched your search

+47 -2
View File
@@ -13,6 +13,7 @@ import {
listIntakeEventsSchema,
} from "@/lib/validations/medication";
import { withIdempotency } from "@/lib/idempotency";
import { consumeOneDose } from "@/lib/medications/inventory/service";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -94,10 +95,45 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
: []),
]);
// v1.4.25 W19b — pen-inventory dose decrement. Only fires for
// non-skipped intakes; a skipped event is not a consumption event.
// No-op when the medication has no tracked pens (most non-GLP-1
// meds). Failures here must never block the intake write, so
// errors are swallowed and logged — the intake is the source of
// truth, the inventory is an opt-in companion.
let inventoryOutcome: Awaited<ReturnType<typeof consumeOneDose>> = null;
if (!skipped) {
try {
inventoryOutcome = await consumeOneDose({
userId: user.id,
medicationId: id,
intakeAt: event.takenAt ?? event.scheduledFor,
});
} catch (err) {
annotate({
action: { name: "medication.inventory.consume_error" },
meta: {
medication_id: id,
message: err instanceof Error ? err.message : String(err),
},
});
}
}
await auditLog("medication.intake", {
userId: user.id,
ipAddress: getClientIp(request),
details: { medicationId: id, eventId: event.id, skipped },
details: {
medicationId: id,
eventId: event.id,
skipped,
...(inventoryOutcome
? {
inventoryItemId: inventoryOutcome.itemId,
inventoryChange: inventoryOutcome.change,
}
: {}),
},
});
annotate({
@@ -106,7 +142,16 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
entity_type: "intake_event",
entity_id: event.id,
},
meta: { medication_id: id, skipped },
meta: {
medication_id: id,
skipped,
...(inventoryOutcome
? {
inventory_item_id: inventoryOutcome.itemId,
inventory_change: inventoryOutcome.change,
}
: {}),
},
});
return apiSuccess(event, 201);
@@ -0,0 +1,159 @@
/**
* v1.4.25 W19b — per-pen inventory item operations.
*
* PATCH /api/medications/[id]/inventory/[itemId]
* - mark-as-first-use (starts the 30-day clock manually)
* - mark-as-used-up (terminal — operator override when a pen is
* physically discarded but the dose ledger hasn't reached zero)
* - update printed expiry (e.g. carton label correction)
* - update notes
*
* DELETE /api/medications/[id]/inventory/[itemId]
* - hard delete; admin / cleanup path. The audit log captures
* the before-state so a row can be reconstructed if needed.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { updateInventoryItemSchema } from "@/lib/validations/medication";
import { computeExpiresAt } from "@/lib/medications/inventory/state-machine";
type RouteParams = { params: Promise<{ id: string; itemId: string }> };
async function loadOwnedItem(
medicationId: string,
itemId: string,
userId: string,
) {
const item = await prisma.medicationInventoryItem.findUnique({
where: { id: itemId },
});
if (!item || item.userId !== userId || item.medicationId !== medicationId) {
return null;
}
return item;
}
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id, itemId } = await params;
const existing = await loadOwnedItem(id, itemId, user.id);
if (!existing) return apiError("Inventory item not found", 404);
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = updateInventoryItemSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { markAsFirstUseAt, markAsUsedUp, printedExpiry, notes } =
parsed.data;
// Compose the next-row shape. Each mutation field is optional and
// commutative — applying them in any order produces the same row.
let nextFirstUseAt = existing.firstUseAt;
let nextState = existing.state;
let nextDosesRemaining = existing.dosesRemaining;
let nextPrintedExpiry = existing.printedExpiry;
if (markAsFirstUseAt) {
// The clock can be set manually if the user opened the pen
// without logging an intake event.
nextFirstUseAt = markAsFirstUseAt;
if (nextState === "ACTIVE") nextState = "IN_USE";
}
if (printedExpiry !== undefined) {
nextPrintedExpiry = printedExpiry;
}
if (markAsUsedUp === true) {
nextDosesRemaining = 0;
nextState = "USED_UP";
}
const nextExpiresAt = computeExpiresAt(nextFirstUseAt, nextPrintedExpiry);
const updated = await prisma.medicationInventoryItem.update({
where: { id: itemId },
data: {
state: nextState,
firstUseAt: nextFirstUseAt,
dosesRemaining: nextDosesRemaining,
printedExpiry: nextPrintedExpiry,
expiresAt: nextExpiresAt,
notes: notes === undefined ? existing.notes : notes,
},
});
await auditLog("medication.inventory.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
itemId,
prevState: existing.state,
nextState: updated.state,
},
});
annotate({
action: {
name: "medication.inventory.update",
entity_type: "inventory_item",
entity_id: itemId,
},
meta: { medication_id: id, prev: existing.state, next: updated.state },
});
return apiSuccess(updated);
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id, itemId } = await params;
const existing = await loadOwnedItem(id, itemId, user.id);
if (!existing) return apiError("Inventory item not found", 404);
await prisma.medicationInventoryItem.delete({ where: { id: itemId } });
await auditLog("medication.inventory.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
itemId,
finalState: existing.state,
dosesRemaining: existing.dosesRemaining,
},
});
annotate({
action: {
name: "medication.inventory.delete",
entity_type: "inventory_item",
entity_id: itemId,
},
meta: { medication_id: id },
});
return apiSuccess({ id: itemId, deleted: true });
},
);
@@ -0,0 +1,315 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationInventoryItem: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, POST } from "../route";
import { PATCH, DELETE } from "../[itemId]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
const MED_OK = { id: "med-1", userId: "user-1" };
function jsonReq(url: string, body: unknown, method = "POST"): NextRequest {
return new NextRequest(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
});
});
describe("GET /api/medications/[id]/inventory", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/inventory"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the medication is not owned by the caller", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "OTHER",
} as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/inventory"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns the inventory list for the owned medication", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationInventoryItem.findMany).mockResolvedValue([
{ id: "inv-1", state: "ACTIVE" },
{ id: "inv-2", state: "IN_USE" },
] as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/inventory"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { items: unknown[]; meta: { total: number } };
};
expect(body.data.items).toHaveLength(2);
expect(body.data.meta.total).toBe(2);
});
});
describe("POST /api/medications/[id]/inventory", () => {
it("rejects unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
dosesTotal: 4,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 422 when dosesTotal is missing or invalid", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
dosesTotal: 0,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("creates an ACTIVE pen with computed expiresAt", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({
id: "inv-new",
state: "ACTIVE",
} as never);
const printed = "2027-06-01T00:00:00Z";
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
dosesTotal: 4,
printedExpiry: printed,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: "user-1",
medicationId: "med-1",
state: "ACTIVE",
dosesTotal: 4,
dosesRemaining: 4,
firstUseAt: null,
printedExpiry: new Date(printed),
expiresAt: new Date(printed),
}),
});
});
it("returns 429 when the rate limit is exceeded", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
dosesTotal: 4,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(429);
});
});
describe("PATCH /api/medications/[id]/inventory/[itemId]", () => {
const existingActive = {
id: "inv-1",
userId: "user-1",
medicationId: "med-1",
state: "ACTIVE" as const,
dosesTotal: 4,
dosesRemaining: 4,
firstUseAt: null,
printedExpiry: null,
purchasedAt: null,
notes: null,
expiresAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
it("flips ACTIVE → IN_USE on markAsFirstUseAt", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue(
existingActive as never,
);
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
...existingActive,
state: "IN_USE",
} as never);
const intake = "2026-05-14T12:00:00Z";
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsFirstUseAt: intake },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0];
expect(updateCall.data).toMatchObject({
state: "IN_USE",
firstUseAt: new Date(intake),
});
});
it("flips state to USED_UP and zeros remaining on markAsUsedUp", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
...existingActive,
state: "IN_USE",
firstUseAt: new Date("2026-05-01"),
dosesRemaining: 2,
} as never);
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
...existingActive,
state: "USED_UP",
dosesRemaining: 0,
} as never);
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsUsedUp: true },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0];
expect(updateCall.data).toMatchObject({
state: "USED_UP",
dosesRemaining: 0,
});
});
it("rejects 404 when the item belongs to another user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
...existingActive,
userId: "OTHER",
} as never);
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsUsedUp: true },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(404);
});
});
describe("DELETE /api/medications/[id]/inventory/[itemId]", () => {
it("hard-deletes the row and audit-logs the final state", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
id: "inv-1",
userId: "user-1",
medicationId: "med-1",
state: "EXPIRED",
dosesRemaining: 2,
} as never);
vi.mocked(prisma.medicationInventoryItem.delete).mockResolvedValue({
id: "inv-1",
} as never);
const res = await DELETE(
new NextRequest("http://localhost/api/medications/med-1/inventory/inv-1", {
method: "DELETE",
}),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
expect(prisma.medicationInventoryItem.delete).toHaveBeenCalledWith({
where: { id: "inv-1" },
});
});
});
@@ -0,0 +1,138 @@
/**
* v1.4.25 W19b — pen / vial inventory CRUD.
*
* GET /api/medications/[id]/inventory — list (user-scoped, all states)
* POST /api/medications/[id]/inventory — register a new pen / vial
*
* Per-item operations live on the `[itemId]` sub-route.
*
* Auth: cookie-session via requireAuth(); the medication is verified
* to belong to the caller before any read/write touches inventory.
* Rate-limit 30/min/user on the POST path (creation is the only
* surface that can spam writes; GET is read-only and benign).
* Audit-log every mutation with the affected itemId.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { createInventoryItemSchema } from "@/lib/validations/medication";
import { buildCreateInventoryInput } from "@/lib/medications/inventory/service";
type RouteParams = { params: Promise<{ id: string }> };
const POST_RATE_LIMIT = 30;
const POST_WINDOW_MS = 60_000;
async function assertMedicationOwnership(
medicationId: string,
userId: string,
) {
const med = await prisma.medication.findUnique({
where: { id: medicationId },
select: { id: true, userId: true },
});
if (!med || med.userId !== userId) {
return apiError("Medication not found", 404);
}
return null;
}
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const items = await prisma.medicationInventoryItem.findMany({
where: { userId: user.id, medicationId: id },
orderBy: [{ state: "asc" }, { expiresAt: "asc" }, { createdAt: "asc" }],
});
annotate({
action: { name: "medication.inventory.list" },
meta: { medication_id: id, total: items.length },
});
return apiSuccess({ items, meta: { total: items.length } });
},
);
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
// Per-user POST rate-limit — 30/min is generous for normal pen
// registrations but cuts off the spam case.
const rl = await checkRateLimit(
`medication-inventory:post:${user.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = createInventoryItemSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { dosesTotal, printedExpiry, purchasedAt, notes } = parsed.data;
const created = await prisma.medicationInventoryItem.create({
data: buildCreateInventoryInput({
userId: user.id,
medicationId: id,
dosesTotal,
printedExpiry: printedExpiry ?? null,
purchasedAt: purchasedAt ?? null,
notes: notes ?? null,
}),
});
await auditLog("medication.inventory.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
itemId: created.id,
dosesTotal,
},
});
annotate({
action: {
name: "medication.inventory.create",
entity_type: "inventory_item",
entity_id: created.id,
},
meta: { medication_id: id },
});
return apiSuccess(created, 201);
},
);
@@ -0,0 +1,189 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
medicationInventoryItem: {
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
},
}));
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 30-day window has lapsed to EXPIRED", async () => {
const firstUseAgo = new Date(NOW.getTime() - 31 * MS_PER_DAY);
vi.mocked(prisma.medicationInventoryItem.findMany).mockResolvedValue([
{
id: "inv-stale",
state: "IN_USE",
dosesTotal: 4,
dosesRemaining: 2,
firstUseAt: firstUseAgo,
printedExpiry: null,
},
] as never);
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({} as never);
const count = await expireStaleInUseItems({ nowMs: NOW.getTime() });
expect(count).toBe(1);
expect(prisma.medicationInventoryItem.update).toHaveBeenCalledWith({
where: { id: "inv-stale" },
data: { state: "EXPIRED" },
});
});
it("scopes to a single user when userId is given", async () => {
vi.mocked(prisma.medicationInventoryItem.findMany).mockResolvedValue([] as never);
await expireStaleInUseItems({ userId: "user-1", nowMs: NOW.getTime() });
expect(prisma.medicationInventoryItem.findMany).toHaveBeenCalledWith({
where: expect.objectContaining({ userId: "user-1" }),
select: expect.anything(),
});
});
});
describe("buildCreateInventoryInput", () => {
it("computes expiresAt = printedExpiry when firstUseAt is null", () => {
const printed = new Date("2027-06-01T00:00:00Z");
const input = buildCreateInventoryInput({
userId: "u",
medicationId: "m",
dosesTotal: 4,
printedExpiry: printed,
purchasedAt: null,
notes: null,
});
expect(input).toMatchObject({
state: "ACTIVE",
dosesTotal: 4,
dosesRemaining: 4,
firstUseAt: null,
expiresAt: printed,
});
});
it("computes expiresAt = null when neither firstUseAt nor printedExpiry is set", () => {
const input = buildCreateInventoryInput({
userId: "u",
medicationId: "m",
dosesTotal: 4,
printedExpiry: null,
purchasedAt: null,
notes: null,
});
expect(input.expiresAt).toBeNull();
});
});
+167
View File
@@ -0,0 +1,167 @@
/**
* 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.
*/
import { prisma } from "@/lib/db";
import {
computeExpiresAt,
computeInventoryState,
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 };
}
/**
* Daily cron pass — flip any IN_USE rows whose 30-day window has
* elapsed into EXPIRED. Returns the count of transitions for logging.
*
* Optionally scoped to a single user — useful for the per-user
* trigger path. Cron with `userId: null` sweeps every user.
*/
export async function expireStaleInUseItems(input: {
userId?: string;
nowMs: number;
}): Promise<number> {
const { userId, nowMs } = input;
const now = new Date(nowMs);
// Candidates: IN_USE rows whose expiresAt has lapsed. EXPIRED rows
// are already terminal; USED_UP rows are off-window by definition.
const candidates = await prisma.medicationInventoryItem.findMany({
where: {
state: "IN_USE",
expiresAt: { lt: now },
...(userId ? { userId } : {}),
},
select: {
id: true,
state: true,
dosesTotal: true,
dosesRemaining: true,
firstUseAt: true,
printedExpiry: true,
},
});
let transitions = 0;
for (const row of candidates) {
const nextState = computeInventoryState(row, nowMs);
if (nextState === row.state) continue;
await prisma.medicationInventoryItem.update({
where: { id: row.id },
data: { state: nextState },
});
transitions += 1;
}
return transitions;
}
/**
* Helper for the POST /[medId]/inventory route. Composes the
* Prisma-create with the computed `expiresAt` so the caller doesn't
* have to re-derive it.
*/
export function buildCreateInventoryInput(input: {
userId: string;
medicationId: string;
dosesTotal: number;
printedExpiry: Date | null;
purchasedAt: Date | null;
notes: string | null;
}) {
return {
userId: input.userId,
medicationId: input.medicationId,
state: "ACTIVE" as const,
dosesTotal: input.dosesTotal,
dosesRemaining: input.dosesTotal,
firstUseAt: null,
printedExpiry: input.printedExpiry,
purchasedAt: input.purchasedAt,
expiresAt: computeExpiresAt(null, input.printedExpiry),
notes: input.notes,
};
}
+41
View File
@@ -104,6 +104,47 @@ export const updateIntakeEventSchema = z.object({
.optional(),
});
/**
* v1.4.25 W19b — inventory (pen / vial) CRUD validators.
*
* The Prisma model carries a 4-state enum
* (ACTIVE | IN_USE | EXPIRED | USED_UP); the API surface only lets
* the user explicitly transition into IN_USE (mark-as-first-use) or
* 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(),
});
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(),
notes: z.string().max(200).nullable().optional(),
});
export type CreateInventoryItemInput = z.infer<typeof createInventoryItemSchema>;
export type UpdateInventoryItemInput = z.infer<typeof updateInventoryItemSchema>;
export type CreateMedicationInput = z.infer<typeof createMedicationSchema>;
export type IntakeInput = z.infer<typeof intakeSchema>;
export type ListIntakeEventsInput = z.infer<typeof listIntakeEventsSchema>;