/** * 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, FamilyRelationship, FlowLevel, GlucoseContext, HomeTestResult, IllnessLifecycle, IllnessType, InboundDocumentKind, InboundDocumentStatus, InjectionSite, IntakeAttributionSource, IntakeSource, MeasurementSource, MeasurementType, MedicationCategory, MedicationDeliveryForm, MedicationScheduleType, OvulationTest, RhythmClassification, SecondarySymptom, SleepStage, } from "@/generated/prisma/enums"; 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(), 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(), 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(); 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([]), }) .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), tags: moodEntryTagsSchema, source: z.string().min(1).optional(), externalId: z.string().nullable().optional(), loggedAt: isoDateTime, deletedAt: isoDateTime.nullable().optional(), factors: z.array(moodFactorSchema).default([]), }) .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. */ 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([]), }) .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(); 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(), moodLogGlobal: 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(), assistantHealthScoreExplainerEnabled: 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(), 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(); 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(), 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(), 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([]), // 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([]), 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; 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; /** 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; } 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, moodEntries: payload.moodEntries.length, cycles: payload.cycles.length, cycleDayLogs: payload.cycleDayLogs.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, }; } /** * 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 ); }