From b132244c03e3b3ccdcc705c4639bc8d99a39a90f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 17 Jun 2026 14:09:04 +0200 Subject: [PATCH] fix(medications): serialise unknown inventory as null, not 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An inventory item's unit count comes from a non-null Decimal column, but a corrupt or legacy row could hold a missing or non-finite value. The serialiser ran `Number(item.unitsTotal)`, and `Number(null) === 0` (and `Number("corrupt") === NaN`), so an unknown count went out on the wire as a fabricated 0 that a client could decrement into negatives. Serialise a genuinely-unknown count (null / non-finite) as `null` so clients render "unknown" instead. A real tracked 0 stays 0. No model change — the distinction is serialisation-only. The supply tab now renders an unknown-units row as "—", pre-fills the correction editor empty, drops the capacity ceiling when total is unknown, and keeps such a row out of the pooled available-supply headline. The low-stock alert already excludes non-usable rows, so it does not fire on unknown stock. OpenAPI marks both unit fields nullable. --- docs/api/openapi.yaml | 15 +++-- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../sections/inventory-section.tsx | 66 +++++++++++++------ .../inventory/__tests__/service.test.ts | 53 +++++++++++++++ .../inventory/__tests__/summary.test.ts | 5 +- src/lib/medications/inventory/service.ts | 25 +++++-- src/lib/openapi/routes/medications.ts | 6 +- 12 files changed, 146 insertions(+), 30 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index abbfb3407..177d98ff8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -11972,14 +11972,21 @@ components: description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level classification; defaults to OTHER. unitsTotal: - type: number + anyOf: + - type: number + - type: "null" description: Units the container shipped with (tablets / ampoules / puffs; 1–1000). v1.16.12 — fractional, so a - split-pill remainder reads cleanly. Dose-derived readouts divide by the medication's `unitsPerDose`. + split-pill remainder reads cleanly. Dose-derived readouts divide by the medication's `unitsPerDose`. v1.18.3 + (iOS#31) — NULL when the unit count is unknown (a corrupt or legacy row); the client renders "unknown", + never a fabricated 0. unitsRemaining: - type: number + anyOf: + - type: number + - type: "null" description: Units left in the container. v1.16.12 — fractional (a ½-tablet dose leaves 29.5 of 30). Decremented by the intake consumption hook (FEFO with spillover across containers); refunded when a taken dose is skipped, - edited away, or deleted. + edited away, or deleted. v1.18.3 (iOS#31) — NULL when the count is unknown (a corrupt or legacy row); the + client renders "unknown", never a fabricated 0 it could decrement into negatives. firstUseAt: anyOf: - type: string diff --git a/messages/de.json b/messages/de.json index 7b3ec3201..139d03c8b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1667,6 +1667,7 @@ "summary": "{remaining} von {total} Dosen übrig", "itemsTitle": "Packungen & Behälter", "doses": "{remaining} / {total} Dosen", + "unknown": "Unbekannt", "state": { "ACTIVE": "Ungeöffnet", "IN_USE": "In Gebrauch", diff --git a/messages/en.json b/messages/en.json index acc413ff5..0b58849d5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1667,6 +1667,7 @@ "summary": "{remaining} of {total} doses remaining", "itemsTitle": "Packs & containers", "doses": "{remaining} / {total} doses", + "unknown": "Unknown", "state": { "ACTIVE": "Unopened", "IN_USE": "In use", diff --git a/messages/es.json b/messages/es.json index 6b52dc580..97cf511b4 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1667,6 +1667,7 @@ "summary": "{remaining} de {total} dosis restantes", "itemsTitle": "Envases y recipientes", "doses": "{remaining} / {total} dosis", + "unknown": "Desconocido", "state": { "ACTIVE": "Sin abrir", "IN_USE": "En uso", diff --git a/messages/fr.json b/messages/fr.json index ce784865e..128938064 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1667,6 +1667,7 @@ "summary": "{remaining} doses restantes sur {total}", "itemsTitle": "Boîtes et contenants", "doses": "{remaining} / {total} doses", + "unknown": "Inconnu", "state": { "ACTIVE": "Non ouvert", "IN_USE": "En cours", diff --git a/messages/it.json b/messages/it.json index ffa5817d9..846289e43 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1667,6 +1667,7 @@ "summary": "{remaining} dosi rimanenti su {total}", "itemsTitle": "Confezioni e contenitori", "doses": "{remaining} / {total} dosi", + "unknown": "Sconosciuto", "state": { "ACTIVE": "Non aperto", "IN_USE": "In uso", diff --git a/messages/pl.json b/messages/pl.json index b2f7a9f2c..21b95ffc9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1667,6 +1667,7 @@ "summary": "Pozostało {remaining} z {total} dawek", "itemsTitle": "Opakowania i pojemniki", "doses": "{remaining} / {total} dawek", + "unknown": "Nieznane", "state": { "ACTIVE": "Nieotwarte", "IN_USE": "W użyciu", diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index b36e11b92..28a769047 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -90,8 +90,10 @@ interface InventoryItem { id: string; state: InventoryState; containerType: ContainerType; - unitsTotal: number; - unitsRemaining: number; + // v1.18.3 (iOS#31) — null = unknown unit count (corrupt / legacy row); + // the UI renders "—" instead of a fabricated 0. + unitsTotal: number | null; + unitsRemaining: number | null; } interface InventoryResponse { @@ -203,13 +205,23 @@ export function InventorySection({ // 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, perDose); + } = summariseSupply( + items.map((item) => ({ + state: item.state, + unitsTotal: item.unitsTotal ?? 0, + unitsRemaining: item.unitsRemaining ?? 0, + })), + perDose, + ); const dialogs = ( <> @@ -370,19 +382,25 @@ export function InventorySection({ inline at meta-text size — read-only, never a control. */} - {t("medications.detail.bestand.doses", { - remaining: Math.floor(item.unitsRemaining / perDose), - total: Math.floor(item.unitsTotal / perDose), - })} - {perDose !== 1 && ( - <> - {" · "} - {t("medications.detail.bestand.unitsDetail", { - remaining: formatUnitCount(item.unitsRemaining), - total: formatUnitCount(item.unitsTotal), + {/* v1.18.3 (iOS#31) — an unknown unit count (null) renders + "—" rather than a fabricated 0-dose figure. */} + {item.unitsRemaining == null || item.unitsTotal == null + ? t("medications.detail.bestand.unknown") + : t("medications.detail.bestand.doses", { + remaining: Math.floor(item.unitsRemaining / perDose), + total: Math.floor(item.unitsTotal / perDose), })} - - )} + {perDose !== 1 && + item.unitsRemaining != null && + item.unitsTotal != null && ( + <> + {" · "} + {t("medications.detail.bestand.unitsDetail", { + remaining: formatUnitCount(item.unitsRemaining), + total: formatUnitCount(item.unitsTotal), + })} + + )} = 0 && parsed <= item.unitsTotal; + value.trim() !== "" && + Number.isFinite(parsed) && + parsed >= 0 && + (item.unitsTotal == null || parsed <= item.unitsTotal); async function submit(e: FormEvent) { e.preventDefault(); @@ -704,7 +730,7 @@ function AdjustInventoryDialog({ type="number" inputMode="decimal" min={0} - max={item.unitsTotal} + max={item.unitsTotal ?? undefined} step="any" required autoComplete="off" @@ -717,7 +743,9 @@ function AdjustInventoryDialog({ className="text-muted-foreground text-xs" > {t("medications.detail.bestand.adjustHelper", { - total: item.unitsTotal, + total: + item.unitsTotal ?? + t("medications.detail.bestand.unknown"), })}

