diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 20506c963..71cf8aa1d 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -624,6 +624,26 @@ const handler = apiHandler( : {}), })), }, + // Nested for the same reason as the two above — the FK binds + // to the id this drug actually got, so a portable file that + // mints fresh ids needs no remap. + // + // `resumedAt` passes through as null when the era is open. It + // is not defaulted to the restore time: a pause that was still + // running when the backup was taken is still running after the + // restore, and closing it here would silently convert an + // ongoing decision into a historical one. + pauseEras: { + create: m.pauseEras.map((p) => ({ + ...(p.id ? { id: p.id } : {}), + userId: ownerId, + pausedAt: new Date(p.pausedAt), + resumedAt: p.resumedAt ? new Date(p.resumedAt) : null, + ...(p.createdAt + ? { createdAt: new Date(p.createdAt) } + : {}), + })), + }, }, }); restoredMedicationIds.add(created.id); diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 0992c9f81..eb19b32fa 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -161,6 +161,25 @@ function makePrisma() { createdAt: new Date("2026-07-17T21:05:00.000Z"), }, ], + // One closed era and one still running. The open one is why + // `resumedAt` is nullable rather than absent: a reader treats null + // as "paused right now" and runs the span to the present, so a + // payload that dropped the distinction would report a resumption + // that never happened. + pauseEras: [ + { + id: "pause-closed", + pausedAt: new Date("2026-05-02T08:00:00.000Z"), + resumedAt: new Date("2026-05-20T08:00:00.000Z"), + createdAt: new Date("2026-05-02T08:01:00.000Z"), + }, + { + id: "pause-open", + pausedAt: new Date("2026-07-15T08:00:00.000Z"), + resumedAt: null, + createdAt: new Date("2026-07-15T08:01:00.000Z"), + }, + ], }, ]), }, diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 2302b88f4..5ca34a214 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -249,6 +249,7 @@ export const TWO_ENDED_MODELS = [ "MedicationSchedule", "MedicationIntakeEvent", "MedicationSideEffect", + "MedicationPauseEra", "MoodEntry", "MoodContext", "MoodEntryTagLink", @@ -344,8 +345,6 @@ export const COVERAGE_PENDING: Readonly> = { "The history of how a schedule changed. Without it a restored medication keeps today's schedule and loses the record of when the dose or timing moved, which is the part a doctor asks about.", MedicationDoseChange: "Titration history — when a dose went up or down and by how much. A restore rebuilds the current dose and drops the ramp that led to it.", - MedicationPauseEra: - "The spans where a medication was deliberately paused. Without them the compliance recomputation counts a deliberate pause as missed doses and the restored account looks non-adherent.", MedicationEfficacyTarget: "What a medication was supposed to move, and by how much. The drug comes back with no statement of what it was for.", MedicationInventoryItem: diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index af51b9d1d..62fcdb55f 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -75,6 +75,7 @@ export interface FullBackupCounts medications: number; intakeEvents: number; medicationSideEffects: number; + medicationPauseEras: number; moodEntries: number; cycles: number; cycleDayLogs: number; @@ -356,6 +357,7 @@ export async function buildFullBackupPayload( include: { schedules: true, sideEffects: { orderBy: { occurredAt: "desc" } }, + pauseEras: { orderBy: { pausedAt: "asc" } }, }, }), prisma.medicationIntakeEvent.findMany({ @@ -556,6 +558,25 @@ export async function buildFullBackupPayload( entry: s.entry, severity: s.severity, })), + // Nested for the same reason the two above are: a pause era is bound to + // its medication by id, and a nested create binds it to whatever id the + // parent actually got. + // + // These spans are what separates "stopped taking it" from "was told to + // stop taking it". Without them the compliance recomputation counts a + // deliberate pause as missed doses, so a restored account reads as + // non-adherent for a stretch during which it did exactly what it was + // told. Nothing else in the payload can reconstruct them: an era is a + // decision, not an observation, and no intake row records its absence. + // + // `resumedAt` stays nullable end to end — an open era means the + // medication is still paused, and readers run it to `now`. + pauseEras: m.pauseEras.map((p) => ({ + ...(disasterRecovery ? { id: p.id } : {}), + pausedAt: p.pausedAt.toISOString(), + resumedAt: p.resumedAt ? p.resumedAt.toISOString() : null, + createdAt: p.createdAt.toISOString(), + })), })), intakeEvents: intakeEvents.map((e) => ({ ...(disasterRecovery @@ -715,6 +736,10 @@ export async function buildFullBackupPayload( (total, m) => total + m.sideEffects.length, 0, ), + medicationPauseEras: medications.reduce( + (total, m) => total + m.pauseEras.length, + 0, + ), moodEntries: moodEntries.length, cycles: cycle.cycles.length, cycleDayLogs: cycle.cycleDayLogs.length, diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 7b5c4c109..fc96f9641 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -167,6 +167,23 @@ const medicationSideEffectSchema = z }) .passthrough(); +/** + * A span during which the drug was deliberately not taken. + * + * `resumedAt` is nullable rather than optional, and the writer always emits + * it. An absent key and an explicit `null` would otherwise both have to mean + * "still paused", and a file that simply forgot the field would assert an open + * era that never existed. Readers run an open era to `now`. + */ +const medicationPauseEraSchema = z + .object({ + id: z.string().min(1).optional(), + pausedAt: isoDateTime, + resumedAt: isoDateTime.nullable(), + createdAt: isoDateTime.optional(), + }) + .passthrough(); + const medicationSchema = z .object({ id: z.string().min(1).optional(), @@ -201,6 +218,9 @@ const medicationSchema = z // Defaulted so a file written before side effects rode the wire still // parses, and a drug with none writes []. sideEffects: z.array(medicationSideEffectSchema).default([]), + // Defaulted for the same reason: a file written before pause eras rode the + // wire still parses, and a drug that was never paused writes []. + pauseEras: z.array(medicationPauseEraSchema).default([]), }) .passthrough(); diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 2923d738b..2b32903a7 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -105,6 +105,8 @@ const COUNT_BACK: Record< p.medicationIntakeEvent.count({ where: { userId } }), MedicationSideEffect: (p, userId) => p.medicationSideEffect.count({ where: { userId } }), + MedicationPauseEra: (p, userId) => + p.medicationPauseEra.count({ where: { userId } }), MoodEntry: (p, userId) => p.moodEntry.count({ where: { userId } }), MoodContext: (p, userId) => p.moodContext.count({ where: { userId } }), MoodEntryTagLink: (p, userId) => @@ -224,6 +226,26 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { notesEncrypted: encryptToBytes(SIDE_EFFECT_NOTE), }, }); + // Two eras, and the open one carries the weight. `resumedAt: null` means the + // drug is paused right now; a restore that closed it at restore time would + // still count one row back here and read as recovered. The field-level + // assertion at the end of the test is what holds the null. + await prisma.medicationPauseEra.createMany({ + data: [ + { + userId: OWNER_ID, + medicationId: medication.id, + pausedAt: AT("2026-05-02T08:00:00.000Z"), + resumedAt: AT("2026-05-20T08:00:00.000Z"), + }, + { + userId: OWNER_ID, + medicationId: medication.id, + pausedAt: AT("2026-07-15T08:00:00.000Z"), + resumedAt: null, + }, + ], + }); // A RATED tag the account defined itself — the export carries only rated // links, so a BINARY tag would leave `MoodEntryTagLink` empty. @@ -685,6 +707,31 @@ describe("every model the plan claims two-ended survives a real restore", () => null, ); + // Both pause eras, and the open one specifically. The count above only + // says two rows returned; a restore that closed the running pause at + // restore time would satisfy it exactly, and the account would come back + // saying a medication it is still off had been resumed — which the + // compliance recomputation then reads as doses missed on purpose-taken + // days. `null` is the assertion that matters here. + const eras = await prisma.medicationPauseEra.findMany({ + where: { userId: OWNER_ID }, + orderBy: { pausedAt: "asc" }, + select: { pausedAt: true, resumedAt: true }, + }); + expect( + eras.map((e) => ({ + pausedAt: e.pausedAt.toISOString(), + resumedAt: e.resumedAt ? e.resumedAt.toISOString() : null, + })), + "an open pause era must come back open", + ).toEqual([ + { + pausedAt: "2026-05-02T08:00:00.000Z", + resumedAt: "2026-05-20T08:00:00.000Z", + }, + { pausedAt: "2026-07-15T08:00:00.000Z", resumedAt: null }, + ]); + // The dose, read back column by column. The count above says a row // returned; a restore that wrote one row with every optional column // defaulted would satisfy it and hand back an Impfpass line with no