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,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({