mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(api): medication-inventory CRUD + intake-event hook for dose decrement
This commit is contained in:
1 parent
4600c2385c
commit
d34c30eab6
7 files changed
+1056
-2
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
Reference in new issue
Block a user