mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
feat(backup): carry the five relation link tables both ways
Lab result to document, visit to medication, visit to dose, medication to document and medication to condition now ride the full backup in their own section file and come back with their original filing instant. A link whose end the restore did not write is dropped and reported under the new relationLink skip kind. The tables leave the coverage register for the two-ended list, guarded by relation-links-backup-completeness.test.ts and seeded in the round trip.
This commit is contained in:
1 parent
e292db1fbd
commit
ffd46b017b
23 files changed
+711
-11
No files matched your search
@@ -7878,6 +7878,7 @@
|
||||
"restoreSkippedCoachAttachment": "Coach-Dokumentverknüpfung",
|
||||
"restoreSkippedCoachReference": "Coach-Verweis",
|
||||
"restoreSkippedDocumentConditionLink": "Dokumentzuordnung",
|
||||
"restoreSkippedRelationLink": "Verknüpfung zwischen Einträgen",
|
||||
"restoreSkippedExtractedFact": "Dokumentbefund",
|
||||
"restoreSkippedFactCommitment": "Befundübernahme",
|
||||
"docsLink": "Dokumentation",
|
||||
|
||||
@@ -7878,6 +7878,7 @@
|
||||
"restoreSkippedCoachAttachment": "Coach document link",
|
||||
"restoreSkippedCoachReference": "Coach reference",
|
||||
"restoreSkippedDocumentConditionLink": "Document filing",
|
||||
"restoreSkippedRelationLink": "Link between records",
|
||||
"restoreSkippedExtractedFact": "Document fact",
|
||||
"restoreSkippedFactCommitment": "Fact commitment",
|
||||
"docsLink": "Documentation",
|
||||
|
||||
@@ -7878,6 +7878,7 @@
|
||||
"restoreSkippedCoachAttachment": "Enlace de documento del Coach",
|
||||
"restoreSkippedCoachReference": "Referencia del Coach",
|
||||
"restoreSkippedDocumentConditionLink": "Clasificación de documento",
|
||||
"restoreSkippedRelationLink": "Vínculo entre registros",
|
||||
"restoreSkippedExtractedFact": "Dato del documento",
|
||||
"restoreSkippedFactCommitment": "Registro del dato",
|
||||
"docsLink": "Documentación",
|
||||
|
||||
@@ -7878,6 +7878,7 @@
|
||||
"restoreSkippedCoachAttachment": "Lien de document du Coach",
|
||||
"restoreSkippedCoachReference": "Référence du Coach",
|
||||
"restoreSkippedDocumentConditionLink": "Classement du document",
|
||||
"restoreSkippedRelationLink": "Lien entre entrées",
|
||||
"restoreSkippedExtractedFact": "Donnée du document",
|
||||
"restoreSkippedFactCommitment": "Enregistrement de la donnée",
|
||||
"docsLink": "Documentation",
|
||||
|
||||
@@ -7878,6 +7878,7 @@
|
||||
"restoreSkippedCoachAttachment": "Collegamento documento del Coach",
|
||||
"restoreSkippedCoachReference": "Riferimento del Coach",
|
||||
"restoreSkippedDocumentConditionLink": "Classificazione del documento",
|
||||
"restoreSkippedRelationLink": "Collegamento tra voci",
|
||||
"restoreSkippedExtractedFact": "Dato del documento",
|
||||
"restoreSkippedFactCommitment": "Registrazione del dato",
|
||||
"docsLink": "Documentazione",
|
||||
|
||||
@@ -7853,6 +7853,7 @@
|
||||
"restoreSkippedCoachAttachment": "코치 문서 연결",
|
||||
"restoreSkippedCoachReference": "코치 참조",
|
||||
"restoreSkippedDocumentConditionLink": "문서 분류",
|
||||
"restoreSkippedRelationLink": "기록 간 연결",
|
||||
"restoreSkippedExtractedFact": "문서 추출 정보",
|
||||
"restoreSkippedFactCommitment": "추출 정보 확정",
|
||||
"docsLink": "문서",
|
||||
|
||||
@@ -7878,6 +7878,7 @@
|
||||
"restoreSkippedCoachAttachment": "Powiązanie dokumentu z Coachem",
|
||||
"restoreSkippedCoachReference": "Odwołanie Coacha",
|
||||
"restoreSkippedDocumentConditionLink": "Przypisanie dokumentu",
|
||||
"restoreSkippedRelationLink": "Powiązanie między wpisami",
|
||||
"restoreSkippedExtractedFact": "Fakt z dokumentu",
|
||||
"restoreSkippedFactCommitment": "Zapis faktu",
|
||||
"docsLink": "Dokumentacja",
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* v1.43 — pair guard binding the five relation link tables in
|
||||
* `prisma/schema.prisma` to the backup that carries them
|
||||
* (`src/lib/export/relation-links-backup.ts`), on the pattern of
|
||||
* `environment-backup-completeness.test.ts`.
|
||||
*
|
||||
* A link table is two ids and an instant, and the failure this guards is the
|
||||
* quiet one: a table the backup does not read comes back empty after a
|
||||
* restore, every record it joined still there and no longer pointing at its
|
||||
* partner, and nothing fails. So each end is compared as a literal:
|
||||
*
|
||||
* - every relation link table of the matrix is a two-ended model of the
|
||||
* backup plan, not on the debt register;
|
||||
* - every scalar column of each table is read by the export's select (or
|
||||
* excluded here with a reason);
|
||||
* - every read column is written back by the restore's `createMany`;
|
||||
* - the wire schema names the same two ends for the section key.
|
||||
*
|
||||
* Mutation check: delete `documentId: true` from the lab-result select and
|
||||
* the export case names it; delete the `MedicationConditionLink` line from
|
||||
* `TWO_ENDED_MODELS` and the plan case names it.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BACKED_UP_MODELS,
|
||||
COVERAGE_PENDING,
|
||||
TWO_ENDED_MODELS,
|
||||
} from "@/lib/export/backup-plan";
|
||||
import { RELATION_LINK_SECTION_KEYS } from "@/lib/export/relation-links-backup";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const schema = readFileSync(join(ROOT, "prisma", "schema.prisma"), "utf8");
|
||||
const source = readFileSync(
|
||||
join(ROOT, "src", "lib", "export", "relation-links-backup.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const wire = readFileSync(
|
||||
join(ROOT, "src", "lib", "validations", "backup.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
/** The five tables v1.43 adds to close the relation matrix. */
|
||||
const RELATION_LINK_MODELS = [
|
||||
"LabResultDocumentLink",
|
||||
"EncounterMedicationLink",
|
||||
"EncounterVaccinationLink",
|
||||
"MedicationDocumentLink",
|
||||
"MedicationConditionLink",
|
||||
] as const;
|
||||
|
||||
/** Columns the backup is right to leave out, with the reason. */
|
||||
const EXCLUDED: Record<string, string> = {
|
||||
id: "nothing addresses a link row by id; the pair is its identity (unique)",
|
||||
userId:
|
||||
"user-scoped backup; the owner is re-derived from the restoring account",
|
||||
};
|
||||
|
||||
function block(text: string, start: string, end: string): string {
|
||||
const from = text.indexOf(start);
|
||||
expect(from, `${start} not found`).toBeGreaterThan(-1);
|
||||
const to = text.indexOf(end, from);
|
||||
expect(to, `${end} not found after ${start}`).toBeGreaterThan(from);
|
||||
return text.slice(from, to);
|
||||
}
|
||||
|
||||
function scalarColumns(model: string): string[] {
|
||||
const models = new Set(
|
||||
[...schema.matchAll(/^model\s+([A-Za-z_]\w*)\s*\{/gm)].map((m) => m[1]),
|
||||
);
|
||||
const out: string[] = [];
|
||||
for (const raw of block(schema, `model ${model} {`, "\n}")
|
||||
.split("\n")
|
||||
.slice(1)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("//") || line.startsWith("@@")) continue;
|
||||
const m = /^([a-zA-Z_]\w*)\s+([A-Za-z_]\w*)(\[\])?/.exec(line);
|
||||
if (!m || m[3] || models.has(m[2]) || line.includes("@relation")) continue;
|
||||
out.push(m[1]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const delegate = (model: string) => model[0].toLowerCase() + model.slice(1);
|
||||
|
||||
describe("relation link tables in the backup plan", () => {
|
||||
it.each(RELATION_LINK_MODELS)("%s travels both ways", (model) => {
|
||||
expect(BACKED_UP_MODELS as readonly string[]).toContain(model);
|
||||
expect(TWO_ENDED_MODELS as readonly string[]).toContain(model);
|
||||
expect(COVERAGE_PENDING).not.toHaveProperty(model);
|
||||
});
|
||||
|
||||
it("names one section key per table", () => {
|
||||
expect([...RELATION_LINK_SECTION_KEYS].sort()).toEqual(
|
||||
RELATION_LINK_MODELS.map((m) => `${delegate(m)}s`).sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(RELATION_LINK_MODELS)("%s backup completeness", (model) => {
|
||||
const columns = scalarColumns(model);
|
||||
const name = delegate(model);
|
||||
const exported = block(source, `prisma.${name}.findMany(`, "}),");
|
||||
const restored = block(source, `tx.${name}.createMany(`, "skipDuplicates");
|
||||
const wired = block(wire, ` ${name}s: z`, ".default([])");
|
||||
const ends = columns.filter((c) => !(c in EXCLUDED) && c !== "createdAt");
|
||||
|
||||
it("parses both ends into a plausible shape", () => {
|
||||
expect(columns).toEqual(
|
||||
expect.arrayContaining(["id", "userId", "createdAt"]),
|
||||
);
|
||||
expect(ends).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("the export reads every column", () => {
|
||||
const missing = columns.filter(
|
||||
(c) => !(c in EXCLUDED) && !new RegExp(`\\b${c}: true\\b`).test(exported),
|
||||
);
|
||||
expect(missing, `the export omits these ${model} columns`).toEqual([]);
|
||||
});
|
||||
|
||||
it("the restore writes every column back", () => {
|
||||
const unwritten = ends.filter(
|
||||
(c) => !new RegExp(`\\b${c}: r\\.${c}\\b`).test(restored),
|
||||
);
|
||||
expect(unwritten, `the restore drops these ${model} columns`).toEqual([]);
|
||||
// `stamp(...)` writes the owner and the original filing instant.
|
||||
expect(restored).toMatch(/\.\.\.stamp\(r\.createdAt\)/);
|
||||
});
|
||||
|
||||
it("the wire schema names the same two ends", () => {
|
||||
for (const end of ends) {
|
||||
expect(wired, `${name}s wire schema`).toContain(`"${end}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,11 @@ vi.mock("@/lib/db", () => ({
|
||||
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
lifeEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
labResultDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterMedicationLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterVaccinationLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// v1.15.0 — cycle tables read by the full-backup helper.
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
@@ -179,6 +184,21 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.symptomDefinition.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.lifeEvent.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.labResultDocumentLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.encounterMedicationLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.encounterVaccinationLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationDocumentLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationConditionLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodTagHidden.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.mentalHealthAssessment.findMany).mockResolvedValue(
|
||||
|
||||
@@ -79,6 +79,11 @@ vi.mock("@/lib/db", () => ({
|
||||
ecgRecording: { findMany: vi.fn() },
|
||||
symptomDefinition: { findMany: vi.fn() },
|
||||
lifeEvent: { findMany: vi.fn() },
|
||||
labResultDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterMedicationLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterVaccinationLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -69,6 +69,11 @@ vi.mock("@/lib/db", () => ({
|
||||
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
lifeEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
labResultDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterMedicationLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterVaccinationLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -448,6 +448,9 @@ function catalogueLabel(
|
||||
if (catalogue === "documentConditionLink") {
|
||||
return t("admin.section.backups.restoreSkippedDocumentConditionLink");
|
||||
}
|
||||
if (catalogue === "relationLink") {
|
||||
return t("admin.section.backups.restoreSkippedRelationLink");
|
||||
}
|
||||
if (catalogue === "extractedFact") {
|
||||
return t("admin.section.backups.restoreSkippedExtractedFact");
|
||||
}
|
||||
|
||||
@@ -446,6 +446,11 @@ function makePrisma() {
|
||||
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
lifeEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
labResultDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterMedicationLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterVaccinationLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationConditionLink: { 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.
|
||||
|
||||
@@ -319,6 +319,11 @@ function sourceClient() {
|
||||
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
lifeEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
labResultDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterMedicationLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterVaccinationLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
customMetric: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -210,7 +210,9 @@ export const BACKED_UP_MODELS = [
|
||||
// A marker's other spellings and its merge ledger, a supplement's
|
||||
// composition, the Dawarich daily values, and the five relation links that
|
||||
// close the relation matrix. Each is owned data nothing recomputes; each is
|
||||
// on COVERAGE_PENDING until its section carries it.
|
||||
// on COVERAGE_PENDING until its section carries it. The relation links
|
||||
// travel since the release that introduces them
|
||||
// (`src/lib/export/relation-links-backup.ts`).
|
||||
"BiomarkerAlias",
|
||||
"BiomarkerMerge",
|
||||
"MedicationIngredient",
|
||||
@@ -294,6 +296,9 @@ export const BACKUP_WRITER_FILES: readonly string[] = [
|
||||
"src/lib/export/symptoms-backup.ts",
|
||||
// The person's life events, both ends beside each other (v1.42).
|
||||
"src/lib/export/life-events-backup.ts",
|
||||
// The five relation links that close the relation matrix (v1.43), both
|
||||
// ends beside each other, each through its OWN delegate.
|
||||
"src/lib/export/relation-links-backup.ts",
|
||||
"src/lib/cycle/backup.ts",
|
||||
// The medication category is read through the one helper that normalises
|
||||
// it; the payload writer calls the helper rather than the delegate. The
|
||||
@@ -318,6 +323,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [
|
||||
"src/lib/export/ecg-backup.ts",
|
||||
"src/lib/export/symptoms-backup.ts",
|
||||
"src/lib/export/life-events-backup.ts",
|
||||
"src/lib/export/relation-links-backup.ts",
|
||||
"src/lib/cycle/backup.ts",
|
||||
// The restore writes the medication category through the same helper.
|
||||
"src/lib/medication-category.ts",
|
||||
@@ -584,6 +590,14 @@ export const TWO_ENDED_MODELS = [
|
||||
// a plain 404 instead of leading to the marker it went into.
|
||||
"BiomarkerAlias",
|
||||
"BiomarkerMerge",
|
||||
// v1.43 — the five relation links that close the relation matrix, carried
|
||||
// from the release that introduces them. Each is two ids and the instant it
|
||||
// was filed, so carrying them cost no more than owing them would have.
|
||||
"LabResultDocumentLink",
|
||||
"EncounterMedicationLink",
|
||||
"EncounterVaccinationLink",
|
||||
"MedicationDocumentLink",
|
||||
"MedicationConditionLink",
|
||||
] as const;
|
||||
|
||||
/** One model claimed to travel both ways. */
|
||||
@@ -626,16 +640,6 @@ export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
|
||||
"What one tablet or capsule of a supplement contains. Without it a restored account's supplements stop counting toward the day's micronutrients, and the composition has to be typed in again from the label.",
|
||||
DawarichDay:
|
||||
"The daily movement values fetched from Dawarich: distances, places visited, time away from home, travel days. Days the Dawarich instance has since pruned cannot be fetched again, so they are lost for good.",
|
||||
LabResultDocumentLink:
|
||||
"Which stored report a lab result was read from. Without it the result and the report stop pointing at each other, and the result loses its source.",
|
||||
EncounterMedicationLink:
|
||||
"Which medications a visit prescribed, changed or reviewed. Without it the visit and the medication stop pointing at each other.",
|
||||
EncounterVaccinationLink:
|
||||
"Which doses were given at a visit. Without it the visit and the dose stop pointing at each other.",
|
||||
MedicationDocumentLink:
|
||||
"Which documents belong to a medication: the prescription, the plan, the package insert. Without it they stop pointing at each other.",
|
||||
MedicationConditionLink:
|
||||
"Which condition a medication treats (its indication). Without it the medication and the condition stop pointing at each other.",
|
||||
WorkoutRoute:
|
||||
"GPS traces. Deliberately absent from the payload today and DISCLOSED as absent in the file's own manifest, which is why this is a documented exclusion rather than a silent one — but it is still a loss for a self-hoster with no other copy.",
|
||||
WorkoutSamples:
|
||||
|
||||
@@ -180,6 +180,7 @@ const SUMMARY_COUNTS = [
|
||||
"encounterLinks",
|
||||
"vaccinations",
|
||||
"vaccinationLinks",
|
||||
"relationLinks",
|
||||
"customVaccines",
|
||||
"measurementReminders",
|
||||
"measurementReminderEvents",
|
||||
|
||||
@@ -91,6 +91,12 @@ import {
|
||||
type DocumentFilingBackupCounts,
|
||||
type DocumentFilingBackupSection,
|
||||
} from "@/lib/export/document-filing-backup";
|
||||
import {
|
||||
buildRelationLinksBackupSection,
|
||||
countRelationLinksBackupSection,
|
||||
type RelationLinksBackupCounts,
|
||||
type RelationLinksBackupSection,
|
||||
} from "@/lib/export/relation-links-backup";
|
||||
import {
|
||||
buildAwardsBackupSection,
|
||||
countAwardsBackupSection,
|
||||
@@ -155,6 +161,7 @@ export interface FullBackupCounts
|
||||
CoachMemoryBackupCounts,
|
||||
SensitiveBackupCounts,
|
||||
DocumentFilingBackupCounts,
|
||||
RelationLinksBackupCounts,
|
||||
AwardsBackupCounts,
|
||||
EnvironmentBackupCounts,
|
||||
OnboardingBackupCounts,
|
||||
@@ -736,6 +743,7 @@ export async function buildFullBackupPayload(
|
||||
ecg,
|
||||
symptoms,
|
||||
lifeEvents,
|
||||
relationLinks,
|
||||
nutrientDays,
|
||||
accountSettings,
|
||||
] = await Promise.all([
|
||||
@@ -925,6 +933,11 @@ export async function buildFullBackupPayload(
|
||||
buildLifeEventsBackupSection(prisma, userId, {
|
||||
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
|
||||
}),
|
||||
// The five relation links that close the relation matrix (v1.43). Both
|
||||
// ends live in `src/lib/export/relation-links-backup.ts`.
|
||||
buildRelationLinksBackupSection(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").
|
||||
@@ -980,6 +993,7 @@ export async function buildFullBackupPayload(
|
||||
const ecgSection: EcgBackupSection = ecg;
|
||||
const symptomsSection: SymptomsBackupSection = symptoms;
|
||||
const lifeEventsSection: LifeEventsBackupSection = lifeEvents;
|
||||
const relationLinksSection: RelationLinksBackupSection = relationLinks;
|
||||
const accountSettingsSection: AccountSettingsBackupSection = accountSettings;
|
||||
|
||||
// The three unbounded tables, either read into arrays or left as markers
|
||||
@@ -1387,6 +1401,7 @@ export async function buildFullBackupPayload(
|
||||
...ecgSection,
|
||||
...symptomsSection,
|
||||
...lifeEventsSection,
|
||||
...relationLinksSection,
|
||||
nutrientDays: nutrientDays.map((n) => ({
|
||||
day: n.day,
|
||||
nutrient: n.nutrient,
|
||||
@@ -1456,6 +1471,7 @@ export async function buildFullBackupPayload(
|
||||
...countEcgBackupSection(ecg),
|
||||
...countSymptomsBackupSection(symptoms),
|
||||
...countLifeEventsBackupSection(lifeEvents),
|
||||
...countRelationLinksBackupSection(relationLinks),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* The relation links that close the relation matrix (v1.43), with both backup
|
||||
* ends in one file.
|
||||
*
|
||||
* Same arrangement as `document-filing-backup.ts`, `visits-backup.ts` and the
|
||||
* other section files: 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 tables ride, each a pair of an account's own records:
|
||||
*
|
||||
* - `LabResultDocumentLink`: the report a lab result was read from;
|
||||
* - `EncounterMedicationLink`: a medication prescribed, changed or reviewed
|
||||
* at a visit;
|
||||
* - `EncounterVaccinationLink`: a dose given at a visit;
|
||||
* - `MedicationDocumentLink`: a prescription, a plan, a package insert;
|
||||
* - `MedicationConditionLink`: the condition a medication treats.
|
||||
*
|
||||
* None holds anything but two ids and the instant the link was filed, so both
|
||||
* purposes carry the same bytes. Both ends are foreign keys that cascade, so
|
||||
* an end that cannot resolve does not dangle quietly: it violates the
|
||||
* constraint and costs the operator the WHOLE restore. The builder therefore
|
||||
* carries a link only when both of its ends are carried (mirroring the
|
||||
* tombstone choices of the sections that carry the records), and the restore
|
||||
* checks every link against the rows this transaction actually wrote and
|
||||
* reports what it could not place, because a hand-edited or truncated file is
|
||||
* the case the write-time filter cannot cover.
|
||||
*
|
||||
* Written directly rather than through the link service, under the standing
|
||||
* restore exemption the visit and vaccination backups hold: a restore rebuilds
|
||||
* an account rather than filing something in one, and it keeps the instant
|
||||
* each link was originally filed at, which the service's signature cannot
|
||||
* express.
|
||||
*/
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import {
|
||||
recordUnknownKeys,
|
||||
type RestoreSkipLog,
|
||||
} from "@/lib/export/restore-skips";
|
||||
|
||||
export interface RelationLinksBackupOptions {
|
||||
purpose?: "portable-export" | "disaster-recovery";
|
||||
}
|
||||
|
||||
export interface LabResultDocumentLinkBackupEntry {
|
||||
labResultId: string;
|
||||
documentId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface EncounterMedicationLinkBackupEntry {
|
||||
encounterId: string;
|
||||
medicationId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface EncounterVaccinationLinkBackupEntry {
|
||||
encounterId: string;
|
||||
vaccinationId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface MedicationDocumentLinkBackupEntry {
|
||||
medicationId: string;
|
||||
documentId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface MedicationConditionLinkBackupEntry {
|
||||
medicationId: string;
|
||||
episodeId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RelationLinksBackupSection {
|
||||
labResultDocumentLinks: LabResultDocumentLinkBackupEntry[];
|
||||
encounterMedicationLinks: EncounterMedicationLinkBackupEntry[];
|
||||
encounterVaccinationLinks: EncounterVaccinationLinkBackupEntry[];
|
||||
medicationDocumentLinks: MedicationDocumentLinkBackupEntry[];
|
||||
medicationConditionLinks: MedicationConditionLinkBackupEntry[];
|
||||
}
|
||||
|
||||
/** The five tables summed, as the visit links are in the summary. */
|
||||
export interface RelationLinksBackupCounts {
|
||||
relationLinks: number;
|
||||
}
|
||||
|
||||
/** The section keys, in wire order. */
|
||||
export const RELATION_LINK_SECTION_KEYS = [
|
||||
"labResultDocumentLinks",
|
||||
"encounterMedicationLinks",
|
||||
"encounterVaccinationLinks",
|
||||
"medicationDocumentLinks",
|
||||
"medicationConditionLinks",
|
||||
] as const satisfies readonly (keyof RelationLinksBackupSection)[];
|
||||
|
||||
export async function buildRelationLinksBackupSection(
|
||||
prisma: Pick<
|
||||
PrismaClient,
|
||||
| "labResultDocumentLink"
|
||||
| "encounterMedicationLink"
|
||||
| "encounterVaccinationLink"
|
||||
| "medicationDocumentLink"
|
||||
| "medicationConditionLink"
|
||||
>,
|
||||
userId: string,
|
||||
options: RelationLinksBackupOptions = {},
|
||||
): Promise<RelationLinksBackupSection> {
|
||||
const disasterRecovery = options.purpose === "disaster-recovery";
|
||||
// The document reader skips tombstoned documents in BOTH purposes; lab
|
||||
// results, visits, doses and conditions are skipped in a portable export
|
||||
// only. A medication carries no tombstone.
|
||||
const liveUnlessDr = disasterRecovery ? {} : { deletedAt: null };
|
||||
const order = { createdAt: "asc" } as const;
|
||||
|
||||
const [labDocs, encounterMeds, encounterDoses, medDocs, medConditions] =
|
||||
await Promise.all([
|
||||
prisma.labResultDocumentLink.findMany({
|
||||
where: {
|
||||
userId,
|
||||
labResult: liveUnlessDr,
|
||||
document: { deletedAt: null },
|
||||
},
|
||||
orderBy: order,
|
||||
select: { labResultId: true, documentId: true, createdAt: true },
|
||||
}),
|
||||
prisma.encounterMedicationLink.findMany({
|
||||
where: { userId, encounter: liveUnlessDr },
|
||||
orderBy: order,
|
||||
select: { encounterId: true, medicationId: true, createdAt: true },
|
||||
}),
|
||||
prisma.encounterVaccinationLink.findMany({
|
||||
where: {
|
||||
userId,
|
||||
encounter: liveUnlessDr,
|
||||
vaccination: liveUnlessDr,
|
||||
},
|
||||
orderBy: order,
|
||||
select: { encounterId: true, vaccinationId: true, createdAt: true },
|
||||
}),
|
||||
prisma.medicationDocumentLink.findMany({
|
||||
where: { userId, document: { deletedAt: null } },
|
||||
orderBy: order,
|
||||
select: { medicationId: true, documentId: true, createdAt: true },
|
||||
}),
|
||||
prisma.medicationConditionLink.findMany({
|
||||
where: { userId, episode: liveUnlessDr },
|
||||
orderBy: order,
|
||||
select: { medicationId: true, episodeId: true, createdAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const at = <T extends { createdAt: Date }>(row: T) => ({
|
||||
...row,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
});
|
||||
return {
|
||||
labResultDocumentLinks: labDocs.map(at),
|
||||
encounterMedicationLinks: encounterMeds.map(at),
|
||||
encounterVaccinationLinks: encounterDoses.map(at),
|
||||
medicationDocumentLinks: medDocs.map(at),
|
||||
medicationConditionLinks: medConditions.map(at),
|
||||
};
|
||||
}
|
||||
|
||||
/** Row counts for the audit trail, mirroring the other section counters. */
|
||||
export function countRelationLinksBackupSection(
|
||||
section: RelationLinksBackupSection,
|
||||
): RelationLinksBackupCounts {
|
||||
return {
|
||||
relationLinks: RELATION_LINK_SECTION_KEYS.reduce(
|
||||
(total, key) => total + section[key].length,
|
||||
0,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Counts this restore wiped, for the audit trail. */
|
||||
export type RelationLinksRestoreCleared = RelationLinksBackupCounts;
|
||||
|
||||
/** The ids of every record this restore has written, by kind. */
|
||||
async function writtenIds(
|
||||
tx: Prisma.TransactionClient,
|
||||
ownerId: string,
|
||||
): Promise<{
|
||||
labResults: Set<string>;
|
||||
documents: Set<string>;
|
||||
encounters: Set<string>;
|
||||
vaccinations: Set<string>;
|
||||
medications: Set<string>;
|
||||
episodes: Set<string>;
|
||||
}> {
|
||||
const where = { userId: ownerId };
|
||||
const select = { id: true } as const;
|
||||
const [
|
||||
labResults,
|
||||
documents,
|
||||
encounters,
|
||||
vaccinations,
|
||||
medications,
|
||||
episodes,
|
||||
] = await Promise.all([
|
||||
tx.labResult.findMany({ where, select }),
|
||||
tx.inboundDocument.findMany({ where, select }),
|
||||
tx.encounter.findMany({ where, select }),
|
||||
tx.vaccinationRecord.findMany({ where, select }),
|
||||
tx.medication.findMany({ where, select }),
|
||||
tx.illnessEpisode.findMany({ where, select }),
|
||||
]);
|
||||
const ids = (rows: Array<{ id: string }>) => new Set(rows.map((r) => r.id));
|
||||
return {
|
||||
labResults: ids(labResults),
|
||||
documents: ids(documents),
|
||||
encounters: ids(encounters),
|
||||
vaccinations: ids(vaccinations),
|
||||
medications: ids(medications),
|
||||
episodes: ids(episodes),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the links whose two ends both exist, and report the rest under the end
|
||||
* that is missing, so an operator can tell "the report is gone" from "the
|
||||
* result is gone".
|
||||
*/
|
||||
function placeable<T>(
|
||||
rows: readonly T[],
|
||||
ends: [(row: T) => string, ReadonlySet<string>][],
|
||||
skips: RestoreSkipLog,
|
||||
): T[] {
|
||||
const dropped: string[] = [];
|
||||
const kept = rows.filter((row) => {
|
||||
for (const [end, written] of ends) {
|
||||
const id = end(row);
|
||||
if (!written.has(id)) {
|
||||
dropped.push(id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
recordUnknownKeys(skips, "relationLink", [...new Set(dropped)], dropped);
|
||||
return kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-create the five link tables.
|
||||
*
|
||||
* Delete-then-recreate inside the caller's transaction, matching every other
|
||||
* section. Each table cascades from both of its ends, so the record wipes
|
||||
* earlier in the restore have already emptied them; they are counted and
|
||||
* cleared explicitly anyway, so the cleared numbers are truthful.
|
||||
*
|
||||
* MUST be called AFTER the lab results, documents, visits, doses, medications
|
||||
* and conditions are restored: every end is a foreign key, and a link written
|
||||
* before its end exists aborts the transaction.
|
||||
*/
|
||||
export async function restoreRelationLinksData(
|
||||
tx: Prisma.TransactionClient,
|
||||
ownerId: string,
|
||||
payload: RelationLinksBackupSection,
|
||||
skips: RestoreSkipLog,
|
||||
): Promise<RelationLinksRestoreCleared> {
|
||||
const owned = { where: { userId: ownerId } };
|
||||
const [c1, c2, c3, c4, c5] = await Promise.all([
|
||||
tx.labResultDocumentLink.deleteMany(owned),
|
||||
tx.encounterMedicationLink.deleteMany(owned),
|
||||
tx.encounterVaccinationLink.deleteMany(owned),
|
||||
tx.medicationDocumentLink.deleteMany(owned),
|
||||
tx.medicationConditionLink.deleteMany(owned),
|
||||
]);
|
||||
|
||||
const written = await writtenIds(tx, ownerId);
|
||||
const stamp = (createdAt: string) => ({
|
||||
userId: ownerId,
|
||||
createdAt: new Date(createdAt),
|
||||
});
|
||||
|
||||
const labDocs = placeable(
|
||||
payload.labResultDocumentLinks,
|
||||
[
|
||||
[(r) => r.labResultId, written.labResults],
|
||||
[(r) => r.documentId, written.documents],
|
||||
],
|
||||
skips,
|
||||
);
|
||||
if (labDocs.length > 0) {
|
||||
await tx.labResultDocumentLink.createMany({
|
||||
data: labDocs.map((r) => ({
|
||||
labResultId: r.labResultId,
|
||||
documentId: r.documentId,
|
||||
...stamp(r.createdAt),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
const encounterMeds = placeable(
|
||||
payload.encounterMedicationLinks,
|
||||
[
|
||||
[(r) => r.encounterId, written.encounters],
|
||||
[(r) => r.medicationId, written.medications],
|
||||
],
|
||||
skips,
|
||||
);
|
||||
if (encounterMeds.length > 0) {
|
||||
await tx.encounterMedicationLink.createMany({
|
||||
data: encounterMeds.map((r) => ({
|
||||
encounterId: r.encounterId,
|
||||
medicationId: r.medicationId,
|
||||
...stamp(r.createdAt),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
const encounterDoses = placeable(
|
||||
payload.encounterVaccinationLinks,
|
||||
[
|
||||
[(r) => r.encounterId, written.encounters],
|
||||
[(r) => r.vaccinationId, written.vaccinations],
|
||||
],
|
||||
skips,
|
||||
);
|
||||
if (encounterDoses.length > 0) {
|
||||
await tx.encounterVaccinationLink.createMany({
|
||||
data: encounterDoses.map((r) => ({
|
||||
encounterId: r.encounterId,
|
||||
vaccinationId: r.vaccinationId,
|
||||
...stamp(r.createdAt),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
const medDocs = placeable(
|
||||
payload.medicationDocumentLinks,
|
||||
[
|
||||
[(r) => r.medicationId, written.medications],
|
||||
[(r) => r.documentId, written.documents],
|
||||
],
|
||||
skips,
|
||||
);
|
||||
if (medDocs.length > 0) {
|
||||
await tx.medicationDocumentLink.createMany({
|
||||
data: medDocs.map((r) => ({
|
||||
medicationId: r.medicationId,
|
||||
documentId: r.documentId,
|
||||
...stamp(r.createdAt),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
const medConditions = placeable(
|
||||
payload.medicationConditionLinks,
|
||||
[
|
||||
[(r) => r.medicationId, written.medications],
|
||||
[(r) => r.episodeId, written.episodes],
|
||||
],
|
||||
skips,
|
||||
);
|
||||
if (medConditions.length > 0) {
|
||||
await tx.medicationConditionLink.createMany({
|
||||
data: medConditions.map((r) => ({
|
||||
medicationId: r.medicationId,
|
||||
episodeId: r.episodeId,
|
||||
...stamp(r.createdAt),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
relationLinks: c1.count + c2.count + c3.count + c4.count + c5.count,
|
||||
};
|
||||
}
|
||||
@@ -116,6 +116,7 @@ import { restoreEnvironmentData } from "@/lib/export/environment-backup";
|
||||
import { restoreEcgData } from "@/lib/export/ecg-backup";
|
||||
import { restoreSymptomsData } from "@/lib/export/symptoms-backup";
|
||||
import { restoreLifeEventsData } from "@/lib/export/life-events-backup";
|
||||
import { restoreRelationLinksData } from "@/lib/export/relation-links-backup";
|
||||
import { restoredMedicationCreatedAt } from "@/lib/export/medication-created-at";
|
||||
import {
|
||||
encryptCategoryLabel,
|
||||
@@ -181,6 +182,7 @@ export interface RestoreResponse {
|
||||
encounterLinks: number;
|
||||
vaccinations: number;
|
||||
vaccinationLinks: number;
|
||||
relationLinks: number;
|
||||
customVaccines: number;
|
||||
measurementReminders: number;
|
||||
measurementReminderEvents: number;
|
||||
@@ -2655,6 +2657,17 @@ export async function restoreBackup(
|
||||
skips,
|
||||
);
|
||||
|
||||
// The five relation links (v1.43), last of the record sections they
|
||||
// join: every end is a foreign key against a lab result, document,
|
||||
// visit, dose, medication or condition, all written above. Both ends
|
||||
// of this section live in `src/lib/export/relation-links-backup.ts`.
|
||||
const relationLinksCleared = await restoreRelationLinksData(
|
||||
tx,
|
||||
ownerId,
|
||||
payload,
|
||||
skips,
|
||||
);
|
||||
|
||||
// The Coach's threads, turns and document links. AFTER the
|
||||
// documents on purpose: an attachment carries a `documentId` that
|
||||
// is a foreign key against them, so running earlier would make
|
||||
@@ -2787,6 +2800,7 @@ export async function restoreBackup(
|
||||
encounterLinks: visitsCleared.encounterLinks,
|
||||
vaccinations: vaccinationsCleared.vaccinations,
|
||||
vaccinationLinks: vaccinationsCleared.vaccinationLinks,
|
||||
relationLinks: relationLinksCleared.relationLinks,
|
||||
customVaccines: vaccinationsCleared.customVaccines,
|
||||
measurementReminders: remindersCleared.measurementReminders,
|
||||
measurementReminderEvents: remindersCleared.measurementReminderEvents,
|
||||
@@ -3066,6 +3080,7 @@ export async function restoreBackup(
|
||||
encounterLinks: summary.encounterLinks,
|
||||
vaccinations: summary.vaccinations,
|
||||
vaccinationLinks: summary.vaccinationLinks,
|
||||
relationLinks: summary.relationLinks,
|
||||
customVaccines: summary.customVaccines,
|
||||
measurementReminders: summary.measurementReminders,
|
||||
measurementReminderEvents: summary.measurementReminderEvents,
|
||||
|
||||
@@ -166,6 +166,12 @@
|
||||
* (`customMoodTags.<key>.labelEncrypted`): the tag or category restores, under
|
||||
* its key, without the person's own name for it.
|
||||
*
|
||||
* `relationLink` (v1.43) is the same row kind as `documentConditionLink`, for
|
||||
* the five link tables that close the relation matrix: a link whose lab
|
||||
* result, document, visit, dose, medication or condition the restore did not
|
||||
* put back. Every end is a real foreign key, so the LINK is dropped and both
|
||||
* records it joined (whichever exist) restore.
|
||||
*
|
||||
* The eighteenth and nineteenth, `visitCiphertext` and `vaccinationCiphertext`,
|
||||
* are the same again for the free text of the visits record (a practitioner's
|
||||
* note, address and phone; a visit's reason, outcome and body site) and of a
|
||||
@@ -183,6 +189,7 @@ export type SkippedCatalogue =
|
||||
| "coachAttachment"
|
||||
| "coachReference"
|
||||
| "documentConditionLink"
|
||||
| "relationLink"
|
||||
| "extractedFact"
|
||||
| "factCommitment"
|
||||
| "personalRecordReference"
|
||||
|
||||
@@ -85,6 +85,8 @@ export interface BackupSummary {
|
||||
vaccinations: number;
|
||||
/** v1.37.19 (A6-8) — vaccination↔document links. */
|
||||
vaccinationLinks: number;
|
||||
/** v1.43 — the five relation link tables, summed. */
|
||||
relationLinks: number;
|
||||
/** v1.42 (#1005) — the person's own vaccine definitions. */
|
||||
customVaccines: number;
|
||||
/** v1.37.20 (#223 / iOS #68) — Vorsorge reminder cadences. */
|
||||
@@ -178,6 +180,12 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary {
|
||||
payload.encounterConditionLinks.length,
|
||||
vaccinations: payload.vaccinations.length,
|
||||
vaccinationLinks: payload.vaccinationDocumentLinks.length,
|
||||
relationLinks:
|
||||
payload.labResultDocumentLinks.length +
|
||||
payload.encounterMedicationLinks.length +
|
||||
payload.encounterVaccinationLinks.length +
|
||||
payload.medicationDocumentLinks.length +
|
||||
payload.medicationConditionLinks.length,
|
||||
customVaccines: payload.customVaccines.length,
|
||||
// v1.37.20 (#223 / iOS #68) — counted from the release that carries them,
|
||||
// so the admin's "what did I just restore" answer never under-counts a
|
||||
|
||||
@@ -1409,6 +1409,23 @@ const vaccinationLinkBackupSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* v1.43 — one row of a relation link table: its two ends, by the column
|
||||
* names of the table, and the instant it was filed.
|
||||
*/
|
||||
function relationLinkBackupSchema<A extends string, B extends string>(
|
||||
a: A,
|
||||
b: B,
|
||||
) {
|
||||
return z
|
||||
.object({
|
||||
[a]: z.string().min(1),
|
||||
[b]: z.string().min(1),
|
||||
createdAt: isoDateTime,
|
||||
} as { [K in A | B]: z.ZodString } & { createdAt: typeof isoDateTime })
|
||||
.passthrough();
|
||||
}
|
||||
|
||||
/**
|
||||
* One Vorsorge reminder, and one row of its completion ledger (v1.37.20,
|
||||
* #223 / iOS #68).
|
||||
@@ -2183,6 +2200,23 @@ export const backupPayloadSchema = z
|
||||
customVaccines: z.array(customVaccineBackupSchema).default([]),
|
||||
vaccinations: z.array(vaccinationBackupSchema).default([]),
|
||||
vaccinationDocumentLinks: z.array(vaccinationLinkBackupSchema).default([]),
|
||||
// v1.43 — the five relation links that close the relation matrix.
|
||||
// Defaulted: a file written before v1.43 carries no key.
|
||||
labResultDocumentLinks: z
|
||||
.array(relationLinkBackupSchema("labResultId", "documentId"))
|
||||
.default([]),
|
||||
encounterMedicationLinks: z
|
||||
.array(relationLinkBackupSchema("encounterId", "medicationId"))
|
||||
.default([]),
|
||||
encounterVaccinationLinks: z
|
||||
.array(relationLinkBackupSchema("encounterId", "vaccinationId"))
|
||||
.default([]),
|
||||
medicationDocumentLinks: z
|
||||
.array(relationLinkBackupSchema("medicationId", "documentId"))
|
||||
.default([]),
|
||||
medicationConditionLinks: z
|
||||
.array(relationLinkBackupSchema("medicationId", "episodeId"))
|
||||
.default([]),
|
||||
// The Vorsorge reminders and their completion ledger (v1.37.20, #223 /
|
||||
// iOS #68). Defaulted for the same reason as the sections above: a file
|
||||
// written before the reminders travelled carries no key, and an account
|
||||
|
||||
@@ -291,6 +291,16 @@ const COUNT_BACK: Record<
|
||||
p.vaccinationRecord.count({ where: { userId } }),
|
||||
VaccinationDocumentLink: (p, userId) =>
|
||||
p.vaccinationDocumentLink.count({ where: { userId } }),
|
||||
LabResultDocumentLink: (p, userId) =>
|
||||
p.labResultDocumentLink.count({ where: { userId } }),
|
||||
EncounterMedicationLink: (p, userId) =>
|
||||
p.encounterMedicationLink.count({ where: { userId } }),
|
||||
EncounterVaccinationLink: (p, userId) =>
|
||||
p.encounterVaccinationLink.count({ where: { userId } }),
|
||||
MedicationDocumentLink: (p, userId) =>
|
||||
p.medicationDocumentLink.count({ where: { userId } }),
|
||||
MedicationConditionLink: (p, userId) =>
|
||||
p.medicationConditionLink.count({ where: { userId } }),
|
||||
CustomVaccine: (p, userId) => p.customVaccine.count({ where: { userId } }),
|
||||
MeasurementReminder: (p, userId) =>
|
||||
p.measurementReminder.count({ where: { userId } }),
|
||||
@@ -1368,6 +1378,44 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
// v1.43 — one row in each of the five relation link tables, joining the
|
||||
// records seeded above.
|
||||
await prisma.labResultDocumentLink.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
labResultId: labResult.id,
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
await prisma.encounterMedicationLink.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
encounterId: encounter.id,
|
||||
medicationId: medication.id,
|
||||
},
|
||||
});
|
||||
await prisma.encounterVaccinationLink.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
encounterId: encounter.id,
|
||||
vaccinationId: vaccination.id,
|
||||
},
|
||||
});
|
||||
await prisma.medicationDocumentLink.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
medicationId: medication.id,
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
await prisma.medicationConditionLink.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
medicationId: medication.id,
|
||||
episodeId: episode.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Three bests, chosen for what a count cannot see.
|
||||
//
|
||||
// The first points at the measurement it was found in. That column is a
|
||||
|
||||
Reference in new issue
Block a user