diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index d92116869..7dd1c6afd 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -329,43 +329,141 @@ const handler = apiHandler( where: { userId: ownerId }, }); - // Stable ids are authoritative for canonical snapshots. Legacy payloads - // without ids fall back to the historic natural measurement key. - for (const measurement of payload.measurements) { - const measuredAt = new Date(measurement.measuredAt); - const source = measurement.source ?? "MANUAL"; - const restoredData = { - type: measurement.type, - value: measurement.value, - unit: measurement.unit, - source, - measuredAt, - notes: null, - notesEncrypted: encryptNote(measurement.notes ?? null), - deletedAt: measurement.deletedAt - ? new Date(measurement.deletedAt) + if (payload.appSettings) { + const settings = payload.appSettings; + const settingsData = { + registrationEnabled: settings.registrationEnabled, + mfaRequired: settings.mfaRequired, + defaultLocale: settings.defaultLocale, + telegramGlobal: settings.telegramGlobal, + ntfyGlobal: settings.ntfyGlobal, + webPushGlobal: settings.webPushGlobal, + webPushVapidPublicKey: settings.webPushVapidPublicKey, + webPushVapidPrivateKeyEncrypted: + settings.webPushVapidPrivateKeyEncrypted, + webPushVapidSubject: settings.webPushVapidSubject, + apiGlobal: settings.apiGlobal, + moodLogGlobal: settings.moodLogGlobal, + umamiEnabled: settings.umamiEnabled, + umamiScriptUrl: settings.umamiScriptUrl, + umamiWebsiteId: settings.umamiWebsiteId, + glitchtipEnabled: settings.glitchtipEnabled, + glitchtipDsn: settings.glitchtipDsn, + glitchtipEnvironment: settings.glitchtipEnvironment, + reminderLateMinutes: settings.reminderLateMinutes, + reminderMissedMinutes: settings.reminderMissedMinutes, + adminAiKeyEncrypted: settings.adminAiKeyEncrypted, + adminAiModel: settings.adminAiModel, + adminAiBaseUrl: settings.adminAiBaseUrl, + adminCodexAccessTokenEncrypted: + settings.adminCodexAccessTokenEncrypted, + adminCodexRefreshTokenEncrypted: + settings.adminCodexRefreshTokenEncrypted, + adminCodexAccountIdEncrypted: + settings.adminCodexAccountIdEncrypted, + adminCodexTokenExpiresAt: settings.adminCodexTokenExpiresAt + ? new Date(settings.adminCodexTokenExpiresAt) : null, + adminCodexConnectedAt: settings.adminCodexConnectedAt + ? new Date(settings.adminCodexConnectedAt) + : null, + adminCodexConnectionStatus: + settings.adminCodexConnectionStatus, + adminAiInsightsFeedbackSummary: + settings.adminAiInsightsFeedbackSummary as never, + defaultUserTimezone: settings.defaultUserTimezone, + assistantEnabled: settings.assistantEnabled, + assistantCoachEnabled: settings.assistantCoachEnabled, + assistantBriefingEnabled: settings.assistantBriefingEnabled, + assistantInsightStatusEnabled: + settings.assistantInsightStatusEnabled, + assistantCorrelationsEnabled: + settings.assistantCorrelationsEnabled, + assistantHealthScoreExplainerEnabled: + settings.assistantHealthScoreExplainerEnabled, + moduleAvailabilityJson: + settings.moduleAvailabilityJson as never, + documentMaxFileBytes: settings.documentMaxFileBytes, + documentQuotaBytes: BigInt(settings.documentQuotaBytes), }; + await tx.appSettings.upsert({ + where: { id: settings.id }, + create: { id: settings.id, ...settingsData }, + update: settingsData, + }); + } - if (measurement.id) { - await tx.measurement.upsert({ - where: { id: measurement.id, userId: ownerId }, - create: { - id: measurement.id, - userId: ownerId, - ...restoredData, - }, - update: restoredData, - }); - continue; - } + const toRestoredMeasurementData = ( + measurement: (typeof payload.measurements)[number], + ) => ({ + type: measurement.type, + value: measurement.value, + valueMin: measurement.valueMin ?? null, + valueMax: measurement.valueMax ?? null, + unit: measurement.unit, + source: measurement.source ?? "MANUAL", + measuredAt: new Date(measurement.measuredAt), + notes: measurement.notes ?? null, + notesEncrypted: + measurement.notesEncrypted !== undefined + ? measurement.notesEncrypted === null + ? null + : decodeEncryptedBytes(measurement.notesEncrypted) + : encryptNote(measurement.notes ?? null), + externalId: measurement.externalId ?? null, + externalSourceVersion: measurement.externalSourceVersion ?? null, + glucoseContext: (measurement.glucoseContext ?? null) as never, + sleepStage: (measurement.sleepStage ?? null) as never, + rhythmClassification: (measurement.rhythmClassification ?? + null) as never, + deviceType: measurement.deviceType ?? null, + syncVersion: measurement.syncVersion ?? 1, + deletedAt: measurement.deletedAt + ? new Date(measurement.deletedAt) + : null, + ...(measurement.createdAt + ? { createdAt: new Date(measurement.createdAt) } + : {}), + ...(measurement.updatedAt + ? { updatedAt: new Date(measurement.updatedAt) } + : {}), + }); + const stableRows = payload.measurements.flatMap((measurement) => + measurement.id + ? [ + { + id: measurement.id, + userId: ownerId, + ...toRestoredMeasurementData(measurement), + }, + ] + : [], + ); + const measurementBatchSize = 1_000; + for ( + let offset = 0; + offset < stableRows.length; + offset += measurementBatchSize + ) { + await tx.measurement.createMany({ + data: stableRows.slice(offset, offset + measurementBatchSize), + }); + } + + // v1 payloads did not require stable ids. Preserve their historical + // natural-key reconciliation without routing canonical v2 rows + // through it. + for (const measurement of payload.measurements) { + if (measurement.id) continue; + const restoredData = toRestoredMeasurementData(measurement); const existing = await tx.measurement.findFirst({ where: { userId: ownerId, type: measurement.type, - source, - measuredAt, + source: restoredData.source, + measuredAt: restoredData.measuredAt, + sleepStage: restoredData.sleepStage, }, select: { id: true }, }); @@ -381,50 +479,97 @@ const handler = apiHandler( } } - // Medications + schedules — restore one at a time so we can - // wire schedules to their parent's freshly-generated id, AND - // build a name → id map for the intake events that follow. const medByName = new Map(); + const restoredMedicationIds = new Set(); for (const m of payload.medications) { const created = await tx.medication.create({ data: { + ...(m.id ? { id: m.id } : {}), userId: ownerId, name: m.name, dose: m.dose, + treatmentClass: m.treatmentClass ?? "GENERIC", + dosesPerUnit: m.dosesPerUnit ?? null, + unitsPerDose: m.unitsPerDose ?? "1", active: m.active ?? true, + notificationsEnabled: m.notificationsEnabled ?? true, + pausedAt: m.pausedAt ? new Date(m.pausedAt) : null, + snoozedUntil: m.snoozedUntil + ? new Date(m.snoozedUntil) + : null, + startsOn: m.startsOn ? new Date(m.startsOn) : null, + endsOn: m.endsOn ? new Date(m.endsOn) : null, + oneShot: m.oneShot ?? false, + asNeeded: m.asNeeded ?? false, + deliveryForm: m.deliveryForm ?? "ORAL", + trackInjectionSites: m.trackInjectionSites ?? false, + allowedInjectionSites: m.allowedInjectionSites ?? [], + liveActivityEnabled: m.liveActivityEnabled ?? false, + criticalAlarmEnabled: m.criticalAlarmEnabled ?? false, + atcCode: m.atcCode ?? null, + rxNormCode: m.rxNormCode ?? null, + lowStockNotifiedAt: m.lowStockNotifiedAt + ? new Date(m.lowStockNotifiedAt) + : null, + lowStockNotifiedThresholdDays: + m.lowStockNotifiedThresholdDays ?? null, + reorderLeadDays: m.reorderLeadDays ?? null, + externalSource: m.externalSource ?? null, + externalId: m.externalId ?? null, + ...(m.createdAt ? { createdAt: new Date(m.createdAt) } : {}), + ...(m.updatedAt ? { updatedAt: new Date(m.updatedAt) } : {}), schedules: { create: m.schedules.map((s) => ({ + ...(s.id ? { id: s.id } : {}), windowStart: s.windowStart, windowEnd: s.windowEnd, label: s.label ?? null, dose: s.dose ?? null, + daysOfWeek: s.daysOfWeek ?? null, + timesOfDay: s.timesOfDay ?? [], + reminderGraceMinutes: s.reminderGraceMinutes ?? null, + rrule: s.rrule ?? null, + rollingIntervalDays: s.rollingIntervalDays ?? null, + scheduleType: s.scheduleType ?? "SCHEDULED", + cyclicOnWeeks: s.cyclicOnWeeks ?? null, + cyclicOffWeeks: s.cyclicOffWeeks ?? null, + doseWindows: (s.doseWindows ?? null) as never, })), }, }, }); - // First write wins on duplicate names — keeps the round-trip - // deterministic against the restore-from-our-own-download case. + restoredMedicationIds.add(created.id); if (!medByName.has(m.name)) medByName.set(m.name, created.id); } - // Intake events — only restore the ones whose `medication` name - // resolved against a row we just created. Orphans (medications - // that were deleted before the snapshot but still referenced by - // intake events) are dropped silently — the alternative would - // be an FK-violation crash, which is worse than a slightly - // smaller history. if (payload.intakeEvents.length > 0) { const rows = payload.intakeEvents .map((e) => { - const medId = medByName.get(e.medication); + const medId = + e.medicationId && restoredMedicationIds.has(e.medicationId) + ? e.medicationId + : medByName.get(e.medication); if (!medId) return null; return { + ...(e.id ? { id: e.id } : {}), userId: ownerId, medicationId: medId, scheduledFor: new Date(e.scheduledFor), takenAt: e.takenAt ? new Date(e.takenAt) : null, skipped: e.skipped ?? false, - source: (e.source ?? "WEB") as never, + autoMissed: e.autoMissed ?? false, + attributionSource: e.attributionSource ?? "AUTO", + source: e.source ?? "WEB", + idempotencyKey: e.idempotencyKey ?? null, + ...(e.createdAt ? { createdAt: new Date(e.createdAt) } : {}), + injectionSite: e.injectionSite ?? null, + doseTaken: e.doseTaken ?? null, + inventoryConsumption: (e.inventoryConsumption ?? + null) as never, + externalId: e.externalId ?? null, + ...(e.updatedAt ? { updatedAt: new Date(e.updatedAt) } : {}), + syncVersion: e.syncVersion ?? 0, + deletedAt: e.deletedAt ? new Date(e.deletedAt) : null, }; }) .filter((r): r is NonNullable => r !== null); @@ -526,6 +671,7 @@ const handler = apiHandler( const cycleCleared = await restoreCycleData(tx, ownerId, payload); const biomarkerByName = new Map(); + const restoredBiomarkerIds = new Set(); for (const biomarker of payload.biomarkers) { const created = await tx.biomarker.create({ data: { @@ -550,15 +696,23 @@ const handler = apiHandler( }, }); biomarkerByName.set(biomarker.name, created.id); + restoredBiomarkerIds.add(created.id); } for (const lab of payload.labResults) { - const biomarkerId = lab.biomarkerName - ? biomarkerByName.get(lab.biomarkerName) - : undefined; - if (lab.biomarkerName && !biomarkerId) { + const biomarkerId = + lab.biomarkerId !== undefined + ? lab.biomarkerId === null + ? null + : restoredBiomarkerIds.has(lab.biomarkerId) + ? lab.biomarkerId + : undefined + : lab.biomarkerName + ? biomarkerByName.get(lab.biomarkerName) + : null; + if (biomarkerId === undefined) { throw new Error( - `Unknown biomarker reference: ${lab.biomarkerName}`, + `Unknown biomarker reference: ${lab.biomarkerId ?? lab.biomarkerName}`, ); } await tx.labResult.create({ @@ -576,7 +730,14 @@ const handler = apiHandler( takenAt: new Date(lab.takenAt), source: lab.source, noteEncrypted: - lab.note == null ? null : encryptNoteToBytes(lab.note), + lab.noteEncrypted !== undefined + ? lab.noteEncrypted === null + ? null + : decodeEncryptedBytes(lab.noteEncrypted) + : lab.note == null + ? null + : encryptNoteToBytes(lab.note), + deletedAt: lab.deletedAt ? new Date(lab.deletedAt) : null, ...(lab.createdAt ? { createdAt: new Date(lab.createdAt) } : {}), @@ -612,7 +773,16 @@ const handler = apiHandler( : null, parentConditionId: null, noteEncrypted: - episode.note == null ? null : encryptToBytes(episode.note), + episode.noteEncrypted !== undefined + ? episode.noteEncrypted === null + ? null + : decodeEncryptedBytes(episode.noteEncrypted) + : episode.note == null + ? null + : encryptToBytes(episode.note), + deletedAt: episode.deletedAt + ? new Date(episode.deletedAt) + : null, ...(episode.createdAt ? { createdAt: new Date(episode.createdAt) } : {}), @@ -667,7 +837,20 @@ const handler = apiHandler( functionalImpact: dayLog.functionalImpact ?? null, feverC: dayLog.feverC ?? null, noteEncrypted: - dayLog.note == null ? null : encryptToBytes(dayLog.note), + dayLog.noteEncrypted !== undefined + ? dayLog.noteEncrypted === null + ? null + : decodeEncryptedBytes(dayLog.noteEncrypted) + : dayLog.note == null + ? null + : encryptToBytes(dayLog.note), + tz: dayLog.tz ?? null, + deletedAt: dayLog.deletedAt + ? new Date(dayLog.deletedAt) + : null, + ...(dayLog.createdAt + ? { createdAt: new Date(dayLog.createdAt) } + : {}), ...(dayLog.updatedAt ? { updatedAt: new Date(dayLog.updatedAt) } : {}), @@ -694,11 +877,24 @@ const handler = apiHandler( status: allergy.status as never, onsetAt: allergy.onsetAt ? new Date(allergy.onsetAt) : null, reactionEncrypted: - allergy.reaction == null - ? null - : encryptToBytes(allergy.reaction), + allergy.reactionEncrypted !== undefined + ? allergy.reactionEncrypted === null + ? null + : decodeEncryptedBytes(allergy.reactionEncrypted) + : allergy.reaction == null + ? null + : encryptToBytes(allergy.reaction), notesEncrypted: - allergy.note == null ? null : encryptToBytes(allergy.note), + allergy.notesEncrypted !== undefined + ? allergy.notesEncrypted === null + ? null + : decodeEncryptedBytes(allergy.notesEncrypted) + : allergy.note == null + ? null + : encryptToBytes(allergy.note), + deletedAt: allergy.deletedAt + ? new Date(allergy.deletedAt) + : null, ...(allergy.createdAt ? { createdAt: new Date(allergy.createdAt) } : {}), diff --git a/src/lib/cycle/__tests__/backup.test.ts b/src/lib/cycle/__tests__/backup.test.ts new file mode 100644 index 000000000..ee7998e65 --- /dev/null +++ b/src/lib/cycle/__tests__/backup.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from "vitest"; + +import { buildCycleBackupSection } from "../backup"; + +const deletedAt = new Date("2026-07-19T12:00:00.000Z"); +const createdAt = new Date("2026-07-01T08:00:00.000Z"); +const updatedAt = new Date("2026-07-19T11:00:00.000Z"); + +describe("buildCycleBackupSection disaster-recovery mode", () => { + it("preserves stable ids, reconciliation fields, and tombstones", async () => { + const prisma = { + cycleProfile: { + findUnique: vi.fn().mockResolvedValue({ + id: "profile-dr", + goal: "GENERAL_HEALTH", + cycleTrackingEnabled: true, + typicalCycleLength: 28, + typicalPeriodLength: 5, + lutealPhaseLength: 14, + secondarySymptom: "MUCUS", + predictionEnabled: true, + rawChartMode: false, + discreetNotifications: true, + sensitiveCategoryEncryption: true, + createdAt, + updatedAt, + }), + }, + menstrualCycle: { + findMany: vi.fn().mockResolvedValue([ + { + id: "cycle-dr", + startDate: "2026-07-01", + endDate: "2026-07-28", + periodEndDate: "2026-07-05", + lengthDays: 28, + ovulationDate: "2026-07-14", + ovulationConfirmed: true, + isPredicted: false, + tz: "Europe/London", + syncVersion: 5, + deletedAt, + createdAt, + updatedAt, + }, + ]), + }, + cycleDayLog: { + findMany: vi.fn().mockResolvedValue([ + { + id: "cycle-day-dr", + date: "2026-07-02", + cycleId: "cycle-dr", + flow: "HEAVY", + intermenstrualBleeding: false, + basalBodyTempC: 36.7, + temperatureExcluded: true, + ovulationTest: "NEGATIVE", + cervicalMucus: "CREAMY", + cervixPosition: "LOW", + cervixFirmness: "FIRM", + cervixOpening: "CLOSED", + sexualActivity: false, + protectedSex: null, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + sensitiveEncrypted: "sensitive-ciphertext", + notesEncrypted: "notes-ciphertext", + source: "APPLE_HEALTH", + externalId: "cycle-day-external", + tz: "Europe/London", + syncVersion: 9, + deletedAt, + createdAt, + updatedAt, + symptomLinks: [{ symptom: { key: "cramps" } }], + }, + ]), + }, + }; + + const section = await buildCycleBackupSection( + prisma as never, + "user-1", + { purpose: "disaster-recovery" }, + ); + + expect(prisma.menstrualCycle.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: "user-1" } }), + ); + expect(prisma.cycleDayLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: "user-1" } }), + ); + expect(section.cycleProfile).toEqual({ + id: "profile-dr", + goal: "GENERAL_HEALTH", + cycleTrackingEnabled: true, + typicalCycleLength: 28, + typicalPeriodLength: 5, + lutealPhaseLength: 14, + secondarySymptom: "MUCUS", + predictionEnabled: true, + rawChartMode: false, + discreetNotifications: true, + sensitiveCategoryEncryption: true, + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + }); + expect(section.cycles[0]).toEqual({ + id: "cycle-dr", + startDate: "2026-07-01", + endDate: "2026-07-28", + periodEndDate: "2026-07-05", + lengthDays: 28, + ovulationDate: "2026-07-14", + ovulationConfirmed: true, + isPredicted: false, + tz: "Europe/London", + syncVersion: 5, + deletedAt: deletedAt.toISOString(), + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + }); + expect(section.cycleDayLogs[0]).toEqual({ + id: "cycle-day-dr", + date: "2026-07-02", + cycleId: "cycle-dr", + flow: "HEAVY", + intermenstrualBleeding: false, + basalBodyTempC: 36.7, + temperatureExcluded: true, + ovulationTest: "NEGATIVE", + cervicalMucus: "CREAMY", + cervixPosition: "LOW", + cervixFirmness: "FIRM", + cervixOpening: "CLOSED", + sexualActivity: false, + protectedSex: null, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + sensitiveEncrypted: "sensitive-ciphertext", + notesEncrypted: "notes-ciphertext", + source: "APPLE_HEALTH", + externalId: "cycle-day-external", + tz: "Europe/London", + syncVersion: 9, + deletedAt: deletedAt.toISOString(), + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + symptomKeys: ["cramps"], + }); + }); +}); diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index bad576278..6affd2607 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -28,8 +28,13 @@ import type { import type { BackupPayload } from "@/lib/validations/backup"; /** The cycle slice of a backup payload. */ +export interface CycleBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + export interface CycleBackupSection { cycleProfile: { + id?: string; goal: string; cycleTrackingEnabled: boolean | null; typicalCycleLength: number | null; @@ -40,8 +45,11 @@ export interface CycleBackupSection { rawChartMode: boolean; discreetNotifications: boolean; sensitiveCategoryEncryption: boolean; + createdAt?: string; + updatedAt?: string; } | null; cycles: Array<{ + id?: string; startDate: string; endDate: string | null; periodEndDate: string | null; @@ -49,8 +57,15 @@ export interface CycleBackupSection { ovulationDate: string | null; ovulationConfirmed: boolean; tz: string | null; + isPredicted?: boolean; + syncVersion?: number; + deletedAt?: string | null; + createdAt?: string; + updatedAt?: string; }>; cycleDayLogs: Array<{ + id?: string; + cycleId?: string | null; date: string; flow: string | null; intermenstrualBleeding: boolean; @@ -71,6 +86,10 @@ export interface CycleBackupSection { source: string; externalId: string | null; tz: string | null; + syncVersion?: number; + deletedAt?: string | null; + createdAt?: string; + updatedAt?: string; symptomKeys: string[]; }>; } @@ -83,15 +102,19 @@ export interface CycleBackupSection { export async function buildCycleBackupSection( prisma: Pick, userId: string, + options: CycleBackupOptions = {}, ): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; const [profile, cycles, dayLogs] = await Promise.all([ prisma.cycleProfile.findUnique({ where: { userId } }), prisma.menstrualCycle.findMany({ - where: { userId, deletedAt: null, isPredicted: false }, + where: disasterRecovery + ? { userId } + : { userId, deletedAt: null, isPredicted: false }, orderBy: { startDate: "asc" }, }), prisma.cycleDayLog.findMany({ - where: { userId, deletedAt: null }, + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, orderBy: { date: "asc" }, include: { symptomLinks: { include: { symptom: { select: { key: true } } } }, @@ -102,6 +125,13 @@ export async function buildCycleBackupSection( return { cycleProfile: profile ? { + ...(disasterRecovery + ? { + id: profile.id, + createdAt: profile.createdAt.toISOString(), + updatedAt: profile.updatedAt.toISOString(), + } + : {}), goal: profile.goal, cycleTrackingEnabled: profile.cycleTrackingEnabled, typicalCycleLength: profile.typicalCycleLength, @@ -115,6 +145,16 @@ export async function buildCycleBackupSection( } : null, cycles: cycles.map((c) => ({ + ...(disasterRecovery + ? { + id: c.id, + isPredicted: c.isPredicted, + syncVersion: c.syncVersion, + deletedAt: c.deletedAt?.toISOString() ?? null, + createdAt: c.createdAt.toISOString(), + updatedAt: c.updatedAt.toISOString(), + } + : {}), startDate: c.startDate, endDate: c.endDate, periodEndDate: c.periodEndDate, @@ -124,6 +164,16 @@ export async function buildCycleBackupSection( tz: c.tz, })), cycleDayLogs: dayLogs.map((d) => ({ + ...(disasterRecovery + ? { + id: d.id, + cycleId: d.cycleId, + syncVersion: d.syncVersion, + deletedAt: d.deletedAt?.toISOString() ?? null, + createdAt: d.createdAt.toISOString(), + updatedAt: d.updatedAt.toISOString(), + } + : {}), date: d.date, flow: d.flow, intermenstrualBleeding: d.intermenstrualBleeding, @@ -139,7 +189,6 @@ export async function buildCycleBackupSection( pregnancyTest: d.pregnancyTest, progesteroneTest: d.progesteroneTest, contraceptive: d.contraceptive, - // Both ciphertext envelopes carried verbatim — never decrypted here. sensitiveEncrypted: d.sensitiveEncrypted, notesEncrypted: d.notesEncrypted, source: d.source, @@ -258,6 +307,7 @@ export async function restoreCycleData( const p = payload.cycleProfile; await tx.cycleProfile.create({ data: { + ...(p.id ? { id: p.id } : {}), userId: ownerId, goal: enumOrNull(p.goal, CYCLE_GOALS) ?? "GENERAL_HEALTH", cycleTrackingEnabled: p.cycleTrackingEnabled ?? null, @@ -270,6 +320,8 @@ export async function restoreCycleData( rawChartMode: p.rawChartMode ?? false, discreetNotifications: p.discreetNotifications ?? false, sensitiveCategoryEncryption: p.sensitiveCategoryEncryption ?? true, + ...(p.createdAt ? { createdAt: new Date(p.createdAt) } : {}), + ...(p.updatedAt ? { updatedAt: new Date(p.updatedAt) } : {}), }, }); } @@ -277,9 +329,11 @@ export async function restoreCycleData( // Recreate observed cycle spans; map startDate → id so day-logs can // re-attach to their owning span. const cycleIdByStart = new Map(); + const restoredCycleIds = new Set(); for (const c of payload.cycles) { const created = await tx.menstrualCycle.create({ data: { + ...(c.id ? { id: c.id } : {}), userId: ownerId, startDate: c.startDate, endDate: c.endDate ?? null, @@ -288,10 +342,15 @@ export async function restoreCycleData( ovulationDate: c.ovulationDate ?? null, ovulationConfirmed: c.ovulationConfirmed ?? false, tz: c.tz ?? null, - isPredicted: false, + isPredicted: c.isPredicted ?? false, + syncVersion: c.syncVersion ?? 0, + deletedAt: c.deletedAt ? new Date(c.deletedAt) : null, + ...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}), + ...(c.updatedAt ? { updatedAt: new Date(c.updatedAt) } : {}), }, }); cycleIdByStart.set(c.startDate, created.id); + restoredCycleIds.add(created.id); } // Resolve the seeded symptom catalogue once for the link re-creation. @@ -328,11 +387,22 @@ export async function restoreCycleData( const symptomIds = (d.symptomKeys ?? []) .map((k) => symptomIdByKey.get(k)) .filter((v): v is string => v !== undefined); + const cycleId = + d.cycleId !== undefined + ? d.cycleId === null + ? null + : restoredCycleIds.has(d.cycleId) + ? d.cycleId + : (() => { + throw new Error(`Unknown cycle reference: ${d.cycleId}`); + })() + : owningCycleId(d.date); await tx.cycleDayLog.create({ data: { + ...(d.id ? { id: d.id } : {}), userId: ownerId, date: d.date, - cycleId: owningCycleId(d.date), + cycleId, flow: enumOrNull(d.flow, FLOW_LEVELS), intermenstrualBleeding: d.intermenstrualBleeding ?? false, basalBodyTempC: d.basalBodyTempC ?? null, @@ -353,6 +423,10 @@ export async function restoreCycleData( source: enumOrNull(d.source, CYCLE_SOURCES) ?? "MANUAL", externalId: d.externalId ?? null, tz: d.tz ?? null, + syncVersion: d.syncVersion ?? 0, + deletedAt: d.deletedAt ? new Date(d.deletedAt) : null, + ...(d.createdAt ? { createdAt: new Date(d.createdAt) } : {}), + ...(d.updatedAt ? { updatedAt: new Date(d.updatedAt) } : {}), ...(symptomIds.length > 0 ? { symptomLinks: { diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 3586b1a6d..30b553b4c 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -1,4 +1,9 @@ import { describe, expect, it, vi } from "vitest"; +import { Buffer } from "node:buffer"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const mocks = vi.hoisted(() => ({ findMeasurementsPaged: vi.fn(), @@ -29,11 +34,132 @@ vi.mock("@/lib/export/records-backup", () => ({ import { buildFullBackupPayload } from "../full-backup-payload"; const deletedAt = new Date("2026-07-19T12:00:00.000Z"); +const measurementNote = encryptToBytes("canonical measurement note"); + +const appSettings = { + id: "singleton", + registrationEnabled: false, + mfaRequired: true, + defaultLocale: "en", + telegramGlobal: false, + ntfyGlobal: false, + webPushGlobal: false, + webPushVapidPublicKey: "public-key", + webPushVapidPrivateKeyEncrypted: "encrypted-private-key", + webPushVapidSubject: "mailto:ops@example.test", + apiGlobal: false, + moodLogGlobal: false, + umamiEnabled: true, + umamiScriptUrl: "https://analytics.example.test/script.js", + umamiWebsiteId: "website-id", + glitchtipEnabled: true, + glitchtipDsn: "https://dsn.example.test/1", + glitchtipEnvironment: "recovery", + reminderLateMinutes: 45, + reminderMissedMinutes: 90, + adminAiKeyEncrypted: "encrypted-ai-key", + adminAiModel: "gpt-test", + adminAiBaseUrl: "https://ai.example.test/v1", + adminCodexAccessTokenEncrypted: "encrypted-access", + adminCodexRefreshTokenEncrypted: "encrypted-refresh", + adminCodexAccountIdEncrypted: "encrypted-account", + adminCodexTokenExpiresAt: new Date("2026-07-21T08:00:00.000Z"), + adminCodexConnectedAt: new Date("2026-07-20T08:00:00.000Z"), + adminCodexConnectionStatus: "connected", + adminAiInsightsFeedbackSummary: { helpful: 7, total: 9 }, + defaultUserTimezone: "Europe/London", + assistantEnabled: false, + assistantCoachEnabled: false, + assistantBriefingEnabled: false, + assistantInsightStatusEnabled: false, + assistantCorrelationsEnabled: false, + assistantHealthScoreExplainerEnabled: false, + moduleAvailabilityJson: { nutrients: false, insights: true }, + documentMaxFileBytes: 12_345_678, + documentQuotaBytes: 9_876_543_210n, +}; function makePrisma() { return { - medication: { findMany: vi.fn().mockResolvedValue([]) }, - medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, + appSettings: { findUnique: vi.fn().mockResolvedValue(appSettings) }, + medication: { + findMany: vi.fn().mockResolvedValue([ + { + id: "medication-canonical", + name: "Example", + dose: "2 mg", + treatmentClass: "GENERIC", + dosesPerUnit: 4, + unitsPerDose: { toString: () => "0.5000" }, + active: false, + notificationsEnabled: false, + pausedAt: new Date("2026-07-18T10:00:00.000Z"), + snoozedUntil: new Date("2026-07-22T10:00:00.000Z"), + startsOn: new Date("2026-07-01T00:00:00.000Z"), + endsOn: new Date("2026-08-01T00:00:00.000Z"), + oneShot: false, + asNeeded: false, + deliveryForm: "INJECTION", + trackInjectionSites: true, + allowedInjectionSites: ["THIGH_LEFT", "THIGH_RIGHT"], + liveActivityEnabled: true, + criticalAlarmEnabled: true, + atcCode: "A10BX10", + rxNormCode: "12345", + lowStockNotifiedAt: new Date("2026-07-17T10:00:00.000Z"), + lowStockNotifiedThresholdDays: 7, + reorderLeadDays: 3, + externalSource: "APPLE_HEALTH", + externalId: "med-external", + createdAt: new Date("2026-06-01T10:00:00.000Z"), + updatedAt: new Date("2026-07-19T10:00:00.000Z"), + schedules: [ + { + id: "schedule-canonical", + windowStart: "08:00", + windowEnd: "10:00", + label: "Morning", + dose: "1 mg", + daysOfWeek: "i2;1,3,5", + timesOfDay: ["08:00", "20:00"], + reminderGraceMinutes: 90, + rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR", + rollingIntervalDays: null, + scheduleType: "CYCLIC", + cyclicOnWeeks: 3, + cyclicOffWeeks: 1, + doseWindows: [ + { timeOfDay: "08:00", start: "07:30", end: "09:00" }, + ], + }, + ], + }, + ]), + }, + medicationIntakeEvent: { + findMany: vi.fn().mockResolvedValue([ + { + id: "intake-canonical", + medicationId: "medication-canonical", + medication: { name: "Example" }, + scheduledFor: new Date("2026-07-19T08:00:00.000Z"), + takenAt: new Date("2026-07-19T08:05:00.000Z"), + skipped: false, + autoMissed: false, + attributionSource: "USER_PIN", + source: "APPLE_HEALTH", + idempotencyKey: "intake-idempotency", + createdAt: new Date("2026-07-19T08:05:00.000Z"), + injectionSite: "THIGH_LEFT", + doseTaken: "1 mg", + inventoryConsumption: [{ itemId: "pen-1", units: 0.5 }], + externalId: "intake-external", + updatedAt: new Date("2026-07-19T08:06:00.000Z"), + syncVersion: 4, + deletedAt, + }, + ]), + }, moodEntry: { findMany: vi.fn().mockResolvedValue([ { @@ -54,17 +180,29 @@ function makePrisma() { } function installSectionMocks() { + void measurementNote; mocks.findMeasurementsPaged.mockResolvedValue([ { id: "measurement-tombstone", - type: "WEIGHT", + type: "SLEEP_DURATION", value: 75, - unit: "kg", + valueMin: 60, + valueMax: 90, + unit: "minutes", measuredAt: new Date("2026-07-19T07:00:00.000Z"), - source: "MANUAL", + source: "IMPORT", notes: null, - notesEncrypted: null, + notesEncrypted: measurementNote, + externalId: "oura-stage-deep", + externalSourceVersion: "oura-v3", + glucoseContext: null, + sleepStage: "DEEP", + rhythmClassification: null, + deviceType: "ring", + syncVersion: 7, deletedAt, + createdAt: new Date("2026-07-19T07:01:00.000Z"), + updatedAt: new Date("2026-07-19T07:02:00.000Z"), }, ]); mocks.buildCycleBackupSection.mockResolvedValue({ @@ -118,14 +256,63 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { { purpose: "disaster-recovery" }, ); expect(payload).toMatchObject({ - schemaVersion: "1", + schemaVersion: "2", exportedAt: "2026-07-20T00:00:00.000Z", + appSettings: { + ...appSettings, + adminCodexTokenExpiresAt: + appSettings.adminCodexTokenExpiresAt.toISOString(), + adminCodexConnectedAt: appSettings.adminCodexConnectedAt.toISOString(), + documentQuotaBytes: appSettings.documentQuotaBytes.toString(), + }, measurements: [ { id: "measurement-tombstone", + type: "SLEEP_DURATION", + value: 75, + valueMin: 60, + valueMax: 90, + unit: "minutes", + measuredAt: "2026-07-19T07:00:00.000Z", + source: "IMPORT", + notesEncrypted: Buffer.from(measurementNote).toString("base64"), + externalId: "oura-stage-deep", + externalSourceVersion: "oura-v3", + glucoseContext: null, + sleepStage: "DEEP", + rhythmClassification: null, + deviceType: "ring", + syncVersion: 7, deletedAt: deletedAt.toISOString(), + createdAt: "2026-07-19T07:01:00.000Z", + updatedAt: "2026-07-19T07:02:00.000Z", }, ], + medications: [ + expect.objectContaining({ + id: "medication-canonical", + asNeeded: false, + deliveryForm: "INJECTION", + unitsPerDose: "0.5000", + schedules: [ + expect.objectContaining({ + id: "schedule-canonical", + rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR", + scheduleType: "CYCLIC", + cyclicOnWeeks: 3, + cyclicOffWeeks: 1, + }), + ], + }), + ], + intakeEvents: [ + expect.objectContaining({ + id: "intake-canonical", + medicationId: "medication-canonical", + syncVersion: 4, + deletedAt: deletedAt.toISOString(), + }), + ], moodEntries: [ { id: "mood-tombstone", diff --git a/src/lib/export/__tests__/records-backup.test.ts b/src/lib/export/__tests__/records-backup.test.ts index aa4d432ac..c94532062 100644 --- a/src/lib/export/__tests__/records-backup.test.ts +++ b/src/lib/export/__tests__/records-backup.test.ts @@ -234,6 +234,166 @@ describe("buildRecordsBackupSection", () => { expect(section.documents[0].summary).toBe("Routine panel."); }); + it("preserves every canonical lab, illness, day-log, and allergy field verbatim", async () => { + const prisma = buildMockPrisma(); + const deletedAt = new Date("2026-04-13T12:00:00.000Z"); + const createdAt = new Date("2026-04-10T01:00:00.000Z"); + const updatedAt = new Date("2026-04-12T23:00:00.000Z"); + const labNote = encryptNoteToBytes("Canonical lab note"); + const illnessNote = encryptToBytes("Canonical illness note"); + const dayNote = encryptToBytes("Canonical day note"); + const reaction = encryptToBytes("Canonical reaction"); + const allergyNote = encryptToBytes("Canonical allergy note"); + + prisma.labResult.findMany.mockResolvedValueOnce([ + { + id: "lab-canonical", + panel: "Lipid panel", + analyte: "LDL", + value: 118, + valueText: null, + unit: "mg/dL", + referenceLow: null, + referenceHigh: 130, + takenAt: new Date("2026-04-01T09:00:00.000Z"), + biomarkerId: "biomarker-canonical", + biomarker: { name: "LDL Cholesterol" }, + source: "IMPORT", + noteEncrypted: labNote, + createdAt, + updatedAt, + deletedAt, + }, + ]); + prisma.illnessEpisode.findMany.mockResolvedValueOnce([ + { + id: "episode-canonical", + label: "Migraine", + type: "CHRONIC", + lifecycle: "CHRONIC_ONGOING", + onsetAt: new Date("2026-04-10T00:00:00.000Z"), + resolvedAt: null, + parentConditionId: null, + noteEncrypted: illnessNote, + createdAt, + updatedAt, + deletedAt, + dayLogs: [ + { + id: "day-canonical", + episodeId: "episode-canonical", + date: "2026-04-11", + functionalImpact: 2, + feverC: 38.2, + noteEncrypted: dayNote, + tz: "Europe/London", + createdAt, + updatedAt, + deletedAt, + symptomLinks: [{ severity: 3, symptom: { key: "headache" } }], + }, + ], + }, + ]); + prisma.allergy.findMany.mockResolvedValueOnce([ + { + id: "allergy-canonical", + substance: "Penicillin", + category: "MEDICATION", + type: "ALLERGY", + severity: "SEVERE", + status: "INACTIVE", + onsetAt: new Date("2020-01-01T00:00:00.000Z"), + reactionEncrypted: reaction, + notesEncrypted: allergyNote, + createdAt, + updatedAt, + deletedAt, + }, + ]); + + prisma.biomarker.findMany.mockResolvedValueOnce([]); + prisma.familyHistoryEntry.findMany.mockResolvedValueOnce([]); + prisma.workout.findMany.mockResolvedValueOnce([]); + prisma.inboundDocument.findMany.mockResolvedValueOnce([]); + const section = await buildRecordsBackupSection(prisma as never, USER_ID, { + purpose: "disaster-recovery", + }); + const episodeQuery = prisma.illnessEpisode.findMany.mock.calls.at(-1)?.[0]; + expect(episodeQuery).toEqual( + expect.objectContaining({ where: { userId: USER_ID } }), + ); + expect(episodeQuery.include.dayLogs).not.toHaveProperty("where"); + expect(prisma.allergy.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: USER_ID } }), + ); + expect(section.labResults[0]).toEqual({ + id: "lab-canonical", + panel: "Lipid panel", + analyte: "LDL", + value: 118, + valueText: null, + unit: "mg/dL", + referenceLow: null, + referenceHigh: 130, + takenAt: "2026-04-01T09:00:00.000Z", + biomarkerId: "biomarker-canonical", + biomarkerName: "LDL Cholesterol", + source: "IMPORT", + note: null, + noteEncrypted: Buffer.from(labNote).toString("base64"), + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + deletedAt: deletedAt.toISOString(), + }); + expect(section.illnessEpisodes[0]).toEqual({ + id: "episode-canonical", + label: "Migraine", + type: "CHRONIC", + lifecycle: "CHRONIC_ONGOING", + onsetAt: "2026-04-10T00:00:00.000Z", + resolvedAt: null, + parentConditionId: null, + note: null, + noteEncrypted: Buffer.from(illnessNote).toString("base64"), + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + deletedAt: deletedAt.toISOString(), + dayLogs: [ + { + id: "day-canonical", + episodeId: "episode-canonical", + date: "2026-04-11", + functionalImpact: 2, + feverC: 38.2, + note: null, + noteEncrypted: Buffer.from(dayNote).toString("base64"), + tz: "Europe/London", + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + deletedAt: deletedAt.toISOString(), + symptoms: [{ key: "headache", severity: 3 }], + }, + ], + }); + expect(section.allergies[0]).toEqual({ + id: "allergy-canonical", + substance: "Penicillin", + category: "MEDICATION", + type: "ALLERGY", + severity: "SEVERE", + status: "INACTIVE", + onsetAt: "2020-01-01T00:00:00.000Z", + reaction: null, + note: null, + reactionEncrypted: Buffer.from(reaction).toString("base64"), + notesEncrypted: Buffer.from(allergyNote).toString("base64"), + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + deletedAt: deletedAt.toISOString(), + }); + }); + it("threads an illness flare's parentConditionId and nests its day-log + symptom", async () => { const prisma = buildMockPrisma(); const section = await buildRecordsBackupSection(prisma as never, USER_ID); diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 211cdbf17..0e2303ed0 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -10,6 +10,7 @@ * The decrypted notes are surfaced here (the backup is the human-readable * artefact); an admin restore re-encrypts them on re-insert. */ +import { Buffer } from "node:buffer"; import type { PrismaClient } from "@/generated/prisma/client"; import { readNote } from "@/lib/crypto/note-cipher"; import { findMeasurementsPaged } from "@/lib/export/paged-measurements"; @@ -50,90 +51,174 @@ export async function buildFullBackupPayload( options: FullBackupOptions = {}, ): Promise { const disasterRecovery = options.purpose === "disaster-recovery"; - const [measurements, medications, intakeEvents, moodEntries, cycle, records] = - await Promise.all([ - // v1.28.25 — keyset-paginated read with a narrow select. The backup - // is inherently whole-history, so on a CGM / per-sample-HR account the - // previous single full-width `findMany` materialised a six-figure row - // set in one shot. The chunked read accumulates the same rows in the - // same `measuredAt desc` order; the payload FORMAT below is untouched - // (restore compatibility). The select is exactly the fields the - // serializer reads: type / value / unit / measuredAt / source + the - // note columns `readNote` decrypts + the `id` cursor key. - findMeasurementsPaged( - prisma, - disasterRecovery ? { userId } : { userId, deletedAt: null }, - { - id: true, - type: true, - value: true, - unit: true, - measuredAt: true, - source: true, - notes: true, - notesEncrypted: true, - deletedAt: true, - }, - ), - prisma.medication.findMany({ - where: { userId }, - select: { - name: true, - dose: true, - active: true, - schedules: { - select: { - windowStart: true, - windowEnd: true, - label: true, - dose: true, - }, + const [ + appSettings, + measurements, + medications, + intakeEvents, + moodEntries, + cycle, + records, + ] = await Promise.all([ + disasterRecovery + ? prisma.appSettings.findUnique({ where: { id: "singleton" } }) + : Promise.resolve(null), + findMeasurementsPaged( + prisma, + disasterRecovery ? { userId } : { userId, deletedAt: null }, + { + id: true, + type: true, + value: true, + valueMin: true, + valueMax: true, + unit: true, + measuredAt: true, + source: true, + notes: true, + notesEncrypted: true, + externalId: true, + externalSourceVersion: true, + glucoseContext: true, + sleepStage: true, + rhythmClassification: true, + deviceType: true, + syncVersion: true, + deletedAt: true, + createdAt: true, + updatedAt: true, + }, + ), + prisma.medication.findMany({ + where: { userId }, + include: { schedules: true }, + }), + prisma.medicationIntakeEvent.findMany({ + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, + include: { medication: { select: { name: true } } }, + orderBy: { scheduledFor: "desc" }, + }), + prisma.moodEntry.findMany({ + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, + orderBy: { moodLoggedAt: "desc" }, + include: { + tagLinks: { + where: { rating: { not: null } }, + select: { + rating: true, + moodTag: { select: { key: true } }, }, }, - }), - prisma.medicationIntakeEvent.findMany({ - where: { userId, deletedAt: null }, - include: { medication: { select: { name: true } } }, - orderBy: { scheduledFor: "desc" }, - }), - prisma.moodEntry.findMany({ - where: disasterRecovery ? { userId } : { userId, deletedAt: null }, - orderBy: { moodLoggedAt: "desc" }, - include: { - tagLinks: { - where: { rating: { not: null } }, - select: { - rating: true, - moodTag: { select: { key: true } }, - }, - }, - }, - }), - buildCycleBackupSection(prisma, userId), - buildRecordsBackupSection(prisma, userId, { - purpose: disasterRecovery ? "disaster-recovery" : "portable-export", - }), - ]); + }, + }), + buildCycleBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), + buildRecordsBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), + ]); const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, exportedAt: (options.exportedAt ?? new Date()).toISOString(), userId, - measurements: measurements.map((m) => ({ - id: m.id, - type: m.type, - value: m.value, - unit: m.unit, - measuredAt: m.measuredAt.toISOString(), - source: m.source, - notes: readNote(m.notesEncrypted, m.notes), - deletedAt: m.deletedAt?.toISOString() ?? null, - })), + appSettings: + disasterRecovery && appSettings + ? { + ...appSettings, + adminCodexTokenExpiresAt: + appSettings.adminCodexTokenExpiresAt?.toISOString() ?? null, + adminCodexConnectedAt: + appSettings.adminCodexConnectedAt?.toISOString() ?? null, + documentQuotaBytes: appSettings.documentQuotaBytes.toString(), + } + : null, + measurements: measurements.map((m) => + disasterRecovery + ? { + id: m.id, + type: m.type, + value: m.value, + valueMin: m.valueMin, + valueMax: m.valueMax, + unit: m.unit, + measuredAt: m.measuredAt.toISOString(), + source: m.source, + notes: m.notes, + notesEncrypted: m.notesEncrypted + ? Buffer.from(m.notesEncrypted).toString("base64") + : null, + externalId: m.externalId, + externalSourceVersion: m.externalSourceVersion, + glucoseContext: m.glucoseContext, + sleepStage: m.sleepStage, + rhythmClassification: m.rhythmClassification, + deviceType: m.deviceType, + syncVersion: m.syncVersion, + deletedAt: m.deletedAt?.toISOString() ?? null, + createdAt: m.createdAt.toISOString(), + updatedAt: m.updatedAt.toISOString(), + } + : { + id: m.id, + type: m.type, + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt.toISOString(), + source: m.source, + notes: readNote(m.notesEncrypted, m.notes), + deletedAt: m.deletedAt?.toISOString() ?? null, + }, + ), medications: medications.map((m) => ({ + ...(disasterRecovery + ? { + id: m.id, + treatmentClass: m.treatmentClass, + dosesPerUnit: m.dosesPerUnit, + unitsPerDose: m.unitsPerDose.toString(), + notificationsEnabled: m.notificationsEnabled, + pausedAt: m.pausedAt?.toISOString() ?? null, + snoozedUntil: m.snoozedUntil?.toISOString() ?? null, + startsOn: m.startsOn?.toISOString() ?? null, + endsOn: m.endsOn?.toISOString() ?? null, + oneShot: m.oneShot, + asNeeded: m.asNeeded, + deliveryForm: m.deliveryForm, + trackInjectionSites: m.trackInjectionSites, + allowedInjectionSites: m.allowedInjectionSites, + liveActivityEnabled: m.liveActivityEnabled, + criticalAlarmEnabled: m.criticalAlarmEnabled, + atcCode: m.atcCode, + rxNormCode: m.rxNormCode, + lowStockNotifiedAt: m.lowStockNotifiedAt?.toISOString() ?? null, + lowStockNotifiedThresholdDays: m.lowStockNotifiedThresholdDays, + reorderLeadDays: m.reorderLeadDays, + externalSource: m.externalSource, + externalId: m.externalId, + createdAt: m.createdAt.toISOString(), + updatedAt: m.updatedAt.toISOString(), + } + : {}), name: m.name, dose: m.dose, active: m.active, schedules: m.schedules.map((s) => ({ + ...(disasterRecovery + ? { + id: s.id, + daysOfWeek: s.daysOfWeek, + timesOfDay: s.timesOfDay, + reminderGraceMinutes: s.reminderGraceMinutes, + rrule: s.rrule, + rollingIntervalDays: s.rollingIntervalDays, + scheduleType: s.scheduleType, + cyclicOnWeeks: s.cyclicOnWeeks, + cyclicOffWeeks: s.cyclicOffWeeks, + doseWindows: s.doseWindows, + } + : {}), windowStart: s.windowStart, windowEnd: s.windowEnd, label: s.label, @@ -141,6 +226,23 @@ export async function buildFullBackupPayload( })), })), intakeEvents: intakeEvents.map((e) => ({ + ...(disasterRecovery + ? { + id: e.id, + medicationId: e.medicationId, + autoMissed: e.autoMissed, + attributionSource: e.attributionSource, + idempotencyKey: e.idempotencyKey, + createdAt: e.createdAt.toISOString(), + injectionSite: e.injectionSite, + doseTaken: e.doseTaken, + inventoryConsumption: e.inventoryConsumption, + externalId: e.externalId, + updatedAt: e.updatedAt.toISOString(), + syncVersion: e.syncVersion, + deletedAt: e.deletedAt?.toISOString() ?? null, + } + : {}), medication: e.medication.name, scheduledFor: e.scheduledFor.toISOString(), takenAt: e.takenAt?.toISOString() ?? null, diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts index a534de18a..3f360d476 100644 --- a/src/lib/export/records-backup.ts +++ b/src/lib/export/records-backup.ts @@ -57,10 +57,15 @@ export interface LabResultBackupEntry { takenAt: string; source: string; /** Human-readable cross-reference into `biomarkers` below, not an id. */ + /** Stable canonical FK; legacy portable payloads use biomarkerName. */ + biomarkerId?: string | null; biomarkerName: string | null; note: string | null; + /** Base64 ciphertext in canonical DR payloads. */ + noteEncrypted?: string | null; createdAt?: string; updatedAt?: string; + deletedAt?: string | null; } export interface BiomarkerBackupEntry { @@ -140,11 +145,30 @@ interface DisasterRecoveryDocumentRow { updatedAt: Date; } +type CanonicalIllnessDayLog = IllnessDayLogDTO & { + noteEncrypted?: string | null; + tz?: string | null; + createdAt?: string; + deletedAt?: string | null; +}; + +type CanonicalIllnessEpisode = IllnessEpisodeDTO & { + noteEncrypted?: string | null; + deletedAt?: string | null; + dayLogs: CanonicalIllnessDayLog[]; +}; + +type CanonicalAllergy = AllergyDTO & { + reactionEncrypted?: string | null; + notesEncrypted?: string | null; + deletedAt?: string | null; +}; + export interface RecordsBackupSection { labResults: LabResultBackupEntry[]; biomarkers: BiomarkerBackupEntry[]; - illnessEpisodes: Array; - allergies: AllergyDTO[]; + illnessEpisodes: CanonicalIllnessEpisode[]; + allergies: CanonicalAllergy[]; familyHistory: FamilyHistoryEntryDTO[]; workouts: WorkoutBackupEntry[]; documents: DocumentBackupEntry[]; @@ -255,7 +279,7 @@ export async function buildRecordsBackupSection( documentRows, ] = await Promise.all([ prisma.labResult.findMany({ - where: { userId, deletedAt: null }, + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, orderBy: { takenAt: "desc" }, include: { biomarker: { select: { name: true } } }, }), @@ -264,18 +288,18 @@ export async function buildRecordsBackupSection( orderBy: { name: "asc" }, }), prisma.illnessEpisode.findMany({ - where: { userId, deletedAt: null }, + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, orderBy: { onsetAt: "desc" }, include: { dayLogs: { - where: { deletedAt: null }, + ...(disasterRecovery ? {} : { where: { deletedAt: null } }), orderBy: { date: "asc" }, include: dayLogSymptomInclude, }, }, }), prisma.allergy.findMany({ - where: { userId, deletedAt: null }, + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, orderBy: { createdAt: "desc" }, }), prisma.familyHistoryEntry.findMany({ @@ -293,10 +317,16 @@ export async function buildRecordsBackupSection( ...(disasterRecovery ? { id: r.id, + biomarkerId: r.biomarkerId, + noteEncrypted: r.noteEncrypted + ? Buffer.from(r.noteEncrypted).toString("base64") + : null, createdAt: r.createdAt.toISOString(), updatedAt: r.updatedAt.toISOString(), + deletedAt: r.deletedAt?.toISOString() ?? null, + note: null, } - : {}), + : { note: decryptNoteSoft(r.noteEncrypted) }), panel: r.panel, analyte: r.analyte, value: r.value, @@ -307,7 +337,6 @@ export async function buildRecordsBackupSection( takenAt: r.takenAt.toISOString(), source: r.source, biomarkerName: r.biomarker?.name ?? null, - note: decryptNoteSoft(r.noteEncrypted), })); const biomarkers: BiomarkerBackupEntry[] = biomarkerRows.map((b) => ({ @@ -327,12 +356,69 @@ export async function buildRecordsBackupSection( context: decryptContextSoft(b.contextEncrypted), })); - const illnessEpisodes = episodeRows.map((row) => ({ - ...toIllnessEpisodeDTO(row), - dayLogs: row.dayLogs.map(toIllnessDayLogDTO), - })); + const illnessEpisodes: CanonicalIllnessEpisode[] = disasterRecovery + ? episodeRows.map((row) => ({ + id: row.id, + label: row.label, + type: row.type, + lifecycle: row.lifecycle, + onsetAt: row.onsetAt.toISOString(), + resolvedAt: row.resolvedAt?.toISOString() ?? null, + parentConditionId: row.parentConditionId, + note: null, + noteEncrypted: row.noteEncrypted + ? Buffer.from(row.noteEncrypted).toString("base64") + : null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null, + dayLogs: row.dayLogs.map((dayLog) => ({ + id: dayLog.id, + episodeId: dayLog.episodeId, + date: dayLog.date, + functionalImpact: dayLog.functionalImpact, + feverC: dayLog.feverC, + note: null, + noteEncrypted: dayLog.noteEncrypted + ? Buffer.from(dayLog.noteEncrypted).toString("base64") + : null, + tz: dayLog.tz, + createdAt: dayLog.createdAt.toISOString(), + updatedAt: dayLog.updatedAt.toISOString(), + deletedAt: dayLog.deletedAt?.toISOString() ?? null, + symptoms: dayLog.symptomLinks.map((link) => ({ + key: link.symptom.key, + severity: link.severity, + })), + })), + })) + : episodeRows.map((row) => ({ + ...toIllnessEpisodeDTO(row), + dayLogs: row.dayLogs.map(toIllnessDayLogDTO), + })); - const allergies = allergyRows.map(toAllergyDTO); + const allergies: CanonicalAllergy[] = disasterRecovery + ? allergyRows.map((row) => ({ + id: row.id, + substance: row.substance, + category: row.category, + type: row.type, + severity: row.severity, + status: row.status, + onsetAt: row.onsetAt?.toISOString() ?? null, + reaction: null, + note: null, + reactionEncrypted: row.reactionEncrypted + ? Buffer.from(row.reactionEncrypted).toString("base64") + : null, + notesEncrypted: row.notesEncrypted + ? Buffer.from(row.notesEncrypted).toString("base64") + : null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null, + })) + : allergyRows.map(toAllergyDTO); const familyHistory = familyRows.map(toFamilyHistoryEntryDTO); const workouts: WorkoutBackupEntry[] = workoutRows.map((w) => ({ diff --git a/src/lib/validations/__tests__/backup.test.ts b/src/lib/validations/__tests__/backup.test.ts index 595f3e375..aba2c9535 100644 --- a/src/lib/validations/__tests__/backup.test.ts +++ b/src/lib/validations/__tests__/backup.test.ts @@ -18,7 +18,12 @@ * of bubbling out of `prisma.createMany` with a useless message. */ import { describe, expect, it } from "vitest"; -import { backupPayloadSchema, summarizeBackup } from "../backup"; +import { + backupPayloadSchema, + isCompatibleSchemaVersion, + parseBackupPayload, + summarizeBackup, +} from "../backup"; const baseEntry = { date: "2026-05-08", @@ -384,6 +389,52 @@ describe("backupPayloadSchema — v1.28 backup-completeness domains", () => { }); }); +describe("backupPayloadSchema — canonical v2 identity compatibility", () => { + const base = { + exportedAt: "2026-07-20T00:00:00.000Z", + userId: "u1", + }; + + it("treats an unversioned payload as legacy v1 and accepts measurements without ids", () => { + const payload = parseBackupPayload({ + ...base, + measurements: [ + { + type: "WEIGHT", + value: 75, + unit: "kg", + measuredAt: "2026-07-19T07:00:00.000Z", + }, + ], + }); + + expect(payload.schemaVersion).toBe("1"); + expect(isCompatibleSchemaVersion(payload.schemaVersion)).toBe(true); + }); + + it("requires stable measurement ids in canonical v2 payloads", () => { + const result = backupPayloadSchema.safeParse({ + ...base, + schemaVersion: "2", + measurements: [ + { + type: "SLEEP_DURATION", + value: 120, + unit: "minutes", + measuredAt: "2026-07-19T22:00:00.000Z", + source: "IMPORT", + sleepStage: "DEEP", + }, + ], + }); + + expect(result.success).toBe(false); + expect(isCompatibleSchemaVersion("1")).toBe(true); + expect(isCompatibleSchemaVersion("2")).toBe(true); + expect(isCompatibleSchemaVersion("3")).toBe(false); + }); +}); + describe("backupPayloadSchema — database enum boundaries", () => { const enumPayload = { schemaVersion: "1", diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index ca68e8f64..48acd29cc 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -34,19 +34,28 @@ import { DocumentSummaryState, FamilyRelationship, FlowLevel, + GlucoseContext, HomeTestResult, IllnessLifecycle, IllnessType, InboundDocumentKind, InboundDocumentStatus, + InjectionSite, + IntakeAttributionSource, IntakeSource, MeasurementSource, MeasurementType, + MedicationCategory, + MedicationDeliveryForm, + MedicationScheduleType, OvulationTest, + RhythmClassification, SecondarySymptom, + SleepStage, } from "@/generated/prisma/enums"; -export const BACKUP_SCHEMA_VERSION = "1" as const; +export const BACKUP_SCHEMA_VERSION = "2" as const; +const LEGACY_BACKUP_SCHEMA_VERSION = "1" as const; const isoDateTime = z .string() @@ -55,44 +64,112 @@ const isoDateTime = z message: "Expected ISO-8601 date-time string", }); +const base64BytesSchema = z + .string() + .min(1) + .refine( + (value) => value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value), + { message: "Expected base64-encoded encrypted bytes" }, + ); + const measurementSchema = z .object({ id: z.string().min(1).optional(), type: z.enum(MeasurementType), value: z.number(), + valueMin: z.number().nullable().optional(), + valueMax: z.number().nullable().optional(), unit: z.string().min(1), measuredAt: isoDateTime, source: z.enum(MeasurementSource).optional(), notes: z.string().nullable().optional(), + notesEncrypted: base64BytesSchema.nullable().optional(), + externalId: z.string().nullable().optional(), + externalSourceVersion: z.string().nullable().optional(), + glucoseContext: z.enum(GlucoseContext).nullable().optional(), + sleepStage: z.enum(SleepStage).nullable().optional(), + rhythmClassification: z.enum(RhythmClassification).nullable().optional(), + deviceType: z.string().nullable().optional(), + syncVersion: z.number().int().optional(), deletedAt: isoDateTime.nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), }) .passthrough(); const medicationScheduleSchema = z .object({ + id: z.string().min(1).optional(), windowStart: z.string().min(1), windowEnd: z.string().min(1), label: z.string().nullable().optional(), dose: z.string().nullable().optional(), + daysOfWeek: z.string().nullable().optional(), + timesOfDay: z.array(z.string()).optional(), + reminderGraceMinutes: z.number().int().nullable().optional(), + rrule: z.string().nullable().optional(), + rollingIntervalDays: z.number().int().nullable().optional(), + scheduleType: z.enum(MedicationScheduleType).optional(), + cyclicOnWeeks: z.number().int().nullable().optional(), + cyclicOffWeeks: z.number().int().nullable().optional(), + doseWindows: z.unknown().nullable().optional(), }) .passthrough(); const medicationSchema = z .object({ + id: z.string().min(1).optional(), name: z.string().min(1), dose: z.string(), + treatmentClass: z.enum(MedicationCategory).optional(), + dosesPerUnit: z.number().int().nullable().optional(), + unitsPerDose: z.string().min(1).optional(), active: z.boolean().optional(), + notificationsEnabled: z.boolean().optional(), + pausedAt: isoDateTime.nullable().optional(), + snoozedUntil: isoDateTime.nullable().optional(), + startsOn: isoDateTime.nullable().optional(), + endsOn: isoDateTime.nullable().optional(), + oneShot: z.boolean().optional(), + asNeeded: z.boolean().optional(), + deliveryForm: z.enum(MedicationDeliveryForm).optional(), + trackInjectionSites: z.boolean().optional(), + allowedInjectionSites: z.array(z.enum(InjectionSite)).optional(), + liveActivityEnabled: z.boolean().optional(), + criticalAlarmEnabled: z.boolean().optional(), + atcCode: z.string().nullable().optional(), + rxNormCode: z.string().nullable().optional(), + lowStockNotifiedAt: isoDateTime.nullable().optional(), + lowStockNotifiedThresholdDays: z.number().int().nullable().optional(), + reorderLeadDays: z.number().int().nullable().optional(), + externalSource: z.enum(IntakeSource).nullable().optional(), + externalId: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), schedules: z.array(medicationScheduleSchema).default([]), }) .passthrough(); const intakeEventSchema = z .object({ + id: z.string().min(1).optional(), + medicationId: z.string().min(1).optional(), medication: z.string().min(1), scheduledFor: isoDateTime, takenAt: isoDateTime.nullable().optional(), skipped: z.boolean().optional(), + autoMissed: z.boolean().optional(), + attributionSource: z.enum(IntakeAttributionSource).optional(), source: z.enum(IntakeSource).optional(), + idempotencyKey: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + injectionSite: z.enum(InjectionSite).nullable().optional(), + doseTaken: z.string().nullable().optional(), + inventoryConsumption: z.unknown().nullable().optional(), + externalId: z.string().nullable().optional(), + updatedAt: isoDateTime.optional(), + syncVersion: z.number().int().optional(), + deletedAt: isoDateTime.nullable().optional(), }) .passthrough(); @@ -156,13 +233,19 @@ const moodEntrySchema = z */ const cycleSpanSchema = z .object({ + id: z.string().min(1).optional(), startDate: z.string().min(1), endDate: z.string().nullable().optional(), periodEndDate: z.string().nullable().optional(), lengthDays: z.number().int().nullable().optional(), ovulationDate: z.string().nullable().optional(), ovulationConfirmed: z.boolean().optional(), + isPredicted: z.boolean().optional(), tz: z.string().nullable().optional(), + syncVersion: z.number().int().optional(), + deletedAt: isoDateTime.nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), }) .passthrough(); @@ -175,7 +258,9 @@ const cycleSpanSchema = z */ const cycleDayLogSchema = z .object({ + id: z.string().min(1).optional(), date: z.string().min(1), + cycleId: z.string().nullable().optional(), flow: z.enum(FlowLevel).nullable().optional(), intermenstrualBleeding: z.boolean().optional(), basalBodyTempC: z.number().nullable().optional(), @@ -195,6 +280,10 @@ const cycleDayLogSchema = z source: z.enum(MeasurementSource).optional(), externalId: z.string().nullable().optional(), tz: z.string().nullable().optional(), + syncVersion: z.number().int().optional(), + deletedAt: isoDateTime.nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), symptomKeys: z.array(z.string()).default([]), }) .passthrough(); @@ -202,6 +291,7 @@ const cycleDayLogSchema = z /** Cycle-tracking preferences (one row per user). */ const cycleProfileSchema = z .object({ + id: z.string().min(1).optional(), goal: z.enum(CycleTrackingGoal).optional(), cycleTrackingEnabled: z.boolean().nullable().optional(), typicalCycleLength: z.number().int().nullable().optional(), @@ -212,6 +302,53 @@ const cycleProfileSchema = z rawChartMode: z.boolean().optional(), discreetNotifications: z.boolean().optional(), sensitiveCategoryEncryption: z.boolean().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); + +const appSettingsBackupSchema = z + .object({ + id: z.string().min(1), + registrationEnabled: z.boolean(), + mfaRequired: z.boolean(), + defaultLocale: z.string(), + telegramGlobal: z.boolean(), + ntfyGlobal: z.boolean(), + webPushGlobal: z.boolean(), + webPushVapidPublicKey: z.string().nullable(), + webPushVapidPrivateKeyEncrypted: z.string().nullable(), + webPushVapidSubject: z.string().nullable(), + apiGlobal: z.boolean(), + moodLogGlobal: z.boolean(), + umamiEnabled: z.boolean(), + umamiScriptUrl: z.string().nullable(), + umamiWebsiteId: z.string().nullable(), + glitchtipEnabled: z.boolean(), + glitchtipDsn: z.string().nullable(), + glitchtipEnvironment: z.string().nullable(), + reminderLateMinutes: z.number().int(), + reminderMissedMinutes: z.number().int(), + adminAiKeyEncrypted: z.string().nullable(), + adminAiModel: z.string(), + adminAiBaseUrl: z.string(), + adminCodexAccessTokenEncrypted: z.string().nullable(), + adminCodexRefreshTokenEncrypted: z.string().nullable(), + adminCodexAccountIdEncrypted: z.string().nullable(), + adminCodexTokenExpiresAt: isoDateTime.nullable(), + adminCodexConnectedAt: isoDateTime.nullable(), + adminCodexConnectionStatus: z.string(), + adminAiInsightsFeedbackSummary: z.unknown().nullable(), + defaultUserTimezone: z.string().nullable(), + assistantEnabled: z.boolean(), + assistantCoachEnabled: z.boolean(), + assistantBriefingEnabled: z.boolean(), + assistantInsightStatusEnabled: z.boolean(), + assistantCorrelationsEnabled: z.boolean(), + assistantHealthScoreExplainerEnabled: z.boolean(), + moduleAvailabilityJson: z.unknown().nullable(), + documentMaxFileBytes: z.number().int(), + documentQuotaBytes: z.string().regex(/^\d+$/), }) .passthrough(); @@ -237,8 +374,11 @@ const labResultBackupSchema = z takenAt: isoDateTime, source: z.string().min(1), biomarkerName: z.string().nullable().optional(), + biomarkerId: z.string().nullable().optional(), note: z.string().nullable().optional(), createdAt: isoDateTime.optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), + deletedAt: isoDateTime.nullable().optional(), updatedAt: isoDateTime.optional(), }) .passthrough(); @@ -275,6 +415,10 @@ const illnessDayLogBackupSchema = z symptoms: z.array(illnessSymptomBackupSchema).default([]), note: z.string().nullable().optional(), updatedAt: isoDateTime.optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), + tz: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + deletedAt: isoDateTime.nullable().optional(), }) .passthrough(); @@ -291,6 +435,8 @@ const illnessEpisodeBackupSchema = z parentConditionId: z.string().nullable().optional(), note: z.string().nullable().optional(), createdAt: isoDateTime.optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), + deletedAt: isoDateTime.nullable().optional(), updatedAt: isoDateTime.optional(), dayLogs: z.array(illnessDayLogBackupSchema).default([]), }) @@ -308,6 +454,9 @@ const allergyBackupSchema = z reaction: z.string().nullable().optional(), note: z.string().nullable().optional(), createdAt: isoDateTime.optional(), + reactionEncrypted: base64BytesSchema.nullable().optional(), + notesEncrypted: base64BytesSchema.nullable().optional(), + deletedAt: isoDateTime.nullable().optional(), updatedAt: isoDateTime.optional(), }) .passthrough(); @@ -346,14 +495,6 @@ const workoutBackupSchema = z }) .passthrough(); -const base64BytesSchema = z - .string() - .min(1) - .refine( - (value) => value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value), - { message: "Expected base64-encoded encrypted bytes" }, - ); - const documentBackupSchema = z .object({ id: z.string().min(1), @@ -401,9 +542,10 @@ const backupManifestSchema = z */ export const backupPayloadSchema = z .object({ - schemaVersion: z.string().min(1).default(BACKUP_SCHEMA_VERSION), + schemaVersion: z.string().min(1).default(LEGACY_BACKUP_SCHEMA_VERSION), exportedAt: isoDateTime, userId: z.string().min(1), + appSettings: appSettingsBackupSchema.nullable().default(null), measurements: z.array(measurementSchema).default([]), medications: z.array(medicationSchema).default([]), intakeEvents: z.array(intakeEventSchema).default([]), @@ -425,7 +567,19 @@ export const backupPayloadSchema = z documents: z.array(documentBackupSchema).default([]), manifest: backupManifestSchema.nullable().default(null), }) - .passthrough(); + .passthrough() + .superRefine((payload, ctx) => { + if (payload.schemaVersion !== BACKUP_SCHEMA_VERSION) return; + payload.measurements.forEach((measurement, index) => { + if (!measurement.id) { + ctx.addIssue({ + code: "custom", + path: ["measurements", index, "id"], + message: "Canonical v2 measurements require a stable id", + }); + } + }); + }); export type BackupPayload = z.infer; @@ -512,5 +666,8 @@ export function parseBackupPayload(input: string | unknown): BackupPayload { * new shape carried. */ export function isCompatibleSchemaVersion(version: string): boolean { - return version === BACKUP_SCHEMA_VERSION; + return ( + version === LEGACY_BACKUP_SCHEMA_VERSION || + version === BACKUP_SCHEMA_VERSION + ); } diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index e61f05ff9..6649ffa2f 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -1,6 +1,7 @@ import { Buffer } from "node:buffer"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Prisma } from "@/generated/prisma/client"; process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -117,6 +118,50 @@ describe("canonical disaster-recovery backup round-trip", () => { const prisma = getPrismaClient(); const admin = await seedAdminSession(); const ownerId = admin.id; + const settings = await prisma.appSettings.create({ + data: { + id: "singleton", + registrationEnabled: false, + mfaRequired: true, + defaultLocale: "en", + telegramGlobal: false, + ntfyGlobal: false, + webPushGlobal: false, + webPushVapidPublicKey: "public-dr", + webPushVapidPrivateKeyEncrypted: "private-dr", + webPushVapidSubject: "mailto:dr@example.test", + apiGlobal: false, + moodLogGlobal: false, + umamiEnabled: true, + umamiScriptUrl: "https://analytics.example.test/script.js", + umamiWebsiteId: "website-dr", + glitchtipEnabled: true, + glitchtipDsn: "https://glitchtip.example.test/1", + glitchtipEnvironment: "dr", + reminderLateMinutes: 47, + reminderMissedMinutes: 93, + adminAiKeyEncrypted: "ai-key-dr", + adminAiModel: "model-dr", + adminAiBaseUrl: "https://ai.example.test/v1", + adminCodexAccessTokenEncrypted: "access-dr", + adminCodexRefreshTokenEncrypted: "refresh-dr", + adminCodexAccountIdEncrypted: "account-dr", + adminCodexTokenExpiresAt: new Date("2026-07-03T00:00:00.000Z"), + adminCodexConnectedAt: new Date("2026-07-01T00:00:00.000Z"), + adminCodexConnectionStatus: "connected", + adminAiInsightsFeedbackSummary: { helpful: 8, total: 10 }, + defaultUserTimezone: "Europe/London", + assistantEnabled: false, + assistantCoachEnabled: false, + assistantBriefingEnabled: false, + assistantInsightStatusEnabled: false, + assistantCorrelationsEnabled: false, + assistantHealthScoreExplainerEnabled: false, + moduleAvailabilityJson: { insights: true, nutrients: false }, + documentMaxFileBytes: 12_345_678, + documentQuotaBytes: 9_876_543_210n, + }, + }); const measurement = await prisma.measurement.create({ data: { @@ -193,6 +238,146 @@ describe("canonical disaster-recovery backup round-trip", () => { }, }); + const medication = await prisma.medication.create({ + data: { + id: "medication-dr", + userId: ownerId, + name: "Canonical medicine", + dose: "2 mg", + treatmentClass: "GENERIC", + dosesPerUnit: 4, + unitsPerDose: "0.5000", + active: false, + notificationsEnabled: false, + pausedAt: new Date("2026-06-25T08:00:00.000Z"), + snoozedUntil: new Date("2026-07-05T08:00:00.000Z"), + startsOn: new Date("2026-06-01T00:00:00.000Z"), + endsOn: new Date("2026-08-01T00:00:00.000Z"), + asNeeded: false, + deliveryForm: "INJECTION", + trackInjectionSites: true, + allowedInjectionSites: ["THIGH_LEFT", "THIGH_RIGHT"], + liveActivityEnabled: true, + criticalAlarmEnabled: true, + atcCode: "A10BX10", + rxNormCode: "12345", + lowStockNotifiedAt: new Date("2026-06-26T08:00:00.000Z"), + lowStockNotifiedThresholdDays: 7, + reorderLeadDays: 3, + externalSource: "APPLE_HEALTH", + externalId: "medication-external-dr", + schedules: { + create: { + id: "schedule-dr", + windowStart: "08:00", + windowEnd: "10:00", + label: "Morning", + dose: "1 mg", + daysOfWeek: "i2;1,3,5", + timesOfDay: ["08:00", "20:00"], + reminderGraceMinutes: 90, + rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR", + scheduleType: "CYCLIC", + cyclicOnWeeks: 3, + cyclicOffWeeks: 1, + doseWindows: [ + { timeOfDay: "08:00", start: "07:30", end: "09:00" }, + ], + }, + }, + }, + include: { schedules: true }, + }); + const intake = await prisma.medicationIntakeEvent.create({ + data: { + id: "intake-dr", + userId: ownerId, + medicationId: medication.id, + scheduledFor: new Date("2026-06-29T08:00:00.000Z"), + takenAt: new Date("2026-06-29T08:05:00.000Z"), + autoMissed: false, + attributionSource: "USER_PIN", + source: "APPLE_HEALTH", + idempotencyKey: "intake-idempotency-dr", + injectionSite: "THIGH_LEFT", + doseTaken: "1 mg", + inventoryConsumption: [{ itemId: "pen-dr", units: 0.5 }], + externalId: "intake-external-dr", + syncVersion: 5, + deletedAt: tombstoneDeletedAt, + }, + }); + + const cycleProfile = await prisma.cycleProfile.create({ + data: { + id: "cycle-profile-dr", + userId: ownerId, + goal: "GENERAL_HEALTH", + cycleTrackingEnabled: true, + typicalCycleLength: 28, + typicalPeriodLength: 5, + lutealPhaseLength: 14, + secondarySymptom: "CERVIX", + predictionEnabled: false, + rawChartMode: true, + discreetNotifications: true, + sensitiveCategoryEncryption: true, + }, + }); + const cycle = await prisma.menstrualCycle.create({ + data: { + id: "cycle-dr", + userId: ownerId, + startDate: "2026-06-01", + endDate: "2026-06-28", + periodEndDate: "2026-06-05", + lengthDays: 28, + ovulationDate: "2026-06-14", + ovulationConfirmed: true, + isPredicted: false, + tz: "Europe/London", + syncVersion: 6, + deletedAt: tombstoneDeletedAt, + }, + }); + const cycleCategory = + await prisma.cycleSymptomCategory.findFirstOrThrow(); + const cycleSymptom = await prisma.cycleSymptom.create({ + data: { + id: "cycle-symptom-dr", + userId: ownerId, + categoryId: cycleCategory.id, + key: "cycle_cramps_dr", + labelKey: "cycle.symptom.crampsDr", + }, + }); + const cycleDay = await prisma.cycleDayLog.create({ + data: { + id: "cycle-day-dr", + userId: ownerId, + cycleId: cycle.id, + date: "2026-06-02", + flow: "HEAVY", + intermenstrualBleeding: true, + basalBodyTempC: 36.7, + temperatureExcluded: true, + ovulationTest: "NEGATIVE", + cervicalMucus: "CREAMY", + cervixPosition: "LOW", + cervixFirmness: "FIRM", + cervixOpening: "CLOSED", + sexualActivity: false, + sensitiveEncrypted: "sensitive-cycle-dr", + notesEncrypted: "notes-cycle-dr", + source: "APPLE_HEALTH", + externalId: "cycle-day-external-dr", + tz: "Europe/London", + syncVersion: 7, + deletedAt: tombstoneDeletedAt, + symptomLinks: { create: { symptomId: cycleSymptom.id } }, + }, + }); + const biomarker = await prisma.biomarker.create({ data: { id: "biomarker-dr", @@ -219,6 +404,7 @@ describe("canonical disaster-recovery backup round-trip", () => { takenAt: new Date("2026-06-30T09:00:00.000Z"), source: "MANUAL", noteEncrypted: encryptToBytes("Fasted"), + deletedAt: tombstoneDeletedAt, }, }); @@ -232,6 +418,7 @@ describe("canonical disaster-recovery backup round-trip", () => { onsetAt: new Date("2026-06-20T00:00:00.000Z"), resolvedAt: new Date("2026-06-24T00:00:00.000Z"), noteEncrypted: encryptToBytes("Recovered fully"), + deletedAt: tombstoneDeletedAt, }, }); const symptom = await prisma.illnessSymptom.create({ @@ -250,6 +437,8 @@ describe("canonical disaster-recovery backup round-trip", () => { functionalImpact: 2, feverC: 38.1, noteEncrypted: encryptToBytes("Rest day"), + tz: "Europe/London", + deletedAt: tombstoneDeletedAt, symptomLinks: { create: { symptomId: symptom.id, severity: 2 }, }, @@ -268,6 +457,7 @@ describe("canonical disaster-recovery backup round-trip", () => { onsetAt: new Date("2020-01-01T00:00:00.000Z"), reactionEncrypted: encryptToBytes("Hives"), notesEncrypted: encryptToBytes("Avoid beta-lactams"), + deletedAt: tombstoneDeletedAt, }, }); const family = await prisma.familyHistoryEntry.create({ @@ -342,14 +532,21 @@ describe("canonical disaster-recovery backup round-trip", () => { decryptBackup(offhostObject!, Buffer.from(OFFHOST_ENCRYPTION_KEY, "hex")), ); expect(payload).toMatchObject({ - schemaVersion: "1", + schemaVersion: "2", userId: ownerId, + appSettings: { id: settings.id }, }); expect(payload.documents[0]).not.toHaveProperty("summary"); expect(JSON.stringify(payload.documents[0])).not.toContain( "private PDF bytes", ); + await prisma.appSettings.deleteMany(); + await prisma.medicationIntakeEvent.deleteMany({ where: { userId: ownerId } }); + await prisma.medication.deleteMany({ where: { userId: ownerId } }); + await prisma.cycleDayLog.deleteMany({ where: { userId: ownerId } }); + await prisma.menstrualCycle.deleteMany({ where: { userId: ownerId } }); + await prisma.cycleProfile.deleteMany({ where: { userId: ownerId } }); await prisma.inboundDocument.deleteMany({ where: { userId: ownerId } }); await prisma.workout.deleteMany({ where: { userId: ownerId } }); await prisma.familyHistoryEntry.deleteMany({ where: { userId: ownerId } }); @@ -373,6 +570,44 @@ describe("canonical disaster-recovery backup round-trip", () => { expect((await restore()).status).toBe(200); + expect( + await prisma.appSettings.findUniqueOrThrow({ + where: { id: settings.id }, + }), + ).toEqual(settings); + expect( + await prisma.medication.findUniqueOrThrow({ + where: { id: medication.id }, + include: { schedules: true }, + }), + ).toEqual(medication); + expect( + await prisma.medicationIntakeEvent.findUniqueOrThrow({ + where: { id: intake.id }, + }), + ).toEqual(intake); + expect( + await prisma.cycleProfile.findUniqueOrThrow({ + where: { id: cycleProfile.id }, + }), + ).toEqual(cycleProfile); + expect( + await prisma.menstrualCycle.findUniqueOrThrow({ + where: { id: cycle.id }, + }), + ).toEqual(cycle); + expect( + await prisma.cycleDayLog.findUniqueOrThrow({ + where: { id: cycleDay.id }, + }), + ).toEqual(cycleDay); + expect( + await prisma.cycleSymptomLink.findMany({ + where: { dayLogId: cycleDay.id }, + select: { symptomId: true }, + }), + ).toEqual([{ symptomId: cycleSymptom.id }]); + expect(await prisma.labResult.count({ where: { userId: ownerId } })).toBe( 1, ); @@ -397,45 +632,19 @@ describe("canonical disaster-recovery backup round-trip", () => { await prisma.measurement.findUniqueOrThrow({ where: { id: measurement.id }, }), - ).toEqual( - expect.objectContaining({ - userId: ownerId, - type: measurement.type, - value: measurement.value, - unit: measurement.unit, - measuredAt: measurement.measuredAt, - source: measurement.source, - deletedAt: null, - }), - ); + ).toEqual(measurement); expect( await prisma.measurement.findUniqueOrThrow({ where: { id: deletedMeasurement.id }, }), - ).toEqual( - expect.objectContaining({ - userId: ownerId, - deletedAt: tombstoneDeletedAt, - }), - ); + ).toEqual(deletedMeasurement); const restoredLab = await prisma.labResult.findUniqueOrThrow({ where: { id: lab.id }, }); expect(decryptFromBytes(restoredLab.noteEncrypted!)).toBe("Fasted"); expect(restoredLab.biomarkerId).toBe(biomarker.id); - expect(restoredLab).toEqual( - expect.objectContaining({ - panel: lab.panel, - analyte: lab.analyte, - value: lab.value, - unit: lab.unit, - referenceLow: lab.referenceLow, - referenceHigh: lab.referenceHigh, - takenAt: lab.takenAt, - source: lab.source, - }), - ); + expect(restoredLab).toEqual(lab); const restoredBiomarker = await prisma.biomarker.findUniqueOrThrow({ where: { id: biomarker.id }, }); @@ -456,26 +665,22 @@ describe("canonical disaster-recovery backup round-trip", () => { const restoredIllness = await prisma.illnessEpisode.findUniqueOrThrow({ where: { id: illness.id }, }); - expect(restoredIllness).toEqual( - expect.objectContaining({ - label: illness.label, - type: illness.type, - lifecycle: illness.lifecycle, - onsetAt: illness.onsetAt, - resolvedAt: illness.resolvedAt, - }), - ); + expect(restoredIllness).toEqual(illness); expect(decryptFromBytes(restoredIllness.noteEncrypted!)).toBe( "Recovered fully", ); const restoredIllnessDay = await prisma.illnessDayLog.findUniqueOrThrow({ where: { id: illnessDay.id }, - include: { symptomLinks: true }, }); + expect(restoredIllnessDay).toEqual(illnessDay); expect(decryptFromBytes(restoredIllnessDay.noteEncrypted!)).toBe( "Rest day", ); - expect(restoredIllnessDay.symptomLinks).toEqual([ + expect( + await prisma.illnessSymptomLink.findMany({ + where: { dayLogId: illnessDay.id }, + }), + ).toEqual([ expect.objectContaining({ symptomId: symptom.id, severity: 2 }), ]); @@ -483,16 +688,7 @@ describe("canonical disaster-recovery backup round-trip", () => { where: { id: allergy.id }, }); expect(decryptFromBytes(restoredAllergy.reactionEncrypted!)).toBe("Hives"); - expect(restoredAllergy).toEqual( - expect.objectContaining({ - substance: allergy.substance, - category: allergy.category, - type: allergy.type, - severity: allergy.severity, - status: allergy.status, - onsetAt: allergy.onsetAt, - }), - ); + expect(restoredAllergy).toEqual(allergy); const restoredFamily = await prisma.familyHistoryEntry.findUniqueOrThrow({ where: { id: family.id }, }); @@ -612,6 +808,149 @@ describe("canonical disaster-recovery backup round-trip", () => { }), ).toEqual([{ id: illness.id }]); }); + + it("rejects a stable measurement id owned by another account without clearing either owner", async () => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + const other = await prisma.user.create({ + data: { username: "stable-id-owner-other" }, + }); + const original = await prisma.measurement.create({ + data: { + id: "owner-original-measurement", + userId: admin.id, + type: "PULSE", + value: 70, + unit: "bpm", + measuredAt: new Date("2026-07-08T10:00:00.000Z"), + }, + }); + const conflicting = await prisma.measurement.create({ + data: { + id: "cross-owner-stable-id", + userId: other.id, + type: "PULSE", + value: 65, + unit: "bpm", + measuredAt: new Date("2026-07-08T11:00:00.000Z"), + }, + }); + const payload = backupPayloadSchema.parse({ + schemaVersion: "2", + exportedAt: "2026-07-12T00:00:00.000Z", + userId: admin.id, + measurements: [ + { + id: conflicting.id, + type: "PULSE", + value: 80, + unit: "bpm", + measuredAt: "2026-07-08T12:00:00.000Z", + source: "IMPORT", + }, + ], + }); + const backup = await prisma.dataBackup.create({ + data: { + userId: admin.id, + type: "STABLE_ID_OWNER_CONFLICT", + data: encrypt(JSON.stringify(payload)), + }, + }); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + + expect(response.status).toBe(500); + expect( + await prisma.measurement.findUnique({ where: { id: original.id } }), + ).toEqual(original); + expect( + await prisma.measurement.findUnique({ where: { id: conflicting.id } }), + ).toEqual(conflicting); + }); + + it("restores many stable-id measurements with a bounded number of writes", async () => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + const measurements = Array.from({ length: 250 }, (_, index) => ({ + id: `stable-measurement-${index}`, + type: "PULSE" as const, + value: 60 + (index % 20), + unit: "bpm", + measuredAt: new Date( + Date.UTC(2026, 6, 10, 0, 0, 0, index), + ).toISOString(), + source: "IMPORT" as const, + externalId: `external-${index}`, + syncVersion: 3, + deletedAt: index % 10 === 0 ? "2026-07-11T00:00:00.000Z" : null, + })); + const payload = backupPayloadSchema.parse({ + schemaVersion: "2", + exportedAt: "2026-07-12T00:00:00.000Z", + userId: admin.id, + measurements, + }); + const backup = await prisma.dataBackup.create({ + data: { + userId: admin.id, + type: "BOUNDED_STABLE_ID_RESTORE", + data: encrypt(JSON.stringify(payload)), + }, + }); + let stableWriteQueries = 0; + const originalTransaction = prisma.$transaction.bind(prisma) as unknown as ( + callback: (tx: Prisma.TransactionClient) => Promise, + options?: { maxWait?: number; timeout?: number }, + ) => Promise; + const transactionSpy = vi.spyOn(prisma, "$transaction"); + transactionSpy.mockImplementation( + (async ( + callback: (tx: Prisma.TransactionClient) => Promise, + options?: { maxWait?: number; timeout?: number }, + ) => + originalTransaction(async (tx) => { + const originalUpsert = tx.measurement.upsert.bind(tx.measurement); + const originalCreateMany = tx.measurement.createMany.bind( + tx.measurement, + ); + vi.spyOn(tx.measurement, "upsert").mockImplementation(async (args) => { + stableWriteQueries += 1; + return originalUpsert(args); + }); + vi.spyOn(tx.measurement, "createMany").mockImplementation( + async (args) => { + stableWriteQueries += 1; + return originalCreateMany(args); + }, + ); + return callback(tx); + }, options)) as never, + ); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + + expect(response.status).toBe(200); + transactionSpy.mockRestore(); + expect(stableWriteQueries).toBeLessThanOrEqual(2); + expect( + await prisma.measurement.findMany({ + where: { userId: admin.id }, + orderBy: { id: "asc" }, + select: { id: true }, + }), + ).toEqual( + measurements + .map(({ id }) => ({ id })) + .sort((left, right) => left.id.localeCompare(right.id)), + ); + }); it.each([ { label: "invalid enum",