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

+37 -1
View File
@@ -2620,7 +2620,10 @@ paths:
- Medications
summary: List a medication's supply containers
description: Returns every inventory item (all states) for the medication, ordered by state, then `expiresAt`, then
`createdAt`. Items count UNITS; divide by the medication's `unitsPerDose` for dose-level figures.
`createdAt`. Items count UNITS; divide by the medication's `unitsPerDose` for dose-level figures. v1.19.0
(iOS#25) — also returns a server-computed `summary` (the canonical {`unitsRemaining`, `unitsTotal`,
`dosesRemaining`, `dosesTotal`, `expiredUnits`}); clients render it directly rather than re-deriving the Bestand
headline, so web and iOS agree.
parameters:
- in: path
name: id
@@ -12794,6 +12797,8 @@ components:
type: array
items:
$ref: "#/components/schemas/MedicationInventoryItem"
summary:
$ref: "#/components/schemas/MedicationSupplySummary"
meta:
type: object
properties:
@@ -12806,6 +12811,7 @@ components:
additionalProperties: false
required:
- items
- summary
- meta
additionalProperties: false
error:
@@ -12922,6 +12928,36 @@ components:
description: One supply container (pen / blister pack / bottle) of a medication. Counts UNITS — the medication's
`unitsPerDose` maps units to doses. The intake write paths consume from the open container first, then
first-expiry-first-out over unopened stock.
MedicationSupplySummary:
type: object
properties:
unitsRemaining:
type: number
description: Pooled units across available (ACTIVE / IN_USE, units left) containers. Floored at 0 — a corrupt / legacy
negative row can never surface a negative Bestand.
unitsTotal:
type: number
description: Pooled capacity across the same available containers.
dosesRemaining:
type: number
description: "Dose-derived headline: `floor(unitsRemaining / unitsPerDose)` (whole doses; a partial dose is not a dose)."
dosesTotal:
type: number
description: "Dose-derived capacity: `floor(unitsTotal / unitsPerDose)`."
expiredUnits:
type: number
description: Units still sitting in EXPIRED containers — visible to the user as a muted suffix, never folded into the
available headline or the runway estimate.
required:
- unitsRemaining
- unitsTotal
- dosesRemaining
- dosesTotal
- expiredUnits
additionalProperties: false
description: Server-authoritative supply summary for a medication's containers. Computed from the same availability
predicate the medications-list payload and the GLP-1 endpoint use, so every surface agrees on what 'remaining'
means.
CreateMedicationInventoryItemResponse:
type: object
properties:
@@ -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 },
});
},
@@ -7,12 +7,19 @@
* suffix — matching the list payload and the GLP-1 endpoint instead of
* counting expired stock as supply.
*
* v1.19.0 (iOS#25) — the headline is now server-authoritative: the GET
* response carries a `summary` computed server-side via `summariseSupply`,
* and the component RENDERS it (no client-side derivation). The seed
* builds that summary with the same canonical helper the server uses, so
* these scenarios assert the component renders the server DTO faithfully.
*
* Project convention is SSR-only component tests (`renderToStaticMarkup`).
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { summariseSupply } from "@/lib/medications/inventory/summary";
const useQueryMock = vi.fn();
@@ -35,9 +42,27 @@ function render(node: React.ReactNode): string {
);
}
function seedItems(items: unknown[]) {
function seedItems(
items: Array<{
state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
unitsTotal: number;
unitsRemaining: number;
[key: string]: unknown;
}>,
unitsPerDose = 1,
) {
// The server computes the canonical summary; reproduce it here with the
// one source of truth so the seed mirrors the real DTO the route ships.
const summary = summariseSupply(
items.map((i) => ({
state: i.state,
unitsTotal: i.unitsTotal,
unitsRemaining: i.unitsRemaining,
})),
unitsPerDose,
);
useQueryMock.mockReturnValue({
data: { items, meta: { total: items.length } },
data: { items, summary, meta: { total: items.length } },
isLoading: false,
});
}
@@ -72,7 +72,7 @@ import { SideEffectsSection } from "@/components/medications/side-effects-sectio
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { TitrationTimeline } from "@/components/medications/titration-timeline";
import { estimateRunwayDays } from "@/components/medications/detail/supply-runway";
import { summariseSupply } from "@/lib/medications/inventory/summary";
import type { SupplySummary } from "@/lib/medications/inventory/summary";
import { MedicationWizardDialog } from "@/components/medications/wizard/medication-wizard-dialog";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { useQuery } from "@tanstack/react-query";
@@ -335,9 +335,8 @@ export function MedicationDetailTabs({
return await apiGet<{
items: Array<{
state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
unitsTotal: number;
unitsRemaining: number;
}>;
summary?: SupplySummary;
} | null>(`/api/medications/${id}/inventory`);
} catch {
return null;
@@ -348,26 +347,34 @@ export function MedicationDetailTabs({
});
const inventoryItems = Array.isArray(inventory?.items) ? inventory.items : [];
// v1.16.10 — items count UNITS; the supply readout and the runway are
// dose-derived (floor over the pooled units — consumption spills
// across containers, so the pool is the honest dose count). The raw
// unit tally renders as secondary text when a dose spans > 1 unit.
// Availability rides the shared summary helper (ACTIVE / IN_USE with
// units only — the list / GLP-1 semantic); expired stock surfaces as
// a separate muted suffix and never feeds the runway.
// v1.16.12 — guard at > 0, NOT ≥ 1, so a fractional unitsPerDose (½
// tablet per dose) stays fractional instead of halving the dose counts.
const perDose =
medication.unitsPerDose && medication.unitsPerDose > 0
? medication.unitsPerDose
: 1;
// v1.19.0 (iOS#25) — the supply headline is server-authoritative: the
// GET response carries a `summary` computed server-side through the
// shared `summariseSupply` helper (ACTIVE / IN_USE with units only —
// the list / GLP-1 semantic; EXPIRED surfaced separately, never
// available; dose-derived counts floored over the pooled units). The
// Übersicht status row renders these ready figures, so web and iOS read
// identical numbers from the same DTO. The runway estimate is a UI
// projection over the canonical `dosesRemaining`.
const {
unitsRemaining,
unitsTotal,
dosesRemaining,
dosesTotal,
expiredUnits,
} = summariseSupply(inventoryItems, perDose);
} = inventory?.summary ?? {
unitsRemaining: 0,
unitsTotal: 0,
dosesRemaining: 0,
dosesTotal: 0,
expiredUnits: 0,
};
// v1.16.12 — fractional unitsPerDose (½ tablet per dose) drives the
// "show the raw unit tally" toggle below; the canonical figures above
// already account for it server-side.
const perDose =
medication.unitsPerDose && medication.unitsPerDose > 0
? medication.unitsPerDose
: 1;
const runwayDays = estimateRunwayDays(dosesRemaining, medication.schedules);
return (
@@ -76,7 +76,7 @@ import {
apiPost,
apiPut,
} from "@/lib/api/api-fetch";
import { summariseSupply } from "@/lib/medications/inventory/summary";
import type { SupplySummary } from "@/lib/medications/inventory/summary";
/**
* Invalidate every read key whose payload reflects a medication's supply
@@ -127,6 +127,11 @@ interface InventoryItem {
interface InventoryResponse {
items: InventoryItem[];
// v1.19.0 (iOS#25) — server-authoritative supply summary. The headline
// figures are computed server-side via the shared `summariseSupply`
// helper and shipped ready; the client renders them rather than
// re-deriving in the browser, so web and iOS agree on the Bestand.
summary?: SupplySummary;
meta?: { total: number };
}
@@ -232,28 +237,19 @@ export function InventorySection({
}
const items = Array.isArray(data?.items) ? data.items : [];
// Available supply: ACTIVE / IN_USE containers with units left — the
// shared summary helper keeps this row, the Übersicht row, the list
// payload and the GLP-1 endpoint on one predicate. Expired stock is
// never available; it renders as a separate muted suffix.
// Dose-derived headline (floor — a partial dose is not a dose).
// v1.18.3 (iOS#31) — an unknown-units row (null) contributes nothing to
// the available-supply math; it shows "—" in its own row but cannot pad
// the pooled headline. Coalesce null → 0 only for the summary inputs.
const {
unitsRemaining: remainingUnits,
unitsTotal: totalUnits,
dosesRemaining: remaining,
dosesTotal: total,
expiredUnits,
} = summariseSupply(
items.map((item) => ({
state: item.state,
unitsTotal: item.unitsTotal ?? 0,
unitsRemaining: item.unitsRemaining ?? 0,
})),
perDose,
);
// v1.19.0 (iOS#25) — the supply headline is server-authoritative: the
// GET response carries a `summary` computed server-side through the
// shared `summariseSupply` helper (ACTIVE / IN_USE with units left;
// EXPIRED surfaced separately, never available; dose-derived counts
// floored). The client renders these ready figures so web and iOS show
// identical numbers. The per-item rows below still divide by `perDose`
// locally, which is presentation only — the canonical pool is the DTO.
const summary = data?.summary;
const remainingUnits = summary?.unitsRemaining ?? 0;
const totalUnits = summary?.unitsTotal ?? 0;
const remaining = summary?.dosesRemaining ?? 0;
const total = summary?.dosesTotal ?? 0;
const expiredUnits = summary?.expiredUnits ?? 0;
const dialogs = (
<>
@@ -140,32 +140,57 @@ describe("summariseSupply / detectSupplyUnderflow — central negative-stock san
});
});
describe("supply surfaces — both detail-page readouts ride the shared helper (source guard)", () => {
describe("supply surfaces — server computes, the detail-page clients render the DTO (source guard)", () => {
// v1.19.0 (iOS#25) — server-authoritative parity. The detail-page
// clients used to call `summariseSupply` in the browser; that risked
// web ↔ iOS drift and dragged the shared math into the client bundle.
// The SERVER now computes the canonical summary via the one source of
// truth and ships it in the DTO; the clients render it. This guard is
// re-pointed at the new seam: (1) the GET inventory route computes the
// summary through `summariseSupply`, (2) neither client imports the
// helper as a value, (3) both consume the server `summary` field.
const read = (rel: string) => readFileSync(join(process.cwd(), rel), "utf8");
it("the Übersicht supply row sums through summariseSupply and feeds the runway with the available-only figure", () => {
it("the SERVER computes the summary through summariseSupply (single source of truth)", () => {
const service = read("src/lib/medications/inventory/service.ts");
expect(service).toContain('from "./summary"');
expect(service).toMatch(/summariseSupply\(/);
expect(service).toContain("buildSupplySummary");
const route = read("src/app/api/medications/[id]/inventory/route.ts");
// The GET response carries the server-computed summary in the DTO.
expect(route).toContain("buildSupplySummary");
expect(route).toMatch(/summary,/);
});
it("the Übersicht client renders the server summary and never imports the helper as a value", () => {
const src = read(
"src/components/medications/detail/medication-detail-tabs.tsx",
);
expect(src).toContain('from "@/lib/medications/inventory/summary"');
expect(src).toMatch(/summariseSupply\(inventoryItems, perDose\)/);
// Type-only import is allowed; a value import of the helper is not.
expect(src).toContain(
'import type { SupplySummary } from "@/lib/medications/inventory/summary"',
);
expect(src).not.toMatch(/^import \{[^}]*summariseSupply/m);
// The client consumes the server-provided summary, not a local
// derivation, and still drives the runway with the available figure.
expect(src).toMatch(/inventory\?\.summary/);
expect(src).toMatch(
/estimateRunwayDays\(dosesRemaining, medication\.schedules\)/,
);
// No hand-rolled "everything but USED_UP" filter remains.
expect(src).not.toContain('state !== "USED_UP"');
expect(src).toContain("medications.detail.bestand.expiredSuffix");
});
it("the Bestand summary sums through summariseSupply and surfaces the expired suffix", () => {
it("the Bestand client renders the server summary and never imports the helper as a value", () => {
const src = read(
"src/components/medications/sections/inventory-section.tsx",
);
expect(src).toContain('from "@/lib/medications/inventory/summary"');
// v1.18.3 (iOS#31) — the items are mapped through a nullable→0 coalesce
// (an unknown-units row contributes nothing to the available headline)
// before the shared helper, but the summary still rides summariseSupply.
expect(src).toMatch(/summariseSupply\(\s*items\.map\(/);
expect(src).toContain(
'import type { SupplySummary } from "@/lib/medications/inventory/summary"',
);
expect(src).not.toMatch(/^import \{[^}]*summariseSupply/m);
expect(src).toMatch(/data\?\.summary/);
expect(src).not.toContain('state !== "USED_UP"');
expect(src).toContain("medications.detail.bestand.expiredSuffix");
});
+42
View File
@@ -14,6 +14,11 @@
import { prisma } from "@/lib/db";
import type { MedicationContainerType } from "@/generated/prisma/client";
import { computeExpiresAt } from "./state-machine";
import {
summariseSupply,
type SupplyItemState,
type SupplySummary,
} from "./summary";
/**
* Daily cron pass — flip any IN_USE rows whose 30-day window has
@@ -89,6 +94,43 @@ export function serializeInventoryItem<
};
}
/**
* v1.19.0 (iOS#25) — server-authoritative supply summary for the
* inventory list response. The detail page used to compute the Bestand
* headline in the browser (calling `summariseSupply` client-side), which
* both risked web ↔ iOS drift and dragged the shared math into the
* client bundle. The SERVER now computes the canonical
* {@link SupplySummary} via the one source of truth (`summariseSupply`)
* and ships it in the DTO; the client renders the ready figures.
*
* The serialized items carry `unitsTotal` / `unitsRemaining` as
* `number | null` (null = unknown count for a corrupt / legacy row).
* A null contributes nothing to the available pool — coalesce to 0 for
* the summary inputs only, exactly as the former client code did, so the
* surfaced numbers stay identical to today's for healthy data while an
* unknown-count row still cannot pad the headline.
*/
export function buildSupplySummary(
items: ReadonlyArray<{
state: SupplyItemState;
unitsTotal: number | null;
unitsRemaining: number | null;
}>,
unitsPerDose: number | null | undefined,
): SupplySummary {
// v1.16.12 — guard at > 0, NOT ≥ 1: a fractional unitsPerDose (½ tablet
// per dose) must stay fractional, else the dose-derived counts halve.
const perDose = unitsPerDose && unitsPerDose > 0 ? unitsPerDose : 1;
return summariseSupply(
items.map((item) => ({
state: item.state,
unitsTotal: item.unitsTotal ?? 0,
unitsRemaining: item.unitsRemaining ?? 0,
})),
perDose,
);
}
/**
* Helper for the POST /[medId]/inventory route. Composes the
* Prisma-create with the computed `expiresAt` so the caller doesn't
+3 -1
View File
@@ -22,6 +22,7 @@ import {
medicationListEntry,
medicationDetailEntry,
medicationInventoryItemResource,
medicationSupplySummaryResource,
medicationIntakeEventResource,
medicationCadenceResponse,
medicationComplianceResponse,
@@ -285,7 +286,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Medications"],
summary: "List a medication's supply containers",
description:
"Returns every inventory item (all states) for the medication, ordered by state, then `expiresAt`, then `createdAt`. Items count UNITS; divide by the medication's `unitsPerDose` for dose-level figures.",
"Returns every inventory item (all states) for the medication, ordered by state, then `expiresAt`, then `createdAt`. Items count UNITS; divide by the medication's `unitsPerDose` for dose-level figures. v1.19.0 (iOS#25) — also returns a server-computed `summary` (the canonical {`unitsRemaining`, `unitsTotal`, `dosesRemaining`, `dosesTotal`, `expiredUnits`}); clients render it directly rather than re-deriving the Bestand headline, so web and iOS agree.",
requestParams: {
path: z.object({ id: z.string() }),
},
@@ -297,6 +298,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
schema: dataEnvelope(
z.object({
items: z.array(medicationInventoryItemResource),
summary: medicationSupplySummaryResource,
meta: z.object({ total: z.number().int().nonnegative() }),
}),
"ListMedicationInventoryResponse",
@@ -319,6 +319,41 @@ export const medicationInventoryItemResource = z
"One supply container (pen / blister pack / bottle) of a medication. Counts UNITS — the medication's `unitsPerDose` maps units to doses. The intake write paths consume from the open container first, then first-expiry-first-out over unopened stock.",
});
// v1.19.0 (iOS#25) — server-computed canonical supply summary returned
// alongside the inventory list. Replaces the former client-side
// derivation so web and iOS render identical Bestand figures from one
// DTO. Pools ACTIVE / IN_USE containers with units left; EXPIRED stock
// is surfaced separately and never counts as available.
export const medicationSupplySummaryResource = z
.object({
unitsRemaining: z
.number()
.describe(
"Pooled units across available (ACTIVE / IN_USE, units left) containers. Floored at 0 — a corrupt / legacy negative row can never surface a negative Bestand.",
),
unitsTotal: z
.number()
.describe("Pooled capacity across the same available containers."),
dosesRemaining: z
.number()
.describe(
"Dose-derived headline: `floor(unitsRemaining / unitsPerDose)` (whole doses; a partial dose is not a dose).",
),
dosesTotal: z
.number()
.describe("Dose-derived capacity: `floor(unitsTotal / unitsPerDose)`."),
expiredUnits: z
.number()
.describe(
"Units still sitting in EXPIRED containers — visible to the user as a muted suffix, never folded into the available headline or the runway estimate.",
),
})
.meta({
id: "MedicationSupplySummary",
description:
"Server-authoritative supply summary for a medication's containers. Computed from the same availability predicate the medications-list payload and the GLP-1 endpoint use, so every surface agrees on what 'remaining' means.",
});
export const medicationIntakeEventResource = z
.object({
id: z.string(),