From 967fa2c56bac6e8404e0c4a138d65794a89a8420 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 14:10:17 +0200 Subject: [PATCH] refactor(medications): move the medication read, edit and delete out of their route GET, PUT and DELETE /api/medications/{id} keep authentication, the ownership guard, the body and the HTTP mapping. The read moves to lib/medications/read-medication.ts; the edit, one 886-line callback, moves to lib/medications/update-medication/, split along its stages in the order they always ran: the preflight checks and refusals, the schedule normalisation, the transaction (era archive, tombstones, the row update, the pause era, the course write) and the commit tail with its audit row. The delete moves to delete-medication.ts. The route falls from 1270 to 100 lines; no function in the new modules is over 115. The parity walk recorded before the move (medication-detail.parity) is unchanged. The sharing guard reads the edit and delete modules for the handler body, the intake-tracking allowlists name the modules that now read the schedule and the open slots, the published-DTO leg reads the read module, and the dense-read guard keeps the moved code in scope. --- .../dense-measurement-read-guard.test.ts | 3 + .../medication-intake-tracking-guard.test.ts | 12 +- src/__tests__/sharing-surface-guard.test.ts | 4 + src/app/api/medications/[id]/route.ts | 1234 +---------------- .../__tests__/auto-mark-taken.test.ts | 2 +- src/lib/medications/delete-medication.ts | 77 + src/lib/medications/read-medication.ts | 277 ++++ .../update-medication/preflight.ts | 273 ++++ .../update-medication/schedule-normalise.ts | 157 +++ .../update-medication/update-medication.ts | 387 ++++++ .../medications/update-medication/write.ts | 455 ++++++ 11 files changed, 1676 insertions(+), 1205 deletions(-) create mode 100644 src/lib/medications/delete-medication.ts create mode 100644 src/lib/medications/read-medication.ts create mode 100644 src/lib/medications/update-medication/preflight.ts create mode 100644 src/lib/medications/update-medication/schedule-normalise.ts create mode 100644 src/lib/medications/update-medication/update-medication.ts create mode 100644 src/lib/medications/update-medication/write.ts 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; + }); +}