mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
A person's whole 'which doctor' history was one free-text column on a reminder: they typed the practice name once and could reach nothing else that happened there. This turns that string into a record. Five tables in migration 0320, all additive: an encounter that happened or is booked, the account's own address book of practitioners, and three links from a visit to the documents, lab results and conditions it produced. The encounter is slim on purpose — the diagnosis stays on the linked condition, the address on the linked practitioner, and the reason each field was kept or cut is written at the model. No module gate, and the schema says why rather than leaving the absence to be read as an oversight. Every carrier lands with the tables rather than after them. All five are classified as travelling both ways and are proven doing it: a seeded row of each goes out through the real export builder, the account is deleted so the schema's cascade decides what survives, and the rows are counted back through the real restore route. Two references deliberately do not survive and are named instead of dropped quietly — the reminder a visit points at is not in the backup, and a link whose far side a portable export does not carry cannot resolve. The wipe plan deletes the links before the lab results and documents they hang off, or the audit row would report zero for rows that existed. The three encrypted columns are registered for key rotation, without which dropping a legacy key would make those notes permanently unreadable. Widening the reminder origin enum is a type error at the reminder DTO, which is the right outcome: the mapper now refuses an appointment row outright rather than relabelling it as a checkup.
574 lines
18 KiB
TypeScript
574 lines
18 KiB
TypeScript
/**
|
|
* Visits and the address book behind them, with both backup ends in one file.
|
|
*
|
|
* The builder and the restore live together deliberately, the way
|
|
* `health-score-backup.ts` and `src/lib/cycle/backup.ts` do: a reader asking
|
|
* "is this carried at both ends?" answers it here, and a reader who greps only
|
|
* the restore ROUTE gets a false negative, because the route delegates.
|
|
*
|
|
* Five models ride: the practitioners, the encounters, and the three link
|
|
* tables between an encounter and a document, a lab result or a condition
|
|
* episode. All five are carried rather than owed. Two neighbours on the debt
|
|
* register already say what the alternative costs — reminders restore silent,
|
|
* and the filing between documents and conditions is lost — and a restore that
|
|
* returned visits and documents with nothing between them would be the same
|
|
* loss one layer deeper.
|
|
*
|
|
* Every read is scoped to the owner. 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 two different models, so a
|
|
* relation-shaped write here would be attributed to whichever of them a
|
|
* matcher happened to find first. Delegate calls cannot be confused that way.
|
|
*
|
|
* Two references deliberately do not survive a restore, and both are reported
|
|
* rather than dropped:
|
|
*
|
|
* - `Encounter.reminderId` points at a `MeasurementReminder`, which is on the
|
|
* coverage-pending register and does not come back. The column resolves to
|
|
* NULL, never to a dangling id.
|
|
* - A link whose far side was not restored. A portable export carries no lab
|
|
* result ids, so a lab link in one cannot resolve; a disaster-recovery
|
|
* payload carries every id and they all do.
|
|
*/
|
|
import { Buffer } from "node:buffer";
|
|
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
|
import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
|
|
|
|
import {
|
|
recordUnknownKeys,
|
|
type RestoreSkipLog,
|
|
} from "@/lib/export/restore-skips";
|
|
|
|
export interface VisitsBackupOptions {
|
|
purpose?: "portable-export" | "disaster-recovery";
|
|
}
|
|
|
|
/** One entry in the account's own address book of doctors and practices. */
|
|
export interface PractitionerBackupEntry {
|
|
/** Always carried: an encounter references a practitioner by id and nothing
|
|
* else about the row is unique enough to rebuild the reference from. */
|
|
id: string;
|
|
name: string;
|
|
specialty: string | null;
|
|
practice: string | null;
|
|
location: string | null;
|
|
phone: string | null;
|
|
/** Base64 ciphertext, carried verbatim — never decrypted into the file. */
|
|
noteEncrypted: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
deletedAt?: string | null;
|
|
}
|
|
|
|
/** One contact with the healthcare system: happened, or booked. */
|
|
export interface EncounterBackupEntry {
|
|
/** Always carried, for the same reason as the practitioner id above: the
|
|
* three link tables address an encounter by it. */
|
|
id: string;
|
|
occurredAt: string;
|
|
status: EncounterStatus;
|
|
kind: EncounterKind;
|
|
practitionerId: string | null;
|
|
reasonEncrypted: string | null;
|
|
outcomeEncrypted: 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;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
deletedAt?: string | null;
|
|
}
|
|
|
|
/** One edge from a visit to something it produced. */
|
|
export interface EncounterLinkBackupEntry {
|
|
encounterId: string;
|
|
targetId: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface VisitsBackupSection {
|
|
practitioners: PractitionerBackupEntry[];
|
|
encounters: EncounterBackupEntry[];
|
|
encounterDocumentLinks: EncounterLinkBackupEntry[];
|
|
encounterLabLinks: EncounterLinkBackupEntry[];
|
|
encounterConditionLinks: EncounterLinkBackupEntry[];
|
|
}
|
|
|
|
export interface VisitsBackupCounts {
|
|
practitioners: number;
|
|
encounters: number;
|
|
encounterLinks: 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.
|
|
*
|
|
* Named rather than inline because a structural matcher binds a model to the
|
|
* literal beside its delegate call, and an inline object inside a `Promise.all`
|
|
* is a shape earlier work in this repository had to refactor away from for
|
|
* exactly that reason.
|
|
*/
|
|
const PRACTITIONER_BACKUP_SELECT = {
|
|
id: true,
|
|
name: true,
|
|
specialty: true,
|
|
practice: true,
|
|
location: true,
|
|
phone: true,
|
|
noteEncrypted: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
deletedAt: true,
|
|
} as const;
|
|
|
|
const ENCOUNTER_BACKUP_SELECT = {
|
|
id: true,
|
|
occurredAt: true,
|
|
status: true,
|
|
kind: true,
|
|
practitionerId: true,
|
|
reasonEncrypted: true,
|
|
outcomeEncrypted: true,
|
|
reminderId: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
deletedAt: true,
|
|
} as const;
|
|
|
|
const ENCOUNTER_DOCUMENT_LINK_BACKUP_SELECT = {
|
|
encounterId: true,
|
|
documentId: true,
|
|
createdAt: true,
|
|
} as const;
|
|
|
|
const ENCOUNTER_LAB_LINK_BACKUP_SELECT = {
|
|
encounterId: true,
|
|
labResultId: true,
|
|
createdAt: true,
|
|
} as const;
|
|
|
|
const ENCOUNTER_CONDITION_LINK_BACKUP_SELECT = {
|
|
encounterId: true,
|
|
episodeId: true,
|
|
createdAt: true,
|
|
} as const;
|
|
|
|
/**
|
|
* Build the visits 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 buildVisitsBackupSection(
|
|
prisma: Pick<
|
|
PrismaClient,
|
|
| "practitioner"
|
|
| "encounter"
|
|
| "encounterDocumentLink"
|
|
| "encounterLabLink"
|
|
| "encounterConditionLink"
|
|
>,
|
|
userId: string,
|
|
options: VisitsBackupOptions = {},
|
|
): Promise<VisitsBackupSection> {
|
|
const disasterRecovery = options.purpose === "disaster-recovery";
|
|
|
|
const [
|
|
practitionerRows,
|
|
encounterRows,
|
|
documentLinkRows,
|
|
labLinkRows,
|
|
conditionLinkRows,
|
|
] = await Promise.all([
|
|
prisma.practitioner.findMany({
|
|
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
|
|
orderBy: { name: "asc" },
|
|
select: PRACTITIONER_BACKUP_SELECT,
|
|
}),
|
|
prisma.encounter.findMany({
|
|
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
|
|
orderBy: { occurredAt: "desc" },
|
|
select: ENCOUNTER_BACKUP_SELECT,
|
|
}),
|
|
prisma.encounterDocumentLink.findMany({
|
|
where: { userId },
|
|
orderBy: { createdAt: "asc" },
|
|
select: ENCOUNTER_DOCUMENT_LINK_BACKUP_SELECT,
|
|
}),
|
|
prisma.encounterLabLink.findMany({
|
|
where: { userId },
|
|
orderBy: { createdAt: "asc" },
|
|
select: ENCOUNTER_LAB_LINK_BACKUP_SELECT,
|
|
}),
|
|
prisma.encounterConditionLink.findMany({
|
|
where: { userId },
|
|
orderBy: { createdAt: "asc" },
|
|
select: ENCOUNTER_CONDITION_LINK_BACKUP_SELECT,
|
|
}),
|
|
]);
|
|
|
|
return {
|
|
practitioners: practitionerRows.map((row) => ({
|
|
id: row.id,
|
|
name: row.name,
|
|
specialty: row.specialty,
|
|
practice: row.practice,
|
|
location: row.location,
|
|
phone: row.phone,
|
|
noteEncrypted: encodeCiphertext(row.noteEncrypted),
|
|
createdAt: row.createdAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
...(disasterRecovery
|
|
? { deletedAt: row.deletedAt?.toISOString() ?? null }
|
|
: {}),
|
|
})),
|
|
encounters: encounterRows.map((row) => ({
|
|
id: row.id,
|
|
occurredAt: row.occurredAt.toISOString(),
|
|
status: row.status,
|
|
kind: row.kind,
|
|
practitionerId: row.practitionerId,
|
|
reasonEncrypted: encodeCiphertext(row.reasonEncrypted),
|
|
outcomeEncrypted: encodeCiphertext(row.outcomeEncrypted),
|
|
reminderId: row.reminderId,
|
|
createdAt: row.createdAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
...(disasterRecovery
|
|
? { deletedAt: row.deletedAt?.toISOString() ?? null }
|
|
: {}),
|
|
})),
|
|
encounterDocumentLinks: documentLinkRows.map((row) => ({
|
|
encounterId: row.encounterId,
|
|
targetId: row.documentId,
|
|
createdAt: row.createdAt.toISOString(),
|
|
})),
|
|
encounterLabLinks: labLinkRows.map((row) => ({
|
|
encounterId: row.encounterId,
|
|
targetId: row.labResultId,
|
|
createdAt: row.createdAt.toISOString(),
|
|
})),
|
|
encounterConditionLinks: conditionLinkRows.map((row) => ({
|
|
encounterId: row.encounterId,
|
|
targetId: row.episodeId,
|
|
createdAt: row.createdAt.toISOString(),
|
|
})),
|
|
};
|
|
}
|
|
|
|
/** Row counts for the audit trail, mirroring the other section counters. */
|
|
export function countVisitsBackupSection(
|
|
section: VisitsBackupSection,
|
|
): VisitsBackupCounts {
|
|
return {
|
|
practitioners: section.practitioners.length,
|
|
encounters: section.encounters.length,
|
|
encounterLinks:
|
|
section.encounterDocumentLinks.length +
|
|
section.encounterLabLinks.length +
|
|
section.encounterConditionLinks.length,
|
|
};
|
|
}
|
|
|
|
/** Counts the visit restore wiped, for the audit trail. */
|
|
export interface VisitsRestoreCleared {
|
|
practitioners: number;
|
|
encounters: number;
|
|
encounterLinks: 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 VisitsRestoreInput {
|
|
practitioners: RestoredPractitioner[];
|
|
encounters: RestoredEncounter[];
|
|
encounterDocumentLinks: EncounterLinkBackupEntry[];
|
|
encounterLabLinks: EncounterLinkBackupEntry[];
|
|
encounterConditionLinks: EncounterLinkBackupEntry[];
|
|
}
|
|
|
|
/**
|
|
* What the restore reads, as the parsed file actually presents it.
|
|
*
|
|
* The builder's entry types above describe what this release WRITES. These
|
|
* describe what a restore may be handed, which is a wider set: a portable
|
|
* export omits the tombstone, and every optional column in the payload schema
|
|
* arrives as `undefined` rather than `null`. Modelling the read side as the
|
|
* write side would compile only because the two happen to agree today, and
|
|
* would break the first time an older file reached it.
|
|
*/
|
|
type OptionalNullable<T> = { [K in keyof T]?: T[K] | undefined };
|
|
|
|
export type RestoredPractitioner = Pick<
|
|
PractitionerBackupEntry,
|
|
"id" | "name" | "createdAt" | "updatedAt"
|
|
> &
|
|
OptionalNullable<
|
|
Pick<
|
|
PractitionerBackupEntry,
|
|
"specialty" | "practice" | "location" | "phone" | "noteEncrypted"
|
|
>
|
|
> & { deletedAt?: string | null };
|
|
|
|
export type RestoredEncounter = Pick<
|
|
EncounterBackupEntry,
|
|
"id" | "occurredAt" | "status" | "kind" | "createdAt" | "updatedAt"
|
|
> &
|
|
OptionalNullable<
|
|
Pick<
|
|
EncounterBackupEntry,
|
|
"practitionerId" | "reasonEncrypted" | "outcomeEncrypted" | "reminderId"
|
|
>
|
|
> & { 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 visits, address book and link rows.
|
|
*
|
|
* Delete-then-recreate inside the caller's transaction, matching every other
|
|
* section. Parents first — practitioners, then encounters, then the links —
|
|
* because a link addresses both of its ends by id.
|
|
*
|
|
* MUST be called after the documents, lab results and condition episodes have
|
|
* been restored: a link is written only when both of its ends exist, and the
|
|
* existence check is what decides. Calling it earlier would report every link
|
|
* as unresolvable and be counted as a successful restore of nothing.
|
|
*/
|
|
export async function restoreVisitsData(
|
|
tx: Prisma.TransactionClient,
|
|
ownerId: string,
|
|
payload: VisitsRestoreInput,
|
|
skips: RestoreSkipLog,
|
|
): Promise<VisitsRestoreCleared> {
|
|
const clearedLinks =
|
|
(await tx.encounterDocumentLink.deleteMany({ where: { userId: ownerId } }))
|
|
.count +
|
|
(await tx.encounterLabLink.deleteMany({ where: { userId: ownerId } }))
|
|
.count +
|
|
(await tx.encounterConditionLink.deleteMany({ where: { userId: ownerId } }))
|
|
.count;
|
|
const clearedEncounters = await tx.encounter.deleteMany({
|
|
where: { userId: ownerId },
|
|
});
|
|
const clearedPractitioners = await tx.practitioner.deleteMany({
|
|
where: { userId: ownerId },
|
|
});
|
|
|
|
if (payload.practitioners.length > 0) {
|
|
await tx.practitioner.createMany({
|
|
data: payload.practitioners.map((entry) => ({
|
|
id: entry.id,
|
|
userId: ownerId,
|
|
name: entry.name,
|
|
specialty: entry.specialty ?? null,
|
|
practice: entry.practice ?? null,
|
|
location: entry.location ?? null,
|
|
phone: entry.phone ?? 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,
|
|
})),
|
|
});
|
|
}
|
|
|
|
// A practitioner the file references but does not carry cannot be invented,
|
|
// and a dangling id would fail the foreign key and roll the whole restore
|
|
// back over one visit. The reference is dropped to NULL instead — the visit
|
|
// itself, its date, its reason and its outcome all survive.
|
|
const restoredPractitioners = new Set(
|
|
payload.practitioners.map((entry) => entry.id),
|
|
);
|
|
const droppedPractitioners: 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.encounters.length > 0) {
|
|
await tx.encounter.createMany({
|
|
data: payload.encounters.map((entry) => {
|
|
const practitionerId =
|
|
entry.practitionerId &&
|
|
restoredPractitioners.has(entry.practitionerId)
|
|
? entry.practitionerId
|
|
: null;
|
|
if (entry.practitionerId && practitionerId === null) {
|
|
droppedPractitioners.push(entry.practitionerId);
|
|
}
|
|
if (entry.reminderId) droppedReminders.push(entry.reminderId);
|
|
return {
|
|
id: entry.id,
|
|
userId: ownerId,
|
|
occurredAt: new Date(entry.occurredAt),
|
|
status: entry.status,
|
|
kind: entry.kind,
|
|
practitionerId,
|
|
reasonEncrypted:
|
|
entry.reasonEncrypted == null
|
|
? null
|
|
: decodeCiphertext(entry.reasonEncrypted),
|
|
outcomeEncrypted:
|
|
entry.outcomeEncrypted == null
|
|
? null
|
|
: decodeCiphertext(entry.outcomeEncrypted),
|
|
// Never the id the file carried: the row it named is not restored.
|
|
reminderId: null,
|
|
createdAt: new Date(entry.createdAt),
|
|
updatedAt: new Date(entry.updatedAt),
|
|
deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null,
|
|
};
|
|
}),
|
|
});
|
|
}
|
|
|
|
recordUnknownKeys(
|
|
skips,
|
|
"visitReference",
|
|
[...new Set(droppedPractitioners)],
|
|
droppedPractitioners,
|
|
);
|
|
recordUnknownKeys(
|
|
skips,
|
|
"visitReference",
|
|
[...new Set(droppedReminders)],
|
|
droppedReminders,
|
|
);
|
|
|
|
const restoredEncounters = new Set(payload.encounters.map((e) => e.id));
|
|
|
|
/**
|
|
* Write the links whose far side actually came back, and name the rest.
|
|
*
|
|
* The far side is checked against the database rather than against the
|
|
* payload, because the document, the lab result and the episode are restored
|
|
* by other branches in the same transaction and this is the only place that
|
|
* can see all three.
|
|
*/
|
|
async function restoreLinks(
|
|
entries: EncounterLinkBackupEntry[],
|
|
existingTargetIds: Set<string>,
|
|
write: (
|
|
rows: Array<{
|
|
encounterId: string;
|
|
targetId: string;
|
|
createdAt: Date;
|
|
}>,
|
|
) => Promise<void>,
|
|
): Promise<void> {
|
|
const writable: Array<{
|
|
encounterId: string;
|
|
targetId: string;
|
|
createdAt: Date;
|
|
}> = [];
|
|
const dropped: string[] = [];
|
|
for (const entry of entries) {
|
|
if (
|
|
restoredEncounters.has(entry.encounterId) &&
|
|
existingTargetIds.has(entry.targetId)
|
|
) {
|
|
writable.push({
|
|
encounterId: entry.encounterId,
|
|
targetId: entry.targetId,
|
|
createdAt: new Date(entry.createdAt),
|
|
});
|
|
} else {
|
|
dropped.push(entry.targetId);
|
|
}
|
|
}
|
|
if (writable.length > 0) await write(writable);
|
|
recordUnknownKeys(skips, "visitReference", [...new Set(dropped)], dropped);
|
|
}
|
|
|
|
const [documentIds, labResultIds, episodeIds] = await Promise.all([
|
|
tx.inboundDocument.findMany({
|
|
where: { userId: ownerId },
|
|
select: { id: true },
|
|
}),
|
|
tx.labResult.findMany({
|
|
where: { userId: ownerId },
|
|
select: { id: true },
|
|
}),
|
|
tx.illnessEpisode.findMany({
|
|
where: { userId: ownerId },
|
|
select: { id: true },
|
|
}),
|
|
]);
|
|
|
|
await restoreLinks(
|
|
payload.encounterDocumentLinks,
|
|
new Set(documentIds.map((row) => row.id)),
|
|
async (rows) => {
|
|
await tx.encounterDocumentLink.createMany({
|
|
data: rows.map((row) => ({
|
|
userId: ownerId,
|
|
encounterId: row.encounterId,
|
|
documentId: row.targetId,
|
|
createdAt: row.createdAt,
|
|
})),
|
|
});
|
|
},
|
|
);
|
|
|
|
await restoreLinks(
|
|
payload.encounterLabLinks,
|
|
new Set(labResultIds.map((row) => row.id)),
|
|
async (rows) => {
|
|
await tx.encounterLabLink.createMany({
|
|
data: rows.map((row) => ({
|
|
userId: ownerId,
|
|
encounterId: row.encounterId,
|
|
labResultId: row.targetId,
|
|
createdAt: row.createdAt,
|
|
})),
|
|
});
|
|
},
|
|
);
|
|
|
|
await restoreLinks(
|
|
payload.encounterConditionLinks,
|
|
new Set(episodeIds.map((row) => row.id)),
|
|
async (rows) => {
|
|
await tx.encounterConditionLink.createMany({
|
|
data: rows.map((row) => ({
|
|
userId: ownerId,
|
|
encounterId: row.encounterId,
|
|
episodeId: row.targetId,
|
|
createdAt: row.createdAt,
|
|
})),
|
|
});
|
|
},
|
|
);
|
|
|
|
return {
|
|
practitioners: clearedPractitioners.count,
|
|
encounters: clearedEncounters.count,
|
|
encounterLinks: clearedLinks,
|
|
};
|
|
}
|