mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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:
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 },
|
||||
});
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user