feat(export): make the full backup actually full

The full-backup export (plaintext + passphrase-encrypted, and the weekly
auto-backup) only ever covered measurements, medications, mood, and cycle
data. Labs, illness episodes, allergies, family history, and workouts were
silently missing, and the export UI implied "everything" was included.

Adds a shared records-backup section (src/lib/export/records-backup.ts) that
both the on-demand export routes and the weekly data-backup worker read from:
lab results + the biomarker catalog, illness episodes (including flares via
parentConditionId) with their day-logs, allergies, family history, and
workout summaries. Every read is owner-scoped and soft-delete-filtered;
encrypted free-text columns decrypt fail-soft so one bad row never aborts
the whole backup.

Document original files and workout GPS/per-sample time series stay
deliberately out of the JSON payload (size). The payload now carries an
explicit manifest disclosing exactly that, and the export UI states it in
plain text instead of implying completeness. These new domains are
export-only for now — admin restore still only recreates the original
measurements/medications/mood/cycle domains; the payload validates and
reports counts for the rest, but restoring a snapshot does not yet recreate
them.

Also fixes the admin backup-upload total to count every domain, not just
the original four.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 02:09:33 +02:00
1 parent 837c44e4c2
commit 3dd5dbeb42
22 files changed
+1491 -68

No files matched your search

