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);
},
);