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:
Marc-André Bombeck committed 2026-06-01 20:25:34 +02:00
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"[];
+20
View File
@@ -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);
});
+55 -1
View File
@@ -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
+7
View File
@@ -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.
+68 -1
View File
@@ -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;
+55 -1
View File
@@ -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({
+6
View File
@@ -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 }),
+6
View File
@@ -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 };
}
+68 -7
View File
@@ -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;
}
+25 -2
View File
@@ -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 ?? [];
}
+3
View File
@@ -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
+66 -1
View File
@@ -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({