diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 706a2e76c..4cbe9afd1 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -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> = 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); diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts new file mode 100644 index 000000000..1d02714a6 --- /dev/null +++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts @@ -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 }); + }, +); diff --git a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts new file mode 100644 index 000000000..6838b9f25 --- /dev/null +++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts @@ -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" }, + }); + }); +}); diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts new file mode 100644 index 000000000..f9c68da38 --- /dev/null +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -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); + }, +); diff --git a/src/lib/medications/inventory/__tests__/service.test.ts b/src/lib/medications/inventory/__tests__/service.test.ts new file mode 100644 index 000000000..053a73e0e --- /dev/null +++ b/src/lib/medications/inventory/__tests__/service.test.ts @@ -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(); + }); +}); diff --git a/src/lib/medications/inventory/service.ts b/src/lib/medications/inventory/service.ts new file mode 100644 index 000000000..5ea29193e --- /dev/null +++ b/src/lib/medications/inventory/service.ts @@ -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 { + 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, + }; +} diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index 76ebad0c5..6d37f64af 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -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; +export type UpdateInventoryItemInput = z.infer; + export type CreateMedicationInput = z.infer; export type IntakeInput = z.infer; export type ListIntakeEventsInput = z.infer;