diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 5abd26b7d..2879e98f9 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -1094,7 +1094,9 @@ export async function buildFullBackupPayload( externalId: m.externalId, updatedAt: m.updatedAt.toISOString(), } - : {}), + : // No id: a portable restore mints a fresh one. The relation links + // still name this drug, so the file says which row they mean. + { ref: m.id }), name: m.name, dose: m.dose, active: m.active, diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts index f2ca65bd2..239d3e5a3 100644 --- a/src/lib/export/records-backup.ts +++ b/src/lib/export/records-backup.ts @@ -83,6 +83,8 @@ function readForExport( export interface LabResultBackupEntry { /** Present in canonical DR payloads so rows keep a stable identity. */ id?: string; + /** The source row's id in a portable payload, for the relation links. */ + ref?: string; panel: string | null; analyte: string; value: number | null; @@ -455,6 +457,9 @@ export async function buildRecordsBackupSection( note: null, } : { + // No id: a portable restore mints a fresh one. The relation links + // still name this row, so the file says which row they mean. + ref: r.id, note: readForExport( r.noteEncrypted, "lab note", diff --git a/src/lib/export/relation-links-backup.ts b/src/lib/export/relation-links-backup.ts index e9a0f91c4..ed9ab58ce 100644 --- a/src/lib/export/relation-links-backup.ts +++ b/src/lib/export/relation-links-backup.ts @@ -244,6 +244,19 @@ function placeable( return kept; } +/** + * The id each medication and lab result had in the file, to the id the + * restore gave it. A portable file carries neither row's id (a restore there + * mints fresh ones), only a `ref` naming the source row, so a link that names + * the source id is re-pointed through this map. An id the map does not hold is + * used as it is: a disaster-recovery file keeps its ids, and a hand-edited one + * falls through to the placement check below. + */ +export interface RelationLinkSourceIds { + medications: ReadonlyMap; + labResults: ReadonlyMap; +} + /** * Re-create the five link tables. * @@ -261,7 +274,13 @@ export async function restoreRelationLinksData( ownerId: string, payload: RelationLinksBackupSection, skips: RestoreSkipLog, + sourceIds: RelationLinkSourceIds = { + medications: new Map(), + labResults: new Map(), + }, ): Promise { + const medication = (id: string) => sourceIds.medications.get(id) ?? id; + const labResult = (id: string) => sourceIds.labResults.get(id) ?? id; const owned = { where: { userId: ownerId } }; const [c1, c2, c3, c4, c5] = await Promise.all([ tx.labResultDocumentLink.deleteMany(owned), @@ -278,7 +297,10 @@ export async function restoreRelationLinksData( }); const labDocs = placeable( - payload.labResultDocumentLinks, + payload.labResultDocumentLinks.map((r) => ({ + ...r, + labResultId: labResult(r.labResultId), + })), [ [(r) => r.labResultId, written.labResults], [(r) => r.documentId, written.documents], @@ -297,7 +319,10 @@ export async function restoreRelationLinksData( } const encounterMeds = placeable( - payload.encounterMedicationLinks, + payload.encounterMedicationLinks.map((r) => ({ + ...r, + medicationId: medication(r.medicationId), + })), [ [(r) => r.encounterId, written.encounters], [(r) => r.medicationId, written.medications], @@ -335,7 +360,10 @@ export async function restoreRelationLinksData( } const medDocs = placeable( - payload.medicationDocumentLinks, + payload.medicationDocumentLinks.map((r) => ({ + ...r, + medicationId: medication(r.medicationId), + })), [ [(r) => r.medicationId, written.medications], [(r) => r.documentId, written.documents], @@ -354,7 +382,10 @@ export async function restoreRelationLinksData( } const medConditions = placeable( - payload.medicationConditionLinks, + payload.medicationConditionLinks.map((r) => ({ + ...r, + medicationId: medication(r.medicationId), + })), [ [(r) => r.medicationId, written.medications], [(r) => r.episodeId, written.episodes], diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 143689656..c860002cf 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -1158,6 +1158,12 @@ export async function restoreBackup( const medByName = new Map(); const restoredMedicationIds = new Set(); + // The id each medication had in the source account, to the id it got + // here. A disaster-recovery file keeps the id, so this is the + // identity there; a portable file names the source row only through + // `ref`, and the relation links (written last) are re-pointed through + // this map instead of being dropped as "end is gone". + const medicationIdBySourceId = new Map(); // The efficacy targets cannot ride inside the medication create // beside the schedules and the packs: `biomarkerId` is a foreign key // into a catalogue this transaction has not restored yet. They are @@ -1454,6 +1460,10 @@ export async function restoreBackup( }, }); restoredMedicationIds.add(created.id); + const medicationSourceId = m.id ?? m.ref; + if (medicationSourceId) { + medicationIdBySourceId.set(medicationSourceId, created.id); + } if (!medByName.has(m.name)) medByName.set(m.name, created.id); // v1.39.4 — the clinical category lives in its own table keyed on the // medication id, so it is written after the row exists and inside the @@ -2172,6 +2182,8 @@ export async function restoreBackup( // `committedRecordId` names the lab result it was committed to, and // that reference is resolved against the rows this loop writes. const restoredLabResultIds = new Set(); + // Same arrangement as `medicationIdBySourceId`, for the lab results. + const labResultIdBySourceId = new Map(); // What each drug was supposed to move, written now that both ends // exist. The drugs came back several hundred lines up and the // analytes only just now, and one of those two had to move for a @@ -2280,6 +2292,9 @@ export async function restoreBackup( }, }); restoredLabResultIds.add(createdLab.id); + const labSourceId = lab.id ?? lab.ref; + if (labSourceId) + labResultIdBySourceId.set(labSourceId, createdLab.id); } reportSection("illness"); @@ -2683,6 +2698,10 @@ export async function restoreBackup( ownerId, payload, skips, + { + medications: medicationIdBySourceId, + labResults: labResultIdBySourceId, + }, ); // The Coach's threads, turns and document links. AFTER the diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index bb6ad4f24..46f816c19 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -392,6 +392,10 @@ const customMedicationCategorySchema = z const medicationSchema = z .object({ id: z.string().min(1).optional(), + // The source row's id in a portable file, which carries no `id`: the + // relation links name the medication by it, and the restore re-points + // them at the row it writes (`relation-links-backup.ts`). + ref: z.string().min(1).optional(), name: z.string().min(1), dose: z.string(), treatmentClass: z.enum(MedicationCategory).optional(), @@ -1126,6 +1130,8 @@ const appSettingsBackupSchema = z const labResultBackupSchema = z .object({ id: z.string().min(1).optional(), + // The source row's id in a portable file, as on a medication. + ref: z.string().min(1).optional(), panel: z.string().nullable().optional(), analyte: z.string().min(1), value: z.number().nullable().optional(), diff --git a/tests/integration/relation-links-portable-restore.test.ts b/tests/integration/relation-links-portable-restore.test.ts new file mode 100644 index 000000000..308369684 --- /dev/null +++ b/tests/integration/relation-links-portable-restore.test.ts @@ -0,0 +1,229 @@ +/** + * The five relation link tables survive a PORTABLE export and restore. + * + * A portable file carries no row id for a medication or a lab result: the + * restore mints fresh ones, because the file may land on another instance or + * another account where the original ids mean nothing. The link tables name + * both of their ends by id, so a link that rode the file under the OLD id of + * either end used to point at nothing after the restore, and the restore's + * own placement check dropped it as "end is gone". Four of the five tables + * have a medication or a lab result at one end; only the visit to dose link + * came back. + * + * Driven through the real restore route. A portable file with documents is + * refused (no document bytes, see `backup-portable-file-restore.test.ts`), so + * the file restored here is the portable payload with the document entries + * taken from the disaster-recovery build of the same account: everything else + * (medications, lab results, links) is the portable bytes exactly as the + * export writes them. + * + * Mutation check: drop the `exportRef` from the portable medication or lab + * result entry and the matching link count goes to zero. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { linkTargets } from "@/lib/links"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { POST } from "./restore-job-driver"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +async function seed() { + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { + username: "portable-links-owner", + email: "portable-links-owner@example.test", + role: "ADMIN", + }, + }); + const userId = owner.id; + const session = await prisma.session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + + const encounter = await prisma.encounter.create({ + data: { + userId, + occurredAt: new Date("2026-07-01T09:00:00.000Z"), + status: "DONE", + kind: "ROUTINE", + }, + }); + const document = await prisma.inboundDocument.create({ + data: { + userId, + kind: "LAB_RESULT", + title: "Thyroid panel", + mimeType: "application/pdf", + byteSize: 12, + contentEncrypted: new Uint8Array([1, 2, 3]), + contentCodec: "binary2", + documentDate: new Date("2026-06-30T00:00:00.000Z"), + }, + }); + const biomarker = await prisma.biomarker.create({ + data: { userId, name: "TSH", unit: "mU/L" }, + }); + const labResult = await prisma.labResult.create({ + data: { + userId, + biomarkerId: biomarker.id, + analyte: "TSH", + value: 2.1, + unit: "mU/L", + takenAt: new Date("2026-06-30T09:00:00.000Z"), + }, + }); + const episode = await prisma.illnessEpisode.create({ + data: { + userId, + label: "Thyroid", + type: "CHRONIC", + onsetAt: new Date("2026-01-20T00:00:00.000Z"), + }, + }); + const medication = await prisma.medication.create({ + data: { userId, name: "Levothyroxine", dose: "50 µg" }, + }); + // A second drug with no links, so a restore that bound every link to the + // first medication it found would be told apart from one that maps by key. + await prisma.medication.create({ + data: { userId, name: "Vitamin D", dose: "1000 IU" }, + }); + const vaccination = await prisma.vaccinationRecord.create({ + data: { + userId, + occurredAt: new Date("2026-05-14T00:00:00.000Z"), + antigenSlug: "tdap", + vaccineName: "Tdap", + }, + }); + + const link = ( + sourceKind: Parameters[1]["sourceKind"], + sourceId: string, + targetKind: Parameters[1]["targetKind"], + targetId: string, + ) => + linkTargets(prisma, { + userId, + sourceKind, + sourceId, + targetKind, + targetIds: [targetId], + }); + await link("labResult", labResult.id, "document", document.id); + await link("encounter", encounter.id, "medication", medication.id); + await link("encounter", encounter.id, "vaccination", vaccination.id); + await link("medication", medication.id, "document", document.id); + await link("medication", medication.id, "conditionEpisode", episode.id); + + return { userId, medication, labResult }; +} + +describe("a portable restore of the five relation link tables", () => { + it("brings every link back, bound to the records the restore wrote", async () => { + const prisma = getPrismaClient(); + const { userId, medication, labResult } = await seed(); + + const portable = parseBackupPayload( + JSON.stringify((await buildFullBackupPayload(prisma, userId)).payload), + ); + const dr = parseBackupPayload( + JSON.stringify( + ( + await buildFullBackupPayload(prisma, userId, { + purpose: "disaster-recovery", + }) + ).payload, + ), + ); + // The premise: the portable file names no medication or lab result id. + expect(portable.medications.every((m) => m.id === undefined)).toBe(true); + expect(portable.labResults.every((l) => l.id === undefined)).toBe(true); + + const file = parseBackupPayload( + JSON.stringify({ ...portable, documents: dr.documents }), + ); + const backup = await prisma.dataBackup.create({ + data: { + userId, + type: "PORTABLE_LINKS_RESTORE", + data: encrypt(JSON.stringify(file)), + }, + }); + + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status, await response.clone().text()).toBe(200); + + // The ends were re-minted, so the old ids are gone. + const restoredMed = await prisma.medication.findFirstOrThrow({ + where: { userId, name: "Levothyroxine" }, + }); + const restoredLab = await prisma.labResult.findFirstOrThrow({ + where: { userId, analyte: "TSH" }, + }); + expect(restoredMed.id).not.toBe(medication.id); + expect(restoredLab.id).not.toBe(labResult.id); + + const where = { userId }; + const [labDocs, encMeds, encDoses, medDocs, medConds] = await Promise.all([ + prisma.labResultDocumentLink.findMany({ where }), + prisma.encounterMedicationLink.findMany({ where }), + prisma.encounterVaccinationLink.findMany({ where }), + prisma.medicationDocumentLink.findMany({ where }), + prisma.medicationConditionLink.findMany({ where }), + ]); + expect(labDocs.map((r) => r.labResultId)).toEqual([restoredLab.id]); + expect(encMeds.map((r) => r.medicationId)).toEqual([restoredMed.id]); + expect(encDoses).toHaveLength(1); + expect(medDocs.map((r) => r.medicationId)).toEqual([restoredMed.id]); + expect(medConds.map((r) => r.medicationId)).toEqual([restoredMed.id]); + }); +});