mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(backups): restore canonical payloads completely
This commit is contained in:
1 parent
a6d4bb414c
commit
452d1a2ae3
6 files changed
+1500
-147
No files matched your search
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user