/** * Zod schemas + helpers for the per-user `DataBackup` JSON payload. * * Single source of truth shared by: * - the pg-boss `data-backup` worker that writes a backup, * - `GET /api/admin/backups/[id]/download` that streams it back as JSON, * - `POST /api/admin/backups/upload` that ingests an admin-supplied file, * - `POST /api/admin/backups/[id]/restore` that re-creates DB rows from it. * * The schema is intentionally permissive about *extra* fields (`.passthrough`) * so older snapshots written before a column was added still parse and so * future minor additions don't break old admins. Required fields are * deliberately tight — any drift surfaces as a validation error rather than * a silent data-loss restore. * * `schemaVersion` is the migration handle. Bump when the on-disk shape * changes incompatibly. The current writer (worker) historically did NOT * include this field; `parseBackupPayload()` defaults it to "1" so legacy * blobs continue to round-trip and the upload validator can still produce * a useful summary for them. */ import { z } from "zod/v4"; import { AllergyCategory, AllergySeverity, AllergyStatus, AllergyType, CervicalMucus, CervixFirmness, CervixOpening, CervixPosition, ContraceptiveKind, CycleTrackingGoal, DocumentSummaryState, EncounterKind, EncounterStatus, ExtractedFactStatus, ExtractedFactType, EnvironmentLocationSource, FamilyRelationship, FlowLevel, GlucoseContext, HomeTestResult, IllnessLifecycle, IllnessType, InboundDocumentKind, InboundDocumentStatus, InjectionSite, IntakeAttributionSource, IntakeSource, Laterality, MeasurementAggregationProvenance, MeasurementReminderEventKind, MeasurementSource, MeasurementType, AssessmentInstrument, MedicationCategory, PhaseMode, MedicationContainerType, MedicationInventoryState, MedicationDeliveryForm, MedicationScheduleType, MedicationSideEffectCategory, MedicationSideEffectEntry, OvulationTest, PersonalRecordDirection, ReminderOrigin, RhythmClassification, SecondarySymptom, SleepStage, VaccinationSite, } from "@/generated/prisma/enums"; import { DEFAULT_HEALTH_PROFILE_AI_SECTIONS, healthProfileAiSectionSchema, healthProfileFactKindSchema, isHealthProfileFactValue, } from "@/lib/validations/health-profile-facts"; import { advanceDirectiveStatusSchema, emergencyBloodTypeSchema, organDonorStatusSchema, } from "@/lib/validations/emergency-profile"; import { REMINDER_EVENT_SOURCES } from "@/lib/measurement-reminders/satisfy"; export const BACKUP_SCHEMA_VERSION = "2" as const; const LEGACY_BACKUP_SCHEMA_VERSION = "1" as const; const isoDateTime = z .string() .min(1) .refine((s) => !Number.isNaN(Date.parse(s)), { message: "Expected ISO-8601 date-time string", }); const base64BytesSchema = z .string() .min(1) .refine( (value) => value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value), { message: "Expected base64-encoded encrypted bytes" }, ); const measurementSchema = z .object({ id: z.string().min(1).optional(), type: z.enum(MeasurementType), value: z.number(), valueMin: z.number().nullable().optional(), valueMax: z.number().nullable().optional(), unit: z.string().min(1), measuredAt: isoDateTime, source: z.enum(MeasurementSource).optional(), notes: z.string().nullable().optional(), notesEncrypted: base64BytesSchema.nullable().optional(), externalId: z.string().nullable().optional(), externalSourceVersion: z.string().nullable().optional(), // Authority of a `stats:` aggregate. `.optional()` keeps every backup // written before this field rode the payload parseable. aggregationProvenance: z .enum(MeasurementAggregationProvenance) .nullable() .optional(), glucoseContext: z.enum(GlucoseContext).nullable().optional(), sleepStage: z.enum(SleepStage).nullable().optional(), rhythmClassification: z.enum(RhythmClassification).nullable().optional(), deviceType: z.string().nullable().optional(), syncVersion: z.number().int().optional(), deletedAt: isoDateTime.nullable().optional(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), }) .passthrough(); const medicationScheduleSchema = z .object({ id: z.string().min(1).optional(), windowStart: z.string().min(1), windowEnd: z.string().min(1), label: z.string().nullable().optional(), dose: z.string().nullable().optional(), // #219 — per-schedule units per dose. Serialised as a Decimal string in a // DR file (or a number in a hand-authored one); NULL means inherit. unitsPerDose: z.union([z.string(), z.number()]).nullable().optional(), daysOfWeek: z.string().nullable().optional(), timesOfDay: z.array(z.string()).optional(), reminderGraceMinutes: z.number().int().nullable().optional(), rrule: z.string().nullable().optional(), rollingIntervalDays: z.number().int().nullable().optional(), scheduleType: z.enum(MedicationScheduleType).optional(), cyclicOnWeeks: z.number().int().nullable().optional(), cyclicOffWeeks: z.number().int().nullable().optional(), doseWindows: z.unknown().nullable().optional(), }) .passthrough(); /** * One recorded side effect, carried inside its medication. * * `notes` is the decrypted note in a portable export and the row's legacy * plaintext column in a canonical DR file; `notesEncrypted` is the base64 * ciphertext and rides only in the DR case. The restore prefers the ciphertext * and encrypts the plaintext when that is all the file has, so neither shape * loses the note and neither writes plaintext back into the column. */ const medicationSideEffectSchema = z .object({ id: z.string().min(1).optional(), occurredAt: isoDateTime, category: z.enum(MedicationSideEffectCategory), entry: z.enum(MedicationSideEffectEntry), severity: z.number().int().min(1).max(5), notes: z.string().nullable().optional(), notesEncrypted: base64BytesSchema.nullable().optional(), createdAt: isoDateTime.optional(), }) .passthrough(); /** * A span during which the drug was deliberately not taken. * * `resumedAt` is nullable rather than optional, and the writer always emits * it. An absent key and an explicit `null` would otherwise both have to mean * "still paused", and a file that simply forgot the field would assert an open * era that never existed. Readers run an open era to `now`. */ const medicationPauseEraSchema = z .object({ id: z.string().min(1).optional(), pausedAt: isoDateTime, resumedAt: isoDateTime.nullable(), createdAt: isoDateTime.optional(), }) .passthrough(); /** * One step of a titration: when the dose moved and to what. * * The note follows the side-effect contract — decrypted prose in a portable * file, ciphertext plus any legacy plaintext in a recovery one. */ const medicationDoseChangeSchema = z .object({ id: z.string().min(1).optional(), effectiveFrom: isoDateTime, doseValue: z.number(), doseUnit: z.string().min(1), note: z.string().nullable().optional(), noteEncrypted: base64BytesSchema.nullable().optional(), createdAt: isoDateTime.optional(), }) .passthrough(); /** * A pack on the shelf. `unitsTotal` / `unitsRemaining` cross the wire as * STRINGS: the columns are `Decimal(12,4)` because half tablets are a real * prescription, and a JSON number would round them on the way through. */ const medicationInventoryItemSchema = z .object({ id: z.string().min(1).optional(), state: z.enum(MedicationInventoryState).optional(), containerType: z.enum(MedicationContainerType).optional(), unitsTotal: z.string().min(1), unitsRemaining: z.string().min(1), firstUseAt: isoDateTime.nullable().optional(), expiresAt: isoDateTime.nullable().optional(), printedExpiry: isoDateTime.nullable().optional(), purchasedAt: isoDateTime.nullable().optional(), manufacturer: z.string().nullable().optional(), doseStrength: z.string().nullable().optional(), notes: z.string().nullable().optional(), notesEncrypted: base64BytesSchema.nullable().optional(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), }) .passthrough(); const medicationInventoryEventSchema = z .object({ id: z.string().min(1).optional(), delta: z.number().int(), reason: z.string().min(1), occurredAt: isoDateTime, }) .passthrough(); /** * The four reminder thresholds a drug was tuned to. Every field optional, so a * file written before a threshold existed restores the schema default rather * than refusing — but the OBJECT is nullable rather than absent, because "no * tuning" and "this file does not say" are different claims. */ const reminderPhaseConfigSchema = z .object({ id: z.string().min(1).optional(), greenValue: z.number().int().optional(), greenMode: z.enum(PhaseMode).optional(), yellowValue: z.number().int().optional(), yellowMode: z.enum(PhaseMode).optional(), orangeValue: z.number().int().optional(), orangeMode: z.enum(PhaseMode).optional(), redValue: z.number().int().optional(), redMode: z.enum(PhaseMode).optional(), }) .passthrough(); /** * One archived schedule era. * * `supersededByIndex` is the self-reference, and it is a POSITION in the * drug's own `scheduleRevisions` array rather than an id: a portable restore * mints fresh ids, so an id would address nothing. It stays a plain integer * with no range check here on purpose — the file is the wrong place to learn * that a position is out of range, because the array it indexes into is the * array being parsed. The restore does that check, against the rows it * actually wrote, and reports what it cannot resolve. * * `payload` is the snapshot of the superseded schedule rows, carried as * written. Unknown on purpose rather than typed: it is an era's frozen copy of * a shape that has changed several times, and a restore must not refuse a file * because a five-release-old snapshot spells a field the current form does not. */ const medicationScheduleRevisionSchema = z .object({ id: z.string().min(1).optional(), validFrom: isoDateTime, validUntil: isoDateTime, payload: z.unknown(), source: z.string().min(1).optional(), supersededByIndex: z.number().int().nullable().optional(), createdAt: isoDateTime.optional(), }) .passthrough(); /** * What a medication was supposed to move. * * The lab arm is a biomarker NAME rather than an id, the way a lab result's * cross-reference is: the id is fresh on a portable restore, and `Biomarker` * is unique on `(userId, name)`. Both arms are nullable because the live * schema produces a row with neither — deleting a biomarker sets the column * NULL and leaves the override behind as an orphan the resolver reads as "no * override". */ const medicationEfficacyTargetSchema = z .object({ id: z.string().min(1).optional(), measurementType: z.enum(MeasurementType).nullable().optional(), biomarkerName: z.string().min(1).nullable().optional(), primary: z.boolean().optional(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), }) .passthrough(); const medicationSchema = z .object({ id: z.string().min(1).optional(), name: z.string().min(1), dose: z.string(), treatmentClass: z.enum(MedicationCategory).optional(), dosesPerUnit: z.number().int().nullable().optional(), unitsPerDose: z.string().min(1).optional(), active: z.boolean().optional(), notificationsEnabled: z.boolean().optional(), pausedAt: isoDateTime.nullable().optional(), snoozedUntil: isoDateTime.nullable().optional(), startsOn: isoDateTime.nullable().optional(), endsOn: isoDateTime.nullable().optional(), oneShot: z.boolean().optional(), asNeeded: z.boolean().optional(), // v1.39.1 (#1033) — absent in a backup written before the field existed; // the restore defaults it to tracking on, the behaviour those had. trackIntake: z.boolean().optional(), deliveryForm: z.enum(MedicationDeliveryForm).optional(), trackInjectionSites: z.boolean().optional(), allowedInjectionSites: z.array(z.enum(InjectionSite)).optional(), liveActivityEnabled: z.boolean().optional(), criticalAlarmEnabled: z.boolean().optional(), atcCode: z.string().nullable().optional(), rxNormCode: z.string().nullable().optional(), lowStockNotifiedAt: isoDateTime.nullable().optional(), lowStockNotifiedThresholdDays: z.number().int().nullable().optional(), reorderLeadDays: z.number().int().nullable().optional(), externalSource: z.enum(IntakeSource).nullable().optional(), externalId: z.string().nullable().optional(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), schedules: z.array(medicationScheduleSchema).default([]), // Defaulted so a file written before side effects rode the wire still // parses, and a drug with none writes []. sideEffects: z.array(medicationSideEffectSchema).default([]), // Defaulted for the same reason: a file written before pause eras rode the // wire still parses, and a drug that was never paused writes []. pauseEras: z.array(medicationPauseEraSchema).default([]), // Same default, same reason: an older file parses, a drug never titrated // writes []. doseChanges: z.array(medicationDoseChangeSchema).default([]), inventoryItems: z.array(medicationInventoryItemSchema).default([]), inventoryEvents: z.array(medicationInventoryEventSchema).default([]), phaseConfig: reminderPhaseConfigSchema.nullable().optional(), // Same default, same reason: an older file parses, and a drug whose // target the resolver derives rather than the person pinning it writes []. efficacyTargets: z.array(medicationEfficacyTargetSchema).default([]), // Same default again. A drug whose plan has never been replaced has no // archived era, and a file written before the eras travelled has no key. scheduleRevisions: z.array(medicationScheduleRevisionSchema).default([]), }) .passthrough(); const intakeEventSchema = z .object({ id: z.string().min(1).optional(), medicationId: z.string().min(1).optional(), medication: z.string().min(1), scheduledFor: isoDateTime, takenAt: isoDateTime.nullable().optional(), skipped: z.boolean().optional(), autoMissed: z.boolean().optional(), attributionSource: z.enum(IntakeAttributionSource).optional(), source: z.enum(IntakeSource).optional(), idempotencyKey: z.string().nullable().optional(), createdAt: isoDateTime.optional(), injectionSite: z.enum(InjectionSite).nullable().optional(), doseTaken: z.string().nullable().optional(), inventoryConsumption: z.unknown().nullable().optional(), externalId: z.string().nullable().optional(), updatedAt: isoDateTime.optional(), syncVersion: z.number().int().optional(), deletedAt: isoDateTime.nullable().optional(), }) .passthrough(); /** * `MoodEntry.tags` is stored as a JSON-array-as-string in the * `mood_entries.tags` column ("[\"work\",\"sleep\"]"). The previous * schema accepted any `string` here, so a malformed blob in a backup * (or one tampered with mid-restore) would land in the DB and crash * downstream readers that `JSON.parse` it. We now refine to one of * `null` / empty-string (legacy null wire format) / a JSON string * that parses to a `string[]`. v1.4.15 H2. */ const moodEntryTagsSchema = z .union([z.null(), z.string()]) .nullable() .optional() .refine( (v) => { if (v == null || v === "") return true; try { const parsed = JSON.parse(v) as unknown; return ( Array.isArray(parsed) && parsed.every((x) => typeof x === "string") ); } catch { return false; } }, { message: "tags must be null, empty, or a JSON array of strings" }, ); const moodFactorSchema = z .object({ key: z.string().min(1), rating: z.number().int(), }) .passthrough(); const moodEntrySchema = z .object({ id: z.string().min(1).optional(), date: z.string().min(1), mood: z.string().min(1), score: z.number().int().min(0).max(10), // The five level-A values. Optional and nullable: a file written before // they existed carries none, and an entry whose sliders were never touched // carries nulls. Absent restores as NULL, never as a defaulted midpoint — // a restore that invents an answer is worse than one that admits none. a1: z.number().int().min(0).max(10).nullable().optional(), a2: z.number().int().min(0).max(10).nullable().optional(), a3: z.number().int().min(0).max(10).nullable().optional(), a4: z.number().int().min(0).max(10).nullable().optional(), a5: z.number().int().min(0).max(10).nullable().optional(), tags: moodEntryTagsSchema, // The free text, in both storage shapes. A portable export carries the // decrypted `note`; a disaster-recovery file carries `noteEncrypted` plus // whatever legacy plaintext the row still holds. Same pair, and same // handling on restore, as `Measurement.notes` / `notesEncrypted`. note: z.string().nullable().optional(), noteEncrypted: base64BytesSchema.nullable().optional(), source: z.string().min(1).optional(), externalId: z.string().nullable().optional(), loggedAt: isoDateTime, // The IANA zone the `date` string is anchored to. Absent means the legacy // Europe/Berlin reading, which is what a row written before v1.4.25 means // by a NULL here — so an old file keeps its old meaning exactly. tz: z.string().nullable().optional(), syncedAt: isoDateTime.optional(), syncVersion: z.number().int().optional(), deletedAt: isoDateTime.nullable().optional(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), factors: z.array(moodFactorSchema).default([]), // BINARY structured-tag keys. Separate from `factors`, which keeps its // RATED-only meaning, so every file written before this one parses // unchanged and restores identically: absent reads as none. structuredTags: z.array(z.string().min(1)).default([]), // The day context, when the entry had one. Optional and nullable at the // top: a file written before this existed carries no `context` key at all // and must parse and restore exactly as it did. Every field inside is // optional for the same reason a context row's columns are nullable — // only the sections somebody opened say anything. // // The two multi-selects ride as the stored JSON string rather than as // arrays, so the restore writes the column back verbatim and a file cannot // arrive with a list the parser and the column disagree about. Nothing is // re-validated against the vocabulary here: a backup restores what the // account had, and a key retired between the export and the restore is // still that person's answer. context: z .object({ workStatus: z.string().nullable().optional(), workMinutes: z.number().int().nullable().optional(), overtimeMinutes: z.number().int().nullable().optional(), workLoad: z.number().int().nullable().optional(), workSatisfaction: z.number().int().nullable().optional(), contactCircles: z.string().nullable().optional(), contactForm: z.string().nullable().optional(), contactExtent: z.string().nullable().optional(), contactQuality: z.number().int().nullable().optional(), contactSupport: z.number().int().nullable().optional(), leisureCategories: z.string().nullable().optional(), leisureMinutes: z.number().int().nullable().optional(), leisureJoy: z.number().int().nullable().optional(), leisureRecovery: z.number().int().nullable().optional(), eventType: z.string().nullable().optional(), eventValence: z.number().int().nullable().optional(), eventAt: isoDateTime.nullable().optional(), // Same pair as the entry's own note: a portable file carries the // plain text, a disaster-recovery file the ciphertext. note: z.string().nullable().optional(), notesEncrypted: base64BytesSchema.nullable().optional(), }) .passthrough() .nullable() .optional(), }) .passthrough(); /* ── v1.15.0 cycle-tracking backup shapes ──────────────────────────── */ /** * One menstrual-cycle span. `startDate` is the natural per-user key * (matching the `(userId, startDate)` unique), so a restore upserts on it. * Predicted (forecast) rows are excluded from the backup — only observed * history round-trips. */ const cycleSpanSchema = z .object({ id: z.string().min(1).optional(), startDate: z.string().min(1), endDate: z.string().nullable().optional(), periodEndDate: z.string().nullable().optional(), lengthDays: z.number().int().nullable().optional(), ovulationDate: z.string().nullable().optional(), ovulationConfirmed: z.boolean().optional(), isPredicted: z.boolean().optional(), tz: z.string().nullable().optional(), syncVersion: z.number().int().optional(), deletedAt: isoDateTime.nullable().optional(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), }) .passthrough(); /** * One cycle day-log. `notesEncrypted` is carried as the AES-256-GCM * ciphertext envelope verbatim — the backup never decrypts it, so the * owner's free-text note round-trips encrypted (and a wrong-surface leak is * impossible). `symptomKeys` carries the seeded catalogue keys so the * restore can re-link without exporting internal join ids, and * `symptomSeverities` carries how hard each of them hit. */ /** * The intensity recorded against one of a day's symptoms. * * A sparse annotation over `symptomKeys`, not a replacement for it: the keys * still say which symptoms the day had, this says how hard the ones that were * rated hit. Only rated links appear, so a missing entry means the person never * put a number on it. `severity` is left as a plain integer here on purpose — * the 1-4 range is checked when the row is written, so a file carrying a value * outside it loses that one intensity instead of failing to restore at all. */ const cycleSymptomSeveritySchema = z .object({ key: z.string().min(1), severity: z.number().int().nullable().optional(), }) .passthrough(); const cycleDayLogSchema = z .object({ id: z.string().min(1).optional(), date: z.string().min(1), cycleId: z.string().nullable().optional(), flow: z.enum(FlowLevel).nullable().optional(), intermenstrualBleeding: z.boolean().optional(), basalBodyTempC: z.number().nullable().optional(), temperatureExcluded: z.boolean().optional(), ovulationTest: z.enum(OvulationTest).nullable().optional(), cervicalMucus: z.enum(CervicalMucus).nullable().optional(), cervixPosition: z.enum(CervixPosition).nullable().optional(), cervixFirmness: z.enum(CervixFirmness).nullable().optional(), cervixOpening: z.enum(CervixOpening).nullable().optional(), sexualActivity: z.boolean().optional(), protectedSex: z.boolean().nullable().optional(), pregnancyTest: z.enum(HomeTestResult).nullable().optional(), progesteroneTest: z.enum(HomeTestResult).nullable().optional(), contraceptive: z.enum(ContraceptiveKind).nullable().optional(), sensitiveEncrypted: z.string().nullable().optional(), notesEncrypted: z.string().nullable().optional(), source: z.enum(MeasurementSource).optional(), externalId: z.string().nullable().optional(), tz: z.string().nullable().optional(), syncVersion: z.number().int().optional(), deletedAt: isoDateTime.nullable().optional(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), symptomKeys: z.array(z.string()).default([]), symptomSeverities: z.array(cycleSymptomSeveritySchema).default([]), }) .passthrough(); /** Cycle-tracking preferences (one row per user). */ const cycleProfileSchema = z .object({ id: z.string().min(1).optional(), goal: z.enum(CycleTrackingGoal).optional(), cycleTrackingEnabled: z.boolean().nullable().optional(), typicalCycleLength: z.number().int().nullable().optional(), typicalPeriodLength: z.number().int().nullable().optional(), lutealPhaseLength: z.number().int().nullable().optional(), secondarySymptom: z.enum(SecondarySymptom).nullable().optional(), predictionEnabled: z.boolean().optional(), rawChartMode: z.boolean().optional(), discreetNotifications: z.boolean().optional(), sensitiveCategoryEncryption: z.boolean().optional(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), }) .passthrough(); /** * A mood tag the account created, as opposed to the seeded catalogue. * * Carried for the same reason as a custom cycle symptom, but the failure it * prevents is louder: the restore resolves an entry's rated factors by key and * throws on one it cannot find, so an account with a single custom rated tag * could not be restored at all. */ const customMoodTagSchema = z .object({ id: z.string().min(1).optional(), key: z.string().min(1), labelKey: z.string().min(1), categoryId: z.string().min(1), // "BINARY" | "RATED", enforced in app code rather than a DB enum. kind: z.string().min(1), isActive: z.boolean().default(true), icon: z.string().nullable().optional(), sortOrder: z.number().int().default(0), // The user's own words for the tag, encrypted at rest and carried // verbatim. Without it the tag comes back as a bare key and the person // who named it "Migräne" gets `custom:cm3x9…` instead. labelEncrypted: z.string().nullable().optional(), // The scale a RATED factor was recorded on. `inverse` marks a factor // where a HIGH value is bad (stress, conflict). Dropping it does not lose // a label, it silently reverses the meaning of every rating already // stored against that tag. scaleMin: z.number().int().default(1), scaleMax: z.number().int().default(5), inverse: z.boolean().default(false), }) .passthrough(); /** * A day's total for one nutrient, from one source. * * The export has written these since v1.29 and the canonical schema never * declared them, so `.passthrough()` carried the key through parsing and no * reader ever looked at it. The restore therefore dropped every water and * vitamin total on the floor while reporting success. */ const nutrientDaySchema = z .object({ day: z.string().min(1), nutrient: z.string().min(1), amount: z.number(), unit: z.string().min(1), // Closed set in app code rather than a DB enum; kept as a string here so a // future source does not make an old file unparseable. source: z.string().min(1), }) .passthrough(); /** * A symptom the account created, as opposed to the seeded catalogue. * * Carried because a day-log's symptom link resolves by key: without the * definition the key resolves to nothing on the restoring instance, and the * link is lost. The seeded rows are deliberately NOT carried — every instance * already has them, and shipping them would mean a restore could rewrite * another instance's reference data. */ const customCycleSymptomSchema = z .object({ id: z.string().min(1).optional(), key: z.string().min(1), labelKey: z.string().min(1), categoryId: z.string().min(1), icon: z.string().nullable().optional(), sortOrder: z.number().int().default(0), isActive: z.boolean().default(true), // Same reasoning as the mood tag's: the user's own words live here. labelEncrypted: z.string().nullable().optional(), }) .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), aiIncludedSections: z .array(healthProfileAiSectionSchema) .default([...DEFAULT_HEALTH_PROFILE_AI_SECTIONS]), // Emergency profile: three plaintext enums (carried by value in both // purposes), three free-text columns following the ciphertext-or-plaintext // split of the self-context fields above. emergencyBloodType: emergencyBloodTypeSchema.nullable().default(null), organDonorStatus: organDonorStatusSchema.nullable().default(null), advanceDirectiveStatus: advanceDirectiveStatusSchema .nullable() .default(null), emergencyContacts: z.string().nullable().default(null), emergencyImplants: z.string().nullable().default(null), emergencyNote: z.string().nullable().default(null), emergencyContactsEncrypted: base64BytesSchema.nullable().optional(), emergencyImplantsEncrypted: base64BytesSchema.nullable().optional(), emergencyNoteEncrypted: base64BytesSchema.nullable().optional(), 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(); const healthProfileFactBackupSchema = z .object({ id: z.string().min(1), kind: healthProfileFactKindSchema, value: z.string().nullable().default(null), valueEncrypted: base64BytesSchema.optional(), validFrom: isoDateTime, validUntil: isoDateTime.nullable(), provenance: z.enum(["USER_REPORTED", "USER_CORRECTION"]), supersededByRevisionId: z.string().min(1).nullable(), createdAt: isoDateTime, }) .passthrough() .superRefine((fact, ctx) => { if (fact.valueEncrypted === undefined) { if ( fact.value === null || !isHealthProfileFactValue(fact.kind, fact.value) ) { ctx.addIssue({ code: "custom", path: ["value"], message: "A portable profile fact requires a valid readable value", }); } } if ( fact.validUntil !== null && Date.parse(fact.validUntil) <= Date.parse(fact.validFrom) ) { ctx.addIssue({ code: "custom", path: ["validUntil"], message: "validUntil must be later than validFrom", }); } }); /** 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(), /// v1.37.20 (A3-11) — the entry tombstone rides DR payloads so a restore /// brings the account back exactly as it stood, undo affordance included. deletedAt: isoDateTime.nullable().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), correlationEnabled: z.boolean().default(false), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), deletedAt: isoDateTime.nullable().optional(), entries: z.array(customMetricEntryBackupSchema).default([]), }) .passthrough(); const correlationPatternBackupSchema = z .object({ id: z.string().min(1).optional(), canonicalKey: z.string().regex(/^p1:[a-f0-9]{64}$/), family: z.string().min(1), factorKey: z.string().min(1), outcomeKey: z.string().min(1), lagDays: z.number().int().min(0), sampleSize: z.number().int().positive(), effectSize: z.number().finite(), pValue: z.number().min(0).max(1), qValue: z.number().min(0).max(1).nullable().default(null), evidenceHash: z.string().regex(/^[a-f0-9]{64}$/), isCurrent: z.boolean(), lastComputedAt: isoDateTime, dismissedAt: isoDateTime.nullable().default(null), dismissedEvidenceHash: z .string() .regex(/^[a-f0-9]{64}$/) .nullable() .default(null), dismissedEffectSize: z.number().finite().nullable().default(null), dismissedSampleSize: z.number().int().positive().nullable().default(null), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), }) .passthrough(); /** * One local day's cumulative curve for one metric. * * The drain writes this row and deletes the per-sample rows it was folded from * in the same transaction, so past the grace window the file is the only copy. * `hourlyCumulative` is left unconstrained in length here on purpose: the * restore checks it against the slot count it shares with the reader and * throws naming the day, which says what is wrong with one row instead of * making a whole file unparseable. */ const intradayProfileBackupSchema = z .object({ id: z.string().min(1).optional(), type: z.enum(MeasurementType), dateKey: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), hourlyCumulative: z.array(z.number()), dayTotal: z.number(), sampleCount: z.number().int(), // The zone the day was cut on. The reader compares it against the // account's current zone and drops the mismatched days, so a restore that // dropped this column would make old curves look comparable when their // hours mean something else. timezone: z.string().min(1), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), }) .passthrough(); const healthScoreRecordBackupSchema = z .object({ id: z.string().min(1).optional(), dayKey: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // The zone the day was cut on. An account that moved zones has days of // different lengths behind it, and a restore that guessed would make them // look comparable. timezone: z.string().min(1), composite: z.number().int().min(0).max(100), // The same closed set the column's CHECK constraint carries. A band the // schema let through and the database refused would fail the whole // restore transaction on one bad row. band: z.enum(["green", "yellow", "red"]), scoreVersion: z.number().int(), composition: z.array(z.string().min(1)).min(1), pillarScores: z.record(z.string(), z.number()), inputFingerprint: z.string().regex(/^[0-9a-f]{64}$/), configVersion: z.number().int().nullable().default(null), configChangedAt: isoDateTime.nullable().default(null), computedAt: isoDateTime, createdAt: isoDateTime.optional(), }) .passthrough(); /** * v1.39 (C1) — the needs-based setup state. One row per record. * * The three answer objects are validated loosely on purpose: the closed * vocabularies live in `src/lib/onboarding/needs.ts`, and the restore reads * every one of them back through the same fail-soft parsers the account * payload uses. Duplicating the enums here would give the file a second, * quietly divergent opinion about what an answer is, and the failure mode of * being stricter here is refusing a whole account's restore over one retired * chip. */ const onboardingRecordBackupSchema = z .object({ id: z.string().min(1).optional(), needs: z.record(z.string(), z.unknown()).default({}), steps: z.array(z.record(z.string(), z.unknown())).default([]), firstResult: z.record(z.string(), z.unknown()).nullable().default(null), // The once-only latch on the module derivation. Carried rather than // defaulted: a null here would hand the next confirm permission to // re-apply the questionnaire over every module decision made since. modulesDerivedAt: isoDateTime.nullable().default(null), completedAt: isoDateTime.nullable().default(null), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), }) .passthrough(); const appSettingsBackupSchema = z .object({ id: z.string().min(1), registrationEnabled: z.boolean(), mfaRequired: z.boolean(), defaultLocale: z.string(), telegramGlobal: z.boolean(), ntfyGlobal: z.boolean(), webPushGlobal: z.boolean(), webPushVapidPublicKey: z.string().nullable(), webPushVapidPrivateKeyEncrypted: z.string().nullable(), webPushVapidSubject: z.string().nullable(), apiGlobal: z.boolean(), umamiEnabled: z.boolean(), umamiScriptUrl: z.string().nullable(), umamiWebsiteId: z.string().nullable(), glitchtipEnabled: z.boolean(), glitchtipDsn: z.string().nullable(), glitchtipEnvironment: z.string().nullable(), reminderLateMinutes: z.number().int(), reminderMissedMinutes: z.number().int(), adminAiKeyEncrypted: z.string().nullable(), adminAiModel: z.string(), adminAiBaseUrl: z.string(), adminCodexAccessTokenEncrypted: z.string().nullable(), adminCodexRefreshTokenEncrypted: z.string().nullable(), adminCodexAccountIdEncrypted: z.string().nullable(), adminCodexTokenExpiresAt: isoDateTime.nullable(), adminCodexConnectedAt: isoDateTime.nullable(), adminCodexConnectionStatus: z.string(), adminAiInsightsFeedbackSummary: z.unknown().nullable(), defaultUserTimezone: z.string().nullable(), assistantEnabled: z.boolean(), assistantCoachEnabled: z.boolean(), assistantBriefingEnabled: z.boolean(), assistantInsightStatusEnabled: z.boolean(), // Optional: a backup written before the switch existed carries none, and // restores it at its default. A backup from before the Correlations switch // was retired still carries that key; `passthrough` keeps it harmless and // the restore writes nothing for it. assistantDocumentAiEnabled: z.boolean().optional(), moduleAvailabilityJson: z.unknown().nullable(), documentMaxFileBytes: z.number().int(), documentQuotaBytes: z.string().regex(/^\d+$/), }) .passthrough(); /* ── Structured-record disaster-recovery shapes ───────────────────── * * These shapes serve both the historical portable export and the canonical * weekly/off-host disaster-recovery payload. Portable document entries remain * metadata-only. Canonical entries additionally carry encrypted BYTEA values * as base64 plus the codec/hash fields required to recreate InboundDocument * without decrypting or fabricating content. */ const labResultBackupSchema = z .object({ id: z.string().min(1).optional(), panel: z.string().nullable().optional(), analyte: z.string().min(1), value: z.number().nullable().optional(), valueText: z.string().nullable().optional(), unit: z.string().min(1), referenceLow: z.number().nullable().optional(), referenceHigh: z.number().nullable().optional(), // Optional so a backup written before these columns existed still // restores; absent reads as "this reading had no source window on file". sourceReferenceLow: z.number().nullable().optional(), sourceReferenceHigh: z.number().nullable().optional(), sourceReferenceText: z.string().nullable().optional(), takenAt: isoDateTime, source: z.string().min(1), biomarkerName: z.string().nullable().optional(), biomarkerId: z.string().nullable().optional(), note: z.string().nullable().optional(), createdAt: isoDateTime.optional(), noteEncrypted: base64BytesSchema.nullable().optional(), deletedAt: isoDateTime.nullable().optional(), updatedAt: isoDateTime.optional(), }) .passthrough(); const biomarkerBackupSchema = z .object({ id: z.string().min(1).optional(), name: z.string().min(1), unit: z.string().min(1), lowerBound: z.number().nullable().optional(), upperBound: z.number().nullable().optional(), panel: z.string().nullable().optional(), hidden: z.boolean().optional(), context: z.string().nullable().optional(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), }) .passthrough(); const illnessSymptomBackupSchema = z .object({ key: z.string().min(1), severity: z.number().int().nullable().optional(), }) .passthrough(); const illnessDayLogBackupSchema = z .object({ id: z.string().min(1).optional(), episodeId: z.string().min(1).optional(), date: z.string().min(1), functionalImpact: z.number().int().nullable().optional(), feverC: z.number().nullable().optional(), symptoms: z.array(illnessSymptomBackupSchema).default([]), note: z.string().nullable().optional(), updatedAt: isoDateTime.optional(), noteEncrypted: base64BytesSchema.nullable().optional(), tz: z.string().nullable().optional(), createdAt: isoDateTime.optional(), deletedAt: isoDateTime.nullable().optional(), }) .passthrough(); const illnessEpisodeBackupSchema = z .object({ id: z.string().min(1), label: z.string().min(1), type: z.enum(IllnessType), lifecycle: z.enum(IllnessLifecycle), onsetAt: isoDateTime, resolvedAt: isoDateTime.nullable().optional(), // Self-referencing flare/exacerbation link, carried as the exported // episode's own id — never resolved against another user's rows. parentConditionId: z.string().nullable().optional(), note: z.string().nullable().optional(), createdAt: isoDateTime.optional(), noteEncrypted: base64BytesSchema.nullable().optional(), deletedAt: isoDateTime.nullable().optional(), updatedAt: isoDateTime.optional(), dayLogs: z.array(illnessDayLogBackupSchema).default([]), }) .passthrough(); /** * A practitioner, an encounter, and the edges between an encounter and the * things it produced. * * `id` is required on both records, unlike the lab result above: a link * addresses an encounter and its far side by id and nothing else on either row * is unique enough to rebuild the reference from. The ciphertext columns ride * verbatim as base64 — a visit note is never decrypted into the file. */ const practitionerBackupSchema = z .object({ id: z.string().min(1), name: z.string().min(1), specialty: z.string().nullable().optional(), practice: z.string().nullable().optional(), location: z.string().nullable().optional(), phone: z.string().nullable().optional(), noteEncrypted: base64BytesSchema.nullable().optional(), createdAt: isoDateTime, updatedAt: isoDateTime, deletedAt: isoDateTime.nullable().optional(), }) .passthrough(); const encounterBackupSchema = z .object({ id: z.string().min(1), occurredAt: isoDateTime, status: z.enum(EncounterStatus), kind: z.enum(EncounterKind), practitionerId: z.string().nullable().optional(), reasonEncrypted: base64BytesSchema.nullable().optional(), outcomeEncrypted: base64BytesSchema.nullable().optional(), // v1.39.1 — the procedure's body site and side. Optional so a file written // before they existed still parses; the visit restores with neither. bodySiteEncrypted: base64BytesSchema.nullable().optional(), laterality: z.enum(Laterality).nullable().optional(), // Remapped against the restored reminders (they travel since v1.37.20); // dropped to NULL, with the drop named, only when the file lacks the row. reminderId: z.string().nullable().optional(), createdAt: isoDateTime, updatedAt: isoDateTime, deletedAt: isoDateTime.nullable().optional(), }) .passthrough(); const encounterLinkBackupSchema = z .object({ encounterId: z.string().min(1), targetId: z.string().min(1), createdAt: isoDateTime, }) .passthrough(); /** * One administered dose. * * `antigenSlug` is a free string here rather than an enum of the catalogue * this release ships, deliberately: a slug the catalogue has since dropped * must still restore, and the renderer degrades to `vaccineName`. Validating * it at the restore boundary would refuse a file the app itself wrote. */ const vaccinationBackupSchema = z .object({ id: z.string().min(1), occurredAt: isoDateTime, antigenSlug: z.string().nullable().optional(), vaccineName: z.string().nullable().optional(), doseNumber: z.number().int().nullable().optional(), seriesDoses: z.number().int().nullable().optional(), lotNumber: z.string().nullable().optional(), site: z.enum(VaccinationSite).nullable().optional(), practitionerId: z.string().nullable().optional(), encounterId: z.string().nullable().optional(), // Remapped against the restored reminders (they travel since v1.37.20); // dropped to NULL, with the drop named, only when the file lacks the row. reminderId: z.string().nullable().optional(), noteEncrypted: base64BytesSchema.nullable().optional(), createdAt: isoDateTime, updatedAt: isoDateTime, deletedAt: isoDateTime.nullable().optional(), }) .passthrough(); const vaccinationLinkBackupSchema = z .object({ vaccinationId: z.string().min(1), targetId: z.string().min(1), createdAt: isoDateTime, }) .passthrough(); /** * One Vorsorge reminder, and one row of its completion ledger (v1.37.20, * #223 / iOS #68). * * `id` is required on both: an encounter, a vaccination record and every * ledger row address a reminder by it, and a ledger row is addressed by its * own id on re-insert. The enums are exactly the API's own — `origin` and * `kind` are the Prisma enums, and the ledger `source` is validated against * the engine's closed call-site set rather than accepted as free text, so a * file cannot smuggle a value the engine itself would never write. */ const measurementReminderBackupSchema = z .object({ id: z.string().min(1), label: z.string().min(1), measurementType: z.enum(MeasurementType).nullable().optional(), intervalDays: z.number().int().nullable().optional(), rrule: z.string().nullable().optional(), anchorDate: isoDateTime.nullable().optional(), endsOn: isoDateTime.nullable().optional(), origin: z.enum(ReminderOrigin).optional(), notifyHour: z.number().int().min(0).max(23).optional(), location: z.string().nullable().optional(), // Server-computed and restored verbatim — recomputing on restore would // move a due date the person had already been shown. nextDueAt: isoDateTime.nullable().optional(), lastSatisfiedAt: isoDateTime.nullable().optional(), enabled: z.boolean().optional(), vaccinationAntigen: z.string().nullable().optional(), snoozedUntil: isoDateTime.nullable().optional(), lastSkippedAt: isoDateTime.nullable().optional(), skipCount: z.number().int().min(0).optional(), createdAt: isoDateTime, updatedAt: isoDateTime, deletedAt: isoDateTime.nullable().optional(), }) .passthrough(); const measurementReminderEventBackupSchema = z .object({ id: z.string().min(1), reminderId: z.string().min(1), kind: z.enum(MeasurementReminderEventKind), occurredAt: isoDateTime, onTime: z.boolean(), source: z.enum(REMINDER_EVENT_SOURCES), createdAt: isoDateTime, }) .passthrough(); /** * One personal best. * * `direction` is required rather than defaulted: it says whether higher or * lower wins, the read path orders by it, and a best time defaulted to MAX * would present the account's worst time as its record. `sourceMeasurementId` * is the provenance pointer the restore resolves, and a real foreign key in the * database, whatever `prisma/schema.prisma` says, so the restore nulls what it * cannot resolve rather than letting Postgres refuse the whole file. */ const personalRecordBackupSchema = z .object({ metricType: z.enum(MeasurementType), metricSlot: z.string().nullable().optional(), direction: z.enum(PersonalRecordDirection), value: z.number(), unit: z.string().min(1), achievedAt: isoDateTime, sourceMeasurementId: z.string().nullable().optional(), source: z.enum(MeasurementSource).optional(), externalId: z.string().nullable().optional(), createdAt: isoDateTime.optional(), }) .passthrough(); /** * One unlocked badge. * * `achievementId` is a free string on purpose: it names a definition in the * code catalogue, which drifts between the release that wrote the file and the * one that reads it, and a row is evidence that a person earned something on a * day rather than a claim about what this build ships. * * `unlockedAt` is REQUIRED, unlike almost every other stamp in this file. It * is the field that cannot be recovered from anywhere else, and a file that * does not state it must fail rather than restore every badge to today. */ const userAchievementBackupSchema = z .object({ achievementId: z.string().min(1), unlockedAt: isoDateTime, createdAt: isoDateTime.optional(), }) .passthrough(); /** `YYYY-MM-DD`, the day key both environment tables are addressed by. */ const environmentDayKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, { message: "Expected a YYYY-MM-DD day key", }); /** * One day's environmental reading. * * `source` is required: it is the only record that the day resolved somewhere * other than the home city, and a defaulted value would re-attribute a trip to * home. Every weather column is optional, because the feed is allowed to be partial * and a file written before a column existed says nothing about it. */ const environmentContextBackupSchema = z .object({ date: environmentDayKey, lat: z.number(), lon: z.number(), locationLabel: z.string(), source: z.enum(EnvironmentLocationSource), tempMin: z.number().nullable().optional(), tempMax: z.number().nullable().optional(), tempMean: z.number().nullable().optional(), apparentMean: z.number().nullable().optional(), sunshineSec: z.number().int().nullable().optional(), daylightSec: z.number().int().nullable().optional(), precipSum: z.number().nullable().optional(), pressureMean: z.number().nullable().optional(), pressureDelta: z.number().nullable().optional(), humidityMean: z.number().nullable().optional(), cloudMean: z.number().nullable().optional(), weatherCode: z.number().int().nullable().optional(), fetchedAt: isoDateTime.optional(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), }) .passthrough(); /** * One declared stretch spent away from home. Both bounds are day keys rather * than instants: the resolver compares them lexicographically against other * day keys, and routing one through a `Date` is how a day key loses a day. */ const environmentTravelLocationBackupSchema = z .object({ startDate: environmentDayKey, endDate: environmentDayKey, lat: z.number(), lon: z.number(), label: z.string(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), }) .passthrough(); /** * One Coach turn. * * `contentEncrypted` and `content` are the two ends of the same contract, so * both are optional here and exactly one arrives: a disaster-recovery file * carries the ciphertext, a portable file carries the prose. Requiring either * would refuse half the valid files; requiring both would refuse all of them. * The restore picks whichever it was handed. */ const coachMessageBackupSchema = z .object({ id: z.string().min(1), role: z.string().min(1), contentEncrypted: base64BytesSchema.optional(), content: z.string().optional(), metricSourceJson: z.string().nullable().optional(), providerType: z.string().nullable().optional(), promptVersion: z.string().nullable().optional(), tokensUsed: z.number().int().nullable().optional(), model: z.string().nullable().optional(), createdAt: isoDateTime, }) .passthrough(); const coachConversationDocumentBackupSchema = z .object({ documentId: z.string().min(1), addedAt: isoDateTime, }) .passthrough(); const coachConversationBackupSchema = z .object({ id: z.string().min(1), title: z.string(), // NOT optional with a default. The fence is permanent and a file that does // not state it is a file that cannot be trusted to re-fence the // conversation, so the absence has to be visible rather than defaulted to // the permissive value. documentScoped: z.boolean(), summaryEncrypted: base64BytesSchema.nullable().optional(), summary: z.string().nullable().optional(), summaryUpdatedAt: isoDateTime.nullable().optional(), summaryTurnCount: z.number().int().min(0).optional(), createdAt: isoDateTime, updatedAt: isoDateTime, messages: z.array(coachMessageBackupSchema).default([]), attachments: z.array(coachConversationDocumentBackupSchema).default([]), }) .passthrough(); /** * The Coach's memory. `deletedAt` is optional rather than nullable-required: * a portable file omits tombstoned rows entirely and never states the field, * while a disaster-recovery file always does. */ const coachFactBackupSchema = z .object({ id: z.string().min(1), factEncrypted: base64BytesSchema.optional(), fact: z.string().optional(), category: z.string().min(1), confidence: z.number().int().min(0).max(100).optional(), sourceConversationId: z.string().nullable().optional(), createdAt: isoDateTime, updatedAt: isoDateTime, deletedAt: isoDateTime.nullable().optional(), }) .passthrough(); const coachPlanBackupSchema = z .object({ id: z.string().min(1), metric: z.string().min(1), ifCueEncrypted: base64BytesSchema.optional(), ifCue: z.string().optional(), thenActionEncrypted: base64BytesSchema.optional(), thenAction: z.string().optional(), targetEncrypted: base64BytesSchema.nullable().optional(), target: z.string().nullable().optional(), outcomeEncrypted: base64BytesSchema.nullable().optional(), outcome: z.string().nullable().optional(), status: z.string().optional(), reviewDate: isoDateTime.nullable().optional(), sourceConversationId: z.string().nullable().optional(), createdAt: isoDateTime, updatedAt: isoDateTime, deletedAt: isoDateTime.nullable().optional(), }) .passthrough(); const coachReminderBackupSchema = z .object({ id: z.string().min(1), noteEncrypted: base64BytesSchema.optional(), note: z.string().optional(), metric: z.string().nullable().optional(), relatedPlanId: z.string().nullable().optional(), triggerKind: z.string().optional(), dueAt: isoDateTime.nullable().optional(), contextCue: z.string().nullable().optional(), status: z.string().optional(), source: z.string().min(1), sourceConversationId: z.string().nullable().optional(), lastSurfacedAt: isoDateTime.nullable().optional(), surfaceCount: z.number().int().min(0).optional(), createdAt: isoDateTime, updatedAt: isoDateTime, deletedAt: isoDateTime.nullable().optional(), }) .passthrough(); /** One document filed against one condition episode. */ const documentConditionLinkBackupSchema = z .object({ documentId: z.string().min(1), episodeId: z.string().min(1), createdAt: isoDateTime, }) .passthrough(); /** * One fact the extraction pass staged against a document. * * `status`, `confidence` and `needsReview` are REQUIRED rather than defaulted, * unlike most optional-looking fields in this file. Each has a schema default * that means "nobody has looked at this yet", and the confirm endpoint acts on * a PENDING fact by committing it into the structured store — so a file that * does not state the review decision must fail to parse rather than have one * invented for it and let an already-committed reading be approved twice. * * `dataEncrypted` / `data` and their provenance siblings are the two ends of * the same contract, so all four are optional and exactly one pair arrives: a * disaster-recovery file carries the ciphertext, a portable file carries the * decrypted JSON. */ const extractedFactBackupSchema = z .object({ id: z.string().min(1), documentId: z.string().min(1), factType: z.enum(ExtractedFactType), status: z.enum(ExtractedFactStatus), confidence: z.number(), needsReview: z.boolean(), committedRecordId: z.string().nullable().optional(), committedRecordType: z.string().nullable().optional(), dataEncrypted: base64BytesSchema.optional(), provenanceEncrypted: base64BytesSchema.optional(), data: z.json().optional(), provenance: z.json().optional(), createdAt: isoDateTime, updatedAt: isoDateTime, }) .passthrough(); /** * A grouping the account created for its own mood factors. `id` is REQUIRED, * unlike everywhere else in this file, because a custom tag addresses its * category by that id and nothing else can resolve the pairing. */ const customMoodTagCategorySchema = z .object({ id: z.string().min(1), key: z.string().min(1), labelKey: z.string().min(1), icon: z.string().nullable().optional(), sortOrder: z.number().int().optional(), isActive: z.boolean().optional(), labelEncrypted: z.string().nullable().optional(), }) .passthrough(); /** A tag the account hid, named by key so a seeded tag resolves anywhere. */ const hiddenMoodTagSchema = z .object({ key: z.string().min(1), createdAt: isoDateTime.optional(), }) .passthrough(); /** * A completed screener administration. `responsesEncrypted` is REQUIRED rather * than optional, unlike every other ciphertext field in this file: the column * is NOT NULL, so a file that omits it describes a row that cannot be written, * and saying so at the schema is better than discovering it inside the * transaction that has already wiped the account. */ const mentalHealthAssessmentBackupSchema = z .object({ id: z.string().min(1), instrument: z.enum(AssessmentInstrument), locale: z.string().min(1), version: z.string().optional(), responsesEncrypted: base64BytesSchema, totalScore: z.number().int(), severityBand: z.string().min(1), item9Flagged: z.boolean().optional(), crisisShownAt: isoDateTime.nullable().optional(), takenAt: isoDateTime, tz: z.string().nullable().optional(), source: z.string().optional(), externalId: z.string().nullable().optional(), syncVersion: z.number().int().optional(), deletedAt: isoDateTime.nullable().optional(), createdAt: isoDateTime, updatedAt: isoDateTime, }) .passthrough(); const consentReceiptBackupSchema = z .object({ id: z.string().min(1), kind: z.string().min(1), artefact: z.string().min(1), signedAt: isoDateTime, revokedAt: isoDateTime.nullable().optional(), createdAt: isoDateTime.optional(), }) .passthrough(); const allergyBackupSchema = z .object({ id: z.string().min(1), substance: z.string().min(1), category: z.enum(AllergyCategory), type: z.enum(AllergyType), severity: z.enum(AllergySeverity).nullable().optional(), status: z.enum(AllergyStatus), onsetAt: isoDateTime.nullable().optional(), reaction: z.string().nullable().optional(), note: z.string().nullable().optional(), createdAt: isoDateTime.optional(), reactionEncrypted: base64BytesSchema.nullable().optional(), notesEncrypted: base64BytesSchema.nullable().optional(), deletedAt: isoDateTime.nullable().optional(), updatedAt: isoDateTime.optional(), }) .passthrough(); const familyHistoryBackupSchema = z .object({ id: z.string().min(1), relationship: z.enum(FamilyRelationship), condition: z.string().min(1), ageAtOnset: z.number().int().nullable().optional(), note: z.string().nullable().optional(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), }) .passthrough(); const workoutBackupSchema = z .object({ id: z.string().min(1).optional(), sportType: z.string().min(1), startedAt: isoDateTime, endedAt: isoDateTime, durationSec: z.number().int(), totalEnergyKcal: z.number().nullable().optional(), totalDistanceM: z.number().nullable().optional(), avgHeartRate: z.number().int().nullable().optional(), maxHeartRate: z.number().int().nullable().optional(), minHeartRate: z.number().int().nullable().optional(), stepCount: z.number().int().nullable().optional(), elevationM: z.number().nullable().optional(), pauseDurationSec: z.number().int().nullable().optional(), source: z.enum(MeasurementSource), externalId: z.string().nullable().optional(), metadata: z.json().optional(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), }) .passthrough(); const documentBackupSchema = z .object({ id: z.string().min(1), kind: z.enum(InboundDocumentKind), title: z.string().nullable().optional(), filename: z.string().nullable().optional(), mimeType: z.string().min(1), byteSize: z.number().int(), status: z.enum(InboundDocumentStatus), reportDate: z.string().nullable().optional(), documentDate: z.string().nullable().optional(), contentEncrypted: base64BytesSchema.optional(), contentSha256: z .string() .regex(/^[a-f0-9]{64}$/) .nullable() .optional(), contentCodec: z.string().min(1).optional(), providerType: z.string().nullable().optional(), errorReason: z.string().nullable().optional(), summaryEncrypted: base64BytesSchema.nullable().optional(), summaryGeneratedAt: isoDateTime.nullable().optional(), summaryState: z.enum(DocumentSummaryState).optional(), // Refs #776 — the index-attempt record (canonical DR payloads only). // Free string on purpose: a future outcome value must not fail a restore. lastIndexAttemptAt: isoDateTime.nullable().optional(), lastIndexOutcome: z.string().nullable().optional(), summary: z.string().nullable().optional(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), }) .passthrough(); /** * One ECG strip. * * `waveformEncrypted` and `waveform` are the two ends of the same contract, so * both are optional here and exactly one arrives: a disaster-recovery file * carries the ciphertext, a portable file carries the micro-volt samples. * Requiring either would refuse half the valid files. `samplingFrequency` and * `sampleCount` stay required in both, because without them the samples are a * list of numbers with no time axis. */ const ecgRecordingBackupSchema = z .object({ id: z.string().min(1).optional(), source: z.enum(MeasurementSource), externalRecordingId: z.string().min(1), recordedAt: isoDateTime, waveformEncrypted: base64BytesSchema.optional(), waveform: z.array(z.number()).optional(), samplingFrequency: z.number().int(), sampleCount: z.number().int(), durationSeconds: z.number().nullable().optional(), lead: z.string().nullable().optional(), averageHeartRate: z.number().int().nullable().optional(), rhythmClassification: z.enum(RhythmClassification).nullable().optional(), measurementId: z.string().min(1).nullable().optional(), createdAt: isoDateTime, updatedAt: isoDateTime, }) .passthrough(); const backupManifestSchema = z .object({ documents: z .object({ included: z.string().min(1), note: z.string().min(1) }) .passthrough(), workouts: z .object({ included: z.string().min(1), note: z.string().min(1) }) .passthrough(), }) .passthrough(); /** * Wire shape — exactly what the pg-boss worker writes today, plus a * `schemaVersion` field that newer writers stamp explicitly. Older blobs * without the field default to v1 in `parseBackupPayload`. */ export const backupPayloadSchema = z .object({ schemaVersion: z.string().min(1).default(LEGACY_BACKUP_SCHEMA_VERSION), exportedAt: isoDateTime, userId: z.string().min(1), appSettings: appSettingsBackupSchema.nullable().default(null), measurements: z.array(measurementSchema).default([]), medications: z.array(medicationSchema).default([]), intakeEvents: z.array(intakeEventSchema).default([]), moodEntries: z.array(moodEntrySchema).default([]), // v1.15.0 — cycle-tracking tables. Default to empty arrays / null so a // pre-v1.15 backup (no cycle keys) still round-trips unchanged. cycleProfile: cycleProfileSchema.nullable().default(null), cycles: z.array(cycleSpanSchema).default([]), cycleDayLogs: z.array(cycleDayLogSchema).default([]), // Defaulted rather than required: files written before this field existed // are still valid, and an account with no custom symptoms writes []. customSymptoms: z.array(customCycleSymptomSchema).default([]), customMoodTagCategories: z.array(customMoodTagCategorySchema).default([]), hiddenMoodTags: z.array(hiddenMoodTagSchema).default([]), customMoodTags: z.array(customMoodTagSchema).default([]), // Structured records default to empty arrays so older backups remain // parseable. Canonical DR writers add stable ids and encrypted document // fields; portable exports retain the metadata-only subset. labResults: z.array(labResultBackupSchema).default([]), biomarkers: z.array(biomarkerBackupSchema).default([]), illnessEpisodes: z.array(illnessEpisodeBackupSchema).default([]), allergies: z.array(allergyBackupSchema).default([]), familyHistory: z.array(familyHistoryBackupSchema).default([]), workouts: z.array(workoutBackupSchema).default([]), documents: z.array(documentBackupSchema).default([]), // What a document was filed against, and what was read out of it. Defaulted // for the same reason as the sections below: a file written before the // vault filing travelled carries no key, and an unsorted vault writes []. documentConditionLinks: z .array(documentConditionLinkBackupSchema) .default([]), extractedFacts: z.array(extractedFactBackupSchema).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), healthProfileFacts: z.array(healthProfileFactBackupSchema).default([]), customMetrics: z.array(customMetricBackupSchema).default([]), correlationPatterns: z.array(correlationPatternBackupSchema).default([]), // The hourly shape of a cumulative day. Defaulted for the same reason as // the pair above: a file written before the table existed carries no key. intradayProfiles: z.array(intradayProfileBackupSchema).default([]), // The score as it was shown, day by day. Defaulted for the same reason as // the sections above: a file written before the table existed carries no // key, and an account whose score never resolved writes []. healthScoreRecords: z.array(healthScoreRecordBackupSchema).default([]), // The needs-based setup answers. Defaulted to null for the same reason as // the sections above: a file written before the table existed carries no // key, and a record that never entered the flow has no row. onboardingRecord: onboardingRecordBackupSchema.nullable().default(null), // Visits, the address book behind them, and the three link tables. // Defaulted for the same reason as the sections above: a file written // before the tables existed carries no key, and an account that has never // filed a visit writes []. practitioners: z.array(practitionerBackupSchema).default([]), encounters: z.array(encounterBackupSchema).default([]), encounterDocumentLinks: z.array(encounterLinkBackupSchema).default([]), encounterLabLinks: z.array(encounterLinkBackupSchema).default([]), encounterConditionLinks: z.array(encounterLinkBackupSchema).default([]), // The immunization log and the pages it was transcribed from. Defaulted // for the same reason as the sections above: a file written before the // tables existed carries no key, and an account with an empty Impfpass // writes []. vaccinations: z.array(vaccinationBackupSchema).default([]), vaccinationDocumentLinks: z.array(vaccinationLinkBackupSchema).default([]), // The Vorsorge reminders and their completion ledger (v1.37.20, #223 / // iOS #68). Defaulted for the same reason as the sections above: a file // written before the reminders travelled carries no key, and an account // with none writes []. coachConversations: z.array(coachConversationBackupSchema).default([]), mentalHealthAssessments: z .array(mentalHealthAssessmentBackupSchema) .default([]), consentReceipts: z.array(consentReceiptBackupSchema).default([]), coachFacts: z.array(coachFactBackupSchema).default([]), coachPlans: z.array(coachPlanBackupSchema).default([]), coachReminders: z.array(coachReminderBackupSchema).default([]), measurementReminders: z.array(measurementReminderBackupSchema).default([]), measurementReminderEvents: z .array(measurementReminderEventBackupSchema) .default([]), // The bests and the badges. Defaulted for the same reason as the sections // above: a file written before they travelled carries no key, and an // account that has earned neither writes []. personalRecords: z.array(personalRecordBackupSchema).default([]), userAchievements: z.array(userAchievementBackupSchema).default([]), // The per-day readings and the location periods that explain them. Two // keys rather than one because they are two tables, but they are written // and restored as a pair: readings whose periods are missing get rewritten // to the home location by the next refresh. environmentContexts: z.array(environmentContextBackupSchema).default([]), environmentTravelLocations: z .array(environmentTravelLocationBackupSchema) .default([]), // The ECG strips. Defaulted for the same reason as the sections above: a // file written before the recordings travelled carries no key, and an // account whose watch has never taken one writes []. ecgRecordings: z.array(ecgRecordingBackupSchema).default([]), manifest: backupManifestSchema.nullable().default(null), // v1.37.19 (A6-9) — field paths a PORTABLE export could not decrypt // (fail-soft nulls). Disclosed in the file so a nulled field is // distinguishable from one never written. Empty/absent on DR payloads. decryptFailures: z.array(z.string()).default([]), }) .passthrough() .superRefine((payload, ctx) => { if (payload.schemaVersion !== BACKUP_SCHEMA_VERSION) return; payload.measurements.forEach((measurement, index) => { if (!measurement.id) { ctx.addIssue({ code: "custom", path: ["measurements", index, "id"], message: "Canonical v2 measurements require a stable id", }); } }); }); export type BackupPayload = z.infer; /** * One element of `measurements`, validated on its own. The streamed readers * (`src/lib/export/streamed-backup.ts`) check a large file's measurements * one at a time with this, and the rest of the file with * `backupPayloadSchema` over a document whose measurements are empty, which * together is the same check `backupPayloadSchema` makes of the whole file. */ export const backupMeasurementSchema = measurementSchema; export type BackupMeasurement = z.infer; /** * Numeric counts of each backed-up record kind. Returned in the * upload + restore API responses so the admin sees what they * uploaded/restored without having to download the file again. */ export interface BackupSummary { schemaVersion: string; userId: string; exportedAt: string; measurements: number; medications: number; intakeEvents: number; /** Side effects recorded against a drug, across every medication. */ medicationSideEffects: number; /** Pinned efficacy targets, across every medication. */ medicationEfficacyTargets: number; /** Archived schedule eras, across every medication. */ medicationScheduleRevisions: number; moodEntries: number; /** v1.15.0 — observed cycle spans in the backup. */ cycles: number; /** v1.15.0 — cycle day-logs in the backup. */ cycleDayLogs: number; /** Lab results in the backup. */ labResults: number; nutrientDays: number; /** User-scoped biomarker catalog entries in the backup. */ biomarkers: number; /** Illness episodes, including flares/exacerbations. */ illnessEpisodes: number; /** Illness day-logs across every episode. */ illnessDayLogs: number; /** Allergy/intolerance records in the backup. */ allergies: number; /** Family-history entries in the backup. */ familyHistory: number; /** Workout summary records in the backup. */ workouts: number; /** Document records (ciphertext included in canonical DR payloads). */ documents: number; /** Which documents were filed against which condition. */ documentConditionLinks: number; /** Facts staged out of a document, with their review decision. */ extractedFacts: number; /** 1 when the account's durable self-context rides the file, 0 otherwise. */ healthProfile: number; /** Effective-dated structured health-profile revisions. */ healthProfileFactRevisions: number; /** Metrics the account defined itself. */ customMetrics: number; /** Readings across every user-defined metric. */ customMetricEntries: number; /** Persisted accepted correlation identities and dismissal decisions. */ correlationPatterns: number; /** Stored day curves for the cumulative metrics, across every metric. */ intradayProfiles: number; /** Local days whose health score was written down as it was shown. */ healthScoreRecords: number; /** 1 when the record's setup answers ride the file, 0 otherwise. */ onboardingRecords: number; /** v1.37.19 (A6-8) — the visit address book. */ practitioners: number; /** v1.37.19 (A6-8) — doctor visits, planned and past. */ encounters: number; /** v1.37.19 (A6-8) — the three encounter link tables, summed. */ encounterLinks: number; /** v1.37.19 (A6-8) — immunization log entries. */ vaccinations: number; /** v1.37.19 (A6-8) — vaccination↔document links. */ vaccinationLinks: number; /** v1.37.20 (#223 / iOS #68) — Vorsorge reminder cadences. */ measurementReminders: number; /** v1.37.20 (#223 / iOS #68) — completion-ledger rows across every reminder. */ measurementReminderEvents: number; /** Coach conversation threads. */ coachConversations: number; /** Coach turns across every thread. */ coachMessages: number; /** Durable facts, agreed plans and things to bring back up. */ coachFacts: number; coachPlans: number; coachReminders: number; /** Completed screener administrations. Disaster-recovery payloads only. */ mentalHealthAssessments: number; /** Consent records. Disaster-recovery payloads only. */ consentReceipts: number; /** Personal bests across every metric and sport slot. */ personalRecords: number; /** Badges the account has unlocked, each with the day it was earned. */ userAchievements: number; /** Local days with an environmental reading. */ environmentContexts: number; /** Declared stretches spent away from home. */ environmentTravelLocations: number; /** ECG strips, with their rhythm verdict and their trace. */ ecgRecordings: number; } export function summarizeBackup(payload: BackupPayload): BackupSummary { return { schemaVersion: payload.schemaVersion, userId: payload.userId, exportedAt: payload.exportedAt, measurements: payload.measurements.length, medications: payload.medications.length, intakeEvents: payload.intakeEvents.length, medicationSideEffects: payload.medications.reduce( (sum, medication) => sum + medication.sideEffects.length, 0, ), medicationEfficacyTargets: payload.medications.reduce( (sum, medication) => sum + medication.efficacyTargets.length, 0, ), medicationScheduleRevisions: payload.medications.reduce( (sum, medication) => sum + medication.scheduleRevisions.length, 0, ), moodEntries: payload.moodEntries.length, cycles: payload.cycles.length, cycleDayLogs: payload.cycleDayLogs.length, nutrientDays: payload.nutrientDays.length, labResults: payload.labResults.length, biomarkers: payload.biomarkers.length, illnessEpisodes: payload.illnessEpisodes.length, illnessDayLogs: payload.illnessEpisodes.reduce( (sum, e) => sum + e.dayLogs.length, 0, ), allergies: payload.allergies.length, familyHistory: payload.familyHistory.length, workouts: payload.workouts.length, documents: payload.documents.length, // Counted from the release that carries them, so the admin's "what did I // just restore" answer never under-counts a filed vault the way it once // did for visits. documentConditionLinks: payload.documentConditionLinks.length, extractedFacts: payload.extractedFacts.length, healthProfile: payload.healthProfile ? 1 : 0, healthProfileFactRevisions: payload.healthProfileFacts.length, customMetrics: payload.customMetrics.length, customMetricEntries: payload.customMetrics.reduce( (sum, metric) => sum + metric.entries.length, 0, ), correlationPatterns: payload.correlationPatterns.length, intradayProfiles: payload.intradayProfiles.length, healthScoreRecords: payload.healthScoreRecords.length, onboardingRecords: payload.onboardingRecord ? 1 : 0, // v1.37.19 (A6-8) — the sections restored since 08-01 were written and // restored but absent from this report, so the admin's "what did I just // restore" answer silently under-counted a file that carried visits or // an Impfpass. practitioners: payload.practitioners.length, encounters: payload.encounters.length, encounterLinks: payload.encounterDocumentLinks.length + payload.encounterLabLinks.length + payload.encounterConditionLinks.length, vaccinations: payload.vaccinations.length, vaccinationLinks: payload.vaccinationDocumentLinks.length, // v1.37.20 (#223 / iOS #68) — counted from the release that carries them, // so the admin's "what did I just restore" answer never under-counts a // file with reminders the way it once did for visits. measurementReminders: payload.measurementReminders.length, measurementReminderEvents: payload.measurementReminderEvents.length, coachConversations: payload.coachConversations.length, coachMessages: payload.coachConversations.reduce( (total, conversation) => total + conversation.messages.length, 0, ), coachFacts: payload.coachFacts.length, coachPlans: payload.coachPlans.length, coachReminders: payload.coachReminders.length, mentalHealthAssessments: payload.mentalHealthAssessments.length, consentReceipts: payload.consentReceipts.length, // Counted from the release that carries them, so the admin's "what did I // just restore" answer never under-counts a file with bests, badges or an // environmental history the way it once did for visits. personalRecords: payload.personalRecords.length, userAchievements: payload.userAchievements.length, environmentContexts: payload.environmentContexts.length, environmentTravelLocations: payload.environmentTravelLocations.length, ecgRecordings: payload.ecgRecordings.length, }; } /** * Parse a JSON blob (string or already-parsed object) against * `backupPayloadSchema`, returning a typed payload. Throws ZodError on * mismatch — the admin route catches it and turns it into a 422 with a * field-level error list. * * Accepts both forms because: * - the upload route hands us the parsed object after `await req.text()` * - the restore route reads `DataBackup.data` (decrypted) which is * always a JSON string straight from the worker. */ export function parseBackupPayload(input: string | unknown): BackupPayload { const raw = typeof input === "string" ? JSON.parse(input) : input; return backupPayloadSchema.parse(raw); } /** * The schemaVersion the system understands today. Used by the upload * route to reject inbound files written by a *future* HealthLog instance * — restoring them under the current code might silently drop data the * new shape carried. */ export function isCompatibleSchemaVersion(version: string): boolean { return ( version === LEGACY_BACKUP_SCHEMA_VERSION || version === BACKUP_SCHEMA_VERSION ); }