From 7d3557a3fd4385ce91cdd168e8a3852f963d0a6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 17:42:31 +0200 Subject: [PATCH] fix(medications): every taken dose moves the stock, in units that match the product MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stock only decremented on the web card's take button — the status toggle, the phone sync, the Telegram buttons and the token API all recorded the dose and left the inventory untouched, which is why a fresh pack kept reading full. Consumption now stamps the intake row itself: every path that turns a row taken consumes through one module — open container first, then earliest expiry with auto-open, spilling into the next pack and flooring at zero — and every path that takes it back (undo, delete, a take turned skip, a converged duplicate) refunds exactly what the stamp recorded. Replays and pending sync echoes can never double-charge, and the nightly convergence pass refunds the rows it tombstones. A medication can declare units per dose, so two 2 mg tablets book as one 4 mg dose and stock reads in doses with the unit count alongside; containers carry a named type including tablet packs, hold up to a thousand units for inhalers, and the GLP-1 card reads the same stock as the inventory tab instead of a separate ledger. The inventory endpoints join the documented API. --- docs/api/openapi.yaml | 443 +++++++++++- messages/de.json | 39 +- messages/en.json | 39 +- messages/es.json | 39 +- messages/fr.json | 39 +- messages/it.json | 39 +- messages/pl.json | 39 +- .../0149_v11610_inventory_units/migration.sql | 36 + prisma/schema.prisma | 47 +- .../inventory-write-isolation.test.ts | 79 +++ .../ingest/medication/__tests__/route.test.ts | 49 ++ src/app/api/ingest/medication/route.ts | 12 + src/app/api/insights/glp1-timeline/route.ts | 9 + .../[id]/glp1/__tests__/route.test.ts | 75 +- src/app/api/medications/[id]/glp1/route.ts | 35 +- .../intake/[eventId]/__tests__/route.test.ts | 174 +++++ .../[id]/intake/[eventId]/route.ts | 50 ++ .../[id]/intake/__tests__/route.test.ts | 24 +- .../bulk-delete/__tests__/route.test.ts | 41 ++ .../[id]/intake/bulk-delete/route.ts | 16 +- src/app/api/medications/[id]/intake/route.ts | 65 +- .../[itemId]/__tests__/route.test.ts | 18 +- .../[id]/inventory/[itemId]/route.ts | 21 +- .../[id]/inventory/__tests__/route.test.ts | 17 +- .../api/medications/[id]/inventory/route.ts | 11 +- src/app/api/medications/[id]/route.ts | 4 + .../intake/__tests__/route.test.ts | 154 ++++ .../intake/bulk/__tests__/route.test.ts | 185 +++++ src/app/api/medications/intake/bulk/route.ts | 54 +- src/app/api/medications/intake/route.ts | 41 ++ src/app/api/medications/route.ts | 3 + .../telegram/webhook/__tests__/route.test.ts | 21 + src/app/api/telegram/webhook/route.ts | 39 +- .../__tests__/add-inventory-dialog.test.tsx | 173 +++++ .../__tests__/supply-runway.test.ts | 24 + .../detail/MedicationDetailTabs.tsx | 32 +- .../sections/inventory-section.tsx | 229 +++++- .../wizard/__tests__/wizard-payload.test.ts | 44 ++ .../medications/wizard/steps/Step3Dose.tsx | 32 +- .../medications/wizard/wizard-payload.ts | 21 + .../ai/coach/__tests__/glp1-snapshot.test.ts | 24 +- src/lib/ai/coach/glp1-snapshot.ts | 31 +- .../intake-slot-dedup-inventory.test.ts | 171 +++++ src/lib/medications/intake-slot-dedup.ts | 23 + .../inventory/__tests__/consumption.test.ts | 655 ++++++++++++++++++ .../inventory/__tests__/service.test.ts | 112 +-- .../inventory/__tests__/state-machine.test.ts | 84 +-- src/lib/medications/inventory/consumption.ts | 377 ++++++++++ src/lib/medications/inventory/service.ts | 114 +-- .../medications/inventory/state-machine.ts | 131 +--- src/lib/openapi/routes/medications.ts | 193 ++++++ .../__tests__/medication-inventory.test.ts | 166 +++++ src/lib/validations/medication.ts | 166 +++-- 53 files changed, 4118 insertions(+), 641 deletions(-) create mode 100644 prisma/migrations/0149_v11610_inventory_units/migration.sql create mode 100644 src/__tests__/inventory-write-isolation.test.ts create mode 100644 src/components/medications/__tests__/add-inventory-dialog.test.tsx create mode 100644 src/lib/medications/__tests__/intake-slot-dedup-inventory.test.ts create mode 100644 src/lib/medications/inventory/__tests__/consumption.test.ts create mode 100644 src/lib/medications/inventory/consumption.ts create mode 100644 src/lib/validations/__tests__/medication-inventory.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 541c54915..c01249a6a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2289,6 +2289,145 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/medications/{id}/inventory: + get: + 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. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Inventory item list. + content: + application/json: + schema: + $ref: "#/components/schemas/ListMedicationInventoryResponse" + "401": *a1 + "404": + description: Medication not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + post: + tags: + - Medications + summary: Register a new supply container + description: Creates an ACTIVE inventory item with `unitsRemaining = unitsTotal`. The request's `dosesTotal` field + carries UNITS (1–1000). Rate-limited 30/min/user. Audits as `medication.inventory.create`. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMedicationInventoryItemRequest" + responses: + "201": + description: Created inventory item. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMedicationInventoryItemResponse" + "401": *a1 + "404": + description: Medication not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/medications/{id}/inventory/{itemId}: + patch: + tags: + - Medications + summary: Mutate a supply container + description: "Per-item operations: manual first-use (`markAsFirstUseAt`), used-up override (`markAsUsedUp`), + printed-expiry correction, absolute remaining-unit correction (`dosesRemaining`, clamped to the item's + capacity), notes. The canonical state machine re-derives the state after every mutation. Audits as + `medication.inventory.update`." + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: itemId + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMedicationInventoryItemRequest" + responses: + "200": + description: Updated inventory item. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMedicationInventoryItemResponse" + "401": *a1 + "404": + description: Inventory item not found (or owned by another user / medication). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + delete: + tags: + - Medications + summary: Delete a supply container + description: Hard-deletes the inventory item. The audit log captures the before-state (`medication.inventory.delete`) so + a row can be reconstructed if needed. Consumption stamps on intake events that reference the item stay in place; + a later restore skips the missing container. + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: itemId + schema: + type: string + required: true + responses: + "200": + description: Deletion succeeded. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteMedicationInventoryItemResponse" + "401": *a1 + "404": + description: Inventory item not found (or owned by another user / medication). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/medications/extract: post: tags: @@ -4532,6 +4671,12 @@ components: dosesPerUnit: type: integer minimum: 1 + maximum: 1000 + unitsPerDose: + description: Inventory units consumed per dose (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The intake + consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it. + type: integer + minimum: 1 maximum: 100 deliveryForm: type: string @@ -4779,8 +4924,14 @@ components: anyOf: - type: integer minimum: 1 - maximum: 100 + maximum: 1000 - type: "null" + unitsPerDose: + description: Inventory units consumed per dose (e.g. 2 tablets of 2 mg for a 4 mg dose). The intake consumption hook + decrements this many units per taken dose; already-stamped intake events keep their recorded consumption. + type: integer + minimum: 1 + maximum: 100 deliveryForm: type: string enum: @@ -4940,6 +5091,78 @@ components: the per-day compliance rollup, and — for one-shot medications — flip `active` to false. The optional `injectionSite` is persisted only for an INJECTION medication with site-tracking enabled and is validated against the medication's effective allowed set (422 on a disallowed value). + CreateMedicationInventoryItemRequest: + type: object + properties: + dosesTotal: + type: integer + minimum: 1 + maximum: 1000 + description: Units the container ships with (tablets / ampoules / puffs; 1–1000). Stored as `unitsTotal`; dose-derived + readouts divide by the medication's `unitsPerDose`. + containerType: + description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level only; + defaults to OTHER. + type: string + enum: + - PEN + - AMPOULE + - BLISTER + - INHALER + - BOTTLE + - OTHER + printedExpiry: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + purchasedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + notes: + anyOf: + - type: string + maxLength: 200 + - type: "null" + required: + - dosesTotal + description: Register a new supply container (pen / blister pack / bottle). `dosesTotal` counts UNITS (1–1000); the item + starts ACTIVE with `unitsRemaining = unitsTotal` and the intake consumption hook decrements it per taken dose. + UpdateMedicationInventoryItemRequest: + type: object + properties: + markAsFirstUseAt: + description: Manually start the 30-day in-use clock (the user opened the container without logging an intake). ACTIVE + flips to IN_USE; a backdated instant whose window already lapsed lands EXPIRED. + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + markAsUsedUp: + description: "Terminal override: zero the remaining units and mark the container USED_UP (physically discarded)." + type: boolean + printedExpiry: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + dosesRemaining: + description: Absolute remaining-unit correction (0–1000). Clamped server-side to the item's `unitsTotal`; the canonical + state machine re-derives the state (0 ⇒ USED_UP). + type: integer + minimum: 0 + maximum: 1000 + notes: + anyOf: + - type: string + maxLength: 200 + - type: "null" + description: "Per-item inventory mutation: manual first-use, used-up override, printed-expiry correction, absolute + remaining-unit correction, notes. Every field is optional and commutative." MedicationExtractRequest: type: object properties: @@ -8264,6 +8487,13 @@ components: maximum: 9007199254740991 - type: "null" description: Doses per pen / vial for inventory tracking. NULL = inventory tracking off. + unitsPerDose: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The + intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts + by it. active: type: boolean notificationsEnabled: @@ -8372,6 +8602,7 @@ components: - dose - treatmentClass - dosesPerUnit + - unitsPerDose - active - notificationsEnabled - liveActivityEnabled @@ -8449,6 +8680,13 @@ components: maximum: 9007199254740991 - type: "null" description: Doses per pen / vial for inventory tracking. NULL = inventory tracking off. + unitsPerDose: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The + intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts + by it. active: type: boolean notificationsEnabled: @@ -8542,6 +8780,7 @@ components: - dose - treatmentClass - dosesPerUnit + - unitsPerDose - active - notificationsEnabled - liveActivityEnabled @@ -8702,6 +8941,200 @@ components: - data - error additionalProperties: false + ListMedicationInventoryResponse: + type: object + properties: + data: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/MedicationInventoryItem" + meta: + type: object + properties: + total: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - total + additionalProperties: false + required: + - items + - meta + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationInventoryItem: + type: object + properties: + id: + type: string + userId: + type: string + medicationId: + type: string + state: + type: string + enum: + - ACTIVE + - IN_USE + - EXPIRED + - USED_UP + description: Container lifecycle state. ACTIVE = unopened; IN_USE = opened (30-day in-use clock running); EXPIRED = + printed expiry or in-use window lapsed with units left; USED_UP = drained (terminal). + containerType: + type: string + enum: + - PEN + - AMPOULE + - BLISTER + - INHALER + - BOTTLE + - OTHER + description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level + classification; defaults to OTHER. + unitsTotal: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Units the container shipped with (tablets / ampoules / puffs; 1–1000). Dose-derived readouts divide by the + medication's `unitsPerDose`. + unitsRemaining: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Units left in the container. Decremented by the intake consumption hook (FEFO with spillover across + containers); refunded when a taken dose is skipped, edited away, or deleted. + firstUseAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: Instant the container was first used. NULL until opened; starts the 30-day in-use clock. + expiresAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: Persisted MIN(firstUseAt + 30 days, printedExpiry). NULL when neither clock has started. + printedExpiry: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + purchasedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + notes: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - userId + - medicationId + - state + - containerType + - unitsTotal + - unitsRemaining + - firstUseAt + - expiresAt + - printedExpiry + - purchasedAt + - notes + - createdAt + - updatedAt + additionalProperties: false + 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. + CreateMedicationInventoryItemResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationInventoryItem" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateMedicationInventoryItemResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationInventoryItem" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteMedicationInventoryItemResponse: + type: object + properties: + data: + type: object + properties: + id: + type: string + deleted: + type: boolean + required: + - id + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false MedicationExtractResponse: type: object properties: @@ -12887,6 +13320,13 @@ components: maximum: 9007199254740991 - type: "null" description: Doses per pen / vial for inventory tracking. NULL = inventory tracking off. + unitsPerDose: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The + intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts + by it. active: type: boolean notificationsEnabled: @@ -12978,6 +13418,7 @@ components: - dose - treatmentClass - dosesPerUnit + - unitsPerDose - active - notificationsEnabled - liveActivityEnabled diff --git a/messages/de.json b/messages/de.json index 008dd5f5f..d49bcd2ae 100644 --- a/messages/de.json +++ b/messages/de.json @@ -277,6 +277,7 @@ "hero": { "scoreLabel": "Health Score", "scoreProvisional": "Noch nicht genug Daten", + "scoreComputing": "Score wird berechnet …", "doses": { "summary": "{taken} von {scheduled} Dosen genommen", "nextAt": "Nächste um {time}", @@ -1056,7 +1057,9 @@ "dosesPerUnitHint": "Dosen pro Pen / Ampulle — leer lassen, wenn kein Bestand getrackt wird.", "injectionSiteToggle": "Injektionsstellen-Rotation", "injectionSiteHint": "Optionaler Rotationshelfer. Die Stelle jeder Dosis wird beim Eintragen der Einnahme erfasst.", - "short": "Dosis" + "short": "Dosis", + "unitsPerDoseLabel": "Einheiten pro Dosis", + "unitsPerDoseHint": "z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis" }, "step4": { "title": "Über welchen Zeitraum nimmst du es?", @@ -1585,7 +1588,7 @@ "bestand": { "title": "Bestand", "summary": "{remaining} von {total} Dosen übrig", - "itemsTitle": "Pens / Ampullen", + "itemsTitle": "Packungen & Behälter", "doses": "{remaining} / {total} Dosen", "state": { "ACTIVE": "Ungeöffnet", @@ -1594,25 +1597,39 @@ "USED_UP": "Aufgebraucht" }, "empty": "Kein Bestand erfasst.", - "emptyHelper": "Erfasse einen Pen oder eine Packung, um verbleibende Dosen und Reichweite zu verfolgen.", + "emptyHelper": "Erfasse eine Packung oder einen Behälter, um den verbleibenden Vorrat und die Reichweite zu verfolgen.", "emptyCta": "Packung erfassen", "addButton": "Erfassen", - "addTitle": "Pen oder Packung erfassen", - "addDescription": "Lege einen neuen Vorrat mit seiner Dosenanzahl an; das Ablaufdatum ist optional.", - "addQuantityLabel": "Menge (Dosen / Einheiten)", - "addQuantityHelper": "1–100 Dosen pro Pen oder Packung.", + "addTitle": "Packung oder Behälter erfassen", + "addDescription": "Lege einen neuen Vorrat mit seiner Einheitenanzahl an; das Ablaufdatum ist optional.", + "addQuantityLabel": "Menge (Einheiten)", + "addQuantityHelper": "1–1000 Einheiten pro Packung oder Behälter.", "addExpiryLabel": "Ablaufdatum (optional)", "addSubmit": "Erfassen", "addSuccess": "Bestand erfasst.", "addFailed": "Bestand konnte nicht erfasst werden.", "adjustButton": "Anpassen", "adjustTitle": "Bestand korrigieren", - "adjustDescription": "Setze die verbleibenden Dosen neu — für Korrekturen oder eine Entnahme. 0 markiert den Vorrat als aufgebraucht.", - "adjustQuantityLabel": "Verbleibende Dosen", - "adjustHelper": "Kapazität: {total} Dosen.", + "adjustDescription": "Setze die verbleibenden Einheiten neu — für Korrekturen oder eine Entnahme. 0 markiert den Vorrat als aufgebraucht.", + "adjustQuantityLabel": "Verbleibende Einheiten", + "adjustHelper": "Kapazität: {total} Einheiten.", "adjustSubmit": "Speichern", "adjustSuccess": "Bestand aktualisiert.", - "adjustFailed": "Bestand konnte nicht aktualisiert werden." + "adjustFailed": "Bestand konnte nicht aktualisiert werden.", + "containerTypeLabel": "Behälterart", + "containerType": { + "PEN": "Pen", + "AMPOULE": "Ampulle", + "BLISTER": "Tablettenpackung", + "INHALER": "Inhalator", + "BOTTLE": "Flasche", + "OTHER": "Sonstiges" + }, + "unitsDetail": "{remaining} / {total} Einheiten", + "quantityModeDoses": "Dosen", + "quantityModeUnits": "Einheiten", + "quantityInUnits": "= {units} Einheiten", + "quantityInDoses": "≈ {doses} Dosen" }, "erweitert": { "group": { diff --git a/messages/en.json b/messages/en.json index 891a06406..e0c9d9a60 100644 --- a/messages/en.json +++ b/messages/en.json @@ -277,6 +277,7 @@ "hero": { "scoreLabel": "Health Score", "scoreProvisional": "Not enough data yet", + "scoreComputing": "Calculating score …", "doses": { "summary": "{taken} of {scheduled} doses taken", "nextAt": "Next at {time}", @@ -1056,7 +1057,9 @@ "dosesPerUnitHint": "Doses per pen / vial — leave empty for no inventory tracking.", "injectionSiteToggle": "Injection-site rotation", "injectionSiteHint": "Optional rotation guide. The site for each dose is recorded when you log the intake.", - "short": "Dose" + "short": "Dose", + "unitsPerDoseLabel": "Units per dose", + "unitsPerDoseHint": "e.g. 2 tablets of 2 mg for a 4 mg dose" }, "step4": { "title": "Over what period are you taking it?", @@ -1585,7 +1588,7 @@ "bestand": { "title": "Supply", "summary": "{remaining} of {total} doses remaining", - "itemsTitle": "Pens / vials", + "itemsTitle": "Packs & containers", "doses": "{remaining} / {total} doses", "state": { "ACTIVE": "Unopened", @@ -1594,25 +1597,39 @@ "USED_UP": "Used up" }, "empty": "No supply recorded.", - "emptyHelper": "Register a pen or pack to track doses remaining and weeks of supply.", + "emptyHelper": "Register a pack or container to track remaining supply and runway.", "emptyCta": "Register a pack", "addButton": "Register", - "addTitle": "Register a pen or pack", - "addDescription": "Record a new supply with its dose count; the expiry date is optional.", - "addQuantityLabel": "Quantity (doses / units)", - "addQuantityHelper": "1–100 doses per pen or pack.", + "addTitle": "Register a pack or container", + "addDescription": "Record a new supply with its unit count; the expiry date is optional.", + "addQuantityLabel": "Quantity (units)", + "addQuantityHelper": "1–1000 units per pack or container.", "addExpiryLabel": "Expiry date (optional)", "addSubmit": "Register", "addSuccess": "Supply registered.", "addFailed": "The supply could not be registered.", "adjustButton": "Adjust", "adjustTitle": "Correct stock", - "adjustDescription": "Set the remaining doses — for corrections or a withdrawal. 0 marks the item as used up.", - "adjustQuantityLabel": "Remaining doses", - "adjustHelper": "Capacity: {total} doses.", + "adjustDescription": "Set the remaining units — for corrections or a withdrawal. 0 marks the item as used up.", + "adjustQuantityLabel": "Remaining units", + "adjustHelper": "Capacity: {total} units.", "adjustSubmit": "Save", "adjustSuccess": "Stock updated.", - "adjustFailed": "The stock could not be updated." + "adjustFailed": "The stock could not be updated.", + "containerTypeLabel": "Container type", + "containerType": { + "PEN": "Pen", + "AMPOULE": "Ampoule", + "BLISTER": "Tablet pack", + "INHALER": "Inhaler", + "BOTTLE": "Bottle", + "OTHER": "Other" + }, + "unitsDetail": "{remaining} / {total} units", + "quantityModeDoses": "Doses", + "quantityModeUnits": "Units", + "quantityInUnits": "= {units} units", + "quantityInDoses": "≈ {doses} doses" }, "erweitert": { "group": { diff --git a/messages/es.json b/messages/es.json index 06537b0b8..6413beec0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -277,6 +277,7 @@ "hero": { "scoreLabel": "Health Score", "scoreProvisional": "Aún no hay datos suficientes", + "scoreComputing": "Calculando la puntuación …", "doses": { "summary": "{taken} de {scheduled} dosis tomadas", "nextAt": "Próxima a las {time}", @@ -1056,7 +1057,9 @@ "dosesPerUnitHint": "Dosis por pluma / vial — déjalo vacío para no controlar el inventario.", "injectionSiteToggle": "Rotación del sitio de inyección", "injectionSiteHint": "Guía de rotación opcional. El sitio de cada dosis se registra al anotar la toma.", - "short": "Dosis" + "short": "Dosis", + "unitsPerDoseLabel": "Unidades por dosis", + "unitsPerDoseHint": "p. ej., 2 comprimidos de 2 mg para una dosis de 4 mg" }, "step4": { "title": "¿Durante qué periodo lo tomas?", @@ -1585,7 +1588,7 @@ "bestand": { "title": "Existencias", "summary": "{remaining} de {total} dosis restantes", - "itemsTitle": "Plumas / viales", + "itemsTitle": "Envases y recipientes", "doses": "{remaining} / {total} dosis", "state": { "ACTIVE": "Sin abrir", @@ -1594,25 +1597,39 @@ "USED_UP": "Agotado" }, "empty": "No hay existencias registradas.", - "emptyHelper": "Registra una pluma o un paquete para hacer seguimiento de las dosis restantes y las semanas de suministro.", + "emptyHelper": "Registra un envase o recipiente para hacer seguimiento de las existencias restantes y la autonomía.", "emptyCta": "Registrar un envase", "addButton": "Registrar", - "addTitle": "Registrar una pluma o un envase", - "addDescription": "Registra una nueva existencia con su número de dosis; la fecha de caducidad es opcional.", - "addQuantityLabel": "Cantidad (dosis / unidades)", - "addQuantityHelper": "1–100 dosis por pluma o envase.", + "addTitle": "Registrar un envase o recipiente", + "addDescription": "Registra una nueva existencia con su número de unidades; la fecha de caducidad es opcional.", + "addQuantityLabel": "Cantidad (unidades)", + "addQuantityHelper": "1–1000 unidades por envase o recipiente.", "addExpiryLabel": "Fecha de caducidad (opcional)", "addSubmit": "Registrar", "addSuccess": "Existencias registradas.", "addFailed": "No se pudieron registrar las existencias.", "adjustButton": "Ajustar", "adjustTitle": "Corregir existencias", - "adjustDescription": "Establece las dosis restantes — para correcciones o una retirada. 0 marca el envase como agotado.", - "adjustQuantityLabel": "Dosis restantes", - "adjustHelper": "Capacidad: {total} dosis.", + "adjustDescription": "Establece las unidades restantes — para correcciones o una retirada. 0 marca el envase como agotado.", + "adjustQuantityLabel": "Unidades restantes", + "adjustHelper": "Capacidad: {total} unidades.", "adjustSubmit": "Guardar", "adjustSuccess": "Existencias actualizadas.", - "adjustFailed": "No se pudieron actualizar las existencias." + "adjustFailed": "No se pudieron actualizar las existencias.", + "containerTypeLabel": "Tipo de envase", + "containerType": { + "PEN": "Pluma", + "AMPOULE": "Ampolla", + "BLISTER": "Envase de comprimidos", + "INHALER": "Inhalador", + "BOTTLE": "Frasco", + "OTHER": "Otro" + }, + "unitsDetail": "{remaining} / {total} unidades", + "quantityModeDoses": "Dosis", + "quantityModeUnits": "Unidades", + "quantityInUnits": "= {units} unidades", + "quantityInDoses": "≈ {doses} dosis" }, "erweitert": { "group": { diff --git a/messages/fr.json b/messages/fr.json index 445d9e1f3..19487aaa5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -277,6 +277,7 @@ "hero": { "scoreLabel": "Health Score", "scoreProvisional": "Pas encore assez de données", + "scoreComputing": "Calcul du score en cours …", "doses": { "summary": "{taken} dose(s) prise(s) sur {scheduled}", "nextAt": "Prochaine à {time}", @@ -1056,7 +1057,9 @@ "dosesPerUnitHint": "Doses par stylo / flacon — laisser vide pour ne pas suivre le stock.", "injectionSiteToggle": "Rotation des sites d'injection", "injectionSiteHint": "Guide de rotation facultatif. Le site de chaque dose est enregistré lors de la saisie de la prise.", - "short": "Dose" + "short": "Dose", + "unitsPerDoseLabel": "Unités par dose", + "unitsPerDoseHint": "p. ex. 2 comprimés de 2 mg pour une dose de 4 mg" }, "step4": { "title": "Sur quelle période le prenez-vous ?", @@ -1585,7 +1588,7 @@ "bestand": { "title": "Stock", "summary": "{remaining} doses restantes sur {total}", - "itemsTitle": "Stylos / flacons", + "itemsTitle": "Boîtes et contenants", "doses": "{remaining} / {total} doses", "state": { "ACTIVE": "Non ouvert", @@ -1594,25 +1597,39 @@ "USED_UP": "Épuisé" }, "empty": "Aucun stock enregistré.", - "emptyHelper": "Enregistrez un stylo ou une boîte pour suivre les doses restantes et l'autonomie.", + "emptyHelper": "Enregistrez une boîte ou un contenant pour suivre le stock restant et l'autonomie.", "emptyCta": "Enregistrer une boîte", "addButton": "Enregistrer", - "addTitle": "Enregistrer un stylo ou une boîte", - "addDescription": "Enregistrez un nouveau stock avec son nombre de doses ; la date de péremption est facultative.", - "addQuantityLabel": "Quantité (doses / unités)", - "addQuantityHelper": "1–100 doses par stylo ou boîte.", + "addTitle": "Enregistrer une boîte ou un contenant", + "addDescription": "Enregistrez un nouveau stock avec son nombre d'unités ; la date de péremption est facultative.", + "addQuantityLabel": "Quantité (unités)", + "addQuantityHelper": "1–1000 unités par boîte ou contenant.", "addExpiryLabel": "Date de péremption (facultatif)", "addSubmit": "Enregistrer", "addSuccess": "Stock enregistré.", "addFailed": "Le stock n'a pas pu être enregistré.", "adjustButton": "Ajuster", "adjustTitle": "Corriger le stock", - "adjustDescription": "Définissez les doses restantes — pour une correction ou un prélèvement. 0 marque l'article comme épuisé.", - "adjustQuantityLabel": "Doses restantes", - "adjustHelper": "Capacité : {total} doses.", + "adjustDescription": "Définissez les unités restantes — pour une correction ou un prélèvement. 0 marque l'article comme épuisé.", + "adjustQuantityLabel": "Unités restantes", + "adjustHelper": "Capacité : {total} unités.", "adjustSubmit": "Enregistrer", "adjustSuccess": "Stock mis à jour.", - "adjustFailed": "Le stock n'a pas pu être mis à jour." + "adjustFailed": "Le stock n'a pas pu être mis à jour.", + "containerTypeLabel": "Type de contenant", + "containerType": { + "PEN": "Stylo", + "AMPOULE": "Ampoule", + "BLISTER": "Plaquette de comprimés", + "INHALER": "Inhalateur", + "BOTTLE": "Flacon", + "OTHER": "Autre" + }, + "unitsDetail": "{remaining} / {total} unités", + "quantityModeDoses": "Doses", + "quantityModeUnits": "Unités", + "quantityInUnits": "= {units} unités", + "quantityInDoses": "≈ {doses} doses" }, "erweitert": { "group": { diff --git a/messages/it.json b/messages/it.json index a71112349..097725139 100644 --- a/messages/it.json +++ b/messages/it.json @@ -277,6 +277,7 @@ "hero": { "scoreLabel": "Health Score", "scoreProvisional": "Dati ancora insufficienti", + "scoreComputing": "Calcolo del punteggio in corso …", "doses": { "summary": "{taken} dosi assunte su {scheduled}", "nextAt": "Prossima alle {time}", @@ -1056,7 +1057,9 @@ "dosesPerUnitHint": "Dosi per penna / fiala — lascia vuoto per non tracciare le scorte.", "injectionSiteToggle": "Rotazione del sito di iniezione", "injectionSiteHint": "Guida alla rotazione facoltativa. Il sito di ogni dose viene registrato al momento della registrazione dell'assunzione.", - "short": "Dose" + "short": "Dose", + "unitsPerDoseLabel": "Unità per dose", + "unitsPerDoseHint": "ad es. 2 compresse da 2 mg per una dose da 4 mg" }, "step4": { "title": "Per quanto tempo lo assumi?", @@ -1585,7 +1588,7 @@ "bestand": { "title": "Scorta", "summary": "{remaining} dosi rimanenti su {total}", - "itemsTitle": "Penne / flaconi", + "itemsTitle": "Confezioni e contenitori", "doses": "{remaining} / {total} dosi", "state": { "ACTIVE": "Non aperto", @@ -1594,25 +1597,39 @@ "USED_UP": "Esaurito" }, "empty": "Nessuna scorta registrata.", - "emptyHelper": "Registra una penna o una confezione per monitorare le dosi rimanenti e l'autonomia.", + "emptyHelper": "Registra una confezione o un contenitore per monitorare la scorta rimanente e l'autonomia.", "emptyCta": "Registra una confezione", "addButton": "Registra", - "addTitle": "Registra una penna o una confezione", - "addDescription": "Registra una nuova scorta con il numero di dosi; la data di scadenza è facoltativa.", - "addQuantityLabel": "Quantità (dosi / unità)", - "addQuantityHelper": "1–100 dosi per penna o confezione.", + "addTitle": "Registra una confezione o un contenitore", + "addDescription": "Registra una nuova scorta con il numero di unità; la data di scadenza è facoltativa.", + "addQuantityLabel": "Quantità (unità)", + "addQuantityHelper": "1–1000 unità per confezione o contenitore.", "addExpiryLabel": "Data di scadenza (facoltativa)", "addSubmit": "Registra", "addSuccess": "Scorta registrata.", "addFailed": "Impossibile registrare la scorta.", "adjustButton": "Correggi", "adjustTitle": "Correggi la scorta", - "adjustDescription": "Imposta le dosi rimanenti — per correzioni o un prelievo. 0 segna la scorta come esaurita.", - "adjustQuantityLabel": "Dosi rimanenti", - "adjustHelper": "Capacità: {total} dosi.", + "adjustDescription": "Imposta le unità rimanenti — per correzioni o un prelievo. 0 segna la scorta come esaurita.", + "adjustQuantityLabel": "Unità rimanenti", + "adjustHelper": "Capacità: {total} unità.", "adjustSubmit": "Salva", "adjustSuccess": "Scorta aggiornata.", - "adjustFailed": "Impossibile aggiornare la scorta." + "adjustFailed": "Impossibile aggiornare la scorta.", + "containerTypeLabel": "Tipo di contenitore", + "containerType": { + "PEN": "Penna", + "AMPOULE": "Fiala", + "BLISTER": "Confezione di compresse", + "INHALER": "Inalatore", + "BOTTLE": "Flacone", + "OTHER": "Altro" + }, + "unitsDetail": "{remaining} / {total} unità", + "quantityModeDoses": "Dosi", + "quantityModeUnits": "Unità", + "quantityInUnits": "= {units} unità", + "quantityInDoses": "≈ {doses} dosi" }, "erweitert": { "group": { diff --git a/messages/pl.json b/messages/pl.json index 18ac910b5..61fb2f695 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -277,6 +277,7 @@ "hero": { "scoreLabel": "Health Score", "scoreProvisional": "Za mało danych", + "scoreComputing": "Trwa obliczanie wyniku …", "doses": { "summary": "Przyjęto {taken} z {scheduled} dawek", "nextAt": "Następna o {time}", @@ -1056,7 +1057,9 @@ "dosesPerUnitHint": "Dawki na wstrzykiwacz / fiolkę — zostaw puste, aby nie śledzić zapasów.", "injectionSiteToggle": "Rotacja miejsc wstrzyknięcia", "injectionSiteHint": "Opcjonalny przewodnik rotacji. Miejsce każdej dawki zapisywane jest przy rejestrowaniu przyjęcia.", - "short": "Dawka" + "short": "Dawka", + "unitsPerDoseLabel": "Jednostki na dawkę", + "unitsPerDoseHint": "np. 2 tabletki po 2 mg na dawkę 4 mg" }, "step4": { "title": "Przez jaki okres go przyjmujesz?", @@ -1585,7 +1588,7 @@ "bestand": { "title": "Zapas", "summary": "Pozostało {remaining} z {total} dawek", - "itemsTitle": "Wstrzykiwacze / fiolki", + "itemsTitle": "Opakowania i pojemniki", "doses": "{remaining} / {total} dawek", "state": { "ACTIVE": "Nieotwarte", @@ -1594,25 +1597,39 @@ "USED_UP": "Zużyte" }, "empty": "Nie zarejestrowano zapasu.", - "emptyHelper": "Zarejestruj wstrzykiwacz lub opakowanie, aby śledzić pozostałe dawki i zapas na tygodnie.", + "emptyHelper": "Zarejestruj opakowanie lub pojemnik, aby śledzić pozostały zapas i jego wystarczalność.", "emptyCta": "Zarejestruj opakowanie", "addButton": "Zarejestruj", - "addTitle": "Zarejestruj wstrzykiwacz lub opakowanie", - "addDescription": "Zapisz nowy zapas z liczbą dawek; data ważności jest opcjonalna.", - "addQuantityLabel": "Ilość (dawki / jednostki)", - "addQuantityHelper": "1–100 dawek na wstrzykiwacz lub opakowanie.", + "addTitle": "Zarejestruj opakowanie lub pojemnik", + "addDescription": "Zapisz nowy zapas z liczbą jednostek; data ważności jest opcjonalna.", + "addQuantityLabel": "Ilość (jednostki)", + "addQuantityHelper": "1–1000 jednostek na opakowanie lub pojemnik.", "addExpiryLabel": "Data ważności (opcjonalnie)", "addSubmit": "Zarejestruj", "addSuccess": "Zapas zarejestrowany.", "addFailed": "Nie udało się zarejestrować zapasu.", "adjustButton": "Skoryguj", "adjustTitle": "Skoryguj zapas", - "adjustDescription": "Ustaw pozostałe dawki — przy korekcie lub pobraniu. 0 oznacza zapas jako zużyty.", - "adjustQuantityLabel": "Pozostałe dawki", - "adjustHelper": "Pojemność: {total} dawek.", + "adjustDescription": "Ustaw pozostałe jednostki — przy korekcie lub pobraniu. 0 oznacza zapas jako zużyty.", + "adjustQuantityLabel": "Pozostałe jednostki", + "adjustHelper": "Pojemność: {total} jednostek.", "adjustSubmit": "Zapisz", "adjustSuccess": "Zapas zaktualizowany.", - "adjustFailed": "Nie udało się zaktualizować zapasu." + "adjustFailed": "Nie udało się zaktualizować zapasu.", + "containerTypeLabel": "Rodzaj pojemnika", + "containerType": { + "PEN": "Wstrzykiwacz", + "AMPOULE": "Ampułka", + "BLISTER": "Opakowanie tabletek", + "INHALER": "Inhalator", + "BOTTLE": "Butelka", + "OTHER": "Inne" + }, + "unitsDetail": "{remaining} / {total} jednostek", + "quantityModeDoses": "Dawki", + "quantityModeUnits": "Jednostki", + "quantityInUnits": "= {units} jednostek", + "quantityInDoses": "≈ {doses} dawek" }, "erweitert": { "group": { diff --git a/prisma/migrations/0149_v11610_inventory_units/migration.sql b/prisma/migrations/0149_v11610_inventory_units/migration.sql new file mode 100644 index 000000000..2c56f2dad --- /dev/null +++ b/prisma/migrations/0149_v11610_inventory_units/migration.sql @@ -0,0 +1,36 @@ +-- v1.16.10 — multi-unit inventory consumption. +-- +-- `units_per_dose` (medications): how many inventory UNITS one dose +-- consumes (2 × 2 mg tablets for a 4 mg dose). Default 1 keeps every +-- existing medication on the one-unit-per-dose behaviour. +-- +-- `container_type` (medication_inventory_items): what kind of physical +-- container the item is (pen / ampoule / blister pack / inhaler / +-- bottle). Display-level only. Backfilled PEN for items whose +-- medication is an INJECTION (the pre-v1.16.10 surface was pen-only), +-- OTHER for everything else. +-- +-- `inventory_consumption` (medication_intake_events): the per-event +-- consumption stamp `[{itemId, units}]` — the exactly-once gate for +-- the consume hook and the refund ledger for the restore hook. NULL = +-- never consumed (pending / skipped / pre-v1.16.10 rows). +-- +-- The Prisma fields `dosesTotal` / `dosesRemaining` are renamed to +-- `unitsTotal` / `unitsRemaining` at the client level only; the DB +-- columns keep their names via @map — zero data migration. + +CREATE TYPE "medication_container_type" AS ENUM ('PEN', 'AMPOULE', 'BLISTER', 'INHALER', 'BOTTLE', 'OTHER'); + +ALTER TABLE "medications" ADD COLUMN "units_per_dose" INTEGER NOT NULL DEFAULT 1; + +ALTER TABLE "medication_inventory_items" ADD COLUMN "container_type" "medication_container_type" NOT NULL DEFAULT 'OTHER'; + +ALTER TABLE "medication_intake_events" ADD COLUMN "inventory_consumption" JSONB; + +-- Backfill: every existing item was registered through the pen-framed +-- surface; items on injection medications are pens. +UPDATE "medication_inventory_items" i +SET "container_type" = 'PEN' +FROM "medications" m +WHERE i."medication_id" = m."id" + AND m."delivery_form" = 'INJECTION'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 439ea78ad..099674630 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1261,6 +1261,12 @@ model Medication { /// (Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose /// pen = 1). NULL = inventory tracking off. dosesPerUnit Int? @map("doses_per_unit") + /// v1.16.10 — units consumed per dose. The inventory items count UNITS + /// (tablets, ampoules, puffs); a single dose may consume several of + /// them (2 × 2 mg tablets for a 4 mg dose). The intake consumption + /// hook multiplies by this factor; dose-level readouts divide by it. + /// Default 1 = one unit per dose (the overwhelmingly common case). + unitsPerDose Int @default(1) @map("units_per_dose") active Boolean @default(true) notificationsEnabled Boolean @default(true) @map("notifications_enabled") pausedAt DateTime? @map("paused_at") @@ -1599,6 +1605,15 @@ model MedicationIntakeEvent { /// log time (half tablet, titration day). NULL = the configured /// medication / schedule dose applies; read paths fall back to it. doseTaken String? @map("dose_taken") + /// v1.16.10 — inventory consumption stamp: `[{itemId, units}]` for the + /// containers this taken event decremented. NULL = never consumed + /// (pending / skipped / pre-v1.16.10 rows). The stamp is the exactly- + /// once gate for the consume hook (a stamped row never decrements + /// again, however often a sync replays it) and the refund ledger for + /// the restore hook (taken → skipped / deleted refunds per item). The + /// stamp freezes history: editing `Medication.unitsPerDose` later + /// never changes what an already-taken dose consumed. + inventoryConsumption Json? @map("inventory_consumption") // v1.7.0 sync — server-set mutation timestamp. The model had no // `updatedAt`; the offline-sync keyset feed orders on it, so it is // added here. `@updatedAt` makes Prisma maintain it on every write. @@ -1706,16 +1721,38 @@ enum MedicationInventoryState { @@map("medication_inventory_state") } +/// v1.16.10 — what kind of physical container an inventory item is. +/// Display-level only (no state-machine behaviour hangs off it); the +/// register dialog defaults it from the medication's delivery form +/// (INJECTION → PEN, ORAL → BLISTER, else OTHER). +enum MedicationContainerType { + PEN + AMPOULE + BLISTER + INHALER + BOTTLE + OTHER + + @@map("medication_container_type") +} + model MedicationInventoryItem { id String @id @default(cuid()) userId String @map("user_id") medicationId String @map("medication_id") state MedicationInventoryState @default(ACTIVE) - /// Doses the pen / vial shipped with. Mounjaro KwikPen = 4, - /// Trulicity single-dose = 1, Byetta = 60. - dosesTotal Int @map("doses_total") - /// Remaining doses; decremented by the intake hook. - dosesRemaining Int @map("doses_remaining") + /// v1.16.10 — container kind (pen / ampoule / blister pack / …). + /// Backfilled PEN for items on INJECTION medications, OTHER otherwise. + containerType MedicationContainerType @default(OTHER) @map("container_type") + /// Units the container shipped with (v1.16.10 — renamed from + /// `dosesTotal`; the DB column stays `doses_total`). A unit is one + /// tablet / ampoule / puff; `Medication.unitsPerDose` maps units to + /// doses. Mounjaro KwikPen = 4, Trulicity single-dose = 1. + unitsTotal Int @map("doses_total") + /// Remaining units; decremented by the intake consumption hook + /// (v1.16.10 — renamed from `dosesRemaining`; column stays + /// `doses_remaining`). + unitsRemaining Int @map("doses_remaining") /// NULL until the pen is first opened. Sets the 30-day in-use /// clock per EMA EPAR §6.3. firstUseAt DateTime? @map("first_use_at") diff --git a/src/__tests__/inventory-write-isolation.test.ts b/src/__tests__/inventory-write-isolation.test.ts new file mode 100644 index 000000000..4c02bc53a --- /dev/null +++ b/src/__tests__/inventory-write-isolation.test.ts @@ -0,0 +1,79 @@ +/** + * v1.16.10 — inventory write-path isolation guard. + * + * The consumption stamp on `MedicationIntakeEvent.inventoryConsumption` + * is only an exactly-once ledger as long as EVERY stock movement flows + * through `src/lib/medications/inventory/consumption.ts` (intake-driven + * consume / restore) or the dedicated inventory surfaces (the CRUD + * routes, the create-input builder, and the expire cron in + * `service.ts`). A `medicationInventoryItem` write sprinkled into some + * future intake path would bypass the stamp and re-open the + * double-decrement / stranded-stock class of bugs this release closed. + * + * This test walks every TypeScript / TSX file under `src/` (excluding + * tests + generated code) and asserts that no file outside the + * allowlist invokes a Prisma write method on `medicationInventoryItem`. + * Reads (`findFirst`, `findMany`, …) stay legal everywhere. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative, sep } from "node:path"; + +const ROOT = join(__dirname, "../.."); +const SRC = join(ROOT, "src"); + +const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]); + +/** + * Files allowed to write `medicationInventoryItem` rows: + * - the consumption module (intake-driven consume / restore); + * - the inventory persistence helpers (expire cron `updateMany`); + * - the two inventory CRUD routes (register / mutate / delete). + */ +const ALLOWED_WRITERS = new Set( + [ + "src/lib/medications/inventory/consumption.ts", + "src/lib/medications/inventory/service.ts", + "src/app/api/medications/[id]/inventory/route.ts", + "src/app/api/medications/[id]/inventory/[itemId]/route.ts", + ].map((p) => p.split("/").join(sep)), +); + +const WRITE_PATTERN = + /medicationInventoryItem\s*\.\s*(create|createMany|update|updateMany|upsert|delete|deleteMany)\s*\(/g; + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + if (SKIP_DIRS.has(name)) continue; + const p = join(dir, name); + const stat = statSync(p); + if (stat.isDirectory()) { + walk(p, out); + } else if (name.endsWith(".ts") || name.endsWith(".tsx")) { + out.push(p); + } + } + return out; +} + +describe("medicationInventoryItem write isolation", () => { + it("no file outside the consumption module + inventory surfaces writes inventory items", () => { + const offenders: string[] = []; + for (const file of walk(SRC)) { + const rel = relative(ROOT, file); + if (ALLOWED_WRITERS.has(rel)) continue; + const content = readFileSync(file, "utf8"); + if (WRITE_PATTERN.test(content)) { + offenders.push(rel); + WRITE_PATTERN.lastIndex = 0; + } + } + expect(offenders).toEqual([]); + }); + + it("the allowlisted writers actually exist (allowlist stays honest)", () => { + for (const rel of ALLOWED_WRITERS) { + expect(() => statSync(join(ROOT, rel))).not.toThrow(); + } + }); +}); diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts index 629b8c247..d33c3457a 100644 --- a/src/app/api/ingest/medication/__tests__/route.test.ts +++ b/src/app/api/ingest/medication/__tests__/route.test.ts @@ -40,6 +40,10 @@ vi.mock("@/lib/app-settings", () => ({ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/medications/inventory/consumption", () => ({ + consumeForIntake: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); @@ -303,4 +307,49 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16. expect(create.data.scheduledFor.getTime()).toBe(offWindow.getTime()); expect(create.data.takenAt.getTime()).toBe(offWindow.getTime()); }); + + // ── v1.16.10 — inventory consumption seam ─────────────────────────── + + it("consumes inventory exactly once on the landed row", async () => { + const { consumeForIntake } = await import( + "@/lib/medications/inventory/consumption" + ); + wireHappyPath(); + const res = await POST( + postReq({ + medicationName: "Metformin", + takenAt: TAKEN_0842_BERLIN.toISOString(), + idempotencyKey: "ha-ingest-inv-1", + }), + ); + expect(res.status).toBe(201); + expect(consumeForIntake).toHaveBeenCalledTimes(1); + expect(consumeForIntake).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + medicationId: "med-1", + eventId: "evt-pending-0900", + }), + ); + }); + + it("an idempotency replay returns the original row without consuming", async () => { + const { consumeForIntake } = await import( + "@/lib/medications/inventory/consumption" + ); + wireHappyPath(); + // The replay probe finds the original event. + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ + id: "evt-original", + } as never); + const res = await POST( + postReq({ + medicationName: "Metformin", + takenAt: TAKEN_0842_BERLIN.toISOString(), + idempotencyKey: "ha-ingest-inv-1", + }), + ); + expect(res.status).toBe(200); + expect(consumeForIntake).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index dfeafa5f4..4b01a61c1 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -19,6 +19,7 @@ import { applyCanonicalSlotWrite, resolveSlotForWriteByBand, } from "@/lib/medications/scheduling/slot-upsert"; +import { consumeForIntake } from "@/lib/medications/inventory/consumption"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { NextRequest, NextResponse } from "next/server"; @@ -224,6 +225,17 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } + // v1.16.10 — the external ingest records a take; consume inventory + // units. The stamp on the row keeps a retried POST exactly-once (the + // idempotency probe above already short-circuits the common replay). + await consumeForIntake({ + client: prisma, + userId: apiToken.userId, + medicationId: medication.id, + eventId: event.id, + intakeAt: effectiveTakenAt, + }); + await auditLog("medication.ingest.external", { userId: apiToken.userId, ipAddress: ip, diff --git a/src/app/api/insights/glp1-timeline/route.ts b/src/app/api/insights/glp1-timeline/route.ts index 45d18cb43..306322109 100644 --- a/src/app/api/insights/glp1-timeline/route.ts +++ b/src/app/api/insights/glp1-timeline/route.ts @@ -82,6 +82,15 @@ export const GET = apiHandler(async (request: NextRequest) => { orderBy: { takenAt: "desc" }, take: 50, }, + // v1.16.10 divergence note: the stock READOUTS (glp1 details + // endpoint, Coach snapshot) moved to the per-item + // MedicationInventoryItem entities, but this timeline renders the + // append-only MedicationInventoryEvent LEDGER as point-in-time + // entries ("+2 pens purchased") — a per-item swap is not + // mechanical (items carry no per-mutation history). The ledger + // only grows through the legacy `POST /api/medications/[id]/glp1` + // inventory branch, so timelines on item-tracked medications show + // no new inventory entries. inventoryEvents: { orderBy: { occurredAt: "desc" }, take: 20 }, }, }); diff --git a/src/app/api/medications/[id]/glp1/__tests__/route.test.ts b/src/app/api/medications/[id]/glp1/__tests__/route.test.ts index b9dfb690e..c8f10a701 100644 --- a/src/app/api/medications/[id]/glp1/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/glp1/__tests__/route.test.ts @@ -404,10 +404,11 @@ describe("GET /api/medications/[id]/glp1 — ownership helper (F-1 C-4)", () => id: "med-1", userId: "user-1", doseChanges: [], - inventoryEvents: [], + inventoryItems: [], intakeEvents: [], schedules: [], dosesPerUnit: null, + unitsPerDose: 1, } as never); const res = await GET(getReq(), { params: Promise.resolve({ id: "med-1" }), @@ -428,3 +429,75 @@ describe("GET /api/medications/[id]/glp1 — ownership helper (F-1 C-4)", () => expect(prisma.medication.findUnique).not.toHaveBeenCalled(); }); }); + +describe("GET /api/medications/[id]/glp1 — v1.16.10 item-backed inventory", () => { + function getReq(): NextRequest { + return new NextRequest("http://localhost/api/medications/med-1/glp1"); + } + + it("sums usable inventory items and derives doses via unitsPerDose", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + userId: "user-1", + doseChanges: [], + intakeEvents: [], + schedules: [], + dosesPerUnit: 4, + unitsPerDose: 2, + inventoryItems: [ + // Usable: open container with 3 units. + { state: "IN_USE", unitsTotal: 4, unitsRemaining: 3 }, + // Usable: unopened container with 4 units. + { state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 }, + // Not usable: drained. + { state: "USED_UP", unitsTotal: 4, unitsRemaining: 0 }, + // Not usable: expired stock is not supply. + { state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 }, + ], + } as never); + + const res = await GET(getReq(), { + params: Promise.resolve({ id: "med-1" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + inventory: { + pensRemaining: number; + dosesRemaining: number; + weeksOfSupply: number; + lowStock: boolean; + }; + }; + }; + // 2 usable containers; 7 pooled units / 2 units per dose = 3 doses. + expect(body.data.inventory).toEqual({ + pensRemaining: 2, + dosesRemaining: 3, + weeksOfSupply: 3, + lowStock: true, + }); + }); + + it("returns a null inventory block when no items exist", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + userId: "user-1", + doseChanges: [], + intakeEvents: [], + schedules: [], + dosesPerUnit: null, + unitsPerDose: 1, + inventoryItems: [], + } as never); + + const res = await GET(getReq(), { + params: Promise.resolve({ id: "med-1" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { inventory: unknown } }; + expect(body.data.inventory).toBeNull(); + }); +}); diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index a951fc643..ee887b531 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -54,7 +54,11 @@ export const GET = apiHandler( where: { id }, include: { doseChanges: { orderBy: { effectiveFrom: "asc" } }, - inventoryEvents: { orderBy: { occurredAt: "asc" } }, + // v1.16.10 — the inventory block reads the per-item entities + // (the same rows the Bestand tab and the intake consumption + // hook move), replacing the legacy MedicationInventoryEvent + // running-sum ledger that no write path feeds any more. + inventoryItems: { orderBy: { createdAt: "asc" } }, intakeEvents: { where: { takenAt: { not: null } }, orderBy: { takenAt: "desc" }, @@ -69,25 +73,32 @@ export const GET = apiHandler( return apiError("Medication not found", 404); } - // Inventory math: running sum of every inventory event. Negative - // when more doses were consumed than purchased — surfaces as a - // low-stock warning rather than a hard error so the UI keeps - // working when the user backdates an inventory event. + // Inventory math over the per-item entities. The response shape is + // locked for the iOS client: `pensRemaining` = count of usable + // containers (ACTIVE / IN_USE with units left), `dosesRemaining` = + // pooled units divided by `unitsPerDose` (floored — consumption + // spills across containers), `weeksOfSupply` keeps the weekly- + // cadence approximation (the canonical GLP-1 case). let inventory: { pensRemaining: number | null; dosesRemaining: number | null; weeksOfSupply: number | null; lowStock: boolean; } | null = null; - if (medication.dosesPerUnit && medication.inventoryEvents.length > 0) { - const pens = medication.inventoryEvents.reduce( - (sum, ev) => sum + ev.delta, + if (medication.inventoryItems.length > 0) { + const usable = medication.inventoryItems.filter( + (item) => + (item.state === "ACTIVE" || item.state === "IN_USE") && + item.unitsRemaining > 0, + ); + const unitsRemaining = usable.reduce( + (sum, item) => sum + item.unitsRemaining, 0, ); - const pensRemaining = Math.max(0, pens); - const dosesRemaining = pensRemaining * medication.dosesPerUnit; - // Approximate weeks-of-supply assuming weekly cadence (the - // canonical GLP-1 case). The Coach snapshot does the same. + const pensRemaining = usable.length; + const dosesRemaining = Math.floor( + unitsRemaining / Math.max(1, medication.unitsPerDose), + ); const weeksOfSupply = dosesRemaining; const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD; inventory = { diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts index cc1223c6c..b832029b5 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts @@ -33,6 +33,10 @@ vi.mock("@/lib/auth/audit", () => ({ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); +vi.mock("@/lib/medications/inventory/consumption", () => ({ + consumeForIntake: vi.fn().mockResolvedValue(undefined), + restoreForIntake: vi.fn().mockResolvedValue(undefined), +})); vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), })); @@ -567,3 +571,173 @@ describe("PUT — v1.15.18 band re-attribution", () => { expect(res.status).toBe(422); }); }); + +// ──────────────────────────────────────────────────────────────────── +// v1.16.10 — inventory consumption seams on PUT / DELETE +// ──────────────────────────────────────────────────────────────────── + +describe("PUT / DELETE — v1.16.10 inventory consumption", () => { + beforeEach(() => { + vi.mocked(getSession).mockResolvedValue(SESSION_TZ as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + oneShot: false, + active: true, + } as never); + vi.mocked(prisma.medication.update).mockResolvedValue({} as never); + stubTwiceDailyMed(); + }); + + it("an edit into taken consumes on the row (no slot move)", async () => { + const { consumeForIntake, restoreForIntake } = await import( + "@/lib/medications/inventory/consumption" + ); + vi.mocked(prisma.medicationIntakeEvent.findFirst) + // PUT lookup — pending row anchored on the 07:00 slot. + .mockResolvedValueOnce({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(7, 0), + takenAt: null, + skipped: false, + } as never) + // lifecycle liveIntake probe + .mockResolvedValue(null as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({ + id: "e1", + scheduledFor: at(7, 0), + takenAt: at(7, 5), + skipped: false, + } as never); + + const res = await PUT(putReq({ takenAt: at(7, 5).toISOString() }), ROUTE_CTX); + expect(res.status).toBe(200); + expect(consumeForIntake).toHaveBeenCalledTimes(1); + expect(consumeForIntake).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + medicationId: "m1", + eventId: "e1", + }), + ); + expect(restoreForIntake).not.toHaveBeenCalled(); + }); + + it("a taken → skipped edit restores the row's stamp and never consumes", async () => { + const { consumeForIntake, restoreForIntake } = await import( + "@/lib/medications/inventory/consumption" + ); + vi.mocked(prisma.medicationIntakeEvent.findFirst) + .mockResolvedValueOnce({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(7, 0), + takenAt: at(7, 5), + skipped: false, + } as never) + .mockResolvedValue(null as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({ + id: "e1", + scheduledFor: at(7, 0), + takenAt: null, + skipped: true, + } as never); + + const res = await PUT(putReq({ skipped: true, takenAt: null }), ROUTE_CTX); + expect(res.status).toBe(200); + expect(restoreForIntake).toHaveBeenCalledTimes(1); + expect(restoreForIntake).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", eventId: "e1" }), + ); + expect(consumeForIntake).not.toHaveBeenCalled(); + }); + + it("a slot-moving taken edit refunds the old row before tombstoning and consumes the converged row — net one", async () => { + const { consumeForIntake, restoreForIntake } = await import( + "@/lib/medications/inventory/consumption" + ); + vi.mocked(prisma.medicationIntakeEvent.findFirst) + .mockResolvedValueOnce({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(7, 0), + takenAt: at(7, 5), + skipped: false, + } as never) + .mockResolvedValue(null as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue( + {} as never, + ); + vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({ + id: "e2", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(19, 0), + takenAt: at(19, 5), + skipped: false, + } as never); + + const res = await PUT( + putReq({ takenAt: at(19, 5).toISOString() }), + ROUTE_CTX, + ); + expect(res.status).toBe(200); + expect(restoreForIntake).toHaveBeenCalledTimes(1); + expect(restoreForIntake).toHaveBeenCalledWith( + expect.objectContaining({ eventId: "e1" }), + ); + expect(consumeForIntake).toHaveBeenCalledTimes(1); + expect(consumeForIntake).toHaveBeenCalledWith( + expect.objectContaining({ eventId: "e2" }), + ); + // The refund runs BEFORE the tombstone write. + const restoreOrder = vi.mocked(restoreForIntake).mock + .invocationCallOrder[0]; + const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update) + .mock.invocationCallOrder[0]; + expect(restoreOrder).toBeLessThan(tombstoneOrder); + }); + + it("DELETE refunds the row's stamp before the tombstone", async () => { + const { consumeForIntake, restoreForIntake } = await import( + "@/lib/medications/inventory/consumption" + ); + vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(7, 0), + takenAt: at(7, 5), + skipped: false, + } as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue( + {} as never, + ); + + const res = await DELETE( + new NextRequest("http://localhost/api/medications/m1/intake/e1", { + method: "DELETE", + }), + ROUTE_CTX, + ); + expect(res.status).toBe(200); + expect(restoreForIntake).toHaveBeenCalledTimes(1); + expect(restoreForIntake).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", eventId: "e1" }), + ); + expect(consumeForIntake).not.toHaveBeenCalled(); + const restoreOrder = vi.mocked(restoreForIntake).mock + .invocationCallOrder[0]; + const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update) + .mock.invocationCallOrder[0]; + expect(restoreOrder).toBeLessThan(tombstoneOrder); + }); +}); diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 997db221a..c6440447f 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -11,6 +11,10 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { updateIntakeEventSchema } from "@/lib/validations/medication"; +import { + consumeForIntake, + restoreForIntake, +} from "@/lib/medications/inventory/consumption"; import { reconcileOneShotState } from "@/lib/medications/lifecycle"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; @@ -241,6 +245,26 @@ export const PUT = apiHandler( syncVersion: { increment: 1 }, }, }); + // v1.16.10 — inventory follows the post-edit state: a row that is + // (now) taken consumes (the stamp keeps an already-consumed row + // untouched — the stamp freezes what the original take pulled, + // even when `unitsPerDose` changed since); a row edited out of + // taken (skip flip, takenAt cleared) refunds its stamp. + if (nextTakenAt !== null && !nextSkipped) { + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: id, + eventId, + intakeAt: nextTakenAt, + }); + } else { + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId, + }); + } } else { // The edit moved the dose to a different slot. Tombstone the original // row (the iOS "a correction is a tombstone + re-insert" model the @@ -248,6 +272,15 @@ export const PUT = apiHandler( // shared canonical-slot upsert, which converges onto any row already at // the target slot (a pending REMINDER row) rather than bare-updating // into an occupied slot and risking a P2002 (audit HIGH-4). + // + // v1.16.10 — refund the source row's consumption stamp BEFORE the + // tombstone; the consume on the converged row below nets the move + // to exactly one consumption. + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId, + }); await prisma.medicationIntakeEvent.update({ where: { id: eventId }, data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, @@ -271,6 +304,15 @@ export const PUT = apiHandler( doseTaken: event.doseTaken, }); updated = applied.row; + if (nextTakenAt !== null && !nextSkipped) { + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: id, + eventId: applied.row.id, + intakeAt: nextTakenAt, + }); + } } await auditLog("medication.intake.update", { @@ -335,6 +377,14 @@ export const DELETE = apiHandler( return apiError("Intake not found", 404); } + // v1.16.10 — a deleted dose record refunds its consumption stamp + // (no-op for a never-consumed row; a re-delete finds it cleared). + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId, + }); + // v1.7.0 sync — soft-delete instead of a hard `delete`. An intake is // an immutable fact, so a "correction" is a tombstone + re-insert // (never an in-place edit). Setting `deletedAt` (+ bumping diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index a1ddc4662..4f7f52e5a 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -53,8 +53,9 @@ vi.mock("@/lib/idempotency", () => ({ fn(...args), })); -vi.mock("@/lib/medications/inventory/service", () => ({ - consumeOneDose: vi.fn().mockResolvedValue(null), +vi.mock("@/lib/medications/inventory/consumption", () => ({ + consumeForIntake: vi.fn().mockResolvedValue(undefined), + restoreForIntake: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/medications/route-guards", () => ({ @@ -412,8 +413,8 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)" }); it("does NOT decrement inventory on a re-post of an already-taken slot (M2)", async () => { - const { consumeOneDose } = await import( - "@/lib/medications/inventory/service" + const { consumeForIntake } = await import( + "@/lib/medications/inventory/consumption" ); // Resolver load → scheduled med. vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce( @@ -455,15 +456,15 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)" expect(res.status).toBe(201); // Re-post of an already-taken slot → no pending→taken transition → // inventory must NOT be consumed. - expect(consumeOneDose).not.toHaveBeenCalled(); + expect(consumeForIntake).not.toHaveBeenCalled(); // The row was updated, not duplicated. expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); }); it("DOES decrement inventory on a genuine pending→taken move (M2)", async () => { - const { consumeOneDose } = await import( - "@/lib/medications/inventory/service" + const { consumeForIntake } = await import( + "@/lib/medications/inventory/consumption" ); vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce( SCHEDULED_MED as never, @@ -501,7 +502,14 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)" ROUTE_PARAMS, ); expect(res.status).toBe(201); - expect(consumeOneDose).toHaveBeenCalledTimes(1); + expect(consumeForIntake).toHaveBeenCalledTimes(1); + expect(consumeForIntake).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + medicationId: "med-1", + eventId: "row-pending", + }), + ); }); it("re-finds and updates the slot when the create races a P2002 (C1)", async () => { diff --git a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts index f733a8821..2a93e23ee 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts @@ -53,6 +53,10 @@ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/medications/inventory/consumption", () => ({ + restoreForIntake: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); @@ -241,3 +245,40 @@ describe("POST /api/medications/[id]/intake/bulk-delete — F-1 H-6 rate limit", ); }); }); + +describe("POST /api/medications/[id]/intake/bulk-delete — v1.16.10 inventory refund", () => { + it("restores each stamped row before the tombstone sweep and ignores unstamped rows", async () => { + const { restoreForIntake } = await import( + "@/lib/medications/inventory/consumption" + ); + const day1 = new Date(Date.UTC(2026, 4, 27, 8, 0, 0)); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ + { + id: "evt-stamped", + scheduledFor: day1, + inventoryConsumption: [{ itemId: "item-1", units: 2 }], + }, + { id: "evt-unstamped", scheduledFor: day1, inventoryConsumption: null }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValueOnce({ + count: 2, + } as never); + + const res = await POST( + postWithBody({ eventIds: ["evt-stamped", "evt-unstamped"] }), + ROUTE_PARAMS, + ); + expect(res.status).toBe(200); + + expect(restoreForIntake).toHaveBeenCalledTimes(1); + expect(restoreForIntake).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", eventId: "evt-stamped" }), + ); + // The refund runs BEFORE the soft-delete sweep. + const restoreOrder = vi.mocked(restoreForIntake).mock + .invocationCallOrder[0]; + const sweepOrder = vi.mocked(prisma.medicationIntakeEvent.updateMany) + .mock.invocationCallOrder[0]; + expect(restoreOrder).toBeLessThan(sweepOrder); + }); +}); diff --git a/src/app/api/medications/[id]/intake/bulk-delete/route.ts b/src/app/api/medications/[id]/intake/bulk-delete/route.ts index 29eb45e76..d6daf54ad 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/route.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/route.ts @@ -11,6 +11,7 @@ import { } from "@/lib/api-response"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { bulkDeleteIntakeEventsSchema } from "@/lib/validations/medication"; +import { restoreForIntake } from "@/lib/medications/inventory/consumption"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { dayKeyForScheduledFor, @@ -106,7 +107,7 @@ export const POST = apiHandler( userId: user.id, medicationId: id, }, - select: { id: true, scheduledFor: true }, + select: { id: true, scheduledFor: true, inventoryConsumption: true }, }); if (targetRows.length === 0) { @@ -118,6 +119,19 @@ export const POST = apiHandler( dayKeysToRefresh.add(dayKeyForScheduledFor(row.scheduledFor, tz)); } + // v1.16.10 — refund each stamped row's inventory consumption before + // the tombstone sweep. Only stamped rows pay the round trip; the + // hook is best-effort and never blocks the delete. + for (const row of targetRows) { + if (row.inventoryConsumption !== null) { + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId: row.id, + }); + } + } + // v1.15.18 LOW-6 — soft-delete to match the single-event DELETE handler // and the iOS tombstone contract: an intake is an immutable fact, so a // bulk "delete" sets `deletedAt` (+ bumps `syncVersion`) so the diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 0fe4beafb..31bad637b 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -17,7 +17,10 @@ import { import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write"; import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; import { withIdempotency } from "@/lib/idempotency"; -import { consumeOneDose } from "@/lib/medications/inventory/service"; +import { + consumeForIntake, + restoreForIntake, +} from "@/lib/medications/inventory/consumption"; import { reconcileOneShotState } from "@/lib/medications/lifecycle"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; @@ -415,35 +418,27 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { } } - // v1.4.25 W19b — pen-inventory dose decrement. Only fires for - // non-skipped intakes; a skipped event is not a consumption event. - // No-op when the medication has no tracked pens (most non-GLP-1 - // meds). Failures here must never block the intake write, so - // errors are swallowed and logged — the intake is the source of - // truth, the inventory is an opt-in companion. - // - // v1.8.2 M2 — gate on an ACTUAL pending→taken transition. An idempotent - // re-post of an already-taken slot updates the row in place but must NOT - // decrement again, else a repeated iOS sync drifts the GLP-1 pen count - // down on every replay. `consumedTransition` is false when the slot was - // already taken (or on a no-downgrade no-op). - let inventoryOutcome: Awaited> = null; + // v1.16.10 — inventory consumption / restore. A taken write consumes + // `unitsPerDose` units (the stamp on the event row makes replays and + // re-posts exactly-once; `consumedTransition` skips the read for the + // no-op echo cases the slot upsert already classified). An explicit + // skip can downgrade a previously-taken row (last-write-wins), so it + // refunds whatever that row's stamp recorded. Both hooks are + // best-effort and never block the intake write. if (!skipped && consumedTransition) { - try { - inventoryOutcome = await consumeOneDose({ - userId: user.id, - medicationId: id, - intakeAt: event.takenAt ?? event.scheduledFor, - }); - } catch (err) { - annotate({ - action: { name: "medication.inventory.consume_error" }, - meta: { - medication_id: id, - message: err instanceof Error ? err.message : String(err), - }, - }); - } + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: id, + eventId: event.id, + intakeAt: event.takenAt ?? event.scheduledFor, + }); + } else if (skipped) { + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId: event.id, + }); } await auditLog("medication.intake", { @@ -453,12 +448,6 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { medicationId: id, eventId: event.id, skipped, - ...(inventoryOutcome - ? { - inventoryItemId: inventoryOutcome.itemId, - inventoryChange: inventoryOutcome.change, - } - : {}), }, }); @@ -471,12 +460,6 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { meta: { medication_id: id, skipped, - ...(inventoryOutcome - ? { - inventory_item_id: inventoryOutcome.itemId, - inventory_change: inventoryOutcome.change, - } - : {}), }, }); diff --git a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts index 799378209..37792abf5 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts @@ -137,8 +137,8 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stoc userId: "user-1", firstUseAt: null, state: "IN_USE", - dosesTotal: 4, - dosesRemaining: 3, + unitsTotal: 4, + unitsRemaining: 3, printedExpiry: null, notes: null, } as never); @@ -154,25 +154,25 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stoc const res = await PATCH(patchReq({ dosesRemaining: 1 }), ROUTE_CTX); expect(res.status).toBe(200); const update = vi.mocked(prisma.medicationInventoryItem.update).mock - .calls[0][0] as { data: { dosesRemaining: number } }; - expect(update.data.dosesRemaining).toBe(1); + .calls[0][0] as unknown as { data: { unitsRemaining: number } }; + expect(update.data.unitsRemaining).toBe(1); }); it("derives USED_UP when corrected to zero", async () => { const res = await PATCH(patchReq({ dosesRemaining: 0 }), ROUTE_CTX); expect(res.status).toBe(200); const update = vi.mocked(prisma.medicationInventoryItem.update).mock - .calls[0][0] as { data: { dosesRemaining: number; state: string } }; - expect(update.data.dosesRemaining).toBe(0); + .calls[0][0] as unknown as { data: { unitsRemaining: number; state: string } }; + expect(update.data.unitsRemaining).toBe(0); expect(update.data.state).toBe("USED_UP"); }); - it("clamps a raise above the item's capacity to dosesTotal", async () => { + it("clamps a raise above the item's capacity to unitsTotal", async () => { const res = await PATCH(patchReq({ dosesRemaining: 99 }), ROUTE_CTX); expect(res.status).toBe(200); const update = vi.mocked(prisma.medicationInventoryItem.update).mock - .calls[0][0] as { data: { dosesRemaining: number } }; - expect(update.data.dosesRemaining).toBe(4); + .calls[0][0] as unknown as { data: { unitsRemaining: number } }; + expect(update.data.unitsRemaining).toBe(4); }); it("rejects a negative correction with 422", async () => { diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts index 684026b20..d31d2b47e 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts @@ -82,7 +82,7 @@ export const PATCH = apiHandler( // commutative — applying them in any order produces the same row. let nextFirstUseAt = existing.firstUseAt; let nextState = existing.state; - let nextDosesRemaining = existing.dosesRemaining; + let nextUnitsRemaining = existing.unitsRemaining; let nextPrintedExpiry = existing.printedExpiry; if (markAsFirstUseAt) { @@ -98,14 +98,15 @@ export const PATCH = apiHandler( if (dosesRemaining !== undefined) { // v1.16.1 — stock correction (Bestand tab adjust / withdraw). - // Clamp to the item's capacity; the state-machine re-run below - // owns every state consequence (0 ⇒ USED_UP, a raise out of 0 - // re-evaluates against the expiry clocks). - nextDosesRemaining = Math.min(dosesRemaining, existing.dosesTotal); + // The wire field counts UNITS. Clamp to the item's capacity; the + // state-machine re-run below owns every state consequence (0 ⇒ + // USED_UP, a raise out of 0 re-evaluates against the expiry + // clocks). + nextUnitsRemaining = Math.min(dosesRemaining, existing.unitsTotal); } if (markAsUsedUp === true) { - nextDosesRemaining = 0; + nextUnitsRemaining = 0; nextState = "USED_UP"; } @@ -124,8 +125,8 @@ export const PATCH = apiHandler( nextState = computeInventoryState( { state: nextState, - dosesTotal: existing.dosesTotal, - dosesRemaining: nextDosesRemaining, + unitsTotal: existing.unitsTotal, + unitsRemaining: nextUnitsRemaining, firstUseAt: nextFirstUseAt, printedExpiry: nextPrintedExpiry, }, @@ -137,7 +138,7 @@ export const PATCH = apiHandler( data: { state: nextState, firstUseAt: nextFirstUseAt, - dosesRemaining: nextDosesRemaining, + unitsRemaining: nextUnitsRemaining, printedExpiry: nextPrintedExpiry, expiresAt: nextExpiresAt, notes: notes === undefined ? existing.notes : notes, @@ -185,7 +186,7 @@ export const DELETE = apiHandler( medicationId: id, itemId, finalState: existing.state, - dosesRemaining: existing.dosesRemaining, + unitsRemaining: existing.unitsRemaining, }, }); diff --git a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts index 4d23b8c1f..14b1cd9d2 100644 --- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts @@ -165,8 +165,9 @@ describe("POST /api/medications/[id]/inventory", () => { userId: "user-1", medicationId: "med-1", state: "ACTIVE", - dosesTotal: 4, - dosesRemaining: 4, + containerType: "OTHER", + unitsTotal: 4, + unitsRemaining: 4, firstUseAt: null, printedExpiry: new Date(printed), expiresAt: new Date(printed), @@ -198,8 +199,8 @@ describe("PATCH /api/medications/[id]/inventory/[itemId]", () => { userId: "user-1", medicationId: "med-1", state: "ACTIVE" as const, - dosesTotal: 4, - dosesRemaining: 4, + unitsTotal: 4, + unitsRemaining: 4, firstUseAt: null, printedExpiry: null, purchasedAt: null, @@ -243,12 +244,12 @@ describe("PATCH /api/medications/[id]/inventory/[itemId]", () => { ...existingActive, state: "IN_USE", firstUseAt: new Date("2026-05-01"), - dosesRemaining: 2, + unitsRemaining: 2, } as never); vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({ ...existingActive, state: "USED_UP", - dosesRemaining: 0, + unitsRemaining: 0, } as never); const res = await PATCH( @@ -264,7 +265,7 @@ describe("PATCH /api/medications/[id]/inventory/[itemId]", () => { .calls[0][0]; expect(updateCall.data).toMatchObject({ state: "USED_UP", - dosesRemaining: 0, + unitsRemaining: 0, }); }); @@ -331,7 +332,7 @@ describe("DELETE /api/medications/[id]/inventory/[itemId]", () => { userId: "user-1", medicationId: "med-1", state: "EXPIRED", - dosesRemaining: 2, + unitsRemaining: 2, } as never); vi.mocked(prisma.medicationInventoryItem.delete).mockResolvedValue({ id: "inv-1", diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index 41b3a5ebf..0f4bbb205 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -90,13 +90,17 @@ export const POST = apiHandler( return returnAllZodIssues(parsed.error, 422); } - const { dosesTotal, printedExpiry, purchasedAt, notes } = parsed.data; + const { dosesTotal, containerType, printedExpiry, purchasedAt, notes } = + parsed.data; const created = await prisma.medicationInventoryItem.create({ + // The wire field `dosesTotal` counts UNITS (tablets / ampoules / + // puffs) — the v1.16.10 rename to `unitsTotal` happens here. data: buildCreateInventoryInput({ userId: user.id, medicationId: id, - dosesTotal, + unitsTotal: dosesTotal, + containerType: containerType ?? "OTHER", printedExpiry: printedExpiry ?? null, purchasedAt: purchasedAt ?? null, notes: notes ?? null, @@ -109,7 +113,8 @@ export const POST = apiHandler( details: { medicationId: id, itemId: created.id, - dosesTotal, + unitsTotal: dosesTotal, + containerType: created.containerType, }, }); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index b7c729399..d8b7b4263 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -222,6 +222,7 @@ export const PUT = apiHandler( category, treatmentClass, dosesPerUnit, + unitsPerDose, deliveryForm, trackInjectionSites, allowedInjectionSites, @@ -515,6 +516,9 @@ export const PUT = apiHandler( ...(dose !== undefined && { dose }), ...(treatmentClass !== undefined && { treatmentClass }), ...(dosesPerUnit !== undefined && { dosesPerUnit }), + // v1.16.10 — units consumed per dose. Field-by-field; the stamp on + // already-taken intake events freezes their recorded consumption. + ...(unitsPerDose !== undefined && { unitsPerDose }), ...(deliveryForm !== undefined && { deliveryForm }), // v1.8.5 — injection-site tracking opt-in + per-medication allowed // sites. Field-by-field; `false` / `[]` are valid explicit values diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index d5f824d08..a09280791 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -8,6 +8,8 @@ vi.mock("@/lib/db", () => ({ findUnique: vi.fn(), findFirst: vi.fn(), update: vi.fn(), + // v1.16.10 — the slot-move path re-inserts via the shared upsert. + create: vi.fn(), // v1.4.39 W-SERVER-FIX — `scope=today` now backfills missing // rows for schedules whose window opens today (covers daily // meds with `daysOfWeek: null` whose reminder hasn't fired yet). @@ -18,6 +20,8 @@ vi.mock("@/lib/db", () => ({ // v1.4.39 W-SERVER-FIX — today's projection reads active meds + // their schedules to know what to backfill. findMany: vi.fn(), + // v1.15.18 — the band resolver loads the medication via findFirst. + findFirst: vi.fn(), }, medicationComplianceRollup: { findFirst: vi.fn(), @@ -44,6 +48,11 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/medications/inventory/consumption", () => ({ + consumeForIntake: vi.fn().mockResolvedValue(undefined), + restoreForIntake: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ @@ -62,6 +71,10 @@ vi.mock("next/headers", () => ({ import { GET, POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { + consumeForIntake, + restoreForIntake, +} from "@/lib/medications/inventory/consumption"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; const SESSION_OK = { @@ -366,6 +379,147 @@ describe("POST /api/medications/intake", () => { where: { id: "e1" }, data: { takenAt: null, skipped: true, syncVersion: { increment: 1 } }, }); + // v1.16.10 — a row toggled out of taken refunds its consumption + // stamp (no-op for a never-consumed row) and never consumes. + expect(restoreForIntake).toHaveBeenCalledTimes(1); + expect(restoreForIntake).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", eventId: "e1" }), + ); + expect(consumeForIntake).not.toHaveBeenCalled(); + }); + + // ── v1.16.10 — inventory consumption seams ────────────────────────── + + it("taken (no slot move) consumes inventory exactly once on the toggled row", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const slot = new Date("2026-05-18T10:00:00.000Z"); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: slot, + takenAt: null, + skipped: false, + injectionSite: null, + doseTaken: null, + medication: { + deliveryForm: "ORAL", + trackInjectionSites: false, + allowedInjectionSites: [], + }, + } as never); + // Band resolver: schedule-less medication → ad-hoc attribution on the + // takenAt, which equals the existing anchor → no slot move. + vi.mocked(prisma.medication.findFirst).mockResolvedValue({ + id: "m1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + schedules: [], + scheduleRevisions: [], + } as never); + vi.mocked(prisma.$transaction).mockImplementation(async (arg: unknown) => + Array.isArray(arg) ? Promise.all(arg) : (arg as (c: unknown) => unknown)(prisma), + ); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({ + id: "e1", + takenAt: slot, + skipped: false, + scheduledFor: slot, + } as never); + vi.mocked(prisma.medication.update).mockResolvedValue({} as never); + + const res = await POST( + req({ + intakeId: "e1", + status: "taken", + takenAt: slot.toISOString(), + }), + ); + expect(res.status).toBe(200); + expect(consumeForIntake).toHaveBeenCalledTimes(1); + expect(consumeForIntake).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + medicationId: "m1", + eventId: "e1", + }), + ); + expect(restoreForIntake).not.toHaveBeenCalled(); + }); + + it("taken with a slot move restores the old row's stamp before tombstoning and consumes the converged row — net one", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const oldSlot = new Date("2026-05-18T10:00:00.000Z"); + const takenAt = new Date("2026-05-18T15:00:00.000Z"); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: oldSlot, + takenAt: oldSlot, + skipped: false, + injectionSite: null, + doseTaken: null, + medication: { + deliveryForm: "ORAL", + trackInjectionSites: false, + allowedInjectionSites: [], + }, + } as never); + // Schedule-less → ad-hoc attribution on the new takenAt → slot moves. + vi.mocked(prisma.medication.findFirst).mockResolvedValue({ + id: "m1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + schedules: [], + scheduleRevisions: [], + } as never); + // Tombstone write on e1, then the shared upsert: no row at the target + // slot → create the converged row e2. + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({ + id: "e1", + } as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({ + id: "e2", + takenAt, + skipped: false, + idempotencyKey: null, + scheduledFor: takenAt, + source: "WEB", + createdAt: takenAt, + } as never); + vi.mocked(prisma.medication.update).mockResolvedValue({} as never); + + const res = await POST( + req({ + intakeId: "e1", + status: "taken", + takenAt: takenAt.toISOString(), + }), + ); + expect(res.status).toBe(200); + // Net one: refund the source row, consume the converged row. + expect(restoreForIntake).toHaveBeenCalledTimes(1); + expect(restoreForIntake).toHaveBeenCalledWith( + expect.objectContaining({ eventId: "e1" }), + ); + expect(consumeForIntake).toHaveBeenCalledTimes(1); + expect(consumeForIntake).toHaveBeenCalledWith( + expect.objectContaining({ eventId: "e2" }), + ); + // The refund runs BEFORE the tombstone write on the source row. + const restoreOrder = vi.mocked(restoreForIntake).mock + .invocationCallOrder[0]; + const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update) + .mock.invocationCallOrder[0]; + expect(restoreOrder).toBeLessThan(tombstoneOrder); }); }); diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index 92d669497..61c827638 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -42,6 +42,10 @@ vi.mock("@/lib/idempotency", () => ({ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); +vi.mock("@/lib/medications/inventory/consumption", () => ({ + consumeForIntake: vi.fn().mockResolvedValue(undefined), + restoreForIntake: vi.fn().mockResolvedValue(undefined), +})); vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined), dayKeyForScheduledFor: vi.fn().mockReturnValue("2026-01-01"), @@ -64,6 +68,10 @@ import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { + consumeForIntake, + restoreForIntake, +} from "@/lib/medications/inventory/consumption"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -805,3 +813,180 @@ describe("POST /api/medications/intake/bulk — v1.15.20 band attribution", () = ); }); }); + +// ──────────────────────────────────────────────────────────────────── +// v1.16.10 — inventory consumption seams +// ──────────────────────────────────────────────────────────────────── + +describe("POST /api/medications/intake/bulk — v1.16.10 inventory consumption", () => { + const SCHEDULED_MED = { + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules: [ + { + id: "s1", + windowStart: "07:00", + windowEnd: "07:00", + daysOfWeek: null, + timesOfDay: ["07:00", "19:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + }, + ], + }; + + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-15T18:00:00.000Z")); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + { id: "med-1" }, + ] as never); + vi.mocked(prisma.medication.findFirst).mockResolvedValue( + SCHEDULED_MED as never, + ); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("consumes exactly once per taken entry, on the landed row id", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ + { + id: "row-pending", + takenAt: null, + skipped: false, + idempotencyKey: null, + scheduledFor: new Date("2026-06-15T05:00:00Z"), + source: "REMINDER", + createdAt: new Date("2026-06-15T00:00:00Z"), + }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({ + id: "row-pending", + } as never); + + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-1", + scheduledFor: "2026-06-15T05:00:30.000Z", + takenAt: "2026-06-15T05:02:00.000Z", + }, + ], + }), + ); + expect(res.status).toBe(200); + expect(consumeForIntake).toHaveBeenCalledTimes(1); + expect(consumeForIntake).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + medicationId: "med-1", + eventId: "row-pending", + }), + ); + expect(restoreForIntake).not.toHaveBeenCalled(); + }); + + it("C2 — a pending echo onto an already-taken slot never reaches consume", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ + { + id: "row-taken", + takenAt: new Date("2026-06-15T05:01:00Z"), + skipped: false, + idempotencyKey: null, + scheduledFor: new Date("2026-06-15T05:00:00Z"), + source: "WEB", + createdAt: new Date("2026-06-15T05:01:00Z"), + }, + ] as never); + + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-1", + scheduledFor: "2026-06-15T05:00:30.000Z", + // no takenAt, skipped defaults false → pending echo + }, + ], + }), + ); + expect(res.status).toBe(200); + expect(consumeForIntake).not.toHaveBeenCalled(); + expect(restoreForIntake).not.toHaveBeenCalled(); + }); + + it("an explicit skip entry restores the slot row's stamp instead of consuming", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ + { + id: "row-taken", + takenAt: new Date("2026-06-15T05:01:00Z"), + skipped: false, + idempotencyKey: null, + scheduledFor: new Date("2026-06-15T05:00:00Z"), + source: "WEB", + createdAt: new Date("2026-06-15T05:01:00Z"), + }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({ + id: "row-taken", + } as never); + + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-1", + scheduledFor: "2026-06-15T05:00:30.000Z", + skipped: true, + }, + ], + }), + ); + expect(res.status).toBe(200); + expect(consumeForIntake).not.toHaveBeenCalled(); + expect(restoreForIntake).toHaveBeenCalledTimes(1); + expect(restoreForIntake).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", eventId: "row-taken" }), + ); + }); + + it("an idempotency-key replay (duplicate) never consumes again", async () => { + // An off-window take (00:20 local night, no scheduledFor) resolves + // ad-hoc → the resolver-null branch runs its replay pre-check and + // classifies the re-submission as a duplicate. + vi.setSystemTime(new Date("2026-06-15T00:30:00.000Z")); + vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValueOnce({ + id: "row-existing", + } as never); + + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-1", + takenAt: "2026-06-15T00:20:00.000Z", + idempotencyKey: "ios-replay-1", + }, + ], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { duplicates: number; entries: Array<{ status: string }> }; + }; + expect(body.data.duplicates).toBe(1); + expect(body.data.entries[0].status).toBe("duplicate"); + expect(consumeForIntake).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 5a1440f62..aa231b9ea 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -58,6 +58,10 @@ import { resolveSlotInstantForWrite, } from "@/lib/medications/scheduling/slot-upsert"; import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write"; +import { + consumeForIntake, + restoreForIntake, +} from "@/lib/medications/inventory/consumption"; import { boundedTakenAtSchema, injectionSiteEnum, @@ -407,7 +411,8 @@ async function postBulk(request: NextRequest): Promise { if (applied.noDowngradeNoOp) { // C2 — pending echo onto an already-actioned slot. Report it as a // duplicate so the iOS cursor advances WITHOUT downgrading the - // recorded dose. + // recorded dose. A pending echo is never a taken write, so it + // must never reach the inventory consume below. duplicates += 1; results.push({ index: i, status: "duplicate", id: applied.row.id }); } else if (applied.outcome === "updated") { @@ -417,6 +422,25 @@ async function postBulk(request: NextRequest): Promise { inserted += 1; results.push({ index: i, status: "inserted", id: applied.row.id }); } + // v1.16.10 — inventory: a taken write consumes (the stamp on the + // row makes a replayed sync batch exactly-once), an explicit skip + // refunds a previously-taken row's stamp. Pending echoes carry + // neither flag and never touch stock. + if (!applied.noDowngradeNoOp && isExplicitTaken) { + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: entry.medicationId, + eventId: applied.row.id, + intakeAt: entry.takenAt as Date, + }); + } else if (!applied.noDowngradeNoOp && isExplicitSkip) { + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId: applied.row.id, + }); + } } else { // Resolver-null: PRN / off-slot / future-slot-guarded. Before the // standalone insert, two guards keep the slot from forking into a @@ -485,6 +509,22 @@ async function postBulk(request: NextRequest): Promise { inserted += 1; results.push({ index: i, status: "inserted", id: applied.row.id }); } + // v1.16.10 — same inventory rule as the canonical-slot branch. + if (!applied.noDowngradeNoOp && isExplicitTaken) { + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: entry.medicationId, + eventId: applied.row.id, + intakeAt: entry.takenAt as Date, + }); + } else if (!applied.noDowngradeNoOp && isExplicitSkip) { + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId: applied.row.id, + }); + } } else { // Genuinely standalone. Anchor the row on the intake instant — // the documented ad-hoc contract (`scheduledFor = takenAt`) — so @@ -515,6 +555,18 @@ async function postBulk(request: NextRequest): Promise { }); inserted += 1; results.push({ index: i, status: "inserted", id: row.id }); + // v1.16.10 — a fresh standalone taken row consumes inventory + // units (a fresh skip / pending row has nothing to consume or + // refund). + if (isExplicitTaken) { + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: entry.medicationId, + eventId: row.id, + intakeAt: entry.takenAt as Date, + }); + } } } const dayKey = dayKeyForScheduledFor( diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index cbc3461a5..2ffc077f3 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -42,6 +42,10 @@ import { resolveSlotForWriteByBand, } from "@/lib/medications/scheduling/slot-upsert"; import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write"; +import { + consumeForIntake, + restoreForIntake, +} from "@/lib/medications/inventory/consumption"; import { injectionSiteEnum, type InjectionSiteValue, @@ -540,11 +544,32 @@ export const POST = apiHandler(async (request: NextRequest) => { data: { snoozedUntil: null }, }), ]); + // v1.16.10 — the toggle recorded a take; consume inventory units. + // The stamp on the row makes a re-toggle of an already-taken slot + // a no-op, so the count only moves on the genuine pending→taken + // (or skipped→taken) transition. + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: existing.medicationId, + eventId: intakeId, + intakeAt: resolvedTakenAt, + }); } else { // The take re-attributed to a different slot. Tombstone the source row // and route the dose through the shared canonical-slot upsert, which // converges onto any row already at the target slot rather than // bare-updating into an occupied slot (P2002-safe). + // + // v1.16.10 — refund the source row's consumption stamp BEFORE the + // tombstone, then consume on the converged row below: a slot move + // is a re-binding of one real dose, so the stock nets exactly one + // consumption. + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId: intakeId, + }); await prisma.medicationIntakeEvent.update({ where: { id: intakeId }, data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, @@ -574,6 +599,15 @@ export const POST = apiHandler(async (request: NextRequest) => { where: { id: existing.medicationId }, data: { snoozedUntil: null }, }); + // v1.16.10 — consume on the converged row (the old row's stamp + // was refunded above, so the move nets one consumption). + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: existing.medicationId, + eventId: applied.row.id, + intakeAt: resolvedTakenAt, + }); } } else if (status === "skipped") { updated = await prisma.medicationIntakeEvent.update({ @@ -581,6 +615,13 @@ export const POST = apiHandler(async (request: NextRequest) => { // v1.7.0 sync — bump the reconciliation counter on the skip toggle. data: { takenAt: null, skipped: true, syncVersion: { increment: 1 } }, }); + // v1.16.10 — the row stopped being taken; refund whatever its + // consumption stamp recorded (no-op for a never-consumed row). + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId: intakeId, + }); } else { // snoozed: snoozedUntil lives on the Medication row. const until = snoozedUntil ?? new Date(Date.now() + 30 * 60_000); // default +30min diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 2da559eaa..f8da7dde9 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -245,6 +245,7 @@ export const POST = apiHandler(async (request: NextRequest) => { category, treatmentClass, dosesPerUnit, + unitsPerDose, deliveryForm, trackInjectionSites, allowedInjectionSites, @@ -312,6 +313,8 @@ export const POST = apiHandler(async (request: NextRequest) => { // wire schema; Prisma fills the default GENERIC when omitted. ...(treatmentClass !== undefined && { treatmentClass }), ...(dosesPerUnit !== undefined && { dosesPerUnit }), + // v1.16.10 — units consumed per dose; Prisma defaults to 1 when omitted. + ...(unitsPerDose !== undefined && { unitsPerDose }), // v1.6.0 — route of administration; Prisma defaults to ORAL when omitted. ...(deliveryForm !== undefined && { deliveryForm }), // v1.8.5 — injection-site tracking opt-in + per-medication allowed diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index ce8c8f72d..69b863a78 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -55,6 +55,11 @@ vi.mock("@/lib/logging/context", () => ({ })), })); +vi.mock("@/lib/medications/inventory/consumption", () => ({ + consumeForIntake: vi.fn().mockResolvedValue(undefined), + restoreForIntake: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); @@ -257,6 +262,15 @@ describe("Telegram webhook — callback dispatch", () => { }); // Intake write + snooze reset commit atomically. expect(prisma.$transaction).toHaveBeenCalledTimes(1); + // v1.16.10 — the confirmed take consumes inventory units (after the + // intake transaction committed, on the created row). + const { consumeForIntake } = await import( + "@/lib/medications/inventory/consumption" + ); + expect(consumeForIntake).toHaveBeenCalledTimes(1); + expect(consumeForIntake).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", medicationId: "med-1" }), + ); expect(answerTelegramCallbackQuery).toHaveBeenCalledTimes(1); expect(deleteMessage).toHaveBeenCalledWith( @@ -329,6 +343,13 @@ describe("Telegram webhook — callback dispatch", () => { expect(snoozedUntil.getMinutes()).toBe(59); // Skip row + rest-of-day snooze commit atomically. expect(prisma.$transaction).toHaveBeenCalledTimes(1); + // v1.16.10 — an explicit skip refunds a previously-consumed stamp + // (no-op for a never-consumed row) and never consumes. + const { consumeForIntake, restoreForIntake } = await import( + "@/lib/medications/inventory/consumption" + ); + expect(consumeForIntake).not.toHaveBeenCalled(); + expect(restoreForIntake).toHaveBeenCalledTimes(1); }); it("'ack:' answers the callback without creating an intake event", async () => { diff --git a/src/app/api/telegram/webhook/route.ts b/src/app/api/telegram/webhook/route.ts index 6a95195a6..e5e5620ef 100644 --- a/src/app/api/telegram/webhook/route.ts +++ b/src/app/api/telegram/webhook/route.ts @@ -20,6 +20,10 @@ import { resolveSlotForWriteByBand, } from "@/lib/medications/scheduling/slot-upsert"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { + consumeForIntake, + restoreForIntake, +} from "@/lib/medications/inventory/consumption"; import { localHmAsUtc } from "@/lib/tz/local-day"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; @@ -268,8 +272,9 @@ async function markMedicationTaken( // an aborted Postgres transaction, so the rare create race rolls the // whole write back instead of converging in place — Telegram // redelivers the non-200 update and the retry finds the raced row. - scheduledFor = await prisma.$transaction(async (tx) => { + const written = await prisma.$transaction(async (tx) => { let slotScheduledFor: Date; + let eventId: string; if (canonicalSlot) { const applied = await applyCanonicalSlotWrite({ client: tx, @@ -285,6 +290,7 @@ async function markMedicationTaken( attributionSource: "AUTO", }); slotScheduledFor = applied.row.scheduledFor; + eventId = applied.row.id; } else { // Ad-hoc / PRN — standalone row under the documented contract // (`scheduledFor = takenAt`). @@ -300,12 +306,25 @@ async function markMedicationTaken( }, }); slotScheduledFor = created.scheduledFor; + eventId = created.id; } await tx.medication.update({ where: { id: medication.id }, data: { snoozedUntil: null }, }); - return slotScheduledFor; + return { slotScheduledFor, eventId }; + }); + scheduledFor = written.slotScheduledFor; + // v1.16.10 — the button confirmed a take; consume inventory units. + // Runs after the intake transaction committed (the dose record must + // never hinge on the stock write); the stamp keeps a Telegram + // redelivery exactly-once. + await consumeForIntake({ + client: prisma, + userId, + medicationId: medication.id, + eventId: written.eventId, + intakeAt: takenAt, }); // The confirmation must reflect on the next read (today feed, card // pill, compliance) rather than wait out the cache TTL. @@ -494,8 +513,9 @@ async function handleCallback(update: TelegramUpdate) { // re-reminded for a recorded skip or silenced without one. Same // P2002 trade-off as the take path — the rare create race rolls // back and Telegram's redelivery converges on the raced row. - skippedScheduledFor = await prisma.$transaction(async (tx) => { + const writtenSkip = await prisma.$transaction(async (tx) => { let slotScheduledFor: Date; + let eventId: string; if (canonicalSlot) { const applied = await applyCanonicalSlotWrite({ client: tx, @@ -510,6 +530,7 @@ async function handleCallback(update: TelegramUpdate) { createSource: "REMINDER", }); slotScheduledFor = applied.row.scheduledFor; + eventId = applied.row.id; } else { const created = await tx.medicationIntakeEvent.create({ data: { @@ -523,12 +544,22 @@ async function handleCallback(update: TelegramUpdate) { }, }); slotScheduledFor = created.scheduledFor; + eventId = created.id; } await tx.medication.update({ where: { id: medication.id }, data: { snoozedUntil: endOfDay }, }); - return slotScheduledFor; + return { slotScheduledFor, eventId }; + }); + skippedScheduledFor = writtenSkip.slotScheduledFor; + // v1.16.10 — an explicit skip can downgrade a previously-taken + // slot row (last-write-wins); refund whatever its consumption + // stamp recorded. No-op for a never-consumed row. + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId: writtenSkip.eventId, }); invalidateUserMedications(user.id, { evict: true }); } diff --git a/src/components/medications/__tests__/add-inventory-dialog.test.tsx b/src/components/medications/__tests__/add-inventory-dialog.test.tsx new file mode 100644 index 000000000..35e39d1c0 --- /dev/null +++ b/src/components/medications/__tests__/add-inventory-dialog.test.tsx @@ -0,0 +1,173 @@ +/** + * v1.16.10 — `` register-flow coverage. + * + * Pins the multi-unit quantity input: the segmented Dosen | Einheiten + * control only surfaces when a dose spans more than one unit, the live + * conversion line renders next to the quantity, the POST always carries + * UNITS, and the container-type select defaults from the delivery form. + * + * Project convention is SSR-only component tests (`renderToStaticMarkup`, + * no `@testing-library/react`) plus source-string structural assertions + * for the interactive plumbing an SSR mount can't exercise. + */ +import { describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +// The Radix Dialog / Select portal at runtime, so their bodies never +// materialise in static markup. Collapse the primitives to plain +// wrappers (same trick as the LogInjectionSiteDialog suite). +vi.mock("@/components/ui/dialog", () => { + const Pass = ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ); + return { + Dialog: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), + DialogContent: Pass, + DialogDescription: Pass, + DialogFooter: Pass, + DialogHeader: Pass, + DialogTitle: Pass, + }; +}); + +vi.mock("@/components/ui/select", () => { + const Pass = ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ); + return { + Select: ({ + value, + children, + }: { + value: string; + children?: React.ReactNode; + }) =>
{children}
, + SelectContent: Pass, + SelectItem: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), + SelectTrigger: Pass, + SelectValue: () => null, + }; +}); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: vi.fn(() => ({ data: undefined, isLoading: false })), + useQueryClient: () => ({ + invalidateQueries: vi.fn().mockResolvedValue(undefined), + }), +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +const { AddInventoryDialog } = await import( + "../sections/inventory-section" +); + +function render(node: React.ReactNode, locale: "en" | "de" = "de"): string { + return renderToStaticMarkup( + {node}, + ); +} + +const src = readFileSync( + resolve(__dirname, "../sections/inventory-section.tsx"), + "utf8", +); + +describe("", () => { + it("surfaces the Dosen | Einheiten segmented control only for multi-unit doses", () => { + const multi = render( + {}} + />, + ); + expect(multi).toContain('data-slot="inventory-quantity-mode"'); + expect(multi).toContain(">Dosen<"); + expect(multi).toContain(">Einheiten<"); + + const single = render( + {}} + />, + ); + expect(single).not.toContain('data-slot="inventory-quantity-mode"'); + }); + + it("renders the live conversion next to the prefilled quantity (units mode → dose equivalent)", () => { + // Prefill 28 units at 4 units per dose → "≈ 7 Dosen". + const html = render( + {}} + />, + ); + expect(html).toContain('data-slot="inventory-quantity-conversion"'); + expect(html).toContain("≈ 7 Dosen"); + }); + + it("defaults the container type from the caller (delivery-form mapping upstream)", () => { + const html = render( + {}} + />, + ); + expect(html).toContain('data-mock-select-value="PEN"'); + // All six container kinds are offered. + for (const label of [ + "Pen", + "Ampulle", + "Tablettenpackung", + "Inhalator", + "Flasche", + "Sonstiges", + ]) { + expect(html).toContain(label); + } + }); + + it("always submits UNITS: dose-mode input multiplies by unitsPerDose before the POST", () => { + // Interactive mode-switching is out of SSR reach; the structural + // contract lives in the source: the submitted value is the converted + // unit count, and the wire field stays `dosesTotal`. + expect(src).toMatch( + /const units =\s*\n?\s*effectiveMode === "doses" \? parsed \* unitsPerDose : parsed;/, + ); + expect(src).toMatch(/dosesTotal: units,/); + expect(src).toMatch(/containerType,/); + }); + + it("bounds the quantity to 1000 units (dose mode scales the max down)", () => { + expect(src).toContain("units >= 1 && units <= 1000"); + expect(src).toContain('Math.floor(1000 / unitsPerDose)'); + }); + + it("maps the delivery form onto the register default (PEN for INJECTION, BLISTER for ORAL, OTHER otherwise)", () => { + expect(src).toMatch( + /if \(deliveryForm === "INJECTION"\) return "PEN";\s*\n\s*if \(deliveryForm === "ORAL"\) return "BLISTER";\s*\n\s*return "OTHER";/, + ); + }); +}); diff --git a/src/components/medications/__tests__/supply-runway.test.ts b/src/components/medications/__tests__/supply-runway.test.ts index bb08eb09b..727cb9f6e 100644 --- a/src/components/medications/__tests__/supply-runway.test.ts +++ b/src/components/medications/__tests__/supply-runway.test.ts @@ -106,3 +106,27 @@ describe("estimateRunwayDays", () => { ).toBe(28); }); }); + +describe("estimateRunwayDays — v1.16.10 multi-unit doses", () => { + it("runs off the dose-derived count: floor(units / unitsPerDose) before dividing by daily consumption", () => { + // 9 units at 2 units per dose → floor(9 / 2) = 4 doses; a daily + // single-dose schedule then reads 4 days of runway. The call sites + // (Übersicht supply row, Bestand summary) derive the dose count this + // way before handing it to the estimator. + const unitsRemaining = 9; + const unitsPerDose = 2; + const dosesRemaining = Math.floor(unitsRemaining / unitsPerDose); + expect( + estimateRunwayDays(dosesRemaining, [schedule({ timesOfDay: ["08:00"] })]), + ).toBe(4); + }); + + it("a partial dose's worth of units never counts as a day", () => { + // 1 unit at 2 units per dose is not a dose → no runway. + expect( + estimateRunwayDays(Math.floor(1 / 2), [ + schedule({ timesOfDay: ["08:00"] }), + ]), + ).toBeNull(); + }); +}); diff --git a/src/components/medications/detail/MedicationDetailTabs.tsx b/src/components/medications/detail/MedicationDetailTabs.tsx index 25f90fb8b..fc6738656 100644 --- a/src/components/medications/detail/MedicationDetailTabs.tsx +++ b/src/components/medications/detail/MedicationDetailTabs.tsx @@ -125,6 +125,8 @@ export interface MedicationDetailSnapshot { treatmentClass?: string; deliveryForm?: string; dosesPerUnit?: number | null; + /** v1.16.10 — inventory units one dose consumes (default 1). */ + unitsPerDose?: number | null; trackInjectionSites?: boolean; allowedInjectionSites?: string[]; active: boolean; @@ -179,6 +181,7 @@ function snapshotToWizardPayload( treatmentClass: med.treatmentClass, deliveryForm: med.deliveryForm, dosesPerUnit: med.dosesPerUnit ?? null, + unitsPerDose: med.unitsPerDose ?? 1, trackInjectionSites: med.trackInjectionSites ?? false, allowedInjectionSites: med.allowedInjectionSites ?? [], notificationsEnabled: med.notificationsEnabled, @@ -314,8 +317,8 @@ export function MedicationDetailTabs({ return await apiGet<{ items: Array<{ state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP"; - dosesTotal: number; - dosesRemaining: number; + unitsTotal: number; + unitsRemaining: number; }>; } | null>(`/api/medications/${id}/inventory`); } catch { @@ -328,11 +331,18 @@ export function MedicationDetailTabs({ const inventoryItems = Array.isArray(inventory?.items) ? inventory.items : []; const liveItems = inventoryItems.filter((i) => i.state !== "USED_UP"); - const dosesRemaining = liveItems.reduce( - (sum, i) => sum + i.dosesRemaining, + // 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. + const perDose = Math.max(1, medication.unitsPerDose ?? 1); + const unitsRemaining = liveItems.reduce( + (sum, i) => sum + i.unitsRemaining, 0, ); - const dosesTotal = liveItems.reduce((sum, i) => sum + i.dosesTotal, 0); + const unitsTotal = liveItems.reduce((sum, i) => sum + i.unitsTotal, 0); + const dosesRemaining = Math.floor(unitsRemaining / perDose); + const dosesTotal = Math.floor(unitsTotal / perDose); const runwayDays = estimateRunwayDays(dosesRemaining, medication.schedules); return ( @@ -422,6 +432,16 @@ export function MedicationDetailTabs({ remaining: dosesRemaining, total: dosesTotal, })} + {perDose > 1 && ( + + {" ("} + {t("medications.detail.bestand.unitsDetail", { + remaining: unitsRemaining, + total: unitsTotal, + })} + {")"} + + )} {runwayDays !== null && ( {" · "} @@ -562,6 +582,8 @@ export function MedicationDetailTabs({ diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index 195763ce1..93e062d5d 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -6,20 +6,29 @@ * v1.16.1 — the tab grows its write affordances: * * - REGISTER: a "+" flow (header button + the empty-state CTA) that - * records a new pen / pack via `POST /api/medications/[id]/inventory` - * — quantity in doses/units, optional printed expiry. The quantity - * prefills from the medication's `dosesPerUnit` when configured. + * records a new pack / container via + * `POST /api/medications/[id]/inventory` — quantity in units, + * optional printed expiry. The quantity prefills from the + * medication's `dosesPerUnit` when configured. * - CORRECT: a per-item adjust flow (`PATCH …/inventory/[itemId]` with * `dosesRemaining`) for stock corrections and manual withdrawals — * the count is set absolutely, clamped server-side to the item's * capacity, and the canonical state machine derives the next state * (0 ⇒ used up). * + * v1.16.10 — items count UNITS (tablets / ampoules / puffs); + * `Medication.unitsPerDose` maps units to doses. Every dose-facing + * readout divides by it (floor), with the raw unit count as secondary + * text when the factor is > 1. The register flow gains a container-type + * select (pen / ampoule / tablet pack / …, defaulted from the delivery + * form) and, for multi-unit doses, a segmented Dosen | Einheiten + * quantity input with live conversion — the wire always carries units. + * * Reads stay on `GET /api/medications/[id]/inventory` (the same - * per-item list the pen/vial CRUD route serves): a calm summary — the + * per-item list the inventory CRUD route serves): a calm summary — the * doses remaining across every non-terminal item — plus a per-item list - * with its state badge. Shown for ALL medications (pill packs count - * too); the richer pen math is implicit in the per-item doses figures. + * with its container type and state badge. Shown for ALL medications + * (pill packs count too). */ import { useState, type FormEvent } from "react"; @@ -39,6 +48,13 @@ import { } from "@/components/ui/dialog"; import { EmptyState } from "@/components/ui/empty-state"; import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { SettingsGroup } from "@/components/medications/settings-group"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -46,11 +62,22 @@ import { apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP"; +const CONTAINER_TYPES = [ + "PEN", + "AMPOULE", + "BLISTER", + "INHALER", + "BOTTLE", + "OTHER", +] as const; +type ContainerType = (typeof CONTAINER_TYPES)[number]; + interface InventoryItem { id: string; state: InventoryState; - dosesTotal: number; - dosesRemaining: number; + containerType: ContainerType; + unitsTotal: number; + unitsRemaining: number; } interface InventoryResponse { @@ -65,18 +92,33 @@ const STATE_BADGE: Record(null); + const perDose = Math.max(1, unitsPerDose ?? 1); + const { data, isLoading } = useQuery({ queryKey: queryKeys.medicationInventory(medicationId), queryFn: async () => { @@ -102,17 +144,22 @@ export function InventorySection({ } const items = Array.isArray(data?.items) ? data.items : []; - // Active supply: doses left across every non-terminal (not used-up) item. + // Active supply: units left across every non-terminal (not used-up) item. const live = items.filter((i) => i.state !== "USED_UP"); - const remaining = live.reduce((sum, i) => sum + i.dosesRemaining, 0); - const total = live.reduce((sum, i) => sum + i.dosesTotal, 0); + const remainingUnits = live.reduce((sum, i) => sum + i.unitsRemaining, 0); + const totalUnits = live.reduce((sum, i) => sum + i.unitsTotal, 0); + // Dose-derived headline (floor — a partial dose is not a dose). + const remaining = Math.floor(remainingUnits / perDose); + const total = Math.floor(totalUnits / perDose); const dialogs = ( <> {addOpen && ( setAddOpen(false)} /> )} @@ -155,6 +202,14 @@ export function InventorySection({

{t("medications.detail.bestand.summary", { remaining, total })} + {perDose > 1 && ( + + {t("medications.detail.bestand.unitsDetail", { + remaining: remainingUnits, + total: totalUnits, + })} + + )}

+ +
+ )} setDosesTotal(e.target.value)} + value={quantity} + onChange={(e) => setQuantity(e.target.value)} aria-describedby="inventory-add-quantity-helper" /> + {unitsPerDose > 1 && Number.isInteger(parsed) && parsed >= 1 && ( +

+ {effectiveMode === "doses" + ? t("medications.detail.bestand.quantityInUnits", { units }) + : t("medications.detail.bestand.quantityInDoses", { + doses: Math.floor(parsed / unitsPerDose), + })} +

+ )}

