diff --git a/.planning/ios-coord/v1.8.5-server-to-ios-injection-site.md b/.planning/ios-coord/v1.8.5-server-to-ios-injection-site.md new file mode 100644 index 000000000..71a999f18 --- /dev/null +++ b/.planning/ios-coord/v1.8.5-server-to-ios-injection-site.md @@ -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. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5e72f31ea..89be8acd4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index f80464152..dbfd00e78 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index a27b5e425..83ec368d5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 5c75fa700..00e26dad5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 477e1d1df..91fc51b2c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index f8dffe04e..9dbe62040 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index f61ac8f73..51cb4985b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/prisma/migrations/0099_v185_medication_injection_tracking/migration.sql b/prisma/migrations/0099_v185_medication_injection_tracking/migration.sql new file mode 100644 index 000000000..4d46e7985 --- /dev/null +++ b/prisma/migrations/0099_v185_medication_injection_tracking/migration.sql @@ -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"[]; diff --git a/prisma/migrations/0100_v185_user_global_excluded_injection_sites/migration.sql b/prisma/migrations/0100_v185_user_global_excluded_injection_sites/migration.sql new file mode 100644 index 000000000..c6912a979 --- /dev/null +++ b/prisma/migrations/0100_v185_user_global_excluded_injection_sites/migration.sql @@ -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"[]; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index af4015027..b7060a1dd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/api/auth/me/injection-site-prefs/route.ts b/src/app/api/auth/me/injection-site-prefs/route.ts new file mode 100644 index 000000000..b265988f0 --- /dev/null +++ b/src/app/api/auth/me/injection-site-prefs/route.ts @@ -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); +}); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 5984eda74..993dc1ac2 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -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 diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index a899b8c5b..c1abfc1bf 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -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. diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 660ddc731..6cfb38f89 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -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 { 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 { 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 { 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 { 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; diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 77b31b80e..7e0792d2e 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -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({ diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index f04616a1b..8ab1cf5e6 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -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 }), diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index 37858ce52..10d81def0 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -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, diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index f9db3485a..c1a28bd07 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -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(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(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({ /> )} + + {/* v1.8.5 — post-dose injection-site capture (optional, skippable). */} + {tracksInjection && ( + r.injectionSite) + .filter(Boolean) as InjectionSiteKey[] + } + onConfirm={confirmInjectionSite} + onSkip={() => setSiteIntakeId(null)} + /> + )} ); } diff --git a/src/components/medications/injection-site-picker.tsx b/src/components/medications/injection-site-picker.tsx index f4246728a..7d166184f 100644 --- a/src/components/medications/injection-site-picker.tsx +++ b/src/components/medications/injection-site-picker.tsx @@ -32,6 +32,15 @@ interface InjectionSitePickerProps { /** Recent rotation history (most recent first). Powers the * "recommended next site" dashed-ring annotation. */ history?: ReadonlyArray; + /** + * 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; /** 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 (
- {isRecommended && !isActive && ( + {isRecommended && !isActive && !disabled && ( @@ -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" + } /> ); diff --git a/src/components/medications/log-injection-site-dialog.tsx b/src/components/medications/log-injection-site-dialog.tsx new file mode 100644 index 000000000..748b78e3f --- /dev/null +++ b/src/components/medications/log-injection-site-dialog.tsx @@ -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; + /** User-level global exclusion deny-list. */ + globalExcludedInjectionSites: ReadonlyArray; + /** Recent rotation history (most recent first) for the dashed-ring hint. */ + history: ReadonlyArray; + /** 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(null); + + const allowed = effectiveAllowedSites( + allowedInjectionSites, + globalExcludedInjectionSites, + ); + + return ( + { + if (!next) onSkip(); + }} + > + + + {t("medications.logInjectionSiteTitle")} + + {t("medications.logInjectionSiteDescription", { + name: medicationName, + })} + + + + {allowed.length === 0 ? ( +

+ {t("medications.logInjectionSiteNoneAvailable")} +

+ ) : ( + + )} + + + + + +
+
+ ); +} diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 7d619eb22..fbb9247e9 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -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(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(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({ /> )} + + {/* v1.8.5 — post-dose injection-site capture (optional, skippable). */} + {tracksInjection && ( + setSiteIntakeId(null)} + /> + )} ); } diff --git a/src/components/medications/wizard/steps/Step3Dose.tsx b/src/components/medications/wizard/steps/Step3Dose.tsx index 170a7fa58..f1eb35493 100644 --- a/src/components/medications/wizard/steps/Step3Dose.tsx +++ b/src/components/medications/wizard/steps/Step3Dose.tsx @@ -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(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 (
@@ -151,40 +166,83 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
- {/* 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 && (
- - {siteOpen && ( -
-

- {t("medications.wizard.steps.step3.injectionSiteHint")} -

- + + applyPartial({ + trackInjectionSites: checked, + // Clear the per-med restriction when tracking is turned off. + ...(checked ? {} : { allowedInjectionSites: [] }), + }) + } + aria-label={t("medications.trackInjectionSitesToggle")} + /> + + + {payload.trackInjectionSites && ( +
+ + {allowedOpen && ( +
+

+ {t("medications.allowedSitesHint")} +

+
+ {INJECTION_SITE_KEYS.map((site) => { + const checked = + payload.allowedInjectionSites.includes(site); + return ( + + ); + })} +
+
+ )}
)}
diff --git a/src/components/medications/wizard/wizard-payload.ts b/src/components/medications/wizard/wizard-payload.ts index 4ba7b9b73..984a05707 100644 --- a/src/components/medications/wizard/wizard-payload.ts +++ b/src/components/medications/wizard/wizard-payload.ts @@ -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, diff --git a/src/components/settings/dashboard-section.tsx b/src/components/settings/dashboard-section.tsx index 588c6434c..55ee5ff02 100644 --- a/src/components/settings/dashboard-section.tsx +++ b/src/components/settings/dashboard-section.tsx @@ -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() { + + ); diff --git a/src/components/settings/injection-sites-card.tsx b/src/components/settings/injection-sites-card.tsx new file mode 100644 index 000000000..63ae404fd --- /dev/null +++ b/src/components/settings/injection-sites-card.tsx @@ -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 ( +
+ +
+ {INJECTION_SITE_KEYS.map((site) => ( + + ))} +
+
+ ); +} diff --git a/src/lib/medications/__tests__/injection-site-write.test.ts b/src/lib/medications/__tests__/injection-site-write.test.ts new file mode 100644 index 000000000..64bac801b --- /dev/null +++ b/src/lib/medications/__tests__/injection-site-write.test.ts @@ -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" }); + }); +}); diff --git a/src/lib/medications/__tests__/injection-sites.test.ts b/src/lib/medications/__tests__/injection-sites.test.ts index 28a1ddd4f..252cfdcd3 100644 --- a/src/lib/medications/__tests__/injection-sites.test.ts +++ b/src/lib/medications/__tests__/injection-sites.test.ts @@ -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( diff --git a/src/lib/medications/injection-site-write.ts b/src/lib/medications/injection-site-write.ts new file mode 100644 index 000000000..b68362b36 --- /dev/null +++ b/src/lib/medications/injection-site-write.ts @@ -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; + /** The user's global exclusion deny-list. */ + globalExcludedInjectionSites: ReadonlyArray; +} + +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 }; +} diff --git a/src/lib/medications/injection-sites.ts b/src/lib/medications/injection-sites.ts index 29c937fb4..29af71019 100644 --- a/src/lib/medications/injection-sites.ts +++ b/src/lib/medications/injection-sites.ts @@ -42,23 +42,83 @@ export const SITE_COORDS: Record = { 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, + globalExcluded: ReadonlyArray, +): InjectionSiteKey[] { + const excludedSet = new Set(globalExcluded); + // Empty per-med list = no per-med restriction → start from all sites. + const base: ReadonlyArray = + 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, + globalExcluded: ReadonlyArray, +): 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, windowSize = 4, + allowed?: ReadonlyArray, ): 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 = + 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; } diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index d01696dcb..09d21612b 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -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; @@ -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 { - 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; diff --git a/src/lib/medications/use-injection-site-prefs.ts b/src/lib/medications/use-injection-site-prefs.ts new file mode 100644 index 000000000..e6bdeffe5 --- /dev/null +++ b/src/lib/medications/use-injection-site-prefs.ts @@ -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 ?? []; +} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 88e9f5986..f75f6149b 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -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 diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index 9b0d884d7..a15e8f958 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -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({