diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index 5545404eb..67d9fe8f5 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -114,6 +114,11 @@ const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) }; beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // The PUT writes inside one interactive transaction; run its callback + // against the same mocked client. + vi.mocked(prisma.$transaction).mockImplementation((async ( + fn: (tx: typeof prisma) => unknown, + ) => fn(prisma)) as never); // v1.40 — the category and course helpers' defaults, which // `resetAllMocks` clears. vi.mocked(resolveCourseFields).mockResolvedValue(new Map()); @@ -203,9 +208,9 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => { vi.mocked(auditLog).mockResolvedValue(undefined); const res = await PUT(putReq({ endsOn: "2026-12-31" }), ROUTE_CTX); expect(res.status).toBe(200); - // v1.40 (#1024) — the window goes through the course writer, which - // writes the row and the latest course in one transaction; the plain - // row update no longer carries it. + // v1.40 (#1024) — the window goes through the course writer, inside the + // same transaction as the row update; the plain row update no longer + // carries it. const call = lastUpdateCall(); expect(call.data).not.toHaveProperty("endsOn"); expect(setCurrentWindow).toHaveBeenCalledWith( @@ -213,6 +218,7 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => { medicationId: "m1", endsOn: new Date("2026-12-31"), }), + prisma, ); }); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index c30e57891..f7e62e198 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -667,7 +667,12 @@ export const PUT = apiHandler( // 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). - const tombstoneOpenSlots = async (): Promise => { + // The rollup days the tombstoned slots sat on, recomputed once the + // transaction below has committed the tombstones. + const staleRollupDays = 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, @@ -689,12 +694,12 @@ export const PUT = apiHandler( scheduledFor: { gte: todayStart }, } as const; // Capture the affected slots BEFORE the tombstone so the compliance - // rollups for those days can be recomputed immediately below — + // 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 prisma.medicationIntakeEvent.findMany({ + const tombstoned = await db.medicationIntakeEvent.findMany({ where: tombstoneWhere, select: { scheduledFor: true }, }); @@ -708,332 +713,369 @@ export const PUT = apiHandler( tombstonedTo = row.scheduledFor; } } - await prisma.medicationIntakeEvent.updateMany({ + await db.medicationIntakeEvent.updateMany({ where: tombstoneWhere, data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, }); - // Recompute each affected rollup day (deduped via the user-tz day - // key). Best-effort like every other rollup write-hook: a failure - // is annotated, never blocks the schedule replace. - const staleDayKeys = new Set( - tombstoned.map((row) => - dayKeyForScheduledFor(row.scheduledFor, userTz), - ), - ); - for (const dayKey of staleDayKeys) { - try { - await recomputeMedicationComplianceForDay( - user.id, - id, - dayKey, - userTz, + // Deduped via the user-tz day key. + for (const row of tombstoned) { + staleRollupDays.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, + }), ); - } catch (err) { - annotate({ - meta: { - medication_compliance_rollup_failed: true, - medication_compliance_rollup_error: - err instanceof Error ? err.message : String(err), + 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 }), + ...(normalisedSchedules && { + schedules: { + create: normalisedSchedules.map((n) => n.createData), + }, + }), + }; + + const withoutNotifications = { ...baseUpdateData } as Record< + string, + unknown + >; + delete withoutNotifications.notificationsEnabled; + const hasPausedAtPatch = Object.keys(pausedAtPatch).length > 0; + const hasNotificationsPatch = notificationsEnabled !== undefined; + + const updateCandidates: Array> = [ + { ...baseUpdateData, ...pausedAtPatch }, + ]; + if (hasPausedAtPatch) { + updateCandidates.push(baseUpdateData); + } + if (hasNotificationsPatch) { + updateCandidates.push({ ...withoutNotifications, ...pausedAtPatch }); + if (hasPausedAtPatch) { + updateCandidates.push(withoutNotifications); + } + } + + let updated; + let lastUpdateErr: unknown; + for (const candidate of updateCandidates) { + try { + updated = await tx.medication.update({ + where: { id }, + data: candidate, + include: { schedules: true }, + }); + break; + } catch (updateErr) { + lastUpdateErr = updateErr; + } + } + if (!updated) throw lastUpdateErr; + + // v1.25 H-MED1 — 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() }, + }); } - } - }; - // 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 prisma.medicationSchedule.findMany({ - where: { medicationId: 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; }); - 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 prisma.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 prisma.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 prisma.medicationScheduleRevision.findFirst({ - where: { medicationId: id, supersededByRevisionId: null }, - orderBy: { validUntil: "desc" }, - select: { validUntil: true }, - }); - await prisma.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(); - await prisma.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 prisma.medicationSchedule.findMany({ - where: { medicationId: id }, - }); - if (liveRows.length > 0) { - const lastRevision = await prisma.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 prisma.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, - }, - }); + } 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; } - // 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(); - } - - 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 }), - ...(normalisedSchedules && { - schedules: { - create: normalisedSchedules.map((n) => n.createData), - }, - }), - }; - - const withoutNotifications = { ...baseUpdateData } as Record< - string, - unknown - >; - delete withoutNotifications.notificationsEnabled; - const hasPausedAtPatch = Object.keys(pausedAtPatch).length > 0; - const hasNotificationsPatch = notificationsEnabled !== undefined; - - const updateCandidates: Array> = [ - { ...baseUpdateData, ...pausedAtPatch }, - ]; - if (hasPausedAtPatch) { - updateCandidates.push(baseUpdateData); - } - if (hasNotificationsPatch) { - updateCandidates.push({ ...withoutNotifications, ...pausedAtPatch }); - if (hasPausedAtPatch) { - updateCandidates.push(withoutNotifications); - } - } - - let medication; - let lastUpdateErr: unknown; - for (const candidate of updateCandidates) { + // Recompute each rollup day the tombstoned slots sat on, now that the + // tombstones are committed. Best-effort like every other rollup + // write-hook: a failure is annotated, never fails the edit. + for (const dayKey of staleRollupDays) { try { - medication = await prisma.medication.update({ - where: { id }, - data: candidate, - include: { schedules: true }, + await recomputeMedicationComplianceForDay( + user.id, + id, + dayKey, + user.timezone || DEFAULT_TIMEZONE, + ); + } catch (err) { + annotate({ + meta: { + medication_compliance_rollup_failed: true, + medication_compliance_rollup_error: + err instanceof Error ? err.message : String(err), + }, }); - break; - } catch (updateErr) { - lastUpdateErr = updateErr; } } - if (!medication) throw lastUpdateErr; - - // v1.25 H-MED1 — 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 prisma.medicationPauseEra.create({ - data: { - medicationId: id, - userId: user.id, - pausedAt: new Date(), - resumedAt: null, - }, - }); - } else if (active === true && !existing.active) { - await prisma.medicationPauseEra.updateMany({ - where: { medicationId: id, userId: user.id, resumedAt: null }, - data: { resumedAt: new Date() }, - }); - } // Apply the v1.5.5 primary-schedule grace bridge after the // medication update so a failure here doesn't roll the medication @@ -1055,29 +1097,6 @@ export const PUT = apiHandler( } } - // v1.40 (#1024) — write the window onto the latest course and project it - // back, then serve the projected row. - if (touchesWindow) { - let projected; - try { - projected = await setCurrentWindow({ - userId: user.id, - medicationId: id, - timeZone: courseTz, - ...nextWindow, - }); - } catch (err) { - // A concurrent course write landed between the check above and this - // transaction. Nothing of the window was written. - if (err instanceof CourseWriteError) { - const r = courseRefusalResponse(err.refusal); - return apiError(r.message, r.status, { errorCode: r.errorCode }); - } - throw err; - } - medication = { ...medication, ...projected }; - } - // 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 diff --git a/src/lib/medications/courses.ts b/src/lib/medications/courses.ts index 760c5baee..4abd7b7d0 100644 --- a/src/lib/medications/courses.ts +++ b/src/lib/medications/courses.ts @@ -323,57 +323,69 @@ function windowAsCourse( * Clearing both on a medication with one course deletes it (chronic again, * the pre-course meaning of no dates); on one with several it is refused, * because it would silently drop the history. + * + * `tx` runs the write inside the caller's transaction, so the PUT's row + * update and this course write commit or roll back together: a refusal here + * then leaves nothing of the PUT behind. */ export async function setCurrentWindow( args: WriteContext & { startsOn: Date | null; endsOn: Date | null }, + tx?: Tx, +): Promise { + return tx + ? writeCurrentWindow(tx, args) + : prisma.$transaction((own) => writeCurrentWindow(own, args)); +} + +async function writeCurrentWindow( + tx: Tx, + args: WriteContext & { startsOn: Date | null; endsOn: Date | null }, ): Promise { const todayKey = userDayKey(args.now ?? new Date(), args.timeZone); - return prisma.$transaction(async (tx) => { - await lockMedication(tx, args.medicationId); - const { medication, courses } = await loadOwned( - tx, - args.userId, - args.medicationId, - ); - const latest = sortCourses(courses).at(-1); + await lockMedication(tx, args.medicationId); + const { medication, courses } = await loadOwned( + tx, + args.userId, + args.medicationId, + ); + const latest = sortCourses(courses).at(-1); - if (args.startsOn === null && args.endsOn === null) { - if (!latest) return project(tx, args.medicationId); - if (courses.length > 1) throw new CourseWriteError("windowRequired"); - await tx.medicationCourse.delete({ where: { id: latest.id } }); - return project(tx, args.medicationId); - } - - const next = windowAsCourse( - args.startsOn, - args.endsOn, - latest, - medication.createdAt, - args.timeZone, - ); - - if (latest) { - refuseIfInvalid( - courses.map((c) => (c.id === latest.id ? next : c)), - todayKey, - medication.oneShot, - ); - await tx.medicationCourse.update({ - where: { id: latest.id }, - data: next, - }); - } else { - refuseIfInvalid([next], todayKey, medication.oneShot); - await tx.medicationCourse.create({ - data: { - medicationId: args.medicationId, - userId: args.userId, - ...next, - }, - }); - } + if (args.startsOn === null && args.endsOn === null) { + if (!latest) return project(tx, args.medicationId); + if (courses.length > 1) throw new CourseWriteError("windowRequired"); + await tx.medicationCourse.delete({ where: { id: latest.id } }); return project(tx, args.medicationId); - }); + } + + const next = windowAsCourse( + args.startsOn, + args.endsOn, + latest, + medication.createdAt, + args.timeZone, + ); + + if (latest) { + refuseIfInvalid( + courses.map((c) => (c.id === latest.id ? next : c)), + todayKey, + medication.oneShot, + ); + await tx.medicationCourse.update({ + where: { id: latest.id }, + data: next, + }); + } else { + refuseIfInvalid([next], todayKey, medication.oneShot); + await tx.medicationCourse.create({ + data: { + medicationId: args.medicationId, + userId: args.userId, + ...next, + }, + }); + } + return project(tx, args.medicationId); } /** diff --git a/tests/integration/medication-put-course-race.test.ts b/tests/integration/medication-put-course-race.test.ts new file mode 100644 index 000000000..5b6410394 --- /dev/null +++ b/tests/integration/medication-put-course-race.test.ts @@ -0,0 +1,152 @@ +/** + * A medication PUT the course writer refuses changes nothing. + * + * The PUT checks the window against the other courses first, then writes the + * row, then writes the course. A course written by another request between + * the check and the course write makes the course write refuse. If the row + * write had already committed, the client got an error while the name, the + * schedule and `oneShot` were changed anyway: a one-time medication with a + * window that spans more than its one day. + * + * The race is reproduced by letting the up-front check pass (it is the check + * the other request outran), so the real course writer meets the conflicting + * courses and refuses, through the shipped route and a real Postgres. + * + * Mutation: move the row update back out of the transaction the course write + * runs in, and the name and `oneShot` assertions turn red. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const OWNER = "user-med-put-race"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +// The check the concurrent request outran: it saw no conflict. +vi.mock("@/lib/medications/courses", async (importOriginal) => ({ + ...(await importOriginal()), + checkCurrentWindow: vi.fn(async () => null), +})); + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + cookieJar.clear(); + headerJar.clear(); + await prisma.user.create({ + data: { + id: OWNER, + username: OWNER, + email: `${OWNER}@example.test`, + timezone: "UTC", + }, + }); + const session = await prisma.session.create({ + data: { userId: OWNER, expiresAt: new Date(Date.now() + 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +function req(method: string, path: string, body?: unknown) { + return new NextRequest(`http://localhost${path}`, { + method, + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +describe("a medication PUT refused by the course writer", () => { + it("leaves the medication row, its schedule and its courses as they were", async () => { + const prisma = getPrismaClient(); + const { POST } = await import("@/app/api/medications/route"); + const created = await POST( + req("POST", "/api/medications", { + name: "Tamiflu", + dose: "75 mg", + startsOn: "2026-03-01", + endsOn: "2026-03-07", + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }), + ); + expect(created.status).toBe(201); + const { id } = (await created.json()).data as { id: string }; + + // The course the other request wrote. + const { POST: postCourse } = + await import("@/app/api/medications/[id]/courses/route"); + const course = await postCourse( + req("POST", `/api/medications/${id}/courses`, { + startsOn: "2026-06-10", + endsOn: "2026-06-16", + }), + { params: Promise.resolve({ id }) }, + ); + expect(course.status).toBe(201); + + const before = await prisma.medication.findUniqueOrThrow({ + where: { id }, + include: { schedules: true, courses: { orderBy: { startsOn: "asc" } } }, + }); + const revisionsBefore = await prisma.medicationScheduleRevision.count({ + where: { medicationId: id }, + }); + + // A one-time medication cannot carry two courses: the writer refuses. + const { PUT } = await import("@/app/api/medications/[id]/route"); + const res = await PUT( + req("PUT", `/api/medications/${id}`, { + name: "Oseltamivir", + oneShot: true, + startsOn: "2026-03-05", + schedules: [{ windowStart: "20:00", windowEnd: "21:00" }], + }), + { params: Promise.resolve({ id }) }, + ); + expect(res.status).toBe(422); + expect((await res.json()).meta.errorCode).toMatch(/^medications\.course\./); + + const after = await prisma.medication.findUniqueOrThrow({ + where: { id }, + include: { schedules: true, courses: { orderBy: { startsOn: "asc" } } }, + }); + expect(after.name).toBe("Tamiflu"); + expect(after.oneShot).toBe(false); + expect(after.startsOn).toEqual(before.startsOn); + expect(after.endsOn).toEqual(before.endsOn); + expect(after.updatedAt).toEqual(before.updatedAt); + expect(after.schedules.map((s) => s.id)).toEqual( + before.schedules.map((s) => s.id), + ); + expect(after.schedules.map((s) => s.windowStart)).toEqual(["08:00"]); + expect(after.courses.map((c) => [c.startsOn, c.endsOn])).toEqual( + before.courses.map((c) => [c.startsOn, c.endsOn]), + ); + expect( + await prisma.medicationScheduleRevision.count({ + where: { medicationId: id }, + }), + ).toBe(revisionsBefore); + }); +});