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, + ); }); });