From 1a7050d95d8d85612e2121b716de19028a78a72e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 05:25:50 +0200 Subject: [PATCH] fix(backup): bring every relation link back from a portable restore A portable file carries no id for a medication or a lab result, so the restore mints fresh ones and the links that named the old ids were dropped as having no end. Carry the source id as ref on both entries and re-point the links through an old-to-new map on restore. --- src/lib/export/full-backup-payload.ts | 4 +- src/lib/export/records-backup.ts | 5 + src/lib/export/relation-links-backup.ts | 39 ++- src/lib/export/restore-backup.ts | 19 ++ src/lib/validations/backup.ts | 6 + .../relation-links-portable-restore.test.ts | 229 ++++++++++++++++++ 6 files changed, 297 insertions(+), 5 deletions(-) create mode 100644 tests/integration/relation-links-portable-restore.test.ts 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]); + }); +});