fix(medications): make inventory Bestand summary server-authoritative

The medication detail clients computed the supply headline in the browser
via summariseSupply, risking web/iOS drift and pulling the shared math
into the client bundle. The GET /api/medications/[id]/inventory route now
computes the canonical SupplySummary server-side through summariseSupply
(the single source of truth) and ships it in the DTO; the Bestand and
Übersicht surfaces render the ready figures. Adds the MedicationSupplySummary
schema to the inventory list response and regenerates the OpenAPI contract.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 10:19:41 +02:00
1 parent 7540d0b508
commit 2386d1fe78
10 files changed
+292 -60

No files matched your search

@@ -122,6 +122,51 @@ describe("GET /api/medications/[id]/inventory", () => {
expect(body.data.items).toHaveLength(2);
expect(body.data.meta.total).toBe(2);
});
// v1.19.0 (iOS#25) — the GET response carries a server-computed supply
// summary so the detail-page clients render the canonical Bestand
// instead of re-deriving it. ACTIVE / IN_USE with units pool in;
// EXPIRED surfaces separately and never counts as available.
it("returns a server-computed supply summary (dose-derived, expired separate)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
unitsPerDose: 2,
} as never);
vi.mocked(prisma.medicationInventoryItem.findMany).mockResolvedValue([
{ id: "inv-1", state: "IN_USE", unitsTotal: 4, unitsRemaining: 3 },
{ id: "inv-2", state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 },
{ id: "inv-3", state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 },
{ id: "inv-4", state: "USED_UP", unitsTotal: 4, unitsRemaining: 0 },
] 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: {
summary: {
unitsRemaining: number;
unitsTotal: number;
dosesRemaining: number;
dosesTotal: number;
expiredUnits: number;
};
};
};
// 7 available units / 2 per dose = 3 doses; capacity 8 units = 4 doses;
// 4 units sit in the expired container (visible, never available).
expect(body.data.summary).toEqual({
unitsRemaining: 7,
unitsTotal: 8,
dosesRemaining: 3,
dosesTotal: 4,
expiredUnits: 4,
});
});
});
describe("POST /api/medications/[id]/inventory", () => {
@@ -30,6 +30,7 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { createInventoryItemSchema } from "@/lib/validations/medication";
import {
buildCreateInventoryInput,
buildSupplySummary,
serializeInventoryItem,
} from "@/lib/medications/inventory/service";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
@@ -48,10 +49,27 @@ export const GET = apiHandler(
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" }],
});
const [items, medication] = await Promise.all([
prisma.medicationInventoryItem.findMany({
where: { userId: user.id, medicationId: id },
orderBy: [{ state: "asc" }, { expiresAt: "asc" }, { createdAt: "asc" }],
}),
prisma.medication.findUnique({
where: { id },
select: { unitsPerDose: true },
}),
]);
const serialized = items.map(serializeInventoryItem);
// v1.19.0 (iOS#25) — the canonical supply summary is computed HERE,
// server-side, through the one source of truth (`summariseSupply`).
// The detail-page client renders these ready figures instead of
// re-deriving them in the browser, so web and iOS read identical
// numbers from the same DTO.
const summary = buildSupplySummary(
serialized,
medication?.unitsPerDose ? Number(medication.unitsPerDose) : 1,
);
annotate({
action: { name: "medication.inventory.list" },
@@ -59,7 +77,8 @@ export const GET = apiHandler(
});
return apiSuccess({
items: items.map(serializeInventoryItem),
items: serialized,
summary,
meta: { total: items.length },
});
},