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 })