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 }),