mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
1241 lines
49 KiB
TypeScript
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
|
|
);
|
|
}
|