mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +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
@@ -329,43 +329,141 @@ const handler = apiHandler(
|
||||
where: { userId: ownerId },
|
||||
});
|
||||
|
||||
// Stable ids are authoritative for canonical snapshots. Legacy payloads
|
||||
// without ids fall back to the historic natural measurement key.
|
||||
for (const measurement of payload.measurements) {
|
||||
const measuredAt = new Date(measurement.measuredAt);
|
||||
const source = measurement.source ?? "MANUAL";
|
||||
const restoredData = {
|
||||
type: measurement.type,
|
||||
value: measurement.value,
|
||||
unit: measurement.unit,
|
||||
source,
|
||||
measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(measurement.notes ?? null),
|
||||
deletedAt: measurement.deletedAt
|
||||
? new Date(measurement.deletedAt)
|
||||
if (payload.appSettings) {
|
||||
const settings = payload.appSettings;
|
||||
const settingsData = {
|
||||
registrationEnabled: settings.registrationEnabled,
|
||||
mfaRequired: settings.mfaRequired,
|
||||
defaultLocale: settings.defaultLocale,
|
||||
telegramGlobal: settings.telegramGlobal,
|
||||
ntfyGlobal: settings.ntfyGlobal,
|
||||
webPushGlobal: settings.webPushGlobal,
|
||||
webPushVapidPublicKey: settings.webPushVapidPublicKey,
|
||||
webPushVapidPrivateKeyEncrypted:
|
||||
settings.webPushVapidPrivateKeyEncrypted,
|
||||
webPushVapidSubject: settings.webPushVapidSubject,
|
||||
apiGlobal: settings.apiGlobal,
|
||||
moodLogGlobal: settings.moodLogGlobal,
|
||||
umamiEnabled: settings.umamiEnabled,
|
||||
umamiScriptUrl: settings.umamiScriptUrl,
|
||||
umamiWebsiteId: settings.umamiWebsiteId,
|
||||
glitchtipEnabled: settings.glitchtipEnabled,
|
||||
glitchtipDsn: settings.glitchtipDsn,
|
||||
glitchtipEnvironment: settings.glitchtipEnvironment,
|
||||
reminderLateMinutes: settings.reminderLateMinutes,
|
||||
reminderMissedMinutes: settings.reminderMissedMinutes,
|
||||
adminAiKeyEncrypted: settings.adminAiKeyEncrypted,
|
||||
adminAiModel: settings.adminAiModel,
|
||||
adminAiBaseUrl: settings.adminAiBaseUrl,
|
||||
adminCodexAccessTokenEncrypted:
|
||||
settings.adminCodexAccessTokenEncrypted,
|
||||
adminCodexRefreshTokenEncrypted:
|
||||
settings.adminCodexRefreshTokenEncrypted,
|
||||
adminCodexAccountIdEncrypted:
|
||||
settings.adminCodexAccountIdEncrypted,
|
||||
adminCodexTokenExpiresAt: settings.adminCodexTokenExpiresAt
|
||||
? new Date(settings.adminCodexTokenExpiresAt)
|
||||
: null,
|
||||
adminCodexConnectedAt: settings.adminCodexConnectedAt
|
||||
? new Date(settings.adminCodexConnectedAt)
|
||||
: null,
|
||||
adminCodexConnectionStatus:
|
||||
settings.adminCodexConnectionStatus,
|
||||
adminAiInsightsFeedbackSummary:
|
||||
settings.adminAiInsightsFeedbackSummary as never,
|
||||
defaultUserTimezone: settings.defaultUserTimezone,
|
||||
assistantEnabled: settings.assistantEnabled,
|
||||
assistantCoachEnabled: settings.assistantCoachEnabled,
|
||||
assistantBriefingEnabled: settings.assistantBriefingEnabled,
|
||||
assistantInsightStatusEnabled:
|
||||
settings.assistantInsightStatusEnabled,
|
||||
assistantCorrelationsEnabled:
|
||||
settings.assistantCorrelationsEnabled,
|
||||
assistantHealthScoreExplainerEnabled:
|
||||
settings.assistantHealthScoreExplainerEnabled,
|
||||
moduleAvailabilityJson:
|
||||
settings.moduleAvailabilityJson as never,
|
||||
documentMaxFileBytes: settings.documentMaxFileBytes,
|
||||
documentQuotaBytes: BigInt(settings.documentQuotaBytes),
|
||||
};
|
||||
await tx.appSettings.upsert({
|
||||
where: { id: settings.id },
|
||||
create: { id: settings.id, ...settingsData },
|
||||
update: settingsData,
|
||||
});
|
||||
}
|
||||
|
||||
if (measurement.id) {
|
||||
await tx.measurement.upsert({
|
||||
where: { id: measurement.id, userId: ownerId },
|
||||
create: {
|
||||
id: measurement.id,
|
||||
userId: ownerId,
|
||||
...restoredData,
|
||||
},
|
||||
update: restoredData,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const toRestoredMeasurementData = (
|
||||
measurement: (typeof payload.measurements)[number],
|
||||
) => ({
|
||||
type: measurement.type,
|
||||
value: measurement.value,
|
||||
valueMin: measurement.valueMin ?? null,
|
||||
valueMax: measurement.valueMax ?? null,
|
||||
unit: measurement.unit,
|
||||
source: measurement.source ?? "MANUAL",
|
||||
measuredAt: new Date(measurement.measuredAt),
|
||||
notes: measurement.notes ?? null,
|
||||
notesEncrypted:
|
||||
measurement.notesEncrypted !== undefined
|
||||
? measurement.notesEncrypted === null
|
||||
? null
|
||||
: decodeEncryptedBytes(measurement.notesEncrypted)
|
||||
: encryptNote(measurement.notes ?? null),
|
||||
externalId: measurement.externalId ?? null,
|
||||
externalSourceVersion: measurement.externalSourceVersion ?? null,
|
||||
glucoseContext: (measurement.glucoseContext ?? null) as never,
|
||||
sleepStage: (measurement.sleepStage ?? null) as never,
|
||||
rhythmClassification: (measurement.rhythmClassification ??
|
||||
null) as never,
|
||||
deviceType: measurement.deviceType ?? null,
|
||||
syncVersion: measurement.syncVersion ?? 1,
|
||||
deletedAt: measurement.deletedAt
|
||||
? new Date(measurement.deletedAt)
|
||||
: null,
|
||||
...(measurement.createdAt
|
||||
? { createdAt: new Date(measurement.createdAt) }
|
||||
: {}),
|
||||
...(measurement.updatedAt
|
||||
? { updatedAt: new Date(measurement.updatedAt) }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const stableRows = payload.measurements.flatMap((measurement) =>
|
||||
measurement.id
|
||||
? [
|
||||
{
|
||||
id: measurement.id,
|
||||
userId: ownerId,
|
||||
...toRestoredMeasurementData(measurement),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
const measurementBatchSize = 1_000;
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < stableRows.length;
|
||||
offset += measurementBatchSize
|
||||
) {
|
||||
await tx.measurement.createMany({
|
||||
data: stableRows.slice(offset, offset + measurementBatchSize),
|
||||
});
|
||||
}
|
||||
|
||||
// v1 payloads did not require stable ids. Preserve their historical
|
||||
// natural-key reconciliation without routing canonical v2 rows
|
||||
// through it.
|
||||
for (const measurement of payload.measurements) {
|
||||
if (measurement.id) continue;
|
||||
const restoredData = toRestoredMeasurementData(measurement);
|
||||
const existing = await tx.measurement.findFirst({
|
||||
where: {
|
||||
userId: ownerId,
|
||||
type: measurement.type,
|
||||
source,
|
||||
measuredAt,
|
||||
source: restoredData.source,
|
||||
measuredAt: restoredData.measuredAt,
|
||||
sleepStage: restoredData.sleepStage,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
@@ -381,50 +479,97 @@ const handler = apiHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// Medications + schedules — restore one at a time so we can
|
||||
// wire schedules to their parent's freshly-generated id, AND
|
||||
// build a name → id map for the intake events that follow.
|
||||
const medByName = new Map<string, string>();
|
||||
const restoredMedicationIds = new Set<string>();
|
||||
for (const m of payload.medications) {
|
||||
const created = await tx.medication.create({
|
||||
data: {
|
||||
...(m.id ? { id: m.id } : {}),
|
||||
userId: ownerId,
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
treatmentClass: m.treatmentClass ?? "GENERIC",
|
||||
dosesPerUnit: m.dosesPerUnit ?? null,
|
||||
unitsPerDose: m.unitsPerDose ?? "1",
|
||||
active: m.active ?? true,
|
||||
notificationsEnabled: m.notificationsEnabled ?? true,
|
||||
pausedAt: m.pausedAt ? new Date(m.pausedAt) : null,
|
||||
snoozedUntil: m.snoozedUntil
|
||||
? new Date(m.snoozedUntil)
|
||||
: null,
|
||||
startsOn: m.startsOn ? new Date(m.startsOn) : null,
|
||||
endsOn: m.endsOn ? new Date(m.endsOn) : null,
|
||||
oneShot: m.oneShot ?? false,
|
||||
asNeeded: m.asNeeded ?? false,
|
||||
deliveryForm: m.deliveryForm ?? "ORAL",
|
||||
trackInjectionSites: m.trackInjectionSites ?? false,
|
||||
allowedInjectionSites: m.allowedInjectionSites ?? [],
|
||||
liveActivityEnabled: m.liveActivityEnabled ?? false,
|
||||
criticalAlarmEnabled: m.criticalAlarmEnabled ?? false,
|
||||
atcCode: m.atcCode ?? null,
|
||||
rxNormCode: m.rxNormCode ?? null,
|
||||
lowStockNotifiedAt: m.lowStockNotifiedAt
|
||||
? new Date(m.lowStockNotifiedAt)
|
||||
: null,
|
||||
lowStockNotifiedThresholdDays:
|
||||
m.lowStockNotifiedThresholdDays ?? null,
|
||||
reorderLeadDays: m.reorderLeadDays ?? null,
|
||||
externalSource: m.externalSource ?? null,
|
||||
externalId: m.externalId ?? null,
|
||||
...(m.createdAt ? { createdAt: new Date(m.createdAt) } : {}),
|
||||
...(m.updatedAt ? { updatedAt: new Date(m.updatedAt) } : {}),
|
||||
schedules: {
|
||||
create: m.schedules.map((s) => ({
|
||||
...(s.id ? { id: s.id } : {}),
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
label: s.label ?? null,
|
||||
dose: s.dose ?? null,
|
||||
daysOfWeek: s.daysOfWeek ?? null,
|
||||
timesOfDay: s.timesOfDay ?? [],
|
||||
reminderGraceMinutes: s.reminderGraceMinutes ?? null,
|
||||
rrule: s.rrule ?? null,
|
||||
rollingIntervalDays: s.rollingIntervalDays ?? null,
|
||||
scheduleType: s.scheduleType ?? "SCHEDULED",
|
||||
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
|
||||
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
|
||||
doseWindows: (s.doseWindows ?? null) as never,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
// First write wins on duplicate names — keeps the round-trip
|
||||
// deterministic against the restore-from-our-own-download case.
|
||||
restoredMedicationIds.add(created.id);
|
||||
if (!medByName.has(m.name)) medByName.set(m.name, created.id);
|
||||
}
|
||||
|
||||
// Intake events — only restore the ones whose `medication` name
|
||||
// resolved against a row we just created. Orphans (medications
|
||||
// that were deleted before the snapshot but still referenced by
|
||||
// intake events) are dropped silently — the alternative would
|
||||
// be an FK-violation crash, which is worse than a slightly
|
||||
// smaller history.
|
||||
if (payload.intakeEvents.length > 0) {
|
||||
const rows = payload.intakeEvents
|
||||
.map((e) => {
|
||||
const medId = medByName.get(e.medication);
|
||||
const medId =
|
||||
e.medicationId && restoredMedicationIds.has(e.medicationId)
|
||||
? e.medicationId
|
||||
: medByName.get(e.medication);
|
||||
if (!medId) return null;
|
||||
return {
|
||||
...(e.id ? { id: e.id } : {}),
|
||||
userId: ownerId,
|
||||
medicationId: medId,
|
||||
scheduledFor: new Date(e.scheduledFor),
|
||||
takenAt: e.takenAt ? new Date(e.takenAt) : null,
|
||||
skipped: e.skipped ?? false,
|
||||
source: (e.source ?? "WEB") as never,
|
||||
autoMissed: e.autoMissed ?? false,
|
||||
attributionSource: e.attributionSource ?? "AUTO",
|
||||
source: e.source ?? "WEB",
|
||||
idempotencyKey: e.idempotencyKey ?? null,
|
||||
...(e.createdAt ? { createdAt: new Date(e.createdAt) } : {}),
|
||||
injectionSite: e.injectionSite ?? null,
|
||||
doseTaken: e.doseTaken ?? null,
|
||||
inventoryConsumption: (e.inventoryConsumption ??
|
||||
null) as never,
|
||||
externalId: e.externalId ?? null,
|
||||
...(e.updatedAt ? { updatedAt: new Date(e.updatedAt) } : {}),
|
||||
syncVersion: e.syncVersion ?? 0,
|
||||
deletedAt: e.deletedAt ? new Date(e.deletedAt) : null,
|
||||
};
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||
@@ -526,6 +671,7 @@ const handler = apiHandler(
|
||||
const cycleCleared = await restoreCycleData(tx, ownerId, payload);
|
||||
|
||||
const biomarkerByName = new Map<string, string>();
|
||||
const restoredBiomarkerIds = new Set<string>();
|
||||
for (const biomarker of payload.biomarkers) {
|
||||
const created = await tx.biomarker.create({
|
||||
data: {
|
||||
@@ -550,15 +696,23 @@ const handler = apiHandler(
|
||||
},
|
||||
});
|
||||
biomarkerByName.set(biomarker.name, created.id);
|
||||
restoredBiomarkerIds.add(created.id);
|
||||
}
|
||||
|
||||
for (const lab of payload.labResults) {
|
||||
const biomarkerId = lab.biomarkerName
|
||||
? biomarkerByName.get(lab.biomarkerName)
|
||||
: undefined;
|
||||
if (lab.biomarkerName && !biomarkerId) {
|
||||
const biomarkerId =
|
||||
lab.biomarkerId !== undefined
|
||||
? lab.biomarkerId === null
|
||||
? null
|
||||
: restoredBiomarkerIds.has(lab.biomarkerId)
|
||||
? lab.biomarkerId
|
||||
: undefined
|
||||
: lab.biomarkerName
|
||||
? biomarkerByName.get(lab.biomarkerName)
|
||||
: null;
|
||||
if (biomarkerId === undefined) {
|
||||
throw new Error(
|
||||
`Unknown biomarker reference: ${lab.biomarkerName}`,
|
||||
`Unknown biomarker reference: ${lab.biomarkerId ?? lab.biomarkerName}`,
|
||||
);
|
||||
}
|
||||
await tx.labResult.create({
|
||||
@@ -576,7 +730,14 @@ const handler = apiHandler(
|
||||
takenAt: new Date(lab.takenAt),
|
||||
source: lab.source,
|
||||
noteEncrypted:
|
||||
lab.note == null ? null : encryptNoteToBytes(lab.note),
|
||||
lab.noteEncrypted !== undefined
|
||||
? lab.noteEncrypted === null
|
||||
? null
|
||||
: decodeEncryptedBytes(lab.noteEncrypted)
|
||||
: lab.note == null
|
||||
? null
|
||||
: encryptNoteToBytes(lab.note),
|
||||
deletedAt: lab.deletedAt ? new Date(lab.deletedAt) : null,
|
||||
...(lab.createdAt
|
||||
? { createdAt: new Date(lab.createdAt) }
|
||||
: {}),
|
||||
@@ -612,7 +773,16 @@ const handler = apiHandler(
|
||||
: null,
|
||||
parentConditionId: null,
|
||||
noteEncrypted:
|
||||
episode.note == null ? null : encryptToBytes(episode.note),
|
||||
episode.noteEncrypted !== undefined
|
||||
? episode.noteEncrypted === null
|
||||
? null
|
||||
: decodeEncryptedBytes(episode.noteEncrypted)
|
||||
: episode.note == null
|
||||
? null
|
||||
: encryptToBytes(episode.note),
|
||||
deletedAt: episode.deletedAt
|
||||
? new Date(episode.deletedAt)
|
||||
: null,
|
||||
...(episode.createdAt
|
||||
? { createdAt: new Date(episode.createdAt) }
|
||||
: {}),
|
||||
@@ -667,7 +837,20 @@ const handler = apiHandler(
|
||||
functionalImpact: dayLog.functionalImpact ?? null,
|
||||
feverC: dayLog.feverC ?? null,
|
||||
noteEncrypted:
|
||||
dayLog.note == null ? null : encryptToBytes(dayLog.note),
|
||||
dayLog.noteEncrypted !== undefined
|
||||
? dayLog.noteEncrypted === null
|
||||
? null
|
||||
: decodeEncryptedBytes(dayLog.noteEncrypted)
|
||||
: dayLog.note == null
|
||||
? null
|
||||
: encryptToBytes(dayLog.note),
|
||||
tz: dayLog.tz ?? null,
|
||||
deletedAt: dayLog.deletedAt
|
||||
? new Date(dayLog.deletedAt)
|
||||
: null,
|
||||
...(dayLog.createdAt
|
||||
? { createdAt: new Date(dayLog.createdAt) }
|
||||
: {}),
|
||||
...(dayLog.updatedAt
|
||||
? { updatedAt: new Date(dayLog.updatedAt) }
|
||||
: {}),
|
||||
@@ -694,11 +877,24 @@ const handler = apiHandler(
|
||||
status: allergy.status as never,
|
||||
onsetAt: allergy.onsetAt ? new Date(allergy.onsetAt) : null,
|
||||
reactionEncrypted:
|
||||
allergy.reaction == null
|
||||
? null
|
||||
: encryptToBytes(allergy.reaction),
|
||||
allergy.reactionEncrypted !== undefined
|
||||
? allergy.reactionEncrypted === null
|
||||
? null
|
||||
: decodeEncryptedBytes(allergy.reactionEncrypted)
|
||||
: allergy.reaction == null
|
||||
? null
|
||||
: encryptToBytes(allergy.reaction),
|
||||
notesEncrypted:
|
||||
allergy.note == null ? null : encryptToBytes(allergy.note),
|
||||
allergy.notesEncrypted !== undefined
|
||||
? allergy.notesEncrypted === null
|
||||
? null
|
||||
: decodeEncryptedBytes(allergy.notesEncrypted)
|
||||
: allergy.note == null
|
||||
? null
|
||||
: encryptToBytes(allergy.note),
|
||||
deletedAt: allergy.deletedAt
|
||||
? new Date(allergy.deletedAt)
|
||||
: null,
|
||||
...(allergy.createdAt
|
||||
? { createdAt: new Date(allergy.createdAt) }
|
||||
: {}),
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { buildCycleBackupSection } from "../backup";
|
||||
|
||||
const deletedAt = new Date("2026-07-19T12:00:00.000Z");
|
||||
const createdAt = new Date("2026-07-01T08:00:00.000Z");
|
||||
const updatedAt = new Date("2026-07-19T11:00:00.000Z");
|
||||
|
||||
describe("buildCycleBackupSection disaster-recovery mode", () => {
|
||||
it("preserves stable ids, reconciliation fields, and tombstones", async () => {
|
||||
const prisma = {
|
||||
cycleProfile: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "profile-dr",
|
||||
goal: "GENERAL_HEALTH",
|
||||
cycleTrackingEnabled: true,
|
||||
typicalCycleLength: 28,
|
||||
typicalPeriodLength: 5,
|
||||
lutealPhaseLength: 14,
|
||||
secondarySymptom: "MUCUS",
|
||||
predictionEnabled: true,
|
||||
rawChartMode: false,
|
||||
discreetNotifications: true,
|
||||
sensitiveCategoryEncryption: true,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
}),
|
||||
},
|
||||
menstrualCycle: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "cycle-dr",
|
||||
startDate: "2026-07-01",
|
||||
endDate: "2026-07-28",
|
||||
periodEndDate: "2026-07-05",
|
||||
lengthDays: 28,
|
||||
ovulationDate: "2026-07-14",
|
||||
ovulationConfirmed: true,
|
||||
isPredicted: false,
|
||||
tz: "Europe/London",
|
||||
syncVersion: 5,
|
||||
deletedAt,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
},
|
||||
]),
|
||||
},
|
||||
cycleDayLog: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "cycle-day-dr",
|
||||
date: "2026-07-02",
|
||||
cycleId: "cycle-dr",
|
||||
flow: "HEAVY",
|
||||
intermenstrualBleeding: false,
|
||||
basalBodyTempC: 36.7,
|
||||
temperatureExcluded: true,
|
||||
ovulationTest: "NEGATIVE",
|
||||
cervicalMucus: "CREAMY",
|
||||
cervixPosition: "LOW",
|
||||
cervixFirmness: "FIRM",
|
||||
cervixOpening: "CLOSED",
|
||||
sexualActivity: false,
|
||||
protectedSex: null,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
sensitiveEncrypted: "sensitive-ciphertext",
|
||||
notesEncrypted: "notes-ciphertext",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "cycle-day-external",
|
||||
tz: "Europe/London",
|
||||
syncVersion: 9,
|
||||
deletedAt,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
symptomLinks: [{ symptom: { key: "cramps" } }],
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const section = await buildCycleBackupSection(
|
||||
prisma as never,
|
||||
"user-1",
|
||||
{ purpose: "disaster-recovery" },
|
||||
);
|
||||
|
||||
expect(prisma.menstrualCycle.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { userId: "user-1" } }),
|
||||
);
|
||||
expect(prisma.cycleDayLog.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { userId: "user-1" } }),
|
||||
);
|
||||
expect(section.cycleProfile).toEqual({
|
||||
id: "profile-dr",
|
||||
goal: "GENERAL_HEALTH",
|
||||
cycleTrackingEnabled: true,
|
||||
typicalCycleLength: 28,
|
||||
typicalPeriodLength: 5,
|
||||
lutealPhaseLength: 14,
|
||||
secondarySymptom: "MUCUS",
|
||||
predictionEnabled: true,
|
||||
rawChartMode: false,
|
||||
discreetNotifications: true,
|
||||
sensitiveCategoryEncryption: true,
|
||||
createdAt: createdAt.toISOString(),
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
});
|
||||
expect(section.cycles[0]).toEqual({
|
||||
id: "cycle-dr",
|
||||
startDate: "2026-07-01",
|
||||
endDate: "2026-07-28",
|
||||
periodEndDate: "2026-07-05",
|
||||
lengthDays: 28,
|
||||
ovulationDate: "2026-07-14",
|
||||
ovulationConfirmed: true,
|
||||
isPredicted: false,
|
||||
tz: "Europe/London",
|
||||
syncVersion: 5,
|
||||
deletedAt: deletedAt.toISOString(),
|
||||
createdAt: createdAt.toISOString(),
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
});
|
||||
expect(section.cycleDayLogs[0]).toEqual({
|
||||
id: "cycle-day-dr",
|
||||
date: "2026-07-02",
|
||||
cycleId: "cycle-dr",
|
||||
flow: "HEAVY",
|
||||
intermenstrualBleeding: false,
|
||||
basalBodyTempC: 36.7,
|
||||
temperatureExcluded: true,
|
||||
ovulationTest: "NEGATIVE",
|
||||
cervicalMucus: "CREAMY",
|
||||
cervixPosition: "LOW",
|
||||
cervixFirmness: "FIRM",
|
||||
cervixOpening: "CLOSED",
|
||||
sexualActivity: false,
|
||||
protectedSex: null,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
sensitiveEncrypted: "sensitive-ciphertext",
|
||||
notesEncrypted: "notes-ciphertext",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "cycle-day-external",
|
||||
tz: "Europe/London",
|
||||
syncVersion: 9,
|
||||
deletedAt: deletedAt.toISOString(),
|
||||
createdAt: createdAt.toISOString(),
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
symptomKeys: ["cramps"],
|
||||
});
|
||||
});
|
||||
});
|
||||
+79
-5
@@ -28,8 +28,13 @@ import type {
|
||||
import type { BackupPayload } from "@/lib/validations/backup";
|
||||
|
||||
/** The cycle slice of a backup payload. */
|
||||
export interface CycleBackupOptions {
|
||||
purpose?: "portable-export" | "disaster-recovery";
|
||||
}
|
||||
|
||||
export interface CycleBackupSection {
|
||||
cycleProfile: {
|
||||
id?: string;
|
||||
goal: string;
|
||||
cycleTrackingEnabled: boolean | null;
|
||||
typicalCycleLength: number | null;
|
||||
@@ -40,8 +45,11 @@ export interface CycleBackupSection {
|
||||
rawChartMode: boolean;
|
||||
discreetNotifications: boolean;
|
||||
sensitiveCategoryEncryption: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
} | null;
|
||||
cycles: Array<{
|
||||
id?: string;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
periodEndDate: string | null;
|
||||
@@ -49,8 +57,15 @@ export interface CycleBackupSection {
|
||||
ovulationDate: string | null;
|
||||
ovulationConfirmed: boolean;
|
||||
tz: string | null;
|
||||
isPredicted?: boolean;
|
||||
syncVersion?: number;
|
||||
deletedAt?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}>;
|
||||
cycleDayLogs: Array<{
|
||||
id?: string;
|
||||
cycleId?: string | null;
|
||||
date: string;
|
||||
flow: string | null;
|
||||
intermenstrualBleeding: boolean;
|
||||
@@ -71,6 +86,10 @@ export interface CycleBackupSection {
|
||||
source: string;
|
||||
externalId: string | null;
|
||||
tz: string | null;
|
||||
syncVersion?: number;
|
||||
deletedAt?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
symptomKeys: string[];
|
||||
}>;
|
||||
}
|
||||
@@ -83,15 +102,19 @@ export interface CycleBackupSection {
|
||||
export async function buildCycleBackupSection(
|
||||
prisma: Pick<PrismaClient, "cycleProfile" | "menstrualCycle" | "cycleDayLog">,
|
||||
userId: string,
|
||||
options: CycleBackupOptions = {},
|
||||
): Promise<CycleBackupSection> {
|
||||
const disasterRecovery = options.purpose === "disaster-recovery";
|
||||
const [profile, cycles, dayLogs] = await Promise.all([
|
||||
prisma.cycleProfile.findUnique({ where: { userId } }),
|
||||
prisma.menstrualCycle.findMany({
|
||||
where: { userId, deletedAt: null, isPredicted: false },
|
||||
where: disasterRecovery
|
||||
? { userId }
|
||||
: { userId, deletedAt: null, isPredicted: false },
|
||||
orderBy: { startDate: "asc" },
|
||||
}),
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
|
||||
orderBy: { date: "asc" },
|
||||
include: {
|
||||
symptomLinks: { include: { symptom: { select: { key: true } } } },
|
||||
@@ -102,6 +125,13 @@ export async function buildCycleBackupSection(
|
||||
return {
|
||||
cycleProfile: profile
|
||||
? {
|
||||
...(disasterRecovery
|
||||
? {
|
||||
id: profile.id,
|
||||
createdAt: profile.createdAt.toISOString(),
|
||||
updatedAt: profile.updatedAt.toISOString(),
|
||||
}
|
||||
: {}),
|
||||
goal: profile.goal,
|
||||
cycleTrackingEnabled: profile.cycleTrackingEnabled,
|
||||
typicalCycleLength: profile.typicalCycleLength,
|
||||
@@ -115,6 +145,16 @@ export async function buildCycleBackupSection(
|
||||
}
|
||||
: null,
|
||||
cycles: cycles.map((c) => ({
|
||||
...(disasterRecovery
|
||||
? {
|
||||
id: c.id,
|
||||
isPredicted: c.isPredicted,
|
||||
syncVersion: c.syncVersion,
|
||||
deletedAt: c.deletedAt?.toISOString() ?? null,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
}
|
||||
: {}),
|
||||
startDate: c.startDate,
|
||||
endDate: c.endDate,
|
||||
periodEndDate: c.periodEndDate,
|
||||
@@ -124,6 +164,16 @@ export async function buildCycleBackupSection(
|
||||
tz: c.tz,
|
||||
})),
|
||||
cycleDayLogs: dayLogs.map((d) => ({
|
||||
...(disasterRecovery
|
||||
? {
|
||||
id: d.id,
|
||||
cycleId: d.cycleId,
|
||||
syncVersion: d.syncVersion,
|
||||
deletedAt: d.deletedAt?.toISOString() ?? null,
|
||||
createdAt: d.createdAt.toISOString(),
|
||||
updatedAt: d.updatedAt.toISOString(),
|
||||
}
|
||||
: {}),
|
||||
date: d.date,
|
||||
flow: d.flow,
|
||||
intermenstrualBleeding: d.intermenstrualBleeding,
|
||||
@@ -139,7 +189,6 @@ export async function buildCycleBackupSection(
|
||||
pregnancyTest: d.pregnancyTest,
|
||||
progesteroneTest: d.progesteroneTest,
|
||||
contraceptive: d.contraceptive,
|
||||
// Both ciphertext envelopes carried verbatim — never decrypted here.
|
||||
sensitiveEncrypted: d.sensitiveEncrypted,
|
||||
notesEncrypted: d.notesEncrypted,
|
||||
source: d.source,
|
||||
@@ -258,6 +307,7 @@ export async function restoreCycleData(
|
||||
const p = payload.cycleProfile;
|
||||
await tx.cycleProfile.create({
|
||||
data: {
|
||||
...(p.id ? { id: p.id } : {}),
|
||||
userId: ownerId,
|
||||
goal: enumOrNull(p.goal, CYCLE_GOALS) ?? "GENERAL_HEALTH",
|
||||
cycleTrackingEnabled: p.cycleTrackingEnabled ?? null,
|
||||
@@ -270,6 +320,8 @@ export async function restoreCycleData(
|
||||
rawChartMode: p.rawChartMode ?? false,
|
||||
discreetNotifications: p.discreetNotifications ?? false,
|
||||
sensitiveCategoryEncryption: p.sensitiveCategoryEncryption ?? true,
|
||||
...(p.createdAt ? { createdAt: new Date(p.createdAt) } : {}),
|
||||
...(p.updatedAt ? { updatedAt: new Date(p.updatedAt) } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -277,9 +329,11 @@ export async function restoreCycleData(
|
||||
// Recreate observed cycle spans; map startDate → id so day-logs can
|
||||
// re-attach to their owning span.
|
||||
const cycleIdByStart = new Map<string, string>();
|
||||
const restoredCycleIds = new Set<string>();
|
||||
for (const c of payload.cycles) {
|
||||
const created = await tx.menstrualCycle.create({
|
||||
data: {
|
||||
...(c.id ? { id: c.id } : {}),
|
||||
userId: ownerId,
|
||||
startDate: c.startDate,
|
||||
endDate: c.endDate ?? null,
|
||||
@@ -288,10 +342,15 @@ export async function restoreCycleData(
|
||||
ovulationDate: c.ovulationDate ?? null,
|
||||
ovulationConfirmed: c.ovulationConfirmed ?? false,
|
||||
tz: c.tz ?? null,
|
||||
isPredicted: false,
|
||||
isPredicted: c.isPredicted ?? false,
|
||||
syncVersion: c.syncVersion ?? 0,
|
||||
deletedAt: c.deletedAt ? new Date(c.deletedAt) : null,
|
||||
...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}),
|
||||
...(c.updatedAt ? { updatedAt: new Date(c.updatedAt) } : {}),
|
||||
},
|
||||
});
|
||||
cycleIdByStart.set(c.startDate, created.id);
|
||||
restoredCycleIds.add(created.id);
|
||||
}
|
||||
|
||||
// Resolve the seeded symptom catalogue once for the link re-creation.
|
||||
@@ -328,11 +387,22 @@ export async function restoreCycleData(
|
||||
const symptomIds = (d.symptomKeys ?? [])
|
||||
.map((k) => symptomIdByKey.get(k))
|
||||
.filter((v): v is string => v !== undefined);
|
||||
const cycleId =
|
||||
d.cycleId !== undefined
|
||||
? d.cycleId === null
|
||||
? null
|
||||
: restoredCycleIds.has(d.cycleId)
|
||||
? d.cycleId
|
||||
: (() => {
|
||||
throw new Error(`Unknown cycle reference: ${d.cycleId}`);
|
||||
})()
|
||||
: owningCycleId(d.date);
|
||||
await tx.cycleDayLog.create({
|
||||
data: {
|
||||
...(d.id ? { id: d.id } : {}),
|
||||
userId: ownerId,
|
||||
date: d.date,
|
||||
cycleId: owningCycleId(d.date),
|
||||
cycleId,
|
||||
flow: enumOrNull(d.flow, FLOW_LEVELS),
|
||||
intermenstrualBleeding: d.intermenstrualBleeding ?? false,
|
||||
basalBodyTempC: d.basalBodyTempC ?? null,
|
||||
@@ -353,6 +423,10 @@ export async function restoreCycleData(
|
||||
source: enumOrNull(d.source, CYCLE_SOURCES) ?? "MANUAL",
|
||||
externalId: d.externalId ?? null,
|
||||
tz: d.tz ?? null,
|
||||
syncVersion: d.syncVersion ?? 0,
|
||||
deletedAt: d.deletedAt ? new Date(d.deletedAt) : null,
|
||||
...(d.createdAt ? { createdAt: new Date(d.createdAt) } : {}),
|
||||
...(d.updatedAt ? { updatedAt: new Date(d.updatedAt) } : {}),
|
||||
...(symptomIds.length > 0
|
||||
? {
|
||||
symptomLinks: {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -18,7 +18,12 @@
|
||||
* of bubbling out of `prisma.createMany` with a useless message.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { backupPayloadSchema, summarizeBackup } from "../backup";
|
||||
import {
|
||||
backupPayloadSchema,
|
||||
isCompatibleSchemaVersion,
|
||||
parseBackupPayload,
|
||||
summarizeBackup,
|
||||
} from "../backup";
|
||||
|
||||
const baseEntry = {
|
||||
date: "2026-05-08",
|
||||
@@ -384,6 +389,52 @@ describe("backupPayloadSchema — v1.28 backup-completeness domains", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("backupPayloadSchema — canonical v2 identity compatibility", () => {
|
||||
const base = {
|
||||
exportedAt: "2026-07-20T00:00:00.000Z",
|
||||
userId: "u1",
|
||||
};
|
||||
|
||||
it("treats an unversioned payload as legacy v1 and accepts measurements without ids", () => {
|
||||
const payload = parseBackupPayload({
|
||||
...base,
|
||||
measurements: [
|
||||
{
|
||||
type: "WEIGHT",
|
||||
value: 75,
|
||||
unit: "kg",
|
||||
measuredAt: "2026-07-19T07:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(payload.schemaVersion).toBe("1");
|
||||
expect(isCompatibleSchemaVersion(payload.schemaVersion)).toBe(true);
|
||||
});
|
||||
|
||||
it("requires stable measurement ids in canonical v2 payloads", () => {
|
||||
const result = backupPayloadSchema.safeParse({
|
||||
...base,
|
||||
schemaVersion: "2",
|
||||
measurements: [
|
||||
{
|
||||
type: "SLEEP_DURATION",
|
||||
value: 120,
|
||||
unit: "minutes",
|
||||
measuredAt: "2026-07-19T22:00:00.000Z",
|
||||
source: "IMPORT",
|
||||
sleepStage: "DEEP",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(isCompatibleSchemaVersion("1")).toBe(true);
|
||||
expect(isCompatibleSchemaVersion("2")).toBe(true);
|
||||
expect(isCompatibleSchemaVersion("3")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("backupPayloadSchema — database enum boundaries", () => {
|
||||
const enumPayload = {
|
||||
schemaVersion: "1",
|
||||
|
||||
+169
-12
@@ -34,19 +34,28 @@ import {
|
||||
DocumentSummaryState,
|
||||
FamilyRelationship,
|
||||
FlowLevel,
|
||||
GlucoseContext,
|
||||
HomeTestResult,
|
||||
IllnessLifecycle,
|
||||
IllnessType,
|
||||
InboundDocumentKind,
|
||||
InboundDocumentStatus,
|
||||
InjectionSite,
|
||||
IntakeAttributionSource,
|
||||
IntakeSource,
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
MedicationCategory,
|
||||
MedicationDeliveryForm,
|
||||
MedicationScheduleType,
|
||||
OvulationTest,
|
||||
RhythmClassification,
|
||||
SecondarySymptom,
|
||||
SleepStage,
|
||||
} from "@/generated/prisma/enums";
|
||||
|
||||
export const BACKUP_SCHEMA_VERSION = "1" as const;
|
||||
export const BACKUP_SCHEMA_VERSION = "2" as const;
|
||||
const LEGACY_BACKUP_SCHEMA_VERSION = "1" as const;
|
||||
|
||||
const isoDateTime = z
|
||||
.string()
|
||||
@@ -55,44 +64,112 @@ const isoDateTime = z
|
||||
message: "Expected ISO-8601 date-time string",
|
||||
});
|
||||
|
||||
const base64BytesSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(value) => value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value),
|
||||
{ message: "Expected base64-encoded encrypted bytes" },
|
||||
);
|
||||
|
||||
const measurementSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
type: z.enum(MeasurementType),
|
||||
value: z.number(),
|
||||
valueMin: z.number().nullable().optional(),
|
||||
valueMax: z.number().nullable().optional(),
|
||||
unit: z.string().min(1),
|
||||
measuredAt: isoDateTime,
|
||||
source: z.enum(MeasurementSource).optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
notesEncrypted: base64BytesSchema.nullable().optional(),
|
||||
externalId: z.string().nullable().optional(),
|
||||
externalSourceVersion: z.string().nullable().optional(),
|
||||
glucoseContext: z.enum(GlucoseContext).nullable().optional(),
|
||||
sleepStage: z.enum(SleepStage).nullable().optional(),
|
||||
rhythmClassification: z.enum(RhythmClassification).nullable().optional(),
|
||||
deviceType: z.string().nullable().optional(),
|
||||
syncVersion: z.number().int().optional(),
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const medicationScheduleSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
windowStart: z.string().min(1),
|
||||
windowEnd: z.string().min(1),
|
||||
label: z.string().nullable().optional(),
|
||||
dose: z.string().nullable().optional(),
|
||||
daysOfWeek: z.string().nullable().optional(),
|
||||
timesOfDay: z.array(z.string()).optional(),
|
||||
reminderGraceMinutes: z.number().int().nullable().optional(),
|
||||
rrule: z.string().nullable().optional(),
|
||||
rollingIntervalDays: z.number().int().nullable().optional(),
|
||||
scheduleType: z.enum(MedicationScheduleType).optional(),
|
||||
cyclicOnWeeks: z.number().int().nullable().optional(),
|
||||
cyclicOffWeeks: z.number().int().nullable().optional(),
|
||||
doseWindows: z.unknown().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const medicationSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
name: z.string().min(1),
|
||||
dose: z.string(),
|
||||
treatmentClass: z.enum(MedicationCategory).optional(),
|
||||
dosesPerUnit: z.number().int().nullable().optional(),
|
||||
unitsPerDose: z.string().min(1).optional(),
|
||||
active: z.boolean().optional(),
|
||||
notificationsEnabled: z.boolean().optional(),
|
||||
pausedAt: isoDateTime.nullable().optional(),
|
||||
snoozedUntil: isoDateTime.nullable().optional(),
|
||||
startsOn: isoDateTime.nullable().optional(),
|
||||
endsOn: isoDateTime.nullable().optional(),
|
||||
oneShot: z.boolean().optional(),
|
||||
asNeeded: z.boolean().optional(),
|
||||
deliveryForm: z.enum(MedicationDeliveryForm).optional(),
|
||||
trackInjectionSites: z.boolean().optional(),
|
||||
allowedInjectionSites: z.array(z.enum(InjectionSite)).optional(),
|
||||
liveActivityEnabled: z.boolean().optional(),
|
||||
criticalAlarmEnabled: z.boolean().optional(),
|
||||
atcCode: z.string().nullable().optional(),
|
||||
rxNormCode: z.string().nullable().optional(),
|
||||
lowStockNotifiedAt: isoDateTime.nullable().optional(),
|
||||
lowStockNotifiedThresholdDays: z.number().int().nullable().optional(),
|
||||
reorderLeadDays: z.number().int().nullable().optional(),
|
||||
externalSource: z.enum(IntakeSource).nullable().optional(),
|
||||
externalId: z.string().nullable().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
schedules: z.array(medicationScheduleSchema).default([]),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const intakeEventSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
medicationId: z.string().min(1).optional(),
|
||||
medication: z.string().min(1),
|
||||
scheduledFor: isoDateTime,
|
||||
takenAt: isoDateTime.nullable().optional(),
|
||||
skipped: z.boolean().optional(),
|
||||
autoMissed: z.boolean().optional(),
|
||||
attributionSource: z.enum(IntakeAttributionSource).optional(),
|
||||
source: z.enum(IntakeSource).optional(),
|
||||
idempotencyKey: z.string().nullable().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
injectionSite: z.enum(InjectionSite).nullable().optional(),
|
||||
doseTaken: z.string().nullable().optional(),
|
||||
inventoryConsumption: z.unknown().nullable().optional(),
|
||||
externalId: z.string().nullable().optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
syncVersion: z.number().int().optional(),
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -156,13 +233,19 @@ const moodEntrySchema = z
|
||||
*/
|
||||
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();
|
||||
|
||||
@@ -175,7 +258,9 @@ const cycleSpanSchema = z
|
||||
*/
|
||||
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(),
|
||||
@@ -195,6 +280,10 @@ const cycleDayLogSchema = z
|
||||
source: z.enum(MeasurementSource).optional(),
|
||||
externalId: z.string().nullable().optional(),
|
||||
tz: z.string().nullable().optional(),
|
||||
syncVersion: z.number().int().optional(),
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
symptomKeys: z.array(z.string()).default([]),
|
||||
})
|
||||
.passthrough();
|
||||
@@ -202,6 +291,7 @@ const cycleDayLogSchema = z
|
||||
/** 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(),
|
||||
@@ -212,6 +302,53 @@ const cycleProfileSchema = z
|
||||
rawChartMode: z.boolean().optional(),
|
||||
discreetNotifications: z.boolean().optional(),
|
||||
sensitiveCategoryEncryption: z.boolean().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const appSettingsBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
registrationEnabled: z.boolean(),
|
||||
mfaRequired: z.boolean(),
|
||||
defaultLocale: z.string(),
|
||||
telegramGlobal: z.boolean(),
|
||||
ntfyGlobal: z.boolean(),
|
||||
webPushGlobal: z.boolean(),
|
||||
webPushVapidPublicKey: z.string().nullable(),
|
||||
webPushVapidPrivateKeyEncrypted: z.string().nullable(),
|
||||
webPushVapidSubject: z.string().nullable(),
|
||||
apiGlobal: z.boolean(),
|
||||
moodLogGlobal: z.boolean(),
|
||||
umamiEnabled: z.boolean(),
|
||||
umamiScriptUrl: z.string().nullable(),
|
||||
umamiWebsiteId: z.string().nullable(),
|
||||
glitchtipEnabled: z.boolean(),
|
||||
glitchtipDsn: z.string().nullable(),
|
||||
glitchtipEnvironment: z.string().nullable(),
|
||||
reminderLateMinutes: z.number().int(),
|
||||
reminderMissedMinutes: z.number().int(),
|
||||
adminAiKeyEncrypted: z.string().nullable(),
|
||||
adminAiModel: z.string(),
|
||||
adminAiBaseUrl: z.string(),
|
||||
adminCodexAccessTokenEncrypted: z.string().nullable(),
|
||||
adminCodexRefreshTokenEncrypted: z.string().nullable(),
|
||||
adminCodexAccountIdEncrypted: z.string().nullable(),
|
||||
adminCodexTokenExpiresAt: isoDateTime.nullable(),
|
||||
adminCodexConnectedAt: isoDateTime.nullable(),
|
||||
adminCodexConnectionStatus: z.string(),
|
||||
adminAiInsightsFeedbackSummary: z.unknown().nullable(),
|
||||
defaultUserTimezone: z.string().nullable(),
|
||||
assistantEnabled: z.boolean(),
|
||||
assistantCoachEnabled: z.boolean(),
|
||||
assistantBriefingEnabled: z.boolean(),
|
||||
assistantInsightStatusEnabled: z.boolean(),
|
||||
assistantCorrelationsEnabled: z.boolean(),
|
||||
assistantHealthScoreExplainerEnabled: z.boolean(),
|
||||
moduleAvailabilityJson: z.unknown().nullable(),
|
||||
documentMaxFileBytes: z.number().int(),
|
||||
documentQuotaBytes: z.string().regex(/^\d+$/),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -237,8 +374,11 @@ const labResultBackupSchema = z
|
||||
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();
|
||||
@@ -275,6 +415,10 @@ const illnessDayLogBackupSchema = z
|
||||
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();
|
||||
|
||||
@@ -291,6 +435,8 @@ const illnessEpisodeBackupSchema = z
|
||||
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([]),
|
||||
})
|
||||
@@ -308,6 +454,9 @@ const allergyBackupSchema = z
|
||||
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();
|
||||
@@ -346,14 +495,6 @@ const workoutBackupSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
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 documentBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
@@ -401,9 +542,10 @@ const backupManifestSchema = z
|
||||
*/
|
||||
export const backupPayloadSchema = z
|
||||
.object({
|
||||
schemaVersion: z.string().min(1).default(BACKUP_SCHEMA_VERSION),
|
||||
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([]),
|
||||
@@ -425,7 +567,19 @@ export const backupPayloadSchema = z
|
||||
documents: z.array(documentBackupSchema).default([]),
|
||||
manifest: backupManifestSchema.nullable().default(null),
|
||||
})
|
||||
.passthrough();
|
||||
.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>;
|
||||
|
||||
@@ -512,5 +666,8 @@ export function parseBackupPayload(input: string | unknown): BackupPayload {
|
||||
* new shape carried.
|
||||
*/
|
||||
export function isCompatibleSchemaVersion(version: string): boolean {
|
||||
return version === BACKUP_SCHEMA_VERSION;
|
||||
return (
|
||||
version === LEGACY_BACKUP_SCHEMA_VERSION ||
|
||||
version === BACKUP_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
process.env.ENCRYPTION_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
@@ -117,6 +118,50 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
const prisma = getPrismaClient();
|
||||
const admin = await seedAdminSession();
|
||||
const ownerId = admin.id;
|
||||
const settings = await prisma.appSettings.create({
|
||||
data: {
|
||||
id: "singleton",
|
||||
registrationEnabled: false,
|
||||
mfaRequired: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: false,
|
||||
ntfyGlobal: false,
|
||||
webPushGlobal: false,
|
||||
webPushVapidPublicKey: "public-dr",
|
||||
webPushVapidPrivateKeyEncrypted: "private-dr",
|
||||
webPushVapidSubject: "mailto:dr@example.test",
|
||||
apiGlobal: false,
|
||||
moodLogGlobal: false,
|
||||
umamiEnabled: true,
|
||||
umamiScriptUrl: "https://analytics.example.test/script.js",
|
||||
umamiWebsiteId: "website-dr",
|
||||
glitchtipEnabled: true,
|
||||
glitchtipDsn: "https://glitchtip.example.test/1",
|
||||
glitchtipEnvironment: "dr",
|
||||
reminderLateMinutes: 47,
|
||||
reminderMissedMinutes: 93,
|
||||
adminAiKeyEncrypted: "ai-key-dr",
|
||||
adminAiModel: "model-dr",
|
||||
adminAiBaseUrl: "https://ai.example.test/v1",
|
||||
adminCodexAccessTokenEncrypted: "access-dr",
|
||||
adminCodexRefreshTokenEncrypted: "refresh-dr",
|
||||
adminCodexAccountIdEncrypted: "account-dr",
|
||||
adminCodexTokenExpiresAt: new Date("2026-07-03T00:00:00.000Z"),
|
||||
adminCodexConnectedAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
adminCodexConnectionStatus: "connected",
|
||||
adminAiInsightsFeedbackSummary: { helpful: 8, total: 10 },
|
||||
defaultUserTimezone: "Europe/London",
|
||||
assistantEnabled: false,
|
||||
assistantCoachEnabled: false,
|
||||
assistantBriefingEnabled: false,
|
||||
assistantInsightStatusEnabled: false,
|
||||
assistantCorrelationsEnabled: false,
|
||||
assistantHealthScoreExplainerEnabled: false,
|
||||
moduleAvailabilityJson: { insights: true, nutrients: false },
|
||||
documentMaxFileBytes: 12_345_678,
|
||||
documentQuotaBytes: 9_876_543_210n,
|
||||
},
|
||||
});
|
||||
|
||||
const measurement = await prisma.measurement.create({
|
||||
data: {
|
||||
@@ -193,6 +238,146 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const medication = await prisma.medication.create({
|
||||
data: {
|
||||
id: "medication-dr",
|
||||
userId: ownerId,
|
||||
name: "Canonical medicine",
|
||||
dose: "2 mg",
|
||||
treatmentClass: "GENERIC",
|
||||
dosesPerUnit: 4,
|
||||
unitsPerDose: "0.5000",
|
||||
active: false,
|
||||
notificationsEnabled: false,
|
||||
pausedAt: new Date("2026-06-25T08:00:00.000Z"),
|
||||
snoozedUntil: new Date("2026-07-05T08:00:00.000Z"),
|
||||
startsOn: new Date("2026-06-01T00:00:00.000Z"),
|
||||
endsOn: new Date("2026-08-01T00:00:00.000Z"),
|
||||
asNeeded: false,
|
||||
deliveryForm: "INJECTION",
|
||||
trackInjectionSites: true,
|
||||
allowedInjectionSites: ["THIGH_LEFT", "THIGH_RIGHT"],
|
||||
liveActivityEnabled: true,
|
||||
criticalAlarmEnabled: true,
|
||||
atcCode: "A10BX10",
|
||||
rxNormCode: "12345",
|
||||
lowStockNotifiedAt: new Date("2026-06-26T08:00:00.000Z"),
|
||||
lowStockNotifiedThresholdDays: 7,
|
||||
reorderLeadDays: 3,
|
||||
externalSource: "APPLE_HEALTH",
|
||||
externalId: "medication-external-dr",
|
||||
schedules: {
|
||||
create: {
|
||||
id: "schedule-dr",
|
||||
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",
|
||||
scheduleType: "CYCLIC",
|
||||
cyclicOnWeeks: 3,
|
||||
cyclicOffWeeks: 1,
|
||||
doseWindows: [
|
||||
{ timeOfDay: "08:00", start: "07:30", end: "09:00" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { schedules: true },
|
||||
});
|
||||
const intake = await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
id: "intake-dr",
|
||||
userId: ownerId,
|
||||
medicationId: medication.id,
|
||||
scheduledFor: new Date("2026-06-29T08:00:00.000Z"),
|
||||
takenAt: new Date("2026-06-29T08:05:00.000Z"),
|
||||
autoMissed: false,
|
||||
attributionSource: "USER_PIN",
|
||||
source: "APPLE_HEALTH",
|
||||
idempotencyKey: "intake-idempotency-dr",
|
||||
injectionSite: "THIGH_LEFT",
|
||||
doseTaken: "1 mg",
|
||||
inventoryConsumption: [{ itemId: "pen-dr", units: 0.5 }],
|
||||
externalId: "intake-external-dr",
|
||||
syncVersion: 5,
|
||||
deletedAt: tombstoneDeletedAt,
|
||||
},
|
||||
});
|
||||
|
||||
const cycleProfile = await prisma.cycleProfile.create({
|
||||
data: {
|
||||
id: "cycle-profile-dr",
|
||||
userId: ownerId,
|
||||
goal: "GENERAL_HEALTH",
|
||||
cycleTrackingEnabled: true,
|
||||
typicalCycleLength: 28,
|
||||
typicalPeriodLength: 5,
|
||||
lutealPhaseLength: 14,
|
||||
secondarySymptom: "CERVIX",
|
||||
predictionEnabled: false,
|
||||
rawChartMode: true,
|
||||
discreetNotifications: true,
|
||||
sensitiveCategoryEncryption: true,
|
||||
},
|
||||
});
|
||||
const cycle = await prisma.menstrualCycle.create({
|
||||
data: {
|
||||
id: "cycle-dr",
|
||||
userId: ownerId,
|
||||
startDate: "2026-06-01",
|
||||
endDate: "2026-06-28",
|
||||
periodEndDate: "2026-06-05",
|
||||
lengthDays: 28,
|
||||
ovulationDate: "2026-06-14",
|
||||
ovulationConfirmed: true,
|
||||
isPredicted: false,
|
||||
tz: "Europe/London",
|
||||
syncVersion: 6,
|
||||
deletedAt: tombstoneDeletedAt,
|
||||
},
|
||||
});
|
||||
const cycleCategory =
|
||||
await prisma.cycleSymptomCategory.findFirstOrThrow();
|
||||
const cycleSymptom = await prisma.cycleSymptom.create({
|
||||
data: {
|
||||
id: "cycle-symptom-dr",
|
||||
userId: ownerId,
|
||||
categoryId: cycleCategory.id,
|
||||
key: "cycle_cramps_dr",
|
||||
labelKey: "cycle.symptom.crampsDr",
|
||||
},
|
||||
});
|
||||
const cycleDay = await prisma.cycleDayLog.create({
|
||||
data: {
|
||||
id: "cycle-day-dr",
|
||||
userId: ownerId,
|
||||
cycleId: cycle.id,
|
||||
date: "2026-06-02",
|
||||
flow: "HEAVY",
|
||||
intermenstrualBleeding: true,
|
||||
basalBodyTempC: 36.7,
|
||||
temperatureExcluded: true,
|
||||
ovulationTest: "NEGATIVE",
|
||||
cervicalMucus: "CREAMY",
|
||||
cervixPosition: "LOW",
|
||||
cervixFirmness: "FIRM",
|
||||
cervixOpening: "CLOSED",
|
||||
sexualActivity: false,
|
||||
sensitiveEncrypted: "sensitive-cycle-dr",
|
||||
notesEncrypted: "notes-cycle-dr",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "cycle-day-external-dr",
|
||||
tz: "Europe/London",
|
||||
syncVersion: 7,
|
||||
deletedAt: tombstoneDeletedAt,
|
||||
symptomLinks: { create: { symptomId: cycleSymptom.id } },
|
||||
},
|
||||
});
|
||||
|
||||
const biomarker = await prisma.biomarker.create({
|
||||
data: {
|
||||
id: "biomarker-dr",
|
||||
@@ -219,6 +404,7 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
takenAt: new Date("2026-06-30T09:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
noteEncrypted: encryptToBytes("Fasted"),
|
||||
deletedAt: tombstoneDeletedAt,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -232,6 +418,7 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
onsetAt: new Date("2026-06-20T00:00:00.000Z"),
|
||||
resolvedAt: new Date("2026-06-24T00:00:00.000Z"),
|
||||
noteEncrypted: encryptToBytes("Recovered fully"),
|
||||
deletedAt: tombstoneDeletedAt,
|
||||
},
|
||||
});
|
||||
const symptom = await prisma.illnessSymptom.create({
|
||||
@@ -250,6 +437,8 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
functionalImpact: 2,
|
||||
feverC: 38.1,
|
||||
noteEncrypted: encryptToBytes("Rest day"),
|
||||
tz: "Europe/London",
|
||||
deletedAt: tombstoneDeletedAt,
|
||||
symptomLinks: {
|
||||
create: { symptomId: symptom.id, severity: 2 },
|
||||
},
|
||||
@@ -268,6 +457,7 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
onsetAt: new Date("2020-01-01T00:00:00.000Z"),
|
||||
reactionEncrypted: encryptToBytes("Hives"),
|
||||
notesEncrypted: encryptToBytes("Avoid beta-lactams"),
|
||||
deletedAt: tombstoneDeletedAt,
|
||||
},
|
||||
});
|
||||
const family = await prisma.familyHistoryEntry.create({
|
||||
@@ -342,14 +532,21 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
decryptBackup(offhostObject!, Buffer.from(OFFHOST_ENCRYPTION_KEY, "hex")),
|
||||
);
|
||||
expect(payload).toMatchObject({
|
||||
schemaVersion: "1",
|
||||
schemaVersion: "2",
|
||||
userId: ownerId,
|
||||
appSettings: { id: settings.id },
|
||||
});
|
||||
expect(payload.documents[0]).not.toHaveProperty("summary");
|
||||
expect(JSON.stringify(payload.documents[0])).not.toContain(
|
||||
"private PDF bytes",
|
||||
);
|
||||
|
||||
await prisma.appSettings.deleteMany();
|
||||
await prisma.medicationIntakeEvent.deleteMany({ where: { userId: ownerId } });
|
||||
await prisma.medication.deleteMany({ where: { userId: ownerId } });
|
||||
await prisma.cycleDayLog.deleteMany({ where: { userId: ownerId } });
|
||||
await prisma.menstrualCycle.deleteMany({ where: { userId: ownerId } });
|
||||
await prisma.cycleProfile.deleteMany({ where: { userId: ownerId } });
|
||||
await prisma.inboundDocument.deleteMany({ where: { userId: ownerId } });
|
||||
await prisma.workout.deleteMany({ where: { userId: ownerId } });
|
||||
await prisma.familyHistoryEntry.deleteMany({ where: { userId: ownerId } });
|
||||
@@ -373,6 +570,44 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
|
||||
expect((await restore()).status).toBe(200);
|
||||
|
||||
expect(
|
||||
await prisma.appSettings.findUniqueOrThrow({
|
||||
where: { id: settings.id },
|
||||
}),
|
||||
).toEqual(settings);
|
||||
expect(
|
||||
await prisma.medication.findUniqueOrThrow({
|
||||
where: { id: medication.id },
|
||||
include: { schedules: true },
|
||||
}),
|
||||
).toEqual(medication);
|
||||
expect(
|
||||
await prisma.medicationIntakeEvent.findUniqueOrThrow({
|
||||
where: { id: intake.id },
|
||||
}),
|
||||
).toEqual(intake);
|
||||
expect(
|
||||
await prisma.cycleProfile.findUniqueOrThrow({
|
||||
where: { id: cycleProfile.id },
|
||||
}),
|
||||
).toEqual(cycleProfile);
|
||||
expect(
|
||||
await prisma.menstrualCycle.findUniqueOrThrow({
|
||||
where: { id: cycle.id },
|
||||
}),
|
||||
).toEqual(cycle);
|
||||
expect(
|
||||
await prisma.cycleDayLog.findUniqueOrThrow({
|
||||
where: { id: cycleDay.id },
|
||||
}),
|
||||
).toEqual(cycleDay);
|
||||
expect(
|
||||
await prisma.cycleSymptomLink.findMany({
|
||||
where: { dayLogId: cycleDay.id },
|
||||
select: { symptomId: true },
|
||||
}),
|
||||
).toEqual([{ symptomId: cycleSymptom.id }]);
|
||||
|
||||
expect(await prisma.labResult.count({ where: { userId: ownerId } })).toBe(
|
||||
1,
|
||||
);
|
||||
@@ -397,45 +632,19 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
await prisma.measurement.findUniqueOrThrow({
|
||||
where: { id: measurement.id },
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
userId: ownerId,
|
||||
type: measurement.type,
|
||||
value: measurement.value,
|
||||
unit: measurement.unit,
|
||||
measuredAt: measurement.measuredAt,
|
||||
source: measurement.source,
|
||||
deletedAt: null,
|
||||
}),
|
||||
);
|
||||
).toEqual(measurement);
|
||||
expect(
|
||||
await prisma.measurement.findUniqueOrThrow({
|
||||
where: { id: deletedMeasurement.id },
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
userId: ownerId,
|
||||
deletedAt: tombstoneDeletedAt,
|
||||
}),
|
||||
);
|
||||
).toEqual(deletedMeasurement);
|
||||
|
||||
const restoredLab = await prisma.labResult.findUniqueOrThrow({
|
||||
where: { id: lab.id },
|
||||
});
|
||||
expect(decryptFromBytes(restoredLab.noteEncrypted!)).toBe("Fasted");
|
||||
expect(restoredLab.biomarkerId).toBe(biomarker.id);
|
||||
expect(restoredLab).toEqual(
|
||||
expect.objectContaining({
|
||||
panel: lab.panel,
|
||||
analyte: lab.analyte,
|
||||
value: lab.value,
|
||||
unit: lab.unit,
|
||||
referenceLow: lab.referenceLow,
|
||||
referenceHigh: lab.referenceHigh,
|
||||
takenAt: lab.takenAt,
|
||||
source: lab.source,
|
||||
}),
|
||||
);
|
||||
expect(restoredLab).toEqual(lab);
|
||||
const restoredBiomarker = await prisma.biomarker.findUniqueOrThrow({
|
||||
where: { id: biomarker.id },
|
||||
});
|
||||
@@ -456,26 +665,22 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
const restoredIllness = await prisma.illnessEpisode.findUniqueOrThrow({
|
||||
where: { id: illness.id },
|
||||
});
|
||||
expect(restoredIllness).toEqual(
|
||||
expect.objectContaining({
|
||||
label: illness.label,
|
||||
type: illness.type,
|
||||
lifecycle: illness.lifecycle,
|
||||
onsetAt: illness.onsetAt,
|
||||
resolvedAt: illness.resolvedAt,
|
||||
}),
|
||||
);
|
||||
expect(restoredIllness).toEqual(illness);
|
||||
expect(decryptFromBytes(restoredIllness.noteEncrypted!)).toBe(
|
||||
"Recovered fully",
|
||||
);
|
||||
const restoredIllnessDay = await prisma.illnessDayLog.findUniqueOrThrow({
|
||||
where: { id: illnessDay.id },
|
||||
include: { symptomLinks: true },
|
||||
});
|
||||
expect(restoredIllnessDay).toEqual(illnessDay);
|
||||
expect(decryptFromBytes(restoredIllnessDay.noteEncrypted!)).toBe(
|
||||
"Rest day",
|
||||
);
|
||||
expect(restoredIllnessDay.symptomLinks).toEqual([
|
||||
expect(
|
||||
await prisma.illnessSymptomLink.findMany({
|
||||
where: { dayLogId: illnessDay.id },
|
||||
}),
|
||||
).toEqual([
|
||||
expect.objectContaining({ symptomId: symptom.id, severity: 2 }),
|
||||
]);
|
||||
|
||||
@@ -483,16 +688,7 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
where: { id: allergy.id },
|
||||
});
|
||||
expect(decryptFromBytes(restoredAllergy.reactionEncrypted!)).toBe("Hives");
|
||||
expect(restoredAllergy).toEqual(
|
||||
expect.objectContaining({
|
||||
substance: allergy.substance,
|
||||
category: allergy.category,
|
||||
type: allergy.type,
|
||||
severity: allergy.severity,
|
||||
status: allergy.status,
|
||||
onsetAt: allergy.onsetAt,
|
||||
}),
|
||||
);
|
||||
expect(restoredAllergy).toEqual(allergy);
|
||||
const restoredFamily = await prisma.familyHistoryEntry.findUniqueOrThrow({
|
||||
where: { id: family.id },
|
||||
});
|
||||
@@ -612,6 +808,149 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
}),
|
||||
).toEqual([{ id: illness.id }]);
|
||||
});
|
||||
|
||||
it("rejects a stable measurement id owned by another account without clearing either owner", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const admin = await seedAdminSession();
|
||||
const other = await prisma.user.create({
|
||||
data: { username: "stable-id-owner-other" },
|
||||
});
|
||||
const original = await prisma.measurement.create({
|
||||
data: {
|
||||
id: "owner-original-measurement",
|
||||
userId: admin.id,
|
||||
type: "PULSE",
|
||||
value: 70,
|
||||
unit: "bpm",
|
||||
measuredAt: new Date("2026-07-08T10:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
const conflicting = await prisma.measurement.create({
|
||||
data: {
|
||||
id: "cross-owner-stable-id",
|
||||
userId: other.id,
|
||||
type: "PULSE",
|
||||
value: 65,
|
||||
unit: "bpm",
|
||||
measuredAt: new Date("2026-07-08T11:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
const payload = backupPayloadSchema.parse({
|
||||
schemaVersion: "2",
|
||||
exportedAt: "2026-07-12T00:00:00.000Z",
|
||||
userId: admin.id,
|
||||
measurements: [
|
||||
{
|
||||
id: conflicting.id,
|
||||
type: "PULSE",
|
||||
value: 80,
|
||||
unit: "bpm",
|
||||
measuredAt: "2026-07-08T12:00:00.000Z",
|
||||
source: "IMPORT",
|
||||
},
|
||||
],
|
||||
});
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
type: "STABLE_ID_OWNER_CONFLICT",
|
||||
data: encrypt(JSON.stringify(payload)),
|
||||
},
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
|
||||
{ params: Promise.resolve({ id: backup.id }) },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(
|
||||
await prisma.measurement.findUnique({ where: { id: original.id } }),
|
||||
).toEqual(original);
|
||||
expect(
|
||||
await prisma.measurement.findUnique({ where: { id: conflicting.id } }),
|
||||
).toEqual(conflicting);
|
||||
});
|
||||
|
||||
it("restores many stable-id measurements with a bounded number of writes", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const admin = await seedAdminSession();
|
||||
const measurements = Array.from({ length: 250 }, (_, index) => ({
|
||||
id: `stable-measurement-${index}`,
|
||||
type: "PULSE" as const,
|
||||
value: 60 + (index % 20),
|
||||
unit: "bpm",
|
||||
measuredAt: new Date(
|
||||
Date.UTC(2026, 6, 10, 0, 0, 0, index),
|
||||
).toISOString(),
|
||||
source: "IMPORT" as const,
|
||||
externalId: `external-${index}`,
|
||||
syncVersion: 3,
|
||||
deletedAt: index % 10 === 0 ? "2026-07-11T00:00:00.000Z" : null,
|
||||
}));
|
||||
const payload = backupPayloadSchema.parse({
|
||||
schemaVersion: "2",
|
||||
exportedAt: "2026-07-12T00:00:00.000Z",
|
||||
userId: admin.id,
|
||||
measurements,
|
||||
});
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
type: "BOUNDED_STABLE_ID_RESTORE",
|
||||
data: encrypt(JSON.stringify(payload)),
|
||||
},
|
||||
});
|
||||
let stableWriteQueries = 0;
|
||||
const originalTransaction = prisma.$transaction.bind(prisma) as unknown as (
|
||||
callback: (tx: Prisma.TransactionClient) => Promise<unknown>,
|
||||
options?: { maxWait?: number; timeout?: number },
|
||||
) => Promise<unknown>;
|
||||
const transactionSpy = vi.spyOn(prisma, "$transaction");
|
||||
transactionSpy.mockImplementation(
|
||||
(async (
|
||||
callback: (tx: Prisma.TransactionClient) => Promise<unknown>,
|
||||
options?: { maxWait?: number; timeout?: number },
|
||||
) =>
|
||||
originalTransaction(async (tx) => {
|
||||
const originalUpsert = tx.measurement.upsert.bind(tx.measurement);
|
||||
const originalCreateMany = tx.measurement.createMany.bind(
|
||||
tx.measurement,
|
||||
);
|
||||
vi.spyOn(tx.measurement, "upsert").mockImplementation(async (args) => {
|
||||
stableWriteQueries += 1;
|
||||
return originalUpsert(args);
|
||||
});
|
||||
vi.spyOn(tx.measurement, "createMany").mockImplementation(
|
||||
async (args) => {
|
||||
stableWriteQueries += 1;
|
||||
return originalCreateMany(args);
|
||||
},
|
||||
);
|
||||
return callback(tx);
|
||||
}, options)) as never,
|
||||
);
|
||||
|
||||
const response = await POST(
|
||||
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
|
||||
{ params: Promise.resolve({ id: backup.id }) },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
transactionSpy.mockRestore();
|
||||
expect(stableWriteQueries).toBeLessThanOrEqual(2);
|
||||
expect(
|
||||
await prisma.measurement.findMany({
|
||||
where: { userId: admin.id },
|
||||
orderBy: { id: "asc" },
|
||||
select: { id: true },
|
||||
}),
|
||||
).toEqual(
|
||||
measurements
|
||||
.map(({ id }) => ({ id }))
|
||||
.sort((left, right) => left.id.localeCompare(right.id)),
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
label: "invalid enum",
|
||||
|
||||
Reference in new issue
Block a user