From 6926caa11dbef0d6a1876dca5eb81a0b3e5215e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 13:54:24 +0200 Subject: [PATCH] feat(vaccinations): carry the immunization log through export and restore - a new backup section reads the dose and its document link through their own delegates and writes both ends in one file, the arrangement the visit section already uses - the practitioner and the visit remap to the rows the same restore put back; the booster reminder never travels, so its reference resolves to null and the drop is reported rather than swallowed - the antigen slug restores verbatim: a slug the catalogue has since dropped still says what a person was given - the round trip seeds a dose with every optional column filled and reads each one back, so a returning row count cannot stand in for a returning record --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../[id]/restore/__tests__/round-trip.test.ts | 2 + .../api/admin/backups/[id]/restore/route.ts | 17 + .../export/__tests__/per-type-routes.test.ts | 6 + .../export/encrypted/__tests__/route.test.ts | 2 + src/components/admin/backups-section.tsx | 3 + .../__tests__/full-backup-payload.test.ts | 2 + src/lib/export/backup-plan.ts | 5 + src/lib/export/full-backup-payload.ts | 19 +- src/lib/export/restore-skips.ts | 10 +- src/lib/export/vaccinations-backup.ts | 418 ++++++++++++++++++ src/lib/validations/backup.ts | 45 ++ tests/integration/backup-round-trip.test.ts | 166 ++++++- 18 files changed, 698 insertions(+), 3 deletions(-) create mode 100644 src/lib/export/vaccinations-backup.ts diff --git a/messages/de.json b/messages/de.json index 22e0b9015..3b3b32bb6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6873,6 +6873,7 @@ "restoreSkippedMoodFactor": "Stimmungsfaktor", "restoreSkippedMoodTag": "Stimmungs-Tag", "restoreSkippedVisitReference": "Besuchsverknüpfung", + "restoreSkippedVaccinationReference": "Impfverknüpfung", "restoreSkippedCheckupClosure": "Vorsorge-Abschluss", "docsLink": "Dokumentation" }, diff --git a/messages/en.json b/messages/en.json index afacc42d2..e4dd15b3e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6873,6 +6873,7 @@ "restoreSkippedMoodFactor": "Mood factor", "restoreSkippedMoodTag": "Mood tag", "restoreSkippedVisitReference": "Visit reference", + "restoreSkippedVaccinationReference": "Vaccination reference", "restoreSkippedCheckupClosure": "Checkup closure", "docsLink": "Documentation" }, diff --git a/messages/es.json b/messages/es.json index 7cec1ba45..f59d0bfaa 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6873,6 +6873,7 @@ "restoreSkippedMoodFactor": "Factor de ánimo", "restoreSkippedMoodTag": "Etiqueta de ánimo", "restoreSkippedVisitReference": "Referencia de visita", + "restoreSkippedVaccinationReference": "Referencia de vacunación", "restoreSkippedCheckupClosure": "Cierre de revisión", "docsLink": "Documentación" }, diff --git a/messages/fr.json b/messages/fr.json index d91f2cf3d..c385161f5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6873,6 +6873,7 @@ "restoreSkippedMoodFactor": "Facteur d'humeur", "restoreSkippedMoodTag": "Étiquette d'humeur", "restoreSkippedVisitReference": "Référence de visite", + "restoreSkippedVaccinationReference": "Référence de vaccination", "restoreSkippedCheckupClosure": "Clôture d'un examen", "docsLink": "Documentation" }, diff --git a/messages/it.json b/messages/it.json index 60bfd0bf4..b5e7ea5db 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6873,6 +6873,7 @@ "restoreSkippedMoodFactor": "Fattore dell'umore", "restoreSkippedMoodTag": "Etichetta dell'umore", "restoreSkippedVisitReference": "Riferimento alla visita", + "restoreSkippedVaccinationReference": "Riferimento alla vaccinazione", "restoreSkippedCheckupClosure": "Chiusura del controllo", "docsLink": "Documentazione" }, diff --git a/messages/pl.json b/messages/pl.json index cd1f4c71e..c754c729f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6873,6 +6873,7 @@ "restoreSkippedMoodFactor": "Czynnik nastroju", "restoreSkippedMoodTag": "Tag nastroju", "restoreSkippedVisitReference": "Odwołanie wizyty", + "restoreSkippedVaccinationReference": "Odwołanie szczepienia", "restoreSkippedCheckupClosure": "Zamknięcie badania", "docsLink": "Dokumentacja" }, diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index a1b1d2209..4d42c8519 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -290,6 +290,8 @@ function sourceClient() { encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, + vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, + vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, customMetric: { findMany: vi.fn().mockResolvedValue([ { diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 149213e80..d7fac6681 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -49,6 +49,7 @@ import { restoreProfileData } from "@/lib/export/profile-backup"; import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup"; import { restoreHealthScoreData } from "@/lib/export/health-score-backup"; import { restoreVisitsData } from "@/lib/export/visits-backup"; +import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup"; import { invalidateUserData } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -92,6 +93,8 @@ interface RestoreResponse { practitioners: number; encounters: number; encounterLinks: number; + vaccinations: number; + vaccinationLinks: number; }; } @@ -1332,6 +1335,18 @@ const handler = apiHandler( skips, ); + // The immunization log, after the visits: a dose remaps its + // practitioner and its encounter against rows the branch above has + // just written, and its document link needs the documents that + // landed before that. Both ends of this section live in + // `src/lib/export/vaccinations-backup.ts`. + const vaccinationsCleared = await restoreVaccinationsData( + tx, + ownerId, + payload, + skips, + ); + const cleared = { measurements: measurements.count, medications: meds.count, @@ -1361,6 +1376,8 @@ const handler = apiHandler( practitioners: visitsCleared.practitioners, encounters: visitsCleared.encounters, encounterLinks: visitsCleared.encounterLinks, + vaccinations: vaccinationsCleared.vaccinations, + vaccinationLinks: vaccinationsCleared.vaccinationLinks, }; return { cleared, skipped: summarizeRestoreSkips(skips) }; }, diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 16e1c3292..2237b5c1f 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -36,6 +36,8 @@ vi.mock("@/lib/db", () => ({ encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, + vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, + vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, @@ -115,6 +117,10 @@ beforeEach(() => { vi.mocked(prisma.encounterConditionLink.findMany).mockResolvedValue( [] as never, ); + vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue( + [] as never, + ); }); afterEach(() => { diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index f1e7b2723..47fc52e16 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -34,6 +34,8 @@ vi.mock("@/lib/db", () => ({ encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, + vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, + vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index b3b92484d..9b80465ea 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -201,6 +201,9 @@ function catalogueLabel( if (catalogue === "visitReference") { return t("admin.section.backups.restoreSkippedVisitReference"); } + if (catalogue === "vaccinationReference") { + return t("admin.section.backups.restoreSkippedVaccinationReference"); + } if (catalogue === "checkupClosure") { return t("admin.section.backups.restoreSkippedCheckupClosure"); } diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 8380077ea..c7aeccfbd 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -255,6 +255,8 @@ function makePrisma() { encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, + vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, + vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, // Left unmocked on purpose: `buildProfileBackupSection` runs for real // against these, so the assertions below exercise the builder rather than // a stand-in that would agree with whatever the payload happened to do. diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 12c5414e0..a1099de52 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -198,6 +198,10 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ // relation-shaped proof would be attributed to whichever one a matcher found // first — the `symptomLinks` confusion, avoided by construction. "src/lib/export/visits-backup.ts", + // The immunization log and its document link, same arrangement for the same + // reason: `documentLinks` is a field name on three models now, so each of + // these reads through its OWN delegate here. + "src/lib/export/vaccinations-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -207,6 +211,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [ "src/lib/export/intraday-profile-backup.ts", "src/lib/export/health-score-backup.ts", "src/lib/export/visits-backup.ts", + "src/lib/export/vaccinations-backup.ts", "src/lib/cycle/backup.ts", ]; diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index f587439c6..05a9537a7 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -43,6 +43,12 @@ import { type VisitsBackupCounts, type VisitsBackupSection, } from "@/lib/export/visits-backup"; +import { + buildVaccinationsBackupSection, + countVaccinationsBackupSection, + type VaccinationsBackupCounts, + type VaccinationsBackupSection, +} from "@/lib/export/vaccinations-backup"; import { buildIntradayProfileBackupSection, countIntradayProfileBackupSection, @@ -56,7 +62,8 @@ export interface FullBackupCounts ProfileBackupCounts, IntradayProfileBackupCounts, HealthScoreBackupCounts, - VisitsBackupCounts { + VisitsBackupCounts, + VaccinationsBackupCounts { measurements: number; medications: number; intakeEvents: number; @@ -269,6 +276,7 @@ export async function buildFullBackupPayload( intradayProfiles, healthScoreRecords, visits, + vaccinations, nutrientDays, ] = await Promise.all([ disasterRecovery @@ -402,6 +410,12 @@ export async function buildFullBackupPayload( buildVisitsBackupSection(prisma, userId, { purpose: disasterRecovery ? "disaster-recovery" : "portable-export", }), + // The immunization log and the pages it was transcribed from. Both ends + // live in `src/lib/export/vaccinations-backup.ts` beside each other, the + // same arrangement as the visits above and for the same reason. + buildVaccinationsBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), // Nutrient day totals were absent from every export path, which // contradicted the schema's own reason for denormalising the unit column // ("rows stay self-describing in exports even if the catalog ever drifts"). @@ -429,6 +443,7 @@ export async function buildFullBackupPayload( const intradaySection: IntradayProfileBackupSection = intradayProfiles; const healthScoreSection: HealthScoreBackupSection = healthScoreRecords; const visitsSection: VisitsBackupSection = visits; + const vaccinationsSection: VaccinationsBackupSection = vaccinations; const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, @@ -659,6 +674,7 @@ export async function buildFullBackupPayload( ...intradaySection, ...healthScoreSection, ...visitsSection, + ...vaccinationsSection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, @@ -687,6 +703,7 @@ export async function buildFullBackupPayload( ...countIntradayProfileBackupSection(intradayProfiles), ...countHealthScoreBackupSection(healthScoreRecords), ...countVisitsBackupSection(visits), + ...countVaccinationsBackupSection(vaccinations), }, }; } diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index b13148f07..24fa35348 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -40,7 +40,14 @@ * travels, or a document, lab result or condition episode whose id a portable * export does not carry. * - * The sixth, `checkupClosure`, is not about a restore at all, and it borrows + * The sixth, `vaccinationReference`, is the same kind as `visitReference` and + * is kept separate rather than folded into it so the report says which part of + * the record lost an edge. It names a row an immunization entry pointed at + * that the restore did not put back — a booster reminder, which never travels, + * a practitioner or visit the file referenced without carrying, or a scanned + * page whose id a portable export does not include. + * + * The seventh, `checkupClosure`, is not about a restore at all, and it borrows * this shape deliberately rather than growing a second reporting mechanism * beside it. The situation is the same one: something a write was asked to do * could not be done, the record itself survives, and the person is told which @@ -54,6 +61,7 @@ export type SkippedCatalogue = | "moodFactor" | "moodTag" | "visitReference" + | "vaccinationReference" | "checkupClosure"; /** One key this instance does not know, and the links it cost. */ diff --git a/src/lib/export/vaccinations-backup.ts b/src/lib/export/vaccinations-backup.ts new file mode 100644 index 000000000..e4515747e --- /dev/null +++ b/src/lib/export/vaccinations-backup.ts @@ -0,0 +1,418 @@ +/** + * The immunization log, with both backup ends in one file. + * + * Same arrangement as `visits-backup.ts` and for the same reason: a reader + * asking "is this carried at both ways?" answers it here, and a reader who + * greps only the restore ROUTE gets a false negative because the route + * delegates. + * + * Two models ride: the dose and the link that keeps the scanned Impfpass page + * beside it. Both are carried rather than owed. `DocumentConditionLink` on the + * debt register says what the alternative costs — documents and conditions + * both restore, the filing between them does not — and an immunization history + * is the record with the least chance of being reconstructed from anywhere + * else, so the same regret is not repeated here. + * + * Each model uses its own named select constant and its own delegate call + * rather than riding a parent's `include`: `documentLinks` is a relation field + * name on three different models now, so a relation-shaped write would be + * attributed to whichever of them a matcher happened to find first. + * + * Three references need care on the way back: + * + * - `practitionerId` and `encounterId` point at models that restore in the + * same run. They remap to the restored rows, and drop to NULL when the + * file references a row it does not carry. + * - `reminderId` points at a `MeasurementReminder`, which is on the + * coverage-pending register and does not come back. It resolves to NULL, + * never to a dangling id, and the drop is reported. + * - `antigenSlug` is restored verbatim with no validation against the + * current catalogue. A slug this release no longer resolves must still + * come back; the renderer degrades to the free-text arm, which is the + * guarantee `src/lib/vaccinations/__tests__/catalog-integrity.test.ts` + * holds. + */ +import { Buffer } from "node:buffer"; +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; +import type { VaccinationSite } from "@/generated/prisma/client"; + +import { + recordUnknownKeys, + type RestoreSkipLog, +} from "@/lib/export/restore-skips"; + +export interface VaccinationsBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + +/** One administered dose, as the Pass recorded it. */ +export interface VaccinationBackupEntry { + /** Always carried: the document link addresses a dose by it. */ + id: string; + occurredAt: string; + /** + * Carried verbatim, never checked against the catalogue this release ships. + * A slug that stopped resolving still describes what the person was given. + */ + antigenSlug: string | null; + vaccineName: string | null; + doseNumber: number | null; + seriesDoses: number | null; + lotNumber: string | null; + site: VaccinationSite | null; + practitionerId: string | null; + encounterId: string | null; + /** + * Carried so the loss is visible in the file rather than inferred from its + * absence. The restore resolves it to NULL and names the drop: the reminder + * it points at is not in the backup. + */ + reminderId: string | null; + /** Base64 ciphertext, carried verbatim — never decrypted into the file. */ + noteEncrypted: string | null; + createdAt: string; + updatedAt: string; + deletedAt?: string | null; +} + +/** One edge from a dose to the page it was transcribed from. */ +export interface VaccinationDocumentLinkBackupEntry { + vaccinationId: string; + targetId: string; + createdAt: string; +} + +export interface VaccinationsBackupSection { + vaccinations: VaccinationBackupEntry[]; + vaccinationDocumentLinks: VaccinationDocumentLinkBackupEntry[]; +} + +export interface VaccinationsBackupCounts { + vaccinations: number; + vaccinationLinks: number; +} + +/** Base64 for a `Bytes` ciphertext column, or null when the column is empty. */ +function encodeCiphertext(value: Uint8Array | null): string | null { + if (!value || value.byteLength === 0) return null; + return Buffer.from(value).toString("base64"); +} + +/** + * Named select constants, one per model — a structural matcher binds a model + * to the literal beside its delegate call, and an inline object inside a + * `Promise.all` is the shape earlier work here had to refactor away from. + */ +const VACCINATION_BACKUP_SELECT = { + id: true, + occurredAt: true, + antigenSlug: true, + vaccineName: true, + doseNumber: true, + seriesDoses: true, + lotNumber: true, + site: true, + practitionerId: true, + encounterId: true, + reminderId: true, + noteEncrypted: true, + createdAt: true, + updatedAt: true, + deletedAt: true, +} as const; + +const VACCINATION_DOCUMENT_LINK_BACKUP_SELECT = { + vaccinationId: true, + documentId: true, + createdAt: true, +} as const; + +/** + * Build the immunization slice of a user's full backup. + * + * Takes the delegates it uses rather than a whole client, matching the other + * section builders. A portable export omits tombstones so a restore cannot + * resurrect them; a disaster-recovery payload keeps them so the account comes + * back as itself. + */ +export async function buildVaccinationsBackupSection( + prisma: Pick, + userId: string, + options: VaccinationsBackupOptions = {}, +): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; + + const [vaccinationRows, documentLinkRows] = await Promise.all([ + prisma.vaccinationRecord.findMany({ + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, + orderBy: { occurredAt: "desc" }, + select: VACCINATION_BACKUP_SELECT, + }), + prisma.vaccinationDocumentLink.findMany({ + where: { userId }, + orderBy: { createdAt: "asc" }, + select: VACCINATION_DOCUMENT_LINK_BACKUP_SELECT, + }), + ]); + + return { + vaccinations: vaccinationRows.map((row) => ({ + id: row.id, + occurredAt: row.occurredAt.toISOString(), + antigenSlug: row.antigenSlug, + vaccineName: row.vaccineName, + doseNumber: row.doseNumber, + seriesDoses: row.seriesDoses, + lotNumber: row.lotNumber, + site: row.site, + practitionerId: row.practitionerId, + encounterId: row.encounterId, + reminderId: row.reminderId, + noteEncrypted: encodeCiphertext(row.noteEncrypted), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + ...(disasterRecovery + ? { deletedAt: row.deletedAt?.toISOString() ?? null } + : {}), + })), + vaccinationDocumentLinks: documentLinkRows.map((row) => ({ + vaccinationId: row.vaccinationId, + targetId: row.documentId, + createdAt: row.createdAt.toISOString(), + })), + }; +} + +/** Row counts for the audit trail, mirroring the other section counters. */ +export function countVaccinationsBackupSection( + section: VaccinationsBackupSection, +): VaccinationsBackupCounts { + return { + vaccinations: section.vaccinations.length, + vaccinationLinks: section.vaccinationDocumentLinks.length, + }; +} + +/** Counts the immunization restore wiped, for the audit trail. */ +export interface VaccinationsRestoreCleared { + vaccinations: number; + vaccinationLinks: number; +} + +/** + * The slice of a parsed backup this restore consumes. + * + * Required rather than optional: the payload schema defaults every key, so + * every caller already satisfies this, and one that stops satisfying it fails + * to compile instead of passing `undefined` into a loop that iterates zero + * times and reports success. + */ +export interface VaccinationsRestoreInput { + vaccinations: RestoredVaccination[]; + vaccinationDocumentLinks: VaccinationDocumentLinkBackupEntry[]; +} + +/** + * What the restore reads, as the parsed file actually presents it — a wider + * set than what this release writes: a portable export omits the tombstone, + * and every optional column arrives as `undefined` rather than `null`. + */ +type OptionalNullable = { [K in keyof T]?: T[K] | undefined }; + +export type RestoredVaccination = Pick< + VaccinationBackupEntry, + "id" | "occurredAt" | "createdAt" | "updatedAt" +> & + OptionalNullable< + Pick< + VaccinationBackupEntry, + | "antigenSlug" + | "vaccineName" + | "doseNumber" + | "seriesDoses" + | "lotNumber" + | "site" + | "practitionerId" + | "encounterId" + | "reminderId" + | "noteEncrypted" + > + > & { deletedAt?: string | null }; + +function decodeCiphertext(encoded: string): Uint8Array { + const decoded = Buffer.from(encoded, "base64"); + const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); + bytes.set(decoded); + return bytes; +} + +/** + * Re-create the account's immunization log and its document links. + * + * Delete-then-recreate inside the caller's transaction, matching every other + * section. The dose first, then the link, because a link addresses both of its + * ends by id. + * + * MUST be called after the practitioners, encounters and documents have been + * restored: the dose remaps two of those references against the database and + * the link is written only when the document actually came back. Calling it + * earlier would drop every reference and be counted as a successful restore of + * a thinner record. + */ +export async function restoreVaccinationsData( + tx: Prisma.TransactionClient, + ownerId: string, + payload: VaccinationsRestoreInput, + skips: RestoreSkipLog, +): Promise { + const clearedLinks = await tx.vaccinationDocumentLink.deleteMany({ + where: { userId: ownerId }, + }); + const clearedVaccinations = await tx.vaccinationRecord.deleteMany({ + where: { userId: ownerId }, + }); + + // Both far sides are restored by other branches of the same transaction, so + // the check is against the database rather than against the payload — this + // is the only place that can see all three sections at once. + const [practitionerIds, encounterIds] = await Promise.all([ + tx.practitioner.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }), + tx.encounter.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }), + ]); + const restoredPractitioners = new Set(practitionerIds.map((row) => row.id)); + const restoredEncounters = new Set(encounterIds.map((row) => row.id)); + + // A row the file references but the restore did not bring back cannot be + // invented, and a dangling id would fail the foreign key and roll the whole + // restore back over one dose. The reference drops to NULL instead — the + // dose, its date, its antigen and its batch code all survive. + const droppedPractitioners: string[] = []; + const droppedEncounters: string[] = []; + // The reminder is on the coverage-pending register: it is not in the file at + // all, so every reference to one is a known, reportable drop. + const droppedReminders: string[] = []; + + if (payload.vaccinations.length > 0) { + await tx.vaccinationRecord.createMany({ + data: payload.vaccinations.map((entry) => { + const practitionerId = + entry.practitionerId && + restoredPractitioners.has(entry.practitionerId) + ? entry.practitionerId + : null; + if (entry.practitionerId && practitionerId === null) { + droppedPractitioners.push(entry.practitionerId); + } + const encounterId = + entry.encounterId && restoredEncounters.has(entry.encounterId) + ? entry.encounterId + : null; + if (entry.encounterId && encounterId === null) { + droppedEncounters.push(entry.encounterId); + } + if (entry.reminderId) droppedReminders.push(entry.reminderId); + return { + id: entry.id, + userId: ownerId, + occurredAt: new Date(entry.occurredAt), + // Verbatim, deliberately: a slug the current catalogue no longer + // resolves still describes what was given, and the renderer falls + // back to `vaccineName`. + antigenSlug: entry.antigenSlug ?? null, + vaccineName: entry.vaccineName ?? null, + doseNumber: entry.doseNumber ?? null, + seriesDoses: entry.seriesDoses ?? null, + lotNumber: entry.lotNumber ?? null, + site: entry.site ?? null, + practitionerId, + encounterId, + // Never the id the file carried: the row it named is not restored. + reminderId: null, + noteEncrypted: + entry.noteEncrypted == null + ? null + : decodeCiphertext(entry.noteEncrypted), + createdAt: new Date(entry.createdAt), + updatedAt: new Date(entry.updatedAt), + deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, + }; + }), + }); + } + + recordUnknownKeys( + skips, + "vaccinationReference", + [...new Set(droppedPractitioners)], + droppedPractitioners, + ); + recordUnknownKeys( + skips, + "vaccinationReference", + [...new Set(droppedEncounters)], + droppedEncounters, + ); + recordUnknownKeys( + skips, + "vaccinationReference", + [...new Set(droppedReminders)], + droppedReminders, + ); + + const restoredVaccinations = new Set( + payload.vaccinations.map((entry) => entry.id), + ); + const documentIds = await tx.inboundDocument.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }); + const restoredDocuments = new Set(documentIds.map((row) => row.id)); + + const writableLinks: Array<{ + vaccinationId: string; + documentId: string; + createdAt: Date; + }> = []; + const droppedLinks: string[] = []; + for (const entry of payload.vaccinationDocumentLinks) { + if ( + restoredVaccinations.has(entry.vaccinationId) && + restoredDocuments.has(entry.targetId) + ) { + writableLinks.push({ + vaccinationId: entry.vaccinationId, + documentId: entry.targetId, + createdAt: new Date(entry.createdAt), + }); + } else { + droppedLinks.push(entry.targetId); + } + } + if (writableLinks.length > 0) { + await tx.vaccinationDocumentLink.createMany({ + data: writableLinks.map((row) => ({ + userId: ownerId, + vaccinationId: row.vaccinationId, + documentId: row.documentId, + createdAt: row.createdAt, + })), + }); + } + recordUnknownKeys( + skips, + "vaccinationReference", + [...new Set(droppedLinks)], + droppedLinks, + ); + + return { + vaccinations: clearedVaccinations.count, + vaccinationLinks: clearedLinks.count, + }; +} diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 9a198e7dd..a9fdb0522 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -57,6 +57,7 @@ import { RhythmClassification, SecondarySymptom, SleepStage, + VaccinationSite, } from "@/generated/prisma/enums"; import { DEFAULT_HEALTH_PROFILE_AI_SECTIONS, @@ -884,6 +885,44 @@ const encounterLinkBackupSchema = z }) .passthrough(); +/** + * One administered dose. + * + * `antigenSlug` is a free string here rather than an enum of the catalogue + * this release ships, deliberately: a slug the catalogue has since dropped + * must still restore, and the renderer degrades to `vaccineName`. Validating + * it at the restore boundary would refuse a file the app itself wrote. + */ +const vaccinationBackupSchema = z + .object({ + id: z.string().min(1), + occurredAt: isoDateTime, + antigenSlug: z.string().nullable().optional(), + vaccineName: z.string().nullable().optional(), + doseNumber: z.number().int().nullable().optional(), + seriesDoses: z.number().int().nullable().optional(), + lotNumber: z.string().nullable().optional(), + site: z.enum(VaccinationSite).nullable().optional(), + practitionerId: z.string().nullable().optional(), + encounterId: z.string().nullable().optional(), + // Carried so the loss is visible in the file. The restore resolves it to + // NULL and names the drop: the reminder it points at does not travel. + reminderId: z.string().nullable().optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); + +const vaccinationLinkBackupSchema = z + .object({ + vaccinationId: z.string().min(1), + targetId: z.string().min(1), + createdAt: isoDateTime, + }) + .passthrough(); + const allergyBackupSchema = z .object({ id: z.string().min(1), @@ -1036,6 +1075,12 @@ export const backupPayloadSchema = z encounterDocumentLinks: z.array(encounterLinkBackupSchema).default([]), encounterLabLinks: z.array(encounterLinkBackupSchema).default([]), encounterConditionLinks: z.array(encounterLinkBackupSchema).default([]), + // The immunization log and the pages it was transcribed from. Defaulted + // for the same reason as the sections above: a file written before the + // tables existed carries no key, and an account with an empty Impfpass + // writes []. + vaccinations: z.array(vaccinationBackupSchema).default([]), + vaccinationDocumentLinks: z.array(vaccinationLinkBackupSchema).default([]), manifest: backupManifestSchema.nullable().default(null), }) .passthrough() diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 8de0ebd8c..966919fd2 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -46,7 +46,7 @@ process.env.ENCRYPTION_KEY ??= import type { PrismaClient } from "@/generated/prisma/client"; import { encrypt, encryptBytes } from "@/lib/crypto"; -import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { readNote } from "@/lib/crypto/note-cipher"; import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan"; @@ -149,6 +149,10 @@ const COUNT_BACK: Record< p.encounterLabLink.count({ where: { userId } }), EncounterConditionLink: (p, userId) => p.encounterConditionLink.count({ where: { userId } }), + VaccinationRecord: (p, userId) => + p.vaccinationRecord.count({ where: { userId } }), + VaccinationDocumentLink: (p, userId) => + p.vaccinationDocumentLink.count({ where: { userId } }), }; /** @@ -468,6 +472,40 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { episodeId: episode.id, }, }); + + // One dose with every optional column filled, and the page it was + // transcribed from. Fat on purpose, against this file's own minimal-fixture + // rule: a count-back proves the row returned, and the assertion after the + // restore reads each column back so it also proves the row returned WHOLE. + // The practitioner and the encounter are the ones seeded above, because both + // are remapped on the way back and a reference to something the restore did + // not put back is a different case (covered by the skip test below). + // + // `reminderId` is deliberately NOT set here: it is the one reference that + // can never resolve, so setting it would make this test's "nothing was + // skipped" assertion false for a reason that is correct behaviour. + const vaccination = await prisma.vaccinationRecord.create({ + data: { + userId: OWNER_ID, + occurredAt: AT("2026-05-14T00:00:00.000Z"), + antigenSlug: "tdap", + vaccineName: "Tetanus, diphtheria and pertussis", + doseNumber: 3, + seriesDoses: 3, + lotNumber: "RT-4471", + site: "LEFT_ARM", + practitionerId: practitioner.id, + encounterId: encounter.id, + noteEncrypted: encryptToBytes("sore arm for a day, nothing else"), + }, + }); + await prisma.vaccinationDocumentLink.create({ + data: { + userId: OWNER_ID, + vaccinationId: vaccination.id, + documentId: document.id, + }, + }); } async function createOwner(prisma: PrismaClient) { @@ -596,5 +634,131 @@ describe("every model the plan claims two-ended survives a real restore", () => expect(sideEffect.notes, "plaintext must not come back in the column").toBe( null, ); + + // The dose, read back column by column. The count above says a row + // returned; a restore that wrote one row with every optional column + // defaulted would satisfy it and hand back an Impfpass line with no + // antigen, no batch code and no arm. The two remapped references are + // asserted as ids that exist rather than as the ids the file carried, + // because that is the property the remap owes. + const dose = await prisma.vaccinationRecord.findFirstOrThrow({ + where: { userId: OWNER_ID }, + include: { + practitioner: { select: { name: true } }, + encounter: { select: { occurredAt: true } }, + documentLinks: { select: { documentId: true } }, + }, + }); + expect({ + occurredAt: dose.occurredAt.toISOString(), + antigenSlug: dose.antigenSlug, + vaccineName: dose.vaccineName, + doseNumber: dose.doseNumber, + seriesDoses: dose.seriesDoses, + lotNumber: dose.lotNumber, + site: dose.site, + practitioner: dose.practitioner?.name ?? null, + encounterAt: dose.encounter?.occurredAt.toISOString() ?? null, + links: dose.documentLinks.length, + note: dose.noteEncrypted ? decryptFromBytes(dose.noteEncrypted) : null, + }).toEqual({ + occurredAt: "2026-05-14T00:00:00.000Z", + antigenSlug: "tdap", + vaccineName: "Tetanus, diphtheria and pertussis", + doseNumber: 3, + seriesDoses: 3, + lotNumber: "RT-4471", + site: "LEFT_ARM", + practitioner: "Round-trip practice", + encounterAt: "2026-06-30T08:00:00.000Z", + links: 1, + note: "sore arm for a day, nothing else", + }); + }); + + /** + * The two references a dose can carry that the restore cannot honour, and + * the slug it must not judge. + * + * Separated from the round trip above because both are correct behaviour + * that shows up as a reported skip, and that file asserts nothing was + * skipped. Reported is the whole point: a reference dropped in silence is + * the same defect as a row dropped in silence, one field smaller. + */ + it("reports the reminder it cannot restore and keeps a slug it cannot resolve", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await createOwner(prisma); + + const reminder = await prisma.measurementReminder.create({ + data: { + userId: OWNER_ID, + label: "Tetanus booster", + intervalDays: 3650, + vaccinationAntigen: "tetanus", + }, + }); + await prisma.vaccinationRecord.create({ + data: { + userId: OWNER_ID, + occurredAt: AT("1987-09-02T00:00:00.000Z"), + // A slug no catalogue this release ships resolves. It must come back + // exactly as written: the renderer degrades to `vaccineName`, and a + // restore that dropped or rewrote it would lose what a person was + // actually given. + antigenSlug: "retired-antigen-from-an-older-release", + vaccineName: "Whatever the Pass called it in 1987", + reminderId: reminder.id, + }, + }); + + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "disaster-recovery", + }); + await prisma.user.delete({ where: { id: OWNER_ID } }); + await createOwner(prisma); + + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "TWO_ENDED_ROUND_TRIP", + data: encrypt(JSON.stringify(payload)), + }, + }); + 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 never, + { params: Promise.resolve({ id: backup.id }) }, + ); + const body = await response.json(); + expect(response.status, JSON.stringify(body)).toBe(200); + + const reported = body.data.skipped.catalogueKeys as Array<{ + catalogue: string; + key: string; + }>; + expect( + reported.filter((entry) => entry.catalogue === "vaccinationReference"), + "the reminder the dose pointed at is not in the backup at all, so " + + "dropping the reference is correct and saying nothing about it is not", + ).toEqual([ + { catalogue: "vaccinationReference", key: reminder.id, links: 1 }, + ]); + + const dose = await prisma.vaccinationRecord.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + expect({ + antigenSlug: dose.antigenSlug, + vaccineName: dose.vaccineName, + reminderId: dose.reminderId, + }).toEqual({ + antigenSlug: "retired-antigen-from-an-older-release", + vaccineName: "Whatever the Pass called it in 1987", + reminderId: null, + }); }); });