diff --git a/messages/de.json b/messages/de.json index cc9a78fd2..fc303b2f4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4503,7 +4503,7 @@ }, "fullBackup": { "title": "Voll-Backup", - "description": "Deine Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese, Nährstoffe und Workouts in einer JSON-Datei. Gleiche Form wie die wöchentliche automatische Sicherung. Einige Satzarten sind noch nicht enthalten — das Manifest der Datei nennt, was drinsteht.", + "description": "Deine Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese, Nährstoffe, Workouts, dein Gesundheitsprofil und deine eigenen Messgrößen in einer JSON-Datei. Gleiche Form wie die wöchentliche automatische Sicherung. Einige Satzarten sind noch nicht enthalten — das Manifest der Datei nennt, was drinsteht.", "scopeNote": "Dokumentmetadaten und die KI-Zusammenfassung, falls vorhanden, sind als Klartext enthalten; die Originaldateien der Dokumente sowie GPS-Routen und Einzelmesswerte von Workouts sind es nicht — lade diese separat aus dem Dokumententresor bzw. dem Workout-Export herunter.", "encryptToggle": "Mit einer Passphrase verschlüsseln", "passphrasePlaceholder": "Passphrase (mind. 12 Zeichen)", diff --git a/messages/en.json b/messages/en.json index bab84d02a..1eb208585 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4503,7 +4503,7 @@ }, "fullBackup": { "title": "Full backup", - "description": "Your measurements, medications, mood, cycle, labs, illness episodes, allergies, family history, nutrients and workouts in one JSON file. Same shape as the weekly auto-backup. Some record types are not carried yet — the file's manifest names what is in it.", + "description": "Your measurements, medications, mood, cycle, labs, illness episodes, allergies, family history, nutrients, workouts, health profile and custom metrics in one JSON file. Same shape as the weekly auto-backup. Some record types are not carried yet — the file's manifest names what is in it.", "scopeNote": "Document metadata and the AI-generated summary, if one exists, are included as plain text; original document files and workout GPS/sample data are not — download those separately from the vault or the workout export.", "encryptToggle": "Encrypt with a passphrase", "passphrasePlaceholder": "Passphrase (min. 12 characters)", diff --git a/messages/es.json b/messages/es.json index ad7bde706..c109e55f1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4503,7 +4503,7 @@ }, "fullBackup": { "title": "Copia completa", - "description": "Tus mediciones, medicamentos, estado de ánimo, ciclo, analíticas, episodios de enfermedad, alergias, antecedentes familiares, nutrientes y entrenamientos en un archivo JSON. El mismo formato que la copia automática semanal. Algunos tipos de registro aún no se incluyen: el manifiesto del archivo indica qué contiene.", + "description": "Tus mediciones, medicamentos, estado de ánimo, ciclo, analíticas, episodios de enfermedad, alergias, antecedentes familiares, nutrientes, entrenamientos, tu perfil de salud y tus métricas personalizadas en un archivo JSON. El mismo formato que la copia automática semanal. Algunos tipos de registro aún no se incluyen: el manifiesto del archivo indica qué contiene.", "scopeNote": "Los metadatos de los documentos y el resumen de IA, si existe, se incluyen en texto plano; 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)", diff --git a/messages/fr.json b/messages/fr.json index 5fc48fc58..5a6520e75 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4503,7 +4503,7 @@ }, "fullBackup": { "title": "Sauvegarde complète", - "description": "Vos mesures, médicaments, humeur, cycle, analyses, épisodes de maladie, allergies, antécédents familiaux, nutriments et séances dans un seul fichier JSON. Même format que la sauvegarde automatique hebdomadaire. Certains types d'enregistrement ne sont pas encore inclus : le manifeste du fichier indique ce qu'il contient.", + "description": "Vos mesures, médicaments, humeur, cycle, analyses, épisodes de maladie, allergies, antécédents familiaux, nutriments, séances, votre profil de santé et vos mesures personnalisées dans un seul fichier JSON. Même format que la sauvegarde automatique hebdomadaire. Certains types d'enregistrement ne sont pas encore inclus : le manifeste du fichier indique ce qu'il contient.", "scopeNote": "Les métadonnées des documents et le résumé IA, s'il existe, sont inclus en texte clair ; les fichiers originaux des documents ainsi que les tracés GPS/données d'échantillons des entraînements ne le sont pas — 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.)", diff --git a/messages/it.json b/messages/it.json index e3a6f3268..6adf6ed54 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4503,7 +4503,7 @@ }, "fullBackup": { "title": "Backup completo", - "description": "Le tue misurazioni, i farmaci, l'umore, il ciclo, le analisi, gli episodi di malattia, le allergie, l'anamnesi familiare, i nutrienti e gli allenamenti in un unico file JSON. Stesso formato del backup automatico settimanale. Alcuni tipi di dato non sono ancora inclusi: il manifest del file indica cosa contiene.", + "description": "Le tue misurazioni, i farmaci, l'umore, il ciclo, le analisi, gli episodi di malattia, le allergie, l'anamnesi familiare, i nutrienti, gli allenamenti, il tuo profilo di salute e le tue metriche personalizzate in un unico file JSON. Stesso formato del backup automatico settimanale. Alcuni tipi di dato non sono ancora inclusi: il manifest del file indica cosa contiene.", "scopeNote": "I metadati dei documenti e il riepilogo IA, se presente, sono inclusi in chiaro; i file originali dei documenti e i dati di percorso GPS/campionamento degli allenamenti non lo sono — scaricali separatamente dall'archivio documenti o dall'esportazione degli allenamenti.", "encryptToggle": "Cifra con una passphrase", "passphrasePlaceholder": "Passphrase (min. 12 caratteri)", diff --git a/messages/pl.json b/messages/pl.json index 08a072c99..3f4decfd3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4503,7 +4503,7 @@ }, "fullBackup": { "title": "Pełna kopia zapasowa", - "description": "Twoje pomiary, leki, nastrój, cykl, wyniki badań, epizody chorobowe, alergie, wywiad rodzinny, składniki odżywcze i treningi w jednym pliku JSON. Ten sam kształt co cotygodniowa kopia automatyczna. Niektóre rodzaje wpisów nie są jeszcze przenoszone — manifest pliku wskazuje, co zawiera.", + "description": "Twoje pomiary, leki, nastrój, cykl, wyniki badań, epizody chorobowe, alergie, wywiad rodzinny, składniki odżywcze, treningi, Twój profil zdrowia i własne wskaźniki w jednym pliku JSON. Ten sam kształt co cotygodniowa kopia automatyczna. Niektóre rodzaje wpisów nie są jeszcze przenoszone — manifest pliku wskazuje, co zawiera.", "scopeNote": "Metadane dokumentów oraz podsumowanie AI, jeśli istnieje, są uwzględnione jako czysty tekst; 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)", diff --git a/src/__tests__/backup-plan-classification.test.ts b/src/__tests__/backup-plan-classification.test.ts index d9419bf9e..676a3feb2 100644 --- a/src/__tests__/backup-plan-classification.test.ts +++ b/src/__tests__/backup-plan-classification.test.ts @@ -129,3 +129,169 @@ describe("every schema model has a backup verdict", () => { ).toEqual([]); }); }); + +/** + * The second half of the guard: a `BACKED_UP` model either travels BOTH ways or + * is named as debt. + * + * The first half above proves every model has an opinion. It would pass + * unchanged while a model classified as carried was read into the payload and + * never written back — which is not a hypothetical, it is what happened to the + * nutrient day totals, to the custom mood tags, and to the custom cycle + * symptoms, three releases running. + * + * The check reads the declared writer and restore files rather than grepping + * for a route, BECAUSE the route delegates. A review that grepped only + * `restore/route.ts` concluded the cycle data was never restored and had to be + * retracted; `restoreCycleData` and `restoreProfileData` both live elsewhere. + * Following the helpers is the entire difficulty of this question, so the file + * list is declared next to the verdicts and checked to exist. + */ + +import { + BACKUP_RESTORE_FILES, + BACKUP_WRITER_FILES, + COVERAGE_PENDING, + TWO_ENDED_MODELS, +} from "@/lib/export/backup-plan"; + +const REPO_ROOT = resolve(__dirname, "../.."); + +function read(relativePath: string): string { + return readFileSync(resolve(REPO_ROOT, relativePath), "utf8"); +} + +/** `Measurement` → `measurement`, the Prisma delegate name. */ +function delegateName(model: string): string { + return model[0].toLowerCase() + model.slice(1); +} + +/** + * Relation fields on OTHER models whose type is `model`. + * + * A child rides its parent — `include: { schedules: true }` on the way out, + * `schedules: { create: [...] }` on the way back — so a delegate-only search + * would report every nested child as uncarried and the guard would be noise. + */ +function relationFieldNames(model: string): string[] { + const source = readFileSync(SCHEMA_PATH, "utf8"); + const names = new Set(); + for (const [, body] of source.matchAll(/^model\s+\w+\s*\{([\s\S]*?)^\}/gm)) { + for (const line of body.split("\n")) { + const match = /^\s*(\w+)\s+(\w+)(\[\])?\??\s*(@|$)/.exec(line); + if (match && match[2] === model) names.add(match[1]); + } + } + return [...names]; +} + +const READ_OPS = /(findMany|findUnique|findFirst|findUniqueOrThrow|groupBy)/; +const WRITE_OPS = /(create|createMany|upsert|update|updateMany)/; + +/** Does any of `files` touch `model` with an operation matching `ops`? */ +function touches( + files: readonly string[], + model: string, + ops: RegExp, +): boolean { + const delegate = delegateName(model); + const relations = relationFieldNames(model); + return files.some((file) => { + // Whitespace-flattened so a call broken across lines still matches. + const source = read(file).replace(/\s+/g, " "); + for (const match of source.matchAll( + new RegExp(`\\.${delegate}\\s*\\.\\s*(\\w+)`, "g"), + )) { + if (ops.test(match[1])) return true; + } + // A nested relation key: `schedules: {`, `dayLogs: [`, `entries: true`. + return relations.some((relation) => + new RegExp(`\\b${relation}\\s*:\\s*[{[t]`).test(source), + ); + }); +} + +describe("every backed-up model travels both ways, or is named as debt", () => { + it("declares writer and restore files that exist", () => { + for (const file of [...BACKUP_WRITER_FILES, ...BACKUP_RESTORE_FILES]) { + expect(() => read(file), `${file} is declared but missing`).not.toThrow(); + } + // A grep of the restore ROUTE alone is the mistake this list prevents, so + // the list has to name more than the route. + expect(BACKUP_RESTORE_FILES.length).toBeGreaterThan(1); + }); + + it("splits BACKED_UP into exactly two-ended and pending, with no overlap", () => { + const backedUp = new Set(BACKED_UP_MODELS); + const twoEnded = new Set(TWO_ENDED_MODELS); + const pending = new Set(Object.keys(COVERAGE_PENDING)); + + const both = [...twoEnded].filter((m) => pending.has(m)); + expect( + both, + "a model cannot be both proven and pending — that is how a gap hides", + ).toEqual([]); + + const unaccounted = [...backedUp].filter( + (m) => !twoEnded.has(m) && !pending.has(m), + ); + expect( + unaccounted, + "add each to TWO_ENDED_MODELS once its reader AND restore branch land, " + + "or to COVERAGE_PENDING with what an account loses meanwhile", + ).toEqual([]); + + const foreign = [...twoEnded, ...pending].filter((m) => !backedUp.has(m)); + expect( + foreign, + "only a BACKED_UP model has two ends to have; these are not on that list", + ).toEqual([]); + }); + + it("finds a payload reader for every model claimed two-ended", () => { + const missing = TWO_ENDED_MODELS.filter( + (model) => !touches(BACKUP_WRITER_FILES, model, READ_OPS), + ); + expect( + missing, + "claimed carried, but no backup writer reads it — the restore branch has " + + "nothing feeding it and a restore silently rebuilds the account without it", + ).toEqual([]); + }); + + it("finds a restore branch for every model claimed two-ended", () => { + const missing = TWO_ENDED_MODELS.filter( + (model) => !touches(BACKUP_RESTORE_FILES, model, WRITE_OPS), + ); + expect( + missing, + "claimed carried, but nothing writes it back — the export is a file the " + + "restore reads past, which is the worst of both: the data is in the " + + "backup and not in the account", + ).toEqual([]); + }); + + it("gives every pending model a reason that says what is lost", () => { + const thin = Object.entries(COVERAGE_PENDING) + .filter(([, reason]) => reason.trim().length < 60) + .map(([model]) => model); + expect( + thin, + "'not yet' is not a reason — say what an account loses, so the debt can " + + "be ordered by cost to a person rather than by ease of writing", + ).toEqual([]); + }); + + it("proves the two named this release are on the covered side", () => { + // Prerequisites for the structured-profile work: both were classified + // BACKED_UP and carried by nothing, and the custom metric was live loss. + for (const model of [ + "UserHealthProfile", + "CustomMetric", + "CustomMetricEntry", + ]) { + expect(TWO_ENDED_MODELS).toContain(model); + expect(COVERAGE_PENDING).not.toHaveProperty(model); + } + }); +}); diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts new file mode 100644 index 000000000..c8de28d7c --- /dev/null +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -0,0 +1,369 @@ +/** + * Export → wire schema → restore, driven end to end. + * + * Every other test around the backup proves ONE end. `full-backup-payload.test.ts` + * proves the builder emits a key; the restore tests prove the route refuses a + * bad file. Between them sits the gap that lost three domains in a row: a key + * the builder emits, the schema drops or nobody reads, and the restore reports + * success over. + * + * So this test does not assert on the payload. It builds a real payload from a + * populated account, serialises it, parses it back through the REAL + * `parseBackupPayload`, hands it to the REAL route, and asserts on the rows the + * restore tried to write. A field that survives that survives a restore. + * + * The transaction stand-in is deliberately not inert: `cycleSymptom.findMany` + * answers from what the restore actually upserted. If the account's own symptom + * definitions do not ride the file, the link resolution finds nothing and the + * restore throws — which is what a real instance does, and is exactly how the + * inert v1.33.1 fix behaved in production. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const mocks = vi.hoisted(() => ({ + decrypt: vi.fn(), + transaction: vi.fn(), +})); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (fn: unknown) => fn, + HttpError: class extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + } + }, + requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + dataBackup: { findUnique: vi.fn() }, + user: { findUnique: vi.fn() }, + $transaction: mocks.transaction, + }, +})); + +// Only `decrypt` is stubbed — it stands in for reading the stored backup blob. +// The rest of the module stays real, so the ciphertext this test builds and the +// ciphertext the restore writes are produced by the same code an instance runs. +vi.mock("@/lib/crypto", async (importOriginal) => ({ + ...(await importOriginal()), + decrypt: mocks.decrypt, +})); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => null), +})); +vi.mock("@/lib/idempotency", () => ({ + withIdempotency: (fn: unknown) => fn, + defaultUserIdResolver: vi.fn(), +})); +vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserData: vi.fn() })); +vi.mock("@/lib/rollups/mood-rollups", () => ({ + recomputeUserMoodRollups: vi.fn(), +})); +vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ + recomputeUserMedicationCompliance: vi.fn(), + MEDICATION_COMPLIANCE_BACKFILL_DAYS: 30, +})); +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + recomputeUserRollups: vi.fn(), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; + +const OWNER = "user-A"; + +/* ── the account being backed up ────────────────────────────────────── */ + +function sourceClient() { + const empty = { findMany: vi.fn().mockResolvedValue([]) }; + return { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + measurement: { + findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + }, + medication: { findMany: vi.fn().mockResolvedValue([]) }, + medicationIntakeEvent: empty, + moodEntry: empty, + moodTag: empty, + nutrientIntakeDay: empty, + // Cycle: one day-log whose symptom is one the ACCOUNT defined, which is + // the case the payload assembly used to drop. + cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, + menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, + cycleDayLog: { + findMany: vi.fn().mockResolvedValue([ + { + id: "day-1", + date: "2026-07-19", + cycleId: null, + flow: null, + intermenstrualBleeding: false, + basalBodyTempC: null, + temperatureExcluded: false, + ovulationTest: null, + cervicalMucus: null, + cervixPosition: null, + cervixFirmness: null, + cervixOpening: null, + sexualActivity: false, + protectedSex: null, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + sensitiveEncrypted: null, + notesEncrypted: null, + source: "MANUAL", + externalId: null, + tz: null, + syncVersion: 0, + deletedAt: null, + createdAt: new Date("2026-07-19T00:00:00.000Z"), + updatedAt: new Date("2026-07-19T00:00:00.000Z"), + symptomLinks: [{ symptom: { key: "custom:jaw_tension" } }], + }, + ]), + }, + cycleSymptom: { + findMany: vi.fn().mockResolvedValue([ + { + id: "sym-1", + key: "custom:jaw_tension", + labelKey: "custom", + categoryId: "cat-1", + icon: null, + sortOrder: 0, + isActive: true, + labelEncrypted: "envelope", + }, + ]), + }, + // Records section: empty, exercised by its own suite. + labResult: empty, + biomarker: empty, + illnessEpisode: empty, + allergy: empty, + familyHistoryEntry: empty, + workout: empty, + inboundDocument: empty, + // The two domains this stream adds. + userHealthProfile: { + findUnique: vi.fn().mockResolvedValue({ + id: "profile-1", + userId: OWNER, + aboutMeEncrypted: encryptToBytes("desk job, two kids"), + conditionsEncrypted: null, + allergiesEncrypted: encryptToBytes("penicillin"), + coachFocusEncrypted: null, + pendingQuestionsEncrypted: encryptToBytes('["How is your sleep?"]'), + createdAt: new Date("2026-07-01T00:00:00.000Z"), + updatedAt: new Date("2026-07-19T00:00:00.000Z"), + }), + }, + customMetric: { + findMany: vi.fn().mockResolvedValue([ + { + id: "metric-1", + userId: OWNER, + name: "Morning grip strength", + unit: "kg", + targetLow: 40, + targetHigh: null, + decimals: 1, + description: "Right hand, before breakfast", + createdAt: new Date("2026-07-01T00:00:00.000Z"), + updatedAt: new Date("2026-07-19T00:00:00.000Z"), + deletedAt: null, + entries: [ + { + id: "entry-1", + value: 44.5, + unit: "kg", + measuredAt: new Date("2026-07-19T06:30:00.000Z"), + note: "felt strong", + createdAt: new Date("2026-07-19T06:31:00.000Z"), + }, + ], + }, + ]), + }, + }; +} + +/* ── the instance being restored into ───────────────────────────────── */ + +interface Written { + model: string; + op: string; + data: Record; +} + +function recordingTx(written: Written[]) { + // Symptom definitions the restore has written back, so link resolution can + // only succeed if the definitions actually rode the file. + const restoredSymptoms = new Map(); + + const delegate = (model: string) => + new Proxy( + {}, + { + get(_t, op: string) { + return async (args: Record = {}) => { + if (op === "deleteMany") return { count: 0 }; + if (op === "findMany") { + if (model === "cycleSymptom") { + return [...restoredSymptoms].map(([key, id]) => ({ id, key })); + } + return []; + } + if (op === "findFirst" || op === "findUnique") return null; + const data = (args.data ?? args.create ?? {}) as Record< + string, + unknown + >; + if (op === "create" || op === "upsert" || op === "createMany") { + if (model === "cycleSymptom" && typeof data.key === "string") { + restoredSymptoms.set(data.key, (data.id as string) ?? "new-id"); + } + written.push({ model, op, data }); + } + return { id: (data.id as string) ?? `${model}-created` }; + }; + }, + }, + ); + + return new Proxy({} as Record, { + get: (_t, model: string) => delegate(model), + }); +} + +function request(): NextRequest { + return new NextRequest("http://localhost/api/admin/backups/b-1/restore", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }); +} + +/** Build a real backup for the account above, then restore it. */ +async function roundTrip(): Promise<{ res: Response; written: Written[] }> { + const { payload } = await buildFullBackupPayload( + sourceClient() as never, + OWNER, + { purpose: "disaster-recovery" }, + ); + + // The file as it lands on disk: JSON, nothing else. + mocks.decrypt.mockReturnValue(JSON.stringify(payload)); + + const written: Written[] = []; + mocks.transaction.mockImplementation( + async (fn: (tx: unknown) => Promise) => fn(recordingTx(written)), + ); + + const res = await ( + POST as unknown as (r: NextRequest, c: unknown) => Promise + )(request(), { params: Promise.resolve({ id: "b-1" }) }); + + return { res, written }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.dataBackup.findUnique).mockResolvedValue({ + id: "b-1", + userId: OWNER, + data: "cipher", + user: { id: OWNER, username: "owner" }, + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: OWNER, + username: "owner", + timezone: "UTC", + } as never); +}); + +describe("backup round trip — export, wire schema, restore", () => { + it("completes without the restore reporting a failure", async () => { + const { res } = await roundTrip(); + expect(res.status).toBe(200); + }); + + it("writes the durable self-context back with its ciphertext intact", async () => { + const { written } = await roundTrip(); + + const profile = written.find((w) => w.model === "userHealthProfile"); + expect( + profile, + "the profile reached the file and nothing wrote it back", + ).toBeDefined(); + expect(profile!.data.userId).toBe(OWNER); + // The envelope round-trips as bytes, not as a re-encryption of plaintext + // the DR file never carried. + expect(profile!.data.aboutMeEncrypted).toBeInstanceOf(Uint8Array); + expect(profile!.data.conditionsEncrypted).toBeNull(); + expect(profile!.data.pendingQuestionsEncrypted).toBeInstanceOf(Uint8Array); + }); + + it("writes each custom metric back with its readings", async () => { + const { written } = await roundTrip(); + + const metric = written.find((w) => w.model === "customMetric"); + expect( + metric, + "the metric reached the file and nothing wrote it back", + ).toBeDefined(); + expect(metric!.data).toMatchObject({ + userId: OWNER, + name: "Morning grip strength", + unit: "kg", + targetLow: 40, + decimals: 1, + description: "Right hand, before breakfast", + }); + + // The readings are the half that would go missing quietly: a metric with + // no entries renders as an empty chart rather than as an error. + const entries = ( + metric!.data.entries as { create: Array> } + ).create; + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + value: 44.5, + unit: "kg", + note: "felt strong", + }); + }); + + it("writes the account's own cycle symptom back before resolving its links", async () => { + const { written, res } = await roundTrip(); + + // The definition has to land, or the link lookup below finds nothing. + expect( + written.find( + (w) => + w.model === "cycleSymptom" && w.data.key === "custom:jaw_tension", + ), + "the custom symptom definition never reached the restore", + ).toBeDefined(); + + // And the day-log that references it restores rather than throwing on an + // unresolvable key — the failure a real account hit while the fix was inert. + expect(res.status).toBe(200); + expect(written.find((w) => w.model === "cycleDayLog")).toBeDefined(); + }); +}); diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 8d1ab75c5..5381a321f 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -38,6 +38,7 @@ import { } from "@/lib/rollups/medication-compliance-rollups"; import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; import { restoreCycleData } from "@/lib/cycle/backup"; +import { restoreProfileData } from "@/lib/export/profile-backup"; import { invalidateUserData } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -64,6 +65,8 @@ interface RestoreResponse { familyHistory: number; workouts: number; documents: number; + healthProfile: number; + customMetrics: number; }; } @@ -562,6 +565,32 @@ const handler = apiHandler( } if (payload.intakeEvents.length > 0) { + // An event whose medication did not come back used to be mapped to + // null and filtered away: the dose history came back short and the + // restore reported success. That is the same silent-drop shape the + // cycle symptom links had, and it gets the same answer — name the + // unresolved reference and stop, so the operator learns from the + // error rather than from a compliance rate that quietly moved. + const unresolvedMedications = [ + ...new Set( + payload.intakeEvents + .filter( + (e) => + !( + e.medicationId && + restoredMedicationIds.has(e.medicationId) + ) && !medByName.has(e.medication), + ) + .map((e) => e.medicationId ?? e.medication), + ), + ]; + if (unresolvedMedications.length > 0) { + throw new Error( + `Unknown medication references in intake events: ${unresolvedMedications.join( + ", ", + )}. The backup records doses against medications it does not carry.`, + ); + } const rows = payload.intakeEvents .map((e) => { const medId = @@ -718,6 +747,12 @@ const handler = apiHandler( // above. `notesEncrypted` is restored as ciphertext verbatim. const cycleCleared = await restoreCycleData(tx, ownerId, payload); + // Durable self-context + user-defined metrics. Both ends of this pair + // live in `src/lib/export/profile-backup.ts` beside its builder — a + // grep of THIS file alone will not find them, exactly as it does not + // find the cycle restore one line above. + const profileCleared = await restoreProfileData(tx, ownerId, payload); + const biomarkerByName = new Map(); const restoredBiomarkerIds = new Set(); for (const biomarker of payload.biomarkers) { @@ -1073,6 +1108,8 @@ const handler = apiHandler( familyHistory: familyHistory.count, workouts: workouts.count, documents: documents.count, + healthProfile: profileCleared.healthProfile, + customMetrics: profileCleared.customMetrics, }; }, { @@ -1197,6 +1234,9 @@ const handler = apiHandler( familyHistory: summary.familyHistory, workouts: summary.workouts, documents: summary.documents, + healthProfile: summary.healthProfile, + customMetrics: summary.customMetrics, + customMetricEntries: summary.customMetricEntries, }, }, }); 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 70e0f0fd6..3d6b74e02 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -18,6 +18,10 @@ vi.mock("@/lib/db", () => ({ moodEntry: { findMany: vi.fn() }, moodTag: { findMany: vi.fn() }, nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) }, + // Durable self-context + user-defined metrics, read by + // `buildProfileBackupSection`. + userHealthProfile: { findUnique: vi.fn().mockResolvedValue(null) }, + customMetric: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, @@ -73,9 +77,13 @@ function mkReq(url: string): NextRequest { beforeEach(() => { vi.resetAllMocks(); - // `resetAllMocks` clears the inline default, and the full-backup payload - // builder reads this unconditionally. + // `resetAllMocks` clears the inline defaults, and the full-backup payload + // builder reads these unconditionally. vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never); }); afterEach(() => { diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 9f7e39fca..1d3bea4ba 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -16,6 +16,10 @@ vi.mock("@/lib/db", () => ({ moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, moodTag: { findMany: vi.fn().mockResolvedValue([]) }, nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) }, + // Durable self-context + user-defined metrics, read by + // `buildProfileBackupSection`. + userHealthProfile: { findUnique: vi.fn().mockResolvedValue(null) }, + customMetric: { findMany: vi.fn().mockResolvedValue([]) }, cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/app/api/export/encrypted/route.ts b/src/app/api/export/encrypted/route.ts index 1bdb223d9..cf13f5eb0 100644 --- a/src/app/api/export/encrypted/route.ts +++ b/src/app/api/export/encrypted/route.ts @@ -117,6 +117,9 @@ export const POST = apiHandler(async (request: NextRequest) => { export_workout_count: counts.workouts, export_document_count: counts.documents, export_nutrient_day_count: counts.nutrientDays, + export_health_profile_count: counts.healthProfile, + export_custom_metric_count: counts.customMetrics, + export_custom_metric_entry_count: counts.customMetricEntries, 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 3589387c4..5c77a14ed 100644 --- a/src/app/api/export/full-backup/route.ts +++ b/src/app/api/export/full-backup/route.ts @@ -76,6 +76,9 @@ export const GET = apiHandler(async (request: NextRequest) => { export_workout_count: counts.workouts, export_document_count: counts.documents, export_nutrient_day_count: counts.nutrientDays, + export_health_profile_count: counts.healthProfile, + export_custom_metric_count: counts.customMetrics, + export_custom_metric_entry_count: counts.customMetricEntries, }, }); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 8fc8beb1d..1c8852914 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -239,6 +239,13 @@ export function BackupsSection() { familyHistory?: number; workouts?: number; documents?: number; + // Optional for the same reason: a file written before each of these + // rode the wire carries no key, and an absent key must total as + // nothing rather than break the count. + nutrientDays?: number; + healthProfile?: number; + customMetrics?: number; + customMetricEntries?: number; }; }>("/api/admin/backups/upload", { method: "POST", body: fd }); }, @@ -257,7 +264,15 @@ export function BackupsSection() { (data.summary.allergies ?? 0) + (data.summary.familyHistory ?? 0) + (data.summary.workouts ?? 0) + - (data.summary.documents ?? 0); + (data.summary.documents ?? 0) + + // The count the admin is shown has to be the count the file carries. + // The nutrient day totals were added to the payload and never to this + // sum, so an upload that restored them reported fewer records than it + // held — a smaller version of the same defect, on the reporting side. + (data.summary.nutrientDays ?? 0) + + (data.summary.healthProfile ?? 0) + + (data.summary.customMetrics ?? 0) + + (data.summary.customMetricEntries ?? 0); toast.success( t("admin.section.backups.uploadSuccess", { count: String(total) }), ); diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index 691a65875..ce511a02c 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -108,6 +108,16 @@ export interface CycleBackupSection { icon: string | null; sortOrder: number; isActive: boolean; + /** + * The user's own words for the symptom, carried as ciphertext verbatim. + * + * The builder has always emitted it and this interface did not declare it, + * which held while the payload was assembled key by key and stopped holding + * the moment the section is spread onto the wire: an undeclared field is a + * field the wire type says is not there. Declared, so the type describes + * the file. + */ + labelEncrypted: string | null; }>; } diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index b699ff685..d5d1e7a07 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -187,6 +187,49 @@ function makePrisma() { }, ]), }, + // Left unmocked on purpose: `buildProfileBackupSection` runs for real + // against these, so the assertions below exercise the builder rather than + // a stand-in that would agree with whatever the payload happened to do. + userHealthProfile: { + findUnique: vi.fn().mockResolvedValue({ + id: "profile-1", + userId: "user-1", + aboutMeEncrypted: encryptToBytes("desk job, two kids"), + conditionsEncrypted: null, + allergiesEncrypted: encryptToBytes("penicillin"), + coachFocusEncrypted: null, + pendingQuestionsEncrypted: encryptToBytes('["How is your sleep?"]'), + createdAt: new Date("2026-07-01T00:00:00.000Z"), + updatedAt: new Date("2026-07-19T00:00:00.000Z"), + }), + }, + customMetric: { + findMany: vi.fn().mockResolvedValue([ + { + id: "metric-1", + userId: "user-1", + name: "Morning grip strength", + unit: "kg", + targetLow: 40, + targetHigh: null, + decimals: 1, + description: "Right hand, before breakfast", + createdAt: new Date("2026-07-01T00:00:00.000Z"), + updatedAt: new Date("2026-07-19T00:00:00.000Z"), + deletedAt: null, + entries: [ + { + id: "entry-1", + value: 44.5, + unit: "kg", + measuredAt: new Date("2026-07-19T06:30:00.000Z"), + note: "felt strong", + createdAt: new Date("2026-07-19T06:31:00.000Z"), + }, + ], + }, + ]), + }, }; } @@ -229,6 +272,19 @@ function installSectionMocks() { cycleProfile: null, cycles: [], cycleDayLogs: [], + // The key the payload assembly used to drop on the floor. + customSymptoms: [ + { + id: "symptom-1", + key: "custom:jaw_tension", + labelKey: "custom", + categoryId: "cat-1", + icon: null, + sortOrder: 0, + isActive: true, + labelEncrypted: "Y2lwaGVy", + }, + ], }); mocks.buildRecordsBackupSection.mockResolvedValue({ labResults: [], @@ -443,3 +499,180 @@ describe("buildFullBackupPayload — aggregation provenance", () => { expect(rows[0]).not.toHaveProperty("externalSourceVersion"); }); }); + +/** + * The assembly step must carry EVERY key its sections produce. + * + * The custom cycle symptoms were lost here and nowhere else: the section + * builder returned them, the wire schema declared them, the restore read them, + * and the payload was assembled by copying keys out one at a time. Nothing in + * the tree compared the two lists, so the omission survived a release that was + * specifically about that data. + * + * Mutation check: hand-pick the cycle keys again instead of spreading the + * section, and this goes red naming the key that fell out. + */ +describe("buildFullBackupPayload — section keys reach the payload", () => { + it("carries every key of every section it builds", async () => { + installSectionMocks(); + const prisma = makePrisma(); + + const { payload } = await buildFullBackupPayload( + prisma as never, + "user-1", + { + purpose: "disaster-recovery", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }, + ); + + const sectionKeys = [ + ...Object.keys(await mocks.buildCycleBackupSection.mock.results[0].value), + ...Object.keys( + await mocks.buildRecordsBackupSection.mock.results[0].value, + ), + // The profile section runs for real, so its keys come from the builder. + "healthProfile", + "customMetrics", + ]; + + const missing = sectionKeys.filter((key) => !(key in payload)); + expect( + missing, + "a section key that never reaches the payload is data the file does not carry, however complete the builder is", + ).toEqual([]); + }); + + it("carries the account's own cycle symptoms", async () => { + installSectionMocks(); + const prisma = makePrisma(); + + const { payload } = await buildFullBackupPayload( + prisma as never, + "user-1", + { + purpose: "disaster-recovery", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }, + ); + + expect(payload.customSymptoms).toEqual([ + expect.objectContaining({ + key: "custom:jaw_tension", + labelEncrypted: "Y2lwaGVy", + }), + ]); + }); +}); + +/** + * Durable self-context and user-defined metrics ride the file. + * + * Both were classified `BACKED_UP` and carried by nothing. The custom metric is + * the live loss: a series the account defined itself is the one kind no + * integration can re-sync, so a restore without it rebuilt the account minus + * the metric AND every reading, and reported success. + */ +describe("buildFullBackupPayload — profile and custom metrics", () => { + it("carries ciphertext verbatim and no plaintext in the recovery payload", async () => { + installSectionMocks(); + const prisma = makePrisma(); + + const { payload, counts } = await buildFullBackupPayload( + prisma as never, + "user-1", + { + purpose: "disaster-recovery", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }, + ); + + const profile = payload.healthProfile as Record; + expect(profile.id).toBe("profile-1"); + // The envelope travels; the plaintext of a column carried encrypted must + // not be duplicated beside it. + expect(typeof profile.aboutMeEncrypted).toBe("string"); + expect(profile.aboutMe).toBeNull(); + expect(profile.conditionsEncrypted).toBeNull(); + expect(typeof profile.pendingQuestionsEncrypted).toBe("string"); + expect(counts.healthProfile).toBe(1); + }); + + it("decrypts the profile free text into the portable export", async () => { + installSectionMocks(); + const prisma = makePrisma(); + + const { payload } = await buildFullBackupPayload( + prisma as never, + "user-1", + { + purpose: "portable-export", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }, + ); + + const profile = payload.healthProfile as Record; + expect(profile.aboutMe).toBe("desk job, two kids"); + expect(profile.allergies).toBe("penicillin"); + expect(profile.conditions).toBeNull(); + // A portable file is the human-readable artefact; it carries no envelopes, + // and it does not carry the server-derived pending questions at all. + expect(profile).not.toHaveProperty("aboutMeEncrypted"); + expect(profile).not.toHaveProperty("pendingQuestionsEncrypted"); + expect(profile).not.toHaveProperty("id"); + }); + + it("carries each metric with its readings nested inside it", async () => { + installSectionMocks(); + const prisma = makePrisma(); + + const { payload, counts } = await buildFullBackupPayload( + prisma as never, + "user-1", + { + purpose: "disaster-recovery", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }, + ); + + expect(payload.customMetrics).toEqual([ + expect.objectContaining({ + id: "metric-1", + name: "Morning grip strength", + unit: "kg", + targetLow: 40, + targetHigh: null, + decimals: 1, + description: "Right hand, before breakfast", + deletedAt: null, + entries: [ + expect.objectContaining({ + id: "entry-1", + value: 44.5, + unit: "kg", + measuredAt: "2026-07-19T06:30:00.000Z", + note: "felt strong", + }), + ], + }), + ]); + expect(counts.customMetrics).toBe(1); + expect(counts.customMetricEntries).toBe(1); + }); + + it("excludes deleted metrics from a portable export", async () => { + installSectionMocks(); + const prisma = makePrisma(); + + await buildFullBackupPayload(prisma as never, "user-1", { + purpose: "portable-export", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }); + + expect(prisma.customMetric.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1", deletedAt: null }, + }), + ); + }); +}); diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index e65a4b572..88deb138e 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -21,21 +21,18 @@ * * `BACKED_UP` — the account would lose something real if this were missing. * - * **Read this as "must be", not "is".** Today the guard beside - * this file checks only that every model HAS a verdict. The - * two-ended check — that each `BACKED_UP` model has both a - * payload reader and a restore branch — does not exist yet, - * and roughly 31 of the models listed here have neither end. - * Three were closed in v1.33.1 (custom cycle symptoms, - * nutrient day totals, custom mood tags); the rest are named - * here so the gap is written down instead of invisible, which - * is the only honest thing this list can do before the guard - * exists. + * Split in two below, because "must be carried" and "is + * carried" are different claims and this file used to make + * only the first. {@link TWO_ENDED_MODELS} names the models + * with BOTH a payload reader and a restore branch, checked by + * the guard beside this file. {@link COVERAGE_PENDING} names + * the rest, each with what an account loses meanwhile. * * An earlier draft of this comment claimed the two-ended guard - * in the present tense. It did not exist then either. That is - * the same defect this file was written to prevent, so it is - * recorded rather than quietly corrected. + * in the present tense. It did not exist then. That is the same + * defect this file was written to prevent, so it is recorded + * rather than quietly corrected — and it is why the guard now + * reads the source rather than trusting a list. * `DERIVED` — recomputable from `BACKED_UP` rows. Leaving it out keeps the * file smaller and cannot lose anything; the reason names what * recomputes it. @@ -130,6 +127,148 @@ export const BACKED_UP_MODELS = [ "ConsentReceipt", ] as const; +/** + * The files that carry a backed-up model OUT of the database, and the files + * that put one back. + * + * Declared rather than discovered, because discovery is what keeps going + * wrong here. A previous review grepped the restore ROUTE, concluded the cycle + * data was never restored, and had to retract it publicly: the route delegates + * to `restoreCycleData`, and a grep of the route alone lies. The profile and + * custom-metric pair added later delegates the same way, on purpose. + * + * Keeping both lists here means a new section has to say where its two ends + * live, and the guard reads those files instead of guessing which ones matter. + */ +export const BACKUP_WRITER_FILES: readonly string[] = [ + "src/lib/export/full-backup-payload.ts", + // Measurements are read through this pager, not directly. The guard caught + // its absence from this list on the first run — the same "the caller + // delegates" mistake the list exists to prevent, made while writing the list. + "src/lib/export/paged-measurements.ts", + "src/lib/export/records-backup.ts", + "src/lib/export/profile-backup.ts", + "src/lib/cycle/backup.ts", +]; + +export const BACKUP_RESTORE_FILES: readonly string[] = [ + "src/app/api/admin/backups/[id]/restore/route.ts", + "src/lib/export/profile-backup.ts", + "src/lib/cycle/backup.ts", +]; + +/** + * Backed-up models that genuinely travel BOTH ways. + * + * The guard checks each of these against the files above: a read in a writer + * file and a write in a restore file, counting a relation carried inside its + * parent (`include: { schedules: true }` out, `schedules: { create: … }` back) + * as covered, because that is how the child rows actually ride. + * + * A model moves onto this list when its two ends land, never before. That is + * the whole point of the split — a verdict of `BACKED_UP` is an intention, and + * an intention is what the nutrient day totals had while a restore was throwing + * them away and reporting success. + */ +export const TWO_ENDED_MODELS: readonly string[] = [ + "Measurement", + "Medication", + "MedicationSchedule", + "MedicationIntakeEvent", + "MoodEntry", + "MoodEntryTagLink", + "MoodTag", + "LabResult", + "Biomarker", + "Allergy", + "FamilyHistoryEntry", + "IllnessEpisode", + "IllnessDayLog", + "IllnessSymptomLink", + "UserHealthProfile", + "CycleProfile", + "MenstrualCycle", + "CycleDayLog", + "CycleSymptom", + "CycleSymptomLink", + "CustomMetric", + "CustomMetricEntry", + "Workout", + "NutrientIntakeDay", + "InboundDocument", +]; + +/** + * Backed-up models that do NOT travel yet, each with what an account loses + * until it does. + * + * This is a debt register, not a design. Every line is a restore that comes + * back short without saying so, which is the failure mode the whole file + * exists to make visible. The reasons say what goes missing rather than + * "not yet", so the next person can order the work by what it costs a person + * rather than by what is cheapest to write. + */ +export const COVERAGE_PENDING: Readonly> = { + MedicationScheduleRevision: + "The history of how a schedule changed. Without it a restored medication keeps today's schedule and loses the record of when the dose or timing moved, which is the part a doctor asks about.", + MedicationDoseChange: + "Titration history — when a dose went up or down and by how much. A restore rebuilds the current dose and drops the ramp that led to it.", + MedicationPauseEra: + "The spans where a medication was deliberately paused. Without them the compliance recomputation counts a deliberate pause as missed doses and the restored account looks non-adherent.", + MedicationSideEffect: + "Side effects the person recorded against a drug. Restoring the drug without them loses the reason someone may have stopped taking it.", + MedicationEfficacyTarget: + "What a medication was supposed to move, and by how much. The drug comes back with no statement of what it was for.", + MedicationInventoryItem: + "Pack sizes and remaining stock. A restored account reports no supply and every low-stock reminder has to be reconstructed by hand.", + MedicationInventoryEvent: + "The ledger of refills and consumption behind the stock count. Without it the count above cannot be rebuilt even in principle.", + MoodTagCategory: + "Categories a person created to group their own mood factors. Custom tags restore into a grouping that no longer exists.", + MoodTagHidden: + "Which seeded mood tags the account chose to hide. A restore un-hides every one, so the mood editor comes back cluttered with tags the person removed on purpose.", + MentalHealthAssessment: + "Completed WHO-5, PHQ and GAD instruments with their scores and dates. Clinically meaningful history that no integration can re-sync, and the single largest gap on this list.", + EcgRecording: + "ECG traces and their rhythm classification. The originating device may still hold them, but a self-hoster who exported and wiped has nothing to re-sync from.", + MeasurementReminder: + "Per-metric reminder cadences the person configured. A restore leaves the account silent until each one is set up again.", + WorkoutRoute: + "GPS traces. Deliberately absent from the payload today and DISCLOSED as absent in the file's own manifest, which is why this is a documented exclusion rather than a silent one — but it is still a loss for a self-hoster with no other copy.", + WorkoutSamples: + "Per-sample heart-rate and pace series behind a workout summary. Same disclosed-exclusion status as the routes above, same cost.", + PersonalRecord: + "Bests the account accumulated. Recomputable in principle from measurements and workouts, but nothing recomputes them today, so in practice they are lost.", + UserAchievement: + "Milestones the account earned, with the date each was reached. The date is the part that cannot be recovered.", + EnvironmentContext: + "Per-day environmental readings joined to the record. Re-fetchable for recent days only; older history is gone once the provider window closes.", + EnvironmentTravelLocation: + "Where the person was on a given day, which is what makes the environmental readings mean anything. Never re-derivable.", + DocumentConditionLink: + "Which documents were filed against which condition. Documents and conditions both restore; the filing between them does not, so a restored vault is unsorted.", + ExtractedFact: + "Facts read out of a document by the AI pass, with their provenance back to the page. Re-derivable only by re-running the extraction against a provider, at the operator's cost.", + CoachConversation: + "The thread structure of past coaching conversations — which messages belong together and what each exchange was about. Without it the messages below, if they were ever carried, would restore as one undifferentiated pile.", + CoachMessage: + "The messages themselves, encrypted at rest. The largest volume of free text an account owns after its documents.", + CoachConversationDocument: + "Which documents a conversation was grounded in — the provenance that makes an old answer checkable.", + CoachFact: + "Durable facts the Coach was told to remember. A restore makes the account re-tell its history.", + CoachPlan: + "An agreed plan and its progress. Restoring the conversation without the plan keeps the talk and loses the commitment.", + CoachReminder: + "Reminders the Coach agreed to send. A restored account silently stops sending them.", + NotificationPreference: + "Per-event channel and quiet-hours choices. A restore reverts to defaults, so an account that had deliberately silenced a category starts being notified again.", + ReminderPhaseConfig: + "Custom reminder phase timings. Same shape of loss as the preferences above.", + ConsentReceipt: + "The record of what the account agreed to and when. Arguably belongs with the instance rather than the account, and that question has to be settled before this one can be carried either way.", +}; + /** * Recomputable from backed-up rows. The reason names what rebuilds it, so a * future reader can check the claim rather than trust it. diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 20a5af75c..dcb6db3c7 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -15,14 +15,25 @@ import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { readNote } from "@/lib/crypto/note-cipher"; import { iterateMeasurementPages } from "@/lib/export/paged-measurements"; import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup"; -import { buildCycleBackupSection } from "@/lib/cycle/backup"; +import { + buildCycleBackupSection, + type CycleBackupSection, +} from "@/lib/cycle/backup"; import { buildRecordsBackupSection, countRecordsBackupSection, type RecordsBackupCounts, + type RecordsBackupSection, } from "@/lib/export/records-backup"; +import { + buildProfileBackupSection, + countProfileBackupSection, + type ProfileBackupCounts, + type ProfileBackupSection, +} from "@/lib/export/profile-backup"; -export interface FullBackupCounts extends RecordsBackupCounts { +export interface FullBackupCounts + extends RecordsBackupCounts, ProfileBackupCounts { measurements: number; medications: number; intakeEvents: number; @@ -95,6 +106,7 @@ export async function buildFullBackupPayload( customMoodTags, cycle, records, + profile, nutrientDays, ] = await Promise.all([ disasterRecovery @@ -191,6 +203,13 @@ export async function buildFullBackupPayload( buildRecordsBackupSection(prisma, userId, { purpose: disasterRecovery ? "disaster-recovery" : "portable-export", }), + // Durable self-context + user-defined metrics. Both were classified as + // carried when the backup plan was written and carried by nothing; the + // metrics are the live loss, since a series the person defined themselves + // is the one kind no integration can re-sync. + buildProfileBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), // Nutrient day totals were absent from every export path, which // contradicted the schema's own reason for denormalising the unit column // ("rows stay self-describing in exports even if the catalog ever drifts"). @@ -210,6 +229,12 @@ export async function buildFullBackupPayload( }), ]); + // Pinned to the section interfaces so the spreads below cannot quietly widen + // (or narrow) what reaches the wire. + const cycleSection: CycleBackupSection = cycle; + const recordsSection: RecordsBackupSection = records; + const profileSection: ProfileBackupSection = profile; + const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, exportedAt: (options.exportedAt ?? new Date()).toISOString(), @@ -335,20 +360,25 @@ export async function buildFullBackupPayload( scaleMax: tag.scaleMax, inverse: tag.inverse, })), - 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, + // Both sections are SPREAD, not hand-picked key by key. + // + // Hand-picking is what lost the custom cycle symptoms. `buildCycleBackupSection` + // returned them, the wire schema declared them, `restoreCycleData` read them + // and — since v1.33.1 — THREW on a key it could not resolve. The list of keys + // copied out here was simply never extended, so every backup written carried + // `customSymptoms: []`. An account with one custom symptom did not get the + // silent drop that release removed; it got a restore that failed outright on + // a key nothing had written back, and the fix was inert in production. + // + // Spreading makes the section interface the single declaration of what rides + // the wire: a field added to `CycleBackupSection` / `RecordsBackupSection` + // reaches the payload by construction rather than by someone remembering. + // `manifest` (from the records section) discloses the two deliberate + // exclusions — document binaries, workout GPS/sample series — in the file + // itself, not just in the export UI copy. + ...cycleSection, + ...recordsSection, + ...profileSection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, @@ -370,6 +400,7 @@ export async function buildFullBackupPayload( cycleDayLogs: cycle.cycleDayLogs.length, nutrientDays: nutrientDays.length, ...countRecordsBackupSection(records), + ...countProfileBackupSection(profile), }, }; } diff --git a/src/lib/export/profile-backup.ts b/src/lib/export/profile-backup.ts new file mode 100644 index 000000000..b0281b7f9 --- /dev/null +++ b/src/lib/export/profile-backup.ts @@ -0,0 +1,433 @@ +/** + * Durable self-context and user-defined metrics — both ends in one file. + * + * Two models classified `BACKED_UP` since `backup-plan.ts` was written and + * carried by nothing: `UserHealthProfile` (what a person told the app is + * durably true of them) and `CustomMetric` / `CustomMetricEntry` (a series the + * person defined themselves, which by definition no integration can re-sync). + * The custom-metric gap is live data loss today: a restore rebuilt the account + * without the metric or a single one of its readings and reported success. + * + * The builder and the restore live together deliberately, the way + * `src/lib/cycle/backup.ts` does. A reader asking "is this carried at both + * ends?" answers it in one file, and — the reason it matters — a reader who + * greps only the restore ROUTE gets a false negative, because the route + * delegates. That mistake has already been made once about the cycle data and + * had to be retracted; keeping the pair adjacent is what makes the answer + * cheap enough to get right. + * + * Encrypted columns follow the established split. A portable export is the + * human-readable artefact, so the profile's free text is decrypted into it. A + * disaster-recovery payload carries the AES-256-GCM envelope verbatim as + * base64 and never decrypts, so the same instance's key reads it back + * unchanged and no plaintext is duplicated into the file. + */ +import { Buffer } from "node:buffer"; + +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { getEvent } from "@/lib/logging/context"; + +export interface ProfileBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + +/** + * The account's durable self-context. One row per user, so there is no + * reference to resolve on the way back in — the owner is the key. + */ +export interface HealthProfileBackupEntry { + /** Present in canonical DR payloads so the row keeps a stable identity. */ + id?: string; + /** + * Portable exports carry the decrypted text; DR payloads carry `null` here + * and the ciphertext below. Never both — a DR file must not duplicate the + * plaintext of a column it is already carrying encrypted. + */ + aboutMe: string | null; + conditions: string | null; + allergies: string | null; + coachFocus: string | null; + /** Base64 of the already-encrypted BYTEA value. DR payloads only. */ + aboutMeEncrypted?: string | null; + conditionsEncrypted?: string | null; + allergiesEncrypted?: string | null; + coachFocusEncrypted?: string | null; + /** + * Server-derived clarifying questions awaiting an answer, encrypted JSON. + * + * DR only, and deliberately: they are not the person's own words, they are + * regenerated after the next profile save, and a portable export that is + * meant to be readable gains nothing from an opaque envelope. Carried in the + * canonical payload because a DR restore should land the account in the + * state it was in, pending prompts included. + */ + pendingQuestionsEncrypted?: string | null; + createdAt?: string; + updatedAt?: string; +} + +/** One reading of a user-defined metric. */ +export interface CustomMetricEntryBackupEntry { + /** Present in canonical DR payloads so the row keeps a stable identity. */ + id?: string; + value: number; + /** Unit snapshot taken from the metric at write time, not a live join. */ + unit: string; + measuredAt: string; + note: string | null; + createdAt?: string; +} + +/** + * A metric the account defined, with its readings nested inside it. + * + * Nested rather than a flat array keyed by metric name, and that is a + * deliberate difference from the intake-event section next door. A flat array + * needs the restore to resolve every row's parent by a natural key, which is + * the exact shape that produces a lookup miss — and a lookup miss is a row + * that either vanishes quietly or stops the restore. Nesting removes the + * reference, so an entry cannot dangle and there is nothing to drop. + */ +export interface CustomMetricBackupEntry { + /** Present in canonical DR payloads so the row keeps a stable identity. */ + id?: string; + name: string; + unit: string; + targetLow: number | null; + targetHigh: number | null; + decimals: number | null; + description: string | null; + createdAt?: string; + updatedAt?: string; + /** Tombstone, DR payloads only — a portable export never carries deleted rows. */ + deletedAt?: string | null; + entries: CustomMetricEntryBackupEntry[]; +} + +export interface ProfileBackupSection { + healthProfile: HealthProfileBackupEntry | null; + customMetrics: CustomMetricBackupEntry[]; +} + +export interface ProfileBackupCounts { + healthProfile: number; + customMetrics: number; + customMetricEntries: number; +} + +/** + * Decrypt one profile field fail-soft. + * + * Fail-soft here and NOT on the restore side, which is the asymmetry that + * matters: a field this instance can no longer read is already lost, and + * refusing to build the rest of the backup over it would turn one unreadable + * column into no backup at all. The warning rides the wide event so the + * operator sees it. On the way back in, by contrast, an unresolvable + * reference throws — absence must read as absence, not as a quiet drop. + */ +function decryptProfileFieldSoft( + buf: Uint8Array | null, + field: string, +): string | null { + if (!buf || buf.byteLength === 0) return null; + try { + return decryptFromBytes(buf); + } catch (err) { + getEvent()?.addWarning( + `health profile ${field} decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return null; + } +} + +function toBase64(buf: Uint8Array | null): string | null { + return buf && buf.byteLength > 0 ? Buffer.from(buf).toString("base64") : null; +} + +/** + * Build the profile + custom-metric slice of a user's full backup. + * + * Takes the delegates it uses rather than a whole `PrismaClient` so the route's + * global client and the worker's local one share one read, matching + * `buildCycleBackupSection` and `buildRecordsBackupSection`. + */ +export async function buildProfileBackupSection( + prisma: Pick, + userId: string, + options: ProfileBackupOptions = {}, +): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; + + const [profileRow, metricRows] = await Promise.all([ + prisma.userHealthProfile.findUnique({ where: { userId } }), + prisma.customMetric.findMany({ + // A portable export never resurrects a tombstone; a DR payload restores + // the account exactly as it stood, deletions included. + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, + orderBy: { name: "asc" }, + include: { + // `CustomMetricEntry` carries no tombstone of its own — a deleted + // metric cascades its readings away — so both purposes read the same + // set and only the parent filter differs. + entries: { orderBy: { measuredAt: "asc" } }, + }, + }), + ]); + + const healthProfile: HealthProfileBackupEntry | null = profileRow + ? disasterRecovery + ? { + id: profileRow.id, + aboutMe: null, + conditions: null, + allergies: null, + coachFocus: null, + aboutMeEncrypted: toBase64(profileRow.aboutMeEncrypted), + conditionsEncrypted: toBase64(profileRow.conditionsEncrypted), + allergiesEncrypted: toBase64(profileRow.allergiesEncrypted), + coachFocusEncrypted: toBase64(profileRow.coachFocusEncrypted), + pendingQuestionsEncrypted: toBase64( + profileRow.pendingQuestionsEncrypted, + ), + createdAt: profileRow.createdAt.toISOString(), + updatedAt: profileRow.updatedAt.toISOString(), + } + : { + aboutMe: decryptProfileFieldSoft( + profileRow.aboutMeEncrypted, + "aboutMe", + ), + conditions: decryptProfileFieldSoft( + profileRow.conditionsEncrypted, + "conditions", + ), + allergies: decryptProfileFieldSoft( + profileRow.allergiesEncrypted, + "allergies", + ), + coachFocus: decryptProfileFieldSoft( + profileRow.coachFocusEncrypted, + "coachFocus", + ), + } + : null; + + const customMetrics: CustomMetricBackupEntry[] = metricRows.map((metric) => ({ + ...(disasterRecovery + ? { + id: metric.id, + createdAt: metric.createdAt.toISOString(), + updatedAt: metric.updatedAt.toISOString(), + deletedAt: metric.deletedAt?.toISOString() ?? null, + } + : {}), + name: metric.name, + unit: metric.unit, + targetLow: metric.targetLow, + targetHigh: metric.targetHigh, + decimals: metric.decimals, + description: metric.description, + entries: metric.entries.map((entry) => ({ + ...(disasterRecovery + ? { + id: entry.id, + createdAt: entry.createdAt.toISOString(), + } + : {}), + value: entry.value, + unit: entry.unit, + measuredAt: entry.measuredAt.toISOString(), + note: entry.note, + })), + })); + + return { healthProfile, customMetrics }; +} + +/** Row counts for the audit trail, mirroring the other section counters. */ +export function countProfileBackupSection( + section: ProfileBackupSection, +): ProfileBackupCounts { + return { + healthProfile: section.healthProfile ? 1 : 0, + customMetrics: section.customMetrics.length, + customMetricEntries: section.customMetrics.reduce( + (sum, metric) => sum + metric.entries.length, + 0, + ), + }; +} + +/** Counts the profile restore wiped, for the audit trail. */ +export interface ProfileRestoreCleared { + healthProfile: number; + customMetrics: number; +} + +/** + * The slice of a parsed backup this restore consumes. + * + * Both fields are REQUIRED rather than optional. `BackupPayload` defaults them, + * so every caller already satisfies this — and a caller that stops satisfying + * it fails to compile instead of passing `undefined` into a loop that iterates + * zero times and reports success. + */ +export interface ProfileRestoreInput { + healthProfile: HealthProfileBackupEntry | null; + customMetrics: CustomMetricBackupEntry[]; +} + +/** + * Decode a base64 ciphertext field, refusing a value that is not decodable + * rather than writing an envelope no key can ever open. + */ +function decodeEncryptedField( + encoded: string, + field: string, +): Uint8Array { + const decoded = Buffer.from(encoded, "base64"); + if (decoded.byteLength === 0) { + throw new Error( + `Health profile field '${field}' carries an unreadable ciphertext value. ` + + "Restoring it would write an envelope no key can open, which reads as " + + "a present-but-empty answer rather than as the missing one it is.", + ); + } + const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); + bytes.set(decoded); + return bytes; +} + +/** + * Resolve one encrypted profile column from the payload. + * + * Mirrors the lab-note contract: ciphertext wins when the DR payload carried + * it, plaintext is re-encrypted when a portable export carried that instead, + * and an absent column comes back absent. + */ +function resolveProfileColumn( + ciphertext: string | null | undefined, + plaintext: string | null, + field: string, +): Uint8Array | null { + if (ciphertext !== undefined) { + return ciphertext === null ? null : decodeEncryptedField(ciphertext, field); + } + return plaintext == null ? null : encryptToBytes(plaintext); +} + +/** + * Re-create the account's self-context and user-defined metrics. + * + * Delete-then-recreate inside the caller's transaction, matching every other + * section. The metric delete cascades its entries, so the readings are wiped + * and rebuilt with their parent rather than orphaned against it. + */ +export async function restoreProfileData( + tx: Prisma.TransactionClient, + ownerId: string, + payload: ProfileRestoreInput, +): Promise { + const profileCleared = await tx.userHealthProfile.deleteMany({ + where: { userId: ownerId }, + }); + const metricsCleared = await tx.customMetric.deleteMany({ + where: { userId: ownerId }, + }); + + if (payload.healthProfile) { + const p = payload.healthProfile; + await tx.userHealthProfile.create({ + data: { + ...(p.id ? { id: p.id } : {}), + userId: ownerId, + aboutMeEncrypted: resolveProfileColumn( + p.aboutMeEncrypted, + p.aboutMe, + "aboutMe", + ), + conditionsEncrypted: resolveProfileColumn( + p.conditionsEncrypted, + p.conditions, + "conditions", + ), + allergiesEncrypted: resolveProfileColumn( + p.allergiesEncrypted, + p.allergies, + "allergies", + ), + coachFocusEncrypted: resolveProfileColumn( + p.coachFocusEncrypted, + p.coachFocus, + "coachFocus", + ), + // Portable exports do not carry the pending questions at all, so + // `undefined` means "this file has nothing to say" and the account + // comes back with no prompts pending — which the next profile save + // regenerates. Only a DR payload can restore them. + pendingQuestionsEncrypted: + p.pendingQuestionsEncrypted == null + ? null + : decodeEncryptedField( + p.pendingQuestionsEncrypted, + "pendingQuestions", + ), + ...(p.createdAt ? { createdAt: new Date(p.createdAt) } : {}), + ...(p.updatedAt ? { updatedAt: new Date(p.updatedAt) } : {}), + }, + }); + } + + // `(userId, name)` is unique, so two metrics sharing a name in one file + // would collide on insert and abort the transaction with a constraint error + // that names a column rather than the problem. Say what is wrong instead. + const seenNames = new Set(); + for (const metric of payload.customMetrics) { + if (seenNames.has(metric.name)) { + throw new Error( + `Duplicate custom metric name: ${metric.name}. ` + + "A backup cannot carry two metrics with the same name for one " + + "account, and picking a winner here would silently discard the " + + "readings of the other.", + ); + } + seenNames.add(metric.name); + + await tx.customMetric.create({ + data: { + ...(metric.id ? { id: metric.id } : {}), + userId: ownerId, + name: metric.name, + unit: metric.unit, + targetLow: metric.targetLow, + targetHigh: metric.targetHigh, + decimals: metric.decimals, + description: metric.description, + ...(metric.createdAt ? { createdAt: new Date(metric.createdAt) } : {}), + ...(metric.updatedAt ? { updatedAt: new Date(metric.updatedAt) } : {}), + deletedAt: metric.deletedAt ? new Date(metric.deletedAt) : null, + entries: { + create: metric.entries.map((entry) => ({ + ...(entry.id ? { id: entry.id } : {}), + userId: ownerId, + value: entry.value, + unit: entry.unit, + measuredAt: new Date(entry.measuredAt), + note: entry.note, + ...(entry.createdAt + ? { createdAt: new Date(entry.createdAt) } + : {}), + })), + }, + }, + }); + } + + return { + healthProfile: profileCleared.count, + customMetrics: metricsCleared.count, + }; +} diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index bd72dae5a..fbf92183a 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -390,6 +390,70 @@ const customCycleSymptomSchema = z }) .passthrough(); +/** + * The account's durable self-context — one row per user. + * + * Portable exports carry the decrypted free text; disaster-recovery payloads + * carry the AES-256-GCM envelopes as base64 and leave the plaintext fields + * null. The restore prefers ciphertext when the file has it and re-encrypts + * the plaintext otherwise, which is the same contract the lab note uses. + */ +const healthProfileBackupSchema = z + .object({ + id: z.string().min(1).optional(), + aboutMe: z.string().nullable().default(null), + conditions: z.string().nullable().default(null), + allergies: z.string().nullable().default(null), + coachFocus: z.string().nullable().default(null), + aboutMeEncrypted: base64BytesSchema.nullable().optional(), + conditionsEncrypted: base64BytesSchema.nullable().optional(), + allergiesEncrypted: base64BytesSchema.nullable().optional(), + coachFocusEncrypted: base64BytesSchema.nullable().optional(), + pendingQuestionsEncrypted: base64BytesSchema.nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); + +/** One reading of a user-defined metric, nested under the metric that owns it. */ +const customMetricEntryBackupSchema = z + .object({ + id: z.string().min(1).optional(), + value: z.number(), + unit: z.string().min(1), + measuredAt: isoDateTime, + note: z.string().nullable().default(null), + createdAt: isoDateTime.optional(), + }) + .passthrough(); + +/** + * A metric the account defined itself. + * + * The one class of series no integration can ever re-sync: nobody else has the + * definition and nobody else has the readings. It was classified as carried + * from the day the backup plan was written and carried by nothing, so a + * restore rebuilt the account without it and reported success. + * + * Readings are nested rather than flat-with-a-name-reference so the restore + * has no parent to look up and therefore no lookup to miss. + */ +const customMetricBackupSchema = z + .object({ + id: z.string().min(1).optional(), + name: z.string().min(1), + unit: z.string().min(1), + targetLow: z.number().nullable().default(null), + targetHigh: z.number().nullable().default(null), + decimals: z.number().int().nullable().default(null), + description: z.string().nullable().default(null), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + deletedAt: isoDateTime.nullable().optional(), + entries: z.array(customMetricEntryBackupSchema).default([]), + }) + .passthrough(); + const appSettingsBackupSchema = z .object({ id: z.string().min(1), @@ -652,6 +716,11 @@ export const backupPayloadSchema = z workouts: z.array(workoutBackupSchema).default([]), documents: z.array(documentBackupSchema).default([]), nutrientDays: z.array(nutrientDaySchema).default([]), + // Durable self-context and user-defined series. Defaulted so files written + // before either rode the wire still parse; an account with neither writes + // `null` / `[]`. + healthProfile: healthProfileBackupSchema.nullable().default(null), + customMetrics: z.array(customMetricBackupSchema).default([]), manifest: backupManifestSchema.nullable().default(null), }) .passthrough() @@ -704,6 +773,12 @@ export interface BackupSummary { workouts: number; /** Document records (ciphertext included in canonical DR payloads). */ documents: number; + /** 1 when the account's durable self-context rides the file, 0 otherwise. */ + healthProfile: number; + /** Metrics the account defined itself. */ + customMetrics: number; + /** Readings across every user-defined metric. */ + customMetricEntries: number; } export function summarizeBackup(payload: BackupPayload): BackupSummary { @@ -729,6 +804,12 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { familyHistory: payload.familyHistory.length, workouts: payload.workouts.length, documents: payload.documents.length, + healthProfile: payload.healthProfile ? 1 : 0, + customMetrics: payload.customMetrics.length, + customMetricEntries: payload.customMetrics.reduce( + (sum, metric) => sum + metric.entries.length, + 0, + ), }; }