fix(backups): restore canonical payloads completely

This commit is contained in:
Marc-André Bombeck committed 2026-07-20 11:23:57 +02:00
1 parent a6d4bb414c
commit 452d1a2ae3
6 files changed
+1500 -147

No files matched your search

+427 -54
View File
@@ -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<ArrayBuffer> {
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<string, string>();
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 = {
+174 -64
View File
@@ -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<RecordsBackupSection> {
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 },
},
};
@@ -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<void>) =>
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",
}),
]);
});
});
+23 -7
View File
@@ -120,13 +120,23 @@ export async function handleDataBackup(jobs: Job<DataBackupPayload>[]) {
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<DataBackupPayload>[]) {
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,
+58 -22
View File
@@ -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;
}
@@ -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<typeof POST>[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<typeof POST>[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<typeof POST>[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();
});
});