From 48f4cba3aa5a78ec0384b758b39944ed1d21c9ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 21 Aug 2026 22:01:51 +0200 Subject: [PATCH] Carry medication pause eras through backup and restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MedicationPauseEra` was classified as backed up and travelled nowhere. The spans where a drug was deliberately paused are the difference between "stopped taking it" and "was told to stop taking it", and nothing else in the payload can reconstruct them: an era is a decision, not an observation, and no intake row records its own absence. Without them the compliance recomputation reads a deliberate pause as missed doses, so a restored account looks non-adherent for a stretch during which it did exactly what it was told. Both ends land together. The eras ride nested inside their medication, like the schedules and side effects beside them, so a portable file that mints fresh medication ids needs no remap — Prisma binds each child to whatever id the parent actually got. ## The open era is the point `resumedAt` is nullable end to end and the writer always emits it. An open era means the drug is paused right now, and readers run it to the present. The restore passes the null through rather than closing it at restore time: a pause that was still running when the backup was taken is still running afterwards, and closing it would silently turn an ongoing decision into a historical one. Counting alone cannot catch that — a restore that closed the era would still hand back two rows. So the round-trip test seeds one closed era and one open one and asserts both field by field. ## What is proven and what is not Moving the model out of `COVERAGE_PENDING` and onto `TWO_ENDED_MODELS` makes the round-trip test's registry incomplete, which is a compile error. Verified by watching `tsc` fail with TS2741 before the count-back was written: the coupling the plan's comment describes does hold. The round-trip test itself needs Postgres via testcontainers and has not been run here. Typecheck, lint, format and the 21458-test unit suite are green; the integration proof comes from CI. --- .../api/admin/backups/[id]/restore/route.ts | 20 ++++++++ .../__tests__/full-backup-payload.test.ts | 19 ++++++++ src/lib/export/backup-plan.ts | 3 +- src/lib/export/full-backup-payload.ts | 25 ++++++++++ src/lib/validations/backup.ts | 20 ++++++++ tests/integration/backup-round-trip.test.ts | 47 +++++++++++++++++++ 6 files changed, 132 insertions(+), 2 deletions(-) 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