From 3dd5dbeb42ed9c4c1cd70010cd8147a08f987067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 01:58:19 +0200 Subject: [PATCH] feat(export): make the full backup actually full MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- .../api/admin/backups/[id]/restore/route.ts | 6 + src/app/api/admin/backups/upload/route.ts | 8 + .../export/__tests__/per-type-routes.test.ts | 118 ++++++- .../__tests__/soft-delete-filter.test.ts | 66 ++++ .../export/encrypted/__tests__/route.test.ts | 21 ++ src/app/api/export/encrypted/route.ts | 15 +- src/app/api/export/full-backup/route.ts | 22 +- src/components/admin/backups-section.tsx | 20 +- src/components/settings/export-section.tsx | 11 + .../export/__tests__/records-backup.test.ts | 316 +++++++++++++++++ src/lib/export/full-backup-payload.ts | 23 +- src/lib/export/records-backup.ts | 334 ++++++++++++++++++ src/lib/jobs/reminder/backup-handlers.ts | 128 ++++--- src/lib/labs/store.ts | 24 ++ src/lib/validations/__tests__/backup.test.ts | 237 ++++++++++++- src/lib/validations/backup.ts | 192 ++++++++++ 22 files changed, 1491 insertions(+), 68 deletions(-) create mode 100644 src/lib/export/__tests__/records-backup.test.ts create mode 100644 src/lib/export/records-backup.ts diff --git a/messages/de.json b/messages/de.json index b54aa1a08..6a3bb382f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index d24bcfc2d..2edd6c69f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index ce0861be5..fdc46ff71 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index 4dfd3c433..a476e572d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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.", diff --git a/messages/it.json b/messages/it.json index 3a34e2792..074ac3ad0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/pl.json b/messages/pl.json index 51652f638..07a0c8c46 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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.", diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 3b6d6cfb1..81c99dd63 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -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; diff --git a/src/app/api/admin/backups/upload/route.ts b/src/app/api/admin/backups/upload/route.ts index 9457cd99d..28012e04b 100644 --- a/src/app/api/admin/backups/upload/route.ts +++ b/src/app/api/admin/backups/upload/route.ts @@ -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, }, }, }); diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 956f61e50..f442c3e15 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -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"); + }); }); diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index ae3d95f6d..05b05118e 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -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 }; + 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")); diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index ca3e57174..075776e11 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -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 () => { diff --git a/src/app/api/export/encrypted/route.ts b/src/app/api/export/encrypted/route.ts index 99193bf76..69c8b5806 100644 --- a/src/app/api/export/encrypted/route.ts +++ b/src/app/api/export/encrypted/route.ts @@ -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, }, }); diff --git a/src/app/api/export/full-backup/route.ts b/src/app/api/export/full-backup/route.ts index aad5fd565..f5fe21918 100644 --- a/src/app/api/export/full-backup/route.ts +++ b/src/app/api/export/full-backup/route.ts @@ -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:` 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, }, }); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index bc01e77e5..5b73bbbdf 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -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) }), ); diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx index 6fc50159d..4140f0dca 100644 --- a/src/components/settings/export-section.tsx +++ b/src/components/settings/export-section.tsx @@ -494,6 +494,17 @@ function FullBackupCard() { } > + {/* 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. */} +

+ {t("settings.sections.export.cards.fullBackup.scopeNote")} +