Files
HealthLog/src/lib/validations/backup.ts
T
Marc-André Bombeck 772da42348 feat(backup): carry emergency profile through rotation and backup
Register the three encrypted emergency columns in the rotation registry
and the key-rotation script so a legacy-key drop cannot strand them.
Carry all six new columns through the profile backup builder and restore
(the enums by value, the free text as ciphertext in a DR payload or
decrypted plaintext in a portable export) and extend the wire schema.
The full-backup round-trip integration test seeds the six columns and
asserts each survives an export and restore.
2026-08-10 05:32:59 +02:00

1241 lines
49 KiB
TypeScript

/**
* 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(),
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(),
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<typeof backupPayloadSchema>;
/**
* 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
);
}