From 75644bc3d27643db82f50a1a8d1faaaa41a1bac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 21:15:45 +0200 Subject: [PATCH] Carry recorded side effects through the backup and back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MedicationSideEffect was classified as backed up and was not carried: the export wrote nothing and the restore had nothing to read, so a restored account kept the drug and lost the reason someone may have stopped taking it. Delegated write access sharpens that — a side effect is one of the things a second person can contribute, and it was the contribution a restore dropped. The rows ride inside their medication, like the schedules beside them. A top-level array would have to carry `medicationId` and survive a restore that mints a fresh id for a portable file, which is the remap the intake events already work around by drug name; a nested create has no id to remap, because Prisma binds the child to whatever id the parent row actually got. The note follows the measurement contract for the same dual-column arrangement: a portable export carries the decrypted note and no ciphertext, a disaster-recovery payload carries the ciphertext verbatim, and the restore re-encrypts a legacy plaintext row rather than writing plaintext back into the column. Move the model from COVERAGE_PENDING to TWO_ENDED_MODELS, and settle the claim where a written verdict cannot: the round trip seeds a side effect, exports through the real builder, empties the account and reads the row back out of the real restore route with its category, entry, severity and note intact. Both halves were excised to check the test can fail — the restore branch turns the count red, the payload turns the field values red. --- .../api/admin/backups/[id]/restore/route.ts | 35 +++++- src/app/api/export/encrypted/route.ts | 1 + src/app/api/export/full-backup/route.ts | 1 + src/components/admin/backups-section.tsx | 4 + .../__tests__/full-backup-payload.test.ts | 108 ++++++++++++++++++ src/lib/export/backup-plan.ts | 3 +- src/lib/export/full-backup-payload.ts | 39 ++++++- src/lib/validations/backup.ts | 33 ++++++ .../admin-backups-canonical-roundtrip.test.ts | 22 ++++ tests/integration/backup-round-trip.test.ts | 63 +++++++++- 10 files changed, 297 insertions(+), 12 deletions(-) diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 8afd9e765..3c22417f3 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -2,10 +2,11 @@ * POST /api/admin/backups/[id]/restore — admin-only disaster recovery. * * After owner and schema validation, one transaction replaces every - * serialized owner-scoped class: measurements, medication history, mood and - * rated factors, cycle data, labs/biomarkers, illness history, allergies, - * family history, workout summaries, and inbound documents. Document content - * and summary ciphertext are decoded from base64 and persisted verbatim. + * serialized owner-scoped class: measurements, medication history including the + * side effects recorded against a drug, mood and rated factors, cycle data, + * labs/biomarkers, illness history, allergies, family history, workout + * summaries, and inbound documents. Document content and summary ciphertext are + * decoded from base64 and persisted verbatim. * * Metadata-only portable document exports are rejected before mutation; the * importer never fabricates content. Audit rows remain outside the wipe, and @@ -584,6 +585,32 @@ const handler = apiHandler( doseWindows: (s.doseWindows ?? null) as never, })), }, + // Written in the same `create` as the drug, so the FK binds to + // the id this row actually got. A canonical DR file preserves + // the medication's id and a portable one mints a fresh cuid; + // neither case needs the old `medicationId` from the file, + // which is why the payload does not carry it. The note follows + // the measurement contract next to it: ciphertext verbatim when + // the file has it, legacy plaintext encrypted on the way in, + // and the plaintext column left null either way. + sideEffects: { + create: m.sideEffects.map((s) => ({ + ...(s.id ? { id: s.id } : {}), + userId: ownerId, + occurredAt: new Date(s.occurredAt), + category: s.category, + entry: s.entry, + severity: s.severity, + notes: null, + notesEncrypted: + s.notesEncrypted == null + ? encryptNote(s.notes ?? null) + : decodeEncryptedBytes(s.notesEncrypted), + ...(s.createdAt + ? { createdAt: new Date(s.createdAt) } + : {}), + })), + }, }, }); restoredMedicationIds.add(created.id); diff --git a/src/app/api/export/encrypted/route.ts b/src/app/api/export/encrypted/route.ts index 1fc1d6748..e68f4f987 100644 --- a/src/app/api/export/encrypted/route.ts +++ b/src/app/api/export/encrypted/route.ts @@ -105,6 +105,7 @@ export const POST = apiHandler(async (request: NextRequest) => { export_measurements_count: counts.measurements, export_medications_count: counts.medications, export_intake_count: counts.intakeEvents, + export_medication_side_effect_count: counts.medicationSideEffects, export_mood_count: counts.moodEntries, export_cycle_count: counts.cycles, export_cycle_day_log_count: counts.cycleDayLogs, diff --git a/src/app/api/export/full-backup/route.ts b/src/app/api/export/full-backup/route.ts index c2ac96575..c7e83a262 100644 --- a/src/app/api/export/full-backup/route.ts +++ b/src/app/api/export/full-backup/route.ts @@ -64,6 +64,7 @@ export const GET = apiHandler(async (request: NextRequest) => { export_measurements_count: counts.measurements, export_medications_count: counts.medications, export_intake_count: counts.intakeEvents, + export_medication_side_effect_count: counts.medicationSideEffects, export_mood_count: counts.moodEntries, export_cycle_count: counts.cycles, export_cycle_day_log_count: counts.cycleDayLogs, diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index a231ed196..2df37779f 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -324,6 +324,9 @@ export function BackupsSection() { medications: number; intakeEvents: number; moodEntries: number; + // Optional for the same reason as the keys below: a file written + // before side effects rode the wire carries no key at all. + medicationSideEffects?: number; cycles?: number; cycleDayLogs?: number; // v1.28 backup-completeness — optional so an older-schema upload @@ -354,6 +357,7 @@ export function BackupsSection() { data.summary.medications + data.summary.intakeEvents + data.summary.moodEntries + + (data.summary.medicationSideEffects ?? 0) + (data.summary.cycles ?? 0) + (data.summary.cycleDayLogs ?? 0) + (data.summary.labResults ?? 0) + diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 810174ed8..4d76bc57e 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -37,6 +37,7 @@ import { restoreProfileData } from "../profile-backup"; const deletedAt = new Date("2026-07-19T12:00:00.000Z"); const measurementNote = encryptToBytes("canonical measurement note"); +const sideEffectNote = encryptToBytes("nausea after the evening dose"); const appSettings = { id: "singleton", @@ -133,6 +134,29 @@ function makePrisma() { ], }, ], + sideEffects: [ + { + id: "side-effect-canonical", + occurredAt: new Date("2026-07-18T21:00:00.000Z"), + category: "GI", + entry: "NAUSEA", + severity: 3, + notes: null, + notesEncrypted: sideEffectNote, + createdAt: new Date("2026-07-18T21:05:00.000Z"), + }, + { + // A row the note backfill has not reached: plaintext only. + id: "side-effect-legacy", + occurredAt: new Date("2026-07-17T21:00:00.000Z"), + category: "COGNITIVE", + entry: "DIZZINESS", + severity: 1, + notes: "legacy plaintext note", + notesEncrypted: null, + createdAt: new Date("2026-07-17T21:05:00.000Z"), + }, + ], }, ]), }, @@ -492,6 +516,90 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { }); }); +/** + * Side effects ride inside their medication, and the note rides with them. + * + * The two shapes differ on purpose: a portable export is the human-readable + * artefact, so it carries the DECRYPTED note and no ciphertext at all; the + * disaster-recovery file carries the ciphertext verbatim so the same instance's + * key reads it back unchanged. Both are asserted, because emitting ciphertext + * into a portable export and losing the note are opposite failures of the same + * decision. + */ +describe("buildFullBackupPayload — medication side effects", () => { + it("decrypts the note and emits no ciphertext in a portable export", async () => { + installSectionMocks(); + + const { payload, counts } = await buildFullBackupPayload( + makePrisma() as never, + "user-1", + { + purpose: "portable-export", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }, + ); + const parsed = parseBackupPayload(payload); + + expect(parsed.medications[0].sideEffects).toEqual([ + { + occurredAt: "2026-07-18T21:00:00.000Z", + category: "GI", + entry: "NAUSEA", + severity: 3, + notes: "nausea after the evening dose", + }, + { + occurredAt: "2026-07-17T21:00:00.000Z", + category: "COGNITIVE", + entry: "DIZZINESS", + severity: 1, + notes: "legacy plaintext note", + }, + ]); + expect(JSON.stringify(payload)).not.toContain( + Buffer.from(sideEffectNote).toString("base64"), + ); + expect(counts.medicationSideEffects).toBe(2); + }); + + it("carries the ciphertext verbatim in a disaster-recovery payload", async () => { + installSectionMocks(); + + const { payload } = await buildFullBackupPayload( + makePrisma() as never, + "user-1", + { + purpose: "disaster-recovery", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }, + ); + const parsed = parseBackupPayload(payload); + + expect(parsed.medications[0].sideEffects).toEqual([ + { + id: "side-effect-canonical", + occurredAt: "2026-07-18T21:00:00.000Z", + category: "GI", + entry: "NAUSEA", + severity: 3, + notes: null, + notesEncrypted: Buffer.from(sideEffectNote).toString("base64"), + createdAt: "2026-07-18T21:05:00.000Z", + }, + { + id: "side-effect-legacy", + occurredAt: "2026-07-17T21:00:00.000Z", + category: "COGNITIVE", + entry: "DIZZINESS", + severity: 1, + notes: "legacy plaintext note", + notesEncrypted: null, + createdAt: "2026-07-17T21:05:00.000Z", + }, + ]); + }); +}); + /** * `aggregationProvenance` is what stops an export.xml source-day estimate from * overwriting a native HealthKit statistic. A disaster-recovery backup that diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 13d9e01c9..be2bd5208 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -210,6 +210,7 @@ export const TWO_ENDED_MODELS = [ "Medication", "MedicationSchedule", "MedicationIntakeEvent", + "MedicationSideEffect", "MoodEntry", "MoodEntryTagLink", "MoodTag", @@ -276,8 +277,6 @@ export const COVERAGE_PENDING: Readonly> = { "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.", - MedicationSideEffect: - "Side effects the person recorded against a drug. Restoring the drug without them loses the reason someone may have stopped taking it.", 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 e0452a9f1..57ef86451 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -53,6 +53,7 @@ export interface FullBackupCounts measurements: number; medications: number; intakeEvents: number; + medicationSideEffects: number; moodEntries: number; cycles: number; cycleDayLogs: number; @@ -186,7 +187,17 @@ export async function buildFullBackupPayload( })(), prisma.medication.findMany({ where: { userId }, - include: { schedules: true }, + // Side effects ride INSIDE their medication, exactly as the schedules + // beside them do. The alternative — a top-level array carrying + // `medicationId` — would have to survive a restore that mints new + // medication ids for a portable file, which is the id-remap the intake + // events already have to work around by drug name. A nested create has + // no id to remap: Prisma binds the child to whatever id the parent row + // actually got. + include: { + schedules: true, + sideEffects: { orderBy: { occurredAt: "desc" } }, + }, }), prisma.medicationIntakeEvent.findMany({ where: disasterRecovery ? { userId } : { userId, deletedAt: null }, @@ -336,6 +347,28 @@ export async function buildFullBackupPayload( label: s.label, dose: s.dose, })), + // What the person recorded against the drug, and the reason a drug may + // have been stopped. `notes` is the same dual-column arrangement + // `Measurement` has and gets the same treatment: a portable export + // carries the DECRYPTED note and no ciphertext, a disaster-recovery + // payload carries the ciphertext verbatim plus whatever legacy plaintext + // the row still holds, and the restore re-encrypts the plaintext case. + sideEffects: m.sideEffects.map((s) => ({ + ...(disasterRecovery + ? { + id: s.id, + notes: s.notes, + notesEncrypted: s.notesEncrypted + ? Buffer.from(s.notesEncrypted).toString("base64") + : null, + createdAt: s.createdAt.toISOString(), + } + : { notes: readNote(s.notesEncrypted, s.notes) }), + occurredAt: s.occurredAt.toISOString(), + category: s.category, + entry: s.entry, + severity: s.severity, + })), })), intakeEvents: intakeEvents.map((e) => ({ ...(disasterRecovery @@ -428,6 +461,10 @@ export async function buildFullBackupPayload( measurements: measurements.length, medications: medications.length, intakeEvents: intakeEvents.length, + medicationSideEffects: medications.reduce( + (total, m) => total + m.sideEffects.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 07cf9d86c..b4afaf650 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -49,6 +49,8 @@ import { MedicationCategory, MedicationDeliveryForm, MedicationScheduleType, + MedicationSideEffectCategory, + MedicationSideEffectEntry, OvulationTest, RhythmClassification, SecondarySymptom, @@ -129,6 +131,28 @@ const medicationScheduleSchema = z }) .passthrough(); +/** + * One recorded side effect, carried inside its medication. + * + * `notes` is the decrypted note in a portable export and the row's legacy + * plaintext column in a canonical DR file; `notesEncrypted` is the base64 + * ciphertext and rides only in the DR case. The restore prefers the ciphertext + * and encrypts the plaintext when that is all the file has, so neither shape + * loses the note and neither writes plaintext back into the column. + */ +const medicationSideEffectSchema = z + .object({ + id: z.string().min(1).optional(), + occurredAt: isoDateTime, + category: z.enum(MedicationSideEffectCategory), + entry: z.enum(MedicationSideEffectEntry), + severity: z.number().int().min(1).max(5), + notes: z.string().nullable().optional(), + notesEncrypted: base64BytesSchema.nullable().optional(), + createdAt: isoDateTime.optional(), + }) + .passthrough(); + const medicationSchema = z .object({ id: z.string().min(1).optional(), @@ -160,6 +184,9 @@ const medicationSchema = z createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), schedules: z.array(medicationScheduleSchema).default([]), + // Defaulted so a file written before side effects rode the wire still + // parses, and a drug with none writes []. + sideEffects: z.array(medicationSideEffectSchema).default([]), }) .passthrough(); @@ -907,6 +934,8 @@ export interface BackupSummary { measurements: number; medications: number; intakeEvents: number; + /** Side effects recorded against a drug, across every medication. */ + medicationSideEffects: number; moodEntries: number; /** v1.15.0 — observed cycle spans in the backup. */ cycles: number; @@ -953,6 +982,10 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { measurements: payload.measurements.length, medications: payload.medications.length, intakeEvents: payload.intakeEvents.length, + medicationSideEffects: payload.medications.reduce( + (sum, medication) => sum + medication.sideEffects.length, + 0, + ), moodEntries: payload.moodEntries.length, cycles: payload.cycles.length, cycleDayLogs: payload.cycleDayLogs.length, diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index 50c4509c2..7a3818ac4 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -284,6 +284,19 @@ describe("canonical disaster-recovery backup round-trip", () => { }, include: { schedules: true }, }); + const sideEffect = await prisma.medicationSideEffect.create({ + data: { + id: "side-effect-dr", + userId: ownerId, + medicationId: medication.id, + occurredAt: new Date("2026-06-27T21:00:00.000Z"), + category: "INJECTION_SITE", + entry: "INJECTION_SWELLING", + severity: 4, + notesEncrypted: encryptToBytes("swollen for a day after the injection"), + createdAt: new Date("2026-06-27T21:10:00.000Z"), + }, + }); const intake = await prisma.medicationIntakeEvent.create({ data: { id: "intake-dr", @@ -583,6 +596,15 @@ describe("canonical disaster-recovery backup round-trip", () => { where: { id: intake.id }, }), ).toEqual(intake); + // Byte-for-byte, ciphertext included: a canonical payload carries the + // encrypted note verbatim, so the same instance's key reads back exactly + // what it wrote. Re-encrypting here would still decrypt to the same words + // and would still be a different row than the one the file described. + expect( + await prisma.medicationSideEffect.findUniqueOrThrow({ + where: { id: sideEffect.id }, + }), + ).toEqual(sideEffect); expect( await prisma.cycleProfile.findUniqueOrThrow({ where: { id: cycleProfile.id }, diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index e8058e90c..9a290d81b 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -26,11 +26,18 @@ * The registry below is keyed by `TwoEndedModel`, so a name added to the plan's * two-ended list without a row seeded and counted here does not compile. * - * What this still does not prove: that a restored row carries the right VALUES. - * It asks whether the rows came back, not whether they came back intact — - * `admin-backups-canonical-roundtrip.test.ts` is where field-level fidelity is - * asserted. A restore that wrote one row per model with every column defaulted - * would satisfy this file and fail that one. + * What this still does not prove, with one exception: that a restored row + * carries the right VALUES. It asks whether the rows came back, not whether + * they came back intact — `admin-backups-canonical-roundtrip.test.ts` is where + * field-level fidelity is asserted. A restore that wrote one row per model with + * every column defaulted would satisfy this file and fail that one. + * + * The exception is the medication side effect. Its note lives in an encrypted + * column beside a legacy plaintext one, so "the row came back" and "the note + * came back" are genuinely different answers here: a restore that dropped the + * ciphertext would still be counted as recovered by everything above. The + * severity, the category and the entry are asserted alongside it, because a + * side effect without them says something happened and not what. */ import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -40,6 +47,7 @@ process.env.ENCRYPTION_KEY ??= import type { PrismaClient } from "@/generated/prisma/client"; import { encrypt, encryptBytes } from "@/lib/crypto"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { readNote } from "@/lib/crypto/note-cipher"; import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan"; import { POST } from "@/app/api/admin/backups/[id]/restore/route"; @@ -74,6 +82,7 @@ vi.mock("@/lib/cache/invalidate", () => ({ const OWNER_ID = "round-trip-owner"; const AT = (iso: string) => new Date(iso); +const SIDE_EFFECT_NOTE = "nausea for two hours after the evening dose"; beforeEach(async () => { await truncateAllTables(getPrismaClient()); @@ -94,6 +103,8 @@ const COUNT_BACK: Record< p.medicationSchedule.count({ where: { medication: { userId } } }), MedicationIntakeEvent: (p, userId) => p.medicationIntakeEvent.count({ where: { userId } }), + MedicationSideEffect: (p, userId) => + p.medicationSideEffect.count({ where: { userId } }), MoodEntry: (p, userId) => p.moodEntry.count({ where: { userId } }), MoodEntryTagLink: (p, userId) => p.moodEntryTagLink.count({ where: { moodEntry: { userId } } }), @@ -183,6 +194,19 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { takenAt: AT("2026-07-01T08:04:00.000Z"), }, }); + // The one row in this fixture that is also checked field by field after the + // restore — see the assertion at the end of the test for why. + await prisma.medicationSideEffect.create({ + data: { + userId: OWNER_ID, + medicationId: medication.id, + occurredAt: AT("2026-07-01T21:00:00.000Z"), + category: "GI", + entry: "NAUSEA", + severity: 3, + notesEncrypted: encryptToBytes(SIDE_EFFECT_NOTE), + }, + }); // A RATED tag the account defined itself — the export carries only rated // links, so a BINARY tag would leave `MoodEntryTagLink` empty. @@ -477,5 +501,34 @@ describe("every model the plan claims two-ended survives a real restore", () => "without them, which is a backup that reports success and hands back " + "less than it was given", ).toEqual([]); + + const sideEffect = await prisma.medicationSideEffect.findFirstOrThrow({ + where: { userId: OWNER_ID }, + include: { medication: { select: { name: true, userId: true } } }, + }); + expect({ + category: sideEffect.category, + entry: sideEffect.entry, + severity: sideEffect.severity, + occurredAt: sideEffect.occurredAt.toISOString(), + medication: sideEffect.medication.name, + medicationOwner: sideEffect.medication.userId, + note: readNote(sideEffect.notesEncrypted, sideEffect.notes), + }).toEqual({ + category: "GI", + entry: "NAUSEA", + severity: 3, + occurredAt: "2026-07-01T21:00:00.000Z", + medication: "Round-trip tablet", + medicationOwner: OWNER_ID, + note: SIDE_EFFECT_NOTE, + }); + // The note must be BACK IN THE COLUMN it came from, not carried as + // plaintext: a restore that writes a portable export's decrypted note into + // `notes` reads identically through `readNote` above and has quietly + // un-encrypted the account's free text. + expect(sideEffect.notes, "plaintext must not come back in the column").toBe( + null, + ); }); });