mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(medications): make injection-site tracking and rotation real
The injection_site column on intake events was read across several surfaces but never written, so site tracking was effectively dead. Wire it end-to-end as a per-medication opt-in, gated on an INJECTION delivery form and default off. - Schema: add medications.track_injection_sites + allowed_injection_sites and users.global_excluded_injection_sites (migrations 0099, 0100; additive, defaulted, no backfill). - Intake write path: accept an optional injectionSite on the per-med, status-toggle, and bulk intake routes. Persist it only on a taken injection with tracking enabled, validated against the medication's effective allowed set (per-med allowed sites minus the user-level global exclusion, deny wins) — a disallowed site returns 422 (the bulk route skips the entry). - Rotation: extend the least-recently-used recommender with an allowed filter; the picker disables sites outside the effective set. - UI: per-medication tracking toggle + allowed-sites editor in the dose wizard step, a skippable post-dose site prompt on both medication cards, and a Settings card for the user-level global exclusion. - Contract: injectionSite is additive on the intake request; OpenAPI regenerated. New GET/PATCH /api/auth/me/injection-site-prefs. The site capture is always optional and never blocks logging a dose.
This commit is contained in:
1 parent
84e592b17e
commit
b99a015266
34 files changed
+1577
-77
No files matched your search
@@ -0,0 +1,82 @@
|
||||
# v1.8.5 — server → iOS: injection-site capture on intake
|
||||
|
||||
**Direction:** server → iOS
|
||||
**Status:** additive, non-breaking. iOS adoption is optional.
|
||||
|
||||
## What changed
|
||||
|
||||
Injection-site tracking is now real on the server. The `injectionSite`
|
||||
column on the intake event existed but was never written; v1.8.5 makes
|
||||
the capture + rotation path end-to-end.
|
||||
|
||||
### 1. New optional request field: `injectionSite`
|
||||
|
||||
`POST /api/medications/{id}/intake`, `POST /api/medications/intake`
|
||||
(status toggle), and `POST /api/medications/intake/bulk` now accept an
|
||||
optional `injectionSite` on a **taken** write. Enum values:
|
||||
|
||||
```
|
||||
ABDOMEN_LEFT, ABDOMEN_RIGHT, ABDOMEN_UPPER_LEFT, ABDOMEN_UPPER_RIGHT,
|
||||
THIGH_LEFT, THIGH_RIGHT, UPPER_ARM_LEFT, UPPER_ARM_RIGHT
|
||||
```
|
||||
|
||||
The field is **optional everywhere** — omit it and the dose records
|
||||
exactly as before. There is no behaviour change for any existing client
|
||||
that never sends it. (It is the same enum the response already echoes via
|
||||
the GLP-1 detail endpoint, so the type is already known to the client.)
|
||||
|
||||
### 2. Server-side gating + validation (important for iOS UX)
|
||||
|
||||
The server only persists `injectionSite` when ALL hold:
|
||||
|
||||
- the dose is being recorded **taken** (never on a skip),
|
||||
- the medication is `deliveryForm === "INJECTION"`,
|
||||
- the medication has `trackInjectionSites === true`,
|
||||
- the submitted site is in the medication's **effective allowed set**.
|
||||
|
||||
The effective allowed set = the per-medication `allowedInjectionSites`
|
||||
(empty = all eight) **minus** the user-level
|
||||
`globalExcludedInjectionSites` deny-list. **Deny always wins**: a site the
|
||||
user globally excluded is never valid even if a medication lists it as
|
||||
preferred.
|
||||
|
||||
A submitted site outside the effective set:
|
||||
- per-medication + status-toggle routes → **422**
|
||||
`errorCode: "medications.intake.injection_site.disallowed"`.
|
||||
- bulk route → that entry is reported `status: "skipped"`,
|
||||
`reason: "injection_site_not_allowed"` (the batch still succeeds).
|
||||
|
||||
A site sent for a non-injection / tracking-off medication, or on a skip,
|
||||
is silently dropped (the dose still records, no error).
|
||||
|
||||
iOS should only offer the picker when the medication is an injection with
|
||||
tracking enabled, and restrict the choices to the effective allowed set,
|
||||
so the 422 path stays an edge case (stale UI / race).
|
||||
|
||||
### 3. New medication fields (read + write)
|
||||
|
||||
`GET /api/medications` and `GET /api/medications/{id}` now echo:
|
||||
|
||||
- `trackInjectionSites: boolean` (default `false`)
|
||||
- `allowedInjectionSites: InjectionSite[]` (default `[]` = no restriction)
|
||||
|
||||
`POST /api/medications` + `PUT /api/medications/{id}` accept both as
|
||||
optional fields (field-by-field; `false` / `[]` are valid explicit
|
||||
values — `false` deactivates tracking, `[]` clears the per-med list).
|
||||
|
||||
### 4. New user-level endpoint
|
||||
|
||||
`GET` / `PATCH /api/auth/me/injection-site-prefs`
|
||||
|
||||
```
|
||||
{ "globalExcludedInjectionSites": InjectionSite[] }
|
||||
```
|
||||
|
||||
The PATCH hard-sets the deny-list (empty array clears it). 60/min rate
|
||||
limit, same envelope as `unit-preference`.
|
||||
|
||||
## Contract source
|
||||
|
||||
All of the above is in `docs/api/openapi.yaml` (regenerated this
|
||||
release). No field was removed or renamed; every addition is optional /
|
||||
defaulted. Existing iOS builds keep working untouched.
|
||||
+58
-1
@@ -2123,6 +2123,26 @@ components:
|
||||
- ORAL
|
||||
- INJECTION
|
||||
- OTHER
|
||||
trackInjectionSites:
|
||||
description: Per-medication injection-site tracking opt-in. Default false. Only meaningful for an INJECTION delivery
|
||||
form; when true the client prompts (skippably) for the site after a taken dose.
|
||||
type: boolean
|
||||
allowedInjectionSites:
|
||||
description: Per-medication allowed / preferred injection sites. Empty array = no per-medication restriction (every site
|
||||
offered). The effective pickable set is this list minus the user's global exclusion.
|
||||
maxItems: 8
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- ABDOMEN_LEFT
|
||||
- ABDOMEN_RIGHT
|
||||
- ABDOMEN_UPPER_LEFT
|
||||
- ABDOMEN_UPPER_RIGHT
|
||||
- THIGH_LEFT
|
||||
- THIGH_RIGHT
|
||||
- UPPER_ARM_LEFT
|
||||
- UPPER_ARM_RIGHT
|
||||
notificationsEnabled:
|
||||
type: boolean
|
||||
liveActivityEnabled:
|
||||
@@ -2303,6 +2323,26 @@ components:
|
||||
- ORAL
|
||||
- INJECTION
|
||||
- OTHER
|
||||
trackInjectionSites:
|
||||
description: Per-medication injection-site tracking opt-in. Only meaningful for an INJECTION delivery form. Set false to
|
||||
deactivate tracking.
|
||||
type: boolean
|
||||
allowedInjectionSites:
|
||||
description: Per-medication allowed / preferred injection sites. Empty array clears the restriction. The effective
|
||||
pickable set subtracts the user's global exclusion (deny wins).
|
||||
maxItems: 8
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- ABDOMEN_LEFT
|
||||
- ABDOMEN_RIGHT
|
||||
- ABDOMEN_UPPER_LEFT
|
||||
- ABDOMEN_UPPER_RIGHT
|
||||
- THIGH_LEFT
|
||||
- THIGH_RIGHT
|
||||
- UPPER_ARM_LEFT
|
||||
- UPPER_ARM_RIGHT
|
||||
active:
|
||||
type: boolean
|
||||
notificationsEnabled:
|
||||
@@ -2381,11 +2421,28 @@ components:
|
||||
row.
|
||||
type: string
|
||||
maxLength: 128
|
||||
injectionSite:
|
||||
description: Optional injection site for a taken dose. Honoured only when the medication is an INJECTION with
|
||||
site-tracking enabled; validated against the medication's effective allowed set (per-medication allowed
|
||||
sites minus the user's global exclusion). A disallowed site returns 422. Omit to record the dose without a
|
||||
site.
|
||||
type: string
|
||||
enum:
|
||||
- ABDOMEN_LEFT
|
||||
- ABDOMEN_RIGHT
|
||||
- ABDOMEN_UPPER_LEFT
|
||||
- ABDOMEN_UPPER_RIGHT
|
||||
- THIGH_LEFT
|
||||
- THIGH_RIGHT
|
||||
- UPPER_ARM_LEFT
|
||||
- UPPER_ARM_RIGHT
|
||||
required:
|
||||
- medicationId
|
||||
description: Per-medication intake log body. Idempotent via `idempotencyKey`; the server also dedupes by a 60-second
|
||||
sliding window when the key is absent. Non-skipped intakes auto-decrement pen inventory (best-effort), refresh
|
||||
the per-day compliance rollup, and — for one-shot medications — flip `active` to false.
|
||||
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).
|
||||
MedicationExtractRequest:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+15
-2
@@ -1271,7 +1271,18 @@
|
||||
"plannedSuffix": "geplant",
|
||||
"drugLevelDisclosure": "Geschätzte Wirkstoffkurve anzeigen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logInjectionSiteTitle": "Injektionsstelle erfassen",
|
||||
"logInjectionSiteDescription": "Wo hast du {name} gespritzt? Optional — du kannst dies überspringen.",
|
||||
"logInjectionSiteSkip": "Überspringen",
|
||||
"logInjectionSiteConfirm": "Stelle speichern",
|
||||
"logInjectionSiteNoneAvailable": "Keine Injektionsstelle verfügbar — alle Stellen sind in deinen Einstellungen ausgeschlossen.",
|
||||
"trackInjectionSitesToggle": "Injektionsstellen erfassen",
|
||||
"trackInjectionSitesHint": "Nach einer eingenommenen Dosis nach der verwendeten Stelle fragen und die nächste Rotation vorschlagen.",
|
||||
"allowedSitesLabel": "Erlaubte Stellen",
|
||||
"allowedSitesHint": "Alle ausgewählt lassen für keine Einschränkung. Global ausgeschlossene Stellen werden nie angeboten.",
|
||||
"injectionRotationOverviewTitle": "Stellen-Rotation",
|
||||
"injectionRotationNextSuggested": "Vorschlag als Nächstes: {site}"
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Diagramm wird geladen…",
|
||||
@@ -3074,7 +3085,9 @@
|
||||
"fhirNote": "Die strukturierte Ausgabe folgt dem FHIR-R4-Standard (ePA-kompatibel).",
|
||||
"generate": "Erstellen",
|
||||
"error": "Export fehlgeschlagen ({code})."
|
||||
}
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen",
|
||||
"globalExcludedInjectionSitesHint": "Hier gelistete Stellen werden für keine Medikation angeboten und beim Erfassen einer Dosis abgelehnt — auch wenn eine Medikation sie bevorzugt."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
+15
-2
@@ -1271,7 +1271,18 @@
|
||||
"plannedSuffix": "planned",
|
||||
"drugLevelDisclosure": "Show estimated drug-level curve"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logInjectionSiteTitle": "Log injection site",
|
||||
"logInjectionSiteDescription": "Where did you inject {name}? Optional — you can skip this.",
|
||||
"logInjectionSiteSkip": "Skip",
|
||||
"logInjectionSiteConfirm": "Save site",
|
||||
"logInjectionSiteNoneAvailable": "No injection site is available — every site is excluded in your settings.",
|
||||
"trackInjectionSitesToggle": "Track injection sites",
|
||||
"trackInjectionSitesHint": "After a dose is taken, ask which site was used and suggest the next rotation.",
|
||||
"allowedSitesLabel": "Allowed sites",
|
||||
"allowedSitesHint": "Leave all selected for no restriction. Sites you excluded globally are never offered.",
|
||||
"injectionRotationOverviewTitle": "Site rotation",
|
||||
"injectionRotationNextSuggested": "Suggested next: {site}"
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Loading chart…",
|
||||
@@ -3074,7 +3085,9 @@
|
||||
"fhirNote": "The structured output follows the FHIR R4 standard (ePA-compatible).",
|
||||
"generate": "Generate",
|
||||
"error": "Export failed ({code})."
|
||||
}
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Globally excluded injection sites",
|
||||
"globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
+15
-2
@@ -1271,7 +1271,18 @@
|
||||
"plannedSuffix": "planificado",
|
||||
"drugLevelDisclosure": "Mostrar curva estimada del principio activo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logInjectionSiteTitle": "Log injection site",
|
||||
"logInjectionSiteDescription": "Where did you inject {name}? Optional — you can skip this.",
|
||||
"logInjectionSiteSkip": "Skip",
|
||||
"logInjectionSiteConfirm": "Save site",
|
||||
"logInjectionSiteNoneAvailable": "No injection site is available — every site is excluded in your settings.",
|
||||
"trackInjectionSitesToggle": "Track injection sites",
|
||||
"trackInjectionSitesHint": "After a dose is taken, ask which site was used and suggest the next rotation.",
|
||||
"allowedSitesLabel": "Allowed sites",
|
||||
"allowedSitesHint": "Leave all selected for no restriction. Sites you excluded globally are never offered.",
|
||||
"injectionRotationOverviewTitle": "Site rotation",
|
||||
"injectionRotationNextSuggested": "Suggested next: {site}"
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Cargando la gráfica…",
|
||||
@@ -3074,7 +3085,9 @@
|
||||
"fhirNote": "La salida estructurada sigue el estándar FHIR R4 (compatible con ePA).",
|
||||
"generate": "Generar",
|
||||
"error": "Error en la exportación ({code})."
|
||||
}
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Globally excluded injection sites",
|
||||
"globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
+15
-2
@@ -1271,7 +1271,18 @@
|
||||
"plannedSuffix": "prévu",
|
||||
"drugLevelDisclosure": "Afficher la courbe estimée du principe actif"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logInjectionSiteTitle": "Log injection site",
|
||||
"logInjectionSiteDescription": "Where did you inject {name}? Optional — you can skip this.",
|
||||
"logInjectionSiteSkip": "Skip",
|
||||
"logInjectionSiteConfirm": "Save site",
|
||||
"logInjectionSiteNoneAvailable": "No injection site is available — every site is excluded in your settings.",
|
||||
"trackInjectionSitesToggle": "Track injection sites",
|
||||
"trackInjectionSitesHint": "After a dose is taken, ask which site was used and suggest the next rotation.",
|
||||
"allowedSitesLabel": "Allowed sites",
|
||||
"allowedSitesHint": "Leave all selected for no restriction. Sites you excluded globally are never offered.",
|
||||
"injectionRotationOverviewTitle": "Site rotation",
|
||||
"injectionRotationNextSuggested": "Suggested next: {site}"
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Chargement du graphique…",
|
||||
@@ -3074,7 +3085,9 @@
|
||||
"fhirNote": "La sortie structurée suit la norme FHIR R4 (compatible ePA).",
|
||||
"generate": "Générer",
|
||||
"error": "Échec de l’export ({code})."
|
||||
}
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Globally excluded injection sites",
|
||||
"globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
+15
-2
@@ -1271,7 +1271,18 @@
|
||||
"plannedSuffix": "pianificato",
|
||||
"drugLevelDisclosure": "Mostra la curva stimata del principio attivo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logInjectionSiteTitle": "Log injection site",
|
||||
"logInjectionSiteDescription": "Where did you inject {name}? Optional — you can skip this.",
|
||||
"logInjectionSiteSkip": "Skip",
|
||||
"logInjectionSiteConfirm": "Save site",
|
||||
"logInjectionSiteNoneAvailable": "No injection site is available — every site is excluded in your settings.",
|
||||
"trackInjectionSitesToggle": "Track injection sites",
|
||||
"trackInjectionSitesHint": "After a dose is taken, ask which site was used and suggest the next rotation.",
|
||||
"allowedSitesLabel": "Allowed sites",
|
||||
"allowedSitesHint": "Leave all selected for no restriction. Sites you excluded globally are never offered.",
|
||||
"injectionRotationOverviewTitle": "Site rotation",
|
||||
"injectionRotationNextSuggested": "Suggested next: {site}"
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Caricamento del grafico…",
|
||||
@@ -3074,7 +3085,9 @@
|
||||
"fhirNote": "L’output strutturato segue lo standard FHIR R4 (compatibile ePA).",
|
||||
"generate": "Genera",
|
||||
"error": "Esportazione non riuscita ({code})."
|
||||
}
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Globally excluded injection sites",
|
||||
"globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
+15
-2
@@ -1271,7 +1271,18 @@
|
||||
"plannedSuffix": "zaplanowane",
|
||||
"drugLevelDisclosure": "Pokaż szacowaną krzywą stężenia leku"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logInjectionSiteTitle": "Log injection site",
|
||||
"logInjectionSiteDescription": "Where did you inject {name}? Optional — you can skip this.",
|
||||
"logInjectionSiteSkip": "Skip",
|
||||
"logInjectionSiteConfirm": "Save site",
|
||||
"logInjectionSiteNoneAvailable": "No injection site is available — every site is excluded in your settings.",
|
||||
"trackInjectionSitesToggle": "Track injection sites",
|
||||
"trackInjectionSitesHint": "After a dose is taken, ask which site was used and suggest the next rotation.",
|
||||
"allowedSitesLabel": "Allowed sites",
|
||||
"allowedSitesHint": "Leave all selected for no restriction. Sites you excluded globally are never offered.",
|
||||
"injectionRotationOverviewTitle": "Site rotation",
|
||||
"injectionRotationNextSuggested": "Suggested next: {site}"
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Ładowanie wykresu…",
|
||||
@@ -3074,7 +3085,9 @@
|
||||
"fhirNote": "Dane ustrukturyzowane są zgodne ze standardem FHIR R4 (kompatybilne z ePA).",
|
||||
"generate": "Generuj",
|
||||
"error": "Eksport nie powiódł się ({code})."
|
||||
}
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Globally excluded injection sites",
|
||||
"globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
-- v1.8.5 — per-medication injection-site tracking.
|
||||
--
|
||||
-- Makes injection-site tracking + rotation real. Prior to this release
|
||||
-- the `injection_site` column on `medication_intake_events` was read in
|
||||
-- several surfaces but never written; tracking was effectively dead.
|
||||
--
|
||||
-- Two additive, defaulted columns on `medications`:
|
||||
--
|
||||
-- - `track_injection_sites` — per-medication opt-in. Only meaningful
|
||||
-- (and only offered in the UI) when `delivery_form = 'INJECTION'`.
|
||||
-- Default false: the post-dose site prompt fires only when this is
|
||||
-- true, and the prompt is always skippable. Deactivatable any time.
|
||||
--
|
||||
-- - `allowed_injection_sites` — per-medication allowed / preferred
|
||||
-- sites. Empty = no per-medication restriction (every site offered).
|
||||
-- The effective pickable set is this list MINUS the user-level
|
||||
-- `users.global_excluded_injection_sites` deny-list (deny wins). The
|
||||
-- intake write path validates a submitted site against the effective
|
||||
-- set and rejects (422) a disallowed value.
|
||||
--
|
||||
-- Both columns are additive + defaulted; no backfill is required. The
|
||||
-- `injection_site` enum type already exists (migration 0046).
|
||||
--
|
||||
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe.
|
||||
--
|
||||
-- Reversibility:
|
||||
-- ALTER TABLE "medications" DROP COLUMN IF EXISTS "track_injection_sites";
|
||||
-- ALTER TABLE "medications" DROP COLUMN IF EXISTS "allowed_injection_sites";
|
||||
-- A roll-back loses the opt-in flag + the per-medication site list; the
|
||||
-- application reads tolerate a missing column / default-empty value.
|
||||
|
||||
ALTER TABLE "medications"
|
||||
ADD COLUMN IF NOT EXISTS "track_injection_sites" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS "allowed_injection_sites" "injection_site"[] NOT NULL DEFAULT ARRAY[]::"injection_site"[];
|
||||
@@ -0,0 +1,30 @@
|
||||
-- v1.8.5 — user-level global injection-site exclusion.
|
||||
--
|
||||
-- A per-user deny-list of injection sites. Sites listed here are never
|
||||
-- offered for ANY medication's injection-site picker, and the intake
|
||||
-- write path rejects (422) a submitted site that lands on this list —
|
||||
-- even when the per-medication `medications.allowed_injection_sites`
|
||||
-- lists it as preferred. Deny always wins.
|
||||
--
|
||||
-- Typical use: a user develops lipohypertrophy at one site and wants it
|
||||
-- excluded across every injectable they track, without editing each
|
||||
-- medication's allowed list individually.
|
||||
--
|
||||
-- Single additive, defaulted enum-array column on `users`. Default
|
||||
-- `'{}'` = no global exclusion (the pre-v1.8.5 behaviour). No backfill.
|
||||
-- The `injection_site` enum type already exists (migration 0046).
|
||||
--
|
||||
-- Idempotent guard (`IF NOT EXISTS`) makes reruns safe.
|
||||
--
|
||||
-- Reversibility:
|
||||
-- ALTER TABLE "users" DROP COLUMN IF EXISTS "global_excluded_injection_sites";
|
||||
-- A roll-back loses the global deny-list (every site becomes pickable
|
||||
-- again, subject to each medication's own allowed list); the
|
||||
-- application reads tolerate a missing column / default-empty value.
|
||||
--
|
||||
-- No index — reads are per-user at request time (`WHERE id = $1`),
|
||||
-- matching `insights_exclude_metrics` and the other display-pref
|
||||
-- columns on the same table.
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "global_excluded_injection_sites" "injection_site"[] NOT NULL DEFAULT ARRAY[]::"injection_site"[];
|
||||
@@ -43,6 +43,13 @@ model User {
|
||||
// behaviour for every existing row.
|
||||
insightsExcludeMetrics String[] @default([]) @map("insights_exclude_metrics")
|
||||
|
||||
// v1.8.5 — user-level injection-site deny-list. Sites listed here are
|
||||
// never offered for ANY medication's injection-site picker and the
|
||||
// intake write path 422s a submitted site that lands on this list,
|
||||
// even when the per-medication `allowedInjectionSites` lists it as
|
||||
// preferred (deny always wins). Default `[]` = no global exclusion.
|
||||
globalExcludedInjectionSites InjectionSite[] @default([]) @map("global_excluded_injection_sites")
|
||||
|
||||
// Telegram notifications (per-user)
|
||||
telegramBotToken String? @map("telegram_bot_token") // AES-256-GCM encrypted
|
||||
telegramChatId String? @map("telegram_chat_id")
|
||||
@@ -917,6 +924,19 @@ model Medication {
|
||||
/// A one-time injection is `oneShot = true` + `deliveryForm =
|
||||
/// INJECTION`. The migration backfills ORAL for every existing row.
|
||||
deliveryForm MedicationDeliveryForm @default(ORAL) @map("delivery_form")
|
||||
/// v1.8.5 — per-medication injection-site tracking opt-in. Only
|
||||
/// meaningful (and only offered in the UI) when `deliveryForm ===
|
||||
/// INJECTION`. Default false: after marking a dose taken the client
|
||||
/// prompts for the site only when this is true, and the prompt is
|
||||
/// always skippable (never blocking). Deactivatable at any time.
|
||||
trackInjectionSites Boolean @default(false) @map("track_injection_sites")
|
||||
/// v1.8.5 — per-medication allowed / preferred injection sites. Empty
|
||||
/// = no per-medication restriction (every site is offered). The
|
||||
/// effective pickable set is this list MINUS the user-level
|
||||
/// `globalExcludedInjectionSites` deny-list (deny always wins). The
|
||||
/// intake write path validates a submitted site against the effective
|
||||
/// set and 422s on a disallowed value.
|
||||
allowedInjectionSites InjectionSite[] @default([]) @map("allowed_injection_sites")
|
||||
/// v1.7.0 — iOS Live Activity opt-in for this medication's reminders.
|
||||
/// Default false; existing rows backfill false. The server stores +
|
||||
/// echoes the flag; the iOS client owns the ActivityKit lifecycle. No
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* v1.8.5 — user-level injection-site preferences endpoint.
|
||||
*
|
||||
* GET /api/auth/me/injection-site-prefs
|
||||
* → `{ globalExcludedInjectionSites: InjectionSite[] }`
|
||||
*
|
||||
* PATCH /api/auth/me/injection-site-prefs
|
||||
* Body: `{ globalExcludedInjectionSites: InjectionSite[] }`
|
||||
* → the resolved next state (idempotent hard-set).
|
||||
*
|
||||
* The global exclusion is a per-user deny-list. Sites listed here are
|
||||
* never offered for ANY medication's injection-site picker, and the
|
||||
* intake write path rejects (422) a submitted site that lands on this
|
||||
* list — even when a medication's `allowedInjectionSites` lists it as
|
||||
* preferred (deny always wins).
|
||||
*
|
||||
* Mirrors the `unit-preference` per-user-scalar pattern: 60/min rate
|
||||
* limit, Zod safeParse → 422 via `returnAllZodIssues`, audit-log row,
|
||||
* field-by-field write (no mass assignment).
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
injectionSiteEnum,
|
||||
INJECTION_SITE_VALUES,
|
||||
type InjectionSiteValue,
|
||||
} from "@/lib/validations/medication";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PATCH_RATE_LIMIT = 60;
|
||||
const PATCH_WINDOW_MS = 60_000;
|
||||
|
||||
const patchBodySchema = z.object({
|
||||
// Dedup at write time; cap at the full enum size. An empty array
|
||||
// clears the exclusion.
|
||||
globalExcludedInjectionSites: z
|
||||
.array(injectionSiteEnum)
|
||||
.max(INJECTION_SITE_VALUES.length)
|
||||
.meta({
|
||||
description:
|
||||
"User-level injection-site deny-list. Sites here are never offered for any medication and rejected at intake (deny wins). Empty array clears the exclusion.",
|
||||
}),
|
||||
});
|
||||
|
||||
interface InjectionSitePrefsResponse {
|
||||
globalExcludedInjectionSites: InjectionSiteValue[];
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.injection-site-prefs.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { globalExcludedInjectionSites: true },
|
||||
});
|
||||
const payload: InjectionSitePrefsResponse = {
|
||||
globalExcludedInjectionSites: (row?.globalExcludedInjectionSites ??
|
||||
[]) as InjectionSiteValue[],
|
||||
};
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`injection-site-prefs:patch:${user.id}`,
|
||||
PATCH_RATE_LIMIT,
|
||||
PATCH_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = patchBodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.injection-site-prefs.patch.invalid_shape" },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
// Dedup while preserving canonical order from the enum tuple.
|
||||
const submitted = new Set(parsed.data.globalExcludedInjectionSites);
|
||||
const next = INJECTION_SITE_VALUES.filter((s) => submitted.has(s));
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { globalExcludedInjectionSites: next },
|
||||
});
|
||||
|
||||
await auditLog("user.injection-site-prefs.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { count: next.length },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.injection-site-prefs.patch" },
|
||||
meta: { excluded_count: next.length },
|
||||
});
|
||||
|
||||
const payload: InjectionSitePrefsResponse = {
|
||||
globalExcludedInjectionSites: next,
|
||||
};
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
intakeSchema,
|
||||
listIntakeEventsSchema,
|
||||
} from "@/lib/validations/medication";
|
||||
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 { reconcileOneShotState } from "@/lib/medications/lifecycle";
|
||||
@@ -76,7 +79,51 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { scheduledFor, takenAt, skipped, idempotencyKey } = parsed.data;
|
||||
const { scheduledFor, takenAt, skipped, idempotencyKey, injectionSite } =
|
||||
parsed.data;
|
||||
|
||||
// v1.8.5 — resolve + server-validate the optional injection site. Load
|
||||
// the medication's delivery form + tracking opt-in + per-medication
|
||||
// allowed sites, plus the user's global exclusion deny-list. A site
|
||||
// outside the effective allowed set is a hard 422; a site on a
|
||||
// non-injection / tracking-off med (or a skip) is silently dropped.
|
||||
let resolvedInjectionSite: InjectionSiteKey | null = null;
|
||||
if (injectionSite !== undefined) {
|
||||
const [med, userRow] = await Promise.all([
|
||||
prisma.medication.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
deliveryForm: true,
|
||||
trackInjectionSites: true,
|
||||
allowedInjectionSites: true,
|
||||
},
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { globalExcludedInjectionSites: true },
|
||||
}),
|
||||
]);
|
||||
const resolution = resolveInjectionSiteForWrite({
|
||||
submitted: injectionSite,
|
||||
taken: !skipped,
|
||||
deliveryForm: med?.deliveryForm ?? "ORAL",
|
||||
trackInjectionSites: med?.trackInjectionSites ?? false,
|
||||
allowedInjectionSites: (med?.allowedInjectionSites ??
|
||||
[]) as InjectionSiteKey[],
|
||||
globalExcludedInjectionSites: (userRow?.globalExcludedInjectionSites ??
|
||||
[]) as InjectionSiteKey[],
|
||||
});
|
||||
if (resolution.kind === "disallowed") {
|
||||
annotate({
|
||||
action: { name: "medication.intake.injection_site.disallowed" },
|
||||
meta: { medication_id: id, site: resolution.site },
|
||||
});
|
||||
return apiError("Injection site is not allowed for this medication", 422, {
|
||||
errorCode: "medications.intake.injection_site.disallowed",
|
||||
});
|
||||
}
|
||||
resolvedInjectionSite = resolution.site;
|
||||
}
|
||||
|
||||
const resolvedTakenAt = skipped ? null : (takenAt ?? new Date());
|
||||
const incomingScheduledFor = scheduledFor ?? takenAt ?? new Date();
|
||||
@@ -163,6 +210,9 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
isExplicitSkip,
|
||||
idempotencyKey: idempotencyKey ?? null,
|
||||
createSource: "WEB",
|
||||
// v1.8.5 — resolved + validated site (null unless a tracking-on
|
||||
// injection taken write supplied an allowed site).
|
||||
injectionSite: resolvedInjectionSite,
|
||||
});
|
||||
event = applied.row;
|
||||
consumedTransition = applied.consumedTransition;
|
||||
@@ -186,6 +236,10 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
skipped,
|
||||
source: "WEB",
|
||||
idempotencyKey: idempotencyKey ?? null,
|
||||
// v1.8.5 — site only on a resolved taken-injection write.
|
||||
...(resolvedInjectionSite !== null && {
|
||||
injectionSite: resolvedInjectionSite,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
// Reset snooze when medication is taken
|
||||
|
||||
@@ -130,6 +130,8 @@ export const PUT = apiHandler(
|
||||
treatmentClass,
|
||||
dosesPerUnit,
|
||||
deliveryForm,
|
||||
trackInjectionSites,
|
||||
allowedInjectionSites,
|
||||
active,
|
||||
notificationsEnabled,
|
||||
liveActivityEnabled,
|
||||
@@ -222,6 +224,11 @@ export const PUT = apiHandler(
|
||||
...(treatmentClass !== undefined && { treatmentClass }),
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
...(deliveryForm !== undefined && { deliveryForm }),
|
||||
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
|
||||
// sites. Field-by-field; `false` / `[]` are valid explicit values
|
||||
// (deactivate tracking / clear the per-med restriction).
|
||||
...(trackInjectionSites !== undefined && { trackInjectionSites }),
|
||||
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
|
||||
...(active !== undefined && { active }),
|
||||
...(notificationsEnabled !== undefined && { notificationsEnabled }),
|
||||
// v1.7.0 — iOS reminder flags, field-by-field.
|
||||
|
||||
@@ -51,6 +51,12 @@ import {
|
||||
applyCanonicalSlotWrite,
|
||||
resolveSlotInstantForWrite,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
|
||||
import {
|
||||
injectionSiteEnum,
|
||||
type InjectionSiteValue,
|
||||
} from "@/lib/validations/medication";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
|
||||
const MAX_ENTRIES_PER_BATCH = 500;
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
@@ -68,6 +74,11 @@ const bulkEntrySchema = z.object({
|
||||
.optional(),
|
||||
skipped: z.boolean().optional().default(false),
|
||||
idempotencyKey: z.string().min(1).max(128).optional(),
|
||||
// v1.8.5 — optional per-entry injection site. Validated against the
|
||||
// medication's effective allowed set; a disallowed site marks the
|
||||
// entry skipped (`injection_site_not_allowed`) without failing the
|
||||
// whole batch, matching the bulk endpoint's per-entry contract.
|
||||
injectionSite: injectionSiteEnum.optional(),
|
||||
});
|
||||
|
||||
const bulkPayloadSchema = z.object({
|
||||
@@ -157,9 +168,35 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
const medicationIds = Array.from(new Set(entries.map((e) => e.medicationId)));
|
||||
const ownedMedications = await prisma.medication.findMany({
|
||||
where: { id: { in: medicationIds }, userId: user.id },
|
||||
select: { id: true },
|
||||
// v1.8.5 — pull the injection-tracking fields so a per-entry
|
||||
// `injectionSite` can be resolved + validated without an extra
|
||||
// per-entry read.
|
||||
select: {
|
||||
id: true,
|
||||
deliveryForm: true,
|
||||
trackInjectionSites: true,
|
||||
allowedInjectionSites: true,
|
||||
},
|
||||
});
|
||||
const ownedSet = new Set(ownedMedications.map((m) => m.id));
|
||||
const medById = new Map(ownedMedications.map((m) => [m.id, m]));
|
||||
|
||||
// v1.8.5 — the user's global exclusion deny-list, loaded once for the
|
||||
// whole batch (it is user-scoped, not per-medication). Only read when
|
||||
// at least one entry actually carries an `injectionSite`, so the hot
|
||||
// sync path (no site) pays for zero extra round-trips.
|
||||
const batchHasInjectionSite = entries.some(
|
||||
(e) => e.injectionSite !== undefined,
|
||||
);
|
||||
let globalExcluded: InjectionSiteValue[] = [];
|
||||
if (batchHasInjectionSite) {
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { globalExcludedInjectionSites: true },
|
||||
});
|
||||
globalExcluded = (userRow?.globalExcludedInjectionSites ??
|
||||
[]) as InjectionSiteValue[];
|
||||
}
|
||||
|
||||
const results: EntryResult[] = [];
|
||||
let inserted = 0;
|
||||
@@ -209,6 +246,31 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
const isExplicitTaken = !entry.skipped && entry.takenAt !== undefined;
|
||||
const isExplicitSkip = entry.skipped === true;
|
||||
|
||||
// v1.8.5 — resolve + validate the optional per-entry injection
|
||||
// site. A disallowed site marks the entry skipped without failing
|
||||
// the batch (per-entry contract); a non-injection / tracking-off /
|
||||
// non-taken entry silently drops it.
|
||||
let resolvedInjectionSite: InjectionSiteKey | null = null;
|
||||
if (entry.injectionSite !== undefined) {
|
||||
const med = medById.get(entry.medicationId);
|
||||
const resolution = resolveInjectionSiteForWrite({
|
||||
submitted: entry.injectionSite,
|
||||
taken: isExplicitTaken,
|
||||
deliveryForm: med?.deliveryForm ?? "ORAL",
|
||||
trackInjectionSites: med?.trackInjectionSites ?? false,
|
||||
allowedInjectionSites: (med?.allowedInjectionSites ??
|
||||
[]) as InjectionSiteKey[],
|
||||
globalExcludedInjectionSites: globalExcluded as InjectionSiteKey[],
|
||||
});
|
||||
if (resolution.kind === "disallowed") {
|
||||
const reason = "injection_site_not_allowed";
|
||||
skipped.push({ index: i, reason });
|
||||
results.push({ index: i, status: "skipped", reason });
|
||||
continue;
|
||||
}
|
||||
resolvedInjectionSite = resolution.site;
|
||||
}
|
||||
|
||||
if (canonicalSlot) {
|
||||
// Scheduled dose — converge onto the one canonical slot row through
|
||||
// the shared upsert: H1 deterministic selection, C2 no-downgrade
|
||||
@@ -226,6 +288,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
isExplicitSkip,
|
||||
idempotencyKey: entry.idempotencyKey ?? null,
|
||||
createSource: "API",
|
||||
injectionSite: resolvedInjectionSite,
|
||||
});
|
||||
if (applied.noDowngradeNoOp) {
|
||||
// C2 — pending echo onto an already-actioned slot. Report it as a
|
||||
@@ -253,6 +316,10 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
skipped: entry.skipped,
|
||||
source: "API",
|
||||
idempotencyKey: entry.idempotencyKey ?? null,
|
||||
// v1.8.5 — site only on a resolved taken-injection entry.
|
||||
...(resolvedInjectionSite !== null && {
|
||||
injectionSite: resolvedInjectionSite,
|
||||
}),
|
||||
},
|
||||
});
|
||||
inserted += 1;
|
||||
|
||||
@@ -36,6 +36,11 @@ import {
|
||||
type ComplianceBucket as RollupComplianceBucket,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
|
||||
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
|
||||
import {
|
||||
injectionSiteEnum,
|
||||
type InjectionSiteValue,
|
||||
} from "@/lib/validations/medication";
|
||||
|
||||
const querySchema = z.object({
|
||||
scope: z.enum(["today", "compliance"]),
|
||||
@@ -53,6 +58,10 @@ const updateSchema = z.object({
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
// v1.8.5 — optional injection site captured alongside a "taken"
|
||||
// status toggle. Validated server-side against the medication's
|
||||
// effective allowed set; ignored for skipped / snoozed.
|
||||
injectionSite: injectionSiteEnum.optional(),
|
||||
});
|
||||
|
||||
function startOfDayInTz(date: Date, tz: string): Date {
|
||||
@@ -314,17 +323,58 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { intakeId, status, takenAt, snoozedUntil } = parsed.data;
|
||||
const { intakeId, status, takenAt, snoozedUntil, injectionSite } =
|
||||
parsed.data;
|
||||
|
||||
// v1.7.0 sync — a tombstoned intake 404s on a status toggle; the
|
||||
// `deletedAt: null` filter refuses to mutate a soft-deleted row.
|
||||
const existing = await prisma.medicationIntakeEvent.findFirst({
|
||||
where: { id: intakeId, deletedAt: null },
|
||||
include: {
|
||||
medication: {
|
||||
select: {
|
||||
deliveryForm: true,
|
||||
trackInjectionSites: true,
|
||||
allowedInjectionSites: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Intake event not found", 404);
|
||||
}
|
||||
|
||||
// v1.8.5 — resolve + server-validate the optional injection site for a
|
||||
// "taken" toggle. A site outside the medication's effective allowed
|
||||
// set is a hard 422; non-injection / tracking-off / non-taken drops it.
|
||||
let resolvedInjectionSite: InjectionSiteValue | null = null;
|
||||
if (injectionSite !== undefined) {
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { globalExcludedInjectionSites: true },
|
||||
});
|
||||
const resolution = resolveInjectionSiteForWrite({
|
||||
submitted: injectionSite,
|
||||
taken: status === "taken",
|
||||
deliveryForm: existing.medication.deliveryForm,
|
||||
trackInjectionSites: existing.medication.trackInjectionSites,
|
||||
allowedInjectionSites:
|
||||
existing.medication.allowedInjectionSites as InjectionSiteValue[],
|
||||
globalExcludedInjectionSites: (userRow?.globalExcludedInjectionSites ??
|
||||
[]) as InjectionSiteValue[],
|
||||
});
|
||||
if (resolution.kind === "disallowed") {
|
||||
annotate({
|
||||
action: { name: "medication.intake.injection_site.disallowed" },
|
||||
meta: { medication_id: existing.medicationId, site: resolution.site },
|
||||
});
|
||||
return apiError("Injection site is not allowed for this medication", 422, {
|
||||
errorCode: "medications.intake.injection_site.disallowed",
|
||||
});
|
||||
}
|
||||
resolvedInjectionSite = resolution.site;
|
||||
}
|
||||
|
||||
const userTzForHook = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
let updated;
|
||||
@@ -338,6 +388,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
takenAt: takenAt ?? new Date(),
|
||||
skipped: false,
|
||||
syncVersion: { increment: 1 },
|
||||
// v1.8.5 — persist the resolved site on the taken branch.
|
||||
...(resolvedInjectionSite !== null && {
|
||||
injectionSite: resolvedInjectionSite,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
prisma.medication.update({
|
||||
|
||||
@@ -155,6 +155,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
treatmentClass,
|
||||
dosesPerUnit,
|
||||
deliveryForm,
|
||||
trackInjectionSites,
|
||||
allowedInjectionSites,
|
||||
notificationsEnabled,
|
||||
liveActivityEnabled,
|
||||
criticalAlarmEnabled,
|
||||
@@ -219,6 +221,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
// 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
|
||||
// sites; Prisma defaults to false / [] when omitted.
|
||||
...(trackInjectionSites !== undefined && { trackInjectionSites }),
|
||||
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
|
||||
// v1.7.0 — iOS reminder flags; Prisma defaults both to false.
|
||||
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
|
||||
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
|
||||
|
||||
@@ -44,6 +44,10 @@ interface Medication {
|
||||
dosesPerUnit?: number | null;
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm?: string;
|
||||
/** v1.8.5 — per-medication injection-site tracking opt-in. */
|
||||
trackInjectionSites?: boolean;
|
||||
/** v1.8.5 — per-medication allowed / preferred injection sites. */
|
||||
allowedInjectionSites?: string[];
|
||||
active: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
pausedAt: string | null;
|
||||
@@ -374,6 +378,8 @@ function medicationToPayload(med: Medication): MedicationPayload {
|
||||
treatmentClass: med.treatmentClass,
|
||||
deliveryForm: med.deliveryForm,
|
||||
dosesPerUnit: med.dosesPerUnit ?? null,
|
||||
trackInjectionSites: med.trackInjectionSites ?? false,
|
||||
allowedInjectionSites: med.allowedInjectionSites ?? [],
|
||||
notificationsEnabled: med.notificationsEnabled,
|
||||
startsOn: med.startsOn ? new Date(med.startsOn) : null,
|
||||
endsOn: med.endsOn ? new Date(med.endsOn) : null,
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
nextInjectionSite,
|
||||
type InjectionSiteKey,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
|
||||
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
|
||||
import {
|
||||
reduceCurrentWindowStatus,
|
||||
toBerlinDate,
|
||||
@@ -79,6 +81,12 @@ export interface Glp1Medication {
|
||||
category: string;
|
||||
treatmentClass?: string;
|
||||
dosesPerUnit?: number | null;
|
||||
/** v1.6.0 — route of administration (drives the injection-site prompt). */
|
||||
deliveryForm?: string;
|
||||
/** v1.8.5 — per-medication injection-site tracking opt-in. */
|
||||
trackInjectionSites?: boolean;
|
||||
/** v1.8.5 — per-medication allowed / preferred injection sites. */
|
||||
allowedInjectionSites?: string[];
|
||||
active: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
pausedAt: string | null;
|
||||
@@ -191,6 +199,12 @@ export function Glp1MedicationCard({
|
||||
const fmt = useFormatters();
|
||||
const [intakeLoading, setIntakeLoading] = useState<string | null>(null);
|
||||
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
|
||||
// v1.8.5 — post-dose injection-site prompt (see medication-card.tsx).
|
||||
const [siteIntakeId, setSiteIntakeId] = useState<string | null>(null);
|
||||
const globalExcluded = useGlobalExcludedInjectionSites();
|
||||
const tracksInjection =
|
||||
medication.deliveryForm === "INJECTION" &&
|
||||
medication.trackInjectionSites === true;
|
||||
|
||||
const { data: compliance } = useQuery({
|
||||
queryKey: queryKeys.medicationCompliance(medication.id),
|
||||
@@ -259,12 +273,39 @@ export function Glp1MedicationCard({
|
||||
),
|
||||
);
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
// v1.8.5 — prompt (skippably) for the injection site on a taken
|
||||
// dose when tracking is enabled.
|
||||
if (!skipped && tracksInjection) {
|
||||
try {
|
||||
const json = await res.json();
|
||||
const eventId = json?.data?.id as string | undefined;
|
||||
if (eventId) setSiteIntakeId(eventId);
|
||||
} catch {
|
||||
/* dose recorded; the site prompt is best-effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIntakeLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmInjectionSite(site: InjectionSiteKey) {
|
||||
const intakeId = siteIntakeId;
|
||||
setSiteIntakeId(null);
|
||||
if (!intakeId) return;
|
||||
const res = await fetch("/api/medications/intake", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ intakeId, status: "taken", injectionSite: site }),
|
||||
});
|
||||
if (res.ok) {
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
} else {
|
||||
toast.error(t("medications.logInjectionSiteNoneAvailable"));
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = medication.schedules[0] ?? null;
|
||||
const now = new Date();
|
||||
const next = nextInjectionFromServer(medication.nextDueAt, now);
|
||||
@@ -469,6 +510,25 @@ export function Glp1MedicationCard({
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* v1.8.5 — post-dose injection-site capture (optional, skippable). */}
|
||||
{tracksInjection && (
|
||||
<LogInjectionSiteDialog
|
||||
open={siteIntakeId !== null}
|
||||
medicationName={medication.name}
|
||||
allowedInjectionSites={
|
||||
(medication.allowedInjectionSites ?? []) as InjectionSiteKey[]
|
||||
}
|
||||
globalExcludedInjectionSites={globalExcluded}
|
||||
history={
|
||||
recentInjections
|
||||
.map((r) => r.injectionSite)
|
||||
.filter(Boolean) as InjectionSiteKey[]
|
||||
}
|
||||
onConfirm={confirmInjectionSite}
|
||||
onSkip={() => setSiteIntakeId(null)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,15 @@ interface InjectionSitePickerProps {
|
||||
/** Recent rotation history (most recent first). Powers the
|
||||
* "recommended next site" dashed-ring annotation. */
|
||||
history?: ReadonlyArray<InjectionSiteKey>;
|
||||
/**
|
||||
* v1.8.5 — the effective allowed set (per-medication allowed sites
|
||||
* minus the user's global exclusion). When supplied, sites outside it
|
||||
* render disabled + non-interactive and the rotation recommendation is
|
||||
* constrained to this set. `undefined` keeps the legacy all-sites
|
||||
* picker (e.g. the wizard rotation preview, which has no medication
|
||||
* context yet).
|
||||
*/
|
||||
allowed?: ReadonlyArray<InjectionSiteKey>;
|
||||
/** Called when the user picks a site. */
|
||||
onChange: (site: InjectionSiteKey) => void;
|
||||
/** Optional class for the outer wrapper. */
|
||||
@@ -41,11 +50,15 @@ interface InjectionSitePickerProps {
|
||||
export function InjectionSitePicker({
|
||||
value,
|
||||
history = [],
|
||||
allowed,
|
||||
onChange,
|
||||
className,
|
||||
}: InjectionSitePickerProps) {
|
||||
const { t } = useTranslations();
|
||||
const recommended = nextInjectionSite(history);
|
||||
const recommended = nextInjectionSite(history, 4, allowed);
|
||||
const allowedSet = allowed ? new Set(allowed) : null;
|
||||
const isAllowed = (site: InjectionSiteKey) =>
|
||||
allowedSet === null || allowedSet.has(site);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -138,9 +151,13 @@ export function InjectionSitePicker({
|
||||
const coord = SITE_COORDS[site];
|
||||
const isActive = site === value;
|
||||
const isRecommended = site === recommended;
|
||||
// v1.8.5 — a site outside the effective allowed set renders
|
||||
// disabled (dimmed, non-interactive) so the user cannot pick a
|
||||
// site the server would reject.
|
||||
const disabled = !isAllowed(site);
|
||||
return (
|
||||
<g key={site}>
|
||||
{isRecommended && !isActive && (
|
||||
{isRecommended && !isActive && !disabled && (
|
||||
<circle
|
||||
cx={coord.x}
|
||||
cy={coord.y}
|
||||
@@ -156,9 +173,11 @@ export function InjectionSitePicker({
|
||||
cy={coord.y}
|
||||
r="6"
|
||||
className={
|
||||
isActive
|
||||
? "fill-primary stroke-primary-foreground"
|
||||
: "fill-muted stroke-foreground/60 hover:fill-accent"
|
||||
disabled
|
||||
? "fill-muted/30 stroke-foreground/20"
|
||||
: isActive
|
||||
? "fill-primary stroke-primary-foreground"
|
||||
: "fill-muted stroke-foreground/60 hover:fill-accent"
|
||||
}
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
@@ -175,17 +194,26 @@ export function InjectionSitePicker({
|
||||
r="12"
|
||||
fill="transparent"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-pressed={isActive}
|
||||
aria-disabled={disabled}
|
||||
aria-label={t(describeInjectionSite(site))}
|
||||
onClick={() => onChange(site)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onChange(site);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer focus:outline-none focus-visible:stroke-current focus-visible:stroke-2"
|
||||
onClick={disabled ? undefined : () => onChange(site)}
|
||||
onKeyDown={
|
||||
disabled
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onChange(site);
|
||||
}
|
||||
}
|
||||
}
|
||||
className={
|
||||
disabled
|
||||
? "cursor-not-allowed focus:outline-none"
|
||||
: "cursor-pointer focus:outline-none focus-visible:stroke-current focus-visible:stroke-2"
|
||||
}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InjectionSitePicker } from "@/components/medications/injection-site-picker";
|
||||
import {
|
||||
effectiveAllowedSites,
|
||||
type InjectionSiteKey,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.8.5 — post-dose injection-site capture.
|
||||
*
|
||||
* Shown after a taken dose for a medication with
|
||||
* `deliveryForm === "INJECTION"` and `trackInjectionSites === true`. The
|
||||
* capture is OPTIONAL and always skippable — closing or "skip" records
|
||||
* no site, the dose stays logged regardless. The picker is constrained
|
||||
* to the medication's effective allowed set (per-medication allowed sites
|
||||
* minus the user's global exclusion), and the rotation suggestion runs
|
||||
* within that set.
|
||||
*/
|
||||
interface LogInjectionSiteDialogProps {
|
||||
open: boolean;
|
||||
medicationName: string;
|
||||
/** Per-medication allowed / preferred sites ([] = no restriction). */
|
||||
allowedInjectionSites: ReadonlyArray<InjectionSiteKey>;
|
||||
/** User-level global exclusion deny-list. */
|
||||
globalExcludedInjectionSites: ReadonlyArray<InjectionSiteKey>;
|
||||
/** Recent rotation history (most recent first) for the dashed-ring hint. */
|
||||
history: ReadonlyArray<InjectionSiteKey>;
|
||||
/** Submit the chosen site (the parent PATCHes it onto the intake). */
|
||||
onConfirm: (site: InjectionSiteKey) => void;
|
||||
/** Dismiss without recording a site (the dose stays taken). */
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
export function LogInjectionSiteDialog({
|
||||
open,
|
||||
medicationName,
|
||||
allowedInjectionSites,
|
||||
globalExcludedInjectionSites,
|
||||
history,
|
||||
onConfirm,
|
||||
onSkip,
|
||||
}: LogInjectionSiteDialogProps) {
|
||||
const { t } = useTranslations();
|
||||
const [selected, setSelected] = useState<InjectionSiteKey | null>(null);
|
||||
|
||||
const allowed = effectiveAllowedSites(
|
||||
allowedInjectionSites,
|
||||
globalExcludedInjectionSites,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) onSkip();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("medications.logInjectionSiteTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.logInjectionSiteDescription", {
|
||||
name: medicationName,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{allowed.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
{t("medications.logInjectionSiteNoneAvailable")}
|
||||
</p>
|
||||
) : (
|
||||
<InjectionSitePicker
|
||||
value={selected}
|
||||
history={history}
|
||||
allowed={allowed}
|
||||
onChange={setSelected}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<Button variant="outline" onClick={onSkip}>
|
||||
{t("medications.logInjectionSiteSkip")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={selected === null}
|
||||
onClick={() => {
|
||||
if (selected !== null) onConfirm(selected);
|
||||
}}
|
||||
>
|
||||
{t("medications.logInjectionSiteConfirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,9 @@ import {
|
||||
medicationDependentKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
|
||||
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
@@ -50,6 +53,12 @@ interface Medication {
|
||||
*/
|
||||
treatmentClass?: string;
|
||||
dosesPerUnit?: number | null;
|
||||
/** v1.6.0 — route of administration (drives the injection-site prompt). */
|
||||
deliveryForm?: string;
|
||||
/** v1.8.5 — per-medication injection-site tracking opt-in. */
|
||||
trackInjectionSites?: boolean;
|
||||
/** v1.8.5 — per-medication allowed / preferred injection sites. */
|
||||
allowedInjectionSites?: string[];
|
||||
active: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
pausedAt: string | null;
|
||||
@@ -122,6 +131,14 @@ export function MedicationCard({
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [intakeLoading, setIntakeLoading] = useState<string | null>(null);
|
||||
// v1.8.5 — post-dose injection-site prompt state. Holds the intake
|
||||
// event id returned by the take POST so the confirm handler can PATCH
|
||||
// the chosen site onto it. Null = dialog closed.
|
||||
const [siteIntakeId, setSiteIntakeId] = useState<string | null>(null);
|
||||
const globalExcluded = useGlobalExcludedInjectionSites();
|
||||
const tracksInjection =
|
||||
medication.deliveryForm === "INJECTION" &&
|
||||
medication.trackInjectionSites === true;
|
||||
|
||||
const { data: compliance } = useQuery({
|
||||
queryKey: queryKeys.medicationCompliance(medication.id),
|
||||
@@ -171,12 +188,40 @@ export function MedicationCard({
|
||||
),
|
||||
);
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
// v1.8.5 — after a TAKEN dose on a tracking-enabled injection,
|
||||
// prompt (skippably) for the site. The dialog PATCHes it onto
|
||||
// the just-created event via the status-toggle route.
|
||||
if (!skipped && tracksInjection) {
|
||||
try {
|
||||
const json = await res.json();
|
||||
const eventId = json?.data?.id as string | undefined;
|
||||
if (eventId) setSiteIntakeId(eventId);
|
||||
} catch {
|
||||
/* dose recorded; the site prompt is best-effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIntakeLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmInjectionSite(site: InjectionSiteKey) {
|
||||
const intakeId = siteIntakeId;
|
||||
setSiteIntakeId(null);
|
||||
if (!intakeId) return;
|
||||
const res = await fetch("/api/medications/intake", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ intakeId, status: "taken", injectionSite: site }),
|
||||
});
|
||||
if (res.ok) {
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
} else {
|
||||
toast.error(t("medications.logInjectionSiteNoneAvailable"));
|
||||
}
|
||||
}
|
||||
|
||||
const rate7 = compliance?.compliance7?.rate ?? 0;
|
||||
const rate30 = compliance?.compliance30?.rate ?? 0;
|
||||
const streak = compliance?.compliance7?.streak ?? 0;
|
||||
@@ -373,6 +418,21 @@ export function MedicationCard({
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* v1.8.5 — post-dose injection-site capture (optional, skippable). */}
|
||||
{tracksInjection && (
|
||||
<LogInjectionSiteDialog
|
||||
open={siteIntakeId !== null}
|
||||
medicationName={medication.name}
|
||||
allowedInjectionSites={
|
||||
(medication.allowedInjectionSites ?? []) as InjectionSiteKey[]
|
||||
}
|
||||
globalExcludedInjectionSites={globalExcluded}
|
||||
history={[]}
|
||||
onConfirm={confirmInjectionSite}
|
||||
onSkip={() => setSiteIntakeId(null)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -13,8 +13,13 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InjectionSitePicker } from "@/components/medications/injection-site-picker";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
INJECTION_SITE_KEYS,
|
||||
describeInjectionSite,
|
||||
type InjectionSiteKey,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
import type { MedicationDeliveryForm } from "@/lib/validations/medication";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -48,14 +53,24 @@ const DELIVERY_FORM_KEYS: readonly MedicationDeliveryForm[] = [
|
||||
|
||||
export function Step3Dose({ payload, applyPartial }: StepProps) {
|
||||
const { t } = useTranslations();
|
||||
// The injection-site rotation map is a visual aide on the editor: the
|
||||
// chosen site is persisted per intake-event (no medication-level
|
||||
// column), so the picker selection here is a local preview the user
|
||||
// collapses when not needed.
|
||||
const [siteOpen, setSiteOpen] = useState(false);
|
||||
const [previewSite, setPreviewSite] = useState<InjectionSiteKey | null>(null);
|
||||
// v1.8.5 — injection-site tracking is a per-medication opt-in surfaced
|
||||
// only for an INJECTION delivery form. When enabled, the user can
|
||||
// restrict the allowed sites; an empty selection means "no
|
||||
// restriction" (every site offered, minus the global exclusion).
|
||||
const [allowedOpen, setAllowedOpen] = useState(false);
|
||||
const isInjection = payload.deliveryForm === "INJECTION";
|
||||
|
||||
function toggleAllowedSite(site: InjectionSiteKey, checked: boolean) {
|
||||
const current = new Set(payload.allowedInjectionSites);
|
||||
if (checked) current.add(site);
|
||||
else current.delete(site);
|
||||
applyPartial({
|
||||
allowedInjectionSites: INJECTION_SITE_KEYS.filter((s) =>
|
||||
current.has(s),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="wizard-step3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -151,40 +166,83 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* v1.6.0 — collapsible injection-site rotation map. Shown only for
|
||||
INJECTION delivery; the per-dose site is logged at intake time
|
||||
so this surface is an optional rotation aide, not a persisted
|
||||
medication field. */}
|
||||
{/* 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
|
||||
suggestion. The allowed-sites checklist restricts the picker
|
||||
(empty = no restriction). */}
|
||||
{isInjection && (
|
||||
<div
|
||||
className="border-border/60 rounded-lg border"
|
||||
className="border-border/60 space-y-3 rounded-lg border p-3"
|
||||
data-slot="wizard-injection-site"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-11 w-full justify-between px-3"
|
||||
aria-expanded={siteOpen}
|
||||
onClick={() => setSiteOpen((prev) => !prev)}
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{t("medications.wizard.steps.step3.injectionSiteToggle")}
|
||||
<label className="flex items-start justify-between gap-3">
|
||||
<span className="space-y-0.5">
|
||||
<span className="block text-sm font-medium">
|
||||
{t("medications.trackInjectionSitesToggle")}
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{t("medications.trackInjectionSitesHint")}
|
||||
</span>
|
||||
</span>
|
||||
{siteOpen ? (
|
||||
<ChevronUp aria-hidden="true" className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown aria-hidden="true" className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
{siteOpen && (
|
||||
<div className="space-y-2 px-3 pb-4">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.wizard.steps.step3.injectionSiteHint")}
|
||||
</p>
|
||||
<InjectionSitePicker
|
||||
value={previewSite}
|
||||
onChange={setPreviewSite}
|
||||
/>
|
||||
<Switch
|
||||
checked={payload.trackInjectionSites}
|
||||
onCheckedChange={(checked) =>
|
||||
applyPartial({
|
||||
trackInjectionSites: checked,
|
||||
// Clear the per-med restriction when tracking is turned off.
|
||||
...(checked ? {} : { allowedInjectionSites: [] }),
|
||||
})
|
||||
}
|
||||
aria-label={t("medications.trackInjectionSitesToggle")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{payload.trackInjectionSites && (
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-9 w-full justify-between px-2"
|
||||
aria-expanded={allowedOpen}
|
||||
onClick={() => setAllowedOpen((prev) => !prev)}
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{t("medications.allowedSitesLabel")}
|
||||
</span>
|
||||
{allowedOpen ? (
|
||||
<ChevronUp aria-hidden="true" className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown aria-hidden="true" className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
{allowedOpen && (
|
||||
<div className="space-y-2 px-1">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.allowedSitesHint")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{INJECTION_SITE_KEYS.map((site) => {
|
||||
const checked =
|
||||
payload.allowedInjectionSites.includes(site);
|
||||
return (
|
||||
<label
|
||||
key={site}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(c) =>
|
||||
toggleAllowedSite(site, c === true)
|
||||
}
|
||||
/>
|
||||
{t(describeInjectionSite(site))}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
type MedicationCategoryValue,
|
||||
type MedicationDeliveryForm,
|
||||
} from "@/lib/validations/medication";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Step 2 — treatment-class taxonomy
|
||||
@@ -207,6 +208,18 @@ export interface WizardPayload {
|
||||
* inventory tracking off (maps to `undefined` / NULL on the wire).
|
||||
*/
|
||||
dosesPerUnit: string;
|
||||
/**
|
||||
* v1.8.5 — per-medication injection-site tracking opt-in. Only
|
||||
* surfaced in Step 3 when `deliveryForm === "INJECTION"`. Default
|
||||
* false (opt-in). Toggling it off clears `allowedInjectionSites`.
|
||||
*/
|
||||
trackInjectionSites: boolean;
|
||||
/**
|
||||
* v1.8.5 — per-medication allowed / preferred injection sites. Empty =
|
||||
* no restriction (every site offered). Only meaningful when
|
||||
* `trackInjectionSites` is true.
|
||||
*/
|
||||
allowedInjectionSites: InjectionSiteKey[];
|
||||
/** Set in Step 5 — mirrors `schedules[activeScheduleIndex].mode`. */
|
||||
mode: "oneShot" | "recurring" | null;
|
||||
/** Mirrors `schedules[activeScheduleIndex].cadence`. */
|
||||
@@ -253,6 +266,8 @@ export function emptyWizardPayload(): WizardPayload {
|
||||
treatmentRow: null,
|
||||
deliveryForm: "ORAL",
|
||||
dosesPerUnit: "",
|
||||
trackInjectionSites: false,
|
||||
allowedInjectionSites: [],
|
||||
mode: draft.mode,
|
||||
cadence: draft.cadence,
|
||||
subControls: draft.subControls,
|
||||
@@ -463,6 +478,10 @@ export interface CreateMedicationBody {
|
||||
deliveryForm: MedicationDeliveryForm;
|
||||
/** v1.6.0 — doses per pen / vial. Omitted when inventory is off. */
|
||||
dosesPerUnit?: 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. */
|
||||
allowedInjectionSites?: InjectionSiteKey[];
|
||||
/**
|
||||
* v1.5.5 D-3 §10 invariant 16 — the wizard owns the create-time
|
||||
* default for `notificationsEnabled` but never overwrites it on
|
||||
@@ -602,6 +621,16 @@ export function buildCreateBody(
|
||||
parsedDosesPerUnit >= 1 && {
|
||||
dosesPerUnit: parsedDosesPerUnit,
|
||||
}),
|
||||
// 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
|
||||
// tracking is on. Non-injections omit both (server keeps defaults).
|
||||
...(committed.deliveryForm === "INJECTION" && {
|
||||
trackInjectionSites: committed.trackInjectionSites,
|
||||
...(committed.trackInjectionSites && {
|
||||
allowedInjectionSites: committed.allowedInjectionSites,
|
||||
}),
|
||||
}),
|
||||
// v1.5.5 D-3 §10 invariant 16 — `notificationsEnabled` only on
|
||||
// create. Edits never round-trip the wizard's default because the
|
||||
// detail page's notifications switch is the single source of
|
||||
@@ -807,6 +836,10 @@ export interface MedicationPayload {
|
||||
deliveryForm?: string;
|
||||
/** v1.6.0 — doses per pen / vial. NULL = inventory tracking off. */
|
||||
dosesPerUnit?: number | null;
|
||||
/** v1.8.5 — injection-site tracking opt-in. */
|
||||
trackInjectionSites?: boolean;
|
||||
/** v1.8.5 — per-medication allowed / preferred injection sites. */
|
||||
allowedInjectionSites?: string[];
|
||||
notificationsEnabled: boolean;
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
@@ -906,6 +939,9 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload
|
||||
typeof initial.dosesPerUnit === "number" && initial.dosesPerUnit >= 1
|
||||
? String(initial.dosesPerUnit)
|
||||
: "",
|
||||
trackInjectionSites: initial.trackInjectionSites ?? false,
|
||||
allowedInjectionSites: (initial.allowedInjectionSites ??
|
||||
[]) as InjectionSiteKey[],
|
||||
mode: first.mode,
|
||||
cadence: first.cadence,
|
||||
subControls: first.subControls,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { DashboardLayoutSection } from "@/components/settings/dashboard-layout-section";
|
||||
import { UnitPreferenceCard } from "@/components/settings/unit-preference-card";
|
||||
import { InjectionSitesCard } from "@/components/settings/injection-sites-card";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -28,6 +29,8 @@ export function DashboardSection() {
|
||||
|
||||
<UnitPreferenceCard isAuthenticated={isAuthenticated} />
|
||||
|
||||
<InjectionSitesCard isAuthenticated={isAuthenticated} />
|
||||
|
||||
<DashboardLayoutSection id="dashboard-layout" />
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Syringe } from "lucide-react";
|
||||
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
INJECTION_SITE_KEYS,
|
||||
describeInjectionSite,
|
||||
type InjectionSiteKey,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
|
||||
/**
|
||||
* v1.8.5 — Settings → global injection-site exclusion.
|
||||
*
|
||||
* A per-user deny-list. Sites checked here are never offered for any
|
||||
* medication's injection-site picker and are rejected (422) at intake —
|
||||
* even when a medication lists them as preferred (deny always wins).
|
||||
* Reads + PATCHes `GET/PATCH /api/auth/me/injection-site-prefs`; the
|
||||
* mutation invalidates `injectionSitePrefs()` so every picker re-reads.
|
||||
*/
|
||||
export function InjectionSitesCard({
|
||||
isAuthenticated,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.injectionSitePrefs(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/auth/me/injection-site-prefs");
|
||||
if (!res.ok) return [] as InjectionSiteKey[];
|
||||
const json = await res.json();
|
||||
return (json.data?.globalExcludedInjectionSites ??
|
||||
[]) as InjectionSiteKey[];
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const excluded: InjectionSiteKey[] = data ?? [];
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (next: InjectionSiteKey[]) => {
|
||||
const res = await fetch("/api/auth/me/injection-site-prefs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ globalExcludedInjectionSites: next }),
|
||||
});
|
||||
if (!res.ok) throw new Error("patch_failed");
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.injectionSitePrefs(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function toggle(site: InjectionSiteKey, checked: boolean) {
|
||||
const set = new Set(excluded);
|
||||
if (checked) set.add(site);
|
||||
else set.delete(site);
|
||||
mutation.mutate(INJECTION_SITE_KEYS.filter((s) => set.has(s)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<SettingsCardHeader
|
||||
icon={Syringe}
|
||||
title={t("settings.globalExcludedInjectionSitesLabel")}
|
||||
description={t("settings.globalExcludedInjectionSitesHint")}
|
||||
/>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{INJECTION_SITE_KEYS.map((site) => (
|
||||
<label key={site} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={excluded.includes(site)}
|
||||
disabled={mutation.isPending}
|
||||
onCheckedChange={(c) => toggle(site, c === true)}
|
||||
/>
|
||||
{t(describeInjectionSite(site))}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
|
||||
|
||||
const base = {
|
||||
taken: true,
|
||||
deliveryForm: "INJECTION",
|
||||
trackInjectionSites: true,
|
||||
allowedInjectionSites: [] as const,
|
||||
globalExcludedInjectionSites: [] as const,
|
||||
};
|
||||
|
||||
describe("resolveInjectionSiteForWrite()", () => {
|
||||
it("persists a valid site on a taken injection with tracking on", () => {
|
||||
const r = resolveInjectionSiteForWrite({ ...base, submitted: "THIGH_LEFT" });
|
||||
expect(r).toEqual({ kind: "ok", site: "THIGH_LEFT" });
|
||||
});
|
||||
|
||||
it("drops the site when the medication is not an INJECTION", () => {
|
||||
const r = resolveInjectionSiteForWrite({
|
||||
...base,
|
||||
deliveryForm: "ORAL",
|
||||
submitted: "THIGH_LEFT",
|
||||
});
|
||||
expect(r).toEqual({ kind: "ok", site: null });
|
||||
});
|
||||
|
||||
it("drops the site when tracking is disabled (default-off)", () => {
|
||||
const r = resolveInjectionSiteForWrite({
|
||||
...base,
|
||||
trackInjectionSites: false,
|
||||
submitted: "THIGH_LEFT",
|
||||
});
|
||||
expect(r).toEqual({ kind: "ok", site: null });
|
||||
});
|
||||
|
||||
it("drops the site on a skipped write (optional, never blocking)", () => {
|
||||
const r = resolveInjectionSiteForWrite({
|
||||
...base,
|
||||
taken: false,
|
||||
submitted: "THIGH_LEFT",
|
||||
});
|
||||
expect(r).toEqual({ kind: "ok", site: null });
|
||||
});
|
||||
|
||||
it("treats an omitted site as no-site (optional capture)", () => {
|
||||
const r = resolveInjectionSiteForWrite({ ...base, submitted: undefined });
|
||||
expect(r).toEqual({ kind: "ok", site: null });
|
||||
});
|
||||
|
||||
it("rejects a site outside the per-medication allowed set (422)", () => {
|
||||
const r = resolveInjectionSiteForWrite({
|
||||
...base,
|
||||
allowedInjectionSites: ["THIGH_RIGHT"],
|
||||
submitted: "ABDOMEN_LEFT",
|
||||
});
|
||||
expect(r).toEqual({ kind: "disallowed", site: "ABDOMEN_LEFT" });
|
||||
});
|
||||
|
||||
it("rejects a globally excluded site even when per-med-preferred (deny wins)", () => {
|
||||
const r = resolveInjectionSiteForWrite({
|
||||
...base,
|
||||
allowedInjectionSites: ["ABDOMEN_LEFT"],
|
||||
globalExcludedInjectionSites: ["ABDOMEN_LEFT"],
|
||||
submitted: "ABDOMEN_LEFT",
|
||||
});
|
||||
expect(r).toEqual({ kind: "disallowed", site: "ABDOMEN_LEFT" });
|
||||
});
|
||||
|
||||
it("accepts a per-med-allowed site that is not globally excluded", () => {
|
||||
const r = resolveInjectionSiteForWrite({
|
||||
...base,
|
||||
allowedInjectionSites: ["ABDOMEN_LEFT", "THIGH_RIGHT"],
|
||||
globalExcludedInjectionSites: ["ABDOMEN_LEFT"],
|
||||
submitted: "THIGH_RIGHT",
|
||||
});
|
||||
expect(r).toEqual({ kind: "ok", site: "THIGH_RIGHT" });
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
INJECTION_SITE_KEYS,
|
||||
SITE_COORDS,
|
||||
describeInjectionSite,
|
||||
effectiveAllowedSites,
|
||||
isSiteAllowed,
|
||||
nextInjectionSite,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
|
||||
@@ -51,6 +53,95 @@ describe("injection-sites", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveAllowedSites() — deny-wins combination", () => {
|
||||
it("returns every site when no per-med list and no exclusion", () => {
|
||||
expect(effectiveAllowedSites([], [])).toEqual([...INJECTION_SITE_KEYS]);
|
||||
});
|
||||
|
||||
it("restricts to the per-med list in canonical order", () => {
|
||||
expect(
|
||||
effectiveAllowedSites(["THIGH_RIGHT", "ABDOMEN_LEFT"], []),
|
||||
).toEqual(["ABDOMEN_LEFT", "THIGH_RIGHT"]);
|
||||
});
|
||||
|
||||
it("drops globally excluded sites even with an empty per-med list", () => {
|
||||
const result = effectiveAllowedSites([], ["ABDOMEN_LEFT", "THIGH_LEFT"]);
|
||||
expect(result).not.toContain("ABDOMEN_LEFT");
|
||||
expect(result).not.toContain("THIGH_LEFT");
|
||||
expect(result).toContain("ABDOMEN_RIGHT");
|
||||
});
|
||||
|
||||
it("global exclusion WINS over a per-med preferred site", () => {
|
||||
// The user lists ABDOMEN_LEFT as preferred for this med, but has
|
||||
// globally excluded it (e.g. lipohypertrophy). Deny wins.
|
||||
const result = effectiveAllowedSites(
|
||||
["ABDOMEN_LEFT", "THIGH_RIGHT"],
|
||||
["ABDOMEN_LEFT"],
|
||||
);
|
||||
expect(result).toEqual(["THIGH_RIGHT"]);
|
||||
});
|
||||
|
||||
it("returns an empty set when the exclusion covers everything", () => {
|
||||
expect(
|
||||
effectiveAllowedSites([], [...INJECTION_SITE_KEYS]),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSiteAllowed()", () => {
|
||||
it("accepts a site in the effective allowed set", () => {
|
||||
expect(isSiteAllowed("THIGH_RIGHT", ["THIGH_RIGHT"], [])).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a site outside the per-med allowed set", () => {
|
||||
expect(isSiteAllowed("ABDOMEN_LEFT", ["THIGH_RIGHT"], [])).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a globally excluded site even when per-med-allowed", () => {
|
||||
expect(
|
||||
isSiteAllowed("ABDOMEN_LEFT", ["ABDOMEN_LEFT"], ["ABDOMEN_LEFT"]),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextInjectionSite() — allowed-set rotation", () => {
|
||||
it("never recommends a site outside the allowed set", () => {
|
||||
const allowed = ["THIGH_LEFT", "THIGH_RIGHT"] as const;
|
||||
const rec = nextInjectionSite(["THIGH_LEFT"], 4, allowed);
|
||||
expect(rec).toBe("THIGH_RIGHT");
|
||||
});
|
||||
|
||||
it("undefined allowed argument keeps legacy all-sites behaviour", () => {
|
||||
// No third argument → rotation over all eight sites, so it picks
|
||||
// a far site rather than the most-recent ABDOMEN_LEFT.
|
||||
expect(nextInjectionSite(["ABDOMEN_LEFT"])).not.toBe("ABDOMEN_LEFT");
|
||||
});
|
||||
|
||||
it("returns null when the effective set is empty (everything excluded)", () => {
|
||||
const everythingExcluded = effectiveAllowedSites(
|
||||
[],
|
||||
[...INJECTION_SITE_KEYS],
|
||||
);
|
||||
expect(everythingExcluded).toEqual([]);
|
||||
expect(
|
||||
nextInjectionSite(["ABDOMEN_LEFT"], 4, everythingExcluded),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("picks the least-recently-used site within the allowed set", () => {
|
||||
// Allowed = three thigh/abdomen sites; history used two of them
|
||||
// recently, so the LRU/untouched one wins.
|
||||
const allowed = ["ABDOMEN_LEFT", "ABDOMEN_RIGHT", "THIGH_LEFT"] as const;
|
||||
const rec = nextInjectionSite(["ABDOMEN_LEFT", "ABDOMEN_RIGHT"], 4, allowed);
|
||||
expect(rec).toBe("THIGH_LEFT");
|
||||
});
|
||||
|
||||
it("falls back to first allowed candidate for empty history", () => {
|
||||
const rec = nextInjectionSite([], 4, ["THIGH_LEFT", "THIGH_RIGHT"]);
|
||||
expect(rec).toBe("THIGH_LEFT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeInjectionSite()", () => {
|
||||
it("returns the right i18n key per site", () => {
|
||||
expect(describeInjectionSite("ABDOMEN_LEFT")).toBe(
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* v1.8.5 — server-side guard for the injection-site write path.
|
||||
*
|
||||
* The intake routes (`POST /api/medications/[id]/intake`,
|
||||
* `POST /api/medications/intake`, `POST /api/medications/intake/bulk`)
|
||||
* accept an optional `injectionSite` and must only ever persist it when:
|
||||
*
|
||||
* 1. the medication is an INJECTION (`deliveryForm === "INJECTION"`),
|
||||
* 2. site-tracking is enabled on the medication
|
||||
* (`trackInjectionSites === true`),
|
||||
* 3. the dose is being recorded as TAKEN (never on a skip),
|
||||
* 4. the submitted site is a member of the medication's EFFECTIVE
|
||||
* allowed set — the per-medication `allowedInjectionSites` minus the
|
||||
* user's `globalExcludedInjectionSites` deny-list (deny wins).
|
||||
*
|
||||
* A submitted site that fails (1)–(3) is silently dropped (the dose still
|
||||
* records, the column stays NULL). A site that fails (4) — a disallowed
|
||||
* value — is a hard validation error the route surfaces as 422, because
|
||||
* the client should never offer a site outside the effective set; a
|
||||
* request that does is either stale UI or a hand-rolled call.
|
||||
*/
|
||||
import {
|
||||
effectiveAllowedSites,
|
||||
type InjectionSiteKey,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
|
||||
export interface ResolveInjectionSiteInput {
|
||||
/** The submitted site, or null/undefined when the client omitted it. */
|
||||
submitted: InjectionSiteKey | null | undefined;
|
||||
/** True only for a taken (non-skipped) write. */
|
||||
taken: boolean;
|
||||
/** The medication's delivery form. */
|
||||
deliveryForm: string;
|
||||
/** The medication's per-medication tracking opt-in. */
|
||||
trackInjectionSites: boolean;
|
||||
/** The medication's per-medication allowed / preferred sites. */
|
||||
allowedInjectionSites: ReadonlyArray<InjectionSiteKey>;
|
||||
/** The user's global exclusion deny-list. */
|
||||
globalExcludedInjectionSites: ReadonlyArray<InjectionSiteKey>;
|
||||
}
|
||||
|
||||
export type ResolveInjectionSiteResult =
|
||||
| { kind: "ok"; site: InjectionSiteKey | null }
|
||||
| { kind: "disallowed"; site: InjectionSiteKey };
|
||||
|
||||
/**
|
||||
* Resolve the site to persist for an intake write, or report that the
|
||||
* submitted site is disallowed.
|
||||
*
|
||||
* - `{ kind: "ok", site: null }` → persist NULL (no site recorded);
|
||||
* covers an omitted site, a skip, a non-injection / tracking-off med.
|
||||
* - `{ kind: "ok", site }` → persist the validated site.
|
||||
* - `{ kind: "disallowed", site }`→ the route returns 422.
|
||||
*/
|
||||
export function resolveInjectionSiteForWrite(
|
||||
input: ResolveInjectionSiteInput,
|
||||
): ResolveInjectionSiteResult {
|
||||
const { submitted, taken, deliveryForm, trackInjectionSites } = input;
|
||||
|
||||
// No site submitted, or the write is not a taken-injection on a
|
||||
// tracking-enabled medication → never persist a site.
|
||||
if (
|
||||
submitted == null ||
|
||||
!taken ||
|
||||
deliveryForm !== "INJECTION" ||
|
||||
!trackInjectionSites
|
||||
) {
|
||||
return { kind: "ok", site: null };
|
||||
}
|
||||
|
||||
const allowed = effectiveAllowedSites(
|
||||
input.allowedInjectionSites,
|
||||
input.globalExcludedInjectionSites,
|
||||
);
|
||||
if (!allowed.includes(submitted)) {
|
||||
return { kind: "disallowed", site: submitted };
|
||||
}
|
||||
return { kind: "ok", site: submitted };
|
||||
}
|
||||
@@ -42,23 +42,83 @@ export const SITE_COORDS: Record<InjectionSiteKey, { x: number; y: number }> = {
|
||||
UPPER_ARM_RIGHT: { x: 102, y: 82 },
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.8.5 — resolve the effective allowed set for a medication given the
|
||||
* per-medication preference and the user-level global exclusion. The
|
||||
* global exclusion is a deny-list and ALWAYS wins: a site the user has
|
||||
* globally excluded is never offered, even if a per-medication entry
|
||||
* lists it as preferred. An empty per-medication list means "no
|
||||
* restriction" — every site is allowed (minus the global exclusion).
|
||||
*
|
||||
* Returns the sites in canonical `INJECTION_SITE_KEYS` order so callers
|
||||
* render a stable picker. The result can be empty only when the global
|
||||
* exclusion covers every remaining site — callers treat an empty set as
|
||||
* "no recommendation, picker shows nothing selectable".
|
||||
*/
|
||||
export function effectiveAllowedSites(
|
||||
allowed: ReadonlyArray<InjectionSiteKey>,
|
||||
globalExcluded: ReadonlyArray<InjectionSiteKey>,
|
||||
): InjectionSiteKey[] {
|
||||
const excludedSet = new Set(globalExcluded);
|
||||
// Empty per-med list = no per-med restriction → start from all sites.
|
||||
const base: ReadonlyArray<InjectionSiteKey> =
|
||||
allowed.length === 0 ? INJECTION_SITE_KEYS : allowed;
|
||||
const allowedSet = new Set(base);
|
||||
// Canonical order, per-med restriction applied, global exclusion wins.
|
||||
return INJECTION_SITE_KEYS.filter(
|
||||
(site) => allowedSet.has(site) && !excludedSet.has(site),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.5 — server-side guard: is `site` a member of the effective
|
||||
* allowed set for this medication? Used by the intake write path to
|
||||
* reject (422) a site the user can no longer pick.
|
||||
*/
|
||||
export function isSiteAllowed(
|
||||
site: InjectionSiteKey,
|
||||
allowed: ReadonlyArray<InjectionSiteKey>,
|
||||
globalExcluded: ReadonlyArray<InjectionSiteKey>,
|
||||
): boolean {
|
||||
return effectiveAllowedSites(allowed, globalExcluded).includes(site);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recommend the next injection site given the recent rotation
|
||||
* history. Strategy: maximise the average Euclidean distance from the
|
||||
* candidate to each of the last N sites (N defaults to 4), tie-break
|
||||
* by least-recently-used (an unseen site beats a stale one).
|
||||
*
|
||||
* Returns `null` only when the input history is empty (no
|
||||
* recommendation to make — the picker shows all sites as available).
|
||||
* v1.8.5 — an optional `allowed` set constrains the candidate pool to
|
||||
* the effective allowed sites (per-med list minus the global
|
||||
* exclusion). Pass the result of {@link effectiveAllowedSites}.
|
||||
* - `undefined` (no argument) → legacy "all eight sites" behaviour.
|
||||
* - a non-empty array → the recommender never suggests a site outside it.
|
||||
* - an explicitly-EMPTY array → no candidate (every site excluded);
|
||||
* the function returns `null`.
|
||||
*
|
||||
* Returns `null` when there is no candidate to recommend — an empty
|
||||
* effective set, or when the only allowed site is the most-recent pick.
|
||||
*/
|
||||
export function nextInjectionSite(
|
||||
history: ReadonlyArray<InjectionSiteKey>,
|
||||
windowSize = 4,
|
||||
allowed?: ReadonlyArray<InjectionSiteKey>,
|
||||
): InjectionSiteKey | null {
|
||||
// v1.8.5 — restrict the candidate pool when an allowed set is given.
|
||||
// `undefined` keeps the legacy all-sites behaviour; an explicitly
|
||||
// empty array means "nothing allowed" and yields no recommendation.
|
||||
const candidates: ReadonlyArray<InjectionSiteKey> =
|
||||
allowed === undefined
|
||||
? INJECTION_SITE_KEYS
|
||||
: INJECTION_SITE_KEYS.filter((s) => allowed.includes(s));
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
if (history.length === 0) {
|
||||
// First-time user — recommend a sensible default (abdomen left)
|
||||
// rather than null. The card needs SOMETHING to point to.
|
||||
return "ABDOMEN_LEFT";
|
||||
// First-time user — recommend a sensible default. Within an allowed
|
||||
// set the first candidate in canonical order stands in for the
|
||||
// legacy "abdomen left" pick.
|
||||
return candidates.includes("ABDOMEN_LEFT") ? "ABDOMEN_LEFT" : candidates[0];
|
||||
}
|
||||
|
||||
const recent = history.slice(0, windowSize);
|
||||
@@ -72,10 +132,10 @@ export function nextInjectionSite(
|
||||
}
|
||||
}
|
||||
|
||||
let bestSite: InjectionSiteKey = INJECTION_SITE_KEYS[0];
|
||||
let bestSite: InjectionSiteKey | null = null;
|
||||
let bestScore = -Infinity;
|
||||
|
||||
for (const candidate of INJECTION_SITE_KEYS) {
|
||||
for (const candidate of candidates) {
|
||||
if (candidate === recent[0]) continue; // never recommend the most-recent site
|
||||
const candidateCoord = SITE_COORDS[candidate];
|
||||
let dist = 0;
|
||||
@@ -95,6 +155,7 @@ export function nextInjectionSite(
|
||||
}
|
||||
}
|
||||
|
||||
// Only candidate was the most-recent site — nothing else to rotate to.
|
||||
return bestSite;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { prisma as defaultPrisma } from "@/lib/db";
|
||||
import { resolveCanonicalSlotInstant } from "@/lib/medications/scheduling/resolve-slot-instant";
|
||||
import type { WorkerScheduleRow } from "@/lib/medications/scheduling/worker-helpers";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
|
||||
type PrismaLike = Pick<PrismaClient, "medication" | "medicationIntakeEvent">;
|
||||
|
||||
@@ -58,6 +59,14 @@ export interface ApplyCanonicalSlotWriteInput {
|
||||
idempotencyKey: string | null;
|
||||
/** Source to stamp on a freshly-created slot row. */
|
||||
createSource: "WEB" | "API";
|
||||
/**
|
||||
* v1.8.5 — resolved + server-validated injection site to persist on a
|
||||
* taken write. `null` = no site (the column stays / is set NULL). Only
|
||||
* ever non-null on an explicit taken write for a tracking-enabled
|
||||
* INJECTION medication; the route resolves + validates it before the
|
||||
* upsert, so this is trusted here.
|
||||
*/
|
||||
injectionSite?: InjectionSiteKey | null;
|
||||
}
|
||||
|
||||
export interface ApplyCanonicalSlotWriteResult {
|
||||
@@ -158,6 +167,7 @@ export async function applyCanonicalSlotWrite(
|
||||
skipped,
|
||||
idempotencyKey,
|
||||
createSource,
|
||||
injectionSite = null,
|
||||
} = input;
|
||||
|
||||
const rows = await findSlotRows(client, userId, medicationId, canonicalSlot);
|
||||
@@ -183,6 +193,9 @@ export async function applyCanonicalSlotWrite(
|
||||
skipped,
|
||||
source: createSource,
|
||||
idempotencyKey,
|
||||
// v1.8.5 — site only when the route resolved one (taken
|
||||
// injection, tracking on, validated allowed).
|
||||
...(injectionSite !== null && { injectionSite }),
|
||||
},
|
||||
select: SLOT_ROW_SELECT,
|
||||
})) as SlotIntakeRow;
|
||||
@@ -221,8 +234,14 @@ async function applyToExisting(
|
||||
existing: SlotIntakeRow,
|
||||
input: ApplyCanonicalSlotWriteInput,
|
||||
): Promise<ApplyCanonicalSlotWriteResult> {
|
||||
const { takenAt, skipped, isExplicitTaken, isExplicitSkip, idempotencyKey } =
|
||||
input;
|
||||
const {
|
||||
takenAt,
|
||||
skipped,
|
||||
isExplicitTaken,
|
||||
isExplicitSkip,
|
||||
idempotencyKey,
|
||||
injectionSite = null,
|
||||
} = input;
|
||||
|
||||
const existingActioned = existing.takenAt !== null || existing.skipped;
|
||||
const incomingIsPendingEcho = !isExplicitTaken && !isExplicitSkip;
|
||||
@@ -255,6 +274,10 @@ async function applyToExisting(
|
||||
skipped,
|
||||
syncVersion: { increment: 1 },
|
||||
idempotencyKey: idempotencyKey ?? existing.idempotencyKey ?? null,
|
||||
// v1.8.5 — write the site only when this write carries a resolved
|
||||
// one (taken injection, tracking on, validated). Never clear a
|
||||
// previously-recorded site with a null on an idempotent re-post.
|
||||
...(injectionSite !== null && { injectionSite }),
|
||||
},
|
||||
select: SLOT_ROW_SELECT,
|
||||
})) as SlotIntakeRow;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
|
||||
/**
|
||||
* v1.8.5 — read the user's global injection-site exclusion deny-list.
|
||||
*
|
||||
* Shared by every surface that constrains the injection-site picker: the
|
||||
* post-dose log dialog, the per-medication allowed-sites editor, and the
|
||||
* Settings exclusion editor. Cached generously — the value changes rarely
|
||||
* and the mutation invalidates `injectionSitePrefs()`.
|
||||
*/
|
||||
export function useGlobalExcludedInjectionSites(): InjectionSiteKey[] {
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.injectionSitePrefs(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/auth/me/injection-site-prefs");
|
||||
if (!res.ok) return [] as InjectionSiteKey[];
|
||||
const json = await res.json();
|
||||
return ((json.data?.globalExcludedInjectionSites ??
|
||||
[]) as InjectionSiteKey[]);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
return data ?? [];
|
||||
}
|
||||
@@ -180,6 +180,9 @@ export const queryKeys = {
|
||||
settingsReminderThresholds: () =>
|
||||
["settings", "reminder-thresholds"] as const,
|
||||
|
||||
/** v1.8.5 — user-level injection-site preferences (global exclusion). */
|
||||
injectionSitePrefs: () => ["settings", "injection-site-prefs"] as const,
|
||||
|
||||
/**
|
||||
* v1.4.41 W-FRONTEND-FACTORY — Settings → AI surfaces (provider chain,
|
||||
* insights settings, user provider preference) and the targets editor
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { SCHEDULE_TYPES } from "@/lib/medications/scheduling/recurrence";
|
||||
import { INJECTION_SITE_KEYS } from "@/lib/medications/injection-sites";
|
||||
|
||||
/**
|
||||
* v1.8.5 — the eight injection-site enum values, mirrored from the
|
||||
* Prisma `InjectionSite` enum via the shared `INJECTION_SITE_KEYS`
|
||||
* tuple. Reused by the intake + bulk-intake request schemas (the
|
||||
* `injectionSite` field) and the per-medication allowed-sites editor.
|
||||
*/
|
||||
export const INJECTION_SITE_VALUES = INJECTION_SITE_KEYS;
|
||||
export type InjectionSiteValue = (typeof INJECTION_SITE_VALUES)[number];
|
||||
|
||||
/** Zod enum over the eight injection sites. */
|
||||
export const injectionSiteEnum = z.enum(INJECTION_SITE_VALUES);
|
||||
|
||||
const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
/**
|
||||
@@ -294,6 +307,28 @@ export const createMedicationSchema = z
|
||||
dosesPerUnit: z.number().int().min(1).max(100).optional(),
|
||||
/** 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. Default
|
||||
* false. Only meaningful when `deliveryForm === "INJECTION"`.
|
||||
*/
|
||||
trackInjectionSites: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-medication injection-site tracking opt-in. Default false. Only meaningful for an INJECTION delivery form; when true the client prompts (skippably) for the site after a taken dose.",
|
||||
),
|
||||
/**
|
||||
* v1.8.5 — per-medication allowed / preferred injection sites.
|
||||
* Empty = no per-medication restriction. The effective pickable set
|
||||
* subtracts the user's global exclusion (deny wins).
|
||||
*/
|
||||
allowedInjectionSites: z
|
||||
.array(injectionSiteEnum)
|
||||
.max(INJECTION_SITE_VALUES.length)
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-medication allowed / preferred injection sites. Empty array = no per-medication restriction (every site offered). The effective pickable set is this list minus the user's global exclusion.",
|
||||
),
|
||||
notificationsEnabled: z.boolean().optional(),
|
||||
/** v1.7.0 — iOS Live Activity opt-in for this medication's reminders. */
|
||||
liveActivityEnabled: z
|
||||
@@ -339,6 +374,21 @@ export const updateMedicationSchema = z
|
||||
dosesPerUnit: z.number().int().min(1).max(100).nullable().optional(),
|
||||
/** 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. */
|
||||
trackInjectionSites: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-medication injection-site tracking opt-in. Only meaningful for an INJECTION delivery form. Set false to deactivate tracking.",
|
||||
),
|
||||
/** v1.8.5 — per-medication allowed / preferred injection sites. */
|
||||
allowedInjectionSites: z
|
||||
.array(injectionSiteEnum)
|
||||
.max(INJECTION_SITE_VALUES.length)
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-medication allowed / preferred injection sites. Empty array clears the restriction. The effective pickable set subtracts the user's global exclusion (deny wins).",
|
||||
),
|
||||
active: z.boolean().optional(),
|
||||
notificationsEnabled: z.boolean().optional(),
|
||||
/** v1.7.0 — iOS Live Activity opt-in for this medication's reminders. */
|
||||
@@ -432,11 +482,26 @@ export const intakeSchema = z
|
||||
.describe(
|
||||
"Caller-issued de-dup key. A second POST with the same key returns the original event without creating a new row.",
|
||||
),
|
||||
/**
|
||||
* v1.8.5 — optional injection-site capture. Only honoured on a
|
||||
* non-skipped (taken) write for a medication with
|
||||
* `deliveryForm === "INJECTION"` and `trackInjectionSites === true`.
|
||||
* The site is validated server-side against the medication's
|
||||
* effective allowed set (per-medication `allowedInjectionSites`
|
||||
* minus the user's `globalExcludedInjectionSites` deny-list); a
|
||||
* disallowed value is rejected with 422. Always optional — the
|
||||
* client may omit it (the dose still records).
|
||||
*/
|
||||
injectionSite: injectionSiteEnum
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional injection site for a taken dose. Honoured only when the medication is an INJECTION with site-tracking enabled; validated against the medication's effective allowed set (per-medication allowed sites minus the user's global exclusion). A disallowed site returns 422. Omit to record the dose without a site.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "MedicationIntakeRequest",
|
||||
description:
|
||||
"Per-medication intake log body. Idempotent via `idempotencyKey`; the server also dedupes by a 60-second sliding window when the key is absent. Non-skipped intakes auto-decrement pen inventory (best-effort), refresh the per-day compliance rollup, and — for one-shot medications — flip `active` to false.",
|
||||
"Per-medication intake log body. Idempotent via `idempotencyKey`; the server also dedupes by a 60-second sliding window when the key is absent. Non-skipped intakes auto-decrement pen inventory (best-effort), refresh 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).",
|
||||
});
|
||||
|
||||
export const externalIntakeSchema = z.object({
|
||||
|
||||
Reference in new issue
Block a user