diff --git a/src/lib/medications/inventory/__tests__/service.test.ts b/src/lib/medications/inventory/__tests__/service.test.ts index 9ac65a5dd..20509e5ae 100644 --- a/src/lib/medications/inventory/__tests__/service.test.ts +++ b/src/lib/medications/inventory/__tests__/service.test.ts @@ -15,6 +15,7 @@ import { prisma } from "@/lib/db"; import { buildCreateInventoryInput, expireStaleInUseItems, + serializeInventoryItem, } from "../service"; const NOW = new Date("2026-05-14T12:00:00Z"); @@ -104,3 +105,55 @@ describe("buildCreateInventoryInput", () => { expect(input.expiresAt).toBeNull(); }); }); + +describe("serializeInventoryItem (iOS#31)", () => { + it("serialises a Decimal-as-string unit count to a JSON number", () => { + const out = serializeInventoryItem({ + id: "i1", + unitsTotal: "30", + unitsRemaining: "29.5", + }); + expect(out.unitsTotal).toBe(30); + expect(out.unitsRemaining).toBe(29.5); + }); + + it("keeps a genuine tracked zero as 0, not null", () => { + const out = serializeInventoryItem({ + id: "i2", + unitsTotal: 4, + unitsRemaining: 0, + }); + expect(out.unitsRemaining).toBe(0); + expect(out.unitsTotal).toBe(4); + }); + + it("serialises an unknown (null) unit count as null, not a fabricated 0", () => { + const out = serializeInventoryItem({ + id: "i3", + unitsTotal: null, + unitsRemaining: null, + }); + expect(out.unitsTotal).toBeNull(); + expect(out.unitsRemaining).toBeNull(); + }); + + it("rejects non-finite corrupt values (NaN / Infinity) to null", () => { + const out = serializeInventoryItem({ + id: "i4", + unitsTotal: "not-a-number", + unitsRemaining: Infinity, + }); + expect(out.unitsTotal).toBeNull(); + expect(out.unitsRemaining).toBeNull(); + }); + + it("preserves every other field unchanged", () => { + const out = serializeInventoryItem({ + id: "i5", + state: "ACTIVE", + unitsTotal: "10", + unitsRemaining: "10", + }); + expect(out).toMatchObject({ id: "i5", state: "ACTIVE" }); + }); +}); diff --git a/src/lib/medications/inventory/__tests__/summary.test.ts b/src/lib/medications/inventory/__tests__/summary.test.ts index 480fee16b..816810317 100644 --- a/src/lib/medications/inventory/__tests__/summary.test.ts +++ b/src/lib/medications/inventory/__tests__/summary.test.ts @@ -108,7 +108,10 @@ describe("supply surfaces — both detail-page readouts ride the shared helper ( "src/components/medications/sections/inventory-section.tsx", ); expect(src).toContain('from "@/lib/medications/inventory/summary"'); - expect(src).toMatch(/summariseSupply\(items, perDose\)/); + // 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).not.toContain('state !== "USED_UP"'); expect(src).toContain("medications.detail.bestand.expiredSuffix"); }); diff --git a/src/lib/medications/inventory/service.ts b/src/lib/medications/inventory/service.ts index f46d8cf60..dd4e8810d 100644 --- a/src/lib/medications/inventory/service.ts +++ b/src/lib/medications/inventory/service.ts @@ -56,19 +56,36 @@ export async function expireStaleInUseItems(input: { * `unitsTotal` / `unitsRemaining` as numbers. Convert at every response * boundary that returns an item, so a fractional remainder (29.5 after a * half-tablet dose) lands as a JSON number, never `"29.5"`. + * + * v1.18.3 (iOS#31) — serialise a genuinely-unknown unit count as `null`, + * not `0`. A well-formed row always carries both Decimals, but a corrupt + * or legacy row could hold null / NaN / Infinity; `Number(null) === 0` + * (and `Number("x") === NaN`) would otherwise fabricate a misleading `0` + * that the client decrements into negatives. `null` lets the client + * render "unbekannt" instead. A real tracked `0` stays `0`. */ +function toFiniteUnit(value: unknown): number | null { + // `Number(null) === 0` and `Number("") === 0`, so reject the empty / + // nullish cases up front — a genuinely-absent count must read as null, + // not a fabricated 0. Anything else goes through the finiteness gate + // (NaN / Infinity from a corrupt string also fall to null). + if (value === null || value === undefined || value === "") return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + export function serializeInventoryItem< T extends { unitsTotal: unknown; unitsRemaining: unknown }, >( item: T, ): Omit & { - unitsTotal: number; - unitsRemaining: number; + unitsTotal: number | null; + unitsRemaining: number | null; } { return { ...item, - unitsTotal: Number(item.unitsTotal), - unitsRemaining: Number(item.unitsRemaining), + unitsTotal: toFiniteUnit(item.unitsTotal), + unitsRemaining: toFiniteUnit(item.unitsRemaining), }; } diff --git a/src/lib/openapi/routes/medications.ts b/src/lib/openapi/routes/medications.ts index 396d48b0c..33ad8265c 100644 --- a/src/lib/openapi/routes/medications.ts +++ b/src/lib/openapi/routes/medications.ts @@ -296,13 +296,15 @@ const medicationInventoryItemResource = z ), unitsTotal: z .number() + .nullable() .describe( - "Units the container shipped with (tablets / ampoules / puffs; 1–1000). v1.16.12 — fractional, so a split-pill remainder reads cleanly. Dose-derived readouts divide by the medication's `unitsPerDose`.", + "Units the container shipped with (tablets / ampoules / puffs; 1–1000). v1.16.12 — fractional, so a split-pill remainder reads cleanly. Dose-derived readouts divide by the medication's `unitsPerDose`. v1.18.3 (iOS#31) — NULL when the unit count is unknown (a corrupt or legacy row); the client renders \"unknown\", never a fabricated 0.", ), unitsRemaining: z .number() + .nullable() .describe( - "Units left in the container. v1.16.12 — fractional (a ½-tablet dose leaves 29.5 of 30). Decremented by the intake consumption hook (FEFO with spillover across containers); refunded when a taken dose is skipped, edited away, or deleted.", + "Units left in the container. v1.16.12 — fractional (a ½-tablet dose leaves 29.5 of 30). Decremented by the intake consumption hook (FEFO with spillover across containers); refunded when a taken dose is skipped, edited away, or deleted. v1.18.3 (iOS#31) — NULL when the count is unknown (a corrupt or legacy row); the client renders \"unknown\", never a fabricated 0 it could decrement into negatives.", ), firstUseAt: z.iso .datetime({ offset: true })