fix(backups): preserve canonical restore identity

This commit is contained in:
Marc-André Bombeck committed 2026-07-20 13:25:37 +02:00
1 parent 8b7cbddd72
commit cd6b5f0cc6
10 files changed
+1722 -215

No files matched your search

@@ -1,4 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { Buffer } from "node:buffer";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
process.env.ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const mocks = vi.hoisted(() => ({
findMeasurementsPaged: vi.fn(),
@@ -29,11 +34,132 @@ vi.mock("@/lib/export/records-backup", () => ({
import { buildFullBackupPayload } from "../full-backup-payload";
const deletedAt = new Date("2026-07-19T12:00:00.000Z");
const measurementNote = encryptToBytes("canonical measurement note");
const appSettings = {
id: "singleton",
registrationEnabled: false,
mfaRequired: true,
defaultLocale: "en",
telegramGlobal: false,
ntfyGlobal: false,
webPushGlobal: false,
webPushVapidPublicKey: "public-key",
webPushVapidPrivateKeyEncrypted: "encrypted-private-key",
webPushVapidSubject: "mailto:ops@example.test",
apiGlobal: false,
moodLogGlobal: false,
umamiEnabled: true,
umamiScriptUrl: "https://analytics.example.test/script.js",
umamiWebsiteId: "website-id",
glitchtipEnabled: true,
glitchtipDsn: "https://dsn.example.test/1",
glitchtipEnvironment: "recovery",
reminderLateMinutes: 45,
reminderMissedMinutes: 90,
adminAiKeyEncrypted: "encrypted-ai-key",
adminAiModel: "gpt-test",
adminAiBaseUrl: "https://ai.example.test/v1",
adminCodexAccessTokenEncrypted: "encrypted-access",
adminCodexRefreshTokenEncrypted: "encrypted-refresh",
adminCodexAccountIdEncrypted: "encrypted-account",
adminCodexTokenExpiresAt: new Date("2026-07-21T08:00:00.000Z"),
adminCodexConnectedAt: new Date("2026-07-20T08:00:00.000Z"),
adminCodexConnectionStatus: "connected",
adminAiInsightsFeedbackSummary: { helpful: 7, total: 9 },
defaultUserTimezone: "Europe/London",
assistantEnabled: false,
assistantCoachEnabled: false,
assistantBriefingEnabled: false,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: false,
assistantHealthScoreExplainerEnabled: false,
moduleAvailabilityJson: { nutrients: false, insights: true },
documentMaxFileBytes: 12_345_678,
documentQuotaBytes: 9_876_543_210n,
};
function makePrisma() {
return {
medication: { findMany: vi.fn().mockResolvedValue([]) },
medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) },
appSettings: { findUnique: vi.fn().mockResolvedValue(appSettings) },
medication: {
findMany: vi.fn().mockResolvedValue([
{
id: "medication-canonical",
name: "Example",
dose: "2 mg",
treatmentClass: "GENERIC",
dosesPerUnit: 4,
unitsPerDose: { toString: () => "0.5000" },
active: false,
notificationsEnabled: false,
pausedAt: new Date("2026-07-18T10:00:00.000Z"),
snoozedUntil: new Date("2026-07-22T10:00:00.000Z"),
startsOn: new Date("2026-07-01T00:00:00.000Z"),
endsOn: new Date("2026-08-01T00:00:00.000Z"),
oneShot: false,
asNeeded: false,
deliveryForm: "INJECTION",
trackInjectionSites: true,
allowedInjectionSites: ["THIGH_LEFT", "THIGH_RIGHT"],
liveActivityEnabled: true,
criticalAlarmEnabled: true,
atcCode: "A10BX10",
rxNormCode: "12345",
lowStockNotifiedAt: new Date("2026-07-17T10:00:00.000Z"),
lowStockNotifiedThresholdDays: 7,
reorderLeadDays: 3,
externalSource: "APPLE_HEALTH",
externalId: "med-external",
createdAt: new Date("2026-06-01T10:00:00.000Z"),
updatedAt: new Date("2026-07-19T10:00:00.000Z"),
schedules: [
{
id: "schedule-canonical",
windowStart: "08:00",
windowEnd: "10:00",
label: "Morning",
dose: "1 mg",
daysOfWeek: "i2;1,3,5",
timesOfDay: ["08:00", "20:00"],
reminderGraceMinutes: 90,
rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR",
rollingIntervalDays: null,
scheduleType: "CYCLIC",
cyclicOnWeeks: 3,
cyclicOffWeeks: 1,
doseWindows: [
{ timeOfDay: "08:00", start: "07:30", end: "09:00" },
],
},
],
},
]),
},
medicationIntakeEvent: {
findMany: vi.fn().mockResolvedValue([
{
id: "intake-canonical",
medicationId: "medication-canonical",
medication: { name: "Example" },
scheduledFor: new Date("2026-07-19T08:00:00.000Z"),
takenAt: new Date("2026-07-19T08:05:00.000Z"),
skipped: false,
autoMissed: false,
attributionSource: "USER_PIN",
source: "APPLE_HEALTH",
idempotencyKey: "intake-idempotency",
createdAt: new Date("2026-07-19T08:05:00.000Z"),
injectionSite: "THIGH_LEFT",
doseTaken: "1 mg",
inventoryConsumption: [{ itemId: "pen-1", units: 0.5 }],
externalId: "intake-external",
updatedAt: new Date("2026-07-19T08:06:00.000Z"),
syncVersion: 4,
deletedAt,
},
]),
},
moodEntry: {
findMany: vi.fn().mockResolvedValue([
{
@@ -54,17 +180,29 @@ function makePrisma() {
}
function installSectionMocks() {
void measurementNote;
mocks.findMeasurementsPaged.mockResolvedValue([
{
id: "measurement-tombstone",
type: "WEIGHT",
type: "SLEEP_DURATION",
value: 75,
unit: "kg",
valueMin: 60,
valueMax: 90,
unit: "minutes",
measuredAt: new Date("2026-07-19T07:00:00.000Z"),
source: "MANUAL",
source: "IMPORT",
notes: null,
notesEncrypted: null,
notesEncrypted: measurementNote,
externalId: "oura-stage-deep",
externalSourceVersion: "oura-v3",
glucoseContext: null,
sleepStage: "DEEP",
rhythmClassification: null,
deviceType: "ring",
syncVersion: 7,
deletedAt,
createdAt: new Date("2026-07-19T07:01:00.000Z"),
updatedAt: new Date("2026-07-19T07:02:00.000Z"),
},
]);
mocks.buildCycleBackupSection.mockResolvedValue({
@@ -118,14 +256,63 @@ describe("buildFullBackupPayload disaster-recovery mode", () => {
{ purpose: "disaster-recovery" },
);
expect(payload).toMatchObject({
schemaVersion: "1",
schemaVersion: "2",
exportedAt: "2026-07-20T00:00:00.000Z",
appSettings: {
...appSettings,
adminCodexTokenExpiresAt:
appSettings.adminCodexTokenExpiresAt.toISOString(),
adminCodexConnectedAt: appSettings.adminCodexConnectedAt.toISOString(),
documentQuotaBytes: appSettings.documentQuotaBytes.toString(),
},
measurements: [
{
id: "measurement-tombstone",
type: "SLEEP_DURATION",
value: 75,
valueMin: 60,
valueMax: 90,
unit: "minutes",
measuredAt: "2026-07-19T07:00:00.000Z",
source: "IMPORT",
notesEncrypted: Buffer.from(measurementNote).toString("base64"),
externalId: "oura-stage-deep",
externalSourceVersion: "oura-v3",
glucoseContext: null,
sleepStage: "DEEP",
rhythmClassification: null,
deviceType: "ring",
syncVersion: 7,
deletedAt: deletedAt.toISOString(),
createdAt: "2026-07-19T07:01:00.000Z",
updatedAt: "2026-07-19T07:02:00.000Z",
},
],
medications: [
expect.objectContaining({
id: "medication-canonical",
asNeeded: false,
deliveryForm: "INJECTION",
unitsPerDose: "0.5000",
schedules: [
expect.objectContaining({
id: "schedule-canonical",
rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR",
scheduleType: "CYCLIC",
cyclicOnWeeks: 3,
cyclicOffWeeks: 1,
}),
],
}),
],
intakeEvents: [
expect.objectContaining({
id: "intake-canonical",
medicationId: "medication-canonical",
syncVersion: 4,
deletedAt: deletedAt.toISOString(),
}),
],
moodEntries: [
{
id: "mood-tombstone",
@@ -234,6 +234,166 @@ describe("buildRecordsBackupSection", () => {
expect(section.documents[0].summary).toBe("Routine panel.");
});
it("preserves every canonical lab, illness, day-log, and allergy field verbatim", async () => {
const prisma = buildMockPrisma();
const deletedAt = new Date("2026-04-13T12:00:00.000Z");
const createdAt = new Date("2026-04-10T01:00:00.000Z");
const updatedAt = new Date("2026-04-12T23:00:00.000Z");
const labNote = encryptNoteToBytes("Canonical lab note");
const illnessNote = encryptToBytes("Canonical illness note");
const dayNote = encryptToBytes("Canonical day note");
const reaction = encryptToBytes("Canonical reaction");
const allergyNote = encryptToBytes("Canonical allergy note");
prisma.labResult.findMany.mockResolvedValueOnce([
{
id: "lab-canonical",
panel: "Lipid panel",
analyte: "LDL",
value: 118,
valueText: null,
unit: "mg/dL",
referenceLow: null,
referenceHigh: 130,
takenAt: new Date("2026-04-01T09:00:00.000Z"),
biomarkerId: "biomarker-canonical",
biomarker: { name: "LDL Cholesterol" },
source: "IMPORT",
noteEncrypted: labNote,
createdAt,
updatedAt,
deletedAt,
},
]);
prisma.illnessEpisode.findMany.mockResolvedValueOnce([
{
id: "episode-canonical",
label: "Migraine",
type: "CHRONIC",
lifecycle: "CHRONIC_ONGOING",
onsetAt: new Date("2026-04-10T00:00:00.000Z"),
resolvedAt: null,
parentConditionId: null,
noteEncrypted: illnessNote,
createdAt,
updatedAt,
deletedAt,
dayLogs: [
{
id: "day-canonical",
episodeId: "episode-canonical",
date: "2026-04-11",
functionalImpact: 2,
feverC: 38.2,
noteEncrypted: dayNote,
tz: "Europe/London",
createdAt,
updatedAt,
deletedAt,
symptomLinks: [{ severity: 3, symptom: { key: "headache" } }],
},
],
},
]);
prisma.allergy.findMany.mockResolvedValueOnce([
{
id: "allergy-canonical",
substance: "Penicillin",
category: "MEDICATION",
type: "ALLERGY",
severity: "SEVERE",
status: "INACTIVE",
onsetAt: new Date("2020-01-01T00:00:00.000Z"),
reactionEncrypted: reaction,
notesEncrypted: allergyNote,
createdAt,
updatedAt,
deletedAt,
},
]);
prisma.biomarker.findMany.mockResolvedValueOnce([]);
prisma.familyHistoryEntry.findMany.mockResolvedValueOnce([]);
prisma.workout.findMany.mockResolvedValueOnce([]);
prisma.inboundDocument.findMany.mockResolvedValueOnce([]);
const section = await buildRecordsBackupSection(prisma as never, USER_ID, {
purpose: "disaster-recovery",
});
const episodeQuery = prisma.illnessEpisode.findMany.mock.calls.at(-1)?.[0];
expect(episodeQuery).toEqual(
expect.objectContaining({ where: { userId: USER_ID } }),
);
expect(episodeQuery.include.dayLogs).not.toHaveProperty("where");
expect(prisma.allergy.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: USER_ID } }),
);
expect(section.labResults[0]).toEqual({
id: "lab-canonical",
panel: "Lipid panel",
analyte: "LDL",
value: 118,
valueText: null,
unit: "mg/dL",
referenceLow: null,
referenceHigh: 130,
takenAt: "2026-04-01T09:00:00.000Z",
biomarkerId: "biomarker-canonical",
biomarkerName: "LDL Cholesterol",
source: "IMPORT",
note: null,
noteEncrypted: Buffer.from(labNote).toString("base64"),
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
deletedAt: deletedAt.toISOString(),
});
expect(section.illnessEpisodes[0]).toEqual({
id: "episode-canonical",
label: "Migraine",
type: "CHRONIC",
lifecycle: "CHRONIC_ONGOING",
onsetAt: "2026-04-10T00:00:00.000Z",
resolvedAt: null,
parentConditionId: null,
note: null,
noteEncrypted: Buffer.from(illnessNote).toString("base64"),
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
deletedAt: deletedAt.toISOString(),
dayLogs: [
{
id: "day-canonical",
episodeId: "episode-canonical",
date: "2026-04-11",
functionalImpact: 2,
feverC: 38.2,
note: null,
noteEncrypted: Buffer.from(dayNote).toString("base64"),
tz: "Europe/London",
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
deletedAt: deletedAt.toISOString(),
symptoms: [{ key: "headache", severity: 3 }],
},
],
});
expect(section.allergies[0]).toEqual({
id: "allergy-canonical",
substance: "Penicillin",
category: "MEDICATION",
type: "ALLERGY",
severity: "SEVERE",
status: "INACTIVE",
onsetAt: "2020-01-01T00:00:00.000Z",
reaction: null,
note: null,
reactionEncrypted: Buffer.from(reaction).toString("base64"),
notesEncrypted: Buffer.from(allergyNote).toString("base64"),
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
deletedAt: deletedAt.toISOString(),
});
});
it("threads an illness flare's parentConditionId and nests its day-log + symptom", async () => {
const prisma = buildMockPrisma();
const section = await buildRecordsBackupSection(prisma as never, USER_ID);
+174 -72
View File
@@ -10,6 +10,7 @@
* The decrypted notes are surfaced here (the backup is the human-readable
* artefact); an admin restore re-encrypts them on re-insert.
*/
import { Buffer } from "node:buffer";
import type { PrismaClient } from "@/generated/prisma/client";
import { readNote } from "@/lib/crypto/note-cipher";
import { findMeasurementsPaged } from "@/lib/export/paged-measurements";
@@ -50,90 +51,174 @@ export async function buildFullBackupPayload(
options: FullBackupOptions = {},
): Promise<FullBackupResult> {
const disasterRecovery = options.purpose === "disaster-recovery";
const [measurements, medications, intakeEvents, moodEntries, cycle, records] =
await Promise.all([
// v1.28.25 — keyset-paginated read with a narrow select. The backup
// is inherently whole-history, so on a CGM / per-sample-HR account the
// previous single full-width `findMany` materialised a six-figure row
// set in one shot. The chunked read accumulates the same rows in the
// same `measuredAt desc` order; the payload FORMAT below is untouched
// (restore compatibility). The select is exactly the fields the
// serializer reads: type / value / unit / measuredAt / source + the
// note columns `readNote` decrypts + the `id` cursor key.
findMeasurementsPaged(
prisma,
disasterRecovery ? { userId } : { userId, deletedAt: null },
{
id: true,
type: true,
value: true,
unit: true,
measuredAt: true,
source: true,
notes: true,
notesEncrypted: true,
deletedAt: true,
},
),
prisma.medication.findMany({
where: { userId },
select: {
name: true,
dose: true,
active: true,
schedules: {
select: {
windowStart: true,
windowEnd: true,
label: true,
dose: true,
},
const [
appSettings,
measurements,
medications,
intakeEvents,
moodEntries,
cycle,
records,
] = await Promise.all([
disasterRecovery
? prisma.appSettings.findUnique({ where: { id: "singleton" } })
: Promise.resolve(null),
findMeasurementsPaged(
prisma,
disasterRecovery ? { userId } : { userId, deletedAt: null },
{
id: true,
type: true,
value: true,
valueMin: true,
valueMax: true,
unit: true,
measuredAt: true,
source: true,
notes: true,
notesEncrypted: true,
externalId: true,
externalSourceVersion: true,
glucoseContext: true,
sleepStage: true,
rhythmClassification: true,
deviceType: true,
syncVersion: true,
deletedAt: true,
createdAt: true,
updatedAt: true,
},
),
prisma.medication.findMany({
where: { userId },
include: { schedules: true },
}),
prisma.medicationIntakeEvent.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
}),
prisma.moodEntry.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
include: {
tagLinks: {
where: { rating: { not: null } },
select: {
rating: true,
moodTag: { select: { key: true } },
},
},
}),
prisma.medicationIntakeEvent.findMany({
where: { userId, deletedAt: null },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
}),
prisma.moodEntry.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
include: {
tagLinks: {
where: { rating: { not: null } },
select: {
rating: true,
moodTag: { select: { key: true } },
},
},
},
}),
buildCycleBackupSection(prisma, userId),
buildRecordsBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
]);
},
}),
buildCycleBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
buildRecordsBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
]);
const payload = {
schemaVersion: BACKUP_SCHEMA_VERSION,
exportedAt: (options.exportedAt ?? new Date()).toISOString(),
userId,
measurements: measurements.map((m) => ({
id: m.id,
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt.toISOString(),
source: m.source,
notes: readNote(m.notesEncrypted, m.notes),
deletedAt: m.deletedAt?.toISOString() ?? null,
})),
appSettings:
disasterRecovery && appSettings
? {
...appSettings,
adminCodexTokenExpiresAt:
appSettings.adminCodexTokenExpiresAt?.toISOString() ?? null,
adminCodexConnectedAt:
appSettings.adminCodexConnectedAt?.toISOString() ?? null,
documentQuotaBytes: appSettings.documentQuotaBytes.toString(),
}
: null,
measurements: measurements.map((m) =>
disasterRecovery
? {
id: m.id,
type: m.type,
value: m.value,
valueMin: m.valueMin,
valueMax: m.valueMax,
unit: m.unit,
measuredAt: m.measuredAt.toISOString(),
source: m.source,
notes: m.notes,
notesEncrypted: m.notesEncrypted
? Buffer.from(m.notesEncrypted).toString("base64")
: null,
externalId: m.externalId,
externalSourceVersion: m.externalSourceVersion,
glucoseContext: m.glucoseContext,
sleepStage: m.sleepStage,
rhythmClassification: m.rhythmClassification,
deviceType: m.deviceType,
syncVersion: m.syncVersion,
deletedAt: m.deletedAt?.toISOString() ?? null,
createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(),
}
: {
id: m.id,
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt.toISOString(),
source: m.source,
notes: readNote(m.notesEncrypted, m.notes),
deletedAt: m.deletedAt?.toISOString() ?? null,
},
),
medications: medications.map((m) => ({
...(disasterRecovery
? {
id: m.id,
treatmentClass: m.treatmentClass,
dosesPerUnit: m.dosesPerUnit,
unitsPerDose: m.unitsPerDose.toString(),
notificationsEnabled: m.notificationsEnabled,
pausedAt: m.pausedAt?.toISOString() ?? null,
snoozedUntil: m.snoozedUntil?.toISOString() ?? null,
startsOn: m.startsOn?.toISOString() ?? null,
endsOn: m.endsOn?.toISOString() ?? null,
oneShot: m.oneShot,
asNeeded: m.asNeeded,
deliveryForm: m.deliveryForm,
trackInjectionSites: m.trackInjectionSites,
allowedInjectionSites: m.allowedInjectionSites,
liveActivityEnabled: m.liveActivityEnabled,
criticalAlarmEnabled: m.criticalAlarmEnabled,
atcCode: m.atcCode,
rxNormCode: m.rxNormCode,
lowStockNotifiedAt: m.lowStockNotifiedAt?.toISOString() ?? null,
lowStockNotifiedThresholdDays: m.lowStockNotifiedThresholdDays,
reorderLeadDays: m.reorderLeadDays,
externalSource: m.externalSource,
externalId: m.externalId,
createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(),
}
: {}),
name: m.name,
dose: m.dose,
active: m.active,
schedules: m.schedules.map((s) => ({
...(disasterRecovery
? {
id: s.id,
daysOfWeek: s.daysOfWeek,
timesOfDay: s.timesOfDay,
reminderGraceMinutes: s.reminderGraceMinutes,
rrule: s.rrule,
rollingIntervalDays: s.rollingIntervalDays,
scheduleType: s.scheduleType,
cyclicOnWeeks: s.cyclicOnWeeks,
cyclicOffWeeks: s.cyclicOffWeeks,
doseWindows: s.doseWindows,
}
: {}),
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label,
@@ -141,6 +226,23 @@ export async function buildFullBackupPayload(
})),
})),
intakeEvents: intakeEvents.map((e) => ({
...(disasterRecovery
? {
id: e.id,
medicationId: e.medicationId,
autoMissed: e.autoMissed,
attributionSource: e.attributionSource,
idempotencyKey: e.idempotencyKey,
createdAt: e.createdAt.toISOString(),
injectionSite: e.injectionSite,
doseTaken: e.doseTaken,
inventoryConsumption: e.inventoryConsumption,
externalId: e.externalId,
updatedAt: e.updatedAt.toISOString(),
syncVersion: e.syncVersion,
deletedAt: e.deletedAt?.toISOString() ?? null,
}
: {}),
medication: e.medication.name,
scheduledFor: e.scheduledFor.toISOString(),
takenAt: e.takenAt?.toISOString() ?? null,
+99 -13
View File
@@ -57,10 +57,15 @@ export interface LabResultBackupEntry {
takenAt: string;
source: string;
/** Human-readable cross-reference into `biomarkers` below, not an id. */
/** Stable canonical FK; legacy portable payloads use biomarkerName. */
biomarkerId?: string | null;
biomarkerName: string | null;
note: string | null;
/** Base64 ciphertext in canonical DR payloads. */
noteEncrypted?: string | null;
createdAt?: string;
updatedAt?: string;
deletedAt?: string | null;
}
export interface BiomarkerBackupEntry {
@@ -140,11 +145,30 @@ interface DisasterRecoveryDocumentRow {
updatedAt: Date;
}
type CanonicalIllnessDayLog = IllnessDayLogDTO & {
noteEncrypted?: string | null;
tz?: string | null;
createdAt?: string;
deletedAt?: string | null;
};
type CanonicalIllnessEpisode = IllnessEpisodeDTO & {
noteEncrypted?: string | null;
deletedAt?: string | null;
dayLogs: CanonicalIllnessDayLog[];
};
type CanonicalAllergy = AllergyDTO & {
reactionEncrypted?: string | null;
notesEncrypted?: string | null;
deletedAt?: string | null;
};
export interface RecordsBackupSection {
labResults: LabResultBackupEntry[];
biomarkers: BiomarkerBackupEntry[];
illnessEpisodes: Array<IllnessEpisodeDTO & { dayLogs: IllnessDayLogDTO[] }>;
allergies: AllergyDTO[];
illnessEpisodes: CanonicalIllnessEpisode[];
allergies: CanonicalAllergy[];
familyHistory: FamilyHistoryEntryDTO[];
workouts: WorkoutBackupEntry[];
documents: DocumentBackupEntry[];
@@ -255,7 +279,7 @@ export async function buildRecordsBackupSection(
documentRows,
] = await Promise.all([
prisma.labResult.findMany({
where: { userId, deletedAt: null },
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { takenAt: "desc" },
include: { biomarker: { select: { name: true } } },
}),
@@ -264,18 +288,18 @@ export async function buildRecordsBackupSection(
orderBy: { name: "asc" },
}),
prisma.illnessEpisode.findMany({
where: { userId, deletedAt: null },
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { onsetAt: "desc" },
include: {
dayLogs: {
where: { deletedAt: null },
...(disasterRecovery ? {} : { where: { deletedAt: null } }),
orderBy: { date: "asc" },
include: dayLogSymptomInclude,
},
},
}),
prisma.allergy.findMany({
where: { userId, deletedAt: null },
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { createdAt: "desc" },
}),
prisma.familyHistoryEntry.findMany({
@@ -293,10 +317,16 @@ export async function buildRecordsBackupSection(
...(disasterRecovery
? {
id: r.id,
biomarkerId: r.biomarkerId,
noteEncrypted: r.noteEncrypted
? Buffer.from(r.noteEncrypted).toString("base64")
: null,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
deletedAt: r.deletedAt?.toISOString() ?? null,
note: null,
}
: {}),
: { note: decryptNoteSoft(r.noteEncrypted) }),
panel: r.panel,
analyte: r.analyte,
value: r.value,
@@ -307,7 +337,6 @@ export async function buildRecordsBackupSection(
takenAt: r.takenAt.toISOString(),
source: r.source,
biomarkerName: r.biomarker?.name ?? null,
note: decryptNoteSoft(r.noteEncrypted),
}));
const biomarkers: BiomarkerBackupEntry[] = biomarkerRows.map((b) => ({
@@ -327,12 +356,69 @@ export async function buildRecordsBackupSection(
context: decryptContextSoft(b.contextEncrypted),
}));
const illnessEpisodes = episodeRows.map((row) => ({
...toIllnessEpisodeDTO(row),
dayLogs: row.dayLogs.map(toIllnessDayLogDTO),
}));
const illnessEpisodes: CanonicalIllnessEpisode[] = disasterRecovery
? episodeRows.map((row) => ({
id: row.id,
label: row.label,
type: row.type,
lifecycle: row.lifecycle,
onsetAt: row.onsetAt.toISOString(),
resolvedAt: row.resolvedAt?.toISOString() ?? null,
parentConditionId: row.parentConditionId,
note: null,
noteEncrypted: row.noteEncrypted
? Buffer.from(row.noteEncrypted).toString("base64")
: null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null,
dayLogs: row.dayLogs.map((dayLog) => ({
id: dayLog.id,
episodeId: dayLog.episodeId,
date: dayLog.date,
functionalImpact: dayLog.functionalImpact,
feverC: dayLog.feverC,
note: null,
noteEncrypted: dayLog.noteEncrypted
? Buffer.from(dayLog.noteEncrypted).toString("base64")
: null,
tz: dayLog.tz,
createdAt: dayLog.createdAt.toISOString(),
updatedAt: dayLog.updatedAt.toISOString(),
deletedAt: dayLog.deletedAt?.toISOString() ?? null,
symptoms: dayLog.symptomLinks.map((link) => ({
key: link.symptom.key,
severity: link.severity,
})),
})),
}))
: episodeRows.map((row) => ({
...toIllnessEpisodeDTO(row),
dayLogs: row.dayLogs.map(toIllnessDayLogDTO),
}));
const allergies = allergyRows.map(toAllergyDTO);
const allergies: CanonicalAllergy[] = disasterRecovery
? allergyRows.map((row) => ({
id: row.id,
substance: row.substance,
category: row.category,
type: row.type,
severity: row.severity,
status: row.status,
onsetAt: row.onsetAt?.toISOString() ?? null,
reaction: null,
note: null,
reactionEncrypted: row.reactionEncrypted
? Buffer.from(row.reactionEncrypted).toString("base64")
: null,
notesEncrypted: row.notesEncrypted
? Buffer.from(row.notesEncrypted).toString("base64")
: null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null,
}))
: allergyRows.map(toAllergyDTO);
const familyHistory = familyRows.map(toFamilyHistoryEntryDTO);
const workouts: WorkoutBackupEntry[] = workoutRows.map((w) => ({