/** * Durable self-context, user-defined metrics, and persisted pattern decisions, * with both backup ends in one file. * * These account-owned models cannot be reconstructed from an integration: * `UserHealthProfile`, `CustomMetric` / `CustomMetricEntry`, and * `CorrelationPattern`. The last model carries stable finding identity and the * evidence baseline that makes a dismissal survive recomputation and restore. * * 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"; import { DEFAULT_HEALTH_PROFILE_AI_SECTIONS, isHealthProfileFactValue, type HealthProfileAiSection, type HealthProfileFactKind, } from "@/lib/validations/health-profile-facts"; 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; aiIncludedSections: HealthProfileAiSection[]; /** * 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; } export interface HealthProfileFactBackupEntry { id: string; kind: HealthProfileFactKind; /** Portable exports carry readable plaintext; DR payloads leave this null. */ value: string | null; /** Base64 encrypted envelope, present only in disaster-recovery payloads. */ valueEncrypted?: string; validFrom: string; validUntil: string | null; provenance: "USER_REPORTED" | "USER_CORRECTION"; supersededByRevisionId: string | null; createdAt: 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; correlationEnabled: boolean; createdAt?: string; updatedAt?: string; /** Tombstone, DR payloads only — a portable export never carries deleted rows. */ deletedAt?: string | null; entries: CustomMetricEntryBackupEntry[]; } /** Persisted accepted correlation evidence and its dismissal decision. */ export interface CorrelationPatternBackupEntry { id?: string; canonicalKey: string; family: string; factorKey: string; outcomeKey: string; lagDays: number; sampleSize: number; effectSize: number; pValue: number; qValue: number | null; evidenceHash: string; isCurrent: boolean; lastComputedAt: string; dismissedAt: string | null; dismissedEvidenceHash: string | null; dismissedEffectSize: number | null; dismissedSampleSize: number | null; createdAt?: string; updatedAt?: string; } export interface ProfileBackupSection { healthProfile: HealthProfileBackupEntry | null; customMetrics: CustomMetricBackupEntry[]; healthProfileFacts: HealthProfileFactBackupEntry[]; correlationPatterns: CorrelationPatternBackupEntry[]; } export interface ProfileBackupCounts { healthProfile: number; customMetrics: number; customMetricEntries: number; healthProfileFactRevisions: number; correlationPatterns: 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< PrismaClient, | "userHealthProfile" | "healthProfileFactRevision" | "customMetric" | "correlationPattern" >, userId: string, options: ProfileBackupOptions = {}, ): Promise { const disasterRecovery = options.purpose === "disaster-recovery"; const [profileRow, metricRows, factRows, patternRows] = 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: { entries: { orderBy: { measuredAt: "asc" } }, }, }), prisma.healthProfileFactRevision.findMany({ where: { userId }, orderBy: [{ kind: "asc" }, { validFrom: "asc" }], }), prisma.correlationPattern.findMany({ where: { userId }, orderBy: { canonicalKey: "asc" }, }), ]); const healthProfile: HealthProfileBackupEntry | null = profileRow ? disasterRecovery ? { id: profileRow.id, aboutMe: null, conditions: null, allergies: null, coachFocus: null, aiIncludedSections: (profileRow.aiIncludedSections as HealthProfileAiSection[] | undefined) ?? [ ...DEFAULT_HEALTH_PROFILE_AI_SECTIONS, ], 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", ), aiIncludedSections: (profileRow.aiIncludedSections as HealthProfileAiSection[] | undefined) ?? [ ...DEFAULT_HEALTH_PROFILE_AI_SECTIONS, ], } : null; const healthProfileFacts: HealthProfileFactBackupEntry[] = []; if (disasterRecovery) { for (const fact of factRows) { healthProfileFacts.push({ id: fact.id, kind: fact.kind as HealthProfileFactKind, value: null, valueEncrypted: toBase64(fact.valueEncrypted)!, validFrom: fact.validFrom.toISOString(), validUntil: fact.validUntil?.toISOString() ?? null, provenance: fact.provenance, supersededByRevisionId: fact.supersededByRevisionId, createdAt: fact.createdAt.toISOString(), }); } } else { const sourceById = new Map(factRows.map((fact) => [fact.id, fact])); const readableById = new Map(); for (const fact of factRows) { const value = decryptProfileFieldSoft( fact.valueEncrypted, `fact.${fact.kind}`, ); const kind = fact.kind as HealthProfileFactKind; if ( value === null || !isHealthProfileFactValue(kind, value) || (fact.validUntil !== null && fact.validUntil <= fact.validFrom) ) { continue; } readableById.set(fact.id, { id: fact.id, kind, value, validFrom: fact.validFrom.toISOString(), validUntil: fact.validUntil?.toISOString() ?? null, provenance: fact.provenance, supersededByRevisionId: null, createdAt: fact.createdAt.toISOString(), }); } for (const fact of factRows) { const readable = readableById.get(fact.id); if (!readable) continue; if (fact.supersededByRevisionId !== null) { const successor = sourceById.get(fact.supersededByRevisionId); if ( fact.validUntil !== null && successor && successor.kind === fact.kind && successor.validFrom.getTime() === fact.validUntil.getTime() && readableById.has(successor.id) ) { readable.supersededByRevisionId = successor.id; } } healthProfileFacts.push(readable); } } 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, correlationEnabled: metric.correlationEnabled, 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, })), })); const correlationPatterns: CorrelationPatternBackupEntry[] = patternRows.map( (pattern) => ({ ...(disasterRecovery ? { id: pattern.id, createdAt: pattern.createdAt.toISOString(), updatedAt: pattern.updatedAt.toISOString(), } : {}), canonicalKey: pattern.canonicalKey, family: pattern.family, factorKey: pattern.factorKey, outcomeKey: pattern.outcomeKey, lagDays: pattern.lagDays, sampleSize: pattern.sampleSize, effectSize: pattern.effectSize, pValue: pattern.pValue, qValue: pattern.qValue, evidenceHash: pattern.evidenceHash, isCurrent: pattern.isCurrent, lastComputedAt: pattern.lastComputedAt.toISOString(), dismissedAt: pattern.dismissedAt?.toISOString() ?? null, dismissedEvidenceHash: pattern.dismissedEvidenceHash, dismissedEffectSize: pattern.dismissedEffectSize, dismissedSampleSize: pattern.dismissedSampleSize, }), ); return { healthProfile, healthProfileFacts, customMetrics, correlationPatterns, }; } /** 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, healthProfileFactRevisions: section.healthProfileFacts.length, customMetricEntries: section.customMetrics.reduce( (sum, metric) => sum + metric.entries.length, 0, ), correlationPatterns: section.correlationPatterns.length, }; } /** Counts the profile restore wiped, for the audit trail. */ export interface ProfileRestoreCleared { healthProfile: number; healthProfileFactRevisions: number; customMetrics: number; correlationPatterns: 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; healthProfileFacts: HealthProfileFactBackupEntry[]; customMetrics: CustomMetricBackupEntry[]; correlationPatterns: CorrelationPatternBackupEntry[]; } /** * 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); } function resolveFactValue( entry: HealthProfileFactBackupEntry, ): Uint8Array { if (entry.valueEncrypted !== undefined) { return decodeEncryptedField(entry.valueEncrypted, `fact.${entry.kind}`); } if ( entry.value === null || !isHealthProfileFactValue(entry.kind, entry.value) ) { throw new Error( `Health profile fact '${entry.kind}' has no valid readable value`, ); } return encryptToBytes(entry.value); } /** * One fact revision's prompt-affecting identity: everything the AI prompt * would see differently if this revision changed. `createdAt` is * deliberately excluded — it never reaches the model, so two revisions that * differ only in when they were inserted still count as unchanged scope. */ interface HealthProfileFactScopeRow { id: string; kind: string; valueEncrypted: Uint8Array; validFrom: Date | string; validUntil: Date | string | null; provenance: string; supersededByRevisionId: string | null; } function factScopeSignature(row: HealthProfileFactScopeRow): string { const validFrom = typeof row.validFrom === "string" ? row.validFrom : row.validFrom.toISOString(); const validUntil = row.validUntil === null ? "" : typeof row.validUntil === "string" ? row.validUntil : row.validUntil.toISOString(); return [ row.id, row.kind, validFrom, validUntil, row.provenance, row.supersededByRevisionId ?? "", toBase64(row.valueEncrypted) ?? "", ].join("|"); } /** True when the two prompt-scope signature sets are not identical. */ function scopeSignaturesDiffer( live: Set, restored: Set, ): boolean { if (live.size !== restored.size) return true; for (const signature of live) { if (!restored.has(signature)) return true; } return false; } /** * 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 { // Snapshot the live AI-prompt scope before any restore write touches it, // so it can be compared with what this restore leaves behind. A restore // that narrows `aiIncludedSections` or changes which facts are live means // a cached briefing may reference content this account no longer has — // that comparison decides whether to clear it below, inside this same // transaction so the clear can never outlive a rolled-back restore. const liveProfileScope = await tx.userHealthProfile.findUnique({ where: { userId: ownerId }, select: { aiIncludedSections: true }, }); const liveFacts = await tx.healthProfileFactRevision.findMany({ where: { userId: ownerId }, select: { id: true, kind: true, valueEncrypted: true, validFrom: true, validUntil: true, provenance: true, supersededByRevisionId: true, }, }); const liveSections = ( (liveProfileScope?.aiIncludedSections as HealthProfileAiSection[] | undefined) ?? DEFAULT_HEALTH_PROFILE_AI_SECTIONS ) .slice() .sort(); const liveFactSignatures = new Set( liveFacts.map((fact) => factScopeSignature(fact)), ); const factsCleared = await tx.healthProfileFactRevision.deleteMany({ where: { userId: ownerId }, }); const profileCleared = await tx.userHealthProfile.deleteMany({ where: { userId: ownerId }, }); const metricsCleared = await tx.customMetric.deleteMany({ where: { userId: ownerId }, }); const patternsCleared = await tx.correlationPattern.deleteMany({ where: { userId: ownerId }, }); if (payload.healthProfile) { const p = payload.healthProfile; await tx.userHealthProfile.create({ data: { ...(p.id ? { id: p.id } : {}), userId: ownerId, aiIncludedSections: p.aiIncludedSections, 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) } : {}), }, }); } const restoredSections = [ ...(payload.healthProfile?.aiIncludedSections ?? DEFAULT_HEALTH_PROFILE_AI_SECTIONS), ].sort(); const factIds = new Set(); const restoredFactSignatures = new Set(); for (const fact of payload.healthProfileFacts) { if (factIds.has(fact.id)) { throw new Error(`Duplicate health profile fact revision id: ${fact.id}`); } factIds.add(fact.id); const valueEncrypted = resolveFactValue(fact); restoredFactSignatures.add( factScopeSignature({ id: fact.id, kind: fact.kind, valueEncrypted, validFrom: fact.validFrom, validUntil: fact.validUntil, provenance: fact.provenance, supersededByRevisionId: fact.supersededByRevisionId, }), ); await tx.healthProfileFactRevision.create({ data: { id: fact.id, userId: ownerId, kind: fact.kind, valueEncrypted, validFrom: new Date(fact.validFrom), validUntil: fact.validUntil ? new Date(fact.validUntil) : null, provenance: fact.provenance, supersededByRevisionId: null, createdAt: new Date(fact.createdAt), }, }); } for (const fact of payload.healthProfileFacts) { if (!fact.supersededByRevisionId) continue; if (!factIds.has(fact.supersededByRevisionId)) { throw new Error( `Health profile fact revision '${fact.id}' points to a missing successor`, ); } await tx.healthProfileFactRevision.update({ where: { id: fact.id }, data: { supersededByRevisionId: fact.supersededByRevisionId }, }); } // `(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, correlationEnabled: metric.correlationEnabled, ...(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) } : {}), })), }, }, }); } const seenPatternKeys = new Set(); for (const pattern of payload.correlationPatterns) { if (seenPatternKeys.has(pattern.canonicalKey)) { throw new Error( `Duplicate correlation pattern key: ${pattern.canonicalKey}`, ); } seenPatternKeys.add(pattern.canonicalKey); await tx.correlationPattern.create({ data: { ...(pattern.id ? { id: pattern.id } : {}), userId: ownerId, canonicalKey: pattern.canonicalKey, family: pattern.family, factorKey: pattern.factorKey, outcomeKey: pattern.outcomeKey, lagDays: pattern.lagDays, sampleSize: pattern.sampleSize, effectSize: pattern.effectSize, pValue: pattern.pValue, qValue: pattern.qValue, evidenceHash: pattern.evidenceHash, isCurrent: pattern.isCurrent, lastComputedAt: new Date(pattern.lastComputedAt), dismissedAt: pattern.dismissedAt ? new Date(pattern.dismissedAt) : null, dismissedEvidenceHash: pattern.dismissedEvidenceHash, dismissedEffectSize: pattern.dismissedEffectSize, dismissedSampleSize: pattern.dismissedSampleSize, ...(pattern.createdAt ? { createdAt: new Date(pattern.createdAt) } : {}), ...(pattern.updatedAt ? { updatedAt: new Date(pattern.updatedAt) } : {}), }, }); } const sectionsChanged = restoredSections.length !== liveSections.length || restoredSections.some((section, index) => section !== liveSections[index]); const factsChanged = scopeSignaturesDiffer( liveFactSignatures, restoredFactSignatures, ); if (sectionsChanged || factsChanged) { // A narrower or altered AI-prompt scope means any cached briefing may // reference profile content this restore just removed or changed. // Clearing it here — inside the same transaction as the write that // changed scope — keeps the clear atomic: a failed restore leaves the // cache exactly as it was, and a committed one can never serve a // pre-restore briefing against a narrower post-restore scope. await tx.user.update({ where: { id: ownerId }, data: { insightsCachedText: null, insightsCachedAt: null }, }); } return { healthProfile: profileCleared.count, healthProfileFactRevisions: factsCleared.count, customMetrics: metricsCleared.count, correlationPatterns: patternsCleared.count, }; }