mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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
This commit is contained in:
1 parent
1bd97de943
commit
6926caa11d
18 files changed
+698
-3
No files matched your search
@@ -6873,6 +6873,7 @@
|
||||
"restoreSkippedMoodFactor": "Stimmungsfaktor",
|
||||
"restoreSkippedMoodTag": "Stimmungs-Tag",
|
||||
"restoreSkippedVisitReference": "Besuchsverknüpfung",
|
||||
"restoreSkippedVaccinationReference": "Impfverknüpfung",
|
||||
"restoreSkippedCheckupClosure": "Vorsorge-Abschluss",
|
||||
"docsLink": "Dokumentation"
|
||||
},
|
||||
|
||||
@@ -6873,6 +6873,7 @@
|
||||
"restoreSkippedMoodFactor": "Mood factor",
|
||||
"restoreSkippedMoodTag": "Mood tag",
|
||||
"restoreSkippedVisitReference": "Visit reference",
|
||||
"restoreSkippedVaccinationReference": "Vaccination reference",
|
||||
"restoreSkippedCheckupClosure": "Checkup closure",
|
||||
"docsLink": "Documentation"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -6873,6 +6873,7 @@
|
||||
"restoreSkippedMoodFactor": "Czynnik nastroju",
|
||||
"restoreSkippedMoodTag": "Tag nastroju",
|
||||
"restoreSkippedVisitReference": "Odwołanie wizyty",
|
||||
"restoreSkippedVaccinationReference": "Odwołanie szczepienia",
|
||||
"restoreSkippedCheckupClosure": "Zamknięcie badania",
|
||||
"docsLink": "Dokumentacja"
|
||||
},
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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) };
|
||||
},
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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([]) },
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<PrismaClient, "vaccinationRecord" | "vaccinationDocumentLink">,
|
||||
userId: string,
|
||||
options: VaccinationsBackupOptions = {},
|
||||
): Promise<VaccinationsBackupSection> {
|
||||
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<T> = { [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<ArrayBuffer> {
|
||||
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<VaccinationsRestoreCleared> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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<void> {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user