mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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
+37
-1
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user