diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 9d553f2cb..68772b223 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -1,35 +1,18 @@ /** - * POST /api/admin/backups/[id]/restore — admin-only restore from backup. + * POST /api/admin/backups/[id]/restore — admin-only disaster recovery. * - * The riskiest endpoint in the backup family. Mirroring the v1.4.14 wipe - * scope (`DELETE /api/admin/data`), the restore replaces the **target - * user's** personal data with the contents of the snapshot: + * 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. * - * 1. Decrypt + parse the `DataBackup.data` blob. - * 2. In a single Prisma transaction: - * a. Delete the user's measurements, medications (cascades to - * schedules + intake events + telegram messages + - * reminder-phase configs), mood entries, notification channels, - * push subscriptions, telegram scheduled deletions. - * b. Re-create measurements, medications + schedules, intake - * events, mood entries from the payload. - * 3. Outside the transaction, write `admin.backups.restore` to the - * AuditLog so the trail outlives the operation. Audit rows are - * intentionally NOT touched by the restore (same contract as the - * wipe). - * - * Triple-confirm: the request body must carry `confirm: "RESTORE"`. - * The client also gates the form on a typed-string match so an - * accidental click cannot fire this. - * - * Idempotency: wrapped in `withIdempotency()` so retries with the same - * `Idempotency-Key` replay the original outcome instead of double- - * restoring (unlikely in practice — the second run would be a no-op - * because the user data already matches the backup — but this still - * prevents two parallel admin clicks from racing through). - * - * Phase B1 / criterion 3 of the v1.4.15 backup-completeness work. + * Metadata-only portable document exports are rejected before mutation; the + * importer never fabricates content. Audit rows remain outside the wipe, and + * cache invalidation runs only after the complete restore transaction. */ +import { Buffer } from "node:buffer"; + import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler"; @@ -37,6 +20,9 @@ import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { decrypt } from "@/lib/crypto"; import { encryptNote } from "@/lib/crypto/note-cipher"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { encryptContextToBytes } from "@/lib/labs/biomarker-store"; +import { encryptNoteToBytes } from "@/lib/labs/store"; import { defaultUserIdResolver, withIdempotency } from "@/lib/idempotency"; import { annotate } from "@/lib/logging/context"; import { @@ -57,12 +43,6 @@ export const dynamic = "force-dynamic"; interface RestoreResponse { restored: true; - // v1.28 — `summary` reports every domain PRESENT in the uploaded file - // (informational — it now includes labs/illness/allergies/family-history/ - // workouts/documents counts too). `cleared` below is the narrower, - // authoritative list of what this restore actually wiped + recreated — - // those newer domains are export-only for now and are NOT touched by a - // restore, so they are deliberately absent from `cleared`. summary: BackupSummary; cleared: { measurements: number; @@ -72,13 +52,26 @@ interface RestoreResponse { notificationChannels: number; pushSubscriptions: number; telegramScheduledDeletions: number; - // v1.15.0 — cycle tables wiped before recreate. cycles: number; cycleDayLogs: number; cycleProfile: number; + labResults: number; + biomarkers: number; + illnessEpisodes: number; + allergies: number; + familyHistory: number; + workouts: number; + documents: number; }; } +function decodeEncryptedBytes(encoded: string): Uint8Array { + const decoded = Buffer.from(encoded, "base64"); + const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); + bytes.set(decoded); + return bytes; +} + /** * Maps a measurement-type string from the backup back into the * Prisma enum literal. Backups are written from `enum MeasurementType` @@ -307,14 +300,48 @@ const handler = apiHandler( } } + // Portable exports intentionally omit document ciphertext. They remain + // valid upload/download artifacts, but cannot be used to manufacture an + // InboundDocument row. A DR restore must fail before any delete rather + // than inventing empty/plaintext content. + const incompleteDocument = payload.documents.find( + (document) => + document.contentEncrypted === undefined || + document.contentSha256 === undefined || + document.contentCodec === undefined || + document.providerType === undefined || + document.reportDate === undefined || + document.documentDate === undefined || + document.errorReason === undefined || + document.summaryEncrypted === undefined || + document.summaryGeneratedAt === undefined || + document.summaryState === undefined || + document.createdAt === undefined || + document.updatedAt === undefined, + ); + if (incompleteDocument) { + await auditLog("admin.backups.restore.failed", { + userId: admin.id, + ipAddress: getClientIp(request), + details: { + backupId: id, + ownerId, + reason: "document_ciphertext_missing", + documentId: incompleteDocument.id, + }, + }); + return apiError( + `Document '${incompleteDocument.id}' is metadata-only and cannot be restored`, + 422, + ); + } + let cleared: RestoreResponse["cleared"]; try { cleared = await prisma.$transaction(async (tx) => { - // ── delete-then-recreate, mirroring the wipe scope plus mood ── - // Order matters: child rows first (intake events / schedules - // cascade via FK, but we delete them explicitly to capture the - // count for the audit trail). Telegram-reminder messages cascade - // off `medication`, so the medication delete sweeps them. + // Delete every serialized owner-scoped partition before rebuilding it + // in the same transaction. Child rows either go first for counts or + // cascade from their serialized parent. const intake = await tx.medicationIntakeEvent.deleteMany({ where: { userId: ownerId }, }); @@ -349,6 +376,27 @@ const handler = apiHandler( const tgDel = await tx.telegramScheduledDeletion.deleteMany({ where: { userId: ownerId }, }); + const documents = await tx.inboundDocument.deleteMany({ + where: { userId: ownerId }, + }); + const workouts = await tx.workout.deleteMany({ + where: { userId: ownerId }, + }); + const familyHistory = await tx.familyHistoryEntry.deleteMany({ + where: { userId: ownerId }, + }); + const allergies = await tx.allergy.deleteMany({ + where: { userId: ownerId }, + }); + const illnessEpisodes = await tx.illnessEpisode.deleteMany({ + where: { userId: ownerId }, + }); + const labResults = await tx.labResult.deleteMany({ + where: { userId: ownerId }, + }); + const biomarkers = await tx.biomarker.deleteMany({ + where: { userId: ownerId }, + }); // ── re-create from payload ── if (payload.measurements.length > 0) { @@ -426,18 +474,56 @@ const handler = apiHandler( } if (payload.moodEntries.length > 0) { - await tx.moodEntry.createMany({ - data: payload.moodEntries.map((e) => ({ - userId: ownerId, - date: e.date, - mood: e.mood, - score: e.score, - tags: e.tags ?? null, - source: e.source ?? "MOODLOG", - moodLoggedAt: new Date(e.loggedAt), - })), - skipDuplicates: true, - }); + const factorKeys = [ + ...new Set( + payload.moodEntries.flatMap((entry) => + entry.factors.map((factor) => factor.key), + ), + ), + ]; + const factorRows = + factorKeys.length === 0 + ? [] + : await tx.moodTag.findMany({ + where: { + key: { in: factorKeys }, + kind: "RATED", + userId: null, + }, + select: { id: true, key: true }, + }); + const factorByKey = new Map( + factorRows.map((factor) => [factor.key, factor.id]), + ); + if (factorByKey.size !== factorKeys.length) { + const missing = factorKeys.filter((key) => !factorByKey.has(key)); + throw new Error(`Unknown mood factor keys: ${missing.join(", ")}`); + } + + for (const entry of payload.moodEntries) { + const created = await tx.moodEntry.create({ + data: { + ...(entry.id ? { id: entry.id } : {}), + userId: ownerId, + date: entry.date, + mood: entry.mood, + score: entry.score, + tags: entry.tags ?? null, + source: entry.source ?? "MOODLOG", + externalId: entry.externalId ?? null, + moodLoggedAt: new Date(entry.loggedAt), + }, + }); + if (entry.factors.length > 0) { + await tx.moodEntryTagLink.createMany({ + data: entry.factors.map((factor) => ({ + moodEntryId: created.id, + moodTagId: factorByKey.get(factor.key)!, + rating: factor.rating, + })), + }); + } + } } // v1.15.0 — cycle tables (profile + observed spans + day-logs + @@ -445,6 +531,276 @@ const handler = apiHandler( // above. `notesEncrypted` is restored as ciphertext verbatim. const cycleCleared = await restoreCycleData(tx, ownerId, payload); + const biomarkerByName = new Map(); + for (const biomarker of payload.biomarkers) { + const created = await tx.biomarker.create({ + data: { + ...(biomarker.id ? { id: biomarker.id } : {}), + userId: ownerId, + name: biomarker.name, + unit: biomarker.unit, + lowerBound: biomarker.lowerBound ?? null, + upperBound: biomarker.upperBound ?? null, + panel: biomarker.panel ?? null, + hidden: biomarker.hidden ?? false, + contextEncrypted: + biomarker.context == null + ? null + : encryptContextToBytes(biomarker.context), + ...(biomarker.createdAt + ? { createdAt: new Date(biomarker.createdAt) } + : {}), + ...(biomarker.updatedAt + ? { updatedAt: new Date(biomarker.updatedAt) } + : {}), + }, + }); + biomarkerByName.set(biomarker.name, created.id); + } + + for (const lab of payload.labResults) { + const biomarkerId = lab.biomarkerName + ? biomarkerByName.get(lab.biomarkerName) + : undefined; + if (lab.biomarkerName && !biomarkerId) { + throw new Error( + `Unknown biomarker reference: ${lab.biomarkerName}`, + ); + } + await tx.labResult.create({ + data: { + ...(lab.id ? { id: lab.id } : {}), + userId: ownerId, + biomarkerId: biomarkerId ?? null, + panel: lab.panel ?? null, + analyte: lab.analyte, + value: lab.value ?? null, + valueText: lab.valueText ?? null, + unit: lab.unit, + referenceLow: lab.referenceLow ?? null, + referenceHigh: lab.referenceHigh ?? null, + takenAt: new Date(lab.takenAt), + source: lab.source, + noteEncrypted: + lab.note == null ? null : encryptNoteToBytes(lab.note), + ...(lab.createdAt + ? { createdAt: new Date(lab.createdAt) } + : {}), + ...(lab.updatedAt + ? { updatedAt: new Date(lab.updatedAt) } + : {}), + }, + }); + } + + const episodeIds = new Set( + payload.illnessEpisodes.map((episode) => episode.id), + ); + for (const episode of payload.illnessEpisodes) { + if ( + episode.parentConditionId && + !episodeIds.has(episode.parentConditionId) + ) { + throw new Error( + `Unknown illness parent: ${episode.parentConditionId}`, + ); + } + await tx.illnessEpisode.create({ + data: { + id: episode.id, + userId: ownerId, + label: episode.label, + type: episode.type as never, + lifecycle: episode.lifecycle as never, + onsetAt: new Date(episode.onsetAt), + resolvedAt: episode.resolvedAt + ? new Date(episode.resolvedAt) + : null, + parentConditionId: null, + noteEncrypted: + episode.note == null ? null : encryptToBytes(episode.note), + ...(episode.createdAt + ? { createdAt: new Date(episode.createdAt) } + : {}), + ...(episode.updatedAt + ? { updatedAt: new Date(episode.updatedAt) } + : {}), + }, + }); + } + for (const episode of payload.illnessEpisodes) { + if (episode.parentConditionId) { + await tx.illnessEpisode.update({ + where: { id: episode.id }, + data: { parentConditionId: episode.parentConditionId }, + }); + } + } + + const symptomKeys = [ + ...new Set( + payload.illnessEpisodes.flatMap((episode) => + episode.dayLogs.flatMap((dayLog) => + dayLog.symptoms.map((symptom) => symptom.key), + ), + ), + ), + ]; + const symptomRows = + symptomKeys.length === 0 + ? [] + : await tx.illnessSymptom.findMany({ + where: { key: { in: symptomKeys } }, + select: { id: true, key: true }, + }); + const symptomByKey = new Map( + symptomRows.map((symptom) => [symptom.key, symptom.id]), + ); + if (symptomByKey.size !== symptomKeys.length) { + const missing = symptomKeys.filter((key) => !symptomByKey.has(key)); + throw new Error(`Unknown illness symptom keys: ${missing.join(", ")}`); + } + for (const episode of payload.illnessEpisodes) { + for (const dayLog of episode.dayLogs) { + await tx.illnessDayLog.create({ + data: { + ...(dayLog.id ? { id: dayLog.id } : {}), + userId: ownerId, + episodeId: episode.id, + date: dayLog.date, + functionalImpact: dayLog.functionalImpact ?? null, + feverC: dayLog.feverC ?? null, + noteEncrypted: + dayLog.note == null ? null : encryptToBytes(dayLog.note), + ...(dayLog.updatedAt + ? { updatedAt: new Date(dayLog.updatedAt) } + : {}), + symptomLinks: { + create: dayLog.symptoms.map((symptom) => ({ + symptomId: symptomByKey.get(symptom.key)!, + severity: symptom.severity ?? null, + })), + }, + }, + }); + } + } + + for (const allergy of payload.allergies) { + await tx.allergy.create({ + data: { + id: allergy.id, + userId: ownerId, + substance: allergy.substance, + category: allergy.category as never, + type: allergy.type as never, + severity: (allergy.severity ?? null) as never, + status: allergy.status as never, + onsetAt: allergy.onsetAt ? new Date(allergy.onsetAt) : null, + reactionEncrypted: + allergy.reaction == null + ? null + : encryptToBytes(allergy.reaction), + notesEncrypted: + allergy.note == null ? null : encryptToBytes(allergy.note), + ...(allergy.createdAt + ? { createdAt: new Date(allergy.createdAt) } + : {}), + ...(allergy.updatedAt + ? { updatedAt: new Date(allergy.updatedAt) } + : {}), + }, + }); + } + + for (const familyEntry of payload.familyHistory) { + await tx.familyHistoryEntry.create({ + data: { + id: familyEntry.id, + userId: ownerId, + relationship: familyEntry.relationship as never, + condition: familyEntry.condition, + ageAtOnset: familyEntry.ageAtOnset ?? null, + notesEncrypted: + familyEntry.note == null + ? null + : encryptToBytes(familyEntry.note), + ...(familyEntry.createdAt + ? { createdAt: new Date(familyEntry.createdAt) } + : {}), + ...(familyEntry.updatedAt + ? { updatedAt: new Date(familyEntry.updatedAt) } + : {}), + }, + }); + } + + if (payload.workouts.length > 0) { + await tx.workout.createMany({ + data: payload.workouts.map((workout) => ({ + ...(workout.id ? { id: workout.id } : {}), + userId: ownerId, + sportType: workout.sportType, + startedAt: new Date(workout.startedAt), + endedAt: new Date(workout.endedAt), + durationSec: workout.durationSec, + totalEnergyKcal: workout.totalEnergyKcal ?? null, + totalDistanceM: workout.totalDistanceM ?? null, + avgHeartRate: workout.avgHeartRate ?? null, + maxHeartRate: workout.maxHeartRate ?? null, + minHeartRate: workout.minHeartRate ?? null, + stepCount: workout.stepCount ?? null, + elevationM: workout.elevationM ?? null, + pauseDurationSec: workout.pauseDurationSec ?? null, + source: workout.source as never, + externalId: workout.externalId ?? null, + ...(workout.createdAt + ? { createdAt: new Date(workout.createdAt) } + : {}), + ...(workout.updatedAt + ? { updatedAt: new Date(workout.updatedAt) } + : {}), + })), + }); + } + + if (payload.documents.length > 0) { + await tx.inboundDocument.createMany({ + data: payload.documents.map((document) => ({ + id: document.id, + userId: ownerId, + kind: document.kind as never, + title: document.title ?? null, + filename: document.filename ?? null, + mimeType: document.mimeType, + byteSize: document.byteSize, + contentEncrypted: decodeEncryptedBytes( + document.contentEncrypted!, + ), + contentSha256: document.contentSha256 ?? null, + contentCodec: document.contentCodec!, + status: document.status as never, + providerType: document.providerType ?? null, + reportDate: document.reportDate + ? new Date(document.reportDate) + : null, + documentDate: document.documentDate + ? new Date(document.documentDate) + : null, + errorReason: document.errorReason ?? null, + summaryEncrypted: document.summaryEncrypted + ? decodeEncryptedBytes(document.summaryEncrypted) + : null, + summaryGeneratedAt: document.summaryGeneratedAt + ? new Date(document.summaryGeneratedAt) + : null, + summaryState: document.summaryState as never, + createdAt: new Date(document.createdAt!), + updatedAt: new Date(document.updatedAt!), + })), + }); + } + return { measurements: measurements.count, medications: meds.count, @@ -456,7 +812,17 @@ const handler = apiHandler( cycles: cycleCleared.cycles, cycleDayLogs: cycleCleared.cycleDayLogs, cycleProfile: cycleCleared.cycleProfile, + labResults: labResults.count, + biomarkers: biomarkers.count, + illnessEpisodes: illnessEpisodes.count, + allergies: allergies.count, + familyHistory: familyHistory.count, + workouts: workouts.count, + documents: documents.count, }; + }, { + maxWait: 10_000, + timeout: 120_000, }); } catch (err) { // Scrub the raw Prisma / driver error from the wire response — @@ -566,12 +932,19 @@ const handler = apiHandler( moodEntries: summary.moodEntries, cycles: summary.cycles, cycleDayLogs: summary.cycleDayLogs, + labResults: summary.labResults, + biomarkers: summary.biomarkers, + illnessEpisodes: summary.illnessEpisodes, + illnessDayLogs: summary.illnessDayLogs, + allergies: summary.allergies, + familyHistory: summary.familyHistory, + workouts: summary.workouts, + documents: summary.documents, }, }, }); - // v1.16.9 — the restore replaced every row the owner's cached - // payloads were built on; hard-evict all of that user's buckets. + // The complete transaction succeeded; only now evict owner-scoped caches. invalidateUserData(ownerId); const response: RestoreResponse = { diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts index b64c1f128..1637560a7 100644 --- a/src/lib/export/records-backup.ts +++ b/src/lib/export/records-backup.ts @@ -1,32 +1,17 @@ /** - * Structured-records / workouts / documents-manifest backup section. + * Structured records, workouts, and document backup section. * - * Shared by the on-demand full-backup builder (`full-backup-payload.ts`, - * consumed by `GET /api/export/full-backup` + `POST /api/export/encrypted`) - * AND the weekly `data-backup` pg-boss worker - * (`src/lib/jobs/reminder/backup-handlers.ts`) so both writers emit the same - * shape — the doc-comment convention `buildCycleBackupSection` already - * established for the cycle tables. + * Portable exports keep the historical v1.28 metadata-only document shape. + * The weekly disaster-recovery writer opts into the canonical mode, which + * carries the encrypted document bytes and their persistence metadata + * verbatim. The DR path never decrypts document content or summaries. * - * Closes the backup-completeness gap: the pre-existing payload covered - * measurements / medications / intake events / mood / cycle only. This adds - * lab results + the biomarker catalog, illness episodes (including - * flares/exacerbations via `parentConditionId`) with their day-logs, - * allergies, family history, and workout summaries. - * - * Every read is scoped to `userId` + `deletedAt: null` (soft-deleted rows - * never resurrect into a backup). Encrypted free-text columns are decrypted - * FAIL-SOFT (null on a bad key / corrupt row, with a wide-event warning) so - * one damaged row never aborts an entire backup — the same posture the - * illness/allergy/family-history DTO layer already uses for list-style - * reads. Never the ciphertext itself leaves this module. - * - * Document BINARIES are deliberately excluded — see `DOCUMENTS_MANIFEST`. - * Workout GPS routes + per-sample time series (`WorkoutRoute` / - * `WorkoutSamples`) are deliberately excluded — see `WORKOUTS_MANIFEST`. - * Both exclusions are disclosed in the payload's `manifest` section (and in - * the export UI copy) rather than silently implied as "everything". + * Every read is scoped to the owner. Soft-deleted rows are excluded so a + * restore cannot resurrect tombstones. Other encrypted free-text fields keep + * the established fail-soft portable representation and are re-encrypted by + * the restore importer. */ +import { Buffer } from "node:buffer"; import type { PrismaClient } from "@/generated/prisma/client"; import { decryptNoteSoft } from "@/lib/labs/store"; import { decryptContextSoft } from "@/lib/labs/biomarker-store"; @@ -60,6 +45,8 @@ export const WORKOUTS_MANIFEST_NOTE = "series are not included in this export."; export interface LabResultBackupEntry { + /** Present in canonical DR payloads so rows keep a stable identity. */ + id?: string; panel: string | null; analyte: string; value: number | null; @@ -72,9 +59,13 @@ export interface LabResultBackupEntry { /** Human-readable cross-reference into `biomarkers` below, not an id. */ biomarkerName: string | null; note: string | null; + createdAt?: string; + updatedAt?: string; } export interface BiomarkerBackupEntry { + /** Present in canonical DR payloads; name remains the natural key. */ + id?: string; name: string; unit: string; lowerBound: number | null; @@ -82,9 +73,15 @@ export interface BiomarkerBackupEntry { panel: string | null; hidden: boolean; context: string | null; + createdAt?: string; + updatedAt?: string; } export interface WorkoutBackupEntry { + /** Present in canonical DR payloads for manual workouts without externalId. */ + id?: string; + createdAt?: string; + updatedAt?: string; sportType: string; startedAt: string; endedAt: string; @@ -111,8 +108,36 @@ export interface DocumentBackupEntry { status: string; reportDate: string | null; documentDate: string | null; - summary: string | null; + /** Portable export only; canonical DR carries summaryEncrypted instead. */ + summary?: string | null; createdAt: string; + updatedAt?: string; + /** Base64 of the already-encrypted BYTEA value; never plaintext. */ + contentEncrypted?: string; + contentSha256?: string | null; + contentCodec?: string; + providerType?: string | null; + errorReason?: string | null; + /** Base64 of the already-encrypted summary BYTEA value. */ + summaryEncrypted?: string | null; + summaryGeneratedAt?: string | null; + summaryState?: string; +} + +export interface RecordsBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + +interface DisasterRecoveryDocumentRow { + contentEncrypted: Uint8Array; + contentSha256: string | null; + contentCodec: string; + providerType: string | null; + errorReason: string | null; + summaryEncrypted: Uint8Array | null; + summaryGeneratedAt: Date | null; + summaryState: string; + updatedAt: Date; } export interface RecordsBackupSection { @@ -124,7 +149,10 @@ export interface RecordsBackupSection { workouts: WorkoutBackupEntry[]; documents: DocumentBackupEntry[]; manifest: { - documents: { included: "metadata-only"; note: string }; + documents: { + included: "metadata-only" | "encrypted-content"; + note: string; + }; workouts: { included: "summary-only"; note: string }; }; } @@ -156,10 +184,8 @@ function decryptSummarySoft(buf: Uint8Array | null): string | null { } /** - * Build the structured-records / workouts / documents-manifest slice of a - * user's full backup. Accepts any client exposing the needed delegates (the - * app's global `prisma` OR the pg-boss worker's `getWorkerPrisma()` client) - * so both writers share one read. + * Build the structured-records slice. The default is the established portable + * export. Only the weekly DR writer passes `purpose: "disaster-recovery"`. */ export async function buildRecordsBackupSection( prisma: Pick< @@ -173,7 +199,52 @@ export async function buildRecordsBackupSection( | "inboundDocument" >, userId: string, + options: RecordsBackupOptions = {}, ): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; + const documentQuery = disasterRecovery + ? prisma.inboundDocument.findMany({ + where: { userId, deletedAt: null }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + kind: true, + title: true, + filename: true, + mimeType: true, + byteSize: true, + contentEncrypted: true, + contentSha256: true, + contentCodec: true, + status: true, + providerType: true, + reportDate: true, + documentDate: true, + errorReason: true, + summaryEncrypted: true, + summaryGeneratedAt: true, + summaryState: true, + createdAt: true, + updatedAt: true, + }, + }) + : prisma.inboundDocument.findMany({ + where: { userId, deletedAt: null }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + kind: true, + title: true, + filename: true, + mimeType: true, + byteSize: true, + status: true, + reportDate: true, + documentDate: true, + summaryEncrypted: true, + createdAt: true, + }, + }); const [ labResultRows, biomarkerRows, @@ -215,26 +286,17 @@ export async function buildRecordsBackupSection( where: { userId }, orderBy: { startedAt: "desc" }, }), - prisma.inboundDocument.findMany({ - where: { userId, deletedAt: null }, - orderBy: { createdAt: "desc" }, - select: { - id: true, - kind: true, - title: true, - filename: true, - mimeType: true, - byteSize: true, - status: true, - reportDate: true, - documentDate: true, - summaryEncrypted: true, - createdAt: true, - }, - }), + documentQuery, ]); const labResults: LabResultBackupEntry[] = labResultRows.map((r) => ({ + ...(disasterRecovery + ? { + id: r.id, + createdAt: r.createdAt.toISOString(), + updatedAt: r.updatedAt.toISOString(), + } + : {}), panel: r.panel, analyte: r.analyte, value: r.value, @@ -249,6 +311,13 @@ export async function buildRecordsBackupSection( })); const biomarkers: BiomarkerBackupEntry[] = biomarkerRows.map((b) => ({ + ...(disasterRecovery + ? { + id: b.id, + createdAt: b.createdAt.toISOString(), + updatedAt: b.updatedAt.toISOString(), + } + : {}), name: b.name, unit: b.unit, lowerBound: b.lowerBound, @@ -267,6 +336,13 @@ export async function buildRecordsBackupSection( const familyHistory = familyRows.map(toFamilyHistoryEntryDTO); const workouts: WorkoutBackupEntry[] = workoutRows.map((w) => ({ + ...(disasterRecovery + ? { + id: w.id, + createdAt: w.createdAt.toISOString(), + updatedAt: w.updatedAt.toISOString(), + } + : {}), sportType: w.sportType, startedAt: w.startedAt.toISOString(), endedAt: w.endedAt.toISOString(), @@ -283,21 +359,49 @@ export async function buildRecordsBackupSection( externalId: w.externalId, })); - const documents: DocumentBackupEntry[] = documentRows.map((d) => ({ - id: d.id, - kind: d.kind, - title: d.title, - filename: d.filename, - mimeType: d.mimeType, - byteSize: d.byteSize, - status: d.status, - reportDate: d.reportDate ? d.reportDate.toISOString().slice(0, 10) : null, - documentDate: d.documentDate - ? d.documentDate.toISOString().slice(0, 10) - : null, - summary: decryptSummarySoft(d.summaryEncrypted), - createdAt: d.createdAt.toISOString(), - })); + const documents: DocumentBackupEntry[] = documentRows.map((d) => { + const metadata = { + id: d.id, + kind: d.kind, + title: d.title, + filename: d.filename, + mimeType: d.mimeType, + byteSize: d.byteSize, + status: d.status, + reportDate: d.reportDate + ? d.reportDate.toISOString().slice(0, 10) + : null, + documentDate: d.documentDate + ? d.documentDate.toISOString().slice(0, 10) + : null, + createdAt: d.createdAt.toISOString(), + }; + + if (disasterRecovery && "contentEncrypted" in d) { + const dr = d as typeof d & DisasterRecoveryDocumentRow; + return { + ...metadata, + reportDate: dr.reportDate?.toISOString() ?? null, + documentDate: dr.documentDate?.toISOString() ?? null, + updatedAt: dr.updatedAt.toISOString(), + contentEncrypted: Buffer.from(dr.contentEncrypted).toString("base64"), + contentSha256: dr.contentSha256, + contentCodec: dr.contentCodec, + providerType: dr.providerType, + errorReason: dr.errorReason, + summaryEncrypted: dr.summaryEncrypted + ? Buffer.from(dr.summaryEncrypted).toString("base64") + : null, + summaryGeneratedAt: dr.summaryGeneratedAt?.toISOString() ?? null, + summaryState: dr.summaryState, + }; + } + + return { + ...metadata, + summary: decryptSummarySoft(d.summaryEncrypted), + }; + }); return { labResults, @@ -308,7 +412,13 @@ export async function buildRecordsBackupSection( workouts, documents, manifest: { - documents: { included: "metadata-only", note: DOCUMENTS_MANIFEST_NOTE }, + documents: disasterRecovery + ? { + included: "encrypted-content", + note: + "Document metadata and encrypted stored bytes are included for disaster recovery.", + } + : { included: "metadata-only", note: DOCUMENTS_MANIFEST_NOTE }, workouts: { included: "summary-only", note: WORKOUTS_MANIFEST_NOTE }, }, }; diff --git a/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts b/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts new file mode 100644 index 000000000..f829ed0be --- /dev/null +++ b/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts @@ -0,0 +1,154 @@ +import { Buffer } from "node:buffer"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + buildRecordsBackupSection: vi.fn(), + encrypt: vi.fn((value: string) => value), + getWorkerPrisma: vi.fn(), + upsert: vi.fn(), +})); + +vi.mock("@/lib/export/records-backup", () => ({ + buildRecordsBackupSection: mocks.buildRecordsBackupSection, +})); + +vi.mock("@/lib/cycle/backup", () => ({ + buildCycleBackupSection: vi.fn().mockResolvedValue({ + cycleProfile: null, + cycles: [], + cycleDayLogs: [], + }), +})); + +vi.mock("@/lib/crypto", () => ({ encrypt: mocks.encrypt })); + +vi.mock("@/lib/logging/background", () => ({ + withBackgroundEvent: vi.fn( + async (_name: string, run: (event: object) => Promise) => + run({ + addMeta: vi.fn(), + addWarning: vi.fn(), + setBackground: vi.fn(), + setError: vi.fn(), + }), + ), +})); + +vi.mock("../shared", () => ({ + getWorkerPrisma: mocks.getWorkerPrisma, +})); + +import { handleDataBackup } from "../backup-handlers"; + +const documentCiphertext = Buffer.from([1, 2, 3, 4]).toString("base64"); + +function buildPrismaMock() { + return { + user: { + findMany: vi.fn().mockResolvedValue([ + { id: "user-dr", username: "backup-owner" }, + ]), + }, + measurement: { findMany: vi.fn().mockResolvedValue([]) }, + medication: { findMany: vi.fn().mockResolvedValue([]) }, + medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, + moodEntry: { + findMany: vi.fn().mockResolvedValue([ + { + id: "mood-dr", + date: "2026-07-01", + mood: "GUT", + score: 4, + tags: null, + source: "MOODLOG", + externalId: "mood-external-dr", + moodLoggedAt: new Date("2026-07-01T20:00:00.000Z"), + tagLinks: [ + { rating: 5, moodTag: { key: "sleep_quality" } }, + ], + }, + ]), + }, + dataBackup: { upsert: mocks.upsert }, + }; +} + +describe("handleDataBackup canonical DR payload", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getWorkerPrisma.mockReturnValue(buildPrismaMock()); + mocks.upsert.mockResolvedValue({}); + mocks.buildRecordsBackupSection.mockResolvedValue({ + labResults: [], + biomarkers: [], + illnessEpisodes: [], + allergies: [], + familyHistory: [], + workouts: [], + documents: [ + { + id: "document-dr", + kind: "OTHER", + title: null, + filename: "record.pdf", + mimeType: "application/pdf", + byteSize: 4, + status: "STORED", + reportDate: null, + documentDate: null, + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-01T00:00:00.000Z", + contentEncrypted: documentCiphertext, + contentSha256: null, + contentCodec: "binary2", + providerType: null, + errorReason: null, + summaryEncrypted: null, + summaryGeneratedAt: null, + summaryState: "NONE", + }, + ], + manifest: { + documents: { + included: "encrypted-content", + note: "Encrypted content included", + }, + workouts: { included: "summary-only", note: "Summary only" }, + }, + }); + }); + + it("requests canonical records and serializes rated mood factors", async () => { + await handleDataBackup([]); + + expect(mocks.buildRecordsBackupSection).toHaveBeenCalledWith( + expect.anything(), + "user-dr", + { purpose: "disaster-recovery" }, + ); + expect(mocks.upsert).toHaveBeenCalledOnce(); + const encrypted = mocks.upsert.mock.calls[0]![0].create.data as string; + const payload = JSON.parse(encrypted) as { + moodEntries: Array<{ + id: string; + externalId: string; + factors: Array<{ key: string; rating: number }>; + }>; + documents: Array<{ contentEncrypted: string; contentCodec: string }>; + }; + expect(payload.moodEntries).toEqual([ + expect.objectContaining({ + id: "mood-dr", + externalId: "mood-external-dr", + factors: [{ key: "sleep_quality", rating: 5 }], + }), + ]); + expect(payload.documents).toEqual([ + expect.objectContaining({ + contentEncrypted: documentCiphertext, + contentCodec: "binary2", + }), + ]); + }); +}); diff --git a/src/lib/jobs/reminder/backup-handlers.ts b/src/lib/jobs/reminder/backup-handlers.ts index 18a024052..f289a3846 100644 --- a/src/lib/jobs/reminder/backup-handlers.ts +++ b/src/lib/jobs/reminder/backup-handlers.ts @@ -120,13 +120,23 @@ export async function handleDataBackup(jobs: Job[]) { prisma.moodEntry.findMany({ where: { userId: user.id }, orderBy: { moodLoggedAt: "desc" }, + include: { + tagLinks: { + where: { rating: { not: null } }, + select: { + rating: true, + moodTag: { select: { key: true } }, + }, + }, + }, }), // v1.15.0 — cycle slice (shared helper, notesEncrypted verbatim). buildCycleBackupSection(prisma, user.id), - // v1.28 backup-completeness — labs/biomarkers, illness episodes - // + day-logs, allergies, family history, workouts, and the - // documents manifest (shared helper — see records-backup.ts). - buildRecordsBackupSection(prisma, user.id), + // Canonical DR mode includes InboundDocument ciphertext and the + // exact codec/hash metadata needed to re-create the row. + buildRecordsBackupSection(prisma, user.id, { + purpose: "disaster-recovery", + }), ]); const backupJson = JSON.stringify({ @@ -159,20 +169,26 @@ export async function handleDataBackup(jobs: Job[]) { source: e.source, })), moodEntries: moodEntries.map((e) => ({ + id: e.id, date: e.date, mood: e.mood, score: e.score, tags: e.tags, source: e.source, loggedAt: e.moodLoggedAt.toISOString(), + externalId: e.externalId, + factors: e.tagLinks.map((link) => ({ + key: link.moodTag.key, + rating: link.rating, + })), })), // v1.15.0 — cycle slice (profile + observed spans + day-logs). cycleProfile: cycle.cycleProfile, cycles: cycle.cycles, cycleDayLogs: cycle.cycleDayLogs, - // v1.28 backup-completeness (see records-backup.ts) — export-only - // domains; the manifest field discloses the two deliberate - // exclusions (document binaries, workout GPS/sample series). + // Canonical disaster-recovery records. Documents carry encrypted + // bytes plus codec/hash metadata; workout route/sample sidecars + // remain outside the serialized workout-summary contract. labResults: records.labResults, biomarkers: records.biomarkers, illnessEpisodes: records.illnessEpisodes, diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index c05ea5010..1b0980090 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -97,14 +97,24 @@ const moodEntryTagsSchema = z { message: "tags must be null, empty, or a JSON array of strings" }, ); +const moodFactorSchema = z + .object({ + key: z.string().min(1), + rating: z.number().int(), + }) + .passthrough(); + const moodEntrySchema = z .object({ + id: z.string().min(1).optional(), date: z.string().min(1), mood: z.string().min(1), score: z.number().int().min(0).max(10), tags: moodEntryTagsSchema, source: z.string().min(1).optional(), + externalId: z.string().nullable().optional(), loggedAt: isoDateTime, + factors: z.array(moodFactorSchema).default([]), }) .passthrough(); @@ -177,22 +187,18 @@ const cycleProfileSchema = z }) .passthrough(); -/* ── v1.28 backup-completeness shapes ────────────────────────────── +/* ── Structured-record disaster-recovery shapes ───────────────────── * - * Labs, illness episodes (+ flares/exacerbations), allergies, family - * history, workouts, and the documents manifest. Written by - * `buildRecordsBackupSection` (`src/lib/export/records-backup.ts`) — shared - * by the on-demand export routes AND the weekly `data-backup` worker. - * - * EXPORT-ONLY today: `POST /api/admin/backups/[id]/restore` does not yet - * recreate these domains on restore (only measurements / medications / - * mood / cycle round-trip). The schemas below exist so the payload - * validates + parses with real types (an admin reviewing an uploaded - * backup sees accurate counts) even though restore ignores them for now. + * These shapes serve both the historical portable export and the canonical + * weekly/off-host disaster-recovery payload. Portable document entries remain + * metadata-only. Canonical entries additionally carry encrypted BYTEA values + * as base64 plus the codec/hash fields required to recreate InboundDocument + * without decrypting or fabricating content. */ const labResultBackupSchema = z .object({ + id: z.string().min(1).optional(), panel: z.string().nullable().optional(), analyte: z.string().min(1), value: z.number().nullable().optional(), @@ -204,11 +210,14 @@ const labResultBackupSchema = z source: z.string().min(1), biomarkerName: z.string().nullable().optional(), note: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), }) .passthrough(); const biomarkerBackupSchema = z .object({ + id: z.string().min(1).optional(), name: z.string().min(1), unit: z.string().min(1), lowerBound: z.number().nullable().optional(), @@ -216,6 +225,8 @@ const biomarkerBackupSchema = z panel: z.string().nullable().optional(), hidden: z.boolean().optional(), context: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), }) .passthrough(); @@ -287,6 +298,7 @@ const familyHistoryBackupSchema = z const workoutBackupSchema = z .object({ + id: z.string().min(1).optional(), sportType: z.string().min(1), startedAt: isoDateTime, endedAt: isoDateTime, @@ -301,9 +313,20 @@ const workoutBackupSchema = z pauseDurationSec: z.number().int().nullable().optional(), source: z.string().min(1), externalId: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), }) .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), @@ -315,8 +338,21 @@ const documentBackupSchema = z status: z.string().min(1), reportDate: z.string().nullable().optional(), documentDate: z.string().nullable().optional(), + contentEncrypted: base64BytesSchema.optional(), + contentSha256: z + .string() + .regex(/^[a-f0-9]{64}$/) + .nullable() + .optional(), + contentCodec: z.string().min(1).optional(), + providerType: z.string().nullable().optional(), + errorReason: z.string().nullable().optional(), + summaryEncrypted: base64BytesSchema.nullable().optional(), + summaryGeneratedAt: isoDateTime.nullable().optional(), + summaryState: z.string().min(1).optional(), summary: z.string().nullable().optional(), createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), }) .passthrough(); @@ -350,9 +386,9 @@ export const backupPayloadSchema = z cycleProfile: cycleProfileSchema.nullable().default(null), cycles: z.array(cycleSpanSchema).default([]), cycleDayLogs: z.array(cycleDayLogSchema).default([]), - // v1.28 backup-completeness — export-only for now (see the block - // comment above). Default to empty arrays / null so a pre-v1.28 backup - // (no records keys) still round-trips unchanged. + // Structured records default to empty arrays so older backups remain + // parseable. Canonical DR writers add stable ids and encrypted document + // fields; portable exports retain the metadata-only subset. labResults: z.array(labResultBackupSchema).default([]), biomarkers: z.array(biomarkerBackupSchema).default([]), illnessEpisodes: z.array(illnessEpisodeBackupSchema).default([]), @@ -383,21 +419,21 @@ export interface BackupSummary { cycles: number; /** v1.15.0 — cycle day-logs in the backup. */ cycleDayLogs: number; - /** v1.28 — lab results in the backup (export-only, see above). */ + /** Lab results in the backup. */ labResults: number; - /** v1.28 — biomarker catalog entries in the backup (export-only). */ + /** User-scoped biomarker catalog entries in the backup. */ biomarkers: number; - /** v1.28 — illness episodes, incl. flares/exacerbations (export-only). */ + /** Illness episodes, including flares/exacerbations. */ illnessEpisodes: number; - /** v1.28 — illness day-logs across every episode (export-only). */ + /** Illness day-logs across every episode. */ illnessDayLogs: number; - /** v1.28 — allergy/intolerance records in the backup (export-only). */ + /** Allergy/intolerance records in the backup. */ allergies: number; - /** v1.28 — family-history entries in the backup (export-only). */ + /** Family-history entries in the backup. */ familyHistory: number; - /** v1.28 — workout summary records in the backup (export-only). */ + /** Workout summary records in the backup. */ workouts: number; - /** v1.28 — document manifest entries (metadata only, export-only). */ + /** Document records (ciphertext included in canonical DR payloads). */ documents: number; } diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts new file mode 100644 index 000000000..3aa274805 --- /dev/null +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -0,0 +1,664 @@ +import { Buffer } from "node:buffer"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt, encryptBytes } from "@/lib/crypto"; +import { encryptToBytes, decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { buildRecordsBackupSection } from "@/lib/export/records-backup"; +import { backupPayloadSchema } from "@/lib/validations/backup"; +import { POST } from "@/app/api/admin/backups/[id]/restore/route"; +import { invalidateUserData } from "@/lib/cache/invalidate"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + vi.mocked(invalidateUserData).mockClear(); +}); + +async function seedAdminSession() { + const prisma = getPrismaClient(); + const admin = await prisma.user.create({ + data: { + username: "canonical-restore-admin", + email: "canonical-restore-admin@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { + userId: admin.id, + expiresAt: new Date(Date.now() + 60_000), + }, + }); + cookieJar.set("healthlog_session", session.id); + return admin; +} + +function makeRequest(id: string) { + return new Request(`http://localhost/api/admin/backups/${id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }); +} + +describe("canonical disaster-recovery backup round-trip", () => { + it("restores every serialized record class and document ciphertext verbatim", async () => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + const ownerId = admin.id; + + const measurement = await prisma.measurement.create({ + data: { + id: "measurement-dr", + userId: ownerId, + type: "WEIGHT", + value: 72.4, + unit: "kg", + measuredAt: new Date("2026-07-01T07:00:00.000Z"), + source: "MANUAL", + }, + }); + + const factorCategory = await prisma.moodTagCategory.create({ + data: { + id: "category-dr", + key: "dr_restore_factors", + labelKey: "mood.tagCategory.drRestore", + }, + }); + const factor = await prisma.moodTag.create({ + data: { + id: "factor-dr", + categoryId: factorCategory.id, + key: "dr_sleep_quality", + labelKey: "mood.tag.drSleepQuality", + kind: "RATED", + scaleMin: 1, + scaleMax: 5, + }, + }); + const mood = await prisma.moodEntry.create({ + data: { + id: "mood-dr", + userId: ownerId, + date: "2026-07-01", + mood: "GUT", + score: 4, + source: "MOODLOG", + moodLoggedAt: new Date("2026-07-01T20:00:00.000Z"), + tagLinks: { + create: { moodTagId: factor.id, rating: 5 }, + }, + }, + include: { + tagLinks: { + select: { rating: true, moodTag: { select: { key: true } } }, + }, + }, + }); + + const biomarker = await prisma.biomarker.create({ + data: { + id: "biomarker-dr", + userId: ownerId, + name: "Ferritin", + unit: "ng/mL", + lowerBound: 30, + upperBound: 300, + panel: "Iron", + contextEncrypted: encryptToBytes("Iron storage context"), + }, + }); + const lab = await prisma.labResult.create({ + data: { + id: "lab-dr", + userId: ownerId, + biomarkerId: biomarker.id, + panel: "Iron", + analyte: "Ferritin", + value: 88, + unit: "ng/mL", + referenceLow: 30, + referenceHigh: 300, + takenAt: new Date("2026-06-30T09:00:00.000Z"), + source: "MANUAL", + noteEncrypted: encryptToBytes("Fasted"), + }, + }); + + const illness = await prisma.illnessEpisode.create({ + data: { + id: "illness-dr", + userId: ownerId, + label: "Cold", + type: "INFECTION", + lifecycle: "ACUTE", + onsetAt: new Date("2026-06-20T00:00:00.000Z"), + resolvedAt: new Date("2026-06-24T00:00:00.000Z"), + noteEncrypted: encryptToBytes("Recovered fully"), + }, + }); + const symptom = await prisma.illnessSymptom.create({ + data: { + id: "symptom-dr", + key: "dr_cough", + labelKey: "illness.symptom.drCough", + }, + }); + const illnessDay = await prisma.illnessDayLog.create({ + data: { + id: "illness-day-dr", + userId: ownerId, + episodeId: illness.id, + date: "2026-06-21", + functionalImpact: 2, + feverC: 38.1, + noteEncrypted: encryptToBytes("Rest day"), + symptomLinks: { + create: { symptomId: symptom.id, severity: 2 }, + }, + }, + }); + + const allergy = await prisma.allergy.create({ + data: { + id: "allergy-dr", + userId: ownerId, + substance: "Penicillin", + category: "MEDICATION", + type: "ALLERGY", + severity: "SEVERE", + status: "ACTIVE", + onsetAt: new Date("2020-01-01T00:00:00.000Z"), + reactionEncrypted: encryptToBytes("Hives"), + notesEncrypted: encryptToBytes("Avoid beta-lactams"), + }, + }); + const family = await prisma.familyHistoryEntry.create({ + data: { + id: "family-dr", + userId: ownerId, + relationship: "MOTHER", + condition: "Type 2 diabetes", + ageAtOnset: 55, + notesEncrypted: encryptToBytes("Diet controlled"), + }, + }); + const workout = await prisma.workout.create({ + data: { + id: "workout-dr", + userId: ownerId, + sportType: "RUNNING", + startedAt: new Date("2026-06-29T06:00:00.000Z"), + endedAt: new Date("2026-06-29T06:30:00.000Z"), + durationSec: 1800, + totalEnergyKcal: 250, + totalDistanceM: 5000, + avgHeartRate: 145, + maxHeartRate: 170, + source: "IMPORT", + externalId: "run-external-dr", + }, + }); + + const encryptedBuffer = encryptBytes(Buffer.from("private PDF bytes")); + const contentEncrypted = new Uint8Array( + new ArrayBuffer(encryptedBuffer.byteLength), + ); + contentEncrypted.set(encryptedBuffer); + const summaryEncrypted = encryptToBytes("Encrypted summary"); + const document = await prisma.inboundDocument.create({ + data: { + id: "document-dr", + userId: ownerId, + kind: "LAB_RESULT", + title: "June labs", + filename: "june-labs.pdf", + mimeType: "application/pdf", + byteSize: 17, + contentEncrypted, + contentSha256: + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + contentCodec: "binary2", + status: "EXTRACTED", + providerType: "local", + reportDate: new Date("2026-06-30T00:00:00.000Z"), + documentDate: new Date("2026-06-30T00:00:00.000Z"), + summaryEncrypted, + summaryGeneratedAt: new Date("2026-07-01T08:00:00.000Z"), + summaryState: "READY", + createdAt: new Date("2026-07-01T07:30:00.000Z"), + updatedAt: new Date("2026-07-01T08:00:00.000Z"), + }, + }); + + const records = await buildRecordsBackupSection(prisma, ownerId, { + purpose: "disaster-recovery", + }); + + const payload = backupPayloadSchema.parse({ + schemaVersion: "1", + exportedAt: "2026-07-02T00:00:00.000Z", + userId: ownerId, + measurements: [ + { + type: measurement.type, + value: measurement.value, + unit: measurement.unit, + measuredAt: measurement.measuredAt.toISOString(), + source: measurement.source, + }, + ], + medications: [], + intakeEvents: [], + moodEntries: [ + { + id: mood.id, + date: mood.date, + mood: mood.mood, + score: mood.score, + tags: mood.tags, + source: mood.source, + loggedAt: mood.moodLoggedAt.toISOString(), + factors: mood.tagLinks.map((link) => ({ + key: link.moodTag.key, + rating: link.rating, + })), + }, + ], + labResults: records.labResults, + biomarkers: records.biomarkers, + illnessEpisodes: records.illnessEpisodes, + allergies: records.allergies, + familyHistory: records.familyHistory, + workouts: records.workouts, + documents: records.documents, + manifest: records.manifest, + }); + expect(payload.documents[0]).not.toHaveProperty("summary"); + expect(JSON.stringify(payload.documents[0])).not.toContain( + "private PDF bytes", + ); + + await prisma.inboundDocument.deleteMany({ where: { userId: ownerId } }); + await prisma.workout.deleteMany({ where: { userId: ownerId } }); + await prisma.familyHistoryEntry.deleteMany({ where: { userId: ownerId } }); + await prisma.allergy.deleteMany({ where: { userId: ownerId } }); + await prisma.illnessEpisode.deleteMany({ where: { userId: ownerId } }); + await prisma.labResult.deleteMany({ where: { userId: ownerId } }); + await prisma.biomarker.deleteMany({ where: { userId: ownerId } }); + + const backup = await prisma.dataBackup.create({ + data: { + userId: ownerId, + type: "CANONICAL_DR_ROUND_TRIP", + data: encrypt(JSON.stringify(payload)), + }, + }); + + const restore = async () => + POST(makeRequest(backup.id) as unknown as Parameters[0], { + params: Promise.resolve({ id: backup.id }), + }); + + expect((await restore()).status).toBe(200); + + expect(await prisma.labResult.count({ where: { userId: ownerId } })).toBe(1); + expect(await prisma.biomarker.count({ where: { userId: ownerId } })).toBe(1); + expect(await prisma.illnessEpisode.count({ where: { userId: ownerId } })).toBe(1); + expect(await prisma.illnessDayLog.count({ where: { userId: ownerId } })).toBe(1); + expect(await prisma.allergy.count({ where: { userId: ownerId } })).toBe(1); + expect(await prisma.familyHistoryEntry.count({ where: { userId: ownerId } })).toBe(1); + expect(await prisma.workout.count({ where: { userId: ownerId } })).toBe(1); + expect(await prisma.inboundDocument.count({ where: { userId: ownerId } })).toBe(1); + expect( + await prisma.measurement.findMany({ + where: { userId: ownerId }, + select: { + type: true, + value: true, + unit: true, + measuredAt: true, + source: true, + }, + }), + ).toEqual([ + { + type: measurement.type, + value: measurement.value, + unit: measurement.unit, + measuredAt: measurement.measuredAt, + source: measurement.source, + }, + ]); + + 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, + }), + ); + const restoredBiomarker = await prisma.biomarker.findUniqueOrThrow({ + where: { id: biomarker.id }, + }); + expect(restoredBiomarker).toEqual( + expect.objectContaining({ + name: biomarker.name, + unit: biomarker.unit, + lowerBound: biomarker.lowerBound, + upperBound: biomarker.upperBound, + panel: biomarker.panel, + hidden: biomarker.hidden, + }), + ); + expect(decryptFromBytes(restoredBiomarker.contextEncrypted!)).toBe( + "Iron storage context", + ); + + 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(decryptFromBytes(restoredIllness.noteEncrypted!)).toBe( + "Recovered fully", + ); + const restoredIllnessDay = await prisma.illnessDayLog.findUniqueOrThrow({ + where: { id: illnessDay.id }, + include: { symptomLinks: true }, + }); + expect(decryptFromBytes(restoredIllnessDay.noteEncrypted!)).toBe("Rest day"); + expect(restoredIllnessDay.symptomLinks).toEqual([ + expect.objectContaining({ symptomId: symptom.id, severity: 2 }), + ]); + + const restoredAllergy = await prisma.allergy.findUniqueOrThrow({ + 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, + }), + ); + const restoredFamily = await prisma.familyHistoryEntry.findUniqueOrThrow({ + where: { id: family.id }, + }); + expect(decryptFromBytes(restoredFamily.notesEncrypted!)).toBe( + "Diet controlled", + ); + expect(restoredFamily).toEqual( + expect.objectContaining({ + relationship: family.relationship, + condition: family.condition, + ageAtOnset: family.ageAtOnset, + }), + ); + expect( + await prisma.workout.findUniqueOrThrow({ where: { id: workout.id } }), + ).toEqual( + expect.objectContaining({ + sportType: workout.sportType, + startedAt: workout.startedAt, + endedAt: workout.endedAt, + durationSec: workout.durationSec, + totalEnergyKcal: workout.totalEnergyKcal, + totalDistanceM: workout.totalDistanceM, + avgHeartRate: workout.avgHeartRate, + maxHeartRate: workout.maxHeartRate, + source: workout.source, + externalId: workout.externalId, + }), + ); + + const restoredDocument = await prisma.inboundDocument.findUniqueOrThrow({ + where: { id: document.id }, + }); + expect(Buffer.from(restoredDocument.contentEncrypted)).toEqual( + Buffer.from(document.contentEncrypted), + ); + expect(Buffer.from(restoredDocument.summaryEncrypted!)).toEqual( + Buffer.from(document.summaryEncrypted!), + ); + expect(restoredDocument).toEqual( + expect.objectContaining({ + title: document.title, + filename: document.filename, + mimeType: document.mimeType, + byteSize: document.byteSize, + contentSha256: document.contentSha256, + contentCodec: document.contentCodec, + status: document.status, + providerType: document.providerType, + summaryState: document.summaryState, + reportDate: document.reportDate, + documentDate: document.documentDate, + errorReason: document.errorReason, + summaryGeneratedAt: document.summaryGeneratedAt, + createdAt: document.createdAt, + updatedAt: document.updatedAt, + }), + ); + + const restoredMood = await prisma.moodEntry.findUniqueOrThrow({ + where: { id: mood.id }, + include: { + tagLinks: { + include: { moodTag: { select: { key: true } } }, + }, + }, + }); + expect(restoredMood).toEqual( + expect.objectContaining({ + date: mood.date, + mood: mood.mood, + score: mood.score, + source: mood.source, + moodLoggedAt: mood.moodLoggedAt, + }), + ); + expect(restoredMood.tagLinks).toEqual([ + expect.objectContaining({ + rating: 5, + moodTag: { key: "dr_sleep_quality" }, + }), + ]); + + expect((await restore()).status).toBe(200); + expect( + await prisma.inboundDocument.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }), + ).toEqual([{ id: document.id }]); + expect( + await prisma.workout.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }), + ).toEqual([{ id: workout.id }]); + expect( + await prisma.labResult.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }), + ).toEqual([{ id: lab.id }]); + expect( + await prisma.illnessEpisode.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }), + ).toEqual([{ id: illness.id }]); + }); + + it("rejects metadata-only documents before mutation or invalidation", async () => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + await prisma.measurement.create({ + data: { + userId: admin.id, + type: "WEIGHT", + value: 91, + unit: "kg", + source: "MANUAL", + measuredAt: new Date("2026-07-03T07:00:00.000Z"), + }, + }); + const payload = backupPayloadSchema.parse({ + schemaVersion: "1", + exportedAt: "2026-07-03T08:00:00.000Z", + userId: admin.id, + measurements: [], + moodEntries: [], + documents: [ + { + id: "metadata-only-document", + kind: "OTHER", + title: null, + filename: "metadata.pdf", + mimeType: "application/pdf", + byteSize: 12, + status: "STORED", + reportDate: null, + documentDate: null, + summary: null, + createdAt: "2026-07-03T08:00:00.000Z", + }, + ], + manifest: { + documents: { included: "metadata-only", note: "Portable export" }, + workouts: { included: "summary-only", note: "Summary only" }, + }, + }); + const backup = await prisma.dataBackup.create({ + data: { + userId: admin.id, + type: "PORTABLE_METADATA_ONLY", + 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(422); + expect( + await prisma.measurement.count({ where: { userId: admin.id } }), + ).toBe(1); + expect(invalidateUserData).not.toHaveBeenCalled(); + }); + + it("rolls back a failed full restore before invalidating caches", async () => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + const measurement = await prisma.measurement.create({ + data: { + userId: admin.id, + type: "WEIGHT", + value: 91, + unit: "kg", + source: "MANUAL", + measuredAt: new Date("2026-07-04T07:00:00.000Z"), + }, + }); + const payload = backupPayloadSchema.parse({ + schemaVersion: "1", + exportedAt: "2026-07-04T08:00:00.000Z", + userId: admin.id, + measurements: [], + moodEntries: [ + { + id: "failed-restore-mood", + date: "2026-07-04", + mood: "OKAY", + score: 3, + tags: null, + loggedAt: "2026-07-04T08:00:00.000Z", + factors: [{ key: "missing-backup-factor", rating: 4 }], + }, + ], + }); + const backup = await prisma.dataBackup.create({ + data: { + userId: admin.id, + type: "FAILED_FULL_RESTORE", + 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.findMany({ + where: { userId: admin.id }, + select: { id: true }, + }), + ).toEqual([{ id: measurement.id }]); + expect( + await prisma.moodEntry.count({ where: { userId: admin.id } }), + ).toBe(0); + expect(invalidateUserData).not.toHaveBeenCalled(); + }); +});