mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Add the visit record and the carriers that keep it
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.
This commit is contained in:
1 parent
2f68b3dc79
commit
24425e9aff
25 files changed
+1466
-174
No files matched your search
@@ -282,6 +282,14 @@ function sourceClient() {
|
||||
},
|
||||
]),
|
||||
},
|
||||
// The visit tables. Empty here: this file asserts the sections it seeds,
|
||||
// and an account with no visits is the ordinary case the builder must
|
||||
// still answer for.
|
||||
practitioner: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounter: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
customMetric: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
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 { invalidateUserData } from "@/lib/cache/invalidate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -88,6 +89,9 @@ interface RestoreResponse {
|
||||
correlationPatterns: number;
|
||||
intradayProfiles: number;
|
||||
healthScoreRecords: number;
|
||||
practitioners: number;
|
||||
encounters: number;
|
||||
encounterLinks: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1271,6 +1275,19 @@ const handler = apiHandler(
|
||||
});
|
||||
}
|
||||
|
||||
// Visits, the address book and the three link tables. Last on
|
||||
// purpose: a link is written only when both of its ends exist, and
|
||||
// the documents above are the final far side to land. Both ends of
|
||||
// this section live in `src/lib/export/visits-backup.ts`, so a grep
|
||||
// of THIS file alone will not find them — the same delegation as the
|
||||
// cycle, profile, intraday and score restores above.
|
||||
const visitsCleared = await restoreVisitsData(
|
||||
tx,
|
||||
ownerId,
|
||||
payload,
|
||||
skips,
|
||||
);
|
||||
|
||||
const cleared = {
|
||||
measurements: measurements.count,
|
||||
medications: meds.count,
|
||||
@@ -1297,6 +1314,9 @@ const handler = apiHandler(
|
||||
correlationPatterns: profileCleared.correlationPatterns,
|
||||
intradayProfiles: intradayCleared.intradayProfiles,
|
||||
healthScoreRecords: healthScoreCleared.healthScoreRecords,
|
||||
practitioners: visitsCleared.practitioners,
|
||||
encounters: visitsCleared.encounters,
|
||||
encounterLinks: visitsCleared.encounterLinks,
|
||||
};
|
||||
return { cleared, skipped: summarizeRestoreSkips(skips) };
|
||||
},
|
||||
|
||||
@@ -28,6 +28,14 @@ vi.mock("@/lib/db", () => ({
|
||||
// `buildIntradayProfileBackupSection`.
|
||||
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// The visit tables. Empty here: this file asserts the sections it seeds,
|
||||
// and an account with no visits is the ordinary case the builder must
|
||||
// still answer for.
|
||||
practitioner: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounter: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// v1.15.0 — cycle tables read by the full-backup helper.
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
@@ -98,6 +106,15 @@ beforeEach(() => {
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.healthScoreRecord.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.practitioner.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.encounterDocumentLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.encounterLabLink.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.encounterConditionLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -26,6 +26,14 @@ vi.mock("@/lib/db", () => ({
|
||||
// `buildIntradayProfileBackupSection`.
|
||||
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// The visit tables. Empty here: this file asserts the sections it seeds,
|
||||
// and an account with no visits is the ordinary case the builder must
|
||||
// still answer for.
|
||||
practitioner: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounter: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -198,6 +198,9 @@ function catalogueLabel(
|
||||
if (catalogue === "moodTag") {
|
||||
return t("admin.section.backups.restoreSkippedMoodTag");
|
||||
}
|
||||
if (catalogue === "visitReference") {
|
||||
return t("admin.section.backups.restoreSkippedVisitReference");
|
||||
}
|
||||
return t("admin.section.backups.restoreSkippedMoodFactor");
|
||||
}
|
||||
|
||||
|
||||
@@ -229,6 +229,15 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
|
||||
{ model: "Allergy", field: "notesEncrypted", kind: "bytes" },
|
||||
{ model: "FamilyHistoryEntry", field: "notesEncrypted", kind: "bytes" },
|
||||
|
||||
// ───── v1.38.0 visit free text (Bytes columns) ─────
|
||||
// Why the person went, what came out of it, and the practice note. The
|
||||
// structured columns a picker sorts and searches on — the practitioner's
|
||||
// name, the kind, the status — stay queryable plaintext, the same split the
|
||||
// structured health records above make.
|
||||
{ model: "Encounter", field: "reasonEncrypted", kind: "bytes" },
|
||||
{ model: "Encounter", field: "outcomeEncrypted", kind: "bytes" },
|
||||
{ model: "Practitioner", field: "noteEncrypted", kind: "bytes" },
|
||||
|
||||
// ───── Inbound clinical document (Bytes column, codec-dispatched) ─────
|
||||
// The raw uploaded document (the most sensitive blob in the corpus; never
|
||||
// logged, never in wide-event meta). Two layouts, recorded per row in
|
||||
|
||||
@@ -76,6 +76,17 @@ export const WIPE_MODELS = [
|
||||
"MoodReminderDispatch",
|
||||
"MoodTagHidden",
|
||||
|
||||
// ── Visits ──────────────────────────────────────────────────────────────
|
||||
// The three link tables cascade from the encounter AND from the far side
|
||||
// they point at — a lab result, a document, a condition episode — so they
|
||||
// are deleted before any of those, or their per-model counts read zero on an
|
||||
// audit row that claims to say what was removed. The encounter follows its
|
||||
// own links; the address book it points at is a parent and goes last.
|
||||
"EncounterDocumentLink",
|
||||
"EncounterLabLink",
|
||||
"EncounterConditionLink",
|
||||
"Encounter",
|
||||
|
||||
// ── Clinical record ─────────────────────────────────────────────────────
|
||||
"MentalHealthAssessment",
|
||||
"LabResult",
|
||||
@@ -188,6 +199,9 @@ export const WIPE_MODELS = [
|
||||
"IllnessEpisode",
|
||||
"Workout",
|
||||
"InboundDocument",
|
||||
// Encounters point here with SetNull, so the address book outlives the
|
||||
// visits it is attached to and is removed after them.
|
||||
"Practitioner",
|
||||
"ApiToken",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -247,6 +247,14 @@ function makePrisma() {
|
||||
]),
|
||||
},
|
||||
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// The visit tables. Empty here: this file asserts the sections it seeds,
|
||||
// and an account with no visits is the ordinary case the builder must
|
||||
// still answer for.
|
||||
practitioner: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounter: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterConditionLink: { 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.
|
||||
|
||||
@@ -134,6 +134,13 @@ export const BACKED_UP_MODELS = [
|
||||
"DocumentConditionLink",
|
||||
"ExtractedFact",
|
||||
|
||||
// ── Visits ────────────────────────────────────────────────────────────────
|
||||
"Practitioner",
|
||||
"Encounter",
|
||||
"EncounterDocumentLink",
|
||||
"EncounterLabLink",
|
||||
"EncounterConditionLink",
|
||||
|
||||
// ── Coach ─────────────────────────────────────────────────────────────────
|
||||
"CoachConversation",
|
||||
"CoachMessage",
|
||||
@@ -176,6 +183,12 @@ export const BACKUP_WRITER_FILES: readonly string[] = [
|
||||
"src/lib/export/profile-backup.ts",
|
||||
"src/lib/export/intraday-profile-backup.ts",
|
||||
"src/lib/export/health-score-backup.ts",
|
||||
// Visits, the address book and the three link tables. Each model reads
|
||||
// through its OWN delegate here rather than riding a relation, because
|
||||
// `documentLinks` is a field name on two different models and a
|
||||
// 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",
|
||||
"src/lib/cycle/backup.ts",
|
||||
];
|
||||
|
||||
@@ -184,6 +197,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [
|
||||
"src/lib/export/profile-backup.ts",
|
||||
"src/lib/export/intraday-profile-backup.ts",
|
||||
"src/lib/export/health-score-backup.ts",
|
||||
"src/lib/export/visits-backup.ts",
|
||||
"src/lib/cycle/backup.ts",
|
||||
];
|
||||
|
||||
@@ -235,6 +249,17 @@ export const TWO_ENDED_MODELS = [
|
||||
"Workout",
|
||||
"NutrientIntakeDay",
|
||||
"InboundDocument",
|
||||
// Visits travel both ways from the release that introduces them. Two
|
||||
// COVERAGE_PENDING neighbours already say what the alternative costs:
|
||||
// reminders restore silent, and the filing between documents and conditions
|
||||
// is lost. Leaving the visit links pending would return visits and documents
|
||||
// with nothing between them — the same loss, one layer deeper — so the links
|
||||
// land carried rather than owed.
|
||||
"Practitioner",
|
||||
"Encounter",
|
||||
"EncounterDocumentLink",
|
||||
"EncounterLabLink",
|
||||
"EncounterConditionLink",
|
||||
] as const;
|
||||
|
||||
/** One model claimed to travel both ways. */
|
||||
|
||||
@@ -37,6 +37,12 @@ import {
|
||||
type HealthScoreBackupCounts,
|
||||
type HealthScoreBackupSection,
|
||||
} from "@/lib/export/health-score-backup";
|
||||
import {
|
||||
buildVisitsBackupSection,
|
||||
countVisitsBackupSection,
|
||||
type VisitsBackupCounts,
|
||||
type VisitsBackupSection,
|
||||
} from "@/lib/export/visits-backup";
|
||||
import {
|
||||
buildIntradayProfileBackupSection,
|
||||
countIntradayProfileBackupSection,
|
||||
@@ -49,7 +55,8 @@ export interface FullBackupCounts
|
||||
RecordsBackupCounts,
|
||||
ProfileBackupCounts,
|
||||
IntradayProfileBackupCounts,
|
||||
HealthScoreBackupCounts {
|
||||
HealthScoreBackupCounts,
|
||||
VisitsBackupCounts {
|
||||
measurements: number;
|
||||
medications: number;
|
||||
intakeEvents: number;
|
||||
@@ -171,6 +178,7 @@ export async function buildFullBackupPayload(
|
||||
profile,
|
||||
intradayProfiles,
|
||||
healthScoreRecords,
|
||||
visits,
|
||||
nutrientDays,
|
||||
] = await Promise.all([
|
||||
disasterRecovery
|
||||
@@ -295,6 +303,12 @@ export async function buildFullBackupPayload(
|
||||
buildHealthScoreBackupSection(prisma, userId, {
|
||||
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
|
||||
}),
|
||||
// Visits, the address book behind them, and the three link tables. Both
|
||||
// ends live in `src/lib/export/visits-backup.ts` beside each other, so a
|
||||
// grep of the restore route alone will not find them.
|
||||
buildVisitsBackupSection(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").
|
||||
@@ -321,6 +335,7 @@ export async function buildFullBackupPayload(
|
||||
const profileSection: ProfileBackupSection = profile;
|
||||
const intradaySection: IntradayProfileBackupSection = intradayProfiles;
|
||||
const healthScoreSection: HealthScoreBackupSection = healthScoreRecords;
|
||||
const visitsSection: VisitsBackupSection = visits;
|
||||
|
||||
const payload = {
|
||||
schemaVersion: BACKUP_SCHEMA_VERSION,
|
||||
@@ -544,6 +559,7 @@ export async function buildFullBackupPayload(
|
||||
...profileSection,
|
||||
...intradaySection,
|
||||
...healthScoreSection,
|
||||
...visitsSection,
|
||||
nutrientDays: nutrientDays.map((n) => ({
|
||||
day: n.day,
|
||||
nutrient: n.nutrient,
|
||||
@@ -571,6 +587,7 @@ export async function buildFullBackupPayload(
|
||||
...countProfileBackupSection(profile),
|
||||
...countIntradayProfileBackupSection(intradayProfiles),
|
||||
...countHealthScoreBackupSection(healthScoreRecords),
|
||||
...countVisitsBackupSection(visits),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -28,9 +28,24 @@
|
||||
* edge rather than a record.
|
||||
*/
|
||||
|
||||
/** Which seeded catalogue a key failed to resolve against. */
|
||||
/**
|
||||
* Which lookup a key failed to resolve against.
|
||||
*
|
||||
* Four are seeded catalogues, as described above. The fifth, `visitReference`,
|
||||
* is a different kind of unresolvable and is here rather than in its own
|
||||
* mechanism because the cost and the honest response are identical: an edge is
|
||||
* lost, the record it hangs off survives, and the operator is told which one.
|
||||
* What it names is a row the file pointed at that the restore did not put
|
||||
* back — a reminder, which is on the coverage-pending register and never
|
||||
* travels, or a document, lab result or condition episode whose id a portable
|
||||
* export does not carry.
|
||||
*/
|
||||
export type SkippedCatalogue =
|
||||
"cycleSymptom" | "illnessSymptom" | "moodFactor" | "moodTag";
|
||||
| "cycleSymptom"
|
||||
| "illnessSymptom"
|
||||
| "moodFactor"
|
||||
| "moodTag"
|
||||
| "visitReference";
|
||||
|
||||
/** One key this instance does not know, and the links it cost. */
|
||||
export interface SkippedCatalogueKey {
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,13 @@ export interface MeasurementReminderDtoShape {
|
||||
/**
|
||||
* v1.18.1 — provenance: `VORSORGE` (user-created) or `COACH` (minted from
|
||||
* a Coach cadence suggestion). The UI labels the source.
|
||||
*
|
||||
* Deliberately two members, while `ReminderOrigin` in the schema has three.
|
||||
* The third, `ENCOUNTER`, belongs to a booked visit and never reaches a
|
||||
* Vorsorge surface: every query that feeds this mapper excludes it, and the
|
||||
* published enum stays closed at these two so the reminder contract does not
|
||||
* move. Widening this union is the wrong repair for a type error here — the
|
||||
* right one is finding the read site that lost its exclusion.
|
||||
*/
|
||||
origin: "VORSORGE" | "COACH";
|
||||
notifyHour: number;
|
||||
@@ -35,9 +42,27 @@ export interface MeasurementReminderDtoShape {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The invariant this mapper enforces: an `ENCOUNTER`-origin row is a booked
|
||||
* visit's one-shot reminder and belongs to no Vorsorge surface. Four read
|
||||
* sites exclude it — the reminder list route, the daily digest, the insights
|
||||
* preventive-care block and the MCP preventive-care tool. Reaching here means
|
||||
* one of them lost its filter, and the honest outcome is a loud failure rather
|
||||
* than an appointment relabelled as a checkup.
|
||||
*
|
||||
* Proven by `src/__tests__/encounter-reminder-exclusion.test.ts`.
|
||||
*/
|
||||
export function toMeasurementReminderDto(
|
||||
row: MeasurementReminder,
|
||||
): MeasurementReminderDtoShape {
|
||||
if (row.origin === "ENCOUNTER") {
|
||||
throw new Error(
|
||||
`toMeasurementReminderDto received an ENCOUNTER-origin reminder (${row.id}). ` +
|
||||
"Appointment reminders are excluded from every Vorsorge read surface; " +
|
||||
"a read site has lost its origin filter. See " +
|
||||
"src/__tests__/encounter-reminder-exclusion.test.ts.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
ContraceptiveKind,
|
||||
CycleTrackingGoal,
|
||||
DocumentSummaryState,
|
||||
EncounterKind,
|
||||
EncounterStatus,
|
||||
FamilyRelationship,
|
||||
FlowLevel,
|
||||
GlucoseContext,
|
||||
@@ -793,6 +795,56 @@ const illnessEpisodeBackupSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* A practitioner, an encounter, and the edges between an encounter and the
|
||||
* things it produced.
|
||||
*
|
||||
* `id` is required on both records, unlike the lab result above: a link
|
||||
* addresses an encounter and its far side by id and nothing else on either row
|
||||
* is unique enough to rebuild the reference from. The ciphertext columns ride
|
||||
* verbatim as base64 — a visit note is never decrypted into the file.
|
||||
*/
|
||||
const practitionerBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
specialty: z.string().nullable().optional(),
|
||||
practice: z.string().nullable().optional(),
|
||||
location: z.string().nullable().optional(),
|
||||
phone: z.string().nullable().optional(),
|
||||
noteEncrypted: base64BytesSchema.nullable().optional(),
|
||||
createdAt: isoDateTime,
|
||||
updatedAt: isoDateTime,
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const encounterBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
occurredAt: isoDateTime,
|
||||
status: z.enum(EncounterStatus),
|
||||
kind: z.enum(EncounterKind),
|
||||
practitionerId: z.string().nullable().optional(),
|
||||
reasonEncrypted: base64BytesSchema.nullable().optional(),
|
||||
outcomeEncrypted: base64BytesSchema.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(),
|
||||
createdAt: isoDateTime,
|
||||
updatedAt: isoDateTime,
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const encounterLinkBackupSchema = z
|
||||
.object({
|
||||
encounterId: z.string().min(1),
|
||||
targetId: z.string().min(1),
|
||||
createdAt: isoDateTime,
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const allergyBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
@@ -936,6 +988,15 @@ export const backupPayloadSchema = z
|
||||
// the sections above: a file written before the table existed carries no
|
||||
// key, and an account whose score never resolved writes [].
|
||||
healthScoreRecords: z.array(healthScoreRecordBackupSchema).default([]),
|
||||
// Visits, the address book behind them, and the three link tables.
|
||||
// Defaulted for the same reason as the sections above: a file written
|
||||
// before the tables existed carries no key, and an account that has never
|
||||
// filed a visit writes [].
|
||||
practitioners: z.array(practitionerBackupSchema).default([]),
|
||||
encounters: z.array(encounterBackupSchema).default([]),
|
||||
encounterDocumentLinks: z.array(encounterLinkBackupSchema).default([]),
|
||||
encounterLabLinks: z.array(encounterLinkBackupSchema).default([]),
|
||||
encounterConditionLinks: z.array(encounterLinkBackupSchema).default([]),
|
||||
manifest: backupManifestSchema.nullable().default(null),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
Reference in new issue
Block a user