mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
The web record and the doctor report named the practitioner of a live visit after the address-book entry was deleted; the MCP read, the search and the appointment reminder did not. All of them now name it, the entry stays out of the address book and the pickers, and a portable backup carries it as a tombstone instead of dropping the visit's reference.
724 lines
23 KiB
TypeScript
724 lines
23 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. A neighbour on the debt
|
|
* register already says what the alternative costs — 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 can fail to resolve on a restore, and both are reported
|
|
* rather than dropped in silence:
|
|
*
|
|
* - `Encounter.reminderId` points at a `MeasurementReminder`, which restores
|
|
* in the same run (v1.37.20, #223 / iOS #68 — it was on the
|
|
* coverage-pending register before that and every reference was a known
|
|
* drop). It remaps against the restored reminders and resolves to NULL,
|
|
* never to a dangling id, only when the file genuinely lacks the reminder
|
|
* — a portable export omits tombstoned ones.
|
|
* - 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 type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
|
import type {
|
|
EncounterKind,
|
|
EncounterStatus,
|
|
Laterality,
|
|
} from "@/generated/prisma/client";
|
|
|
|
import { encryptNote } from "@/lib/crypto/note-cipher";
|
|
import {
|
|
encodeSealedBytes,
|
|
openSealedBytesForExport,
|
|
sealBytesForRestore,
|
|
} from "@/lib/export/sealed-text";
|
|
import { readPractitionerContact } from "@/lib/practitioners/dto";
|
|
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;
|
|
/**
|
|
* v1.39.4 — the address and phone number are encrypted at rest. A portable
|
|
* export carries them readable here; a disaster-recovery payload leaves
|
|
* these null and carries the ciphertext in the two fields below.
|
|
*/
|
|
location: string | null;
|
|
phone: string | null;
|
|
/** Base64 ciphertext of the address, disaster-recovery payloads only. */
|
|
locationEncrypted?: string | null;
|
|
/** Base64 ciphertext of the phone number, disaster-recovery payloads only. */
|
|
phoneEncrypted?: string | null;
|
|
/** Base64 ciphertext; disaster-recovery payloads only. */
|
|
noteEncrypted?: string | null;
|
|
/** The note readable; portable payloads only. */
|
|
note?: 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;
|
|
/** Base64 ciphertext; disaster-recovery payloads only. */
|
|
reasonEncrypted?: string | null;
|
|
outcomeEncrypted?: string | null;
|
|
/** The procedure's body site, ciphertext like the two above (v1.39.1). */
|
|
bodySiteEncrypted?: string | null;
|
|
/** The three readable; portable payloads only. */
|
|
reason?: string | null;
|
|
outcome?: string | null;
|
|
bodySite?: string | null;
|
|
laterality: Laterality | null;
|
|
/**
|
|
* The appointment's reminder row, which restores in the same run since
|
|
* v1.37.20. The restore remaps it against the restored reminders and drops
|
|
* it to NULL, with the drop named, only when the file lacks the reminder.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
locationEncrypted: true,
|
|
phoneEncrypted: 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,
|
|
bodySiteEncrypted: true,
|
|
laterality: 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({
|
|
// A portable file leaves the address book's tombstones out, except one
|
|
// a live visit or dose still names: the record shows it, so the file
|
|
// carries it (with its `deletedAt`) instead of dropping the reference.
|
|
where: disasterRecovery
|
|
? { userId }
|
|
: {
|
|
userId,
|
|
OR: [
|
|
{ deletedAt: null },
|
|
{ encounters: { some: { deletedAt: null } } },
|
|
{ vaccinations: { some: { 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,
|
|
// v1.39.4 — a disaster-recovery file carries the contact fields as
|
|
// ciphertext only, sealing a row the backfill has not reached yet on
|
|
// the way out; a portable export carries them readable.
|
|
...(disasterRecovery
|
|
? {
|
|
location: null,
|
|
phone: null,
|
|
locationEncrypted: encodeSealedBytes(
|
|
row.locationEncrypted ?? encryptNote(row.location),
|
|
),
|
|
phoneEncrypted: encodeSealedBytes(
|
|
row.phoneEncrypted ?? encryptNote(row.phone),
|
|
),
|
|
}
|
|
: {
|
|
location: readPractitionerContact(
|
|
row.locationEncrypted,
|
|
row.location,
|
|
"location",
|
|
),
|
|
phone: readPractitionerContact(
|
|
row.phoneEncrypted,
|
|
row.phone,
|
|
"phone",
|
|
),
|
|
}),
|
|
...(disasterRecovery
|
|
? { noteEncrypted: encodeSealedBytes(row.noteEncrypted) }
|
|
: {
|
|
note: openSealedBytesForExport(
|
|
row.noteEncrypted,
|
|
"practitioner note",
|
|
),
|
|
}),
|
|
createdAt: row.createdAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
// A portable file names a tombstone only when a live visit holds it,
|
|
// and says so, so the restore keeps it out of the address book.
|
|
...(disasterRecovery || row.deletedAt
|
|
? { 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,
|
|
...(disasterRecovery
|
|
? {
|
|
reasonEncrypted: encodeSealedBytes(row.reasonEncrypted),
|
|
outcomeEncrypted: encodeSealedBytes(row.outcomeEncrypted),
|
|
bodySiteEncrypted: encodeSealedBytes(row.bodySiteEncrypted),
|
|
}
|
|
: {
|
|
reason: openSealedBytesForExport(
|
|
row.reasonEncrypted,
|
|
"visit reason",
|
|
),
|
|
outcome: openSealedBytesForExport(
|
|
row.outcomeEncrypted,
|
|
"visit outcome",
|
|
),
|
|
bodySite: openSealedBytesForExport(
|
|
row.bodySiteEncrypted,
|
|
"visit body site",
|
|
),
|
|
}),
|
|
laterality: row.laterality,
|
|
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"
|
|
| "locationEncrypted"
|
|
| "phoneEncrypted"
|
|
| "noteEncrypted"
|
|
| "note"
|
|
>
|
|
> & { deletedAt?: string | null };
|
|
|
|
export type RestoredEncounter = Pick<
|
|
EncounterBackupEntry,
|
|
"id" | "occurredAt" | "status" | "kind" | "createdAt" | "updatedAt"
|
|
> &
|
|
OptionalNullable<
|
|
Pick<
|
|
EncounterBackupEntry,
|
|
| "practitionerId"
|
|
| "reasonEncrypted"
|
|
| "outcomeEncrypted"
|
|
| "bodySiteEncrypted"
|
|
| "reason"
|
|
| "outcome"
|
|
| "bodySite"
|
|
| "laterality"
|
|
| "reminderId"
|
|
>
|
|
> & { deletedAt?: string | null };
|
|
|
|
/**
|
|
* 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, condition episodes AND the
|
|
* measurement reminders have been restored: a link is written only when both
|
|
* of its ends exist, the encounter's `reminderId` remaps against the restored
|
|
* reminders, and the existence check is what decides. Calling it earlier would
|
|
* report every one of those references as unresolvable and be counted as a
|
|
* successful restore of a thinner record.
|
|
*/
|
|
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 },
|
|
});
|
|
|
|
// Sealed values this host's keys do not open, by file path.
|
|
const unopened: string[] = [];
|
|
|
|
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,
|
|
// v1.39.4 — ciphertext wins when a disaster-recovery file carried it;
|
|
// a portable file's (or an older file's) readable value is sealed
|
|
// under this instance's key. The readable columns are never written.
|
|
locationEncrypted: sealBytesForRestore(
|
|
entry.locationEncrypted,
|
|
entry.location,
|
|
`practitioners.${entry.id}.locationEncrypted`,
|
|
unopened,
|
|
),
|
|
phoneEncrypted: sealBytesForRestore(
|
|
entry.phoneEncrypted,
|
|
entry.phone,
|
|
`practitioners.${entry.id}.phoneEncrypted`,
|
|
unopened,
|
|
),
|
|
noteEncrypted: sealBytesForRestore(
|
|
entry.noteEncrypted,
|
|
entry.note,
|
|
`practitioners.${entry.id}.noteEncrypted`,
|
|
unopened,
|
|
),
|
|
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 reminders are restored by another branch of the same transaction
|
|
// (v1.37.20 — they used to be on the coverage-pending register, when every
|
|
// reference was a known drop), so the check is against the database rather
|
|
// than against the payload. A reference the file genuinely lacks — a
|
|
// portable export omits tombstoned reminders — still drops to NULL and is
|
|
// still reported.
|
|
const reminderRows = await tx.measurementReminder.findMany({
|
|
where: { userId: ownerId },
|
|
select: { id: true },
|
|
});
|
|
const restoredReminders = new Set(reminderRows.map((row) => row.id));
|
|
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);
|
|
}
|
|
const reminderId =
|
|
entry.reminderId && restoredReminders.has(entry.reminderId)
|
|
? entry.reminderId
|
|
: null;
|
|
if (entry.reminderId && reminderId === null) {
|
|
droppedReminders.push(entry.reminderId);
|
|
}
|
|
return {
|
|
id: entry.id,
|
|
userId: ownerId,
|
|
occurredAt: new Date(entry.occurredAt),
|
|
status: entry.status,
|
|
kind: entry.kind,
|
|
practitionerId,
|
|
reasonEncrypted: sealBytesForRestore(
|
|
entry.reasonEncrypted,
|
|
entry.reason,
|
|
`encounters.${entry.id}.reasonEncrypted`,
|
|
unopened,
|
|
),
|
|
outcomeEncrypted: sealBytesForRestore(
|
|
entry.outcomeEncrypted,
|
|
entry.outcome,
|
|
`encounters.${entry.id}.outcomeEncrypted`,
|
|
unopened,
|
|
),
|
|
// Absent in a file written before v1.39.1: the visit comes back
|
|
// with no site, which is what it had.
|
|
bodySiteEncrypted: sealBytesForRestore(
|
|
entry.bodySiteEncrypted,
|
|
entry.bodySite,
|
|
`encounters.${entry.id}.bodySiteEncrypted`,
|
|
unopened,
|
|
),
|
|
laterality: entry.laterality ?? null,
|
|
reminderId,
|
|
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,
|
|
);
|
|
recordUnknownKeys(skips, "visitCiphertext", unopened, unopened);
|
|
|
|
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,
|
|
};
|
|
}
|