Files
HealthLog/src/lib/medications/create-medication.ts
T
Marc-André Bombeck 135d5baf28 fix(medications): return a medication's schedules in one order everywhere
The list reads ordered schedules by their window and id, but the detail,
create, update, compliance, dose-history, GLP-1 and efficacy reads took
them in whatever order Postgres found them, so two schedules created in
the same millisecond could swap places between two answers. Every read
now uses MEDICATION_SCHEDULE_ORDER, and the guard checks every
schedules include, not only the list reads.
2026-10-25 19:09:27 +01:00

535 lines
19 KiB
TypeScript

/**
* Create a medication with its nested schedule: `POST /api/medications`.
*
* The route authenticates (WRITE over the `medications` section), reads the
* body and answers the schema refusal; everything from the category check to
* the cache eviction runs here, in the order it always ran. The result says
* what the route answers: a refusal, the existing row of a mirrored concept
* the record already holds, or the new row.
*/
import type { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { courseRefusalResponse } from "@/lib/medications/courses";
import {
dateOfDayKey,
dayKeyOfDate,
validateCourses,
} from "@/lib/medications/course-window";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
import type { createMedicationSchema } from "@/lib/validations/medication";
import {
isAssignableCategory,
setMedicationCategory,
} from "@/lib/medication-category";
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import type { Medication, MedicationSchedule } from "@/generated/prisma/client";
import {
medicationDetail,
type ReadUser,
} from "@/lib/medications/read-medication";
import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order";
export type CreateMedicationInput = z.output<typeof createMedicationSchema>;
type ScheduleInput = NonNullable<CreateMedicationInput["schedules"]>[number];
/**
* Blast-radius cap on externally-mirrored medications per user. A client
* that mints a fresh `externalId` for the same source concept on every sync
* sweep (a known iOS unstable-key bug class) never matches the idempotency
* triple, so each sweep would otherwise create a new mirror row unbounded.
* Apple's own medication list makes >30 genuinely distinct meds implausible,
* so this caps the damage a buggy client can do without breaking any
* legitimate use.
*/
const APPLE_HEALTH_MIRROR_CAP = 30;
export type CreateMedicationOutcome =
| {
kind: "refused";
message: string;
status: number;
meta?: { errorCode: string };
}
| { kind: "existing"; body: Record<string, unknown> }
| { kind: "created"; body: Record<string, unknown> };
/**
* Idempotent response for a re-mirrored medication. The create returns the
* EXISTING row (status 200, vs 201 for a fresh create) when the caller
* re-posts a `(externalSource, externalId)` pair this user already mirrors —
* the iOS re-sync replays its full HealthKit medication list, and a replay
* must neither duplicate nor 500. It answers the medication's read model,
* the same body the fresh create and the detail read answer; no audit row
* and no cache eviction because nothing was written.
*/
async function existingMirrorBody(
user: ReadUser,
grantId: string | null,
medication: Medication & { schedules: MedicationSchedule[] },
): Promise<CreateMedicationOutcome> {
annotate({
action: {
name: "medication.create",
entity_type: "medication",
entity_id: medication.id,
},
meta: { idempotent_replay: true },
});
// The read model, exactly as `GET /api/medications/{id}` answers it.
const body = await medicationDetail({
user,
grantId,
medication,
announce: false,
});
return { kind: "existing", body };
}
/**
* Idempotent mirror create. A mirrored medication (Apple Health) is keyed by
* the `(userId, externalSource, externalId)` unique; a re-post of the same
* concept id (iOS re-sync, retry after a dropped response) returns the
* EXISTING medication with 200 instead of minting a duplicate or 500-ing on
* P2002. The pre-query covers the common replay; the P2002 catch around the
* create covers the concurrent race. Null when the create goes ahead.
*/
async function existingMirrorOrCap(
user: ReadUser,
grantId: string | null,
externalSource: NonNullable<CreateMedicationInput["externalSource"]>,
externalId: string,
): Promise<CreateMedicationOutcome | null> {
const userId = user.id;
const existing = await prisma.medication.findFirst({
where: { userId, externalSource, externalId },
include: { schedules: { orderBy: MEDICATION_SCHEDULE_ORDER } },
});
if (existing) {
return existingMirrorBody(user, grantId, existing);
}
// Growth guard. This point is only reached when the idempotency lookup
// MISSED, i.e. this is a genuinely NEW mirror row, not a replay of one the
// user already holds (a replay returned 200 above). An idempotent re-post
// of an existing mirror is therefore never blocked, even for a user already
// at the cap. Refuse only the minting of a fresh row once the user already
// holds the cap of Apple-Health-mirrored medications, so a client feeding a
// rotating key can no longer fill the list with duplicates. The count runs
// on the mirror-create-miss path only; native creates (no `externalSource`)
// never reach this branch and pay nothing.
const mirroredCount = await prisma.medication.count({
where: { userId, externalSource: "APPLE_HEALTH" },
});
if (mirroredCount >= APPLE_HEALTH_MIRROR_CAP) {
annotate({
action: { name: "medication.mirror.limit_exceeded" },
meta: { existing: mirroredCount },
});
return {
kind: "refused",
message:
"This account already mirrors the maximum number of medications from an external source",
status: 422,
meta: { errorCode: "medications.mirror.limit_exceeded" },
};
}
return null;
}
/**
* Route invariants for the scheduling primitives.
*
* The Zod layer already enforces `rrule` xor `rollingIntervalDays`
* (scheduleSchema.refine). This layer enforces the cross-field invariants
* that span medication + schedules:
*
* 1. **One-shot consistency** — when `oneShot === true`:
* a. The medication has at most ONE schedule.
* b. The single schedule carries neither `rrule` nor
* `rollingIntervalDays` (a one-shot dose IS the schedule).
* c. `endsOn` is normalised to equal `startsOn` on write so the
* worker's date-range cap matches the one-and-only slot.
*
* 2. **Recurring default** — when `oneShot !== true` AND the schedule has
* no `rrule`, no `rollingIntervalDays`, and no legacy `daysOfWeek`, the
* create stamps `rrule = "FREQ=DAILY"` so the canonical recurrence
* engine has a clean shape going forward instead of falling through to
* the legacy parser.
*
* 3. **iOS contract dual-write** — when `timesOfDay` is absent or empty,
* the create stamps `[windowStart]` so the new engine always sees a
* populated `timesOfDay` while legacy iOS clients (v0.6.x) keep encoding
* the `windowStart` / `windowEnd` / `daysOfWeek` shape. Both shapes
* coexist.
* 4. **As-needed (#316)** — when `asNeeded === true` the medication carries
* ZERO schedules (the Zod refine already rejects a populated array,
* mutual exclusion with `oneShot` included). Every "never due / never
* reminded / never scored" surface follows structurally from the empty
* schedule list.
*/
function refuseOneShotShape(
oneShot: boolean | undefined,
scheduleInputs: readonly ScheduleInput[],
): CreateMedicationOutcome | null {
if (oneShot === true) {
if (scheduleInputs.length > 1) {
return {
kind: "refused",
message: "A one-shot medication can have at most one schedule",
status: 422,
};
}
const s = scheduleInputs[0];
if (s && (s.rrule !== undefined || s.rollingIntervalDays !== undefined)) {
return {
kind: "refused",
message:
"A one-shot medication cannot have a recurrence (rrule or rollingIntervalDays)",
status: 422,
};
}
}
return null;
}
/**
* A medication created with a window starts with one course; the row
* projects it (#1024). An end without a start has always meant "running
* since creation", so the course starts today (or on the end day, when that
* lies earlier). No window: no course, chronic since creation.
*/
function resolveInitialCourse(
timezone: string,
startsOn: Date | null | undefined,
normalisedEndsOn: Date | undefined,
oneShot: boolean | undefined,
):
| { initialCourse: { startsOn: Date; endsOn: Date | null } | null }
| { refusal: CreateMedicationOutcome } {
let initialCourse: { startsOn: Date; endsOn: Date | null } | null = null;
if (startsOn || normalisedEndsOn) {
const todayKey = userDayKey(new Date(), timezone || DEFAULT_TIMEZONE);
const endKey = normalisedEndsOn ? dayKeyOfDate(normalisedEndsOn) : null;
initialCourse = {
startsOn:
startsOn ??
dateOfDayKey(endKey && endKey < todayKey ? endKey : todayKey),
endsOn: normalisedEndsOn ?? null,
};
const refusal = validateCourses(
[initialCourse],
todayKey,
oneShot === true,
);
if (refusal) {
const r = courseRefusalResponse(refusal);
return {
refusal: {
kind: "refused",
message: r.message,
status: r.status,
meta: { errorCode: r.errorCode },
},
};
}
}
return { initialCourse };
}
/** One nested schedule row, field-by-field from the parsed input. */
function scheduleCreateData(s: ScheduleInput, oneShot: boolean | undefined) {
// Invariant 2 — default to FREQ=DAILY when nothing else is set. PRN
// schedules carry no cadence, so never default them to FREQ=DAILY (they
// would otherwise project + remind).
const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0;
const isPrn = s.scheduleType === "PRN";
const defaultedRrule =
!oneShot &&
!isPrn &&
s.rrule === undefined &&
s.rollingIntervalDays === undefined &&
!hasLegacyDays
? "FREQ=DAILY"
: s.rrule;
// Invariant 3 — dual-write `timesOfDay` from windowStart when the
// legacy-shape iOS client doesn't send the new field.
const effectiveTimesOfDay =
s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay : [s.windowStart];
return {
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
// #219 — per-schedule inventory units. Field-by-field (no mass
// assignment); absent leaves the column NULL so the consume hook
// inherits the medication-level units_per_dose.
...(s.unitsPerDose !== undefined && {
unitsPerDose: s.unitsPerDose,
}),
daysOfWeek: serializeScheduleRecurrence({
daysOfWeek: s.daysOfWeek ?? [],
intervalWeeks: s.intervalWeeks ?? 1,
}),
// First-class times-of-day.
timesOfDay: effectiveTimesOfDay,
...(s.reminderGraceMinutes !== undefined && {
reminderGraceMinutes: s.reminderGraceMinutes,
}),
...(defaultedRrule !== undefined && { rrule: defaultedRrule }),
...(s.rollingIntervalDays !== undefined && {
rollingIntervalDays: s.rollingIntervalDays,
}),
// Schedule type + cyclic weeks, field-by-field.
...(s.scheduleType !== undefined && {
scheduleType: s.scheduleType,
}),
...(s.cyclicOnWeeks !== undefined && {
cyclicOnWeeks: s.cyclicOnWeeks,
}),
...(s.cyclicOffWeeks !== undefined && {
cyclicOffWeeks: s.cyclicOffWeeks,
}),
// Per-dose configurable on-time windows. Stored as the validated
// `{ timeOfDay, start, end }[]` JSON; absent leaves the column NULL
// (every slot on the default ±1h derivation).
...(s.doseWindows !== undefined && {
doseWindows: s.doseWindows,
}),
};
}
/** The insert itself, field-by-field from the parsed input. */
function insertMedication(
userId: string,
data: CreateMedicationInput,
shape: {
scheduleInputs: readonly ScheduleInput[];
normalisedEndsOn: Date | undefined;
initialCourse: { startsOn: Date; endsOn: Date | null } | null;
},
) {
const {
name,
dose,
treatmentClass,
dosesPerUnit,
unitsPerDose,
reorderLeadDays,
deliveryForm,
trackInjectionSites,
allowedInjectionSites,
notificationsEnabled,
liveActivityEnabled,
criticalAlarmEnabled,
atcCode,
rxNormCode,
externalSource,
externalId,
startsOn,
oneShot,
asNeeded,
trackIntake,
autoMarkTaken,
} = data;
const { scheduleInputs, normalisedEndsOn, initialCourse } = shape;
return prisma.medication.create({
data: {
userId,
name,
dose,
// treatmentClass + dosesPerUnit are optional in the wire schema; Prisma
// fills the default GENERIC when omitted.
...(treatmentClass !== undefined && { treatmentClass }),
...(dosesPerUnit !== undefined && { dosesPerUnit }),
// Units consumed per dose; Prisma defaults to 1 when omitted.
...(unitsPerDose !== undefined && { unitsPerDose }),
// Optional reorder lead override; null/omitted = inherit the
// user-level default in the low-stock alert.
...(reorderLeadDays !== undefined && { reorderLeadDays }),
// Route of administration; Prisma defaults to ORAL when omitted.
...(deliveryForm !== undefined && { deliveryForm }),
// Injection-site tracking opt-in + per-medication allowed sites; Prisma
// defaults to false / [] when omitted.
...(trackInjectionSites !== undefined && { trackInjectionSites }),
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
// iOS reminder flags; Prisma defaults both to false.
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
// Optional drug-classification codes (ATC / RxNorm). Validated for
// format by Zod; stored verbatim when present.
...(atcCode !== undefined && { atcCode }),
...(rxNormCode !== undefined && { rxNormCode }),
// Mirrored-medication provenance (Apple Health). Zod enforces the
// both-or-neither pairing; field-by-field like every other column.
...(externalSource !== undefined && { externalSource }),
...(externalId !== undefined && { externalId }),
// The wizard's reminders toggle ships through the create payload.
// Prisma defaults to true when omitted, matching the legacy form's
// behaviour.
...(notificationsEnabled !== undefined && { notificationsEnabled }),
// Scheduling primitives — pass-through when supplied.
...(startsOn !== undefined && { startsOn }),
...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }),
...(initialCourse && {
startsOn: initialCourse.startsOn,
endsOn: initialCourse.endsOn,
courses: {
create: {
userId,
startsOn: initialCourse.startsOn,
endsOn: initialCourse.endsOn,
},
},
}),
...(oneShot !== undefined && { oneShot }),
// As-needed flag, field-by-field. An asNeeded create carries an empty
// `scheduleInputs`, so the nested create below persists zero schedule
// rows.
...(asNeeded !== undefined && { asNeeded }),
// (#1033) — intake tracking, field-by-field. Created off, the schedule
// is stored as a record from the start; the column default (true)
// covers every caller that does not send it.
...(trackIntake !== undefined && { trackIntake }),
// Auto-mark-taken, field-by-field. Created on, the auto-take job fills
// slots from this moment only.
...(autoMarkTaken !== undefined && {
autoMarkTaken,
autoMarkTakenSince: autoMarkTaken ? new Date() : null,
}),
schedules: {
create: scheduleInputs.map((s) => scheduleCreateData(s, oneShot)),
},
},
include: { schedules: { orderBy: MEDICATION_SCHEDULE_ORDER } },
});
}
export async function createMedication(input: {
user: ReadUser;
/** The grant the request was resolved under; null for the owner. */
grantId: string | null;
data: CreateMedicationInput;
ipAddress: string | null;
}): Promise<CreateMedicationOutcome> {
const { user, grantId, data } = input;
const {
name,
category,
externalSource,
externalId,
schedules,
startsOn,
endsOn,
oneShot,
} = data;
// (#1041) — a custom category must be one of this record's own.
if (
category !== undefined &&
!(await isAssignableCategory(user.id, category))
) {
return {
kind: "refused",
message: "Unknown medication category",
status: 422,
meta: { errorCode: "medications.category.unknown" },
};
}
if (externalSource !== undefined && externalId !== undefined) {
const mirror = await existingMirrorOrCap(
user,
grantId,
externalSource,
externalId,
);
if (mirror) return mirror;
}
const scheduleInputs = schedules ?? [];
const oneShotRefusal = refuseOneShotShape(oneShot, scheduleInputs);
if (oneShotRefusal) return oneShotRefusal;
// Normalise endsOn for one-shot. Zod gives us `Date | null | undefined`.
const normalisedEndsOn =
oneShot === true && startsOn ? startsOn : (endsOn ?? undefined);
const course = resolveInitialCourse(
user.timezone,
startsOn,
normalisedEndsOn,
oneShot,
);
if ("refusal" in course) return course.refusal;
const { initialCourse } = course;
let medication: Awaited<ReturnType<typeof insertMedication>>;
try {
medication = await insertMedication(user.id, data, {
scheduleInputs,
normalisedEndsOn,
initialCourse,
});
} catch (err: unknown) {
// P2002 on the mirror triple: a concurrent create of the same
// `(userId, externalSource, externalId)` won the race between the
// pre-query above and this insert. Resolve to the winning row and answer
// idempotently instead of 500-ing. Any other unique violation (there is
// none on this table today) rethrows.
const isP2002 =
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code: string }).code === "P2002";
if (isP2002 && externalSource !== undefined && externalId !== undefined) {
const winner = await prisma.medication.findFirst({
where: { userId: user.id, externalSource, externalId },
include: { schedules: { orderBy: MEDICATION_SCHEDULE_ORDER } },
});
if (winner) return existingMirrorBody(user, grantId, winner);
}
throw err;
}
await setMedicationCategory(medication.id, category);
await auditLog("medication.create", {
userId: user.id,
ipAddress: input.ipAddress,
details: {
medicationId: medication.id,
name,
// A medication created with its doses recorded automatically says so
// in its audit row.
autoMarkTaken: medication.autoMarkTaken,
},
});
annotate({
action: {
name: "medication.create",
entity_type: "medication",
entity_id: medication.id,
},
});
// Bust per-user medications + compliance + achievement caches so the next
// read reflects the new schedule.
invalidateUserMedications(user.id, { evict: true });
// The read model, exactly as `GET /api/medications/{id}` answers it.
const body = await medicationDetail({
user,
grantId,
medication,
announce: false,
});
return { kind: "created", body };
}