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")} +