diff --git a/src/__tests__/dense-measurement-read-guard.test.ts b/src/__tests__/dense-measurement-read-guard.test.ts index 1134cdf0d..b36fd59eb 100644 --- a/src/__tests__/dense-measurement-read-guard.test.ts +++ b/src/__tests__/dense-measurement-read-guard.test.ts @@ -69,6 +69,9 @@ const SCOPED_DIRS = [ "lib/measurements/batch-ingest/", "lib/measurements/create/", "lib/measurements/list/measurement-list.ts", + "lib/medications/read-medication.ts", + "lib/medications/update-medication/", + "lib/medications/delete-medication.ts", ]; /** Types a device writes at sampling rate rather than a few times a day. */ diff --git a/src/__tests__/medication-intake-tracking-guard.test.ts b/src/__tests__/medication-intake-tracking-guard.test.ts index 5638ee82f..9313e5e3b 100644 --- a/src/__tests__/medication-intake-tracking-guard.test.ts +++ b/src/__tests__/medication-intake-tracking-guard.test.ts @@ -70,8 +70,8 @@ const INTAKE_MAY_SEE_EVERY_ROW: Record = { "one medication's recorded history, listed or written", "app/api/medications/[id]/intake/bulk-delete/route.ts": "deletes rows the person chose", - "app/api/medications/[id]/route.ts": - "one medication's slots; tombstones on the tracking switch", + "lib/medications/update-medication/write.ts": + "one medication's open slots, tombstoned on a schedule replace or the tracking switch", "app/api/medications/intake/bulk/route.ts": "writes recorded doses", "lib/medications/scheduling/slot-upsert.ts": "binds a recorded dose to its slot", @@ -113,6 +113,14 @@ const MAY_READ_EVERY_ROW: Record = { "units consumed by a recorded dose", "lib/medications/intake-slot-dedup.ts": "folds duplicate rows of doses already recorded", + // The medication edit: the schedule count and the primary schedule's id + // for the reminder-window bridge, and the replaced rows archived as an era + // whose payload is empty for a stretch with tracking off. Nothing due comes + // out of either; the edit answers through scheduleWireFields. + "lib/medications/update-medication/preflight.ts": + "schedule count and the primary schedule's id, derives nothing due", + "lib/medications/update-medication/write.ts": + "archives the replaced schedule; the era payload follows the tracking switch", // Readers that hand the rows to a builder whose input TYPE requires // `trackIntake` and which gates on it: the per-medication compliance // payload (`buildCompliancePayload`, INTAKE_NOT_TRACKED) and the doctor diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index b30d2e7ee..b7e69ff8b 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -435,6 +435,10 @@ function callsSymbol(rel: string, symbol: string): boolean { */ const HANDLER_BODY_MODULES: Record = { "app/api/medications/route.ts": ["lib/medications/create-medication.ts"], + "app/api/medications/[id]/route.ts": [ + "lib/medications/update-medication/update-medication.ts", + "lib/medications/delete-medication.ts", + ], "app/api/medications/[id]/intake/[eventId]/route.ts": [ "lib/medications/update-intake-event.ts", "lib/medications/delete-intake-event.ts", diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 211413d6a..7295d4532 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -1,330 +1,39 @@ -import { prisma } from "@/lib/db"; -import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken"; import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; -import { toIngredientDtos } from "@/lib/medications/ingredients/composition"; -import { - enqueueSupplementNutrientRecompute, - medicationHasComposition, -} from "@/lib/medications/ingredients/recompute-job"; -import { annotate } from "@/lib/logging/context"; -import { auditLog } from "@/lib/auth/audit"; -import { overwriteDetails } from "@/lib/sharing/audit-details"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { parseJsonBody } from "@/lib/api/body"; import { updateMedicationSchema } from "@/lib/validations/medication"; -import { invalidateUserMedications } from "@/lib/cache/invalidate"; -import { - deleteMedicationCategory, - isAssignableCategory, - resolveMedicationCategories, - setMedicationCategory, -} from "@/lib/medication-category"; -import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; -import { - checkCurrentWindow, - CourseWriteError, - courseRefusalResponse, - resolveCourseFields, - setCurrentWindow, -} from "@/lib/medications/courses"; -import { - schedulesMateriallyDiffer, - toRevisionPayloadEntry, -} from "@/lib/medications/scheduling/schedule-eras"; -import type { Prisma } from "@/generated/prisma/client"; -import { - loadAutoTakeRollingAnchors, - moveAutoTakeFloorToNow, -} from "@/lib/medications/auto-take"; -import { - computeDisplayDue, - OVERDUE_LOOKBACK_MS, - toResolvedSlotMark, -} from "@/lib/medications/scheduling/next-due"; -import { - dayKeyForScheduledFor, - refreshDoseDay, -} from "@/lib/medications/dose-day-refresh"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { readMedication } from "@/lib/medications/read-medication"; +import { findMedicationForEdit } from "@/lib/medications/update-medication/preflight"; +import { updateMedication } from "@/lib/medications/update-medication/update-medication"; import { - effectiveUnitsPerDose, - estimateUnitsRunwayDays, -} from "@/lib/medications/supply-runway"; -import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto"; -import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm"; -import { getUserTodayBounds } from "@/lib/tz/local-day"; + deleteMedication, + findMedicationForDelete, +} from "@/lib/medications/delete-medication"; import { NextRequest } from "next/server"; -import { - dueSchedules, - isRecordOnly, - scheduleWireFields, -} from "@/lib/medications/intake-tracking"; -import { floorWhole } from "@/lib/medications/units-per-dose"; -import { resolveIntakeActionability } from "@/lib/medications/intake-actionable"; -import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; -import { buildRelated } from "@/lib/links/related"; type RouteParams = { params: Promise<{ id: string }> }; -/** Parse "HH:mm" into minutes-of-day; NaN-safe (malformed → 0). */ -function hhmmToMinutes(value: string): number { - return hhmmToMinutesOrNull(value) ?? 0; -} - -/** - * Reconcile a schedule's `windowStart` / `windowEnd` with its effective - * `timesOfDay`. The schedule editor can change only the dose times while the - * client echoes the previous window back; a time outside the window then - * breaks every consumer that assumes `timesOfDay ⊆ [windowStart, windowEnd]` - * (the window-status pill, the reminder phases, the legacy compliance - * walker). When ANY time falls outside the window — including the overnight - * `windowEnd < windowStart` shape, checked with wrap-around membership — - * the window is pulled to the min/max of the times so the persisted row is - * self-consistent. A window that already covers every time is left - * byte-identical (explicitly wider windows are a feature). - */ -function reconcileScheduleWindow( - windowStart: string, - windowEnd: string, - timesOfDay: string[], -): { windowStart: string; windowEnd: string } { - if (timesOfDay.length === 0) return { windowStart, windowEnd }; - const startMins = hhmmToMinutes(windowStart); - const endMins = hhmmToMinutes(windowEnd); - const overnight = endMins < startMins; - const inWindow = (t: number): boolean => - overnight ? t >= startMins || t <= endMins : t >= startMins && t <= endMins; - const sorted = [...timesOfDay].sort( - (a, b) => hhmmToMinutes(a) - hhmmToMinutes(b), - ); - const allInside = sorted.every((t) => inWindow(hhmmToMinutes(t))); - if (allInside) return { windowStart, windowEnd }; - return { windowStart: sorted[0], windowEnd: sorted[sorted.length - 1] }; -} - export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { const { user, grantId } = await requireRecordAuth("read", "medications"); const { id } = await params; - const medication = await prisma.medication.findUnique({ - where: { id }, - include: { schedules: true }, - }); - - if (!medication || medication.userId !== user.id) { + const body = await readMedication({ user, grantId, id }); + if (!body) { return apiError("Medication not found", 404); } - let category = "OTHER"; - let categoryLabel: string | null = null; - try { - const categories = await resolveMedicationCategories([id]); - category = categories[id]?.category ?? "OTHER"; - categoryLabel = categories[id]?.categoryLabel ?? null; - } catch { - // Category enrichment is optional - } - - // v1.7.0 — server-computed next due instant. Rolling - // cadences re-anchor on the latest non-skipped intake, so fetch it - // once (no-op for calendar cadences but cheap + keeps the value - // correct for rolling injections). - const lastIntake = dueSchedules(medication).some( - (s) => s.rollingIntervalDays !== null, - ) - ? await prisma.medicationIntakeEvent.findFirst({ - // v1.7.0 sync — a tombstoned intake no longer anchors the - // rolling-interval next-due computation. - where: { - userId: user.id, - medicationId: id, - deletedAt: null, - takenAt: { not: null }, - }, - orderBy: { takenAt: "desc" }, - select: { takenAt: true }, - }) - : null; - // v1.15.10 — slots the user has already acted on near now, so the - // next-due search skips them and advances to the next genuinely-open - // slot. Bound the read to [now-1d, now+2d] — the lookahead only needs the - // slots adjacent to now. - const now = new Date(); - // v1.16.4 — reach back as far as the widest band tail so a - // long-resolved past slot can never resurface as "overdue". - const resolvedFrom = new Date(now.getTime() - OVERDUE_LOOKBACK_MS); - const resolvedTo = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000); - const resolvedRows = await prisma.medicationIntakeEvent.findMany({ - where: { - userId: user.id, - medicationId: id, - deletedAt: null, - scheduledFor: { gte: resolvedFrom, lte: resolvedTo }, - OR: [ - { takenAt: { not: null } }, - { skipped: true }, - { autoMissed: true }, - ], - }, - // v1.16.9 — `takenAt` rides along so an ad-hoc row (`scheduledFor - // === takenAt`) cannot ±6h-resolve a different slot. - select: { scheduledFor: true, takenAt: true }, - }); - // v1.16.4 — current-era floor: the open-overdue search mints from - // the LIVE schedule rows, so it must not reach past the newest - // revision boundary into a previous era's cadence. - const latestRevision = await prisma.medicationScheduleRevision.findFirst({ - // Superseded rows are audit records — a correction may have - // shortened the era, so the boundary reads only active rows. - where: { medicationId: id, supersededByRevisionId: null }, - orderBy: { validUntil: "desc" }, - select: { validUntil: true }, - }); - // v1.39.1 (#1033) — intake tracking off: nothing is due from the - // stored rows, and no runway derives from them. - const liveSchedules = dueSchedules(medication); - const userTz = user.timezone || DEFAULT_TIMEZONE; - // v1.42.2 — under auto-take a rolling cadence stands where the job will - // record next (see `auto-take.ts`). - const engineLastIntakeAt = await loadAutoTakeRollingAnchors(prisma, { - userId: user.id, - medicationIds: medication.autoMarkTaken ? [id] : [], - now, - userTz, - lastIntakeAt: () => lastIntake?.takenAt ?? null, - }); - const display = computeDisplayDue({ - medication: { - id: medication.id, - startsOn: medication.startsOn, - endsOn: medication.endsOn, - oneShot: medication.oneShot, - createdAt: medication.createdAt, - }, - schedules: liveSchedules, - now, - userTz, - lastIntakeAt: engineLastIntakeAt(id), - resolvedSlots: resolvedRows.map(toResolvedSlotMark), - eraStart: latestRevision?.validUntil ?? null, - }); - - annotate({ - action: { - name: "medication.get", - entity_type: "medication", - entity_id: id, - }, - }); - - // v1.37.19 — stock + runway on the detail wire, mirroring the list - // payload's semantics: NULL stock = inventory tracking off, 0 = the - // supply ran out; runwayDays via the slot-aware burn rate. Published - // resolved so no client re-derives the inheritance / cadence math. - const [usableStock, anyItemCount] = await Promise.all([ - prisma.medicationInventoryItem.aggregate({ - where: { - userId: user.id, - medicationId: id, - state: { in: ["ACTIVE", "IN_USE"] }, - unitsRemaining: { gt: 0 }, - }, - _sum: { unitsRemaining: true }, - }), - prisma.medicationInventoryItem.count({ - where: { userId: user.id, medicationId: id }, - }), - ]); - const schedulesDto = serializeScheduleUnitsPerDose( - medication.schedules, - medication.unitsPerDose, - ); - const stockUnitsRemaining = - anyItemCount > 0 ? Number(usableStock._sum.unitsRemaining ?? 0) : null; - const stockDosesRemaining = - stockUnitsRemaining === null - ? null - : floorWhole( - stockUnitsRemaining / - effectiveUnitsPerDose( - schedulesDto, - Number(medication.unitsPerDose), - ), - ); - const runwayDays = - stockUnitsRemaining === null - ? null - : estimateUnitsRunwayDays( - stockUnitsRemaining, - isRecordOnly(medication) ? [] : schedulesDto, - Number(medication.unitsPerDose), - ); - - // v1.43 — the composition per unit, labelled in the account language. - const ingredientRows = await prisma.medicationIngredient.findMany({ - where: { medicationId: id, userId: user.id }, - select: { - nutrient: true, - amountPerUnit: true, - unit: true, - enteredAmount: true, - enteredUnit: true, - }, - }); - - // v1.40 (#1024) — the courses, resolved on the server. - const courseFields = ( - await resolveCourseFields( - [medication], - now, - user.timezone || DEFAULT_TIMEZONE, - ) - ).get(id); - - return apiSuccess({ - ...medication, - unitsPerDose: Number(medication.unitsPerDose), - // v1.39.1 (#1033) — `schedules: []` plus `recordedSchedules` when - // intake tracking is off (see `scheduleWireFields`). - ...scheduleWireFields(medication.trackIntake, schedulesDto), - category, - categoryLabel, - ...courseFields, - nextDueAt: display ? display.at.toISOString() : null, - // v1.42.2 — the server's own reminder decision, published resolved. - remindersActive: sendsMedicationReminders(medication, { - now, - timeZone: userTz, - }), - nextDueOverdue: display?.overdue ?? false, - // v1.39.4 (#1040) — see `resolveIntakeActionability`. - ...resolveIntakeActionability( - medication, - now, - user.timezone || DEFAULT_TIMEZONE, - ), - stockUnitsRemaining, - stockDosesRemaining, - runwayDays, - ingredients: toIngredientDtos(ingredientRows, user.locale ?? null), - // v1.43 — the records this medication relates to (`src/lib/links/`). - related: await buildRelated(prisma, { - userId: user.id, - grantId, - kind: "medication", - id, - }), - }); + return apiSuccess(body); }, ); export const PUT = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - // v1.37.0 — MANAGE, and the strongest candidate in the domain: a manager + // MANAGE, and the strongest candidate in the domain: a manager // administering somebody's regimen is the case the level exists for. What // it destroys is scheduling anchors, not history — the tombstone `where` - // below excludes every actioned row — and the previous cadence is the one + // excludes every actioned row — and the previous cadence is the one // genuinely unrecoverable part, which is what C7 puts in the audit row. // The DELETE beside it stays refused. const { user, authMethod } = await requireRecordAuth( @@ -333,36 +42,13 @@ export const PUT = apiHandler( ); const { id } = await params; - // v1.5.5 — route ownership check through the shared helper - // so the 404 leak shape stays identical across every - // `[id]/**` handler. The PUT branch still needs `existing.active` - // for the pausedAt-derivation step below, so re-read the narrow - // field after the guard passes. + // Route ownership check through the shared helper so the 404 leak shape + // stays identical across every `[id]/**` handler. The edit still needs + // `existing.active` for the pausedAt derivation, so it re-reads the + // narrow pre-image after the guard passes. const guard = await assertMedicationOwnership(id, user.id); if (guard) return guard; - const existing = await prisma.medication.findUnique({ - where: { id }, - // v1.16.11 — `asNeeded` + the schedule count feed the as-needed - // invariants below: a flip to as-needed must end schedule-less, - // a flip back to scheduled must end with at least one schedule. - select: { - active: true, - createdAt: true, - asNeeded: true, - // v1.39.1 (#1033) — the tracking transition below needs the before. - trackIntake: true, - // v1.42.2 — `autoMarkTakenSince` is stamped only on an off→on flip. - autoMarkTaken: true, - // v1.37.0 — the pre-image the audit row carries (C4). - name: true, - dose: true, - endsOn: true, - // v1.40 (#1024) — the course check below needs the stored window. - startsOn: true, - oneShot: true, - _count: { select: { schedules: true } }, - }, - }); + const existing = await findMedicationForEdit(id); if (!existing) { return apiError("Medication not found", 404); } @@ -372,839 +58,19 @@ export const PUT = apiHandler( }); if (!parsed.ok) return parsed.error; - // ── v1.39.1 (#1033) — a record-only schedule is only replaced knowingly ── - // - // A medication with intake tracking off is served with `schedules: []` - // and its stored schedule in `recordedSchedules`, so a client that - // predates the field arms no reminder from it. The price is that such a - // client cannot SEE the stored schedule. The shipped iPhone app (1.0.3 / - // 1.0.4) opens a medication without schedule rows in its editor as - // "daily at 08:00"; a rename or dose edit sends no `schedules` (its - // editor diffs against that same prefill), but any touch of a schedule - // field sends a full replacement built from the 08:00 default plus - // `asNeeded` and `oneShot`, and never `trackIntake`. Applied, that would - // overwrite the schedule the person kept on record with one they never - // chose (or flip it to as-needed and delete the rows). - // - // Rule: on a medication whose intake tracking is off, a write that does - // not name `trackIntake` leaves the schedule shape alone. `schedules`, - // `asNeeded` and `oneShot` are dropped from it; every other field (name, - // dose, dates, `active`, `notificationsEnabled`, inventory, ...) applies - // as sent. A client that knows about the switch names it on every - // schedule save (the web editor and wizard do), and those writes apply. - const keepRecordedSchedule = - existing.trackIntake === false && - parsed.data.trackIntake === undefined && - (parsed.data.schedules !== undefined || - parsed.data.asNeeded !== undefined || - parsed.data.oneShot !== undefined); - const input = keepRecordedSchedule - ? { - ...parsed.data, - schedules: undefined, - asNeeded: undefined, - oneShot: undefined, - } - : parsed.data; - if (keepRecordedSchedule) { - annotate({ - action: { - name: "medication.update.record_schedule_kept", - entity_type: "medication", - entity_id: id, - }, - meta: { - dropped_schedules: parsed.data.schedules !== undefined, - dropped_as_needed: parsed.data.asNeeded !== undefined, - dropped_one_shot: parsed.data.oneShot !== undefined, - }, - }); - } - - const { - name, - dose, - category, - treatmentClass, - dosesPerUnit, - unitsPerDose, - reorderLeadDays, - deliveryForm, - trackInjectionSites, - allowedInjectionSites, - active, - notificationsEnabled, - liveActivityEnabled, - criticalAlarmEnabled, - atcCode, - rxNormCode, - schedules, - startsOn, - endsOn, - oneShot, - asNeeded, - trackIntake, - autoMarkTaken, - reminderGraceMinutes: topLevelGraceMinutes, - } = input; - - // v1.40 (#1041) — a custom category must be one of this record's own. - if ( - category !== undefined && - !(await isAssignableCategory(user.id, category)) - ) { - return apiError("Unknown medication category", 422, { - errorCode: "medications.category.unknown", - }); - } - - // v1.39.1 (#1033) — intake tracking transition. Off keeps the schedule - // as a record; the live era from here on expects nothing. - const wasTracked = existing.trackIntake !== false; - const willTrack = trackIntake ?? wasTracked; - const trackingChanged = wasTracked !== willTrack; - - // ── v1.16.11 (#316) — as-needed invariants ────────────────────── - // - // An as-needed medication ends schedule-less, always. The Zod layer - // already rejects a populated `schedules` array alongside - // `asNeeded: true`; the route covers the cross-row cases the schema - // cannot see: - // - flipping an existing scheduled medication to as-needed - // requires `schedules: []` in the same request (keeping the old - // rows alongside the flag is a 422, per the feature contract); - // - flipping as-needed OFF requires the medication to end with at - // least one schedule (the wizard always sends the full array). - const effectiveAsNeeded = asNeeded ?? existing.asNeeded; - if (effectiveAsNeeded) { - const endsScheduleless = schedules - ? schedules.length === 0 - : existing._count.schedules === 0; - if (!endsScheduleless) { - return apiError( - "An as-needed medication cannot carry schedules (send schedules: [] to clear them)", - 422, - ); - } - } else if ( - asNeeded === false && - existing.asNeeded && - (!schedules || schedules.length === 0) - ) { - return apiError( - "A scheduled medication requires at least one schedule", - 422, - ); - } - - // ── v1.5.5 — primary-schedule grace bridge ────────────────────── - // - // The detail-page settings section saves the reminder-window in - // one PUT carrying only `{ reminderGraceMinutes }` at the top - // level (no full `schedules` array). Map it onto the medication's - // primary schedule before the Prisma update so the persisted - // shape stays per-schedule and the engine reads it from the same - // column as the wizard write-path. A full `schedules` array on - // the same request takes precedence — the wizard already declares - // the grace per row and we never want to overwrite that intent. - let primaryScheduleGracePatch: { - scheduleId: string; - reminderGraceMinutes: number | null; - } | null = null; - if (topLevelGraceMinutes !== undefined && !schedules) { - const primary = await prisma.medicationSchedule.findFirst({ - where: { medicationId: id }, - orderBy: { windowStart: "asc" }, - select: { id: true }, - }); - if (primary) { - primaryScheduleGracePatch = { - scheduleId: primary.id, - reminderGraceMinutes: topLevelGraceMinutes, - }; - } - } - - // ── v1.5 route invariants for the new scheduling primitives ───── - // - // Mirrors the POST route: oneShot ⇒ at-most-one schedule + no - // recurrence on it; endsOn normalised to startsOn for one-shot; - // recurring default = FREQ=DAILY when nothing else is set; - // timesOfDay dual-write from windowStart when absent. - // - // The PUT replaces all schedules wholesale, so the invariants run - // against the incoming `schedules` array (if provided) the same - // way they do on create. - if (oneShot === true && schedules) { - if (schedules.length > 1) { - return apiError( - "A one-shot medication can have at most one schedule", - 422, - ); - } - const s = schedules[0]; - if (s && (s.rrule !== undefined || s.rollingIntervalDays !== undefined)) { - return apiError( - "A one-shot medication cannot have a recurrence (rrule or rollingIntervalDays)", - 422, - ); - } - } - - // Normalise endsOn for one-shot. `oneShot === true` + `startsOn` - // means the dose is the start date; endsOn auto-matches. - const normalisedEndsOn = oneShot === true && startsOn ? startsOn : endsOn; - - // v1.40 (#1024) — the window a PUT sets is the latest course. Check it - // against the other courses BEFORE the row changes, so a refused window - // leaves the medication as it was. - const touchesWindow = - startsOn !== undefined || - normalisedEndsOn !== undefined || - oneShot !== undefined; - const nextWindow = { - startsOn: startsOn !== undefined ? startsOn : existing.startsOn, - endsOn: - normalisedEndsOn !== undefined ? normalisedEndsOn : existing.endsOn, - }; - const courseTz = user.timezone || DEFAULT_TIMEZONE; - if (touchesWindow) { - const refusal = await checkCurrentWindow({ - userId: user.id, - medicationId: id, - timeZone: courseTz, - ...nextWindow, - oneShot: oneShot ?? existing.oneShot, - createdAt: existing.createdAt, - }); - if (refusal) { - const r = courseRefusalResponse(refusal); - return apiError(r.message, r.status, { errorCode: r.errorCode }); - } - } - - const pausedAtPatch = - active === undefined - ? {} - : active - ? { pausedAt: null as Date | null } - : existing.active - ? { pausedAt: new Date() } - : {}; - - // v1.16.3 — normalise the incoming schedules ONCE: the same values feed - // the create payload below AND the revision compare/snapshot (so the - // material-change gate sees exactly what will be persisted). - const normalisedSchedules = schedules?.map((s) => { - // Invariant 2 — default to FREQ=DAILY when nothing else is set. - // v1.7.0 — PRN carries no cadence, so never default it. - const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0; - const isPrn = s.scheduleType === "PRN"; - const defaultedRrule = - oneShot !== true && - !isPrn && - s.rrule === undefined && - s.rollingIntervalDays === undefined && - !hasLegacyDays - ? "FREQ=DAILY" - : s.rrule; - - // Invariant 3 — dual-write timesOfDay from windowStart. - const effectiveTimesOfDay = - s.timesOfDay && s.timesOfDay.length > 0 - ? s.timesOfDay - : [s.windowStart]; - - // Invariant 4 — window / times consistency. See - // `reconcileScheduleWindow`: a times-only edit that echoes the - // stale window back gets the window pulled to the min/max of - // the new times. - const window = reconcileScheduleWindow( - s.windowStart, - s.windowEnd, - effectiveTimesOfDay, - ); - - const serializedDaysOfWeek = serializeScheduleRecurrence({ - daysOfWeek: s.daysOfWeek ?? [], - intervalWeeks: s.intervalWeeks ?? 1, - }); - - return { - createData: { - windowStart: window.windowStart, - windowEnd: window.windowEnd, - label: s.label ?? null, - dose: s.dose ?? null, - // #219 — per-schedule inventory units, field-by-field. A schedule - // replace re-creates the rows, so absent leaves the column NULL - // and the consume hook inherits the medication-level value. - ...(s.unitsPerDose !== undefined && { - unitsPerDose: s.unitsPerDose, - }), - daysOfWeek: serializedDaysOfWeek, - // v1.5 first-class times-of-day. - timesOfDay: effectiveTimesOfDay, - ...(s.reminderGraceMinutes !== undefined && { - reminderGraceMinutes: s.reminderGraceMinutes, - }), - ...(defaultedRrule !== undefined && { rrule: defaultedRrule }), - ...(s.rollingIntervalDays !== undefined && { - rollingIntervalDays: s.rollingIntervalDays, - }), - // v1.7.0 — 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, - }), - // v1.15.18 — per-dose configurable on-time windows. A `schedules` - // replace re-creates the rows, so the column is re-written each - // time; absent leaves it NULL (default ±1h derivation). - ...(s.doseWindows !== undefined && { - doseWindows: s.doseWindows, - }), - }, - // Mirror of the row the create above will persist (defaults applied), - // shaped for the cadence compare. - snapshot: toRevisionPayloadEntry({ - timesOfDay: effectiveTimesOfDay, - windowStart: window.windowStart, - windowEnd: window.windowEnd, - daysOfWeek: serializedDaysOfWeek, - rrule: defaultedRrule ?? null, - rollingIntervalDays: s.rollingIntervalDays ?? null, - scheduleType: s.scheduleType ?? "SCHEDULED", - cyclicOnWeeks: s.cyclicOnWeeks ?? null, - cyclicOffWeeks: s.cyclicOffWeeks ?? null, - doseWindows: s.doseWindows ?? null, - label: s.label ?? null, - dose: s.dose ?? null, - reminderGraceMinutes: s.reminderGraceMinutes ?? null, - }), - }; - }); - - // v1.37.0 — C7. What a schedule replacement destroys, captured for the - // audit row: the cadence that was there and the pending slots that were - // tombstoned with it. A feed line saying "changed a medication" cannot - // answer "back to what?", and unlike a row a schedule keeps ACTING after - // the manager's grant ends. - let replacedCadence: string | null = null; - let replacedScheduleCount: number | null = null; - let tombstonedSlotCount = null as number | null; - // Assigned inside `tombstoneOpenSlots`; the casts keep TypeScript from - // narrowing them to `null` across the closure. - let tombstonedFrom = null as Date | null; - let tombstonedTo = null as Date | null; - - // Tombstone today's and future open pending rows of this medication. - // Runs on a schedule replace (the old anchors are stale) and when intake - // tracking is switched off (nothing is due any more). - // The dose days the tombstoned slots sat on, refreshed once the - // transaction below has committed the tombstones. - const staleDoseDays = new Set(); - const tombstoneOpenSlots = async ( - db: Prisma.TransactionClient, - ): Promise => { - // A schedule replace invalidates the open slot anchors the projector / - // reminder worker minted for the OLD times: a pending 08:00 row for a - // medication that now doses at 20:00 would linger as a phantom slot, - // auto-miss later, and depress compliance. Tombstone today's and - // future open pending rows (never an actioned row — `takenAt`, - // `skipped`, and `autoMissed` are user-visible history) and bump - // `syncVersion` so delta-sync clients drop them. The projector and - // the reminder worker re-mint the anchors for the new times on their - // next pass — the route does not pre-create them. - const userTz = user.timezone || DEFAULT_TIMEZONE; - const { start: todayStart } = getUserTodayBounds(new Date(), userTz); - const tombstoneWhere = { - userId: user.id, - medicationId: id, - deletedAt: null, - takenAt: null, - skipped: false, - autoMissed: false, - scheduledFor: { gte: todayStart }, - } as const; - // Capture the affected slots BEFORE the tombstone so the compliance - // rollups for those days can be recomputed after the commit — - // `updateMany` does not return the touched rows. Without the - // recompute a tombstoned pending kept counting as `scheduled` in - // the rollup row until the next event-driven write for that day, - // depressing the rate the cards read in the meantime. - const tombstoned = await db.medicationIntakeEvent.findMany({ - where: tombstoneWhere, - select: { scheduledFor: true }, - }); - // The count and the day range of the anchors that went. - tombstonedSlotCount = tombstoned.length; - for (const row of tombstoned) { - if (tombstonedFrom === null || row.scheduledFor < tombstonedFrom) { - tombstonedFrom = row.scheduledFor; - } - if (tombstonedTo === null || row.scheduledFor > tombstonedTo) { - tombstonedTo = row.scheduledFor; - } - } - await db.medicationIntakeEvent.updateMany({ - where: tombstoneWhere, - data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, - }); - // Deduped via the user-tz day key. - for (const row of tombstoned) { - staleDoseDays.add(dayKeyForScheduledFor(row.scheduledFor, userTz)); - } - }; - - // v1.40 — everything the PUT writes to the medication, its schedule and - // its course commits as one transaction. The course write can still be - // refused after the check above (another request wrote a course in - // between); with the row update committed first, the client got the - // refusal while the name, the schedule and `oneShot` were already - // changed. Now a refusal rolls all of it back. The rollup recompute and - // the grace bridge stay outside: both read or write what this commit - // settles, and neither may undo it. - let medication; - try { - medication = await prisma.$transaction(async (tx) => { - // If schedules provided, replace all - if (schedules && normalisedSchedules) { - // v1.16.3 — effective dating. Before the wholesale replace below wipes - // the old rows, archive them as ONE revision covering - // `[previous validUntil | medication.createdAt, now)` — but only when a - // cadence-relevant field actually changes. A no-op edit (the Zeitplan - // tab echoing the same rows back) must not mint a phantom era. - const previousRows = await tx.medicationSchedule.findMany({ - where: { medicationId: id }, - }); - const previousEntries = previousRows.map((row) => - toRevisionPayloadEntry({ - timesOfDay: row.timesOfDay, - windowStart: row.windowStart, - windowEnd: row.windowEnd, - daysOfWeek: row.daysOfWeek, - rrule: row.rrule, - rollingIntervalDays: row.rollingIntervalDays, - scheduleType: row.scheduleType, - cyclicOnWeeks: row.cyclicOnWeeks, - cyclicOffWeeks: row.cyclicOffWeeks, - doseWindows: row.doseWindows, - label: row.label, - dose: row.dose, - reminderGraceMinutes: row.reminderGraceMinutes, - }), - ); - replacedScheduleCount = previousRows.length; - replacedCadence = - previousRows.length > 0 - ? previousRows - .map((row) => - [ - row.timesOfDay, - row.rrule ?? "", - row.rollingIntervalDays !== null - ? `every ${row.rollingIntervalDays}d` - : "", - row.daysOfWeek ?? "", - ] - .filter((part) => part !== "") - .join(" "), - ) - .join(" | ") - : null; - - // v1.39.1 (#1033) — a stretch with intake tracking off expected - // nothing, so it archives with an empty payload whatever the rows - // said; and a tracking transition is itself an era boundary even when - // the rows stay the same. - const archivedEntries = wasTracked ? previousEntries : []; - if ( - previousRows.length > 0 && - (trackingChanged || - !wasTracked || - schedulesMateriallyDiffer( - previousEntries, - normalisedSchedules.map((n) => n.snapshot), - )) - ) { - const lastRevision = await tx.medicationScheduleRevision.findFirst({ - // Chain from the ACTIVE boundary: a superseded row is an - // audit record whose `validUntil` may sit past its correction. - where: { medicationId: id, supersededByRevisionId: null }, - orderBy: { validUntil: "desc" }, - select: { validUntil: true }, - }); - await tx.medicationScheduleRevision.create({ - data: { - medicationId: id, - validFrom: lastRevision?.validUntil ?? existing.createdAt, - validUntil: new Date(), - payload: archivedEntries as unknown as Prisma.InputJsonValue, - }, - }); - annotate({ - action: { - name: "medication.schedule.revision_archived", - entity_type: "medication", - entity_id: id, - }, - meta: { schedule_revision_rows: archivedEntries.length }, - }); - } else if ( - previousRows.length === 0 && - existing.asNeeded && - asNeeded === false && - normalisedSchedules.length > 0 - ) { - // v1.16.11 — flipping as-needed OFF. The medication carried ZERO - // schedule rows while as-needed, so the wholesale-replace archive - // above never fires — and without a revision the live era would - // start at the PREVIOUS revision's `validUntil` (or `createdAt`), - // retro-painting the schedule-less as-needed stretch with the NEW - // schedule's expected slots: every PRN day would read as missed. - // Archive an EMPTY era covering the as-needed stretch instead; an - // empty payload expands to zero schedules, so era-aware compliance - // expects nothing there — exactly the as-needed contract. - const lastRevision = await tx.medicationScheduleRevision.findFirst({ - where: { medicationId: id, supersededByRevisionId: null }, - orderBy: { validUntil: "desc" }, - select: { validUntil: true }, - }); - await tx.medicationScheduleRevision.create({ - data: { - medicationId: id, - validFrom: lastRevision?.validUntil ?? existing.createdAt, - validUntil: new Date(), - payload: [] as unknown as Prisma.InputJsonValue, - }, - }); - annotate({ - action: { - name: "medication.schedule.revision_archived", - entity_type: "medication", - entity_id: id, - }, - meta: { schedule_revision_rows: 0 }, - }); - } - await tombstoneOpenSlots(tx); - await tx.medicationSchedule.deleteMany({ - where: { medicationId: id }, - }); - } else if (trackingChanged) { - // v1.39.1 (#1033) — intake tracking switched without a schedule - // replace. Close the current era at this instant: switching OFF - // archives the schedule that was in force (the tracked stretch keeps - // its expected doses); switching back ON archives the untracked - // stretch with an empty payload, so compliance never counts it as - // missed and the live schedule, the projector and the reminder worker - // all resume from now. - const liveRows = await tx.medicationSchedule.findMany({ - where: { medicationId: id }, - }); - if (liveRows.length > 0) { - const lastRevision = await tx.medicationScheduleRevision.findFirst({ - where: { medicationId: id, supersededByRevisionId: null }, - orderBy: { validUntil: "desc" }, - select: { validUntil: true }, - }); - const archivedEntries = wasTracked - ? liveRows.map((row) => - toRevisionPayloadEntry({ - timesOfDay: row.timesOfDay, - windowStart: row.windowStart, - windowEnd: row.windowEnd, - daysOfWeek: row.daysOfWeek, - rrule: row.rrule, - rollingIntervalDays: row.rollingIntervalDays, - scheduleType: row.scheduleType, - cyclicOnWeeks: row.cyclicOnWeeks, - cyclicOffWeeks: row.cyclicOffWeeks, - doseWindows: row.doseWindows, - label: row.label, - dose: row.dose, - reminderGraceMinutes: row.reminderGraceMinutes, - }), - ) - : []; - await tx.medicationScheduleRevision.create({ - data: { - medicationId: id, - validFrom: lastRevision?.validUntil ?? existing.createdAt, - validUntil: new Date(), - payload: archivedEntries as unknown as Prisma.InputJsonValue, - }, - }); - annotate({ - action: { - name: "medication.schedule.revision_archived", - entity_type: "medication", - entity_id: id, - }, - meta: { - schedule_revision_rows: archivedEntries.length, - track_intake: willTrack, - }, - }); - } - } - - // v1.39.1 (#1033) — switching intake tracking off leaves nothing due: - // the open placeholders of today and later go the way a schedule - // replace sends them (a replace in the same request already did it). - if (trackingChanged && !willTrack && !schedules) { - await tombstoneOpenSlots(tx); - } - - const baseUpdateData = { - ...(name !== undefined && { name }), - ...(dose !== undefined && { dose }), - ...(treatmentClass !== undefined && { treatmentClass }), - ...(dosesPerUnit !== undefined && { dosesPerUnit }), - // v1.16.10 — units consumed per dose. Field-by-field; the stamp on - // already-taken intake events freezes their recorded consumption. - ...(unitsPerDose !== undefined && { unitsPerDose }), - // v1.17.0 — reorder lead override. Field-by-field; `null` is a - // valid explicit value (clears the override → user-level default). - ...(reorderLeadDays !== undefined && { reorderLeadDays }), - ...(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. - ...(liveActivityEnabled !== undefined && { liveActivityEnabled }), - ...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }), - // v1.9.0 — optional drug-classification codes (ATC / RxNorm). - // Field-by-field; `null` is a valid explicit value (clears the code). - ...(atcCode !== undefined && { atcCode }), - ...(rxNormCode !== undefined && { rxNormCode }), - // v1.5 scheduling primitives — pass-through when supplied. - // `startsOn` / `endsOn` are `Date | null | undefined` (the - // schema lets the user clear them explicitly with null). - // v1.40 (#1024) — `startsOn` / `endsOn` are not written here: the - // course writer below writes them together with the course they - // project, in one transaction, so the row and its courses cannot - // disagree if one of the two writes fails. - ...(oneShot !== undefined && { oneShot }), - // v1.16.11 — as-needed flag, field-by-field (the invariants above - // already guaranteed the medication ends schedule-consistent). - ...(asNeeded !== undefined && { asNeeded }), - // v1.39.1 (#1033) — intake tracking, field-by-field. - ...(trackIntake !== undefined && { trackIntake }), - // v1.42.2 — auto-mark-taken, field-by-field. Only an off→on flip - // stamps `autoMarkTakenSince`, so re-saving the form with the - // option already on never moves the floor; switching it off - // clears the stamp. - ...(autoMarkTaken !== undefined && - autoMarkTaken !== existing.autoMarkTaken && { - autoMarkTaken, - autoMarkTakenSince: autoMarkTaken ? new Date() : null, - }), - ...(normalisedSchedules && { - schedules: { - create: normalisedSchedules.map((n) => n.createData), - }, - }), - }; - - const updated = await tx.medication.update({ - where: { id }, - data: { ...baseUpdateData, ...pausedAtPatch }, - include: { schedules: true }, - }); - - // v1.25 — durable pause intervals. The `pausedAt` column is a - // single live marker that resume clears, so the paused window is - // irrecoverable once resumed and every paused day's slot collapses to - // "missed" in the compliance denominator. Mirror the live transition - // onto an additive era record the rate paths read: a pause opens an era - // (resumedAt null), a resume closes the latest open era. `userId` comes - // from the authenticated session; the data object is built field-by-field. - if (active === false && existing.active) { - await tx.medicationPauseEra.create({ - data: { - medicationId: id, - userId: user.id, - pausedAt: new Date(), - resumedAt: null, - }, - }); - } else if (active === true && !existing.active) { - await tx.medicationPauseEra.updateMany({ - where: { medicationId: id, userId: user.id, resumedAt: null }, - data: { resumedAt: new Date() }, - }); - // v1.42.2 — made active again: the auto-take pass starts from - // now, never filling the inactive days as taken. - await moveAutoTakeFloorToNow(tx, id); - } - - // v1.40 (#1024) — write the window onto the latest course and - // project it back, then serve the projected row. - if (touchesWindow) { - const projected = await setCurrentWindow( - { - userId: user.id, - medicationId: id, - timeZone: courseTz, - ...nextWindow, - }, - tx, - ); - return { ...updated, ...projected }; - } - return updated; - }); - } catch (err) { - // A concurrent course write landed between the check above and the - // course write. The transaction rolled back: nothing was written. - if (err instanceof CourseWriteError) { - const r = courseRefusalResponse(err.refusal); - return apiError(r.message, r.status, { errorCode: r.errorCode }); - } - throw err; - } - - // Refresh each dose day the tombstoned slots sat on, now that the - // tombstones are committed. Best-effort like every dose write-hook: a - // failure is reported, never fails the edit. - for (const dayKey of staleDoseDays) { - await refreshDoseDay({ - userId: user.id, - medicationId: id, - dayKey, - tz: user.timezone || DEFAULT_TIMEZONE, - }); - } - - // Apply the v1.5.5 primary-schedule grace bridge after the - // medication update so a failure here doesn't roll the medication - // edit back. Re-read the row so the response shape carries the - // converged grace value. - if (primaryScheduleGracePatch) { - await prisma.medicationSchedule.update({ - where: { id: primaryScheduleGracePatch.scheduleId }, - data: { - reminderGraceMinutes: primaryScheduleGracePatch.reminderGraceMinutes, - }, - }); - const refreshed = await prisma.medication.findUnique({ - where: { id }, - include: { schedules: true }, - }); - if (refreshed) { - medication = refreshed; - } - } - - // v1.40 (#1041) — an iPhone app build that predates custom categories - // decodes an unknown category as OTHER and sends it back on every edit, - // which would overwrite the person's own category with OTHER. A native - // client (Bearer) sending OTHER over a custom category keeps the custom - // one; moving a custom category back to OTHER stays possible on the web. - let categoryToWrite = category; - if (category === "OTHER" && authMethod === "bearer") { - const current = (await resolveMedicationCategories([id]))[id]; - if (current?.category.startsWith("custom:")) { - categoryToWrite = undefined; - annotate({ - action: { name: "medication.update.custom_category_kept" }, - meta: { entity_id: id }, - }); - } - } - if (categoryToWrite !== undefined) - await setMedicationCategory(id, categoryToWrite); - const resolvedCategory = (await resolveMedicationCategories([id]))[id]; - const normalizedCategory = resolvedCategory?.category ?? "OTHER"; - - // v1.43 — a changed units-per-dose (on the medication or on a slot) - // changes what every taken dose added, so a medication with a composition - // has its doses recomputed. Cheap and coalesced when nothing moved. - if (await medicationHasComposition(user.id, id)) { - await enqueueSupplementNutrientRecompute(user.id); - } - - await auditLog("medication.update", { - userId: user.id, + const outcome = await updateMedication({ + user, + authMethod, + id, + existing, + data: parsed.data, ipAddress: getClientIp(request), - // C4 on what the medication row itself lost, and C7 on the schedule: - // the cadence that was replaced, and how many pending slots went with - // it over which days. - details: { - medicationId: id, - ...overwriteDetails({ - before: { - name: existing.name, - dose: existing.dose, - active: existing.active, - asNeeded: existing.asNeeded, - trackIntake: existing.trackIntake, - // v1.42.2 — switching automatic recording on or off is a change - // to how doses are recorded, so the row names it. - autoMarkTaken: existing.autoMarkTaken, - endsOn: existing.endsOn, - }, - after: { - name: medication.name, - dose: medication.dose, - active: medication.active, - asNeeded: medication.asNeeded, - trackIntake: medication.trackIntake, - autoMarkTaken: medication.autoMarkTaken, - endsOn: medication.endsOn, - }, - }), - ...(replacedScheduleCount !== null - ? { - replacedCadence, - replacedScheduleCount, - tombstonedSlotCount, - tombstonedFrom: tombstonedFrom?.toISOString() ?? null, - tombstonedTo: tombstonedTo?.toISOString() ?? null, - } - : {}), - }, }); + if (!outcome.ok) { + return apiError(outcome.message, outcome.status, outcome.meta); + } - annotate({ - action: { - name: "medication.update", - entity_type: "medication", - entity_id: id, - }, - }); - - // v1.4.34 — bust per-user medications + compliance + achievement - // caches so the next read reflects the schedule change. - invalidateUserMedications(user.id, { evict: true }); - - return apiSuccess({ - ...medication, - unitsPerDose: Number(medication.unitsPerDose), - ...scheduleWireFields( - medication.trackIntake, - serializeScheduleUnitsPerDose( - medication.schedules, - medication.unitsPerDose, - ), - ), - category: normalizedCategory, - categoryLabel: resolvedCategory?.categoryLabel ?? null, - // v1.40 (#1024) — the courses, as every medication read carries them. - ...(await resolveCourseFields([medication], new Date(), courseTz)).get( - id, - ), - }); + return apiSuccess(outcome.body); }, ); @@ -1213,58 +79,22 @@ export const DELETE = apiHandler( const { user } = await requireAuth(); const { id } = await params; - // v1.5.5 — ownership via the shared helper. The DELETE - // branch needs the medication name for the audit-row detail; pull - // it after the guard passes. + // Ownership via the shared helper. The delete needs the medication name + // for the audit-row detail; it is read after the guard passes. const guard = await assertMedicationOwnership(id, user.id); if (guard) return guard; - const existing = await prisma.medication.findUnique({ - where: { id }, - select: { name: true }, - }); + const existing = await findMedicationForDelete(id); if (!existing) { return apiError("Medication not found", 404); } - // Revoke API tokens scoped to this medication - const medicationScope = `medication:${id}:ingest`; - await prisma.apiToken.updateMany({ - where: { - userId: user.id, - revoked: false, - permissions: { has: medicationScope }, - }, - data: { revoked: true }, - }); - - // v1.43 — whether its doses counted toward the micronutrients, read - // before the delete cascades the composition away. - const hadComposition = await medicationHasComposition(user.id, id); - - await deleteMedicationCategory(id); - await prisma.medication.delete({ where: { id } }); - - await auditLog("medication.delete", { + await deleteMedication({ userId: user.id, + id, + existing, ipAddress: getClientIp(request), - details: { medicationId: id, name: existing.name }, }); - annotate({ - action: { - name: "medication.delete", - entity_type: "medication", - entity_id: id, - }, - }); - - // v1.4.34 — bust per-user medications + compliance + achievement - // caches so the next read reflects the deletion. - invalidateUserMedications(user.id, { evict: true }); - - // Its doses are gone with it, so the days they counted on are recomputed. - if (hadComposition) await enqueueSupplementNutrientRecompute(user.id); - return apiSuccess({ deleted: true }); }, ); diff --git a/src/lib/medications/__tests__/auto-mark-taken.test.ts b/src/lib/medications/__tests__/auto-mark-taken.test.ts index 0f04c842b..7c072ee97 100644 --- a/src/lib/medications/__tests__/auto-mark-taken.test.ts +++ b/src/lib/medications/__tests__/auto-mark-taken.test.ts @@ -83,7 +83,7 @@ describe("sendsMedicationReminders", () => { } for (const dto of [ "lib/medications/list-read.ts", - "app/api/medications/[id]/route.ts", + "lib/medications/read-medication.ts", ]) { expect(src(dto), dto).toMatch( /remindersActive:\s*sendsMedicationReminders\(/, diff --git a/src/lib/medications/delete-medication.ts b/src/lib/medications/delete-medication.ts new file mode 100644 index 000000000..59d0f5a60 --- /dev/null +++ b/src/lib/medications/delete-medication.ts @@ -0,0 +1,77 @@ +/** + * Delete one medication: `DELETE /api/medications/{id}`. + * + * The route authenticates (the caller's own account) and runs the ownership + * guard; the token revocation, the delete, its audit row and its tails run + * here, in the order they always ran. + */ +import { prisma } from "@/lib/db"; +import { + enqueueSupplementNutrientRecompute, + medicationHasComposition, +} from "@/lib/medications/ingredients/recompute-job"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { deleteMedicationCategory } from "@/lib/medication-category"; + +/** + * The name the audit row carries, read after the ownership guard passed. + * Null for the route's 404. + */ +export async function findMedicationForDelete( + id: string, +): Promise<{ name: string } | null> { + return prisma.medication.findUnique({ + where: { id }, + select: { name: true }, + }); +} + +export async function deleteMedication(input: { + userId: string; + id: string; + existing: { name: string }; + ipAddress: string | null; +}): Promise { + const { userId, id, existing } = input; + + // Revoke API tokens scoped to this medication + const medicationScope = `medication:${id}:ingest`; + await prisma.apiToken.updateMany({ + where: { + userId, + revoked: false, + permissions: { has: medicationScope }, + }, + data: { revoked: true }, + }); + + // Whether its doses counted toward the micronutrients, read + // before the delete cascades the composition away. + const hadComposition = await medicationHasComposition(userId, id); + + await deleteMedicationCategory(id); + await prisma.medication.delete({ where: { id } }); + + await auditLog("medication.delete", { + userId, + ipAddress: input.ipAddress, + details: { medicationId: id, name: existing.name }, + }); + + annotate({ + action: { + name: "medication.delete", + entity_type: "medication", + entity_id: id, + }, + }); + + // Bust per-user medications + compliance + achievement + // caches so the next read reflects the deletion. + invalidateUserMedications(userId, { evict: true }); + + // Its doses are gone with it, so the days they counted on are recomputed. + if (hadComposition) await enqueueSupplementNutrientRecompute(userId); +} diff --git a/src/lib/medications/read-medication.ts b/src/lib/medications/read-medication.ts new file mode 100644 index 000000000..9b1c02f85 --- /dev/null +++ b/src/lib/medications/read-medication.ts @@ -0,0 +1,277 @@ +/** + * Read one medication: `GET /api/medications/{id}`. + * + * The route authenticates (READ over the `medications` section) and answers + * the 404; the fetch, the category enrichment, the server-computed next due + * instant, the stock and runway, the composition, the courses and the related + * records are resolved here, in the order they always were. `null` means the + * medication is not the record's. + */ +import type { Medication, MedicationSchedule } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken"; +import { toIngredientDtos } from "@/lib/medications/ingredients/composition"; +import { annotate } from "@/lib/logging/context"; +import type { SessionUser } from "@/lib/auth/session-user"; +import { resolveMedicationCategories } from "@/lib/medication-category"; +import { resolveCourseFields } from "@/lib/medications/courses"; +import { loadAutoTakeRollingAnchors } from "@/lib/medications/auto-take"; +import { + computeDisplayDue, + OVERDUE_LOOKBACK_MS, + toResolvedSlotMark, +} from "@/lib/medications/scheduling/next-due"; +import { + effectiveUnitsPerDose, + estimateUnitsRunwayDays, +} from "@/lib/medications/supply-runway"; +import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto"; +import { + dueSchedules, + isRecordOnly, + scheduleWireFields, +} from "@/lib/medications/intake-tracking"; +import { floorWhole } from "@/lib/medications/units-per-dose"; +import { resolveIntakeActionability } from "@/lib/medications/intake-actionable"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { buildRelated } from "@/lib/links/related"; + +type ReadUser = Pick; +type MedicationWithSchedules = Medication & { + schedules: MedicationSchedule[]; +}; + +/** + * The server-computed next due instant. Rolling cadences re-anchor on the + * latest non-skipped intake, so fetch it once (no-op for calendar cadences + * but cheap + keeps the value correct for rolling injections). + */ +async function loadDisplayDue( + user: ReadUser, + id: string, + medication: MedicationWithSchedules, +) { + const lastIntake = dueSchedules(medication).some( + (s) => s.rollingIntervalDays !== null, + ) + ? await prisma.medicationIntakeEvent.findFirst({ + // A tombstoned intake no longer anchors the rolling-interval + // next-due computation. + where: { + userId: user.id, + medicationId: id, + deletedAt: null, + takenAt: { not: null }, + }, + orderBy: { takenAt: "desc" }, + select: { takenAt: true }, + }) + : null; + // Slots the user has already acted on near now, so the next-due search + // skips them and advances to the next genuinely-open slot. Bound the read + // to [now-1d, now+2d] — the lookahead only needs the slots adjacent to now. + const now = new Date(); + // Reach back as far as the widest band tail so a long-resolved past slot + // can never resurface as "overdue". + const resolvedFrom = new Date(now.getTime() - OVERDUE_LOOKBACK_MS); + const resolvedTo = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000); + const resolvedRows = await prisma.medicationIntakeEvent.findMany({ + where: { + userId: user.id, + medicationId: id, + deletedAt: null, + scheduledFor: { gte: resolvedFrom, lte: resolvedTo }, + OR: [{ takenAt: { not: null } }, { skipped: true }, { autoMissed: true }], + }, + // `takenAt` rides along so an ad-hoc row (`scheduledFor === takenAt`) + // cannot ±6h-resolve a different slot. + select: { scheduledFor: true, takenAt: true }, + }); + // Current-era floor: the open-overdue search mints from the LIVE schedule + // rows, so it must not reach past the newest revision boundary into a + // previous era's cadence. + const latestRevision = await prisma.medicationScheduleRevision.findFirst({ + // Superseded rows are audit records — a correction may have + // shortened the era, so the boundary reads only active rows. + where: { medicationId: id, supersededByRevisionId: null }, + orderBy: { validUntil: "desc" }, + select: { validUntil: true }, + }); + // Intake tracking off (#1033): nothing is due from the stored rows, and no + // runway derives from them. + const liveSchedules = dueSchedules(medication); + const userTz = user.timezone || DEFAULT_TIMEZONE; + // Under auto-take a rolling cadence stands where the job will record next + // (see `auto-take.ts`). + const engineLastIntakeAt = await loadAutoTakeRollingAnchors(prisma, { + userId: user.id, + medicationIds: medication.autoMarkTaken ? [id] : [], + now, + userTz, + lastIntakeAt: () => lastIntake?.takenAt ?? null, + }); + const display = computeDisplayDue({ + medication: { + id: medication.id, + startsOn: medication.startsOn, + endsOn: medication.endsOn, + oneShot: medication.oneShot, + createdAt: medication.createdAt, + }, + schedules: liveSchedules, + now, + userTz, + lastIntakeAt: engineLastIntakeAt(id), + resolvedSlots: resolvedRows.map(toResolvedSlotMark), + eraStart: latestRevision?.validUntil ?? null, + }); + return { display, userTz, now }; +} + +/** + * Stock + runway on the detail wire, mirroring the list payload's semantics: + * NULL stock = inventory tracking off, 0 = the supply ran out; runwayDays via + * the slot-aware burn rate. Published resolved so no client re-derives the + * inheritance / cadence math. + */ +async function loadStock( + user: ReadUser, + id: string, + medication: MedicationWithSchedules, +) { + const [usableStock, anyItemCount] = await Promise.all([ + prisma.medicationInventoryItem.aggregate({ + where: { + userId: user.id, + medicationId: id, + state: { in: ["ACTIVE", "IN_USE"] }, + unitsRemaining: { gt: 0 }, + }, + _sum: { unitsRemaining: true }, + }), + prisma.medicationInventoryItem.count({ + where: { userId: user.id, medicationId: id }, + }), + ]); + const schedulesDto = serializeScheduleUnitsPerDose( + medication.schedules, + medication.unitsPerDose, + ); + const stockUnitsRemaining = + anyItemCount > 0 ? Number(usableStock._sum.unitsRemaining ?? 0) : null; + const stockDosesRemaining = + stockUnitsRemaining === null + ? null + : floorWhole( + stockUnitsRemaining / + effectiveUnitsPerDose( + schedulesDto, + Number(medication.unitsPerDose), + ), + ); + const runwayDays = + stockUnitsRemaining === null + ? null + : estimateUnitsRunwayDays( + stockUnitsRemaining, + isRecordOnly(medication) ? [] : schedulesDto, + Number(medication.unitsPerDose), + ); + return { schedulesDto, stockUnitsRemaining, stockDosesRemaining, runwayDays }; +} + +/** The detail answer of one medication, or null for the route's 404. */ +export async function readMedication(input: { + user: ReadUser; + grantId: string | null; + id: string; +}): Promise | null> { + const { user, grantId, id } = input; + const medication = await prisma.medication.findUnique({ + where: { id }, + include: { schedules: true }, + }); + + if (!medication || medication.userId !== user.id) { + return null; + } + + let category = "OTHER"; + let categoryLabel: string | null = null; + try { + const categories = await resolveMedicationCategories([id]); + category = categories[id]?.category ?? "OTHER"; + categoryLabel = categories[id]?.categoryLabel ?? null; + } catch { + // Category enrichment is optional + } + + const { display, userTz, now } = await loadDisplayDue(user, id, medication); + + annotate({ + action: { + name: "medication.get", + entity_type: "medication", + entity_id: id, + }, + }); + + const { schedulesDto, stockUnitsRemaining, stockDosesRemaining, runwayDays } = + await loadStock(user, id, medication); + + // The composition per unit, labelled in the account language. + const ingredientRows = await prisma.medicationIngredient.findMany({ + where: { medicationId: id, userId: user.id }, + select: { + nutrient: true, + amountPerUnit: true, + unit: true, + enteredAmount: true, + enteredUnit: true, + }, + }); + + // The courses, resolved on the server (#1024). + const courseFields = ( + await resolveCourseFields( + [medication], + now, + user.timezone || DEFAULT_TIMEZONE, + ) + ).get(id); + + return { + ...medication, + unitsPerDose: Number(medication.unitsPerDose), + // `schedules: []` plus `recordedSchedules` when intake tracking is off + // (#1033, see `scheduleWireFields`). + ...scheduleWireFields(medication.trackIntake, schedulesDto), + category, + categoryLabel, + ...courseFields, + nextDueAt: display ? display.at.toISOString() : null, + // The server's own reminder decision, published resolved. + remindersActive: sendsMedicationReminders(medication, { + now, + timeZone: userTz, + }), + nextDueOverdue: display?.overdue ?? false, + // See `resolveIntakeActionability` (#1040). + ...resolveIntakeActionability( + medication, + now, + user.timezone || DEFAULT_TIMEZONE, + ), + stockUnitsRemaining, + stockDosesRemaining, + runwayDays, + ingredients: toIngredientDtos(ingredientRows, user.locale ?? null), + // The records this medication relates to (`src/lib/links/`). + related: await buildRelated(prisma, { + userId: user.id, + grantId, + kind: "medication", + id, + }), + }; +} diff --git a/src/lib/medications/update-medication/preflight.ts b/src/lib/medications/update-medication/preflight.ts new file mode 100644 index 000000000..5becef569 --- /dev/null +++ b/src/lib/medications/update-medication/preflight.ts @@ -0,0 +1,273 @@ +/** + * What a medication edit checks before anything is written: the stored + * pre-image, the record-only rule, the category, the as-needed and one-shot + * shapes, the primary-schedule grace bridge and the course window. Each + * refusal leaves the medication as it was. + */ +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { + checkCurrentWindow, + courseRefusalResponse, +} from "@/lib/medications/courses"; +import type { MedicationEdit } from "./schedule-normalise"; + +/** A refusal the route answers with `apiError(message, status, meta)`. */ +export interface MedicationEditRefusal { + ok: false; + message: string; + status: number; + meta?: { errorCode: string }; +} + +const EDIT_SELECT = { + active: true, + createdAt: true, + // `asNeeded` + the schedule count feed the as-needed invariants: a flip + // to as-needed must end schedule-less, a flip back to scheduled must end + // with at least one schedule. + asNeeded: true, + // The tracking transition (#1033) needs the before. + trackIntake: true, + // `autoMarkTakenSince` is stamped only on an off→on flip. + autoMarkTaken: true, + // The pre-image the audit row carries (C4). + name: true, + dose: true, + endsOn: true, + // The course check (#1024) needs the stored window. + startsOn: true, + oneShot: true, + _count: { select: { schedules: true } }, +} satisfies Prisma.MedicationSelect; + +export type EditableMedication = Prisma.MedicationGetPayload<{ + select: typeof EDIT_SELECT; +}>; + +/** + * The narrow pre-image the edit reads after the route's ownership guard + * passed: `active` for the pausedAt derivation, and the rest for the + * invariants and the audit row. Null for the route's 404. + */ +export async function findMedicationForEdit( + id: string, +): Promise { + return prisma.medication.findUnique({ + where: { id }, + select: EDIT_SELECT, + }); +} + +/** + * ── A record-only schedule is only replaced knowingly (#1033) ── + * + * A medication with intake tracking off is served with `schedules: []` + * and its stored schedule in `recordedSchedules`, so a client that + * predates the field arms no reminder from it. The price is that such a + * client cannot SEE the stored schedule. The shipped iPhone app (1.0.3 / + * 1.0.4) opens a medication without schedule rows in its editor as + * "daily at 08:00"; a rename or dose edit sends no `schedules` (its + * editor diffs against that same prefill), but any touch of a schedule + * field sends a full replacement built from the 08:00 default plus + * `asNeeded` and `oneShot`, and never `trackIntake`. Applied, that would + * overwrite the schedule the person kept on record with one they never + * chose (or flip it to as-needed and delete the rows). + * + * Rule: on a medication whose intake tracking is off, a write that does + * not name `trackIntake` leaves the schedule shape alone. `schedules`, + * `asNeeded` and `oneShot` are dropped from it; every other field (name, + * dose, dates, `active`, `notificationsEnabled`, inventory, ...) applies + * as sent. A client that knows about the switch names it on every + * schedule save (the web editor and wizard do), and those writes apply. + */ +export function applyRecordOnlyRule( + existing: EditableMedication, + data: MedicationEdit, + id: string, +): MedicationEdit { + const keepRecordedSchedule = + existing.trackIntake === false && + data.trackIntake === undefined && + (data.schedules !== undefined || + data.asNeeded !== undefined || + data.oneShot !== undefined); + const input = keepRecordedSchedule + ? { + ...data, + schedules: undefined, + asNeeded: undefined, + oneShot: undefined, + } + : data; + if (keepRecordedSchedule) { + annotate({ + action: { + name: "medication.update.record_schedule_kept", + entity_type: "medication", + entity_id: id, + }, + meta: { + dropped_schedules: data.schedules !== undefined, + dropped_as_needed: data.asNeeded !== undefined, + dropped_one_shot: data.oneShot !== undefined, + }, + }); + } + return input; +} + +/** + * ── As-needed invariants (#316) ── + * + * An as-needed medication ends schedule-less, always. The Zod layer + * already rejects a populated `schedules` array alongside + * `asNeeded: true`; the route covers the cross-row cases the schema + * cannot see: + * - flipping an existing scheduled medication to as-needed + * requires `schedules: []` in the same request (keeping the old + * rows alongside the flag is a 422, per the feature contract); + * - flipping as-needed OFF requires the medication to end with at + * least one schedule (the wizard always sends the full array). + */ +export function refuseAsNeededShape( + existing: EditableMedication, + asNeeded: boolean | undefined, + schedules: MedicationEdit["schedules"], +): MedicationEditRefusal | null { + const effectiveAsNeeded = asNeeded ?? existing.asNeeded; + if (effectiveAsNeeded) { + const endsScheduleless = schedules + ? schedules.length === 0 + : existing._count.schedules === 0; + if (!endsScheduleless) { + return { + ok: false, + message: + "An as-needed medication cannot carry schedules (send schedules: [] to clear them)", + status: 422, + }; + } + } else if ( + asNeeded === false && + existing.asNeeded && + (!schedules || schedules.length === 0) + ) { + return { + ok: false, + message: "A scheduled medication requires at least one schedule", + status: 422, + }; + } + return null; +} + +export interface GracePatch { + scheduleId: string; + reminderGraceMinutes: number | null; +} + +/** + * ── Primary-schedule grace bridge ── + * + * The detail-page settings section saves the reminder-window in + * one PUT carrying only `{ reminderGraceMinutes }` at the top + * level (no full `schedules` array). Map it onto the medication's + * primary schedule before the Prisma update so the persisted + * shape stays per-schedule and the engine reads it from the same + * column as the wizard write-path. A full `schedules` array on + * the same request takes precedence — the wizard already declares + * the grace per row and we never want to overwrite that intent. + */ +export async function findPrimaryScheduleGracePatch( + id: string, + topLevelGraceMinutes: number | null | undefined, + schedules: MedicationEdit["schedules"], +): Promise { + let primaryScheduleGracePatch: GracePatch | null = null; + if (topLevelGraceMinutes !== undefined && !schedules) { + const primary = await prisma.medicationSchedule.findFirst({ + where: { medicationId: id }, + orderBy: { windowStart: "asc" }, + select: { id: true }, + }); + if (primary) { + primaryScheduleGracePatch = { + scheduleId: primary.id, + reminderGraceMinutes: topLevelGraceMinutes, + }; + } + } + return primaryScheduleGracePatch; +} + +/** + * ── Route invariants for the scheduling primitives ── + * + * Mirrors the create: oneShot ⇒ at-most-one schedule + no + * recurrence on it; endsOn normalised to startsOn for one-shot; + * recurring default = FREQ=DAILY when nothing else is set; + * timesOfDay dual-write from windowStart when absent. + * + * The PUT replaces all schedules wholesale, so the invariants run + * against the incoming `schedules` array (if provided) the same + * way they do on create. + */ +export function refuseOneShotShape( + oneShot: boolean | undefined, + schedules: MedicationEdit["schedules"], +): MedicationEditRefusal | null { + if (oneShot === true && schedules) { + if (schedules.length > 1) { + return { + ok: false, + message: "A one-shot medication can have at most one schedule", + status: 422, + }; + } + const s = schedules[0]; + if (s && (s.rrule !== undefined || s.rollingIntervalDays !== undefined)) { + return { + ok: false, + message: + "A one-shot medication cannot have a recurrence (rrule or rollingIntervalDays)", + status: 422, + }; + } + } + return null; +} + +/** + * The window a PUT sets is the latest course (#1024). Check it against the + * other courses BEFORE the row changes, so a refused window leaves the + * medication as it was. + */ +export async function refuseCourseWindow(args: { + userId: string; + medicationId: string; + timeZone: string; + nextWindow: { startsOn: Date | null; endsOn: Date | null }; + oneShot: boolean; + createdAt: Date; +}): Promise { + const refusal = await checkCurrentWindow({ + userId: args.userId, + medicationId: args.medicationId, + timeZone: args.timeZone, + ...args.nextWindow, + oneShot: args.oneShot, + createdAt: args.createdAt, + }); + if (refusal) { + const r = courseRefusalResponse(refusal); + return { + ok: false, + message: r.message, + status: r.status, + meta: { errorCode: r.errorCode }, + }; + } + return null; +} diff --git a/src/lib/medications/update-medication/schedule-normalise.ts b/src/lib/medications/update-medication/schedule-normalise.ts new file mode 100644 index 000000000..0d81fa66a --- /dev/null +++ b/src/lib/medications/update-medication/schedule-normalise.ts @@ -0,0 +1,157 @@ +/** + * The incoming schedules of a medication edit, normalised once. + * + * The same values feed the nested create payload AND the revision + * compare/snapshot, so the material-change gate sees exactly what will be + * persisted. The invariants mirror the create: recurring default + * `FREQ=DAILY` when nothing else is set, `timesOfDay` dual-written from + * `windowStart` when absent, and the window pulled to the dose times when a + * time falls outside it. + */ +import type { z } from "zod/v4"; + +import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; +import { toRevisionPayloadEntry } from "@/lib/medications/scheduling/schedule-eras"; +import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm"; +import type { updateMedicationSchema } from "@/lib/validations/medication"; + +export type MedicationEdit = z.output; +type ScheduleInput = NonNullable[number]; + +/** Parse "HH:mm" into minutes-of-day; NaN-safe (malformed → 0). */ +function hhmmToMinutes(value: string): number { + return hhmmToMinutesOrNull(value) ?? 0; +} + +/** + * Reconcile a schedule's `windowStart` / `windowEnd` with its effective + * `timesOfDay`. The schedule editor can change only the dose times while the + * client echoes the previous window back; a time outside the window then + * breaks every consumer that assumes `timesOfDay ⊆ [windowStart, windowEnd]` + * (the window-status pill, the reminder phases, the legacy compliance + * walker). When ANY time falls outside the window — including the overnight + * `windowEnd < windowStart` shape, checked with wrap-around membership — + * the window is pulled to the min/max of the times so the persisted row is + * self-consistent. A window that already covers every time is left + * byte-identical (explicitly wider windows are a feature). + */ +function reconcileScheduleWindow( + windowStart: string, + windowEnd: string, + timesOfDay: string[], +): { windowStart: string; windowEnd: string } { + if (timesOfDay.length === 0) return { windowStart, windowEnd }; + const startMins = hhmmToMinutes(windowStart); + const endMins = hhmmToMinutes(windowEnd); + const overnight = endMins < startMins; + const inWindow = (t: number): boolean => + overnight ? t >= startMins || t <= endMins : t >= startMins && t <= endMins; + const sorted = [...timesOfDay].sort( + (a, b) => hhmmToMinutes(a) - hhmmToMinutes(b), + ); + const allInside = sorted.every((t) => inWindow(hhmmToMinutes(t))); + if (allInside) return { windowStart, windowEnd }; + return { windowStart: sorted[0], windowEnd: sorted[sorted.length - 1] }; +} + +function normaliseSchedule(s: ScheduleInput, oneShot: boolean | undefined) { + // Invariant 2 — default to FREQ=DAILY when nothing else is set. + // PRN carries no cadence, so never default it. + const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0; + const isPrn = s.scheduleType === "PRN"; + const defaultedRrule = + oneShot !== true && + !isPrn && + s.rrule === undefined && + s.rollingIntervalDays === undefined && + !hasLegacyDays + ? "FREQ=DAILY" + : s.rrule; + + // Invariant 3 — dual-write timesOfDay from windowStart. + const effectiveTimesOfDay = + s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay : [s.windowStart]; + + // Invariant 4 — window / times consistency. See + // `reconcileScheduleWindow`: a times-only edit that echoes the + // stale window back gets the window pulled to the min/max of + // the new times. + const window = reconcileScheduleWindow( + s.windowStart, + s.windowEnd, + effectiveTimesOfDay, + ); + + const serializedDaysOfWeek = serializeScheduleRecurrence({ + daysOfWeek: s.daysOfWeek ?? [], + intervalWeeks: s.intervalWeeks ?? 1, + }); + + return { + createData: { + windowStart: window.windowStart, + windowEnd: window.windowEnd, + label: s.label ?? null, + dose: s.dose ?? null, + // #219 — per-schedule inventory units, field-by-field. A schedule + // replace re-creates the rows, so absent leaves the column NULL + // and the consume hook inherits the medication-level value. + ...(s.unitsPerDose !== undefined && { + unitsPerDose: s.unitsPerDose, + }), + daysOfWeek: serializedDaysOfWeek, + // 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. A `schedules` + // replace re-creates the rows, so the column is re-written each + // time; absent leaves it NULL (default ±1h derivation). + ...(s.doseWindows !== undefined && { + doseWindows: s.doseWindows, + }), + }, + // Mirror of the row the create above will persist (defaults applied), + // shaped for the cadence compare. + snapshot: toRevisionPayloadEntry({ + timesOfDay: effectiveTimesOfDay, + windowStart: window.windowStart, + windowEnd: window.windowEnd, + daysOfWeek: serializedDaysOfWeek, + rrule: defaultedRrule ?? null, + rollingIntervalDays: s.rollingIntervalDays ?? null, + scheduleType: s.scheduleType ?? "SCHEDULED", + cyclicOnWeeks: s.cyclicOnWeeks ?? null, + cyclicOffWeeks: s.cyclicOffWeeks ?? null, + doseWindows: s.doseWindows ?? null, + label: s.label ?? null, + dose: s.dose ?? null, + reminderGraceMinutes: s.reminderGraceMinutes ?? null, + }), + }; +} + +export type NormalisedSchedule = ReturnType; + +/** The incoming schedules, normalised; undefined when the edit sends none. */ +export function normaliseSchedules( + schedules: MedicationEdit["schedules"], + oneShot: boolean | undefined, +): NormalisedSchedule[] | undefined { + return schedules?.map((s) => normaliseSchedule(s, oneShot)); +} diff --git a/src/lib/medications/update-medication/update-medication.ts b/src/lib/medications/update-medication/update-medication.ts new file mode 100644 index 000000000..be7c9ba20 --- /dev/null +++ b/src/lib/medications/update-medication/update-medication.ts @@ -0,0 +1,387 @@ +/** + * Edit one medication: `PUT /api/medications/{id}`. + * + * The route authenticates (MANAGE over the `medications` section), runs the + * ownership guard, reads the pre-image through `findMedicationForEdit` and + * parses the body; the record-only rule, the invariants, the transaction and + * everything after it run here, in the order they always ran. The outcome is + * a refusal the route answers with `apiError`, or the edited medication. + */ +import { prisma } from "@/lib/db"; +import { + enqueueSupplementNutrientRecompute, + medicationHasComposition, +} from "@/lib/medications/ingredients/recompute-job"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import type { SessionUser } from "@/lib/auth/session-user"; +import { overwriteDetails } from "@/lib/sharing/audit-details"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { + isAssignableCategory, + resolveMedicationCategories, + setMedicationCategory, +} from "@/lib/medication-category"; +import { + CourseWriteError, + courseRefusalResponse, + resolveCourseFields, +} from "@/lib/medications/courses"; +import { refreshDoseDay } from "@/lib/medications/dose-day-refresh"; +import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto"; +import { scheduleWireFields } from "@/lib/medications/intake-tracking"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { + applyRecordOnlyRule, + findPrimaryScheduleGracePatch, + refuseAsNeededShape, + refuseCourseWindow, + refuseOneShotShape, + type EditableMedication, + type GracePatch, + type MedicationEditRefusal, +} from "./preflight"; +import { normaliseSchedules, type MedicationEdit } from "./schedule-normalise"; +import { + createEraTrail, + writeMedicationEdit, + type EraTrail, + type MedicationEditWrite, +} from "./write"; + +type EditUser = Pick; +type EditedMedication = Awaited>; + +export type MedicationEditOutcome = + MedicationEditRefusal | { ok: true; body: Record }; + +/** + * Everything the edit checks and derives before the transaction, in the + * order the route always ran it. A refusal leaves the medication as it was. + */ +async function planMedicationEdit(input: { + user: EditUser; + id: string; + existing: EditableMedication; + data: MedicationEdit; +}): Promise< + | MedicationEditRefusal + | { + ok: true; + write: MedicationEditWrite; + primaryScheduleGracePatch: GracePatch | null; + } +> { + const { user, id, existing } = input; + const edit = applyRecordOnlyRule(existing, input.data, id); + const { + category, + active, + schedules, + startsOn, + endsOn, + oneShot, + asNeeded, + trackIntake, + reminderGraceMinutes: topLevelGraceMinutes, + } = edit; + + // A custom category must be one of this record's own (#1041). + if ( + category !== undefined && + !(await isAssignableCategory(user.id, category)) + ) { + return { + ok: false, + message: "Unknown medication category", + status: 422, + meta: { errorCode: "medications.category.unknown" }, + }; + } + + // Intake tracking transition (#1033). Off keeps the schedule as a record; + // the live era from here on expects nothing. + const wasTracked = existing.trackIntake !== false; + const willTrack = trackIntake ?? wasTracked; + const trackingChanged = wasTracked !== willTrack; + + const asNeededRefusal = refuseAsNeededShape(existing, asNeeded, schedules); + if (asNeededRefusal) return asNeededRefusal; + + const primaryScheduleGracePatch = await findPrimaryScheduleGracePatch( + id, + topLevelGraceMinutes, + schedules, + ); + + const oneShotRefusal = refuseOneShotShape(oneShot, schedules); + if (oneShotRefusal) return oneShotRefusal; + + // Normalise endsOn for one-shot. `oneShot === true` + `startsOn` + // means the dose is the start date; endsOn auto-matches. + const normalisedEndsOn = oneShot === true && startsOn ? startsOn : endsOn; + + const touchesWindow = + startsOn !== undefined || + normalisedEndsOn !== undefined || + oneShot !== undefined; + const nextWindow = { + startsOn: startsOn !== undefined ? startsOn : existing.startsOn, + endsOn: normalisedEndsOn !== undefined ? normalisedEndsOn : existing.endsOn, + }; + const courseTz = user.timezone || DEFAULT_TIMEZONE; + if (touchesWindow) { + const refusal = await refuseCourseWindow({ + userId: user.id, + medicationId: id, + timeZone: courseTz, + nextWindow, + oneShot: oneShot ?? existing.oneShot, + createdAt: existing.createdAt, + }); + if (refusal) return refusal; + } + + const pausedAtPatch = + active === undefined + ? {} + : active + ? { pausedAt: null as Date | null } + : existing.active + ? { pausedAt: new Date() } + : {}; + + return { + ok: true, + primaryScheduleGracePatch, + write: { + user, + id, + existing, + input: edit, + normalisedSchedules: normaliseSchedules(schedules, oneShot), + wasTracked, + willTrack, + trackingChanged, + pausedAtPatch, + touchesWindow, + nextWindow, + courseTz, + trail: createEraTrail(), + }, + }; +} + +/** + * Refresh each dose day the tombstoned slots sat on, now that the tombstones + * are committed, then apply the primary-schedule grace bridge. Both run + * after the transaction so a failure here doesn't roll the edit back. + */ +async function settleAfterCommit( + write: MedicationEditWrite, + medication: EditedMedication, + primaryScheduleGracePatch: GracePatch | null, +): Promise { + const { user, id, trail } = write; + // Best-effort like every dose write-hook: a failure is reported, never + // fails the edit. + for (const dayKey of trail.staleDoseDays) { + await refreshDoseDay({ + userId: user.id, + medicationId: id, + dayKey, + tz: user.timezone || DEFAULT_TIMEZONE, + }); + } + + // Re-read the row so the response shape carries the converged grace value. + if (primaryScheduleGracePatch) { + await prisma.medicationSchedule.update({ + where: { id: primaryScheduleGracePatch.scheduleId }, + data: { + reminderGraceMinutes: primaryScheduleGracePatch.reminderGraceMinutes, + }, + }); + const refreshed = await prisma.medication.findUnique({ + where: { id }, + include: { schedules: true }, + }); + if (refreshed) { + return refreshed; + } + } + return medication; +} + +/** + * An iPhone app build that predates custom categories (#1041) decodes an + * unknown category as OTHER and sends it back on every edit, which would + * overwrite the person's own category with OTHER. A native client (Bearer) + * sending OTHER over a custom category keeps the custom one; moving a custom + * category back to OTHER stays possible on the web. + */ +async function writeCategory( + id: string, + category: string | undefined, + authMethod: string, +) { + let categoryToWrite = category; + if (category === "OTHER" && authMethod === "bearer") { + const current = (await resolveMedicationCategories([id]))[id]; + if (current?.category.startsWith("custom:")) { + categoryToWrite = undefined; + annotate({ + action: { name: "medication.update.custom_category_kept" }, + meta: { entity_id: id }, + }); + } + } + if (categoryToWrite !== undefined) + await setMedicationCategory(id, categoryToWrite); + return (await resolveMedicationCategories([id]))[id]; +} + +/** The audit row: C4 on the medication row itself, C7 on the schedule. */ +async function auditMedicationEdit( + write: MedicationEditWrite, + medication: EditedMedication, + ipAddress: string | null, +) { + const { user, id, existing, trail } = write; + const { + replacedCadence, + replacedScheduleCount, + tombstonedSlotCount, + tombstonedFrom, + tombstonedTo, + }: EraTrail = trail; + await auditLog("medication.update", { + userId: user.id, + ipAddress, + // C4 on what the medication row itself lost, and C7 on the schedule: + // the cadence that was replaced, and how many pending slots went with + // it over which days. + details: { + medicationId: id, + ...overwriteDetails({ + before: { + name: existing.name, + dose: existing.dose, + active: existing.active, + asNeeded: existing.asNeeded, + trackIntake: existing.trackIntake, + // Switching automatic recording on or off is a change + // to how doses are recorded, so the row names it. + autoMarkTaken: existing.autoMarkTaken, + endsOn: existing.endsOn, + }, + after: { + name: medication.name, + dose: medication.dose, + active: medication.active, + asNeeded: medication.asNeeded, + trackIntake: medication.trackIntake, + autoMarkTaken: medication.autoMarkTaken, + endsOn: medication.endsOn, + }, + }), + ...(replacedScheduleCount !== null + ? { + replacedCadence, + replacedScheduleCount, + tombstonedSlotCount, + tombstonedFrom: tombstonedFrom?.toISOString() ?? null, + tombstonedTo: tombstonedTo?.toISOString() ?? null, + } + : {}), + }, + }); +} + +/** Edit one medication the route found and authorised. */ +export async function updateMedication(input: { + user: EditUser; + authMethod: string; + id: string; + existing: EditableMedication; + data: MedicationEdit; + ipAddress: string | null; +}): Promise { + const { user, id } = input; + const plan = await planMedicationEdit(input); + if (!plan.ok) return plan; + const { write } = plan; + + let medication: EditedMedication; + try { + medication = await writeMedicationEdit(write); + } catch (err) { + // A concurrent course write landed between the check above and the + // course write. The transaction rolled back: nothing was written. + if (err instanceof CourseWriteError) { + const r = courseRefusalResponse(err.refusal); + return { + ok: false, + message: r.message, + status: r.status, + meta: { errorCode: r.errorCode }, + }; + } + throw err; + } + + medication = await settleAfterCommit( + write, + medication, + plan.primaryScheduleGracePatch, + ); + + const resolvedCategory = await writeCategory( + id, + write.input.category, + input.authMethod, + ); + const normalizedCategory = resolvedCategory?.category ?? "OTHER"; + + // A changed units-per-dose (on the medication or on a slot) changes what + // every taken dose added, so a medication with a composition has its doses + // recomputed. Cheap and coalesced when nothing moved. + if (await medicationHasComposition(user.id, id)) { + await enqueueSupplementNutrientRecompute(user.id); + } + + await auditMedicationEdit(write, medication, input.ipAddress); + + annotate({ + action: { + name: "medication.update", + entity_type: "medication", + entity_id: id, + }, + }); + + // Bust per-user medications + compliance + achievement caches so the next + // read reflects the schedule change. + invalidateUserMedications(user.id, { evict: true }); + + return { + ok: true, + body: { + ...medication, + unitsPerDose: Number(medication.unitsPerDose), + ...scheduleWireFields( + medication.trackIntake, + serializeScheduleUnitsPerDose( + medication.schedules, + medication.unitsPerDose, + ), + ), + category: normalizedCategory, + categoryLabel: resolvedCategory?.categoryLabel ?? null, + // The courses, as every medication read carries them (#1024). + ...( + await resolveCourseFields([medication], new Date(), write.courseTz) + ).get(id), + }, + }; +} diff --git a/src/lib/medications/update-medication/write.ts b/src/lib/medications/update-medication/write.ts new file mode 100644 index 000000000..c15b69575 --- /dev/null +++ b/src/lib/medications/update-medication/write.ts @@ -0,0 +1,455 @@ +/** + * The write of a medication edit: everything the PUT changes on the + * medication, its schedule and its course commits as one transaction. + * + * The course write can still be refused after the preflight check (another + * request wrote a course in between); with the row update committed first, + * the client got the refusal while the name, the schedule and `oneShot` were + * already changed. Inside one transaction a refusal rolls all of it back. The + * dose-day refresh and the grace bridge stay outside: both read or write what + * this commit settles, and neither may undo it. + */ +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import type { SessionUser } from "@/lib/auth/session-user"; +import { setCurrentWindow } from "@/lib/medications/courses"; +import { + schedulesMateriallyDiffer, + toRevisionPayloadEntry, +} from "@/lib/medications/scheduling/schedule-eras"; +import { moveAutoTakeFloorToNow } from "@/lib/medications/auto-take"; +import { dayKeyForScheduledFor } from "@/lib/medications/dose-day-refresh"; +import { getUserTodayBounds } from "@/lib/tz/local-day"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import type { EditableMedication } from "./preflight"; +import type { MedicationEdit, NormalisedSchedule } from "./schedule-normalise"; + +/** + * C7. What a schedule replacement destroys, captured for the audit row: the + * cadence that was there and the pending slots that were tombstoned with it. + * A feed line saying "changed a medication" cannot answer "back to what?", + * and unlike a row a schedule keeps ACTING after the manager's grant ends. + * `staleDoseDays` holds the dose days the tombstoned slots sat on, refreshed + * once the transaction has committed the tombstones. + */ +export interface EraTrail { + replacedCadence: string | null; + replacedScheduleCount: number | null; + tombstonedSlotCount: number | null; + tombstonedFrom: Date | null; + tombstonedTo: Date | null; + staleDoseDays: Set; +} + +export function createEraTrail(): EraTrail { + return { + replacedCadence: null, + replacedScheduleCount: null, + tombstonedSlotCount: null, + tombstonedFrom: null, + tombstonedTo: null, + staleDoseDays: new Set(), + }; +} + +export interface MedicationEditWrite { + user: Pick; + id: string; + existing: EditableMedication; + input: MedicationEdit; + normalisedSchedules: NormalisedSchedule[] | undefined; + wasTracked: boolean; + willTrack: boolean; + trackingChanged: boolean; + pausedAtPatch: { pausedAt?: Date | null }; + touchesWindow: boolean; + nextWindow: { startsOn: Date | null; endsOn: Date | null }; + courseTz: string; + trail: EraTrail; +} + +type RevisionSourceRow = Parameters[0]; + +/** + * Tombstone today's and future open pending rows of this medication. Runs on + * a schedule replace (the old anchors are stale) and when intake tracking is + * switched off (nothing is due any more). + */ +async function tombstoneOpenSlots( + db: Prisma.TransactionClient, + edit: MedicationEditWrite, +): Promise { + const { user, id, trail } = edit; + // A schedule replace invalidates the open slot anchors the projector / + // reminder worker minted for the OLD times: a pending 08:00 row for a + // medication that now doses at 20:00 would linger as a phantom slot, + // auto-miss later, and depress compliance. Tombstone today's and + // future open pending rows (never an actioned row — `takenAt`, + // `skipped`, and `autoMissed` are user-visible history) and bump + // `syncVersion` so delta-sync clients drop them. The projector and + // the reminder worker re-mint the anchors for the new times on their + // next pass — the route does not pre-create them. + const userTz = user.timezone || DEFAULT_TIMEZONE; + const { start: todayStart } = getUserTodayBounds(new Date(), userTz); + const tombstoneWhere = { + userId: user.id, + medicationId: id, + deletedAt: null, + takenAt: null, + skipped: false, + autoMissed: false, + scheduledFor: { gte: todayStart }, + } as const; + // Capture the affected slots BEFORE the tombstone so the compliance + // rollups for those days can be recomputed after the commit — + // `updateMany` does not return the touched rows. Without the + // recompute a tombstoned pending kept counting as `scheduled` in + // the rollup row until the next event-driven write for that day, + // depressing the rate the cards read in the meantime. + const tombstoned = await db.medicationIntakeEvent.findMany({ + where: tombstoneWhere, + select: { scheduledFor: true }, + }); + // The count and the day range of the anchors that went. + trail.tombstonedSlotCount = tombstoned.length; + for (const row of tombstoned) { + if ( + trail.tombstonedFrom === null || + row.scheduledFor < trail.tombstonedFrom + ) { + trail.tombstonedFrom = row.scheduledFor; + } + if (trail.tombstonedTo === null || row.scheduledFor > trail.tombstonedTo) { + trail.tombstonedTo = row.scheduledFor; + } + } + await db.medicationIntakeEvent.updateMany({ + where: tombstoneWhere, + data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, + }); + // Deduped via the user-tz day key. + for (const row of tombstoned) { + trail.staleDoseDays.add(dayKeyForScheduledFor(row.scheduledFor, userTz)); + } +} + +function revisionEntryOf(row: RevisionSourceRow) { + return toRevisionPayloadEntry({ + timesOfDay: row.timesOfDay, + windowStart: row.windowStart, + windowEnd: row.windowEnd, + daysOfWeek: row.daysOfWeek, + rrule: row.rrule, + rollingIntervalDays: row.rollingIntervalDays, + scheduleType: row.scheduleType, + cyclicOnWeeks: row.cyclicOnWeeks, + cyclicOffWeeks: row.cyclicOffWeeks, + doseWindows: row.doseWindows, + label: row.label, + dose: row.dose, + reminderGraceMinutes: row.reminderGraceMinutes, + }); +} + +/** One archived era covering `[previous validUntil | createdAt, now)`. */ +async function archiveEra( + tx: Prisma.TransactionClient, + edit: MedicationEditWrite, + payload: unknown[], + meta: Record, +): Promise { + const { id, existing } = edit; + const lastRevision = await tx.medicationScheduleRevision.findFirst({ + // Chain from the ACTIVE boundary: a superseded row is an + // audit record whose `validUntil` may sit past its correction. + where: { medicationId: id, supersededByRevisionId: null }, + orderBy: { validUntil: "desc" }, + select: { validUntil: true }, + }); + await tx.medicationScheduleRevision.create({ + data: { + medicationId: id, + validFrom: lastRevision?.validUntil ?? existing.createdAt, + validUntil: new Date(), + payload: payload as unknown as Prisma.InputJsonValue, + }, + }); + annotate({ + action: { + name: "medication.schedule.revision_archived", + entity_type: "medication", + entity_id: id, + }, + meta, + }); +} + +/** The cadence a schedule replace destroys, as one comparable line. */ +function cadenceOf(previousRows: RevisionSourceRow[]): string | null { + return previousRows.length > 0 + ? previousRows + .map((row) => + [ + row.timesOfDay, + row.rrule ?? "", + row.rollingIntervalDays !== null + ? `every ${row.rollingIntervalDays}d` + : "", + row.daysOfWeek ?? "", + ] + .filter((part) => part !== "") + .join(" "), + ) + .join(" | ") + : null; +} + +/** + * A schedule replace. Effective dating: before the wholesale replace wipes + * the old rows, archive them as ONE revision covering `[previous validUntil | + * medication.createdAt, now)` — but only when a cadence-relevant field + * actually changes. A no-op edit (the Zeitplan tab echoing the same rows + * back) must not mint a phantom era. + */ +async function replaceSchedules( + tx: Prisma.TransactionClient, + edit: MedicationEditWrite, + normalisedSchedules: NormalisedSchedule[], +): Promise { + const { id, existing, input, wasTracked, trackingChanged, trail } = edit; + const previousRows = await tx.medicationSchedule.findMany({ + where: { medicationId: id }, + }); + const previousEntries = previousRows.map(revisionEntryOf); + trail.replacedScheduleCount = previousRows.length; + trail.replacedCadence = cadenceOf(previousRows); + + // A stretch with intake tracking off expected nothing (#1033), so it + // archives with an empty payload whatever the rows said; and a tracking + // transition is itself an era boundary even when the rows stay the same. + const archivedEntries = wasTracked ? previousEntries : []; + if ( + previousRows.length > 0 && + (trackingChanged || + !wasTracked || + schedulesMateriallyDiffer( + previousEntries, + normalisedSchedules.map((n) => n.snapshot), + )) + ) { + await archiveEra(tx, edit, archivedEntries, { + schedule_revision_rows: archivedEntries.length, + }); + } else if ( + previousRows.length === 0 && + existing.asNeeded && + input.asNeeded === false && + normalisedSchedules.length > 0 + ) { + // Flipping as-needed OFF. The medication carried ZERO + // schedule rows while as-needed, so the wholesale-replace archive + // above never fires — and without a revision the live era would + // start at the PREVIOUS revision's `validUntil` (or `createdAt`), + // retro-painting the schedule-less as-needed stretch with the NEW + // schedule's expected slots: every PRN day would read as missed. + // Archive an EMPTY era covering the as-needed stretch instead; an + // empty payload expands to zero schedules, so era-aware compliance + // expects nothing there — exactly the as-needed contract. + await archiveEra(tx, edit, [], { schedule_revision_rows: 0 }); + } + await tombstoneOpenSlots(tx, edit); + await tx.medicationSchedule.deleteMany({ + where: { medicationId: id }, + }); +} + +/** + * Intake tracking switched without a schedule replace (#1033). Close the + * current era at this instant: switching OFF archives the schedule that was + * in force (the tracked stretch keeps its expected doses); switching back ON + * archives the untracked stretch with an empty payload, so compliance never + * counts it as missed and the live schedule, the projector and the reminder + * worker all resume from now. + */ +async function archiveTrackingSwitch( + tx: Prisma.TransactionClient, + edit: MedicationEditWrite, +): Promise { + const { id, wasTracked, willTrack } = edit; + const liveRows = await tx.medicationSchedule.findMany({ + where: { medicationId: id }, + }); + if (liveRows.length > 0) { + const archivedEntries = wasTracked ? liveRows.map(revisionEntryOf) : []; + await archiveEra(tx, edit, archivedEntries, { + schedule_revision_rows: archivedEntries.length, + track_intake: willTrack, + }); + } +} + +/** The medication row's update, field-by-field from the parsed body. */ +function medicationUpdateData(edit: MedicationEditWrite) { + const { existing, input, normalisedSchedules } = edit; + const { + name, + dose, + treatmentClass, + dosesPerUnit, + unitsPerDose, + reorderLeadDays, + deliveryForm, + trackInjectionSites, + allowedInjectionSites, + active, + notificationsEnabled, + liveActivityEnabled, + criticalAlarmEnabled, + atcCode, + rxNormCode, + oneShot, + asNeeded, + trackIntake, + autoMarkTaken, + } = input; + return { + ...(name !== undefined && { name }), + ...(dose !== undefined && { dose }), + ...(treatmentClass !== undefined && { treatmentClass }), + ...(dosesPerUnit !== undefined && { dosesPerUnit }), + // Units consumed per dose. Field-by-field; the stamp on + // already-taken intake events freezes their recorded consumption. + ...(unitsPerDose !== undefined && { unitsPerDose }), + // Reorder lead override. Field-by-field; `null` is a + // valid explicit value (clears the override → user-level default). + ...(reorderLeadDays !== undefined && { reorderLeadDays }), + ...(deliveryForm !== undefined && { deliveryForm }), + // 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 }), + // iOS reminder flags, field-by-field. + ...(liveActivityEnabled !== undefined && { liveActivityEnabled }), + ...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }), + // Optional drug-classification codes (ATC / RxNorm). + // Field-by-field; `null` is a valid explicit value (clears the code). + ...(atcCode !== undefined && { atcCode }), + ...(rxNormCode !== undefined && { rxNormCode }), + // Scheduling primitives — pass-through when supplied. + // `startsOn` / `endsOn` are not written here (#1024): the + // course writer below writes them together with the course they + // project, in one transaction, so the row and its courses cannot + // disagree if one of the two writes fails. + ...(oneShot !== undefined && { oneShot }), + // As-needed flag, field-by-field (the invariants above + // already guaranteed the medication ends schedule-consistent). + ...(asNeeded !== undefined && { asNeeded }), + // Intake tracking, field-by-field (#1033). + ...(trackIntake !== undefined && { trackIntake }), + // Auto-mark-taken, field-by-field. Only an off→on flip + // stamps `autoMarkTakenSince`, so re-saving the form with the + // option already on never moves the floor; switching it off + // clears the stamp. + ...(autoMarkTaken !== undefined && + autoMarkTaken !== existing.autoMarkTaken && { + autoMarkTaken, + autoMarkTakenSince: autoMarkTaken ? new Date() : null, + }), + ...(normalisedSchedules && { + schedules: { + create: normalisedSchedules.map((n) => n.createData), + }, + }), + }; +} + +/** + * Durable pause intervals. The `pausedAt` column is a single live marker that + * resume clears, so the paused window is irrecoverable once resumed and every + * paused day's slot collapses to "missed" in the compliance denominator. + * Mirror the live transition onto an additive era record the rate paths read: + * a pause opens an era (resumedAt null), a resume closes the latest open era. + * `userId` comes from the authenticated session; the data object is built + * field-by-field. + */ +async function recordPauseTransition( + tx: Prisma.TransactionClient, + edit: MedicationEditWrite, +): Promise { + const { user, id, existing } = edit; + const { active } = edit.input; + if (active === false && existing.active) { + await tx.medicationPauseEra.create({ + data: { + medicationId: id, + userId: user.id, + pausedAt: new Date(), + resumedAt: null, + }, + }); + } else if (active === true && !existing.active) { + await tx.medicationPauseEra.updateMany({ + where: { medicationId: id, userId: user.id, resumedAt: null }, + data: { resumedAt: new Date() }, + }); + // Made active again: the auto-take pass starts from + // now, never filling the inactive days as taken. + await moveAutoTakeFloorToNow(tx, id); + } +} + +/** + * The transaction. Throws `CourseWriteError` when a concurrent course write + * landed between the preflight check and the course write; nothing is then + * written. + */ +export async function writeMedicationEdit(edit: MedicationEditWrite) { + const { user, id, input, normalisedSchedules, trackingChanged, willTrack } = + edit; + const { schedules } = input; + return prisma.$transaction(async (tx) => { + // If schedules provided, replace all + if (schedules && normalisedSchedules) { + await replaceSchedules(tx, edit, normalisedSchedules); + } else if (trackingChanged) { + await archiveTrackingSwitch(tx, edit); + } + + // Switching intake tracking off leaves nothing due (#1033): + // the open placeholders of today and later go the way a schedule + // replace sends them (a replace in the same request already did it). + if (trackingChanged && !willTrack && !schedules) { + await tombstoneOpenSlots(tx, edit); + } + + const baseUpdateData = medicationUpdateData(edit); + + const updated = await tx.medication.update({ + where: { id }, + data: { ...baseUpdateData, ...edit.pausedAtPatch }, + include: { schedules: true }, + }); + + await recordPauseTransition(tx, edit); + + // Write the window onto the latest course and + // project it back, then serve the projected row (#1024). + if (edit.touchesWindow) { + const projected = await setCurrentWindow( + { + userId: user.id, + medicationId: id, + timeZone: edit.courseTz, + ...edit.nextWindow, + }, + tx, + ); + return { ...updated, ...projected }; + } + return updated; + }); +}