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

@@ -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>
);
}