= 0 && parsed <= item.dosesTotal; + Number.isInteger(parsed) && parsed >= 0 && parsed <= item.unitsTotal; async function submit(e: FormEvent) { e.preventDefault(); @@ -365,6 +521,7 @@ function AdjustInventoryDialog({ setBusy(true); try { await apiPatch(`/api/medications/${medicationId}/inventory/${item.id}`, { + // Wire field name is `dosesRemaining`; it carries UNITS. dosesRemaining: parsed, }); await queryClient.invalidateQueries({ @@ -403,7 +560,7 @@ function AdjustInventoryDialog({ type="number" inputMode="numeric" min={0} - max={item.dosesTotal} + max={item.unitsTotal} step={1} required autoComplete="off" @@ -416,7 +573,7 @@ function AdjustInventoryDialog({ className="text-muted-foreground text-xs" > {t("medications.detail.bestand.adjustHelper", { - total: item.dosesTotal, + total: item.unitsTotal, })}

diff --git a/src/components/medications/wizard/__tests__/wizard-payload.test.ts b/src/components/medications/wizard/__tests__/wizard-payload.test.ts index 5d41d10cc..481649cff 100644 --- a/src/components/medications/wizard/__tests__/wizard-payload.test.ts +++ b/src/components/medications/wizard/__tests__/wizard-payload.test.ts @@ -383,6 +383,50 @@ describe("buildCreateBody", () => { expect(body.schedules[0].windowEnd).toBe("09:00"); }); + it("carries unitsPerDose onto the body and round-trips it on edit-hydrate (v1.16.10)", () => { + const p: WizardPayload = { + ...withCadence("daily"), + treatmentRow: "bloodPressure", + timesOfDay: ["08:00"], + startsOn: new Date(Date.UTC(2026, 4, 28)), + endsOn: null, + unitsPerDose: "2", + }; + expect(buildCreateBody(p).unitsPerDose).toBe(2); + + // The default "1" still ships (an edit back to 1 must reach the server). + expect(buildCreateBody({ ...p, unitsPerDose: "1" }).unitsPerDose).toBe(1); + // An empty field omits it (server default applies). + expect(buildCreateBody({ ...p, unitsPerDose: "" }).unitsPerDose).toBeUndefined(); + + const hydrated = hydrateWizardPayload({ + id: "m1", + name: "Foo", + dose: "5 mg", + category: "BLOOD_PRESSURE", + unitsPerDose: 2, + notificationsEnabled: true, + startsOn: null, + endsOn: null, + oneShot: false, + schedules: [], + }); + expect(hydrated.unitsPerDose).toBe("2"); + // Absent on the snapshot → the default 1. + const hydratedDefault = hydrateWizardPayload({ + id: "m1", + name: "Foo", + dose: "5 mg", + category: "BLOOD_PRESSURE", + notificationsEnabled: true, + startsOn: null, + endsOn: null, + oneShot: false, + schedules: [], + }); + expect(hydratedDefault.unitsPerDose).toBe("1"); + }); + it("emits DIABETES category for the diabetes row", () => { const p: WizardPayload = { ...withCadence("daily"), diff --git a/src/components/medications/wizard/steps/Step3Dose.tsx b/src/components/medications/wizard/steps/Step3Dose.tsx index 8a1593abd..c58cb8ff0 100644 --- a/src/components/medications/wizard/steps/Step3Dose.tsx +++ b/src/components/medications/wizard/steps/Step3Dose.tsx @@ -147,13 +147,14 @@ export function Step3Dose({ payload, applyPartial }: StepProps) { value={payload.dosesPerUnit} onChange={(e) => applyPartial({ - dosesPerUnit: e.target.value.replace(/[^0-9]/g, "").slice(0, 3), + // v1.16.10 — the cap rose to 1000, so four digits. + dosesPerUnit: e.target.value.replace(/[^0-9]/g, "").slice(0, 4), }) } placeholder={t( "medications.wizard.steps.step3.dosesPerUnitPlaceholder", )} - maxLength={3} + maxLength={4} autoComplete="off" />

@@ -162,6 +163,33 @@ export function Step3Dose({ payload, applyPartial }: StepProps) { + {/* v1.16.10 — units consumed per dose. The inventory counts units + (tablets / ampoules); a multi-unit dose (2 × 2 mg for 4 mg) + decrements several per take and every dose-level readout + divides by this factor. */} +

+ + + applyPartial({ + unitsPerDose: e.target.value.replace(/[^0-9]/g, "").slice(0, 3), + }) + } + placeholder="1" + maxLength={3} + autoComplete="off" + /> +

+ {t("medications.wizard.steps.step3.unitsPerDoseHint")} +

+
+ {/* v1.8.5 — injection-site tracking opt-in + allowed-sites editor. Shown only for an INJECTION delivery form. Default off; enabling it surfaces the post-dose site prompt and the rotation diff --git a/src/components/medications/wizard/wizard-payload.ts b/src/components/medications/wizard/wizard-payload.ts index c9f3bf8a5..fff9cd3d2 100644 --- a/src/components/medications/wizard/wizard-payload.ts +++ b/src/components/medications/wizard/wizard-payload.ts @@ -219,6 +219,11 @@ export interface WizardPayload { * inventory tracking off (maps to `undefined` / NULL on the wire). */ dosesPerUnit: string; + /** + * v1.16.10 — inventory units one dose consumes (2 × 2 mg tablets for + * a 4 mg dose). Empty string / "1" both map to the server default 1. + */ + unitsPerDose: string; /** * v1.8.5 — per-medication injection-site tracking opt-in. Only * surfaced in Step 3 when `deliveryForm === "INJECTION"`. Default @@ -283,6 +288,7 @@ export function emptyWizardPayload(): WizardPayload { treatmentRow: null, deliveryForm: "ORAL", dosesPerUnit: "", + unitsPerDose: "1", trackInjectionSites: false, allowedInjectionSites: [], mode: draft.mode, @@ -528,6 +534,8 @@ export interface CreateMedicationBody { deliveryForm: MedicationDeliveryForm; /** v1.6.0 — doses per pen / vial. Omitted when inventory is off. */ dosesPerUnit?: number; + /** v1.16.10 — inventory units per dose. Omitted at the default 1. */ + unitsPerDose?: number; /** v1.8.5 — injection-site tracking opt-in. Omitted for non-injections. */ trackInjectionSites?: boolean; /** v1.8.5 — per-medication allowed sites. Omitted when tracking is off. */ @@ -673,6 +681,7 @@ export function buildCreateBody( : committed.schedules; const parsedDosesPerUnit = Number.parseInt(committed.dosesPerUnit, 10); + const parsedUnitsPerDose = Number.parseInt(committed.unitsPerDose, 10); const body: CreateMedicationBody = { name: committed.name.trim(), dose, @@ -683,6 +692,12 @@ export function buildCreateBody( parsedDosesPerUnit >= 1 && { dosesPerUnit: parsedDosesPerUnit, }), + // v1.16.10 — always sent when valid (an edit back to 1 must reach + // the server; the create default matches the schema default). + ...(Number.isFinite(parsedUnitsPerDose) && + parsedUnitsPerDose >= 1 && { + unitsPerDose: parsedUnitsPerDose, + }), // v1.8.5 — injection-site tracking is only meaningful for an // INJECTION delivery form. Always send the boolean for an injection // (so edits can deactivate it); send the allowed list only when @@ -903,6 +918,8 @@ export interface MedicationPayload { deliveryForm?: string; /** v1.6.0 — doses per pen / vial. NULL = inventory tracking off. */ dosesPerUnit?: number | null; + /** v1.16.10 — inventory units per dose (default 1). */ + unitsPerDose?: number | null; /** v1.8.5 — injection-site tracking opt-in. */ trackInjectionSites?: boolean; /** v1.8.5 — per-medication allowed / preferred injection sites. */ @@ -1014,6 +1031,10 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload typeof initial.dosesPerUnit === "number" && initial.dosesPerUnit >= 1 ? String(initial.dosesPerUnit) : "", + unitsPerDose: + typeof initial.unitsPerDose === "number" && initial.unitsPerDose >= 1 + ? String(initial.unitsPerDose) + : "1", trackInjectionSites: initial.trackInjectionSites ?? false, allowedInjectionSites: (initial.allowedInjectionSites ?? []) as InjectionSiteKey[], diff --git a/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts b/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts index a15dfd158..62542b13b 100644 --- a/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts @@ -30,7 +30,8 @@ function fakeMedication(overrides: Partial> = {}) { note: null, }, ], - inventoryEvents: [], + unitsPerDose: 1, + inventoryItems: [], intakeEvents: [], ...overrides, }; @@ -134,4 +135,25 @@ describe("buildGlp1SnapshotBlock", () => { expect(out?.medications[0].genericName).toBe("Semaglutide"); expect(out?.medications[0].currentDose?.unit).toBe("mg"); }); + + it("derives pen inventory from the per-item entities (v1.16.10)", async () => { + prismaMock.medication.findMany.mockResolvedValue([ + fakeMedication({ + unitsPerDose: 2, + inventoryItems: [ + { state: "IN_USE", unitsTotal: 4, unitsRemaining: 3 }, + { state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 }, + { state: "USED_UP", unitsTotal: 4, unitsRemaining: 0 }, + { state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 }, + ], + }), + ]); + const out = await buildGlp1SnapshotBlock("user-1"); + // 2 usable containers; 7 pooled units / 2 units per dose = 3 doses. + expect(out?.medications[0].penInventory).toEqual({ + pensRemaining: 2, + dosesRemaining: 3, + weeksOfSupplyApprox: 3, + }); + }); }); diff --git a/src/lib/ai/coach/glp1-snapshot.ts b/src/lib/ai/coach/glp1-snapshot.ts index 917995eae..c48221731 100644 --- a/src/lib/ai/coach/glp1-snapshot.ts +++ b/src/lib/ai/coach/glp1-snapshot.ts @@ -266,7 +266,9 @@ export async function buildGlp1SnapshotBlock( include: { schedules: true, doseChanges: { orderBy: { effectiveFrom: "asc" } }, - inventoryEvents: { orderBy: { occurredAt: "asc" } }, + // v1.16.10 — pen inventory reads the per-item entities (the rows + // the intake consumption hook moves), not the legacy event ledger. + inventoryItems: { orderBy: { createdAt: "asc" } }, intakeEvents: { where: { takenAt: { not: null } }, orderBy: { takenAt: "desc" }, @@ -368,16 +370,29 @@ export async function buildGlp1SnapshotBlock( const nextInjection = predictNextInjection(schedule, lastInjection, now); - // Inventory math: running sum of all inventory events for this med. - // Convert to "weeks of supply" via dosesPerUnit + the cadence so the - // Coach can answer "how long until my next refill?". + // Inventory math over the per-item entities (v1.16.10 — the same + // rows the Bestand tab and the consumption hook move; the legacy + // event ledger no longer receives writes). `pensRemaining` counts + // usable containers; `dosesRemaining` pools the units and divides + // by `unitsPerDose` so the Coach can answer "how long until my + // next refill?". let dosesRemaining: number | null = null; let pensRemaining: number | null = null; let weeksOfSupplyApprox: number | null = null; - if (med.inventoryEvents.length > 0 && med.dosesPerUnit) { - const units = med.inventoryEvents.reduce((sum, ev) => sum + ev.delta, 0); - pensRemaining = Math.max(0, units); - dosesRemaining = pensRemaining * med.dosesPerUnit; + if (med.inventoryItems.length > 0) { + const usable = med.inventoryItems.filter( + (item) => + (item.state === "ACTIVE" || item.state === "IN_USE") && + item.unitsRemaining > 0, + ); + const unitsRemaining = usable.reduce( + (sum, item) => sum + item.unitsRemaining, + 0, + ); + pensRemaining = usable.length; + dosesRemaining = Math.floor( + unitsRemaining / Math.max(1, med.unitsPerDose), + ); const dosesPerWeek = schedule?.cadence === "weekly" ? 1 diff --git a/src/lib/medications/__tests__/intake-slot-dedup-inventory.test.ts b/src/lib/medications/__tests__/intake-slot-dedup-inventory.test.ts new file mode 100644 index 000000000..704bee41c --- /dev/null +++ b/src/lib/medications/__tests__/intake-slot-dedup-inventory.test.ts @@ -0,0 +1,171 @@ +/** + * v1.16.10 — inventory refunds in the intake-slot dedup pass. + * + * Collapsing a duplicate slot soft-deletes the loser rows. A loser that + * carries a consumption stamp is a REAL taken row whose units left the + * stock — tombstoning it without a refund would strand those units + * forever (the row is no longer reachable through any user-facing + * restore path). The dedup therefore refunds every stamped loser before + * the tombstone; unstamped losers (pending / skipped / pre-stamp rows) + * never consumed and are swept without a refund round-trip. The winner + * keeps its own stamp untouched. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + medication: { findMany: vi.fn() }, + medicationIntakeEvent: { + findFirst: vi.fn(), + findMany: vi.fn(), + updateMany: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() })); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ + recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMedications: vi.fn(), +})); +vi.mock("@/lib/medications/inventory/consumption", () => ({ + restoreForIntake: vi.fn().mockResolvedValue(undefined), +})); + +import { dedupeUserIntakeSlots } from "../intake-slot-dedup"; +import { restoreForIntake } from "@/lib/medications/inventory/consumption"; +import { prisma } from "@/lib/db"; + +const TZ = "Europe/Berlin"; + +const SCHEDULE = { + id: "s1", + windowStart: "07:00", + windowEnd: "07:00", + daysOfWeek: null, + timesOfDay: ["07:00", "19:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, +}; + +const MED = { + id: "med-1", + userId: "u1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules: [SCHEDULE], +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: TZ, + } as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([MED] as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue( + {} as never, + ); +}); + +describe("dedupeUserIntakeSlots — inventory refunds (v1.16.10)", () => { + it("refunds a stamped loser before tombstoning; the winner keeps its stamp", async () => { + // 07:00 CEST = 05:00Z. Two TAKEN rows for the same dose — the iOS + // drift duplicate (+30 s) beside the server row. The fresher row + // (higher syncVersion) wins; the stamped loser must be refunded. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + { + id: "r-loser", + medicationId: "med-1", + scheduledFor: new Date("2026-06-15T05:00:00.000Z"), + takenAt: new Date("2026-06-15T05:01:00.000Z"), + skipped: false, + syncVersion: 0, + createdAt: new Date("2026-06-15T05:01:00Z"), + attributionSource: "AUTO", + source: "WEB", + inventoryConsumption: [{ itemId: "item-1", units: 2 }], + }, + { + id: "r-winner", + medicationId: "med-1", + scheduledFor: new Date("2026-06-15T05:00:30.000Z"), + takenAt: new Date("2026-06-15T05:01:30.000Z"), + skipped: false, + syncVersion: 3, + createdAt: new Date("2026-06-15T05:01:30Z"), + attributionSource: "AUTO", + source: "API", + inventoryConsumption: [{ itemId: "item-1", units: 2 }], + }, + ] as never); + + const summary = await dedupeUserIntakeSlots("u1"); + expect(summary.slotsCollapsed).toBe(1); + expect(summary.rowsSoftDeleted).toBe(1); + + // Only the LOSER is refunded — the winner keeps its stamp. + expect(restoreForIntake).toHaveBeenCalledTimes(1); + expect(restoreForIntake).toHaveBeenCalledWith( + expect.objectContaining({ userId: "u1", eventId: "r-loser" }), + ); + + // The refund runs before the loser tombstone sweep. + const restoreOrder = vi.mocked(restoreForIntake).mock + .invocationCallOrder[0]; + const sweepOrder = vi.mocked(prisma.medicationIntakeEvent.updateMany) + .mock.invocationCallOrder[0]; + expect(restoreOrder).toBeLessThan(sweepOrder); + }); + + it("ignores unstamped losers (a pending sibling never consumed)", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + { + id: "r-taken", + medicationId: "med-1", + scheduledFor: new Date("2026-06-15T05:00:00.000Z"), + takenAt: new Date("2026-06-15T05:01:00.000Z"), + skipped: false, + syncVersion: 1, + createdAt: new Date("2026-06-15T05:01:00Z"), + attributionSource: "AUTO", + source: "WEB", + inventoryConsumption: [{ itemId: "item-1", units: 1 }], + }, + { + id: "r-pending", + medicationId: "med-1", + scheduledFor: new Date("2026-06-15T05:00:30.000Z"), + takenAt: null, + skipped: false, + syncVersion: 0, + createdAt: new Date("2026-06-15T04:00:00Z"), + attributionSource: "AUTO", + source: "REMINDER", + inventoryConsumption: null, + }, + ] as never); + + const summary = await dedupeUserIntakeSlots("u1"); + expect(summary.slotsCollapsed).toBe(1); + expect(summary.rowsSoftDeleted).toBe(1); + // The taken row wins; the pending loser carries no stamp → no refund. + expect(restoreForIntake).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/medications/intake-slot-dedup.ts b/src/lib/medications/intake-slot-dedup.ts index 1e475ad9f..073c47db4 100644 --- a/src/lib/medications/intake-slot-dedup.ts +++ b/src/lib/medications/intake-slot-dedup.ts @@ -44,6 +44,7 @@ */ import { prisma } from "@/lib/db"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { restoreForIntake } from "@/lib/medications/inventory/consumption"; import { isP2002 } from "@/lib/prisma-errors"; import { annotate } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -119,6 +120,12 @@ interface DedupIntakeRow { * takenAt`); a WEB or IMPORT ad-hoc row is a deliberate user shape. */ source: string; + /** + * v1.16.10 — inventory consumption stamp. A stamped LOSER row refunds + * its consumed units before the tombstone (the winner keeps its own + * stamp untouched), so collapsing a duplicate never strands stock. + */ + inventoryConsumption: unknown; } /** @@ -266,6 +273,8 @@ export async function dedupeUserIntakeSlots( attributionSource: true, // v1.16.9 — write origin for the ad-hoc→pending convergence gate. source: true, + // v1.16.10 — stamped losers refund their consumption on collapse. + inventoryConsumption: true, }, orderBy: { scheduledFor: "asc" }, })) as DedupIntakeRow[]; @@ -347,6 +356,20 @@ export async function dedupeUserIntakeSlots( const winner = pickWinner(slotRows); const losers = slotRows.filter((r) => r.id !== winner.id); + // v1.16.10 — a stamped loser (a real taken row losing to a pin) + // refunds its consumed units before the tombstone; unstamped + // losers (pending / skipped / pre-stamp rows) skip the round + // trip. The winner keeps its own stamp untouched. + for (const loser of losers) { + if (loser.inventoryConsumption !== null) { + await restoreForIntake({ + client: prisma, + userId, + eventId: loser.id, + }); + } + } + // Soft-delete every loser in one shot. Bump syncVersion so the // sync feed echoes a monotonic value and iOS drops the tombstone. const loserIds = losers.map((r) => r.id); diff --git a/src/lib/medications/inventory/__tests__/consumption.test.ts b/src/lib/medications/inventory/__tests__/consumption.test.ts new file mode 100644 index 000000000..8929d7bf6 --- /dev/null +++ b/src/lib/medications/inventory/__tests__/consumption.test.ts @@ -0,0 +1,655 @@ +/** + * v1.16.10 — consumption hook contract tests. + * + * Pins the exactly-once stamp semantics, the multi-unit FEFO selection + * with spillover + auto-open, the floor-at-zero partial stamp, and the + * symmetric restore (clamped refund, state re-derivation, stamp clear). + * The fake client mirrors the Prisma surface the hook touches and + * honours the orderBy specs the hook passes, so the selection order the + * tests assert is the order production gets from Postgres. + * + * Timezone-stable: every instant is an explicit UTC ISO string and the + * wall clock is pinned with fake timers. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +import { Prisma } from "@/generated/prisma/client"; +import { annotate } from "@/lib/logging/context"; +import { consumeForIntake, restoreForIntake } from "../consumption"; + +const NOW = new Date("2026-06-10T12:00:00.000Z"); +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +interface FakeEvent { + id: string; + userId: string; + medicationId: string; + takenAt: Date | null; + skipped: boolean; + deletedAt: Date | null; + inventoryConsumption: unknown; +} + +interface FakeItem { + id: string; + userId: string; + medicationId: string; + state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP"; + unitsTotal: number; + unitsRemaining: number; + firstUseAt: Date | null; + expiresAt: Date | null; + printedExpiry: Date | null; + purchasedAt: Date | null; + createdAt: Date; +} + +interface FakeState { + events: FakeEvent[]; + items: FakeItem[]; + unitsPerDose: number; +} + +type OrderSpec = Record< + string, + "asc" | "desc" | { sort: "asc" | "desc"; nulls?: "first" | "last" } +>; + +/** Honour the hook's orderBy the way Postgres would. */ +function sortRows(rows: FakeItem[], orderBy: OrderSpec[]): FakeItem[] { + return [...rows].sort((a, b) => { + for (const spec of orderBy) { + const [field, raw] = Object.entries(spec)[0]; + const dir = typeof raw === "string" ? raw : raw.sort; + const nulls = typeof raw === "object" ? raw.nulls : undefined; + const av = a[field as keyof FakeItem] as Date | null; + const bv = b[field as keyof FakeItem] as Date | null; + if (av === null && bv === null) continue; + if (av === null) return nulls === "last" ? 1 : -1; + if (bv === null) return nulls === "last" ? -1 : 1; + const diff = av.getTime() - bv.getTime(); + if (diff !== 0) return dir === "asc" ? diff : -diff; + } + return 0; + }); +} + +function makeClient(state: FakeState) { + return { + medication: { + findFirst: vi.fn(async () => ({ unitsPerDose: state.unitsPerDose })), + }, + medicationIntakeEvent: { + findFirst: vi.fn( + async (args: { where: { id: string; userId: string } }) => + state.events.find( + (e) => e.id === args.where.id && e.userId === args.where.userId, + ) ?? null, + ), + update: vi.fn( + async (args: { + where: { id: string }; + data: Record; + }) => { + const event = state.events.find((e) => e.id === args.where.id)!; + if ("inventoryConsumption" in args.data) { + event.inventoryConsumption = + args.data.inventoryConsumption === Prisma.DbNull + ? null + : args.data.inventoryConsumption; + } + return event; + }, + ), + }, + medicationInventoryItem: { + findMany: vi.fn( + async (args: { + where: { + userId: string; + medicationId: string; + state: FakeItem["state"]; + unitsRemaining: { gt: number }; + }; + orderBy: OrderSpec[]; + }) => + sortRows( + state.items.filter( + (i) => + i.userId === args.where.userId && + i.medicationId === args.where.medicationId && + i.state === args.where.state && + i.unitsRemaining > args.where.unitsRemaining.gt, + ), + args.orderBy, + ), + ), + findFirst: vi.fn( + async (args: { where: { id: string; userId: string } }) => + state.items.find( + (i) => i.id === args.where.id && i.userId === args.where.userId, + ) ?? null, + ), + update: vi.fn( + async (args: { + where: { id: string }; + data: Partial; + }) => { + const item = state.items.find((i) => i.id === args.where.id)!; + Object.assign(item, args.data); + return item; + }, + ), + }, + }; +} + +function takenEvent(overrides: Partial = {}): FakeEvent { + return { + id: "evt-1", + userId: "user-1", + medicationId: "med-1", + takenAt: NOW, + skipped: false, + deletedAt: null, + inventoryConsumption: null, + ...overrides, + }; +} + +function item(overrides: Partial = {}): FakeItem { + return { + id: "item-1", + userId: "user-1", + medicationId: "med-1", + state: "ACTIVE", + unitsTotal: 10, + unitsRemaining: 10, + firstUseAt: null, + expiresAt: null, + printedExpiry: null, + purchasedAt: null, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + ...overrides, + }; +} + +function consumeArgs(client: ReturnType) { + return { + client: client as never, + userId: "user-1", + medicationId: "med-1", + eventId: "evt-1", + intakeAt: NOW, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("consumeForIntake", () => { + it("consumes unitsPerDose units from the open container and stamps the event", async () => { + const state: FakeState = { + unitsPerDose: 2, + events: [takenEvent()], + items: [ + item({ + id: "open", + state: "IN_USE", + unitsRemaining: 5, + firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY), + expiresAt: new Date(NOW.getTime() + 27 * MS_PER_DAY), + }), + ], + }; + const client = makeClient(state); + await consumeForIntake(consumeArgs(client)); + + expect(state.items[0].unitsRemaining).toBe(3); + expect(state.items[0].state).toBe("IN_USE"); + expect(state.events[0].inventoryConsumption).toEqual([ + { itemId: "open", units: 2 }, + ]); + expect(annotate).toHaveBeenCalledWith( + expect.objectContaining({ + action: { name: "medication.inventory.consumed" }, + meta: expect.objectContaining({ + medication_id: "med-1", + units: 2, + item_ids: ["open"], + auto_opened: 0, + }), + }), + ); + }); + + it("prefers the IN_USE container over fresher ACTIVE stock", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent()], + items: [ + item({ id: "sealed", state: "ACTIVE", unitsRemaining: 10 }), + item({ + id: "open", + state: "IN_USE", + unitsRemaining: 2, + firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY), + expiresAt: new Date(NOW.getTime() + 27 * MS_PER_DAY), + }), + ], + }; + const client = makeClient(state); + await consumeForIntake(consumeArgs(client)); + + expect(state.items.find((i) => i.id === "open")!.unitsRemaining).toBe(1); + expect(state.items.find((i) => i.id === "sealed")!.unitsRemaining).toBe(10); + }); + + it("opens unopened stock first-expiry-first-out, null printed expiry last", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent()], + items: [ + item({ + id: "no-expiry", + printedExpiry: null, + createdAt: new Date("2026-05-01T00:00:00.000Z"), + }), + item({ + id: "later-expiry", + printedExpiry: new Date("2027-06-01T00:00:00.000Z"), + }), + item({ + id: "sooner-expiry", + printedExpiry: new Date("2026-09-01T00:00:00.000Z"), + }), + ], + }; + const client = makeClient(state); + await consumeForIntake(consumeArgs(client)); + + expect( + state.items.find((i) => i.id === "sooner-expiry")!.unitsRemaining, + ).toBe(9); + expect(state.items.find((i) => i.id === "no-expiry")!.unitsRemaining).toBe( + 10, + ); + expect( + state.items.find((i) => i.id === "later-expiry")!.unitsRemaining, + ).toBe(10); + }); + + it("auto-opens an ACTIVE container: firstUseAt = intakeAt, state IN_USE, expiresAt derived", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent()], + items: [item({ id: "sealed" })], + }; + const client = makeClient(state); + await consumeForIntake(consumeArgs(client)); + + const opened = state.items[0]; + expect(opened.firstUseAt).toEqual(NOW); + expect(opened.state).toBe("IN_USE"); + expect(opened.expiresAt?.getTime()).toBe( + NOW.getTime() + 30 * MS_PER_DAY, + ); + expect(annotate).toHaveBeenCalledWith( + expect.objectContaining({ + meta: expect.objectContaining({ auto_opened: 1 }), + }), + ); + }); + + it("lands a backdated auto-open straight in EXPIRED when its window has already lapsed", async () => { + const backdated = new Date(NOW.getTime() - 40 * MS_PER_DAY); + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent({ takenAt: backdated })], + items: [item({ id: "sealed" })], + }; + const client = makeClient(state); + await consumeForIntake({ + ...consumeArgs(client), + intakeAt: backdated, + }); + + const opened = state.items[0]; + expect(opened.firstUseAt).toEqual(backdated); + // firstUseAt + 30 days lies in the past — the state machine says + // EXPIRED at the wall clock, and that is the honest answer. + expect(opened.state).toBe("EXPIRED"); + expect(opened.unitsRemaining).toBe(9); + expect(state.events[0].inventoryConsumption).toEqual([ + { itemId: "sealed", units: 1 }, + ]); + }); + + it("spills across containers when the open one runs dry", async () => { + const state: FakeState = { + unitsPerDose: 3, + events: [takenEvent()], + items: [ + item({ + id: "open", + state: "IN_USE", + unitsRemaining: 1, + firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY), + expiresAt: new Date(NOW.getTime() + 27 * MS_PER_DAY), + }), + item({ id: "sealed", unitsRemaining: 4, unitsTotal: 4 }), + ], + }; + const client = makeClient(state); + await consumeForIntake(consumeArgs(client)); + + const open = state.items.find((i) => i.id === "open")!; + const sealed = state.items.find((i) => i.id === "sealed")!; + expect(open.unitsRemaining).toBe(0); + expect(open.state).toBe("USED_UP"); + expect(sealed.unitsRemaining).toBe(2); + expect(sealed.state).toBe("IN_USE"); + expect(state.events[0].inventoryConsumption).toEqual([ + { itemId: "open", units: 1 }, + { itemId: "sealed", units: 2 }, + ]); + }); + + it("floors at zero with a partial stamp when stock runs out — never negative, never an error", async () => { + const state: FakeState = { + unitsPerDose: 4, + events: [takenEvent()], + items: [ + item({ + id: "open", + state: "IN_USE", + unitsRemaining: 1, + firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY), + expiresAt: new Date(NOW.getTime() + 27 * MS_PER_DAY), + }), + ], + }; + const client = makeClient(state); + await consumeForIntake(consumeArgs(client)); + + expect(state.items[0].unitsRemaining).toBe(0); + expect(state.items[0].state).toBe("USED_UP"); + expect(state.events[0].inventoryConsumption).toEqual([ + { itemId: "open", units: 1 }, + ]); + expect(annotate).toHaveBeenCalledWith( + expect.objectContaining({ + meta: expect.objectContaining({ units: 1 }), + }), + ); + }); + + it("stamps an empty consumption when no tracked stock exists (exactly-once gate)", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent()], + items: [], + }; + const client = makeClient(state); + await consumeForIntake(consumeArgs(client)); + + expect(state.events[0].inventoryConsumption).toEqual([]); + expect(client.medicationInventoryItem.update).not.toHaveBeenCalled(); + }); + + it("is a no-op on an already-stamped row (replay never double-decrements)", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [ + takenEvent({ inventoryConsumption: [{ itemId: "open", units: 1 }] }), + ], + items: [ + item({ + id: "open", + state: "IN_USE", + unitsRemaining: 3, + firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY), + }), + ], + }; + const client = makeClient(state); + await consumeForIntake(consumeArgs(client)); + await consumeForIntake(consumeArgs(client)); + + expect(state.items[0].unitsRemaining).toBe(3); + expect(client.medicationInventoryItem.update).not.toHaveBeenCalled(); + expect(client.medicationIntakeEvent.update).not.toHaveBeenCalled(); + }); + + it.each([ + ["pending", { takenAt: null }], + ["skipped", { skipped: true }], + ["tombstoned", { deletedAt: NOW }], + ])("never consumes for a %s row", async (_label, overrides) => { + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent(overrides as Partial)], + items: [item({ id: "sealed" })], + }; + const client = makeClient(state); + await consumeForIntake(consumeArgs(client)); + + expect(state.items[0].unitsRemaining).toBe(10); + expect(state.events[0].inventoryConsumption).toBeNull(); + }); + + it("swallows unexpected failures and annotates consume_error", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent()], + items: [item({ id: "sealed" })], + }; + const client = makeClient(state); + client.medicationInventoryItem.findMany.mockRejectedValue( + new Error("connection lost"), + ); + + await expect(consumeForIntake(consumeArgs(client))).resolves.toBeUndefined(); + expect(annotate).toHaveBeenCalledWith( + expect.objectContaining({ + action: { name: "medication.inventory.consume_error" }, + }), + ); + }); + + it("uses the caller's interactive transaction when the client carries one", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent()], + items: [item({ id: "sealed" })], + }; + const inner = makeClient(state); + const $transaction = vi.fn(async (fn: (tx: unknown) => Promise) => + fn(inner), + ); + await consumeForIntake({ + client: { ...inner, $transaction } as never, + userId: "user-1", + medicationId: "med-1", + eventId: "evt-1", + intakeAt: NOW, + }); + expect($transaction).toHaveBeenCalledTimes(1); + expect(state.items[0].unitsRemaining).toBe(9); + }); +}); + +describe("restoreForIntake", () => { + function restoreArgs(client: ReturnType) { + return { client: client as never, userId: "user-1", eventId: "evt-1" }; + } + + it("refunds per stamp entry, re-derives the state and clears the stamp", async () => { + const state: FakeState = { + unitsPerDose: 2, + events: [ + takenEvent({ + inventoryConsumption: [{ itemId: "open", units: 2 }], + }), + ], + items: [ + item({ + id: "open", + state: "USED_UP", + unitsTotal: 4, + unitsRemaining: 0, + firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY), + }), + ], + }; + const client = makeClient(state); + await restoreForIntake(restoreArgs(client)); + + expect(state.items[0].unitsRemaining).toBe(2); + // USED_UP flips back to IN_USE — the window is still open. + expect(state.items[0].state).toBe("IN_USE"); + expect(state.events[0].inventoryConsumption).toBeNull(); + expect(annotate).toHaveBeenCalledWith( + expect.objectContaining({ + action: { name: "medication.inventory.restored" }, + meta: expect.objectContaining({ + medication_id: "med-1", + units: 2, + item_ids: ["open"], + }), + }), + ); + }); + + it("clamps the refund to the item's capacity", async () => { + const state: FakeState = { + unitsPerDose: 2, + events: [ + takenEvent({ + inventoryConsumption: [{ itemId: "open", units: 2 }], + }), + ], + items: [ + item({ + id: "open", + state: "IN_USE", + unitsTotal: 4, + // A stock correction raised the count since the take. + unitsRemaining: 3, + firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY), + }), + ], + }; + const client = makeClient(state); + await restoreForIntake(restoreArgs(client)); + + expect(state.items[0].unitsRemaining).toBe(4); + }); + + it("re-derives EXPIRED when the refunded container's window has lapsed meanwhile", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [ + takenEvent({ + inventoryConsumption: [{ itemId: "open", units: 1 }], + }), + ], + items: [ + item({ + id: "open", + state: "USED_UP", + unitsTotal: 4, + unitsRemaining: 0, + firstUseAt: new Date(NOW.getTime() - 35 * MS_PER_DAY), + }), + ], + }; + const client = makeClient(state); + await restoreForIntake(restoreArgs(client)); + + expect(state.items[0].unitsRemaining).toBe(1); + expect(state.items[0].state).toBe("EXPIRED"); + }); + + it("is a no-op when the row carries no stamp", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent()], + items: [item({ id: "open", unitsRemaining: 3 })], + }; + const client = makeClient(state); + await restoreForIntake(restoreArgs(client)); + + expect(state.items[0].unitsRemaining).toBe(3); + expect(client.medicationInventoryItem.update).not.toHaveBeenCalled(); + expect(client.medicationIntakeEvent.update).not.toHaveBeenCalled(); + }); + + it("skips a stamped container that was deleted since, still clearing the stamp", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [ + takenEvent({ + inventoryConsumption: [ + { itemId: "gone", units: 1 }, + { itemId: "open", units: 1 }, + ], + }), + ], + items: [ + item({ + id: "open", + state: "IN_USE", + unitsRemaining: 1, + firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY), + }), + ], + }; + const client = makeClient(state); + await restoreForIntake(restoreArgs(client)); + + expect(state.items[0].unitsRemaining).toBe(2); + expect(state.events[0].inventoryConsumption).toBeNull(); + }); + + it("allows a re-take to consume afresh after a restore (skip → take cycle)", async () => { + const state: FakeState = { + unitsPerDose: 1, + events: [takenEvent()], + items: [ + item({ + id: "open", + state: "IN_USE", + unitsRemaining: 4, + unitsTotal: 4, + firstUseAt: new Date(NOW.getTime() - 3 * MS_PER_DAY), + }), + ], + }; + const client = makeClient(state); + + await consumeForIntake(consumeArgs(client)); + expect(state.items[0].unitsRemaining).toBe(3); + + await restoreForIntake(restoreArgs(client)); + expect(state.items[0].unitsRemaining).toBe(4); + expect(state.events[0].inventoryConsumption).toBeNull(); + + await consumeForIntake(consumeArgs(client)); + expect(state.items[0].unitsRemaining).toBe(3); + expect(state.events[0].inventoryConsumption).toEqual([ + { itemId: "open", units: 1 }, + ]); + }); +}); diff --git a/src/lib/medications/inventory/__tests__/service.test.ts b/src/lib/medications/inventory/__tests__/service.test.ts index 1864a30c3..9ac65a5dd 100644 --- a/src/lib/medications/inventory/__tests__/service.test.ts +++ b/src/lib/medications/inventory/__tests__/service.test.ts @@ -14,118 +14,19 @@ vi.mock("@/lib/db", () => ({ import { prisma } from "@/lib/db"; import { buildCreateInventoryInput, - consumeOneDose, expireStaleInUseItems, } from "../service"; const NOW = new Date("2026-05-14T12:00:00Z"); -const MS_PER_DAY = 24 * 60 * 60 * 1000; beforeEach(() => { vi.resetAllMocks(); }); -describe("consumeOneDose", () => { - it("returns null when no eligible pen exists for the medication", async () => { - vi.mocked(prisma.medicationInventoryItem.findFirst).mockResolvedValueOnce( - null, - ); - vi.mocked(prisma.medicationInventoryItem.findFirst).mockResolvedValueOnce( - null, - ); - - const result = await consumeOneDose({ - userId: "u", - medicationId: "m", - intakeAt: NOW, - }); - expect(result).toBeNull(); - expect(prisma.medicationInventoryItem.update).not.toHaveBeenCalled(); - }); - - it("flips an ACTIVE pen to IN_USE on first use", async () => { - // No IN_USE pen, fall back to ACTIVE. - vi.mocked(prisma.medicationInventoryItem.findFirst).mockResolvedValueOnce( - null, - ); - vi.mocked(prisma.medicationInventoryItem.findFirst).mockResolvedValueOnce({ - id: "inv-1", - state: "ACTIVE", - dosesTotal: 4, - dosesRemaining: 4, - firstUseAt: null, - printedExpiry: null, - } as never); - vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({} as never); - - const result = await consumeOneDose({ - userId: "u", - medicationId: "m", - intakeAt: NOW, - }); - expect(result).toEqual({ itemId: "inv-1", change: "first_use" }); - - const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock - .calls[0][0]; - expect(updateCall.data).toMatchObject({ - state: "IN_USE", - dosesRemaining: 3, - firstUseAt: NOW, - }); - }); - - it("prefers the IN_USE pen with the earliest expiry", async () => { - const firstUse = new Date(NOW.getTime() - 5 * MS_PER_DAY); - vi.mocked(prisma.medicationInventoryItem.findFirst).mockResolvedValueOnce({ - id: "inv-active", - state: "IN_USE", - dosesTotal: 4, - dosesRemaining: 3, - firstUseAt: firstUse, - printedExpiry: null, - } as never); - vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({} as never); - - const result = await consumeOneDose({ - userId: "u", - medicationId: "m", - intakeAt: NOW, - }); - expect(result).toEqual({ itemId: "inv-active", change: "consumed" }); - expect(prisma.medicationInventoryItem.findFirst).toHaveBeenCalledTimes(1); - }); - - it("returns 'depleted' when the final dose is consumed", async () => { - const firstUse = new Date(NOW.getTime() - 5 * MS_PER_DAY); - vi.mocked(prisma.medicationInventoryItem.findFirst).mockResolvedValueOnce({ - id: "inv-last", - state: "IN_USE", - dosesTotal: 4, - dosesRemaining: 1, - firstUseAt: firstUse, - printedExpiry: null, - } as never); - vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({} as never); - - const result = await consumeOneDose({ - userId: "u", - medicationId: "m", - intakeAt: NOW, - }); - expect(result).toEqual({ itemId: "inv-last", change: "depleted" }); - const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock - .calls[0][0]; - expect(updateCall.data).toMatchObject({ - state: "USED_UP", - dosesRemaining: 0, - }); - }); -}); - describe("expireStaleInUseItems", () => { it("transitions IN_USE rows whose deadline has lapsed via a single updateMany", async () => { // The selector `state = IN_USE AND expiresAt < now` already proves - // the row must transition to EXPIRED (IN_USE implies dosesRemaining + // the row must transition to EXPIRED (IN_USE implies unitsRemaining // > 0, and the expiresAt filter proves the deadline lapsed). Using // `updateMany` collapses N row round-trips into one. vi.mocked(prisma.medicationInventoryItem.updateMany).mockResolvedValue({ @@ -174,15 +75,17 @@ describe("buildCreateInventoryInput", () => { const input = buildCreateInventoryInput({ userId: "u", medicationId: "m", - dosesTotal: 4, + unitsTotal: 4, + containerType: "PEN", printedExpiry: printed, purchasedAt: null, notes: null, }); expect(input).toMatchObject({ state: "ACTIVE", - dosesTotal: 4, - dosesRemaining: 4, + containerType: "PEN", + unitsTotal: 4, + unitsRemaining: 4, firstUseAt: null, expiresAt: printed, }); @@ -192,7 +95,8 @@ describe("buildCreateInventoryInput", () => { const input = buildCreateInventoryInput({ userId: "u", medicationId: "m", - dosesTotal: 4, + unitsTotal: 4, + containerType: "OTHER", printedExpiry: null, purchasedAt: null, notes: null, diff --git a/src/lib/medications/inventory/__tests__/state-machine.test.ts b/src/lib/medications/inventory/__tests__/state-machine.test.ts index 120347070..2126d5b1b 100644 --- a/src/lib/medications/inventory/__tests__/state-machine.test.ts +++ b/src/lib/medications/inventory/__tests__/state-machine.test.ts @@ -4,8 +4,6 @@ import { computeExpiresAt, computeInventoryState, daysRemainingInUse, - decrementDose, - DEFAULT_IN_USE_WINDOW_DAYS, type InventoryItemView, } from "../state-machine"; @@ -16,8 +14,8 @@ const NOW_MS = NOW.getTime(); function makeItem(overrides: Partial = {}): InventoryItemView { return { state: "ACTIVE", - dosesTotal: 4, - dosesRemaining: 4, + unitsTotal: 4, + unitsRemaining: 4, firstUseAt: null, printedExpiry: null, ...overrides, @@ -58,17 +56,17 @@ describe("computeInventoryState", () => { expect(computeInventoryState(item, NOW_MS)).toBe("EXPIRED"); }); - it("returns USED_UP when dosesRemaining is zero (terminal — outranks EXPIRED)", () => { + it("returns USED_UP when unitsRemaining is zero (terminal — outranks EXPIRED)", () => { const item = makeItem({ - dosesRemaining: 0, + unitsRemaining: 0, firstUseAt: new Date(NOW_MS - 31 * MS_PER_DAY), }); expect(computeInventoryState(item, NOW_MS)).toBe("USED_UP"); }); - it("returns USED_UP even when printed expiry has lapsed (dosesRemaining wins)", () => { + it("returns USED_UP even when printed expiry has lapsed (unitsRemaining wins)", () => { const item = makeItem({ - dosesRemaining: 0, + unitsRemaining: 0, printedExpiry: new Date(NOW_MS - 5 * MS_PER_DAY), }); expect(computeInventoryState(item, NOW_MS)).toBe("USED_UP"); @@ -122,74 +120,6 @@ describe("computeExpiresAt", () => { }); }); -describe("decrementDose", () => { - it("first_use — flips ACTIVE → IN_USE and sets firstUseAt", () => { - const item = makeItem(); - const result = decrementDose(item, NOW); - - expect(result.change).toBe("first_use"); - expect(result.item.state).toBe("IN_USE"); - expect(result.item.firstUseAt).toEqual(NOW); - expect(result.item.dosesRemaining).toBe(3); - expect(result.item.expiresAt?.getTime()).toBe( - NOW_MS + DEFAULT_IN_USE_WINDOW_DAYS * MS_PER_DAY, - ); - }); - - it("consumed — IN_USE pen with doses left stays IN_USE", () => { - const firstUse = new Date(NOW_MS - 3 * MS_PER_DAY); - const item = makeItem({ - state: "IN_USE", - dosesRemaining: 3, - firstUseAt: firstUse, - }); - const result = decrementDose(item, NOW); - - expect(result.change).toBe("consumed"); - expect(result.item.state).toBe("IN_USE"); - expect(result.item.firstUseAt).toEqual(firstUse); - expect(result.item.dosesRemaining).toBe(2); - }); - - it("depleted — final dose flips state to USED_UP", () => { - const firstUse = new Date(NOW_MS - 3 * MS_PER_DAY); - const item = makeItem({ - state: "IN_USE", - dosesRemaining: 1, - firstUseAt: firstUse, - }); - const result = decrementDose(item, NOW); - - expect(result.change).toBe("depleted"); - expect(result.item.state).toBe("USED_UP"); - expect(result.item.dosesRemaining).toBe(0); - }); - - it("does not mutate its input", () => { - const firstUse = new Date(NOW_MS - 3 * MS_PER_DAY); - const item = makeItem({ - state: "IN_USE", - dosesRemaining: 3, - firstUseAt: firstUse, - }); - const snapshot = JSON.stringify(item); - decrementDose(item, NOW); - expect(JSON.stringify(item)).toBe(snapshot); - }); - - it("clamps dosesRemaining at zero even if called on an already-empty pen", () => { - const item = makeItem({ - state: "USED_UP", - dosesRemaining: 0, - firstUseAt: new Date(NOW_MS - 5 * MS_PER_DAY), - }); - const result = decrementDose(item, NOW); - - expect(result.item.dosesRemaining).toBe(0); - expect(result.item.state).toBe("USED_UP"); - }); -}); - describe("daysRemainingInUse", () => { it("returns null for an ACTIVE (unopened) pen", () => { expect(daysRemainingInUse(makeItem(), NOW_MS)).toBeNull(); @@ -204,7 +134,7 @@ describe("daysRemainingInUse", () => { it("returns null for a USED_UP pen", () => { const item = makeItem({ - dosesRemaining: 0, + unitsRemaining: 0, firstUseAt: new Date(NOW_MS - 5 * MS_PER_DAY), }); expect(daysRemainingInUse(item, NOW_MS)).toBeNull(); diff --git a/src/lib/medications/inventory/consumption.ts b/src/lib/medications/inventory/consumption.ts new file mode 100644 index 000000000..c80a38d2b --- /dev/null +++ b/src/lib/medications/inventory/consumption.ts @@ -0,0 +1,377 @@ +/** + * v1.16.10 — per-intake inventory consumption + restore. + * + * The single write path between intake events and inventory items. + * Every seam that flips an intake event to TAKEN calls + * `consumeForIntake`; every seam that takes a row OUT of taken + * (skip toggle, edit, delete, bulk delete, slot-dedup tombstone) + * calls `restoreForIntake`. No other module may write + * `medicationInventoryItem` rows outside the inventory CRUD routes — + * a convention test pins that. + * + * Exactly-once semantics hang off the consumption STAMP — the + * `MedicationIntakeEvent.inventoryConsumption` Json column holding + * `[{itemId, units}]`: + * + * - `consumeForIntake` is a no-op on a stamped row, so idempotent + * replays (iOS sync, Telegram redelivery, double taps) can call it + * freely and the stock only ever moves once per taken event. + * - `restoreForIntake` refunds exactly what the stamp recorded and + * clears it, so a later re-take consumes afresh. The stamp freezes + * history: editing `Medication.unitsPerDose` after the fact never + * changes what an already-taken dose consumed or refunds. + * + * Consumption is in UNITS (`Medication.unitsPerDose` per dose), pulled + * from the open container first, then FEFO over unopened stock with + * auto-open, spilling across containers, flooring at zero when the + * tracked stock runs out (partial stamp — never negative, never an + * error). + * + * Both functions are best-effort and NEVER throw: a failure annotates + * (`medication.inventory.consume_error` / `.restore_error`) and + * returns — the intake write is the source of truth, the inventory is + * an opt-in companion that must never block a dose record. + */ + +import { Prisma, type PrismaClient } from "@/generated/prisma/client"; +import { annotate } from "@/lib/logging/context"; +import { + computeExpiresAt, + computeInventoryState, +} from "./state-machine"; + +/** The Prisma surface the consumption hook needs. A bare transaction + * client satisfies it; the base client additionally carries + * `$transaction`, which the hook uses to keep the item updates and + * the stamp write atomic. */ +type ConsumptionClient = Pick< + PrismaClient, + "medication" | "medicationIntakeEvent" | "medicationInventoryItem" +> & + Partial>; + +type TxClient = Pick< + PrismaClient, + "medication" | "medicationIntakeEvent" | "medicationInventoryItem" +>; + +/** One consumed slice of the stamp: `units` taken from `itemId`. */ +export interface InventoryConsumptionEntry { + itemId: string; + units: number; +} + +/** + * Run `fn` atomically. When the caller hands us the base client we + * open an interactive transaction; when it hands us a transaction + * client (no `$transaction` on it) the caller's transaction already + * provides the atomicity and we run inline. + */ +async function runAtomically( + client: ConsumptionClient, + fn: (tx: TxClient) => Promise, +): Promise { + if (typeof client.$transaction === "function") { + return client.$transaction((tx) => fn(tx as TxClient)); + } + return fn(client); +} + +/** Parse a stamp column value defensively — a malformed stamp refunds + * nothing rather than throwing. */ +function parseStamp(value: Prisma.JsonValue | null): InventoryConsumptionEntry[] | null { + if (value === null || !Array.isArray(value)) return null; + const entries: InventoryConsumptionEntry[] = []; + for (const raw of value) { + if ( + typeof raw === "object" && + raw !== null && + !Array.isArray(raw) && + typeof (raw as { itemId?: unknown }).itemId === "string" && + typeof (raw as { units?: unknown }).units === "number" && + Number.isFinite((raw as { units: number }).units) && + (raw as { units: number }).units > 0 + ) { + entries.push({ + itemId: (raw as { itemId: string }).itemId, + units: Math.floor((raw as { units: number }).units), + }); + } + } + return entries; +} + +/** + * Consume the medication's `unitsPerDose` units for one taken intake + * event and stamp the event with what was consumed. + * + * No-op unless the event row is live (`deletedAt: null`), taken + * (`takenAt` set), non-skipped, and UNSTAMPED — the stamp is the + * exactly-once gate, so replays and re-posts never double-decrement. + * + * Container selection: + * 1. the open container — IN_USE with `unitsRemaining > 0`, earliest + * `expiresAt` first (createdAt tiebreak); + * 2. then FEFO over unopened ACTIVE stock — `printedExpiry` asc with + * nulls last, then `purchasedAt` asc (nulls last), then + * `createdAt` asc. Pulling from an ACTIVE container auto-opens it + * (`firstUseAt = intakeAt`, state via the state machine — a + * backdated intake can land it straight in EXPIRED when the + * 30-day window has already lapsed; that is correct). + * + * Consumption spills: when the current container holds fewer units + * than still owed, it drains to zero and the next container covers the + * rest. When the tracked stock runs out the consumption floors at + * zero overall — the stamp records the partial amount, no error, no + * negative counts. + */ +export async function consumeForIntake(input: { + client: ConsumptionClient; + userId: string; + medicationId: string; + eventId: string; + intakeAt: Date; +}): Promise { + const { client, userId, medicationId, eventId, intakeAt } = input; + try { + await runAtomically(client, async (tx) => { + const event = await tx.medicationIntakeEvent.findFirst({ + where: { id: eventId, userId }, + select: { + id: true, + medicationId: true, + takenAt: true, + skipped: true, + deletedAt: true, + inventoryConsumption: true, + }, + }); + if ( + !event || + event.deletedAt !== null || + event.takenAt === null || + event.skipped || + event.inventoryConsumption !== null + ) { + return; + } + + const medication = await tx.medication.findFirst({ + where: { id: medicationId, userId }, + select: { unitsPerDose: true }, + }); + if (!medication) return; + const unitsPerDose = Math.max(1, medication.unitsPerDose); + + // Candidate containers, in consumption order: the open container + // first (earliest in-use deadline), then FEFO over unopened + // stock. Both lists are loaded inside the transaction so the + // updates apply to the rows just read. + const inUse = await tx.medicationInventoryItem.findMany({ + where: { + userId, + medicationId, + state: "IN_USE", + unitsRemaining: { gt: 0 }, + }, + orderBy: [ + { expiresAt: { sort: "asc", nulls: "last" } }, + { createdAt: "asc" }, + ], + }); + const active = await tx.medicationInventoryItem.findMany({ + where: { + userId, + medicationId, + state: "ACTIVE", + unitsRemaining: { gt: 0 }, + }, + orderBy: [ + { printedExpiry: { sort: "asc", nulls: "last" } }, + { purchasedAt: { sort: "asc", nulls: "last" } }, + { createdAt: "asc" }, + ], + }); + + const stamp: InventoryConsumptionEntry[] = []; + const itemIds: string[] = []; + let autoOpened = 0; + let owed = unitsPerDose; + // State derives at the WALL clock, not the intake instant: a + // backdated take that auto-opens a container starts its in-use + // window at the (past) intakeAt, so the container can land + // straight in EXPIRED when that window has already lapsed by now + // — correct, the daily cron would flip it on the next sweep + // anyway. + const nowMs = Date.now(); + + for (const item of [...inUse, ...active]) { + if (owed <= 0) break; + const take = Math.min(owed, item.unitsRemaining); + if (take <= 0) continue; + + const wasUnopened = item.firstUseAt === null; + const nextFirstUseAt = item.firstUseAt ?? intakeAt; + const nextUnitsRemaining = item.unitsRemaining - take; + const nextExpiresAt = computeExpiresAt( + nextFirstUseAt, + item.printedExpiry, + ); + const nextState = computeInventoryState( + { + state: item.state, + unitsTotal: item.unitsTotal, + unitsRemaining: nextUnitsRemaining, + firstUseAt: nextFirstUseAt, + printedExpiry: item.printedExpiry, + }, + nowMs, + ); + + await tx.medicationInventoryItem.update({ + where: { id: item.id }, + data: { + state: nextState, + unitsRemaining: nextUnitsRemaining, + firstUseAt: nextFirstUseAt, + expiresAt: nextExpiresAt, + }, + }); + + stamp.push({ itemId: item.id, units: take }); + itemIds.push(item.id); + if (wasUnopened) autoOpened += 1; + owed -= take; + } + + // Stamp the event even when nothing (or only part) could be + // consumed: the stamp is the exactly-once gate, and a dose taken + // with no tracked stock must not retro-consume containers + // registered later when a sync replay revisits the row. + await tx.medicationIntakeEvent.update({ + where: { id: event.id }, + data: { inventoryConsumption: stamp as unknown as Prisma.InputJsonValue }, + }); + + annotate({ + action: { name: "medication.inventory.consumed" }, + meta: { + medication_id: medicationId, + units: unitsPerDose - owed, + item_ids: itemIds, + auto_opened: autoOpened, + }, + }); + }); + } catch (err) { + annotate({ + action: { name: "medication.inventory.consume_error" }, + meta: { + medication_id: medicationId, + event_id: eventId, + message: err instanceof Error ? err.message : String(err), + }, + }); + } +} + +/** + * Refund the consumption a stamped intake event recorded and clear + * the stamp. + * + * No stamp = no-op (the row never consumed, or was already restored). + * Each refund is clamped to the item's `unitsTotal` so a stock + * correction in between can never overfill a container; the state + * re-derives through the state machine, so a USED_UP container whose + * units come back flips to IN_USE (or EXPIRED when its clock has + * lapsed meanwhile). A stamped item that was deleted since is skipped. + */ +export async function restoreForIntake(input: { + client: ConsumptionClient; + userId: string; + eventId: string; +}): Promise { + const { client, userId, eventId } = input; + try { + await runAtomically(client, async (tx) => { + const event = await tx.medicationIntakeEvent.findFirst({ + where: { id: eventId, userId }, + select: { + id: true, + medicationId: true, + inventoryConsumption: true, + }, + }); + if (!event) return; + const stamp = parseStamp(event.inventoryConsumption); + if (stamp === null || stamp.length === 0) { + // Clear an empty/malformed stamp so a later re-take can + // consume afresh; a NULL stamp is already clear. + if (event.inventoryConsumption !== null) { + await tx.medicationIntakeEvent.update({ + where: { id: event.id }, + data: { inventoryConsumption: Prisma.DbNull }, + }); + } + return; + } + + const nowMs = Date.now(); + let refunded = 0; + const itemIds: string[] = []; + for (const entry of stamp) { + const item = await tx.medicationInventoryItem.findFirst({ + where: { id: entry.itemId, userId }, + }); + if (!item) continue; // container deleted since — nothing to refund. + + const nextUnitsRemaining = Math.min( + item.unitsTotal, + item.unitsRemaining + entry.units, + ); + const refundedHere = nextUnitsRemaining - item.unitsRemaining; + const nextState = computeInventoryState( + { + state: item.state, + unitsTotal: item.unitsTotal, + unitsRemaining: nextUnitsRemaining, + firstUseAt: item.firstUseAt, + printedExpiry: item.printedExpiry, + }, + nowMs, + ); + await tx.medicationInventoryItem.update({ + where: { id: item.id }, + data: { + state: nextState, + unitsRemaining: nextUnitsRemaining, + }, + }); + refunded += refundedHere; + itemIds.push(item.id); + } + + await tx.medicationIntakeEvent.update({ + where: { id: event.id }, + data: { inventoryConsumption: Prisma.DbNull }, + }); + + annotate({ + action: { name: "medication.inventory.restored" }, + meta: { + medication_id: event.medicationId, + units: refunded, + item_ids: itemIds, + }, + }); + }); + } catch (err) { + annotate({ + action: { name: "medication.inventory.restore_error" }, + meta: { + event_id: eventId, + message: err instanceof Error ? err.message : String(err), + }, + }); + } +} diff --git a/src/lib/medications/inventory/service.ts b/src/lib/medications/inventory/service.ts index 58e9f8bc7..83df1c318 100644 --- a/src/lib/medications/inventory/service.ts +++ b/src/lib/medications/inventory/service.ts @@ -1,97 +1,19 @@ /** * v1.4.25 W19b — inventory persistence helpers. * - * Thin layer on top of Prisma that the route handlers and the - * intake hook compose. Keeps the state-machine math (pure) separate - * from the I/O (here), so unit tests on the math don't need a DB. + * Thin layer on top of Prisma that the route handlers compose. Keeps + * the state-machine math (pure) separate from the I/O (here), so unit + * tests on the math don't need a DB. + * + * v1.16.10 — the per-intake consumption hook moved to + * `./consumption.ts` (multi-unit, FEFO, spillover, stamped on the + * intake event); this module keeps the expire cron and the + * create-input builder. */ import { prisma } from "@/lib/db"; -import { - computeExpiresAt, - decrementDose, - type DecrementOutcome, -} from "./state-machine"; - -/** - * Consume one dose from the user's active in-use pen for the given - * medication. Called from the intake POST handler after the intake - * event has been created. - * - * Selection policy: - * 1. Prefer the IN_USE pen with the earliest `expiresAt` — that's - * the pen the user is currently working through. - * 2. Fall back to the oldest ACTIVE pen (FIFO consumption — the - * pen that was purchased first gets opened first). The intake - * flips it ACTIVE → IN_USE and sets firstUseAt. - * 3. If no eligible pen exists, this is a no-op. The user just - * logged an intake without a tracked pen — perfectly valid - * since inventory is opt-in per the W19a doses-per-unit - * design. - * - * The function intentionally only updates one pen per intake — if a - * user double-doses (rare), they need to record two intake events - * and the second one will pick up the same or a new pen. - */ -export async function consumeOneDose(input: { - userId: string; - medicationId: string; - intakeAt: Date; -}): Promise<{ - itemId: string; - change: DecrementOutcome; -} | null> { - const { userId, medicationId, intakeAt } = input; - - // Prefer the IN_USE pen with the earliest expiry; tie-break by - // createdAt ascending. - const inUsePen = await prisma.medicationInventoryItem.findFirst({ - where: { - userId, - medicationId, - state: "IN_USE", - dosesRemaining: { gt: 0 }, - }, - orderBy: [{ expiresAt: "asc" }, { createdAt: "asc" }], - }); - - const target = - inUsePen ?? - (await prisma.medicationInventoryItem.findFirst({ - where: { - userId, - medicationId, - state: "ACTIVE", - dosesRemaining: { gt: 0 }, - }, - orderBy: [{ purchasedAt: "asc" }, { createdAt: "asc" }], - })); - - if (!target) return null; - - const { item: nextValues, change } = decrementDose( - { - state: target.state, - dosesTotal: target.dosesTotal, - dosesRemaining: target.dosesRemaining, - firstUseAt: target.firstUseAt, - printedExpiry: target.printedExpiry, - }, - intakeAt, - ); - - await prisma.medicationInventoryItem.update({ - where: { id: target.id }, - data: { - state: nextValues.state, - dosesRemaining: nextValues.dosesRemaining, - firstUseAt: nextValues.firstUseAt, - expiresAt: nextValues.expiresAt, - }, - }); - - return { itemId: target.id, change }; -} +import type { MedicationContainerType } from "@/generated/prisma/client"; +import { computeExpiresAt } from "./state-machine"; /** * Daily cron pass — flip any IN_USE rows whose 30-day window has @@ -111,10 +33,10 @@ export async function expireStaleInUseItems(input: { // selector + state-machine semantics line up here — every row that // matches `state = IN_USE AND expiresAt < now` is also a row that // `computeInventoryState` would classify EXPIRED (clause 2 or 3), - // because IN_USE implies `dosesRemaining > 0` (decrementDose flips - // an empty pen to USED_UP) and the `expiresAt` filter already - // proves the printed-expiry-or-window deadline has lapsed. EXPIRED - // rows are already terminal; USED_UP rows are off-window by + // because IN_USE implies `unitsRemaining > 0` (the consumption hook + // flips an empty container to USED_UP) and the `expiresAt` filter + // already proves the printed-expiry-or-window deadline has lapsed. + // EXPIRED rows are already terminal; USED_UP rows are off-window by // definition. Use a single `updateMany` instead of one update per // row — the daily cron sweep ran N round-trips for nothing. const result = await prisma.medicationInventoryItem.updateMany({ @@ -136,7 +58,8 @@ export async function expireStaleInUseItems(input: { export function buildCreateInventoryInput(input: { userId: string; medicationId: string; - dosesTotal: number; + unitsTotal: number; + containerType: MedicationContainerType; printedExpiry: Date | null; purchasedAt: Date | null; notes: string | null; @@ -145,8 +68,9 @@ export function buildCreateInventoryInput(input: { userId: input.userId, medicationId: input.medicationId, state: "ACTIVE" as const, - dosesTotal: input.dosesTotal, - dosesRemaining: input.dosesTotal, + containerType: input.containerType, + unitsTotal: input.unitsTotal, + unitsRemaining: input.unitsTotal, firstUseAt: null, printedExpiry: input.printedExpiry, purchasedAt: input.purchasedAt, diff --git a/src/lib/medications/inventory/state-machine.ts b/src/lib/medications/inventory/state-machine.ts index a095f05a5..83d50980d 100644 --- a/src/lib/medications/inventory/state-machine.ts +++ b/src/lib/medications/inventory/state-machine.ts @@ -2,16 +2,22 @@ * v1.4.25 W19b — pen / vial inventory state machine. * * Pure functions over a minimal `InventoryItemView` shape — no DB - * access, no Prisma imports. The route handlers and the daily expire - * cron both compose this module with their own persistence layer. + * access, no Prisma imports. The route handlers, the consumption hook + * (`./consumption.ts`), and the daily expire cron all compose this + * module with their own persistence layer. * * Decision tree (in evaluation order): - * 1. dosesRemaining === 0 → USED_UP (terminal) + * 1. unitsRemaining === 0 → USED_UP (terminal) * 2. printedExpiry && now > expiry → EXPIRED (printed label trumps clock) * 3. firstUseAt && now > firstUseAt + IN_USE_WINDOW_MS → EXPIRED * 4. firstUseAt is set → IN_USE * 5. otherwise → ACTIVE * + * v1.16.10 — the item counts UNITS (tablets / ampoules / puffs), not + * doses; `Medication.unitsPerDose` maps between the two. The state + * machine is unit-agnostic — it only cares whether the count reached + * zero. + * * The window length is fixed at 30 days per EMA EPAR §6.3 for * Mounjaro KwikPen / Saxenda / Trulicity. Ozempic ships with a 56-day * window per its own EPAR — that drug-specific override is *not* @@ -38,8 +44,8 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000; */ export interface InventoryItemView { state: MedicationInventoryState; - dosesTotal: number; - dosesRemaining: number; + unitsTotal: number; + unitsRemaining: number; firstUseAt: Date | null; printedExpiry: Date | null; } @@ -49,26 +55,26 @@ export interface InventoryItemView { * * The function is pure — given the same input it always returns the * same state — so the caller can re-evaluate as often as it wants - * without side-effects. The expire-cron and the intake-hook both use - * this same evaluator; the only difference is what they do with the - * result. + * without side-effects. The expire-cron and the consumption hook both + * use this same evaluator; the only difference is what they do with + * the result. */ export function computeInventoryState( item: InventoryItemView, nowMs: number, inUseWindowDays: number = DEFAULT_IN_USE_WINDOW_DAYS, ): MedicationInventoryState { - // (1) Terminal — once a pen is used up, it stays used up. The - // printed expiry on an empty pen is irrelevant; the EXPIRED state - // exists to warn the user not to inject from a pen that's still - // got doses but has gone stale, and an empty pen has neither + // (1) Terminal — once a container is used up, it stays used up. The + // printed expiry on an empty container is irrelevant; the EXPIRED + // state exists to warn the user not to dose from a container that's + // still got units but has gone stale, and an empty one has neither // problem. - if (item.dosesRemaining <= 0) { + if (item.unitsRemaining <= 0) { return "USED_UP"; } - // (2) Printed expiry trumps the in-use clock. A pen whose carton - // expiry has lapsed is EXPIRED even if it was never opened. + // (2) Printed expiry trumps the in-use clock. A container whose + // carton expiry has lapsed is EXPIRED even if it was never opened. if (item.printedExpiry && nowMs > item.printedExpiry.getTime()) { return "EXPIRED"; } @@ -91,9 +97,9 @@ export function computeInventoryState( /** * Compute the `expiresAt` column. It's the min of the in-use * deadline (firstUseAt + window) and the printed expiry — whichever - * lands first. If the pen has never been opened we fall back to the - * printed expiry alone (in-use clock hasn't started). If neither is - * set we return null so the daily expire-cron has nothing to scan. + * lands first. If the container has never been opened we fall back to + * the printed expiry alone (in-use clock hasn't started). If neither + * is set we return null so the daily expire-cron has nothing to scan. */ export function computeExpiresAt( firstUseAt: Date | null, @@ -112,93 +118,10 @@ export function computeExpiresAt( return inUseDeadline ?? printedExpiry ?? null; } -/** - * Outcome of decrementing a dose. `first_use` fires when a pen - * transitions from ACTIVE → IN_USE (this intake event was its first - * use). `consumed` is the routine case. `depleted` fires on the - * intake that brings dosesRemaining to zero. - */ -export type DecrementOutcome = "first_use" | "consumed" | "depleted"; - -/** - * Result of `decrementDose`. The returned `item` is a fresh object — - * the function does not mutate its input. The caller persists the - * returned shape (typically via a `prisma.medicationInventoryItem.update`). - */ -export interface DecrementResult { - item: { - state: MedicationInventoryState; - dosesRemaining: number; - firstUseAt: Date | null; - expiresAt: Date | null; - }; - change: DecrementOutcome; -} - -/** - * Consume one dose from a pen. - * - * If the pen is ACTIVE (never opened), this intake event sets - * `firstUseAt = intakeAt` and starts the 30-day clock. If the pen - * was already IN_USE, only `dosesRemaining` decrements. When the - * last dose is taken, the state moves to USED_UP and `expiresAt` - * is left untouched (the in-use window is no longer meaningful for - * an empty pen, but persisting the deadline preserves the audit - * trail). - * - * The function is defensive — if called on an EXPIRED or USED_UP - * pen it still decrements (caller's job to gate). The state - * recomputation respects whatever the final dose count + clock say. - */ -export function decrementDose( - item: InventoryItemView, - intakeAt: Date, - inUseWindowDays: number = DEFAULT_IN_USE_WINDOW_DAYS, -): DecrementResult { - const wasFirstUse = item.firstUseAt === null; - const nextFirstUseAt = item.firstUseAt ?? intakeAt; - const nextDosesRemaining = Math.max(0, item.dosesRemaining - 1); - const nextExpiresAt = computeExpiresAt( - nextFirstUseAt, - item.printedExpiry, - inUseWindowDays, - ); - - const projected: InventoryItemView = { - state: item.state, - dosesTotal: item.dosesTotal, - dosesRemaining: nextDosesRemaining, - firstUseAt: nextFirstUseAt, - printedExpiry: item.printedExpiry, - }; - const nextState = computeInventoryState( - projected, - intakeAt.getTime(), - inUseWindowDays, - ); - - const change: DecrementOutcome = - nextDosesRemaining === 0 - ? "depleted" - : wasFirstUse - ? "first_use" - : "consumed"; - - return { - item: { - state: nextState, - dosesRemaining: nextDosesRemaining, - firstUseAt: nextFirstUseAt, - expiresAt: nextExpiresAt, - }, - change, - }; -} - /** * Whole-days remaining in the 30-day in-use window, rounded toward - * zero. Returns null when the pen is not currently IN_USE (the UI - * surface for ACTIVE / EXPIRED / USED_UP shows a different label). + * zero. Returns null when the container is not currently IN_USE (the + * UI surface for ACTIVE / EXPIRED / USED_UP shows a different label). * * The day count is computed against the in-use deadline only — * the printed expiry is its own separate countdown surfaced as the @@ -229,7 +152,7 @@ export function daysRemainingInUse( // (the inventory disclosure UI on the medication card) supply just // `firstUseAt` and have already filtered on state === "IN_USE" before // reaching here. The gate is silently skipped for the thin form. - if ("state" in item && "dosesRemaining" in item) { + if ("state" in item && "unitsRemaining" in item) { const state = computeInventoryState( { ...item, firstUseAt }, nowMs, diff --git a/src/lib/openapi/routes/medications.ts b/src/lib/openapi/routes/medications.ts index 2bec317a1..52791dff5 100644 --- a/src/lib/openapi/routes/medications.ts +++ b/src/lib/openapi/routes/medications.ts @@ -11,7 +11,10 @@ import { createMedicationSchema, updateMedicationSchema, intakeSchema, + createInventoryItemSchema, + updateInventoryItemSchema, MEDICATION_CATEGORY_VALUES, + MEDICATION_CONTAINER_TYPE_VALUES, MEDICATION_TREATMENT_CLASS_VALUES, } from "@/lib/validations/medication"; import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt"; @@ -139,6 +142,12 @@ export const medicationResource = z .describe( "Doses per pen / vial for inventory tracking. NULL = inventory tracking off.", ), + unitsPerDose: z + .number() + .int() + .describe( + "v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.", + ), active: z.boolean(), notificationsEnabled: z.boolean(), liveActivityEnabled: z @@ -236,6 +245,61 @@ const medicationDetailEntry = medicationResource "Detail variant of the medication resource enriched with the joined `category`. The base medication fields are inlined; see the `Medication` component for their semantics.", }); +// v1.16.10 — per-container inventory entity (pen / blister pack / +// bottle). Counts UNITS; the medication's `unitsPerDose` maps units to +// doses. The intake consumption hook decrements `unitsRemaining` per +// taken dose and stamps the intake event with what it consumed. +const medicationInventoryItemResource = z + .object({ + id: z.string(), + userId: z.string(), + medicationId: z.string(), + state: z + .enum(["ACTIVE", "IN_USE", "EXPIRED", "USED_UP"]) + .describe( + "Container lifecycle state. ACTIVE = unopened; IN_USE = opened (30-day in-use clock running); EXPIRED = printed expiry or in-use window lapsed with units left; USED_UP = drained (terminal).", + ), + containerType: z + .enum(MEDICATION_CONTAINER_TYPE_VALUES) + .describe( + "Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level classification; defaults to OTHER.", + ), + unitsTotal: z + .number() + .int() + .describe( + "Units the container shipped with (tablets / ampoules / puffs; 1–1000). Dose-derived readouts divide by the medication's `unitsPerDose`.", + ), + unitsRemaining: z + .number() + .int() + .describe( + "Units left in the container. Decremented by the intake consumption hook (FEFO with spillover across containers); refunded when a taken dose is skipped, edited away, or deleted.", + ), + firstUseAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "Instant the container was first used. NULL until opened; starts the 30-day in-use clock.", + ), + expiresAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "Persisted MIN(firstUseAt + 30 days, printedExpiry). NULL when neither clock has started.", + ), + printedExpiry: z.iso.datetime({ offset: true }).nullable(), + purchasedAt: z.iso.datetime({ offset: true }).nullable(), + notes: z.string().nullable(), + createdAt: z.iso.datetime({ offset: true }), + updatedAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "MedicationInventoryItem", + 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.", + }); + const medicationIntakeEventResource = z .object({ id: z.string(), @@ -792,6 +856,135 @@ export const medicationPaths: NonNullable = { }, }, }, + "/api/medications/{id}/inventory": { + get: { + 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.", + requestParams: { + path: z.object({ id: z.string() }), + }, + responses: { + "200": { + description: "Inventory item list.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + items: z.array(medicationInventoryItemResource), + meta: z.object({ total: z.number().int().nonnegative() }), + }), + "ListMedicationInventoryResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Medications"], + summary: "Register a new supply container", + description: + "Creates an ACTIVE inventory item with `unitsRemaining = unitsTotal`. The request's `dosesTotal` field carries UNITS (1–1000). Rate-limited 30/min/user. Audits as `medication.inventory.create`.", + requestParams: { + path: z.object({ id: z.string() }), + }, + requestBody: { + required: true, + content: { + "application/json": { schema: createInventoryItemSchema }, + }, + }, + responses: { + "201": { + description: "Created inventory item.", + content: { + "application/json": { + schema: dataEnvelope( + medicationInventoryItemResource, + "CreateMedicationInventoryItemResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/inventory/{itemId}": { + patch: { + tags: ["Medications"], + summary: "Mutate a supply container", + description: + "Per-item operations: manual first-use (`markAsFirstUseAt`), used-up override (`markAsUsedUp`), printed-expiry correction, absolute remaining-unit correction (`dosesRemaining`, clamped to the item's capacity), notes. The canonical state machine re-derives the state after every mutation. Audits as `medication.inventory.update`.", + requestParams: { + path: z.object({ id: z.string(), itemId: z.string() }), + }, + requestBody: { + required: true, + content: { + "application/json": { schema: updateInventoryItemSchema }, + }, + }, + responses: { + "200": { + description: "Updated inventory item.", + content: { + "application/json": { + schema: dataEnvelope( + medicationInventoryItemResource, + "UpdateMedicationInventoryItemResponse", + ), + }, + }, + }, + "404": { + description: + "Inventory item not found (or owned by another user / medication).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Medications"], + summary: "Delete a supply container", + description: + "Hard-deletes the inventory item. The audit log captures the before-state (`medication.inventory.delete`) so a row can be reconstructed if needed. Consumption stamps on intake events that reference the item stay in place; a later restore skips the missing container.", + requestParams: { + path: z.object({ id: z.string(), itemId: z.string() }), + }, + responses: { + "200": { + description: "Deletion succeeded.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ id: z.string(), deleted: z.boolean() }), + "DeleteMedicationInventoryItemResponse", + ), + }, + }, + }, + "404": { + description: + "Inventory item not found (or owned by another user / medication).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, "/api/medications/extract": { post: { tags: ["Medications"], diff --git a/src/lib/validations/__tests__/medication-inventory.test.ts b/src/lib/validations/__tests__/medication-inventory.test.ts new file mode 100644 index 000000000..e29fc0399 --- /dev/null +++ b/src/lib/validations/__tests__/medication-inventory.test.ts @@ -0,0 +1,166 @@ +/** + * v1.16.10 — inventory + units-per-dose validation bounds. + * + * Pins the raised unit caps (100 → 1000 on container capacity and the + * stock correction; `dosesPerUnit` rides along) and the new + * `unitsPerDose` (1–100) and `containerType` fields. The GLP-1 legacy + * ledger delta deliberately KEEPS its ±100 bound — it counts pens, not + * units. + */ +import { describe, expect, it } from "vitest"; + +import { + createInventoryItemSchema, + updateInventoryItemSchema, + createMedicationSchema, + updateMedicationSchema, + glp1InventoryPostSchema, +} from "../medication"; + +const MINIMAL_MEDICATION = { + name: "Med", + dose: "5 mg", + schedules: [ + { + windowStart: "08:00", + windowEnd: "09:00", + }, + ], +}; + +describe("createInventoryItemSchema — unit cap 1000", () => { + it("accepts 1000 units", () => { + const r = createInventoryItemSchema.safeParse({ dosesTotal: 1000 }); + expect(r.success).toBe(true); + }); + + it("rejects 1001 units", () => { + const r = createInventoryItemSchema.safeParse({ dosesTotal: 1001 }); + expect(r.success).toBe(false); + }); + + it("rejects zero", () => { + const r = createInventoryItemSchema.safeParse({ dosesTotal: 0 }); + expect(r.success).toBe(false); + }); + + it("accepts every container type and defaults to absent", () => { + for (const ct of [ + "PEN", + "AMPOULE", + "BLISTER", + "INHALER", + "BOTTLE", + "OTHER", + ]) { + const r = createInventoryItemSchema.safeParse({ + dosesTotal: 4, + containerType: ct, + }); + expect(r.success).toBe(true); + } + const r = createInventoryItemSchema.safeParse({ dosesTotal: 4 }); + expect(r.success).toBe(true); + expect(r.success && r.data.containerType).toBeUndefined(); + }); + + it("rejects an unknown container type", () => { + const r = createInventoryItemSchema.safeParse({ + dosesTotal: 4, + containerType: "BAG", + }); + expect(r.success).toBe(false); + }); +}); + +describe("updateInventoryItemSchema — stock correction cap 1000", () => { + it("accepts 1000", () => { + const r = updateInventoryItemSchema.safeParse({ dosesRemaining: 1000 }); + expect(r.success).toBe(true); + }); + + it("rejects 1001", () => { + const r = updateInventoryItemSchema.safeParse({ dosesRemaining: 1001 }); + expect(r.success).toBe(false); + }); +}); + +describe("medication schemas — dosesPerUnit cap 1000 + unitsPerDose 1–100", () => { + it("create accepts dosesPerUnit 1000 and rejects 1001", () => { + expect( + createMedicationSchema.safeParse({ + ...MINIMAL_MEDICATION, + dosesPerUnit: 1000, + }).success, + ).toBe(true); + expect( + createMedicationSchema.safeParse({ + ...MINIMAL_MEDICATION, + dosesPerUnit: 1001, + }).success, + ).toBe(false); + }); + + it("update accepts dosesPerUnit 1000 and rejects 1001", () => { + expect(updateMedicationSchema.safeParse({ dosesPerUnit: 1000 }).success).toBe( + true, + ); + expect(updateMedicationSchema.safeParse({ dosesPerUnit: 1001 }).success).toBe( + false, + ); + }); + + it("create bounds unitsPerDose to 1–100", () => { + expect( + createMedicationSchema.safeParse({ + ...MINIMAL_MEDICATION, + unitsPerDose: 1, + }).success, + ).toBe(true); + expect( + createMedicationSchema.safeParse({ + ...MINIMAL_MEDICATION, + unitsPerDose: 100, + }).success, + ).toBe(true); + expect( + createMedicationSchema.safeParse({ + ...MINIMAL_MEDICATION, + unitsPerDose: 0, + }).success, + ).toBe(false); + expect( + createMedicationSchema.safeParse({ + ...MINIMAL_MEDICATION, + unitsPerDose: 101, + }).success, + ).toBe(false); + expect( + createMedicationSchema.safeParse({ + ...MINIMAL_MEDICATION, + unitsPerDose: 1.5, + }).success, + ).toBe(false); + }); + + it("update bounds unitsPerDose to 1–100", () => { + expect(updateMedicationSchema.safeParse({ unitsPerDose: 2 }).success).toBe( + true, + ); + expect(updateMedicationSchema.safeParse({ unitsPerDose: 0 }).success).toBe( + false, + ); + expect( + updateMedicationSchema.safeParse({ unitsPerDose: 101 }).success, + ).toBe(false); + }); +}); + +describe("glp1InventoryPostSchema — the legacy pen-ledger delta keeps ±100", () => { + it("accepts ±100 and rejects ±101", () => { + expect(glp1InventoryPostSchema.safeParse({ delta: 100, reason: "purchased" }).success).toBe(true); + expect(glp1InventoryPostSchema.safeParse({ delta: -100, reason: "damaged" }).success).toBe(true); + expect(glp1InventoryPostSchema.safeParse({ delta: 101, reason: "purchased" }).success).toBe(false); + expect(glp1InventoryPostSchema.safeParse({ delta: -101, reason: "damaged" }).success).toBe(false); + }); +}); diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index 1c9213675..d8ab0b5cb 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -456,8 +456,23 @@ export const createMedicationSchema = z category: z.enum(MEDICATION_CATEGORY_VALUES).optional(), /** v1.4.25 W4d — treatment-class discriminator (GENERIC | GLP1). */ treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(), - /** v1.4.25 W4d — doses per pen/vial for inventory tracking. */ - dosesPerUnit: z.number().int().min(1).max(100).optional(), + /** v1.4.25 W4d — doses per pen/vial for inventory tracking. + * v1.16.10 raises the cap to 1000 (large tablet packs). */ + dosesPerUnit: z.number().int().min(1).max(1000).optional(), + /** + * v1.16.10 — inventory units one dose consumes (2 × 2 mg tablets + * for a 4 mg dose). Default 1. Dose-level readouts divide the + * unit counts by this factor. + */ + unitsPerDose: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe( + "Inventory units consumed per dose (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.", + ), /** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */ deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(), /** @@ -528,7 +543,17 @@ export const updateMedicationSchema = z dose: z.string().min(1).max(50).optional(), category: z.enum(MEDICATION_CATEGORY_VALUES).optional(), treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(), - dosesPerUnit: z.number().int().min(1).max(100).nullable().optional(), + dosesPerUnit: z.number().int().min(1).max(1000).nullable().optional(), + /** v1.16.10 — inventory units one dose consumes. Default 1. */ + unitsPerDose: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe( + "Inventory units consumed per dose (e.g. 2 tablets of 2 mg for a 4 mg dose). The intake consumption hook decrements this many units per taken dose; already-stamped intake events keep their recorded consumption.", + ), /** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */ deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(), /** v1.8.5 — per-medication injection-site tracking opt-in. */ @@ -799,41 +824,103 @@ export type BulkDeleteIntakeEventsInput = z.infer< * USED_UP (manual override). EXPIRED is owned by the daily cron and * the intake hook, so the PATCH schema deliberately omits it. */ -export const createInventoryItemSchema = z.object({ - dosesTotal: z.number().int().min(1).max(100), - printedExpiry: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .nullable() - .optional(), - purchasedAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .nullable() - .optional(), - notes: z.string().max(200).nullable().optional(), -}); +/** + * v1.16.10 — container kinds, mirrored from the Prisma + * `MedicationContainerType` enum. Display-level classification only. + */ +export const MEDICATION_CONTAINER_TYPE_VALUES = [ + "PEN", + "AMPOULE", + "BLISTER", + "INHALER", + "BOTTLE", + "OTHER", +] as const; +export type MedicationContainerTypeValue = + (typeof MEDICATION_CONTAINER_TYPE_VALUES)[number]; -export const updateInventoryItemSchema = z.object({ - markAsFirstUseAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .optional(), - markAsUsedUp: z.boolean().optional(), - printedExpiry: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .nullable() - .optional(), - /** - * v1.16.1 — stock correction (the Bestand tab's adjust / withdraw - * flow). Sets the remaining-dose count directly; the route clamps to - * `dosesTotal` and re-runs the canonical state machine (0 ⇒ USED_UP, - * a raise out of 0 re-evaluates against the expiry clocks). - */ - dosesRemaining: z.number().int().min(0).max(100).optional(), - notes: z.string().max(200).nullable().optional(), -}); +export const createInventoryItemSchema = z + .object({ + /** Units the container ships with. v1.16.10 raises the cap from + * 100 to 1000 (large tablet packs); the wire field name stays + * `dosesTotal` — it counts units, mapped to doses via + * `Medication.unitsPerDose`. */ + dosesTotal: z + .number() + .int() + .min(1) + .max(1000) + .describe( + "Units the container ships with (tablets / ampoules / puffs; 1–1000). Stored as `unitsTotal`; dose-derived readouts divide by the medication's `unitsPerDose`.", + ), + /** v1.16.10 — container kind. Defaults to OTHER when absent. */ + containerType: z + .enum(MEDICATION_CONTAINER_TYPE_VALUES) + .optional() + .describe( + "Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level only; defaults to OTHER.", + ), + printedExpiry: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .nullable() + .optional(), + purchasedAt: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .nullable() + .optional(), + notes: z.string().max(200).nullable().optional(), + }) + .meta({ + id: "CreateMedicationInventoryItemRequest", + description: + "Register a new supply container (pen / blister pack / bottle). `dosesTotal` counts UNITS (1–1000); the item starts ACTIVE with `unitsRemaining = unitsTotal` and the intake consumption hook decrements it per taken dose.", + }); + +export const updateInventoryItemSchema = z + .object({ + markAsFirstUseAt: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional() + .describe( + "Manually start the 30-day in-use clock (the user opened the container without logging an intake). ACTIVE flips to IN_USE; a backdated instant whose window already lapsed lands EXPIRED.", + ), + markAsUsedUp: z + .boolean() + .optional() + .describe( + "Terminal override: zero the remaining units and mark the container USED_UP (physically discarded).", + ), + printedExpiry: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .nullable() + .optional(), + /** + * v1.16.1 — stock correction (the Bestand tab's adjust / withdraw + * flow). Sets the remaining-unit count directly; the route clamps to + * `unitsTotal` and re-runs the canonical state machine (0 ⇒ USED_UP, + * a raise out of 0 re-evaluates against the expiry clocks). + * v1.16.10 raises the cap to 1000 alongside the capacity cap. + */ + dosesRemaining: z + .number() + .int() + .min(0) + .max(1000) + .optional() + .describe( + "Absolute remaining-unit correction (0–1000). Clamped server-side to the item's `unitsTotal`; the canonical state machine re-derives the state (0 ⇒ USED_UP).", + ), + notes: z.string().max(200).nullable().optional(), + }) + .meta({ + id: "UpdateMedicationInventoryItemRequest", + description: + "Per-item inventory mutation: manual first-use, used-up override, printed-expiry correction, absolute remaining-unit correction, notes. Every field is optional and commutative.", + }); export type CreateInventoryItemInput = z.infer< typeof createInventoryItemSchema @@ -859,8 +946,9 @@ export type UpdateInventoryItemInput = z.infer< * - `effectiveFrom` is constrained to a ±5-year window around now — * a paper-record back-fill or a planned future step both fit, but * "1970" / "9999" do not. - * - `delta` is a non-zero finite integer in [−100, 100] (the existing - * inventory route caps `dosesTotal` at 100 doses per pen). + * - `delta` is a non-zero finite integer in [−100, 100] — the legacy + * ledger counts pens, and ±100 pens per correction stays plenty + * (deliberately NOT raised with the v1.16.10 per-item unit cap). * - `reason` is a bounded string (the route logs it; raw blob bad). */ const MAX_DOSE_MG = 100;