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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 14:10:17 +02:00
1 parent e8510ae167
commit 967fa2c56b
11 files changed
+1676 -1205

No files matched your search

@@ -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. */
@@ -70,8 +70,8 @@ const INTAKE_MAY_SEE_EVERY_ROW: Record<string, string> = {
"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<string, string> = {
"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
@@ -435,6 +435,10 @@ function callsSymbol(rel: string, symbol: string): boolean {
*/
const HANDLER_BODY_MODULES: Record<string, readonly string[]> = {
"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",
File diff suppressed because it is too large. Load diff
@@ -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\(/,
+77
View File
@@ -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<void> {
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);
}
+277
View File
@@ -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<SessionUser, "id" | "timezone" | "locale">;
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<Record<string, unknown> | 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,
}),
};
}
@@ -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<EditableMedication | null> {
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<GracePatch | null> {
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<MedicationEditRefusal | null> {
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;
}
@@ -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<typeof updateMedicationSchema>;
type ScheduleInput = NonNullable<MedicationEdit["schedules"]>[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<typeof normaliseSchedule>;
/** 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));
}
@@ -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<SessionUser, "id" | "timezone">;
type EditedMedication = Awaited<ReturnType<typeof writeMedicationEdit>>;
export type MedicationEditOutcome =
MedicationEditRefusal | { ok: true; body: Record<string, unknown> };
/**
* 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<EditedMedication> {
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<MedicationEditOutcome> {
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),
},
};
}
@@ -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<string>;
}
export function createEraTrail(): EraTrail {
return {
replacedCadence: null,
replacedScheduleCount: null,
tombstonedSlotCount: null,
tombstonedFrom: null,
tombstonedTo: null,
staleDoseDays: new Set<string>(),
};
}
export interface MedicationEditWrite {
user: Pick<SessionUser, "id" | "timezone">;
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<typeof toRevisionPayloadEntry>[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<void> {
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<string, unknown>,
): Promise<void> {
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<void> {
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<void> {
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<void> {
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;
});
}