/** * 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, FamilyRelationship, FlowLevel, GlucoseContext, HomeTestResult, IllnessLifecycle, IllnessType, InboundDocumentKind, InboundDocumentStatus, InjectionSite, IntakeAttributionSource, IntakeSource, MeasurementAggregationProvenance, MeasurementSource, MeasurementType, MedicationCategory, MedicationDeliveryForm, MedicationScheduleType, MedicationSideEffectCategory, MedicationSideEffectEntry, OvulationTest, 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"; 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(); 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(), 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([]), }) .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(), }) .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(); 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(), assistantCorrelationsEnabled: z.boolean(), 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(), // Carried so the loss is visible in the file. The restore resolves it to // NULL and names the drop: the reminder it points at does not travel. 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(), // Carried so the loss is visible in the file. The restore resolves it to // NULL and names the drop: the reminder it points at does not travel. 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(); 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(); 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([]), 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([]), 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([]), // 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([]), manifest: backupManifestSchema.nullable().default(null), }) .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; /** * 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; 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; /** 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; } 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, ), 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, 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, }; } /** * 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 ); }