+2 -1
View File
@@ -4386,7 +4386,8 @@
},
"fullBackup": {
"title": "Voll-Backup",
"description": "Eine einzige JSON-Datei mit allen Daten — gleiches Format wie das wöchentliche Auto-Backup. Praktisch zum Selbst-Wiederherstellen via Admin-Upload.",
"description": "Alle deine Daten in einer JSON-Datei — Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese und Workouts. Gleiches Format wie das wöchentliche Auto-Backup.",
"scopeNote": "Die Originaldateien der Dokumente sowie GPS-Routen und Einzelmesswerte von Workouts sind nicht enthalten — lade diese separat aus dem Dokumententresor bzw. dem Workout-Export herunter.",
"encryptToggle": "Mit einer Passphrase verschlüsseln",
"passphrasePlaceholder": "Passphrase (mind. 12 Zeichen)",
"passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.",
+2 -1
View File
@@ -4386,7 +4386,8 @@
},
"fullBackup": {
"title": "Full backup",
"description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload.",
"description": "Every record you own in one JSON file — measurements, medications, mood, cycle, labs, illness episodes, allergies, family history, and workouts. Same shape as the weekly auto-backup.",
"scopeNote": "Original document files and workout GPS/sample data are not included — download those separately from the vault or the workout export.",
"encryptToggle": "Encrypt with a passphrase",
"passphrasePlaceholder": "Passphrase (min. 12 characters)",
"passphraseHelp": "Derives the archive key with Argon2id and seals it with AES-256-GCM. Save the file with a .hlx extension.",
+2 -1
View File
@@ -4386,7 +4386,8 @@
},
"fullBackup": {
"title": "Copia completa",
"description": "Un único archivo JSON con todo — el mismo formato que la copia automática semanal. Útil para restaurar tú mismo mediante la carga de administrador.",
"description": "Todos tus datos en un único archivo JSON: mediciones, medicamentos, estado de ánimo, ciclo, analíticas, episodios de enfermedad, alergias, antecedentes familiares y entrenamientos. Mismo formato que la copia automática semanal.",
"scopeNote": "Los archivos originales de los documentos y los datos de rutas GPS/muestras de los entrenamientos no están incluidos — descárgalos por separado desde el archivo de documentos o la exportación de entrenamientos.",
"encryptToggle": "Cifrar con una contraseña",
"passphrasePlaceholder": "Contraseña (mín. 12 caracteres)",
"passphraseHelp": "Deriva la clave del archivo con Argon2id y lo sella con AES-256-GCM. Guarda el archivo con la extensión .hlx.",
+2 -1
View File
@@ -4386,7 +4386,8 @@
},
"fullBackup": {
"title": "Sauvegarde complète",
"description": "Un seul fichier JSON contenant tout — même format que la sauvegarde automatique hebdomadaire. Utile pour une restauration personnelle via le téléversement administrateur.",
"description": "Toutes vos données dans un seul fichier JSON : mesures, médicaments, humeur, cycle, analyses de laboratoire, épisodes de maladie, allergies, antécédents familiaux et entraînements. Même format que la sauvegarde automatique hebdomadaire.",
"scopeNote": "Les fichiers originaux des documents ainsi que les tracés GPS/données d'échantillons des entraînements ne sont pas inclus — téléchargez-les séparément depuis le coffre de documents ou l'export des entraînements.",
"encryptToggle": "Chiffrer avec une phrase secrète",
"passphrasePlaceholder": "Phrase secrète (12 caractères min.)",
"passphraseHelp": "Dérive la clé de l'archive avec Argon2id et la scelle avec AES-256-GCM. Enregistrez le fichier avec l'extension .hlx.",
+2 -1
View File
@@ -4386,7 +4386,8 @@
},
"fullBackup": {
"title": "Backup completo",
"description": "Un unico file JSON con tutto — stesso formato del backup automatico settimanale. Utile per il ripristino autonomo tramite il caricamento da amministratore.",
"description": "Tutti i tuoi dati in un unico file JSON: misurazioni, farmaci, umore, ciclo, esami di laboratorio, episodi di malattia, allergie, anamnesi familiare e allenamenti. Stesso formato del backup automatico settimanale.",
"scopeNote": "I file originali dei documenti e i dati di percorso GPS/campionamento degli allenamenti non sono inclusi — scaricali separatamente dall'archivio documenti o dall'esportazione degli allenamenti.",
"encryptToggle": "Cifra con una passphrase",
"passphrasePlaceholder": "Passphrase (min. 12 caratteri)",
"passphraseHelp": "Deriva la chiave dell'archivio con Argon2id e lo sigilla con AES-256-GCM. Salva il file con estensione .hlx.",
+2 -1
View File
@@ -4386,7 +4386,8 @@
},
"fullBackup": {
"title": "Pełna kopia zapasowa",
"description": "Pojedynczy plik JSON ze wszystkim — taki sam format jak cotygodniowa automatyczna kopia. Przydatny do samodzielnego przywracania przez przesłanie w panelu administratora.",
"description": "Wszystkie Twoje dane w jednym pliku JSON — pomiary, leki, nastrój, cykl, wyniki badań laboratoryjnych, epizody chorobowe, alergie, wywiad rodzinny i treningi. Taki sam format jak cotygodniowa automatyczna kopia.",
"scopeNote": "Oryginalne pliki dokumentów oraz dane tras GPS/próbek treningów nie są uwzględnione — pobierz je osobno z archiwum dokumentów lub eksportu treningów.",
"encryptToggle": "Zaszyfruj hasłem-frazą",
"passphrasePlaceholder": "Hasło-fraza (min. 12 znaków)",
"passphraseHelp": "Wyprowadza klucz archiwum za pomocą Argon2id i pieczętuje je AES-256-GCM. Zapisz plik z rozszerzeniem .hlx.",
@@ -57,6 +57,12 @@ 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;
@@ -218,6 +218,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
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,
},
},
});
@@ -7,7 +7,7 @@
* action name, and rate-limit wiring. The full DB round-trip lives in
* the integration suite (`tests/integration/export-per-type.test.ts`).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
@@ -20,6 +20,15 @@ vi.mock("@/lib/db", () => ({
cycleProfile: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
cycleDayLog: { findMany: vi.fn() },
// v1.28 backup-completeness — the records section the full-backup
// helper now also reads (`buildRecordsBackupSection`).
labResult: { findMany: vi.fn() },
biomarker: { findMany: vi.fn() },
illnessEpisode: { findMany: vi.fn() },
allergy: { findMany: vi.fn() },
familyHistoryEntry: { findMany: vi.fn() },
workout: { findMany: vi.fn() },
inboundDocument: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
auditLog: { create: vi.fn() },
},
@@ -63,6 +72,13 @@ beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
// The v1.28 records-domain end-to-end test stubs ENCRYPTION_KEY to
// exercise the real crypto path; unstub unconditionally (harmless no-op
// for every other test) so a stub never leaks past its own test.
vi.unstubAllEnvs();
});
describe("GET /api/export/measurements", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
@@ -385,6 +401,15 @@ describe("GET /api/export/full-backup", () => {
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never);
const { GET } = await import("../full-backup/route");
const res = await GET(mkReq("http://localhost/api/export/full-backup"));
@@ -402,6 +427,20 @@ describe("GET /api/export/full-backup", () => {
medications: [],
intakeEvents: [],
moodEntries: [],
// v1.28 backup-completeness — the newer domains round-trip to empty
// arrays (not undefined/missing) when the account has no records,
// and the manifest still discloses the two deliberate exclusions.
labResults: [],
biomarkers: [],
illnessEpisodes: [],
allergies: [],
familyHistory: [],
workouts: [],
documents: [],
manifest: {
documents: { included: "metadata-only" },
workouts: { included: "summary-only" },
},
});
expect(typeof json.exportedAt).toBe("string");
expect(auditLog).toHaveBeenCalledWith(
@@ -409,4 +448,81 @@ describe("GET /api/export/full-backup", () => {
expect.objectContaining({ userId: "user-1" }),
);
});
it("includes decrypted v1.28 records-domain data end-to-end", async () => {
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", "a".repeat(64));
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec");
vi.mocked(prisma.labResult.findMany).mockResolvedValue([
{
panel: null,
analyte: "HbA1c",
value: 5.4,
valueText: null,
unit: "%",
referenceLow: null,
referenceHigh: null,
takenAt: new Date("2026-04-01T09:00:00.000Z"),
source: "MANUAL",
biomarker: null,
noteEncrypted: null,
},
] as never);
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([
{
id: "ep-1",
label: "Migraine flare",
type: "CHRONIC",
lifecycle: "FLARE",
onsetAt: new Date("2026-04-10T00:00:00.000Z"),
resolvedAt: null,
parentConditionId: "ep-parent",
noteEncrypted: encryptToBytes("Triggered by travel."),
createdAt: new Date("2026-04-10T00:00:00.000Z"),
updatedAt: new Date("2026-04-10T00:00:00.000Z"),
dayLogs: [],
},
] as never);
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never);
const { GET } = await import("../full-backup/route");
const res = await GET(mkReq("http://localhost/api/export/full-backup"));
expect(res.status).toBe(200);
const json = await res.json();
expect(json.labResults).toEqual([
expect.objectContaining({ analyte: "HbA1c", value: 5.4 }),
]);
// The flare's parent link + decrypted note round-trip in the JSON —
// never the ciphertext.
expect(json.illnessEpisodes[0].parentConditionId).toBe("ep-parent");
expect(json.illnessEpisodes[0].note).toBe("Triggered by travel.");
// The raw ciphertext column never leaves the server — only the
// decrypted `note` field is present on the DTO.
expect(json.illnessEpisodes[0]).not.toHaveProperty("noteEncrypted");
});
});
@@ -42,6 +42,18 @@ vi.mock("@/lib/db", () => ({
auditLog: { findMany: vi.fn() },
userHealthProfile: { findUnique: vi.fn() },
illnessEpisode: { findMany: vi.fn() },
// v1.28 backup-completeness — the cycle + records sections the
// full-backup builder now also reads (`buildCycleBackupSection` /
// `buildRecordsBackupSection`).
cycleProfile: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
cycleDayLog: { findMany: vi.fn() },
labResult: { findMany: vi.fn() },
biomarker: { findMany: vi.fn() },
allergy: { findMany: vi.fn() },
familyHistoryEntry: { findMany: vi.fn() },
workout: { findMany: vi.fn() },
inboundDocument: { findMany: vi.fn() },
},
}));
@@ -125,6 +137,18 @@ beforeEach(() => {
null as never,
);
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
// v1.28 backup-completeness — the full-backup route now also reads the
// cycle + records sections; let them resolve to empty so the route
// completes instead of rejecting on an unmocked delegate.
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never);
});
describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
@@ -148,6 +172,48 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
);
});
it("/api/export/full-backup scopes every v1.28 records domain to userId + deletedAt: null", async () => {
const { GET } = await import("../full-backup/route");
await GET(mkReq("http://localhost/api/export/full-backup"));
expect(prisma.labResult.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1", deletedAt: null },
}),
);
expect(prisma.illnessEpisode.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1", deletedAt: null },
}),
);
expect(prisma.allergy.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1", deletedAt: null },
}),
);
expect(prisma.familyHistoryEntry.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1", deletedAt: null },
}),
);
expect(prisma.inboundDocument.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1", deletedAt: null },
}),
);
// Biomarker + Workout carry no soft-delete tombstone — userId-only scope.
expect(prisma.biomarker.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: "user-1" } }),
);
expect(prisma.workout.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: "user-1" } }),
);
// The document read must never select the encrypted content blob.
const documentCall = vi.mocked(prisma.inboundDocument.findMany).mock
.calls[0][0] as { select?: Record<string, unknown> };
expect(documentCall.select).not.toHaveProperty("contentEncrypted");
});
it("/api/export (legacy) scopes the measurement read to deletedAt: null", async () => {
const { GET } = await import("../route");
await GET(mkReq("http://localhost/api/export?type=measurements"));
@@ -17,6 +17,15 @@ vi.mock("@/lib/db", () => ({
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
// v1.28 backup-completeness — the records section the shared payload
// builder now also reads (`buildRecordsBackupSection`).
labResult: { findMany: vi.fn().mockResolvedValue([]) },
biomarker: { findMany: vi.fn().mockResolvedValue([]) },
illnessEpisode: { findMany: vi.fn().mockResolvedValue([]) },
allergy: { findMany: vi.fn().mockResolvedValue([]) },
familyHistoryEntry: { findMany: vi.fn().mockResolvedValue([]) },
workout: { findMany: vi.fn().mockResolvedValue([]) },
inboundDocument: { findMany: vi.fn().mockResolvedValue([]) },
session: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn() },
},
@@ -77,6 +86,18 @@ describe("POST /api/export/encrypted", () => {
const payload = JSON.parse(plaintext);
expect(payload).toHaveProperty("schemaVersion");
expect(payload).toHaveProperty("measurements");
// v1.28 backup-completeness — the encrypted archive carries the same
// records domains as the plaintext export.
expect(payload).toHaveProperty("labResults");
expect(payload).toHaveProperty("illnessEpisodes");
expect(payload).toHaveProperty("allergies");
expect(payload).toHaveProperty("familyHistory");
expect(payload).toHaveProperty("workouts");
expect(payload).toHaveProperty("documents");
expect(payload.manifest).toMatchObject({
documents: { included: "metadata-only" },
workouts: { included: "summary-only" },
});
});
it("blocks an MFA account without a fresh second factor (401 step-up)", async () => {
+12 -3
View File
@@ -2,9 +2,10 @@
* POST /api/export/encrypted
*
* v1.23 — passphrase-encrypted variant of the full-backup export. Returns the
* same restore-compatible payload as `GET /api/export/full-backup`, but sealed
* into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under a passphrase
* the caller supplies in the request body. The binary archive is returned as
* same payload as `GET /api/export/full-backup` (see that route's doc comment
* for which domains restore recreates vs. export-only), sealed into an
* `HLX1` archive (Argon2id-derived key + AES-256-GCM) under a passphrase the
* caller supplies in the request body. The binary archive is returned as
* `application/octet-stream`.
*
* SECURITY:
@@ -107,6 +108,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
export_mood_count: counts.moodEntries,
export_cycle_count: counts.cycles,
export_cycle_day_log_count: counts.cycleDayLogs,
export_lab_result_count: counts.labResults,
export_biomarker_count: counts.biomarkers,
export_illness_episode_count: counts.illnessEpisodes,
export_illness_day_log_count: counts.illnessDayLogs,
export_allergy_count: counts.allergies,
export_family_history_count: counts.familyHistory,
export_workout_count: counts.workouts,
export_document_count: counts.documents,
export_archive_bytes: archive.byteLength,
},
});
+21 -1
View File
@@ -11,6 +11,16 @@
* `.json` so the browser writes a `.json` file even though the route
* segment doesn't carry the extension.
*
* Backup-completeness — the payload also carries lab results, the
* biomarker catalog, illness episodes (incl. flares/exacerbations) with
* their day-logs, allergies, family history, and workout summaries. These
* domains are EXPORT-ONLY today: `POST /api/admin/backups/[id]/restore`
* only re-creates the original measurements/medications/mood/cycle
* domains, so restoring a snapshot does not (yet) recreate these sections
* — see `src/lib/export/records-backup.ts`. Document original files and
* workout GPS/sample time series are never included; the payload's
* `manifest` field discloses both, and the export UI states this too.
*
* Auth: cookie session OR Bearer token (`requireAuth`).
* Rate-limit: shared `export:<userId>` bucket (10/h).
* Audit: `user.export.full-backup` with the row counts.
@@ -34,7 +44,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
// v1.23 — the payload builder is shared with the passphrase-encrypted
// export route so both emit the byte-for-byte same restore-compatible shape.
// export route so both emit the byte-for-byte same shape (the measurement/
// medication/mood/cycle domains are restore-compatible; the newer records
// domains are export-only for now — see the module doc comment above).
const { payload, counts } = await buildFullBackupPayload(prisma, user.id);
await auditLog("user.export.full-backup", {
@@ -51,6 +63,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
export_mood_count: counts.moodEntries,
export_cycle_count: counts.cycles,
export_cycle_day_log_count: counts.cycleDayLogs,
export_lab_result_count: counts.labResults,
export_biomarker_count: counts.biomarkers,
export_illness_episode_count: counts.illnessEpisodes,
export_illness_day_log_count: counts.illnessDayLogs,
export_allergy_count: counts.allergies,
export_family_history_count: counts.familyHistory,
export_workout_count: counts.workouts,
export_document_count: counts.documents,
},
});
+19 -1
View File
@@ -229,6 +229,16 @@ export function BackupsSection() {
moodEntries: number;
cycles?: number;
cycleDayLogs?: number;
// v1.28 backup-completeness — optional so an older-schema upload
// (pre-v1.28) still totals correctly without these keys.
labResults?: number;
biomarkers?: number;
illnessEpisodes?: number;
illnessDayLogs?: number;
allergies?: number;
familyHistory?: number;
workouts?: number;
documents?: number;
};
}>("/api/admin/backups/upload", { method: "POST", body: fd });
},
@@ -239,7 +249,15 @@ export function BackupsSection() {
data.summary.intakeEvents +
data.summary.moodEntries +
(data.summary.cycles ?? 0) +
(data.summary.cycleDayLogs ?? 0);
(data.summary.cycleDayLogs ?? 0) +
(data.summary.labResults ?? 0) +
(data.summary.biomarkers ?? 0) +
(data.summary.illnessEpisodes ?? 0) +
(data.summary.illnessDayLogs ?? 0) +
(data.summary.allergies ?? 0) +
(data.summary.familyHistory ?? 0) +
(data.summary.workouts ?? 0) +
(data.summary.documents ?? 0);
toast.success(
t("admin.section.backups.uploadSuccess", { count: String(total) }),
);
@@ -494,6 +494,17 @@ function FullBackupCard() {
</Button>
}
>
{/* v1.28 backup-completeness — the description above lists what's
included; this line honestly discloses what isn't (document
binaries, workout GPS/sample series) rather than implying
"everything" is in the file. Always visible, not gated on the
encrypt toggle. */}
<p
data-testid="export-full-backup-scope-note"
className="text-muted-foreground text-xs"
>
{t("settings.sections.export.cards.fullBackup.scopeNote")}
</p>
<label
htmlFor="export-full-backup-encrypt"
className="flex min-h-11 cursor-pointer items-center gap-3 text-xs"
@@ -0,0 +1,316 @@
/**
* v1.28 backup-completeness — `buildRecordsBackupSection` is the shared
* builder both the on-demand full-backup export AND the weekly `data-backup`
* worker read from (see the module doc comment in `../records-backup.ts`).
* These tests pin:
* - every read is scoped to the caller's `userId` (+ `deletedAt: null`
* where the model soft-deletes) — no cross-user leakage,
* - encrypted free-text columns decrypt through the real crypto path
* (round-tripped with the same key-stub pattern `note-cipher.test.ts`
* uses) rather than an opaque mock,
* - a document manifest entry never carries the raw file bytes,
* - the manifest discloses the two deliberate exclusions,
* - `countRecordsBackupSection` totals every domain, including nested
* illness day-logs.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { encryptNoteToBytes } from "@/lib/labs/store";
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
import { encryptDocumentSummary } from "@/lib/documents/store";
import {
buildRecordsBackupSection,
countRecordsBackupSection,
DOCUMENTS_MANIFEST_NOTE,
WORKOUTS_MANIFEST_NOTE,
} from "../records-backup";
const KEY_A = "a".repeat(64);
beforeEach(() => {
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", KEY_A);
});
afterEach(() => {
vi.unstubAllEnvs();
});
const USER_ID = "user-1";
function buildMockPrisma() {
return {
labResult: {
findMany: vi.fn().mockResolvedValue([
{
panel: "Lipid panel",
analyte: "LDL",
value: 118,
valueText: null,
unit: "mg/dL",
referenceLow: null,
referenceHigh: 130,
takenAt: new Date("2026-04-01T09:00:00.000Z"),
source: "MANUAL",
biomarker: { name: "LDL Cholesterol" },
noteEncrypted: encryptNoteToBytes("Fasted draw."),
},
]),
},
biomarker: {
findMany: vi.fn().mockResolvedValue([
{
name: "LDL Cholesterol",
unit: "mg/dL",
lowerBound: null,
upperBound: 130,
panel: "Lipid panel",
hidden: false,
contextEncrypted: encryptContextToBytes("Low-density lipoprotein."),
},
]),
},
illnessEpisode: {
findMany: vi.fn().mockResolvedValue([
{
id: "ep-parent",
label: "Migraine",
type: "CHRONIC",
lifecycle: "CHRONIC_ONGOING",
onsetAt: new Date("2026-01-01T00:00:00.000Z"),
resolvedAt: null,
parentConditionId: null,
noteEncrypted: null,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
dayLogs: [],
},
{
id: "ep-flare",
label: "Migraine flare",
type: "CHRONIC",
lifecycle: "FLARE",
onsetAt: new Date("2026-04-10T00:00:00.000Z"),
resolvedAt: new Date("2026-04-12T00:00:00.000Z"),
parentConditionId: "ep-parent",
noteEncrypted: encryptToBytes("Triggered by travel."),
createdAt: new Date("2026-04-10T00:00:00.000Z"),
updatedAt: new Date("2026-04-12T00:00:00.000Z"),
dayLogs: [
{
id: "dl-1",
episodeId: "ep-flare",
date: "2026-04-10",
functionalImpact: 2,
feverC: null,
noteEncrypted: encryptToBytes("Bad day."),
updatedAt: new Date("2026-04-10T00:00:00.000Z"),
symptomLinks: [{ severity: 3, symptom: { key: "headache" } }],
},
],
},
]),
},
allergy: {
findMany: vi.fn().mockResolvedValue([
{
id: "al-1",
substance: "Penicillin",
category: "MEDICATION",
type: "ALLERGY",
severity: "SEVERE",
status: "ACTIVE",
onsetAt: null,
reactionEncrypted: encryptToBytes("Hives"),
notesEncrypted: null,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
},
]),
},
familyHistoryEntry: {
findMany: vi.fn().mockResolvedValue([
{
id: "fh-1",
relationship: "MOTHER",
condition: "Type 2 diabetes",
ageAtOnset: 52,
notesEncrypted: null,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
},
]),
},
workout: {
findMany: vi.fn().mockResolvedValue([
{
sportType: "running",
startedAt: new Date("2026-04-01T07:00:00.000Z"),
endedAt: new Date("2026-04-01T08:00:00.000Z"),
durationSec: 3600,
totalEnergyKcal: 600,
totalDistanceM: 10000,
avgHeartRate: 150,
maxHeartRate: 175,
minHeartRate: 110,
stepCount: 9000,
elevationM: 80,
pauseDurationSec: 0,
source: "APPLE_HEALTH",
externalId: "hk-1",
},
]),
},
inboundDocument: {
findMany: vi.fn().mockResolvedValue([
{
id: "doc-1",
kind: "LAB_RESULT",
title: "Blood panel",
filename: "panel.pdf",
mimeType: "application/pdf",
byteSize: 12345,
status: "STORED",
reportDate: new Date("2026-03-30T00:00:00.000Z"),
documentDate: new Date("2026-03-30T00:00:00.000Z"),
summaryEncrypted: encryptDocumentSummary("Routine panel."),
createdAt: new Date("2026-03-30T00:00:00.000Z"),
},
]),
},
};
}
describe("buildRecordsBackupSection", () => {
it("scopes every soft-deletable read to userId + deletedAt: null", async () => {
const prisma = buildMockPrisma();
await buildRecordsBackupSection(prisma as never, USER_ID);
expect(prisma.labResult.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: USER_ID, deletedAt: null },
}),
);
expect(prisma.illnessEpisode.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: USER_ID, deletedAt: null },
}),
);
expect(prisma.allergy.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: USER_ID, deletedAt: null },
}),
);
expect(prisma.familyHistoryEntry.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: USER_ID, deletedAt: null },
}),
);
expect(prisma.inboundDocument.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: USER_ID, deletedAt: null },
}),
);
// Biomarker + Workout are not soft-deleted models — userId-only scope.
expect(prisma.biomarker.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: USER_ID } }),
);
expect(prisma.workout.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: USER_ID } }),
);
});
it("decrypts every free-text column through the real crypto path", async () => {
const prisma = buildMockPrisma();
const section = await buildRecordsBackupSection(prisma as never, USER_ID);
expect(section.labResults[0].note).toBe("Fasted draw.");
expect(section.labResults[0].biomarkerName).toBe("LDL Cholesterol");
expect(section.biomarkers[0].context).toBe("Low-density lipoprotein.");
expect(section.allergies[0].reaction).toBe("Hives");
expect(section.familyHistory[0].condition).toBe("Type 2 diabetes");
expect(section.documents[0].summary).toBe("Routine panel.");
});
it("threads an illness flare's parentConditionId and nests its day-log + symptom", async () => {
const prisma = buildMockPrisma();
const section = await buildRecordsBackupSection(prisma as never, USER_ID);
const flare = section.illnessEpisodes.find((e) => e.id === "ep-flare");
expect(flare?.parentConditionId).toBe("ep-parent");
expect(flare?.note).toBe("Triggered by travel.");
expect(flare?.dayLogs).toHaveLength(1);
expect(flare?.dayLogs[0].note).toBe("Bad day.");
expect(flare?.dayLogs[0].symptoms).toEqual([
{ key: "headache", severity: 3 },
]);
});
it("never carries the document's raw content bytes in the manifest entry", async () => {
const prisma = buildMockPrisma();
const section = await buildRecordsBackupSection(prisma as never, USER_ID);
expect(section.documents[0]).not.toHaveProperty("contentEncrypted");
expect(section.documents[0]).not.toHaveProperty("content");
// The read itself never selects the blob column — pin the `select`.
const call = prisma.inboundDocument.findMany.mock.calls[0][0];
expect(call.select).not.toHaveProperty("contentEncrypted");
});
it("discloses the document + workout exclusions in the manifest", async () => {
const prisma = buildMockPrisma();
const section = await buildRecordsBackupSection(prisma as never, USER_ID);
expect(section.manifest.documents).toEqual({
included: "metadata-only",
note: DOCUMENTS_MANIFEST_NOTE,
});
expect(section.manifest.workouts).toEqual({
included: "summary-only",
note: WORKOUTS_MANIFEST_NOTE,
});
});
it("counts every domain, including nested illness day-logs", async () => {
const prisma = buildMockPrisma();
const section = await buildRecordsBackupSection(prisma as never, USER_ID);
const counts = countRecordsBackupSection(section);
expect(counts).toEqual({
labResults: 1,
biomarkers: 1,
illnessEpisodes: 2,
illnessDayLogs: 1,
allergies: 1,
familyHistory: 1,
workouts: 1,
documents: 1,
});
});
it("fails soft (null, not a throw) on an undecryptable note", async () => {
const prisma = buildMockPrisma();
// Corrupt the ciphertext so decrypt throws internally — the row must
// still come back with `note: null`, never abort the whole backup.
prisma.labResult.findMany.mockResolvedValueOnce([
{
panel: null,
analyte: "HbA1c",
value: 5.4,
valueText: null,
unit: "%",
referenceLow: null,
referenceHigh: null,
takenAt: new Date("2026-04-01T09:00:00.000Z"),
source: "MANUAL",
biomarker: null,
noteEncrypted: new Uint8Array([1, 2, 3, 4]),
},
]);
const section = await buildRecordsBackupSection(prisma as never, USER_ID);
expect(section.labResults[0].note).toBeNull();
});
});
+21 -2
View File
@@ -15,8 +15,13 @@ import { readNote } from "@/lib/crypto/note-cipher";
import { findMeasurementsPaged } from "@/lib/export/paged-measurements";
import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup";
import { buildCycleBackupSection } from "@/lib/cycle/backup";
import {
buildRecordsBackupSection,
countRecordsBackupSection,
type RecordsBackupCounts,
} from "@/lib/export/records-backup";
export interface FullBackupCounts {
export interface FullBackupCounts extends RecordsBackupCounts {
measurements: number;
medications: number;
intakeEvents: number;
@@ -38,7 +43,7 @@ export async function buildFullBackupPayload(
prisma: PrismaClient,
userId: string,
): Promise<FullBackupResult> {
const [measurements, medications, intakeEvents, moodEntries, cycle] =
const [measurements, medications, intakeEvents, moodEntries, cycle, records] =
await Promise.all([
// v1.28.25 — keyset-paginated read with a narrow select. The backup
// is inherently whole-history, so on a CGM / per-sample-HR account the
@@ -88,6 +93,7 @@ export async function buildFullBackupPayload(
orderBy: { moodLoggedAt: "desc" },
}),
buildCycleBackupSection(prisma, userId),
buildRecordsBackupSection(prisma, userId),
]);
const payload = {
@@ -131,6 +137,18 @@ export async function buildFullBackupPayload(
cycleProfile: cycle.cycleProfile,
cycles: cycle.cycles,
cycleDayLogs: cycle.cycleDayLogs,
// v1.28 backup-completeness — the domains the pre-existing shape never
// covered. `manifest` discloses the two deliberate exclusions (document
// binaries, workout GPS/sample time series) inline in the file itself,
// not just in the export UI copy.
labResults: records.labResults,
biomarkers: records.biomarkers,
illnessEpisodes: records.illnessEpisodes,
allergies: records.allergies,
familyHistory: records.familyHistory,
workouts: records.workouts,
documents: records.documents,
manifest: records.manifest,
};
return {
@@ -142,6 +160,7 @@ export async function buildFullBackupPayload(
moodEntries: moodEntries.length,
cycles: cycle.cycles.length,
cycleDayLogs: cycle.cycleDayLogs.length,
...countRecordsBackupSection(records),
},
};
}
+334
View File
@@ -0,0 +1,334 @@
/**
* Structured-records / workouts / documents-manifest 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.
*
* 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".
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { decryptNoteSoft } from "@/lib/labs/store";
import { decryptContextSoft } from "@/lib/labs/biomarker-store";
import { decryptDocumentSummary } from "@/lib/documents/store";
import { getEvent } from "@/lib/logging/context";
import {
toIllnessEpisodeDTO,
toIllnessDayLogDTO,
dayLogSymptomInclude,
type IllnessEpisodeDTO,
type IllnessDayLogDTO,
} from "@/lib/illness/dto";
import {
toAllergyDTO,
toFamilyHistoryEntryDTO,
type AllergyDTO,
type FamilyHistoryEntryDTO,
} from "@/lib/records/dto";
/** Disclosed in the payload manifest AND mirrored in the export UI copy. */
export const DOCUMENTS_MANIFEST_NOTE =
"Document metadata (title, filename, type, dates, and the AI-generated " +
"summary if one was generated) is included. The original uploaded files " +
"are NOT included in this export — download them individually from " +
"Settings → Documents, or via the document API.";
/** Disclosed in the payload manifest AND mirrored in the export UI copy. */
export const WORKOUTS_MANIFEST_NOTE =
"Workout summary records (duration, distance, heart rate, calories, " +
"steps) are included. GPS routes and per-sample heart-rate/pace time " +
"series are not included in this export.";
export interface LabResultBackupEntry {
panel: string | null;
analyte: string;
value: number | null;
valueText: string | null;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
takenAt: string;
source: string;
/** Human-readable cross-reference into `biomarkers` below, not an id. */
biomarkerName: string | null;
note: string | null;
}
export interface BiomarkerBackupEntry {
name: string;
unit: string;
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
hidden: boolean;
context: string | null;
}
export interface WorkoutBackupEntry {
sportType: string;
startedAt: string;
endedAt: string;
durationSec: number;
totalEnergyKcal: number | null;
totalDistanceM: number | null;
avgHeartRate: number | null;
maxHeartRate: number | null;
minHeartRate: number | null;
stepCount: number | null;
elevationM: number | null;
pauseDurationSec: number | null;
source: string;
externalId: string | null;
}
export interface DocumentBackupEntry {
id: string;
kind: string;
title: string | null;
filename: string | null;
mimeType: string;
byteSize: number;
status: string;
reportDate: string | null;
documentDate: string | null;
summary: string | null;
createdAt: string;
}
export interface RecordsBackupSection {
labResults: LabResultBackupEntry[];
biomarkers: BiomarkerBackupEntry[];
illnessEpisodes: Array<IllnessEpisodeDTO & { dayLogs: IllnessDayLogDTO[] }>;
allergies: AllergyDTO[];
familyHistory: FamilyHistoryEntryDTO[];
workouts: WorkoutBackupEntry[];
documents: DocumentBackupEntry[];
manifest: {
documents: { included: "metadata-only"; note: string };
workouts: { included: "summary-only"; note: string };
};
}
export interface RecordsBackupCounts {
labResults: number;
biomarkers: number;
illnessEpisodes: number;
illnessDayLogs: number;
allergies: number;
familyHistory: number;
workouts: number;
documents: number;
}
/** Decrypt a document's optional AI summary fail-soft (null on any error). */
function decryptSummarySoft(buf: Uint8Array | null): string | null {
if (!buf || buf.byteLength === 0) return null;
try {
return decryptDocumentSummary(buf);
} catch (err) {
getEvent()?.addWarning(
`document summary decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return 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.
*/
export async function buildRecordsBackupSection(
prisma: Pick<
PrismaClient,
| "labResult"
| "biomarker"
| "illnessEpisode"
| "allergy"
| "familyHistoryEntry"
| "workout"
| "inboundDocument"
>,
userId: string,
): Promise<RecordsBackupSection> {
const [
labResultRows,
biomarkerRows,
episodeRows,
allergyRows,
familyRows,
workoutRows,
documentRows,
] = await Promise.all([
prisma.labResult.findMany({
where: { userId, deletedAt: null },
orderBy: { takenAt: "desc" },
include: { biomarker: { select: { name: true } } },
}),
prisma.biomarker.findMany({
where: { userId },
orderBy: { name: "asc" },
}),
prisma.illnessEpisode.findMany({
where: { userId, deletedAt: null },
orderBy: { onsetAt: "desc" },
include: {
dayLogs: {
where: { deletedAt: null },
orderBy: { date: "asc" },
include: dayLogSymptomInclude,
},
},
}),
prisma.allergy.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "desc" },
}),
prisma.familyHistoryEntry.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "desc" },
}),
prisma.workout.findMany({
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,
},
}),
]);
const labResults: LabResultBackupEntry[] = labResultRows.map((r) => ({
panel: r.panel,
analyte: r.analyte,
value: r.value,
valueText: r.valueText,
unit: r.unit,
referenceLow: r.referenceLow,
referenceHigh: r.referenceHigh,
takenAt: r.takenAt.toISOString(),
source: r.source,
biomarkerName: r.biomarker?.name ?? null,
note: decryptNoteSoft(r.noteEncrypted),
}));
const biomarkers: BiomarkerBackupEntry[] = biomarkerRows.map((b) => ({
name: b.name,
unit: b.unit,
lowerBound: b.lowerBound,
upperBound: b.upperBound,
panel: b.panel,
hidden: b.hidden,
context: decryptContextSoft(b.contextEncrypted),
}));
const illnessEpisodes = episodeRows.map((row) => ({
...toIllnessEpisodeDTO(row),
dayLogs: row.dayLogs.map(toIllnessDayLogDTO),
}));
const allergies = allergyRows.map(toAllergyDTO);
const familyHistory = familyRows.map(toFamilyHistoryEntryDTO);
const workouts: WorkoutBackupEntry[] = workoutRows.map((w) => ({
sportType: w.sportType,
startedAt: w.startedAt.toISOString(),
endedAt: w.endedAt.toISOString(),
durationSec: w.durationSec,
totalEnergyKcal: w.totalEnergyKcal,
totalDistanceM: w.totalDistanceM,
avgHeartRate: w.avgHeartRate,
maxHeartRate: w.maxHeartRate,
minHeartRate: w.minHeartRate,
stepCount: w.stepCount,
elevationM: w.elevationM,
pauseDurationSec: w.pauseDurationSec,
source: w.source,
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(),
}));
return {
labResults,
biomarkers,
illnessEpisodes,
allergies,
familyHistory,
workouts,
documents,
manifest: {
documents: { included: "metadata-only", note: DOCUMENTS_MANIFEST_NOTE },
workouts: { included: "summary-only", note: WORKOUTS_MANIFEST_NOTE },
},
};
}
/** Row counts for the audit trail / annotate() meta, mirroring `FullBackupCounts`. */
export function countRecordsBackupSection(
section: RecordsBackupSection,
): RecordsBackupCounts {
return {
labResults: section.labResults.length,
biomarkers: section.biomarkers.length,
illnessEpisodes: section.illnessEpisodes.length,
illnessDayLogs: section.illnessEpisodes.reduce(
(sum, e) => sum + e.dayLogs.length,
0,
),
allergies: section.allergies.length,
familyHistory: section.familyHistory.length,
workouts: section.workouts.length,
documents: section.documents.length,
};
}
+75 -53
View File
@@ -9,6 +9,7 @@ import { recordError } from "@/lib/jobs/worker-status";
import { encrypt } from "@/lib/crypto";
import { withBackgroundEvent } from "@/lib/logging/background";
import { buildCycleBackupSection } from "@/lib/cycle/backup";
import { buildRecordsBackupSection } from "@/lib/export/records-backup";
import { runOffhostBackup } from "@/lib/jobs/offhost-backup";
import {
collectPagedMeasurements,
@@ -64,59 +65,69 @@ export async function handleDataBackup(jobs: Job<DataBackupPayload>[]) {
let backed = 0;
for (const user of users) {
try {
const [measurements, medications, intakeEvents, moodEntries, cycle] =
await Promise.all([
// Keyset-paged narrow read instead of one full-set
// findMany + map: a heavy multi-year tenant otherwise
// loads every measurement ORM row into a single array
// alongside the giant JSON.stringify below, the worker's
// dominant heap spike. The page reader projects each page
// to the compact backup shape before the next page loads,
// bounding peak heap to the page size. Row scope is
// unchanged — no `deletedAt` filter, so tombstoned rows
// still round-trip exactly as before. The order is
// restored to measuredAt-desc to match the prior output.
collectPagedMeasurements({
fetchPage: (afterId, take) =>
prisma.measurement.findMany({
where: {
userId: user.id,
...(afterId ? { id: { gt: afterId } } : {}),
},
// Exactly the columns the admin restore recreates a
// row from, plus `id` for the keyset cursor.
select: {
id: true,
type: true,
value: true,
unit: true,
source: true,
measuredAt: true,
notes: true,
notesEncrypted: true,
},
orderBy: { id: "asc" },
take,
}),
project: toWeeklyBackupMeasurement,
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
}).then(sortWeeklyMeasurementsDesc),
prisma.medication.findMany({
where: { userId: user.id },
include: { schedules: true },
}),
prisma.medicationIntakeEvent.findMany({
where: { userId: user.id },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
}),
prisma.moodEntry.findMany({
where: { userId: user.id },
orderBy: { moodLoggedAt: "desc" },
}),
// v1.15.0 — cycle slice (shared helper, notesEncrypted verbatim).
buildCycleBackupSection(prisma, user.id),
]);
const [
measurements,
medications,
intakeEvents,
moodEntries,
cycle,
records,
] = await Promise.all([
// Keyset-paged narrow read instead of one full-set
// findMany + map: a heavy multi-year tenant otherwise
// loads every measurement ORM row into a single array
// alongside the giant JSON.stringify below, the worker's
// dominant heap spike. The page reader projects each page
// to the compact backup shape before the next page loads,
// bounding peak heap to the page size. Row scope is
// unchanged — no `deletedAt` filter, so tombstoned rows
// still round-trip exactly as before. The order is
// restored to measuredAt-desc to match the prior output.
collectPagedMeasurements({
fetchPage: (afterId, take) =>
prisma.measurement.findMany({
where: {
userId: user.id,
...(afterId ? { id: { gt: afterId } } : {}),
},
// Exactly the columns the admin restore recreates a
// row from, plus `id` for the keyset cursor.
select: {
id: true,
type: true,
value: true,
unit: true,
source: true,
measuredAt: true,
notes: true,
notesEncrypted: true,
},
orderBy: { id: "asc" },
take,
}),
project: toWeeklyBackupMeasurement,
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
}).then(sortWeeklyMeasurementsDesc),
prisma.medication.findMany({
where: { userId: user.id },
include: { schedules: true },
}),
prisma.medicationIntakeEvent.findMany({
where: { userId: user.id },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
}),
prisma.moodEntry.findMany({
where: { userId: user.id },
orderBy: { moodLoggedAt: "desc" },
}),
// 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),
]);
const backupJson = JSON.stringify({
// Bumped only when the on-disk shape changes incompatibly.
@@ -159,6 +170,17 @@ export async function handleDataBackup(jobs: Job<DataBackupPayload>[]) {
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).
labResults: records.labResults,
biomarkers: records.biomarkers,
illnessEpisodes: records.illnessEpisodes,
allergies: records.allergies,
familyHistory: records.familyHistory,
workouts: records.workouts,
documents: records.documents,
manifest: records.manifest,
});
// Encrypt the backup data (contains sensitive health information)
+24
View File
@@ -9,6 +9,7 @@
import { Buffer } from "node:buffer";
import { decrypt, encrypt } from "@/lib/crypto";
import { getEvent } from "@/lib/logging/context";
/** Encrypt a UTF-8 note into the `Bytes` payload the schema stores. */
export function encryptNoteToBytes(plaintext: string): Uint8Array<ArrayBuffer> {
@@ -25,3 +26,26 @@ export function encryptNoteToBytes(plaintext: string): Uint8Array<ArrayBuffer> {
export function decryptNoteFromBytes(buf: Uint8Array): string {
return decrypt(Buffer.from(buf).toString("utf8"));
}
/**
* Decrypt a stored `Bytes` note, fail-soft to `null` on any error.
*
* Mirrors `decryptContextSoft` in the biomarker store: a single bad-key /
* malformed row must not fail a bulk read (list view, backup export) that
* spans many rows. The single-resource GET path uses the throwing
* `decryptNoteFromBytes` so a genuine decrypt failure there surfaces instead
* of silently masking a key-rotation gap.
*/
export function decryptNoteSoft(buf: Uint8Array | null): string | null {
if (!buf || buf.byteLength === 0) return null;
try {
return decryptNoteFromBytes(buf);
} catch (err) {
getEvent()?.addWarning(
`lab note decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}
+236 -1
View File
@@ -18,7 +18,7 @@
* of bubbling out of `prisma.createMany` with a useless message.
*/
import { describe, expect, it } from "vitest";
import { backupPayloadSchema } from "../backup";
import { backupPayloadSchema, summarizeBackup } from "../backup";
const baseEntry = {
date: "2026-05-08",
@@ -148,3 +148,238 @@ describe("backupPayloadSchema — v1.15.0 cycle round-trip", () => {
}
});
});
describe("backupPayloadSchema — v1.28 backup-completeness domains", () => {
it("defaults every records field when a pre-v1.28 blob omits them", () => {
const r = backupPayloadSchema.safeParse({
schemaVersion: "1",
exportedAt: "2026-05-08T07:00:00.000Z",
userId: "u1",
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.labResults).toEqual([]);
expect(r.data.biomarkers).toEqual([]);
expect(r.data.illnessEpisodes).toEqual([]);
expect(r.data.allergies).toEqual([]);
expect(r.data.familyHistory).toEqual([]);
expect(r.data.workouts).toEqual([]);
expect(r.data.documents).toEqual([]);
expect(r.data.manifest).toBeNull();
}
});
it("round-trips a lab result cross-referencing a biomarker by name", () => {
const r = backupPayloadSchema.safeParse({
schemaVersion: "1",
exportedAt: "2026-05-08T07:00:00.000Z",
userId: "u1",
biomarkers: [
{
name: "LDL Cholesterol",
unit: "mg/dL",
lowerBound: null,
upperBound: 130,
panel: "Lipid panel",
hidden: false,
context: "Low-density lipoprotein.",
},
],
labResults: [
{
panel: "Lipid panel",
analyte: "LDL Cholesterol",
value: 118,
valueText: null,
unit: "mg/dL",
referenceLow: null,
referenceHigh: 130,
takenAt: "2026-04-01T09:00:00.000Z",
source: "MANUAL",
biomarkerName: "LDL Cholesterol",
note: "Fasted draw.",
},
],
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.biomarkers[0].name).toBe("LDL Cholesterol");
expect(r.data.labResults[0].biomarkerName).toBe("LDL Cholesterol");
expect(r.data.labResults[0].note).toBe("Fasted draw.");
}
});
it("round-trips an illness episode with a flare referencing its parent + nested day-logs", () => {
const r = backupPayloadSchema.safeParse({
schemaVersion: "1",
exportedAt: "2026-05-08T07:00:00.000Z",
userId: "u1",
illnessEpisodes: [
{
id: "ep-1",
label: "Migraine",
type: "CHRONIC",
lifecycle: "CHRONIC_ONGOING",
onsetAt: "2026-01-01T00:00:00.000Z",
resolvedAt: null,
parentConditionId: null,
note: null,
dayLogs: [],
},
{
id: "ep-2",
label: "Migraine flare",
type: "CHRONIC",
lifecycle: "FLARE",
onsetAt: "2026-04-10T00:00:00.000Z",
resolvedAt: "2026-04-12T00:00:00.000Z",
parentConditionId: "ep-1",
note: "Triggered by travel.",
dayLogs: [
{
date: "2026-04-10",
functionalImpact: 2,
feverC: null,
symptoms: [{ key: "headache", severity: 3 }],
note: "Bad day.",
},
],
},
],
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.illnessEpisodes).toHaveLength(2);
expect(r.data.illnessEpisodes[1].parentConditionId).toBe("ep-1");
expect(r.data.illnessEpisodes[1].dayLogs[0].symptoms[0].key).toBe(
"headache",
);
}
});
it("round-trips allergies, family history, workouts, documents, and the manifest", () => {
const r = backupPayloadSchema.safeParse({
schemaVersion: "1",
exportedAt: "2026-05-08T07:00:00.000Z",
userId: "u1",
allergies: [
{
id: "al-1",
substance: "Penicillin",
category: "MEDICATION",
type: "ALLERGY",
severity: "SEVERE",
status: "ACTIVE",
onsetAt: null,
reaction: "Hives",
note: null,
},
],
familyHistory: [
{
id: "fh-1",
relationship: "MOTHER",
condition: "Type 2 diabetes",
ageAtOnset: 52,
note: null,
},
],
workouts: [
{
sportType: "running",
startedAt: "2026-04-01T07:00:00.000Z",
endedAt: "2026-04-01T08:00:00.000Z",
durationSec: 3600,
totalEnergyKcal: 600,
totalDistanceM: 10000,
avgHeartRate: 150,
maxHeartRate: 175,
minHeartRate: 110,
stepCount: 9000,
elevationM: 80,
pauseDurationSec: 0,
source: "APPLE_HEALTH",
externalId: "hk-1",
},
],
documents: [
{
id: "doc-1",
kind: "LAB_RESULT",
title: "Blood panel",
filename: "panel.pdf",
mimeType: "application/pdf",
byteSize: 12345,
status: "STORED",
reportDate: "2026-03-30",
documentDate: "2026-03-30",
summary: "Routine panel.",
},
],
manifest: {
documents: { included: "metadata-only", note: "Files excluded." },
workouts: { included: "summary-only", note: "Routes excluded." },
},
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.allergies[0].substance).toBe("Penicillin");
expect(r.data.familyHistory[0].relationship).toBe("MOTHER");
expect(r.data.workouts[0].sportType).toBe("running");
expect(r.data.documents[0].id).toBe("doc-1");
// A document manifest entry never carries the raw file bytes — the
// schema simply has no field for it, so an attacker-supplied
// `contentEncrypted` key on upload is dropped as passthrough noise,
// never parsed into a typed field a restore path could act on.
expect(r.data.documents[0]).not.toHaveProperty("contentEncrypted");
expect(r.data.manifest?.documents.included).toBe("metadata-only");
expect(r.data.manifest?.workouts.included).toBe("summary-only");
}
});
it("summarizeBackup reports counts for every v1.28 domain, incl. nested day-logs", () => {
const parsed = backupPayloadSchema.parse({
schemaVersion: "1",
exportedAt: "2026-05-08T07:00:00.000Z",
userId: "u1",
labResults: [
{
analyte: "HbA1c",
unit: "%",
takenAt: "2026-04-01T09:00:00.000Z",
source: "MANUAL",
},
],
illnessEpisodes: [
{
id: "ep-1",
label: "Cold",
type: "INFECTION",
lifecycle: "ACUTE",
onsetAt: "2026-04-01T00:00:00.000Z",
dayLogs: [
{ date: "2026-04-01", symptoms: [] },
{ date: "2026-04-02", symptoms: [] },
],
},
],
workouts: [
{
sportType: "cycling",
startedAt: "2026-04-01T07:00:00.000Z",
endedAt: "2026-04-01T08:00:00.000Z",
durationSec: 3600,
source: "MANUAL",
},
],
});
const summary = summarizeBackup(parsed);
expect(summary.labResults).toBe(1);
expect(summary.illnessEpisodes).toBe(1);
expect(summary.illnessDayLogs).toBe(2);
expect(summary.workouts).toBe(1);
expect(summary.allergies).toBe(0);
expect(summary.familyHistory).toBe(0);
expect(summary.documents).toBe(0);
});
});
+192
View File
@@ -177,6 +177,160 @@ const cycleProfileSchema = z
})
.passthrough();
/* ── v1.28 backup-completeness 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.
*/
const labResultBackupSchema = z
.object({
panel: z.string().nullable().optional(),
analyte: z.string().min(1),
value: z.number().nullable().optional(),
valueText: z.string().nullable().optional(),
unit: z.string().min(1),
referenceLow: z.number().nullable().optional(),
referenceHigh: z.number().nullable().optional(),
takenAt: isoDateTime,
source: z.string().min(1),
biomarkerName: z.string().nullable().optional(),
note: z.string().nullable().optional(),
})
.passthrough();
const biomarkerBackupSchema = z
.object({
name: z.string().min(1),
unit: z.string().min(1),
lowerBound: z.number().nullable().optional(),
upperBound: z.number().nullable().optional(),
panel: z.string().nullable().optional(),
hidden: z.boolean().optional(),
context: z.string().nullable().optional(),
})
.passthrough();
const illnessSymptomBackupSchema = z
.object({
key: z.string().min(1),
severity: z.number().int().nullable().optional(),
})
.passthrough();
const illnessDayLogBackupSchema = z
.object({
id: z.string().min(1).optional(),
episodeId: z.string().min(1).optional(),
date: z.string().min(1),
functionalImpact: z.number().int().nullable().optional(),
feverC: z.number().nullable().optional(),
symptoms: z.array(illnessSymptomBackupSchema).default([]),
note: z.string().nullable().optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
const illnessEpisodeBackupSchema = z
.object({
id: z.string().min(1),
label: z.string().min(1),
type: z.string().min(1),
lifecycle: z.string().min(1),
onsetAt: isoDateTime,
resolvedAt: isoDateTime.nullable().optional(),
// Self-referencing flare/exacerbation link, carried as the exported
// episode's own id — never resolved against another user's rows.
parentConditionId: z.string().nullable().optional(),
note: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
dayLogs: z.array(illnessDayLogBackupSchema).default([]),
})
.passthrough();
const allergyBackupSchema = z
.object({
id: z.string().min(1),
substance: z.string().min(1),
category: z.string().min(1),
type: z.string().min(1),
severity: z.string().nullable().optional(),
status: z.string().min(1),
onsetAt: isoDateTime.nullable().optional(),
reaction: z.string().nullable().optional(),
note: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
const familyHistoryBackupSchema = z
.object({
id: z.string().min(1),
relationship: z.string().min(1),
condition: z.string().min(1),
ageAtOnset: z.number().int().nullable().optional(),
note: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
const workoutBackupSchema = z
.object({
sportType: z.string().min(1),
startedAt: isoDateTime,
endedAt: isoDateTime,
durationSec: z.number().int(),
totalEnergyKcal: z.number().nullable().optional(),
totalDistanceM: z.number().nullable().optional(),
avgHeartRate: z.number().int().nullable().optional(),
maxHeartRate: z.number().int().nullable().optional(),
minHeartRate: z.number().int().nullable().optional(),
stepCount: z.number().int().nullable().optional(),
elevationM: z.number().nullable().optional(),
pauseDurationSec: z.number().int().nullable().optional(),
source: z.string().min(1),
externalId: z.string().nullable().optional(),
})
.passthrough();
const documentBackupSchema = z
.object({
id: z.string().min(1),
kind: z.string().min(1),
title: z.string().nullable().optional(),
filename: z.string().nullable().optional(),
mimeType: z.string().min(1),
byteSize: z.number().int(),
status: z.string().min(1),
reportDate: z.string().nullable().optional(),
documentDate: z.string().nullable().optional(),
summary: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
})
.passthrough();
const backupManifestSchema = z
.object({
documents: z
.object({ included: z.string().min(1), note: z.string().min(1) })
.passthrough(),
workouts: z
.object({ included: z.string().min(1), note: z.string().min(1) })
.passthrough(),
})
.passthrough();
/**
* Wire shape — exactly what the pg-boss worker writes today, plus a
* `schemaVersion` field that newer writers stamp explicitly. Older blobs
@@ -196,6 +350,17 @@ 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.
labResults: z.array(labResultBackupSchema).default([]),
biomarkers: z.array(biomarkerBackupSchema).default([]),
illnessEpisodes: z.array(illnessEpisodeBackupSchema).default([]),
allergies: z.array(allergyBackupSchema).default([]),
familyHistory: z.array(familyHistoryBackupSchema).default([]),
workouts: z.array(workoutBackupSchema).default([]),
documents: z.array(documentBackupSchema).default([]),
manifest: backupManifestSchema.nullable().default(null),
})
.passthrough();
@@ -218,6 +383,22 @@ 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). */
labResults: number;
/** v1.28 — biomarker catalog entries in the backup (export-only). */
biomarkers: number;
/** v1.28 — illness episodes, incl. flares/exacerbations (export-only). */
illnessEpisodes: number;
/** v1.28 — illness day-logs across every episode (export-only). */
illnessDayLogs: number;
/** v1.28 — allergy/intolerance records in the backup (export-only). */
allergies: number;
/** v1.28 — family-history entries in the backup (export-only). */
familyHistory: number;
/** v1.28 — workout summary records in the backup (export-only). */
workouts: number;
/** v1.28 — document manifest entries (metadata only, export-only). */
documents: number;
}
export function summarizeBackup(payload: BackupPayload): BackupSummary {
@@ -231,6 +412,17 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary {
moodEntries: payload.moodEntries.length,
cycles: payload.cycles.length,
cycleDayLogs: payload.cycleDayLogs.length,
labResults: payload.labResults.length,
biomarkers: payload.biomarkers.length,
illnessEpisodes: payload.illnessEpisodes.length,
illnessDayLogs: payload.illnessEpisodes.reduce(
(sum, e) => sum + e.dayLogs.length,
0,
),
allergies: payload.allergies.length,
familyHistory: payload.familyHistory.length,
workouts: payload.workouts.length,
documents: payload.documents.length,
};
}