mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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:
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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in new issue
Block a user