diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 71cf8aa1d..579e25967 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -644,6 +644,26 @@ const handler = apiHandler( : {}), })), }, + // The titration steps, nested for the same reason. The note + // takes the measurement contract next to it: ciphertext when + // the file has it, legacy plaintext encrypted on the way in, + // and the plaintext column left null either way. + doseChanges: { + create: m.doseChanges.map((d) => ({ + ...(d.id ? { id: d.id } : {}), + effectiveFrom: new Date(d.effectiveFrom), + doseValue: d.doseValue, + doseUnit: d.doseUnit, + note: null, + noteEncrypted: + d.noteEncrypted == null + ? encryptNote(d.note ?? null) + : decodeEncryptedBytes(d.noteEncrypted), + ...(d.createdAt + ? { createdAt: new Date(d.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 eb19b32fa..4a776c329 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -38,6 +38,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 doseChangeNote = encryptToBytes("titration note, encrypted at rest"); const moodNotePlaintext = "quiet evening, slept badly"; const moodNote = encryptToBytes(moodNotePlaintext); @@ -180,6 +181,28 @@ function makePrisma() { createdAt: new Date("2026-07-15T08:01:00.000Z"), }, ], + // A two-step ramp, one row carrying an encrypted note and one + // without, so both arms of the note contract are exercised. + doseChanges: [ + { + id: "dose-ramp-up", + effectiveFrom: new Date("2026-04-01T08:00:00.000Z"), + doseValue: 5, + doseUnit: "mg", + note: null, + noteEncrypted: doseChangeNote, + createdAt: new Date("2026-04-01T08:01:00.000Z"), + }, + { + id: "dose-ramp-hold", + effectiveFrom: new Date("2026-06-01T08:00:00.000Z"), + doseValue: 10, + doseUnit: "mg", + note: null, + noteEncrypted: null, + createdAt: new Date("2026-06-01T08:01:00.000Z"), + }, + ], }, ]), }, diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 5ca34a214..16367a481 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -250,6 +250,7 @@ export const TWO_ENDED_MODELS = [ "MedicationIntakeEvent", "MedicationSideEffect", "MedicationPauseEra", + "MedicationDoseChange", "MoodEntry", "MoodContext", "MoodEntryTagLink", @@ -343,8 +344,6 @@ export const STRUCTURALLY_UNATTRIBUTABLE: Readonly> = { export const COVERAGE_PENDING: Readonly> = { MedicationScheduleRevision: "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.", 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 62fcdb55f..c34e73696 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -76,6 +76,7 @@ export interface FullBackupCounts intakeEvents: number; medicationSideEffects: number; medicationPauseEras: number; + medicationDoseChanges: number; moodEntries: number; cycles: number; cycleDayLogs: number; @@ -358,6 +359,7 @@ export async function buildFullBackupPayload( schedules: true, sideEffects: { orderBy: { occurredAt: "desc" } }, pauseEras: { orderBy: { pausedAt: "asc" } }, + doseChanges: { orderBy: { effectiveFrom: "asc" } }, }, }), prisma.medicationIntakeEvent.findMany({ @@ -577,6 +579,29 @@ export async function buildFullBackupPayload( resumedAt: p.resumedAt ? p.resumedAt.toISOString() : null, createdAt: p.createdAt.toISOString(), })), + // Titration: when the dose moved, to what, and why. The drug comes + // back at today's dose either way; without these the ramp that led to + // it is gone, and that ramp is what a prescriber asks about. + // + // The note follows the side-effect contract beside it: 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. + doseChanges: m.doseChanges.map((d) => ({ + ...(disasterRecovery + ? { + id: d.id, + note: d.note, + noteEncrypted: d.noteEncrypted + ? Buffer.from(d.noteEncrypted).toString("base64") + : null, + } + : { note: readNote(d.noteEncrypted, d.note) }), + effectiveFrom: d.effectiveFrom.toISOString(), + doseValue: d.doseValue, + doseUnit: d.doseUnit, + createdAt: d.createdAt.toISOString(), + })), })), intakeEvents: intakeEvents.map((e) => ({ ...(disasterRecovery @@ -740,6 +765,10 @@ export async function buildFullBackupPayload( (total, m) => total + m.pauseEras.length, 0, ), + medicationDoseChanges: medications.reduce( + (total, m) => total + m.doseChanges.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 fc96f9641..092741828 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -184,6 +184,24 @@ const medicationPauseEraSchema = z }) .passthrough(); +/** + * One step of a titration: when the dose moved and to what. + * + * The note follows the side-effect contract — decrypted prose in a portable + * file, ciphertext plus any legacy plaintext in a recovery one. + */ +const medicationDoseChangeSchema = z + .object({ + id: z.string().min(1).optional(), + effectiveFrom: isoDateTime, + doseValue: z.number(), + doseUnit: z.string().min(1), + note: z.string().nullable().optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), + createdAt: isoDateTime.optional(), + }) + .passthrough(); + const medicationSchema = z .object({ id: z.string().min(1).optional(), @@ -221,6 +239,9 @@ const medicationSchema = z // 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([]), + // Same default, same reason: an older file parses, a drug never titrated + // writes []. + doseChanges: z.array(medicationDoseChangeSchema).default([]), }) .passthrough(); diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 2b32903a7..f1670215e 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -82,6 +82,7 @@ vi.mock("@/lib/cache/invalidate", () => ({ const OWNER_ID = "round-trip-owner"; const AT = (iso: string) => new Date(iso); +const DOSE_CHANGE_NOTE = "titration note, encrypted at rest"; const SIDE_EFFECT_NOTE = "nausea for two hours after the evening dose"; beforeEach(async () => { @@ -107,6 +108,9 @@ const COUNT_BACK: Record< p.medicationSideEffect.count({ where: { userId } }), MedicationPauseEra: (p, userId) => p.medicationPauseEra.count({ where: { userId } }), + // No own `userId` column — reached through the drug, like the schedules. + MedicationDoseChange: (p, userId) => + p.medicationDoseChange.count({ where: { medication: { userId } } }), MoodEntry: (p, userId) => p.moodEntry.count({ where: { userId } }), MoodContext: (p, userId) => p.moodContext.count({ where: { userId } }), MoodEntryTagLink: (p, userId) => @@ -230,6 +234,27 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { // 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. + // A two-step ramp. The ORDER is the content here: same drug, same unit, + // 5 mg then 10 mg. A restore that returned them reversed would still count + // two rows back and would describe the opposite clinical story. + await prisma.medicationDoseChange.createMany({ + data: [ + { + medicationId: medication.id, + effectiveFrom: AT("2026-04-01T08:00:00.000Z"), + doseValue: 5, + doseUnit: "mg", + noteEncrypted: encryptToBytes(DOSE_CHANGE_NOTE), + }, + { + medicationId: medication.id, + effectiveFrom: AT("2026-06-01T08:00:00.000Z"), + doseValue: 10, + doseUnit: "mg", + noteEncrypted: null, + }, + ], + }); await prisma.medicationPauseEra.createMany({ data: [ { @@ -732,6 +757,39 @@ describe("every model the plan claims two-ended survives a real restore", () => { pausedAt: "2026-07-15T08:00:00.000Z", resumedAt: null }, ]); + // The ramp, in order, with the note back in its column. Counting says two + // rows returned; only this says they came back as the same ramp, and that + // the encrypted note did not land in the plaintext column on the way. + const doses = await prisma.medicationDoseChange.findMany({ + where: { medication: { userId: OWNER_ID } }, + orderBy: { effectiveFrom: "asc" }, + }); + expect( + doses.map((d) => ({ + effectiveFrom: d.effectiveFrom.toISOString(), + doseValue: d.doseValue, + doseUnit: d.doseUnit, + note: readNote(d.noteEncrypted, d.note), + plaintextColumn: d.note, + })), + "the titration must come back as the same ramp, in order", + ).toEqual([ + { + effectiveFrom: "2026-04-01T08:00:00.000Z", + doseValue: 5, + doseUnit: "mg", + note: DOSE_CHANGE_NOTE, + plaintextColumn: null, + }, + { + effectiveFrom: "2026-06-01T08:00:00.000Z", + doseValue: 10, + doseUnit: "mg", + note: null, + plaintextColumn: 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