fix(medications): every taken dose moves the stock, in units that match the product

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 17:42:31 +02:00
1 parent dacdcccfd2
commit 7d3557a3fd
53 files changed
+4118 -641

No files matched your search

+442 -1
View File
@@ -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
+28 -11
View File
@@ -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": {
+28 -11
View File
@@ -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": {
+28 -11
View File
@@ -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": {
+28 -11
View File
@@ -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": {
+28 -11
View File
@@ -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": {
+28 -11
View File
@@ -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": {
@@ -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';
+42 -5
View File
@@ -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")
@@ -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();
}
});
});
@@ -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();
});
});
+12
View File
@@ -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,
@@ -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 },
},
});
@@ -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();
});
});
+23 -12
View File
@@ -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 = {
@@ -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);
});
});
@@ -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
@@ -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 () => {
@@ -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);
});
});
@@ -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
+24 -41
View File
@@ -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<ReturnType<typeof consumeOneDose>> = 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,
}
: {}),
},
});
@@ -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 () => {
@@ -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,
},
});
@@ -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",
@@ -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,
},
});
+4
View File
@@ -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
@@ -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);
});
});
@@ -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();
});
});
+53 -1
View File
@@ -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<Response> {
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<Response> {
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<Response> {
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<Response> {
});
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(
+41
View File
@@ -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
+3
View File
@@ -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
@@ -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:<medId>' answers the callback without creating an intake event", async () => {
+35 -4
View File
@@ -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 });
}
@@ -0,0 +1,173 @@
/**
* v1.16.10 — `<AddInventoryDialog>` 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 }) => (
<div>{children}</div>
);
return {
Dialog: ({ children }: { children?: React.ReactNode }) => (
<div data-slot="mock-dialog">{children}</div>
),
DialogContent: Pass,
DialogDescription: Pass,
DialogFooter: Pass,
DialogHeader: Pass,
DialogTitle: Pass,
};
});
vi.mock("@/components/ui/select", () => {
const Pass = ({ children }: { children?: React.ReactNode }) => (
<div>{children}</div>
);
return {
Select: ({
value,
children,
}: {
value: string;
children?: React.ReactNode;
}) => <div data-mock-select-value={value}>{children}</div>,
SelectContent: Pass,
SelectItem: ({ children }: { children?: React.ReactNode }) => (
<div data-slot="mock-select-item">{children}</div>
),
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(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const src = readFileSync(
resolve(__dirname, "../sections/inventory-section.tsx"),
"utf8",
);
describe("<AddInventoryDialog>", () => {
it("surfaces the Dosen | Einheiten segmented control only for multi-unit doses", () => {
const multi = render(
<AddInventoryDialog
medicationId="med-1"
defaultUnitsTotal={28}
unitsPerDose={4}
initialContainerType="BLISTER"
onClose={() => {}}
/>,
);
expect(multi).toContain('data-slot="inventory-quantity-mode"');
expect(multi).toContain(">Dosen<");
expect(multi).toContain(">Einheiten<");
const single = render(
<AddInventoryDialog
medicationId="med-1"
defaultUnitsTotal={28}
unitsPerDose={1}
initialContainerType="BLISTER"
onClose={() => {}}
/>,
);
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(
<AddInventoryDialog
medicationId="med-1"
defaultUnitsTotal={28}
unitsPerDose={4}
initialContainerType="BLISTER"
onClose={() => {}}
/>,
);
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(
<AddInventoryDialog
medicationId="med-1"
defaultUnitsTotal={null}
unitsPerDose={1}
initialContainerType="PEN"
onClose={() => {}}
/>,
);
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";/,
);
});
});
@@ -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();
});
});
@@ -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 && (
<span className="text-muted-foreground">
{" ("}
{t("medications.detail.bestand.unitsDetail", {
remaining: unitsRemaining,
total: unitsTotal,
})}
{")"}
</span>
)}
{runwayDays !== null && (
<span className="text-muted-foreground">
{" · "}
@@ -562,6 +582,8 @@ export function MedicationDetailTabs({
<InventorySection
medicationId={id}
dosesPerUnit={medication.dosesPerUnit}
unitsPerDose={medication.unitsPerDose}
deliveryForm={medication.deliveryForm}
/>
</TabsContent>
@@ -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<InventoryState, "secondary" | "outline" | "destructive
USED_UP: "outline",
};
/** Default container kind for the register flow, from the delivery form. */
function defaultContainerType(deliveryForm: string | undefined): ContainerType {
if (deliveryForm === "INJECTION") return "PEN";
if (deliveryForm === "ORAL") return "BLISTER";
return "OTHER";
}
export function InventorySection({
medicationId,
dosesPerUnit,
unitsPerDose,
deliveryForm,
}: {
medicationId: string;
/** Prefills the register flow's quantity when the medication tracks it. */
dosesPerUnit?: number | null;
/** Units one dose consumes; dose-derived readouts divide by it. */
unitsPerDose?: number | null;
/** Defaults the register flow's container type. */
deliveryForm?: string;
}) {
const { t } = useTranslations();
const [addOpen, setAddOpen] = useState(false);
const [adjustItem, setAdjustItem] = useState<InventoryItem | null>(null);
const perDose = Math.max(1, unitsPerDose ?? 1);
const { data, isLoading } = useQuery<InventoryResponse>({
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 && (
<AddInventoryDialog
medicationId={medicationId}
defaultDosesTotal={dosesPerUnit ?? null}
defaultUnitsTotal={dosesPerUnit ?? null}
unitsPerDose={perDose}
initialContainerType={defaultContainerType(deliveryForm)}
onClose={() => setAddOpen(false)}
/>
)}
@@ -155,6 +202,14 @@ export function InventorySection({
<div className="flex items-center justify-between gap-3 py-3">
<p className="text-foreground text-sm font-medium">
{t("medications.detail.bestand.summary", { remaining, total })}
{perDose > 1 && (
<span className="text-muted-foreground block text-xs font-normal">
{t("medications.detail.bestand.unitsDetail", {
remaining: remainingUnits,
total: totalUnits,
})}
</span>
)}
</p>
<Button
variant="outline"
@@ -180,10 +235,26 @@ export function InventorySection({
data-slot="inventory-item-row"
>
<span className="text-foreground text-sm">
{t("medications.detail.bestand.doses", {
remaining: item.dosesRemaining,
total: item.dosesTotal,
})}
<span className="block">
{t(
`medications.detail.bestand.containerType.${item.containerType}`,
)}
</span>
<span className="text-muted-foreground block text-xs">
{t("medications.detail.bestand.doses", {
remaining: Math.floor(item.unitsRemaining / perDose),
total: Math.floor(item.unitsTotal / perDose),
})}
{perDose > 1 && (
<>
{" · "}
{t("medications.detail.bestand.unitsDetail", {
remaining: item.unitsRemaining,
total: item.unitsTotal,
})}
</>
)}
</span>
</span>
<span className="flex shrink-0 items-center gap-1">
<Badge variant={STATE_BADGE[item.state]} className="text-xs">
@@ -209,32 +280,47 @@ export function InventorySection({
}
/**
* Register a new pen / pack. Two fields (quantity + optional printed
* expiry) → Dialog per ui-guidelines §2.3. The quantity prefills from
* the medication's `dosesPerUnit` so the common case is one tap.
* Register a new pack / container. Quantity (units), container type and
* optional printed expiry → Dialog per ui-guidelines §2.3. The quantity
* prefills from the medication's `dosesPerUnit` so the common case is
* one tap. For multi-unit doses (`unitsPerDose > 1`) a segmented
* Dosen | Einheiten control converts live — the POST always carries
* units.
*/
function AddInventoryDialog({
export function AddInventoryDialog({
medicationId,
defaultDosesTotal,
defaultUnitsTotal,
unitsPerDose,
initialContainerType,
onClose,
}: {
medicationId: string;
defaultDosesTotal: number | null;
defaultUnitsTotal: number | null;
unitsPerDose: number;
initialContainerType: ContainerType;
onClose: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [dosesTotal, setDosesTotal] = useState(
defaultDosesTotal && defaultDosesTotal >= 1 && defaultDosesTotal <= 100
? String(defaultDosesTotal)
const [quantity, setQuantity] = useState(
defaultUnitsTotal && defaultUnitsTotal >= 1 && defaultUnitsTotal <= 1000
? String(defaultUnitsTotal)
: "",
);
// The unit the typed quantity is read in. Only surfaced when a dose
// spans several units; the stored value is ALWAYS units.
const [quantityMode, setQuantityMode] = useState<"units" | "doses">("units");
const [containerType, setContainerType] =
useState<ContainerType>(initialContainerType);
const [expiry, setExpiry] = useState("");
const [busy, setBusy] = useState(false);
const parsed = Number(dosesTotal);
const parsed = Number(quantity);
const effectiveMode = unitsPerDose > 1 ? quantityMode : "units";
const units =
effectiveMode === "doses" ? parsed * unitsPerDose : parsed;
const quantityValid =
Number.isInteger(parsed) && parsed >= 1 && parsed <= 100;
Number.isInteger(parsed) && parsed >= 1 && units >= 1 && units <= 1000;
async function submit(e: FormEvent) {
e.preventDefault();
@@ -242,7 +328,9 @@ function AddInventoryDialog({
setBusy(true);
try {
await apiPost(`/api/medications/${medicationId}/inventory`, {
dosesTotal: parsed,
// Wire field name is `dosesTotal`; it carries UNITS.
dosesTotal: units,
containerType,
printedExpiry: expiry
? new Date(`${expiry}T00:00:00`).toISOString()
: null,
@@ -269,6 +357,33 @@ function AddInventoryDialog({
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="inventory-add-container-type"
className="text-sm font-medium"
>
{t("medications.detail.bestand.containerTypeLabel")}
</label>
<Select
value={containerType}
onValueChange={(v) => setContainerType(v as ContainerType)}
>
<SelectTrigger
id="inventory-add-container-type"
className="w-full"
data-slot="inventory-container-type"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONTAINER_TYPES.map((ct) => (
<SelectItem key={ct} value={ct}>
{t(`medications.detail.bestand.containerType.${ct}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label
htmlFor="inventory-add-quantity"
@@ -276,19 +391,60 @@ function AddInventoryDialog({
>
{t("medications.detail.bestand.addQuantityLabel")}
</label>
{unitsPerDose > 1 && (
<div
className="border-border/60 inline-flex rounded-md border p-0.5"
role="group"
aria-label={t("medications.detail.bestand.addQuantityLabel")}
data-slot="inventory-quantity-mode"
>
<Button
type="button"
size="sm"
variant={quantityMode === "doses" ? "secondary" : "ghost"}
aria-pressed={quantityMode === "doses"}
className="h-7 px-2 text-xs"
onClick={() => setQuantityMode("doses")}
>
{t("medications.detail.bestand.quantityModeDoses")}
</Button>
<Button
type="button"
size="sm"
variant={quantityMode === "units" ? "secondary" : "ghost"}
aria-pressed={quantityMode === "units"}
className="h-7 px-2 text-xs"
onClick={() => setQuantityMode("units")}
>
{t("medications.detail.bestand.quantityModeUnits")}
</Button>
</div>
)}
<Input
id="inventory-add-quantity"
type="number"
inputMode="numeric"
min={1}
max={100}
max={effectiveMode === "doses" ? Math.floor(1000 / unitsPerDose) : 1000}
step={1}
required
autoComplete="off"
value={dosesTotal}
onChange={(e) => 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 && (
<p
className="text-muted-foreground text-xs"
data-slot="inventory-quantity-conversion"
>
{effectiveMode === "doses"
? t("medications.detail.bestand.quantityInUnits", { units })
: t("medications.detail.bestand.quantityInDoses", {
doses: Math.floor(parsed / unitsPerDose),
})}
</p>
)}
<p
id="inventory-add-quantity-helper"
className="text-muted-foreground text-xs"
@@ -336,7 +492,7 @@ function AddInventoryDialog({
}
/**
* Stock correction for one item: sets the remaining-dose count
* Stock correction for one item: sets the remaining-unit count
* absolutely (covers both "I miscounted" and a manual withdrawal). The
* server clamps to the item's capacity and re-derives the state; 0
* marks the item used up.
@@ -352,12 +508,12 @@ function AdjustInventoryDialog({
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [value, setValue] = useState(String(item.dosesRemaining));
const [value, setValue] = useState(String(item.unitsRemaining));
const [busy, setBusy] = useState(false);
const parsed = Number(value);
const valid =
Number.isInteger(parsed) && parsed >= 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,
})}
</p>
</div>
@@ -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"),
@@ -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"
/>
<p className="text-muted-foreground text-xs">
@@ -162,6 +163,33 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
</div>
</div>
{/* 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. */}
<div className="space-y-2">
<Label htmlFor="wizard-units-per-dose" className="text-sm">
{t("medications.wizard.steps.step3.unitsPerDoseLabel")}
</Label>
<Input
id="wizard-units-per-dose"
type="text"
inputMode="numeric"
value={payload.unitsPerDose}
onChange={(e) =>
applyPartial({
unitsPerDose: e.target.value.replace(/[^0-9]/g, "").slice(0, 3),
})
}
placeholder="1"
maxLength={3}
autoComplete="off"
/>
<p className="text-muted-foreground text-xs">
{t("medications.wizard.steps.step3.unitsPerDoseHint")}
</p>
</div>
{/* 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
@@ -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[],
@@ -30,7 +30,8 @@ function fakeMedication(overrides: Partial<Record<string, unknown>> = {}) {
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,
});
});
});
+23 -8
View File
@@ -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
@@ -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();
});
});
+23
View File
@@ -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);
@@ -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<string, unknown>;
}) => {
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<FakeItem>;
}) => {
const item = state.items.find((i) => i.id === args.where.id)!;
Object.assign(item, args.data);
return item;
},
),
},
};
}
function takenEvent(overrides: Partial<FakeEvent> = {}): 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> = {}): 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<typeof makeClient>) {
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<FakeEvent>)],
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<void>) =>
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<typeof makeClient>) {
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 },
]);
});
});
@@ -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,
@@ -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> = {}): 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();
@@ -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<Pick<PrismaClient, "$transaction">>;
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<T>(
client: ConsumptionClient,
fn: (tx: TxClient) => Promise<T>,
): Promise<T> {
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<void> {
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<void> {
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),
},
});
}
}
+19 -95
View File
@@ -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,
+27 -104
View File
@@ -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,
+193
View File
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
},
"/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"],
@@ -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);
});
});
+127 -39
View File
@@ -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;