mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
fix(backups): preserve canonical restore identity
This commit is contained in:
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in new issue
Block a user