From b4bc039d165816fe68b9a09719889ff1a43b3bbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 23:57:34 +0200 Subject: [PATCH] feat(links): close the relation matrix and publish related[] on six details The link service gains medication and lab result sources and the five new tables, each readable from both ends. POST/DELETE /api/medications/{id}/links and /api/labs/{id}/links replace their 501 stubs; the visit links route also files medications and doses, the dose links route its visit. A scan commit with a documentId files each written result against its report. GET on a lab result, visit, document, condition, medication and dose now carries related[]: stored links, the condition a result shares a visit with, and the medication whose efficacy target is the result's marker, each with its web path. A kind is listed only when its module is on and the caller's grant covers its section. relation-matrix.test.ts holds every one of the fifteen pairs to a table, a reader or a written exception. --- docs/api/openapi.yaml | 123 ++++- src/__tests__/contract-stub-inventory.test.ts | 4 +- .../inbound/[id]/__tests__/route.test.ts | 9 + src/app/api/documents/inbound/[id]/route.ts | 27 +- src/app/api/encounters/[id]/links/route.ts | 3 +- src/app/api/encounters/[id]/route.ts | 10 +- src/app/api/illness/episodes/[id]/route.ts | 12 +- src/app/api/labs/[id]/links/route.ts | 48 +- src/app/api/labs/[id]/route.ts | 15 +- src/app/api/labs/ocr/commit/route.ts | 11 + src/app/api/medications/[id]/links/route.ts | 48 +- src/app/api/medications/[id]/route.ts | 10 +- src/app/api/vaccinations/[id]/links/route.ts | 3 +- src/app/api/vaccinations/[id]/route.ts | 9 +- src/lib/encounters/service.ts | 1 + .../__tests__/link-surface-guard.test.ts | 11 +- .../links/__tests__/relation-matrix.test.ts | 128 +++++ src/lib/links/link-service.ts | 177 ++++++- src/lib/links/related.ts | 492 ++++++++++++++++++ src/lib/openapi/routes/encounters.ts | 2 +- src/lib/openapi/routes/relation-links.ts | 8 +- src/lib/openapi/routes/vaccinations.ts | 6 +- src/lib/validations/encounters.ts | 3 + src/lib/validations/vaccinations.ts | 10 +- tests/integration/relation-links.test.ts | 481 +++++++++++++++++ 25 files changed, 1569 insertions(+), 82 deletions(-) create mode 100644 src/lib/links/__tests__/relation-matrix.test.ts create mode 100644 src/lib/links/related.ts create mode 100644 tests/integration/relation-links.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5559a9937..8227b616e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -15780,7 +15780,8 @@ paths: - Records summary: File targets against a visit description: "Adds links. Idempotent: a pair that already exists succeeds and changes nothing. Session-authenticated - only, matching document link and unlink." + only, matching document link and unlink. Since v1.43 `targetKind` also takes `medication` and `vaccination`; + those links show in the visit's `related` list (`GET /api/encounters/{id}`), not in `links`." parameters: - in: path name: id @@ -16393,9 +16394,11 @@ paths: post: tags: - Records - summary: File documents against a vaccination + summary: File documents or a visit against a vaccination description: "Adds links. Idempotent: a pair that already exists succeeds and changes nothing. Session-authenticated - only, matching document and visit link and unlink." + only, matching document and visit link and unlink. Since v1.43 `targetKind` also takes `encounter` (the visit + the dose was given at); that link shows in the dose's `related` list (`GET /api/vaccinations/{id}`), not in + `documents`." parameters: - in: path name: id @@ -16422,7 +16425,7 @@ paths: delete: tags: - Records - summary: Unfile documents from a vaccination + summary: Unfile documents or a visit from a vaccination description: Removes links. Idempotent, and it still works for a page the account has since deleted — refusing would leave a filing nobody can clear. parameters: @@ -24506,7 +24509,6 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a44 delete: tags: - Links @@ -24542,7 +24544,6 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a44 /api/labs/{id}/links: post: tags: @@ -24579,7 +24580,6 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a44 delete: tags: - Links @@ -24615,7 +24615,6 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a44 /api/bugreport: post: security: [] @@ -31558,6 +31557,8 @@ components: - document - labResult - conditionEpisode + - medication + - vaccination targetIds: minItems: 1 maxItems: 100 @@ -31966,6 +31967,7 @@ components: type: string enum: - document + - encounter targetIds: minItems: 1 maxItems: 100 @@ -41980,6 +41982,106 @@ components: - targetId - createdAt additionalProperties: {} + labResultDocumentLinks: + default: [] + type: array + items: + type: object + properties: + labResultId: + type: string + minLength: 1 + documentId: + type: string + minLength: 1 + createdAt: + type: string + minLength: 1 + required: + - labResultId + - documentId + - createdAt + additionalProperties: {} + encounterMedicationLinks: + default: [] + type: array + items: + type: object + properties: + encounterId: + type: string + minLength: 1 + medicationId: + type: string + minLength: 1 + createdAt: + type: string + minLength: 1 + required: + - encounterId + - medicationId + - createdAt + additionalProperties: {} + encounterVaccinationLinks: + default: [] + type: array + items: + type: object + properties: + encounterId: + type: string + minLength: 1 + vaccinationId: + type: string + minLength: 1 + createdAt: + type: string + minLength: 1 + required: + - encounterId + - vaccinationId + - createdAt + additionalProperties: {} + medicationDocumentLinks: + default: [] + type: array + items: + type: object + properties: + medicationId: + type: string + minLength: 1 + documentId: + type: string + minLength: 1 + createdAt: + type: string + minLength: 1 + required: + - medicationId + - documentId + - createdAt + additionalProperties: {} + medicationConditionLinks: + default: [] + type: array + items: + type: object + properties: + medicationId: + type: string + minLength: 1 + episodeId: + type: string + minLength: 1 + createdAt: + type: string + minLength: 1 + required: + - medicationId + - episodeId + - createdAt + additionalProperties: {} coachConversations: default: [] type: array @@ -43204,6 +43306,11 @@ components: - customVaccines - vaccinations - vaccinationDocumentLinks + - labResultDocumentLinks + - encounterMedicationLinks + - encounterVaccinationLinks + - medicationDocumentLinks + - medicationConditionLinks - coachConversations - mentalHealthAssessments - consentReceipts diff --git a/src/__tests__/contract-stub-inventory.test.ts b/src/__tests__/contract-stub-inventory.test.ts index 5d73a18ab..26c1c6fe4 100644 --- a/src/__tests__/contract-stub-inventory.test.ts +++ b/src/__tests__/contract-stub-inventory.test.ts @@ -51,17 +51,15 @@ const CONTRACT_STUBS: Readonly> = { "environment/travel/suggestions/route.ts": [ "environment.travel_suggestions.get", ], - "labs/[id]/links/route.ts": ["labs.link."], "medications/[id]/ingredients/extract/route.ts": [ "medications.ingredients.extract", ], "medications/[id]/ingredients/route.ts": ["medications.ingredients.put"], - "medications/[id]/links/route.ts": ["medication.link."], "nutrients/catalog/route.ts": ["nutrients.catalog.get"], }; /** The size of the register when it was written; see the header. */ -const REGISTER_SIZE = 21; +const REGISTER_SIZE = 19; function routeFiles(): string[] { return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] }) diff --git a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts index b82bd9d65..e9a75d030 100644 --- a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts @@ -57,6 +57,15 @@ vi.mock("@/lib/db", () => ({ }, })); +// `related[]` has its own tests (`src/lib/links/__tests__`); here it is the +// empty list, so the detail assertions stay about the document. +vi.mock("@/lib/links/related", () => ({ + withRelated: async (_tx: unknown, detail: object) => ({ + ...detail, + related: [], + }), +})); + vi.mock("@/lib/crypto", () => ({ encrypt: vi.fn((s: string) => `v1.${s}`), decrypt: vi.fn((s: string) => s.replace(/^v1\./u, "")), diff --git a/src/app/api/documents/inbound/[id]/route.ts b/src/app/api/documents/inbound/[id]/route.ts index 0d3468b07..d4eff24b6 100644 --- a/src/app/api/documents/inbound/[id]/route.ts +++ b/src/app/api/documents/inbound/[id]/route.ts @@ -45,6 +45,7 @@ import { } from "@/lib/validations/inbound-documents"; import type { Prisma } from "@/generated/prisma/client"; import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only"; +import { withRelated } from "@/lib/links/related"; type RouteParams = { params: Promise<{ id: string }> }; @@ -132,17 +133,21 @@ export const GET = apiHandler( }); return apiSuccess( - serialiseDocumentDetail( - document, - document.facts, - links.get(document.id) ?? [], - contentIndex !== null, - toContentIndexSource(contentIndex?.source), - thumbnail !== null, - visitLinks.get(document.id) ?? [], - doseLinks === null ? null : (doseLinks.get(document.id) ?? []), - // v1.43 — the marker each pending lab value resolves to, or choices. - await markerIndexForFacts(user.id, document.facts), + await withRelated( + prisma, + serialiseDocumentDetail( + document, + document.facts, + links.get(document.id) ?? [], + contentIndex !== null, + toContentIndexSource(contentIndex?.source), + thumbnail !== null, + visitLinks.get(document.id) ?? [], + doseLinks === null ? null : (doseLinks.get(document.id) ?? []), + // v1.43 — the marker each pending lab value resolves to, or choices. + await markerIndexForFacts(user.id, document.facts), + ), + { userId: user.id, grantId, kind: "document", id: document.id }, ), ); }, diff --git a/src/app/api/encounters/[id]/links/route.ts b/src/app/api/encounters/[id]/links/route.ts index c22af968e..37251a8ed 100644 --- a/src/app/api/encounters/[id]/links/route.ts +++ b/src/app/api/encounters/[id]/links/route.ts @@ -1,5 +1,6 @@ /** - * `POST /api/encounters/{id}/links` — file things against a visit. + * `POST /api/encounters/{id}/links` — file things against a visit: documents, + * lab results, conditions and, since v1.43, medications and doses. * `DELETE /api/encounters/{id}/links` — unfile them. * * Deliberately bare `requireAuth()` rather than a delegable declaration, and diff --git a/src/app/api/encounters/[id]/route.ts b/src/app/api/encounters/[id]/route.ts index 9445b6416..a954f3a9b 100644 --- a/src/app/api/encounters/[id]/route.ts +++ b/src/app/api/encounters/[id]/route.ts @@ -54,6 +54,7 @@ import { } from "@/lib/export/restore-skips"; import type { Prisma } from "@/generated/prisma/client"; import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; +import { withRelated } from "@/lib/links/related"; type RouteParams = { params: Promise<{ id: string }> }; @@ -81,7 +82,14 @@ export const GET = apiHandler( }, }); - return apiSuccess(toEncounterDTO(row, links)); + return apiSuccess( + await withRelated(prisma, toEncounterDTO(row, links), { + userId: user.id, + grantId, + kind: "encounter", + id, + }), + ); }, ); diff --git a/src/app/api/illness/episodes/[id]/route.ts b/src/app/api/illness/episodes/[id]/route.ts index f614398c5..6aa5475a3 100644 --- a/src/app/api/illness/episodes/[id]/route.ts +++ b/src/app/api/illness/episodes/[id]/route.ts @@ -27,12 +27,13 @@ import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { illnessEpisodeUpdateSchema } from "@/lib/validations/illness"; import { toIllnessEpisodeDTO } from "@/lib/illness/dto"; import type { Prisma } from "@/generated/prisma/client"; +import { withRelated } from "@/lib/links/related"; type RouteParams = { params: Promise<{ id: string }> }; export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read", "illness"); + const { user, grantId } = await requireRecordAuth("read", "illness"); const gate = await requireIllnessEnabled(user.id); if (!gate.enabled) return gate.response; @@ -51,7 +52,14 @@ export const GET = apiHandler( }, }); - return apiSuccess(toIllnessEpisodeDTO(row)); + return apiSuccess( + await withRelated(prisma, toIllnessEpisodeDTO(row), { + userId: user.id, + grantId, + kind: "conditionEpisode", + id, + }), + ); }, ); diff --git a/src/app/api/labs/[id]/links/route.ts b/src/app/api/labs/[id]/links/route.ts index 6ea66439a..114b4a23f 100644 --- a/src/app/api/labs/[id]/links/route.ts +++ b/src/app/api/labs/[id]/links/route.ts @@ -11,8 +11,15 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { returnAllZodIssues, safeJson } from "@/lib/api-response"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { linkTargets, unlinkTargets } from "@/lib/links"; +import { annotate } from "@/lib/logging/context"; import { labResultLinkSchema } from "@/lib/validations/relation-links"; type RouteParams = { params: Promise<{ id: string }> }; @@ -22,8 +29,8 @@ async function handle( { params }: RouteParams, verb: "link" | "unlink", ): Promise { - await requireAuth(); - await params; + const { user } = await requireAuth(); + const { id } = await params; const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, @@ -36,7 +43,38 @@ async function handle( }); } - return notImplemented(`labs.link.${verb}`); + const apply = verb === "link" ? linkTargets : unlinkTargets; + const result = await apply(prisma, { + userId: user.id, + sourceKind: "labResult", + sourceId: id, + targetKind: parsed.data.targetKind, + targetIds: parsed.data.targetIds, + }); + // Like the visit and vaccination link routes: a link to a source this + // account does not own is a 404; an unlink is scoped to the account's own + // link rows and so is an idempotent no-op instead. + if (result.unknownSource) { + return apiError("Lab result not found", 404); + } + + annotate({ + action: { + name: `labs.link.${verb}`, + entity_type: "lab_result", + entity_id: id, + }, + meta: { + target_kind: parsed.data.targetKind, + changed: result.changed, + unknown: result.unknownTargetIds.length, + }, + }); + + return apiSuccess({ + changed: result.changed, + unknown: result.unknownTargetIds, + }); } export const POST = apiHandler( diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index c828bdf1f..146a0b42a 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -20,6 +20,7 @@ import { } from "@/lib/labs/serialise"; import { decryptNoteFromBytes, encryptNoteToBytes } from "@/lib/labs/store"; import { annotate } from "@/lib/logging/context"; +import { withRelated } from "@/lib/links/related"; import { effectiveBound, isInvertedRange, @@ -66,7 +67,7 @@ function toResolved( export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read", "labs"); + const { user, grantId } = await requireRecordAuth("read", "labs"); const { id } = await params; const row = await prisma.labResult.findFirst({ @@ -80,10 +81,14 @@ export const GET = apiHandler( annotate({ action: { name: "labs.get" }, meta: { labResultId: id } }); return apiSuccess( - serialiseLabResultDetail( - row, - toResolved(row.biomarker), - row.noteEncrypted ? decryptNoteFromBytes(row.noteEncrypted) : null, + await withRelated( + prisma, + serialiseLabResultDetail( + row, + toResolved(row.biomarker), + row.noteEncrypted ? decryptNoteFromBytes(row.noteEncrypted) : null, + ), + { userId: user.id, grantId, kind: "labResult", id }, ), ); }, diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 9dbc01d45..07c77e50e 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -371,6 +371,17 @@ async function commitOcrRows(request: NextRequest) { parsed.data.documentId, linkable, ).catch(() => {}); + // v1.43 — the stored relation as well: each written result points at the + // report it was read from (`lab_result_document_links`), so the result's + // and the document's `related` lists name each other. Best-effort for the + // same reason; the service narrows both ends to this record. + await linkTargets(prisma, { + userId: user.id, + sourceKind: "document", + sourceId: parsed.data.documentId, + targetKind: "labResult", + targetIds: linkable.map((row) => row.labResultId), + }).catch(() => {}); } // The visit the review step offered, if it offered one. ONE LINK PER RESULT diff --git a/src/app/api/medications/[id]/links/route.ts b/src/app/api/medications/[id]/links/route.ts index 80aae09de..7535fe98d 100644 --- a/src/app/api/medications/[id]/links/route.ts +++ b/src/app/api/medications/[id]/links/route.ts @@ -11,8 +11,15 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { returnAllZodIssues, safeJson } from "@/lib/api-response"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { linkTargets, unlinkTargets } from "@/lib/links"; +import { annotate } from "@/lib/logging/context"; import { medicationLinkSchema } from "@/lib/validations/relation-links"; type RouteParams = { params: Promise<{ id: string }> }; @@ -22,8 +29,8 @@ async function handle( { params }: RouteParams, verb: "link" | "unlink", ): Promise { - await requireAuth(); - await params; + const { user } = await requireAuth(); + const { id } = await params; const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, @@ -36,7 +43,38 @@ async function handle( }); } - return notImplemented(`medication.link.${verb}`); + const apply = verb === "link" ? linkTargets : unlinkTargets; + const result = await apply(prisma, { + userId: user.id, + sourceKind: "medication", + sourceId: id, + targetKind: parsed.data.targetKind, + targetIds: parsed.data.targetIds, + }); + // Like the visit and vaccination link routes: a link to a source this + // account does not own is a 404; an unlink is scoped to the account's own + // link rows and so is an idempotent no-op instead. + if (result.unknownSource) { + return apiError("Medication not found", 404); + } + + annotate({ + action: { + name: `medication.link.${verb}`, + entity_type: "medication", + entity_id: id, + }, + meta: { + target_kind: parsed.data.targetKind, + changed: result.changed, + unknown: result.unknownTargetIds.length, + }, + }); + + return apiSuccess({ + changed: result.changed, + unknown: result.unknownTargetIds, + }); } export const POST = apiHandler( diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 212716afe..9e76d99f6 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -62,6 +62,7 @@ import { import { floorWhole } from "@/lib/medications/units-per-dose"; import { resolveIntakeActionability } from "@/lib/medications/intake-actionable"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { buildRelated } from "@/lib/links/related"; type RouteParams = { params: Promise<{ id: string }> }; @@ -103,7 +104,7 @@ function reconcileScheduleWindow( export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read", "medications"); + const { user, grantId } = await requireRecordAuth("read", "medications"); const { id } = await params; const medication = await prisma.medication.findUnique({ @@ -294,6 +295,13 @@ export const GET = apiHandler( stockUnitsRemaining, stockDosesRemaining, runwayDays, + // v1.43 — the records this medication relates to (`src/lib/links/`). + related: await buildRelated(prisma, { + userId: user.id, + grantId, + kind: "medication", + id, + }), }); }, ); diff --git a/src/app/api/vaccinations/[id]/links/route.ts b/src/app/api/vaccinations/[id]/links/route.ts index d8ba5cbed..b6cd14fe9 100644 --- a/src/app/api/vaccinations/[id]/links/route.ts +++ b/src/app/api/vaccinations/[id]/links/route.ts @@ -1,5 +1,6 @@ /** - * `POST /api/vaccinations/{id}/links` — file a scanned page against a dose. + * `POST /api/vaccinations/{id}/links` — file a scanned page, or since v1.43 + * the visit it was given at, against a dose. * `DELETE /api/vaccinations/{id}/links` — unfile it. * * Deliberately bare `requireAuth()` rather than a delegable declaration, and diff --git a/src/app/api/vaccinations/[id]/route.ts b/src/app/api/vaccinations/[id]/route.ts index 376016267..78111f637 100644 --- a/src/app/api/vaccinations/[id]/route.ts +++ b/src/app/api/vaccinations/[id]/route.ts @@ -41,6 +41,7 @@ import { } from "@/lib/vaccinations/service"; import type { Prisma } from "@/generated/prisma/client"; import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; +import { withRelated } from "@/lib/links/related"; type RouteParams = { params: Promise<{ id: string }> }; @@ -71,7 +72,13 @@ export const GET = apiHandler( }, }); - return apiSuccess(toVaccinationDTO(row, series.get(id) ?? [], documents)); + return apiSuccess( + await withRelated( + prisma, + toVaccinationDTO(row, series.get(id) ?? [], documents), + { userId: user.id, grantId, kind: "vaccination", id }, + ), + ); }, ); diff --git a/src/lib/encounters/service.ts b/src/lib/encounters/service.ts index 7d65b544c..e9d30108e 100644 --- a/src/lib/encounters/service.ts +++ b/src/lib/encounters/service.ts @@ -74,6 +74,7 @@ const TARGET_DOMAIN: Record = { encounter: "profile", // A dose lives in the health background, like the visit itself. vaccination: "profile", + medication: "medications", }; /** diff --git a/src/lib/links/__tests__/link-surface-guard.test.ts b/src/lib/links/__tests__/link-surface-guard.test.ts index 086af0bfe..aa4891133 100644 --- a/src/lib/links/__tests__/link-surface-guard.test.ts +++ b/src/lib/links/__tests__/link-surface-guard.test.ts @@ -42,6 +42,12 @@ const LINK_DELEGATES = [ "encounterLabLink", "encounterConditionLink", "vaccinationDocumentLink", + // v1.43 — the five tables that close the relation matrix. + "labResultDocumentLink", + "encounterMedicationLink", + "encounterVaccinationLink", + "medicationDocumentLink", + "medicationConditionLink", ] as const; /** @@ -72,6 +78,8 @@ const RESTORE_EXEMPTIONS = [ // above: it re-creates the link with the instant it was originally filed at, // which the service's filing signature has no way to express. "lib/export/document-filing-backup.ts", + // The five relation links (v1.43), same standing rule. + "lib/export/relation-links-backup.ts", ].sort(); /** Every non-test, non-generated source file under `src/`, relative to `src/`. */ @@ -128,7 +136,7 @@ describe("link-surface guard — the tables have one gateway", () => { } }); - it("the restore exemption set is exactly the three backup writers", () => { + it("the restore exemption set is exactly the four backup writers", () => { // Freeze the exemption list so widening it is a reviewed diff, not a quiet // addition. Every file must exist and must actually write a link table. for (const rel of RESTORE_EXEMPTIONS) { @@ -137,6 +145,7 @@ describe("link-surface guard — the tables have one gateway", () => { expect(RESTORE_EXEMPTIONS).toEqual( [ "lib/export/document-filing-backup.ts", + "lib/export/relation-links-backup.ts", "lib/export/vaccinations-backup.ts", "lib/export/visits-backup.ts", ].sort(), diff --git a/src/lib/links/__tests__/relation-matrix.test.ts b/src/lib/links/__tests__/relation-matrix.test.ts new file mode 100644 index 000000000..f7ad53c0b --- /dev/null +++ b/src/lib/links/__tests__/relation-matrix.test.ts @@ -0,0 +1,128 @@ +/** + * The relation matrix has no dead ends. + * + * Six kinds of record (lab result, visit, document, condition, medication, + * vaccination) make fifteen pairs. Each pair is either stored (a link table in + * `LINK_TABLES`, readable from both ends, so both detail pages list the + * other), derived (no table, a reader in `DERIVED_READERS` for both + * directions), or a written exception with its reason. A pair with none of + * the three is a place where the person reaches one record and cannot get to + * the other, which is the class of defect this guards. + * + * Mutation check: delete the `"document:medication"` entry from `LINK_TABLES` + * and the stored case names it; delete `"medication:labResult"` from + * `DERIVED_READERS` and the derived case names it. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + DERIVED_READERS, + RELATED_KIND_DOMAIN, + RELATED_KIND_MODULE, + RELATION_MATRIX, + relationCell, + type RelatedKind, +} from "@/lib/links/related"; +import { RELATED_KINDS } from "@/lib/validations/relation-links"; + +const SERVICE = readFileSync( + join(process.cwd(), "src", "lib", "links", "link-service.ts"), + "utf8", +); + +/** `LINK_TABLES` as `"from:to" → Prisma delegate`, read from the source. */ +function linkTables(): Map { + const start = SERVICE.indexOf("const LINK_TABLES"); + expect(start).toBeGreaterThan(-1); + const body = SERVICE.slice(start, SERVICE.indexOf("\n};", start)); + const out = new Map(); + for (const m of body.matchAll( + /"(\w+:\w+)":\s*\{\s*delegate:\s*\(tx\)\s*=>\s*narrow\(\s*tx\.(\w+)\s*\)/g, + )) { + out.set(m[1], m[2]); + } + return out; +} + +const KINDS = [...RELATED_KINDS] as RelatedKind[]; + +function pairs(): Array<[RelatedKind, RelatedKind]> { + const out: Array<[RelatedKind, RelatedKind]> = []; + for (let i = 0; i < KINDS.length; i++) { + for (let j = i + 1; j < KINDS.length; j++) out.push([KINDS[i], KINDS[j]]); + } + return out; +} + +describe("relation matrix", () => { + const tables = linkTables(); + + it("reads the link table and covers fifteen pairs", () => { + // Non-vacuous: the matcher found the tables the service declares. + expect(tables.size).toBeGreaterThanOrEqual(15); + expect(pairs()).toHaveLength(15); + }); + + it("declares every pair exactly once", () => { + const keys = Object.keys(RELATION_MATRIX); + expect(keys).toHaveLength(15); + for (const [a, b] of pairs()) { + const forward = `${a}:${b}` in RELATION_MATRIX; + const reverse = `${b}:${a}` in RELATION_MATRIX; + expect(forward !== reverse, `${a} and ${b}`).toBe(true); + } + }); + + it.each(pairs())("%s and %s are not a dead end", (a, b) => { + const cell = relationCell(a, b); + expect(cell, `${a}:${b} has no cell`).toBeDefined(); + if (!cell) return; + if (cell.mode === "stored") { + const forward = tables.get(`${a}:${b}`); + const reverse = tables.get(`${b}:${a}`); + expect(forward, `LINK_TABLES lacks ${a}:${b}`).toBeDefined(); + expect(reverse, `LINK_TABLES lacks ${b}:${a}`).toBeDefined(); + // Both directions read ONE table, so the two pages cannot disagree. + expect(forward).toBe(reverse); + } else if (cell.mode === "derived") { + expect(DERIVED_READERS[`${a}:${b}`], `no reader ${a}:${b}`).toBeDefined(); + expect(DERIVED_READERS[`${b}:${a}`], `no reader ${b}:${a}`).toBeDefined(); + // A derived pair has no table on purpose; one appearing means the cell + // is stale. + expect(tables.has(`${a}:${b}`)).toBe(false); + } else { + expect(cell.reason.length, `${a}:${b} reason`).toBeGreaterThan(40); + expect(tables.has(`${a}:${b}`) || tables.has(`${b}:${a}`)).toBe(false); + } + }); + + it("every link table is a pair of the matrix", () => { + for (const key of tables.keys()) { + const [a, b] = key.split(":") as [RelatedKind, RelatedKind]; + expect(relationCell(a, b)?.mode, key).toBe("stored"); + } + }); + + it("the five v1.43 tables close their pairs", () => { + const delegates = new Set(tables.values()); + for (const delegate of [ + "labResultDocumentLink", + "encounterMedicationLink", + "encounterVaccinationLink", + "medicationDocumentLink", + "medicationConditionLink", + ]) { + expect(delegates.has(delegate), delegate).toBe(true); + } + }); + + it("names a module and a sharing section for every kind", () => { + for (const kind of KINDS) { + expect(kind in RELATED_KIND_MODULE, kind).toBe(true); + expect(RELATED_KIND_DOMAIN[kind], kind).toBeTruthy(); + } + }); +}); diff --git a/src/lib/links/link-service.ts b/src/lib/links/link-service.ts index 815e53a8c..08b750d60 100644 --- a/src/lib/links/link-service.ts +++ b/src/lib/links/link-service.ts @@ -13,11 +13,21 @@ export const MAX_TARGETS_PER_CALL = 100; /** The record a link hangs off. */ export type LinkSourceKind = - "encounter" | "document" | "vaccination" | "conditionEpisode"; + | "encounter" + | "document" + | "vaccination" + | "conditionEpisode" + | "medication" + | "labResult"; /** The record a link points at. */ export type LinkTargetKind = - "document" | "labResult" | "conditionEpisode" | "encounter" | "vaccination"; + | "document" + | "labResult" + | "conditionEpisode" + | "encounter" + | "vaccination" + | "medication"; export interface LinkRequest { userId: string; @@ -87,6 +97,12 @@ interface OwnedRowDelegate { interface EndpointSpec { /** The owning model's delegate, for the ownership re-read. */ delegate: (tx: Prisma.TransactionClient) => OwnedRowDelegate; + /** + * Whether the model carries a `deletedAt` tombstone. A medication does + * not (it is paused or ended, never tombstoned), so filtering it on the + * column would be a query error rather than a no-op. + */ + softDeletes: boolean; /** Columns the label and date are derived from. */ select: Record; label: (row: Record) => string; @@ -114,6 +130,11 @@ function narrow(delegate: unknown): T { return delegate as T; } +/** The "still live" filter for a model, empty for one without a tombstone. */ +function live(softDeletes: boolean): { deletedAt?: null } { + return softDeletes ? { deletedAt: null } : {}; +} + function asDate(value: unknown): string | null { return value instanceof Date ? value.toISOString() : null; } @@ -129,30 +150,48 @@ const SOURCES: Record< delegate: (tx: Prisma.TransactionClient) => OwnedRowDelegate; /** The link row's relation field back to the source, for a joined filter. */ relationField: string; + softDeletes: boolean; } > = { encounter: { delegate: (tx) => narrow(tx.encounter), relationField: "encounter", + softDeletes: true, }, document: { delegate: (tx) => narrow(tx.inboundDocument), relationField: "document", + softDeletes: true, }, vaccination: { delegate: (tx) => narrow(tx.vaccinationRecord), relationField: "vaccination", + softDeletes: true, }, - // A condition is a source only in the read direction of two tables that - // already exist (see the two `conditionEpisode:*` pairs below). + // A condition is a source in the read direction of the tables that file + // things against it (see the `conditionEpisode:*` pairs below). conditionEpisode: { delegate: (tx) => narrow(tx.illnessEpisode), relationField: "episode", + softDeletes: true, + }, + // v1.43 — a medication files its indication, its documents and its visits. + medication: { + delegate: (tx) => narrow(tx.medication), + relationField: "medication", + softDeletes: false, + }, + // v1.43 — a lab result files the report it was read from and its visits. + labResult: { + delegate: (tx) => narrow(tx.labResult), + relationField: "labResult", + softDeletes: true, }, }; const DOCUMENT_ENDPOINT: EndpointSpec = { delegate: (tx) => narrow(tx.inboundDocument), + softDeletes: true, select: { id: true, title: true, @@ -171,6 +210,7 @@ const DOCUMENT_ENDPOINT: EndpointSpec = { const LAB_RESULT_ENDPOINT: EndpointSpec = { delegate: (tx) => narrow(tx.labResult), + softDeletes: true, select: { id: true, analyte: true, panel: true, takenAt: true }, label: (row) => { const panel = asString(row.panel); @@ -182,6 +222,7 @@ const LAB_RESULT_ENDPOINT: EndpointSpec = { const ENCOUNTER_ENDPOINT: EndpointSpec = { delegate: (tx) => narrow(tx.encounter), + softDeletes: true, select: { id: true, kind: true, occurredAt: true }, // The visit's KIND, as the enum constant, and this is the one endpoint whose // label is deliberately not prose. A document title and an analyte name are @@ -196,6 +237,7 @@ const ENCOUNTER_ENDPOINT: EndpointSpec = { const VACCINATION_ENDPOINT: EndpointSpec = { delegate: (tx) => narrow(tx.vaccinationRecord), + softDeletes: true, select: { id: true, antigenSlug: true, vaccineName: true, occurredAt: true }, // The person's own wording first, then the catalogue slug. A caller that // renders a dose resolves the slug through its own bundle instead (see @@ -208,26 +250,38 @@ const VACCINATION_ENDPOINT: EndpointSpec = { const CONDITION_ENDPOINT: EndpointSpec = { delegate: (tx) => narrow(tx.illnessEpisode), + softDeletes: true, select: { id: true, label: true, onsetAt: true }, label: (row) => String(row.label), date: (row) => asDate(row.onsetAt), }; +const MEDICATION_ENDPOINT: EndpointSpec = { + delegate: (tx) => narrow(tx.medication), + softDeletes: false, + select: { id: true, name: true, dose: true, startsOn: true, createdAt: true }, + label: (row) => { + const dose = asString(row.dose); + return dose ? `${String(row.name)} ${dose}` : String(row.name); + }, + date: (row) => asDate(row.startsOn) ?? asDate(row.createdAt), +}; + /** * Every legal (source, target) pair and the table behind it. * * A pair absent from here is not linkable, and asking for one is a programming * error rather than a user error — hence the throw in {@link tableFor} rather - * than a result field. Three is the number of encounter link tables this - * design allows; a fourth is the point at which this stops being a small set - * of named relations and starts being a general edge store. + * than a result field. * - * That ceiling is per source, and the `vaccination` source owns exactly ONE - * pair. A dose is administered once, so the practitioner and the visit attach - * by scalar FK rather than through a table; the many-to-many argument that - * justifies the lab and condition links does not transfer. A second - * vaccination pair is this becoming the edge store, and is the point to stop - * and raise it rather than to add a row here. + * Since v1.43 this is the storage half of the relation matrix: every pair of + * lab result, visit, document, condition, medication and vaccination either + * has a table here (written in one direction, read in both) or is a written + * exception in `src/lib/links/related.ts`, and `relation-matrix.test.ts` holds + * the two together. The earlier ceilings (three visit tables, one vaccination + * pair) were lifted by that decision rather than by drift: a visit that + * prescribed a medication or gave a dose was a dead end otherwise. Each table + * is still a named relation between two records, never a general edge store. */ const LINK_TABLES: Partial< Record<`${LinkSourceKind}:${LinkTargetKind}`, LinkTableSpec> @@ -305,6 +359,82 @@ const LINK_TABLES: Partial< targetColumn: "encounterId", target: ENCOUNTER_ENDPOINT, }, + + // ── v1.43 — the pairs that close the relation matrix ────────────────────── + // `lab_result_document_links`: the report a result was read from. Written + // from the result's links route and, one document to many results, by the + // scan commit. + "labResult:document": { + delegate: (tx) => narrow(tx.labResultDocumentLink), + sourceColumn: "labResultId", + targetColumn: "documentId", + target: DOCUMENT_ENDPOINT, + }, + "document:labResult": { + delegate: (tx) => narrow(tx.labResultDocumentLink), + sourceColumn: "documentId", + targetColumn: "labResultId", + target: LAB_RESULT_ENDPOINT, + }, + // `encounter_lab_links` read from the result's end. + "labResult:encounter": { + delegate: (tx) => narrow(tx.encounterLabLink), + sourceColumn: "labResultId", + targetColumn: "encounterId", + target: ENCOUNTER_ENDPOINT, + }, + // `encounter_medication_links`: prescribed, changed or reviewed at a visit. + "encounter:medication": { + delegate: (tx) => narrow(tx.encounterMedicationLink), + sourceColumn: "encounterId", + targetColumn: "medicationId", + target: MEDICATION_ENDPOINT, + }, + "medication:encounter": { + delegate: (tx) => narrow(tx.encounterMedicationLink), + sourceColumn: "medicationId", + targetColumn: "encounterId", + target: ENCOUNTER_ENDPOINT, + }, + // `encounter_vaccination_links`: a dose given at a visit. + "encounter:vaccination": { + delegate: (tx) => narrow(tx.encounterVaccinationLink), + sourceColumn: "encounterId", + targetColumn: "vaccinationId", + target: VACCINATION_ENDPOINT, + }, + "vaccination:encounter": { + delegate: (tx) => narrow(tx.encounterVaccinationLink), + sourceColumn: "vaccinationId", + targetColumn: "encounterId", + target: ENCOUNTER_ENDPOINT, + }, + // `medication_document_links`: a prescription, a plan, a package insert. + "medication:document": { + delegate: (tx) => narrow(tx.medicationDocumentLink), + sourceColumn: "medicationId", + targetColumn: "documentId", + target: DOCUMENT_ENDPOINT, + }, + "document:medication": { + delegate: (tx) => narrow(tx.medicationDocumentLink), + sourceColumn: "documentId", + targetColumn: "medicationId", + target: MEDICATION_ENDPOINT, + }, + // `medication_condition_links`: the indication. + "medication:conditionEpisode": { + delegate: (tx) => narrow(tx.medicationConditionLink), + sourceColumn: "medicationId", + targetColumn: "episodeId", + target: CONDITION_ENDPOINT, + }, + "conditionEpisode:medication": { + delegate: (tx) => narrow(tx.medicationConditionLink), + sourceColumn: "episodeId", + targetColumn: "medicationId", + target: MEDICATION_ENDPOINT, + }, }; function tableFor( @@ -340,8 +470,9 @@ async function ownsSource( sourceId, }: Omit, ): Promise { - const rows = await SOURCES[sourceKind].delegate(tx).findMany({ - where: { id: sourceId, userId, deletedAt: null }, + const source = SOURCES[sourceKind]; + const rows = await source.delegate(tx).findMany({ + where: { id: sourceId, userId, ...live(source.softDeletes) }, select: { id: true }, }); return rows.length === 1; @@ -356,7 +487,7 @@ async function ownedTargetIds( ): Promise> { if (targetIds.length === 0) return new Set(); const rows = await spec.target.delegate(tx).findMany({ - where: { id: { in: targetIds }, userId, deletedAt: null }, + where: { id: { in: targetIds }, userId, ...live(spec.target.softDeletes) }, select: { id: true }, }); return new Set(rows.map((row) => String(row.id))); @@ -527,7 +658,11 @@ export async function listTargets( // soft-delete filter belongs on the target, and a tombstoned row must drop // out of the list even though its link row survives the soft delete. const rows = await spec.target.delegate(tx).findMany({ - where: { id: { in: targetIds }, userId: request.userId, deletedAt: null }, + where: { + id: { in: targetIds }, + userId: request.userId, + ...live(spec.target.softDeletes), + }, select: spec.target.select, }); const byId = new Map(rows.map((row) => [String(row.id), row])); @@ -587,7 +722,7 @@ export async function listTargetsBySource( where: { id: { in: targetIds }, userId: request.userId, - ...(request.includeDeletedTargets ? {} : { deletedAt: null }), + ...(request.includeDeletedTargets ? {} : live(spec.target.softDeletes)), }, select: spec.target.select, }); @@ -626,12 +761,14 @@ export async function listDistinctTargets( }, ): Promise { const spec = tableFor(request.sourceKind, request.targetKind); - const sourceRelation = SOURCES[request.sourceKind].relationField; + const source = SOURCES[request.sourceKind]; const links = await spec.delegate(tx).findMany({ where: { userId: request.userId, - [sourceRelation]: { deletedAt: null }, + ...(source.softDeletes + ? { [source.relationField]: { deletedAt: null } } + : {}), }, select: { [spec.targetColumn]: true }, orderBy: { [spec.targetColumn]: "asc" }, diff --git a/src/lib/links/related.ts b/src/lib/links/related.ts new file mode 100644 index 000000000..77701f425 --- /dev/null +++ b/src/lib/links/related.ts @@ -0,0 +1,492 @@ +/** + * `related[]` on the six detail responses: every record the one shown points + * at, or is pointed at by, resolved on the server. + * + * The relation matrix (lab result, visit, document, condition, medication, + * vaccination; fifteen pairs) is declared once, in {@link RELATION_MATRIX}. + * Every pair is one of three things: + * + * - **stored**: a link table in `link-service.ts`, read in both directions; + * - **derived**: no table, the relation runs through something else the + * record already stores (a visit both are filed against, or the + * medication's efficacy target being this marker); + * - **exception**: no clinical relation the record stores, with the reason. + * + * `relation-matrix.test.ts` walks the fifteen pairs and fails when a pair has + * neither a table nor a reader nor a written exception, so a dead end between + * two features cannot come back quietly. + * + * Visibility is decided here, not at the call site: a kind is listed only when + * its module is on for the record and the caller's sharing grant covers its + * section. A derived relation through a visit also needs the visit's section, + * because the relation itself says the visit exists. + */ +import type { Prisma } from "@/generated/prisma/client"; + +import { decryptField } from "@/lib/encounters/dto"; +import { listTargets } from "@/lib/links/link-service"; +import type { LinkSourceKind, LinkTargetKind } from "@/lib/links/link-service"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import type { ModuleKey } from "@/lib/modules/registry"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; +import type { ShareDomain } from "@/lib/sharing/scope"; +import type { + RELATED_KINDS, + RELATED_VIA, +} from "@/lib/validations/relation-links"; + +export type RelatedKind = (typeof RELATED_KINDS)[number]; +export type RelatedVia = (typeof RELATED_VIA)[number]; + +/** One entry of `related[]`, the wire shape of `RelatedLink`. */ +export interface RelatedLink { + kind: RelatedKind; + id: string; + label: string; + date: string | null; + href: string; + via: RelatedVia; +} + +/** At most this many entries of one kind; a full panel stays readable. */ +export const RELATED_PER_KIND_CAP = 50; + +/** One cell of the matrix, seen from `from`. */ +export type RelationCell = + | { mode: "stored" } + | { mode: "derived"; via: Exclude } + | { mode: "exception"; reason: string }; + +type Pair = `${RelatedKind}:${RelatedKind}`; + +/** + * The fifteen unordered pairs, keyed in one direction. The reverse direction is + * the same cell; {@link relationCell} looks both up. + */ +export const RELATION_MATRIX: Readonly>> = { + "labResult:encounter": { mode: "stored" }, + "labResult:document": { mode: "stored" }, + "labResult:conditionEpisode": { mode: "derived", via: "encounter" }, + "labResult:medication": { mode: "derived", via: "efficacyTarget" }, + "labResult:vaccination": { + mode: "exception", + reason: + "A dose is not judged by a lab value the record stores; an antibody titre is a lab result in its own right and is filed against the visit or the report.", + }, + "encounter:document": { mode: "stored" }, + "encounter:conditionEpisode": { mode: "stored" }, + "encounter:medication": { mode: "stored" }, + "encounter:vaccination": { mode: "stored" }, + "document:conditionEpisode": { mode: "stored" }, + "document:medication": { mode: "stored" }, + "document:vaccination": { mode: "stored" }, + "conditionEpisode:medication": { mode: "stored" }, + "conditionEpisode:vaccination": { + mode: "exception", + reason: + "A vaccination prevents rather than treats, and the record stores no indication for a dose; the visit or the document carries the context.", + }, + "medication:vaccination": { + mode: "exception", + reason: + "A vaccine is logged in the immunization log, not as a medication, and nothing the record stores relates one to the other.", + }, +}; + +export function relationCell( + a: RelatedKind, + b: RelatedKind, +): RelationCell | undefined { + return RELATION_MATRIX[`${a}:${b}`] ?? RELATION_MATRIX[`${b}:${a}`]; +} + +/** The module that owns each kind; `null` for the visit, which is core. */ +export const RELATED_KIND_MODULE: Readonly< + Record +> = { + labResult: "labs", + encounter: null, + document: "inboundDocuments", + conditionEpisode: "illness", + medication: "medications", + vaccination: "vaccinations", +}; + +/** The sharing section each kind lives in. */ +export const RELATED_KIND_DOMAIN: Readonly> = { + labResult: "labs", + encounter: "profile", + document: "documents", + conditionEpisode: "illness", + medication: "medications", + // A dose lives in the health background, like the visit itself. + vaccination: "profile", +}; + +const ALL_KINDS: readonly RelatedKind[] = [ + "labResult", + "encounter", + "document", + "conditionEpisode", + "medication", + "vaccination", +]; + +/* ── hydration: one query per kind, label + date + href ──────────────────── */ + +interface Hydrated { + label: string; + date: string | null; + href: string; +} + +function iso(value: Date | null | undefined): string | null { + return value instanceof Date ? value.toISOString() : null; +} + +async function hydrate( + tx: Prisma.TransactionClient, + userId: string, + kind: RelatedKind, + ids: string[], +): Promise> { + const out = new Map(); + if (ids.length === 0) return out; + const scope = { id: { in: ids }, userId }; + switch (kind) { + case "labResult": { + const rows = await tx.labResult.findMany({ + where: { ...scope, deletedAt: null }, + select: { id: true, analyte: true, takenAt: true, biomarkerId: true }, + }); + for (const row of rows) { + out.set(row.id, { + label: row.analyte, + date: iso(row.takenAt), + href: row.biomarkerId + ? `/labs/${encodeURIComponent(row.biomarkerId)}` + : `/labs?analyte=${encodeURIComponent(row.analyte)}`, + }); + } + return out; + } + case "encounter": { + const rows = await tx.encounter.findMany({ + where: { ...scope, deletedAt: null }, + select: { + id: true, + kind: true, + occurredAt: true, + reasonEncrypted: true, + practitioner: { select: { name: true } }, + }, + }); + for (const row of rows) { + out.set(row.id, { + // The person's own reason first, then who they saw, then the + // visit's kind as its enum constant (the one closed value a client + // names from its own bundle). + label: + decryptField(row.reasonEncrypted, "reason") ?? + row.practitioner?.name ?? + row.kind, + date: iso(row.occurredAt), + href: `/checkups?visit=${encodeURIComponent(row.id)}`, + }); + } + return out; + } + case "document": { + const rows = await tx.inboundDocument.findMany({ + where: { ...scope, deletedAt: null }, + select: { + id: true, + title: true, + filename: true, + documentDate: true, + reportDate: true, + createdAt: true, + }, + }); + for (const row of rows) { + out.set(row.id, { + label: row.title || row.filename || row.id, + date: + iso(row.documentDate) ?? iso(row.reportDate) ?? iso(row.createdAt), + href: `/documents?doc=${encodeURIComponent(row.id)}`, + }); + } + return out; + } + case "conditionEpisode": { + const rows = await tx.illnessEpisode.findMany({ + where: { ...scope, deletedAt: null }, + select: { id: true, label: true, onsetAt: true }, + }); + for (const row of rows) { + out.set(row.id, { + label: row.label, + date: iso(row.onsetAt), + href: `/illness/${encodeURIComponent(row.id)}`, + }); + } + return out; + } + case "medication": { + const rows = await tx.medication.findMany({ + where: scope, + select: { + id: true, + name: true, + dose: true, + startsOn: true, + createdAt: true, + }, + }); + for (const row of rows) { + out.set(row.id, { + label: row.dose ? `${row.name} ${row.dose}` : row.name, + date: iso(row.startsOn) ?? iso(row.createdAt), + href: `/medications/${encodeURIComponent(row.id)}`, + }); + } + return out; + } + case "vaccination": { + const rows = await tx.vaccinationRecord.findMany({ + where: { ...scope, deletedAt: null }, + select: { + id: true, + vaccineName: true, + antigenSlug: true, + occurredAt: true, + customVaccine: { select: { name: true } }, + }, + }); + for (const row of rows) { + out.set(row.id, { + label: + row.vaccineName ?? + row.customVaccine?.name ?? + row.antigenSlug ?? + row.id, + date: iso(row.occurredAt), + href: `/vaccinations?dose=${encodeURIComponent(row.id)}`, + }); + } + return out; + } + } +} + +/* ── readers: the ids related to one record, per kind ────────────────────── */ + +interface Candidate { + kind: RelatedKind; + id: string; + via: RelatedVia; +} + +async function stored( + tx: Prisma.TransactionClient, + userId: string, + from: RelatedKind, + sourceId: string, + to: RelatedKind, +): Promise { + const rows = await listTargets(tx, { + userId, + sourceKind: from as LinkSourceKind, + sourceId, + targetKind: to as LinkTargetKind, + }); + return rows.map((row) => ({ kind: to, id: row.id, via: "direct" })); +} + +/** The things filed against the visits this record is filed against. */ +async function throughEncounters( + tx: Prisma.TransactionClient, + userId: string, + from: "labResult" | "conditionEpisode", + sourceId: string, + to: "labResult" | "conditionEpisode", +): Promise { + const visits = await listTargets(tx, { + userId, + sourceKind: from, + sourceId, + targetKind: "encounter", + }); + const seen = new Set(); + const out: Candidate[] = []; + for (const visit of visits) { + const rows = await listTargets(tx, { + userId, + sourceKind: "encounter", + sourceId: visit.id, + targetKind: to, + }); + for (const row of rows) { + if (row.id === sourceId || seen.has(row.id)) continue; + seen.add(row.id); + out.push({ kind: to, id: row.id, via: "encounter" }); + } + } + return out; +} + +/** Medications whose efficacy target is this result's marker. */ +async function medicationsTargetingLab( + tx: Prisma.TransactionClient, + userId: string, + labResultId: string, +): Promise { + const lab = await tx.labResult.findFirst({ + where: { id: labResultId, userId, deletedAt: null }, + select: { biomarkerId: true }, + }); + if (!lab?.biomarkerId) return []; + const targets = await tx.medicationEfficacyTarget.findMany({ + where: { biomarkerId: lab.biomarkerId, medication: { userId } }, + select: { medicationId: true }, + orderBy: { createdAt: "asc" }, + }); + return [...new Set(targets.map((t) => t.medicationId))].map((id) => ({ + kind: "medication" as const, + id, + via: "efficacyTarget" as const, + })); +} + +/** The newest reading of each marker this medication targets. */ +async function labsTargetedByMedication( + tx: Prisma.TransactionClient, + userId: string, + medicationId: string, +): Promise { + const targets = await tx.medicationEfficacyTarget.findMany({ + where: { + medicationId, + medication: { userId }, + biomarkerId: { not: null }, + }, + select: { biomarkerId: true }, + }); + const biomarkerIds = [ + ...new Set(targets.flatMap((t) => (t.biomarkerId ? [t.biomarkerId] : []))), + ]; + if (biomarkerIds.length === 0) return []; + const rows = await tx.labResult.findMany({ + where: { userId, deletedAt: null, biomarkerId: { in: biomarkerIds } }, + select: { id: true, biomarkerId: true }, + orderBy: { takenAt: "desc" }, + distinct: ["biomarkerId"], + }); + return rows.map((row) => ({ + kind: "labResult" as const, + id: row.id, + via: "efficacyTarget" as const, + })); +} + +type Reader = ( + tx: Prisma.TransactionClient, + userId: string, + sourceId: string, +) => Promise; + +/** + * The reader of every derived cell, in both directions. A derived cell + * without both readers here is a dead end, which `relation-matrix.test.ts` + * refuses. + */ +export const DERIVED_READERS: Readonly>> = { + "labResult:conditionEpisode": (tx, userId, id) => + throughEncounters(tx, userId, "labResult", id, "conditionEpisode"), + "conditionEpisode:labResult": (tx, userId, id) => + throughEncounters(tx, userId, "conditionEpisode", id, "labResult"), + "labResult:medication": medicationsTargetingLab, + "medication:labResult": labsTargetedByMedication, +}; + +async function readCell( + tx: Prisma.TransactionClient, + userId: string, + from: RelatedKind, + sourceId: string, + to: RelatedKind, + cell: RelationCell, +): Promise { + if (cell.mode === "exception") return []; + if (cell.mode === "stored") return stored(tx, userId, from, sourceId, to); + const reader = DERIVED_READERS[`${from}:${to}`]; + return reader ? reader(tx, userId, sourceId) : []; +} + +/** + * The `related` list for one record. + * + * `userId` is the record's owner (the one `requireRecordAuth` resolved), and + * `grantId` the grant the caller acts under, or `null` for the owner. The + * record itself must already have been read and authorised by the caller. + */ +export async function buildRelated( + tx: Prisma.TransactionClient, + input: { + userId: string; + grantId: string | null; + kind: RelatedKind; + id: string; + }, +): Promise { + const [visible, modules] = await Promise.all([ + actingDomainVisibility(tx, input.grantId), + resolveModuleMap(input.userId), + ]); + const shown = (kind: RelatedKind) => { + const mod = RELATED_KIND_MODULE[kind]; + return (mod === null || modules[mod]) && visible(RELATED_KIND_DOMAIN[kind]); + }; + + const out: RelatedLink[] = []; + for (const to of ALL_KINDS) { + if (to === input.kind || !shown(to)) continue; + const cell = relationCell(input.kind, to); + if (!cell) continue; + // A relation through a visit says the visit exists, so it needs the + // visit's own visibility as well. + if ( + cell.mode === "derived" && + cell.via === "encounter" && + !shown("encounter") + ) { + continue; + } + const candidates = ( + await readCell(tx, input.userId, input.kind, input.id, to, cell) + ).slice(0, RELATED_PER_KIND_CAP); + const hydrated = await hydrate( + tx, + input.userId, + to, + candidates.map((c) => c.id), + ); + for (const candidate of candidates) { + const row = hydrated.get(candidate.id); + if (!row) continue; + out.push({ kind: to, id: candidate.id, via: candidate.via, ...row }); + } + } + return out; +} + +/** + * `{ ...detail, related }` for a detail route, so each route adds one call. + */ +export async function withRelated( + tx: Prisma.TransactionClient, + detail: T, + input: Parameters[1], +): Promise { + return { ...detail, related: await buildRelated(tx, input) }; +} diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts index 0b1c06033..bd6bade34 100644 --- a/src/lib/openapi/routes/encounters.ts +++ b/src/lib/openapi/routes/encounters.ts @@ -552,7 +552,7 @@ export const encounterPaths: NonNullable = { tags: ["Records"], summary: "File targets against a visit", description: - "Adds links. Idempotent: a pair that already exists succeeds and changes nothing. Session-authenticated only, matching document link and unlink.", + "Adds links. Idempotent: a pair that already exists succeeds and changes nothing. Session-authenticated only, matching document link and unlink. Since v1.43 `targetKind` also takes `medication` and `vaccination`; those links show in the visit's `related` list (`GET /api/encounters/{id}`), not in `links`.", requestParams: idPath, requestBody: { required: true, diff --git a/src/lib/openapi/routes/relation-links.ts b/src/lib/openapi/routes/relation-links.ts index 25fde948b..becbde597 100644 --- a/src/lib/openapi/routes/relation-links.ts +++ b/src/lib/openapi/routes/relation-links.ts @@ -16,12 +16,7 @@ import { medicationLinkSchema, } from "@/lib/validations/relation-links"; -import { - dataEnvelope, - errorEnvelope, - notImplementedResponse, - stdResponses, -} from "./shared"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; export const relatedLink = z .object({ @@ -118,7 +113,6 @@ function linkOperations( content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, - ...notImplementedResponse, }, }); return { [path]: { post: op("post"), delete: op("delete") } }; diff --git a/src/lib/openapi/routes/vaccinations.ts b/src/lib/openapi/routes/vaccinations.ts index 35ccdeafd..1cb6c1730 100644 --- a/src/lib/openapi/routes/vaccinations.ts +++ b/src/lib/openapi/routes/vaccinations.ts @@ -628,9 +628,9 @@ export const vaccinationPaths: NonNullable = { "/api/vaccinations/{id}/links": { post: { tags: ["Records"], - summary: "File documents against a vaccination", + summary: "File documents or a visit against a vaccination", description: - "Adds links. Idempotent: a pair that already exists succeeds and changes nothing. Session-authenticated only, matching document and visit link and unlink.", + "Adds links. Idempotent: a pair that already exists succeeds and changes nothing. Session-authenticated only, matching document and visit link and unlink. Since v1.43 `targetKind` also takes `encounter` (the visit the dose was given at); that link shows in the dose's `related` list (`GET /api/vaccinations/{id}`), not in `documents`.", requestParams: idPath, requestBody: { required: true, @@ -657,7 +657,7 @@ export const vaccinationPaths: NonNullable = { }, delete: { tags: ["Records"], - summary: "Unfile documents from a vaccination", + summary: "Unfile documents or a visit from a vaccination", description: "Removes links. Idempotent, and it still works for a page the account has since deleted — refusing would leave a filing nobody can clear.", requestParams: idPath, diff --git a/src/lib/validations/encounters.ts b/src/lib/validations/encounters.ts index 4d8126699..5bd8f3cb3 100644 --- a/src/lib/validations/encounters.ts +++ b/src/lib/validations/encounters.ts @@ -217,6 +217,9 @@ export const encounterLinkTargetEnum = z.enum([ "document", "labResult", "conditionEpisode", + // v1.43 — prescribed, changed or reviewed at the visit; a dose given there. + "medication", + "vaccination", ]); export const encounterLinkSchema = z diff --git a/src/lib/validations/vaccinations.ts b/src/lib/validations/vaccinations.ts index 1299afa87..4155b7564 100644 --- a/src/lib/validations/vaccinations.ts +++ b/src/lib/validations/vaccinations.ts @@ -253,13 +253,11 @@ export type CustomVaccineUpdate = z.infer; /* ── links ────────────────────────────────────────────────────────── */ /** - * The one link family a dose has. - * - * A single-member enum rather than a bare constant, so the wire shape matches - * the encounter link route's and a second family — if this venture ever loses - * the argument against one — is an added member rather than a changed shape. + * The link families a dose has: the page it was transcribed from and, since + * v1.43, the visit it was given at (`encounter_vaccination_links`, the same + * table the visit's own links route writes from its end). */ -export const vaccinationLinkTargetEnum = z.enum(["document"]); +export const vaccinationLinkTargetEnum = z.enum(["document", "encounter"]); export const vaccinationLinkSchema = z .object({ diff --git a/tests/integration/relation-links.test.ts b/tests/integration/relation-links.test.ts new file mode 100644 index 000000000..def5458c9 --- /dev/null +++ b/tests/integration/relation-links.test.ts @@ -0,0 +1,481 @@ +/** + * The relation links and `related[]` against real Postgres (v1.43). + * + * What is asserted here lives in `where` clauses and joins: the new link + * pairs refuse another account's ids, both detail pages of a stored pair list + * each other, the derived pairs run through a visit and through a medication's + * efficacy target, a module that is off and a grant that does not cover a + * section hide their kind, and the backup carries the five tables both ways. + * A mocked client would report every one of them as working. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { + buildRelationLinksBackupSection, + restoreRelationLinksData, +} from "@/lib/export/relation-links-backup"; +import type { RestoreSkipLog } from "@/lib/export/restore-skips"; +import { linkTargets, unlinkTargets } from "@/lib/links"; +import { buildRelated } from "@/lib/links/related"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const OWNER = "relation-owner"; +const STRANGER = "relation-stranger"; +const READER = "relation-reader"; + +async function seedAccount(id: string) { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { id, username: id, email: `${id}@example.test` }, + }); + const encounter = await prisma.encounter.create({ + data: { + userId: id, + occurredAt: new Date("2026-07-01T09:00:00.000Z"), + status: "DONE", + kind: "ROUTINE", + }, + }); + const document = await prisma.inboundDocument.create({ + data: { + userId: id, + kind: "LAB_RESULT", + title: `${id} report`, + mimeType: "application/pdf", + byteSize: 12, + contentEncrypted: new Uint8Array([1, 2, 3]), + contentCodec: "binary2", + documentDate: new Date("2026-06-30T00:00:00.000Z"), + }, + }); + const biomarker = await prisma.biomarker.create({ + data: { userId: id, name: "TSH", unit: "mU/L" }, + }); + const labResult = await prisma.labResult.create({ + data: { + userId: id, + biomarkerId: biomarker.id, + analyte: "TSH", + value: 2.1, + unit: "mU/L", + takenAt: new Date("2026-06-30T09:00:00.000Z"), + }, + }); + const episode = await prisma.illnessEpisode.create({ + data: { + userId: id, + label: `${id} thyroid`, + type: "CHRONIC", + onsetAt: new Date("2026-01-20T00:00:00.000Z"), + }, + }); + const medication = await prisma.medication.create({ + data: { userId: id, name: "Levothyroxine", dose: "50 µg" }, + }); + const vaccination = await prisma.vaccinationRecord.create({ + data: { + userId: id, + occurredAt: new Date("2026-05-14T00:00:00.000Z"), + antigenSlug: "tdap", + vaccineName: "Tdap", + }, + }); + return { + encounter, + document, + biomarker, + labResult, + episode, + medication, + vaccination, + }; +} + +let owner: Awaited>; +let stranger: Awaited>; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + owner = await seedAccount(OWNER); + stranger = await seedAccount(STRANGER); +}); + +const related = ( + kind: Parameters[1]["kind"], + id: string, +) => + buildRelated(getPrismaClient(), { userId: OWNER, grantId: null, kind, id }); + +describe("new link pairs", () => { + it("files a medication's indication, document and visit, and refuses foreign ids", async () => { + const prisma = getPrismaClient(); + const cases = [ + ["conditionEpisode", owner.episode.id, stranger.episode.id], + ["document", owner.document.id, stranger.document.id], + ["encounter", owner.encounter.id, stranger.encounter.id], + ] as const; + for (const [targetKind, mine, theirs] of cases) { + const result = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "medication", + sourceId: owner.medication.id, + targetKind, + targetIds: [mine, theirs], + }); + expect(result.changed, targetKind).toBe(1); + expect(result.unknownTargetIds, targetKind).toEqual([theirs]); + } + expect(await prisma.medicationConditionLink.count()).toBe(1); + expect(await prisma.medicationDocumentLink.count()).toBe(1); + expect(await prisma.encounterMedicationLink.count()).toBe(1); + + // Re-posting is a success that changes nothing. + const again = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "medication", + sourceId: owner.medication.id, + targetKind: "document", + targetIds: [owner.document.id], + }); + expect(again.changed).toBe(0); + }); + + it("refuses another account's medication as the source", async () => { + const result = await linkTargets(getPrismaClient(), { + userId: OWNER, + sourceKind: "medication", + sourceId: stranger.medication.id, + targetKind: "document", + targetIds: [owner.document.id], + }); + expect(result.unknownSource).toBe(true); + }); + + it("files a lab result against its report and visit, and unfiles it", async () => { + const prisma = getPrismaClient(); + for (const [targetKind, id] of [ + ["document", owner.document.id], + ["encounter", owner.encounter.id], + ] as const) { + const result = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "labResult", + sourceId: owner.labResult.id, + targetKind, + targetIds: [id], + }); + expect(result.changed, targetKind).toBe(1); + } + expect(await prisma.labResultDocumentLink.count()).toBe(1); + expect(await prisma.encounterLabLink.count()).toBe(1); + + const removed = await unlinkTargets(prisma, { + userId: OWNER, + sourceKind: "labResult", + sourceId: owner.labResult.id, + targetKind: "document", + targetIds: [owner.document.id], + }); + expect(removed.changed).toBe(1); + expect(await prisma.labResultDocumentLink.count()).toBe(0); + }); + + it("files a dose against a visit from either end, into one table", async () => { + const prisma = getPrismaClient(); + await linkTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: owner.vaccination.id, + targetKind: "encounter", + targetIds: [owner.encounter.id], + }); + const fromVisit = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "vaccination", + targetIds: [owner.vaccination.id], + }); + expect(fromVisit.changed).toBe(0); + expect(await prisma.encounterVaccinationLink.count()).toBe(1); + }); +}); + +describe("related[]", () => { + it("lists a stored pair on both detail pages, with its web path", async () => { + await linkTargets(getPrismaClient(), { + userId: OWNER, + sourceKind: "medication", + sourceId: owner.medication.id, + targetKind: "conditionEpisode", + targetIds: [owner.episode.id], + }); + + const fromMedication = await related("medication", owner.medication.id); + expect(fromMedication).toContainEqual({ + kind: "conditionEpisode", + id: owner.episode.id, + label: `${OWNER} thyroid`, + date: "2026-01-20T00:00:00.000Z", + href: `/illness/${owner.episode.id}`, + via: "direct", + }); + + const fromCondition = await related("conditionEpisode", owner.episode.id); + expect(fromCondition).toContainEqual( + expect.objectContaining({ + kind: "medication", + id: owner.medication.id, + label: "Levothyroxine 50 µg", + href: `/medications/${owner.medication.id}`, + via: "direct", + }), + ); + }); + + it("derives a lab result's condition through the visit both are filed against", async () => { + const prisma = getPrismaClient(); + await linkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "labResult", + targetIds: [owner.labResult.id], + }); + await linkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "conditionEpisode", + targetIds: [owner.episode.id], + }); + + const fromLab = await related("labResult", owner.labResult.id); + expect(fromLab).toContainEqual( + expect.objectContaining({ + kind: "conditionEpisode", + id: owner.episode.id, + via: "encounter", + }), + ); + expect(fromLab).toContainEqual( + expect.objectContaining({ + kind: "encounter", + id: owner.encounter.id, + href: `/checkups?visit=${owner.encounter.id}`, + via: "direct", + }), + ); + const fromCondition = await related("conditionEpisode", owner.episode.id); + expect(fromCondition).toContainEqual( + expect.objectContaining({ + kind: "labResult", + id: owner.labResult.id, + href: `/labs/${owner.biomarker.id}`, + via: "encounter", + }), + ); + }); + + it("derives a medication and its marker's newest reading through the efficacy target", async () => { + const prisma = getPrismaClient(); + await prisma.medicationEfficacyTarget.create({ + data: { + medicationId: owner.medication.id, + biomarkerId: owner.biomarker.id, + }, + }); + const older = await prisma.labResult.create({ + data: { + userId: OWNER, + biomarkerId: owner.biomarker.id, + analyte: "TSH", + value: 4.4, + unit: "mU/L", + takenAt: new Date("2026-01-30T09:00:00.000Z"), + }, + }); + + const fromLab = await related("labResult", older.id); + expect(fromLab).toContainEqual( + expect.objectContaining({ + kind: "medication", + id: owner.medication.id, + via: "efficacyTarget", + }), + ); + const fromMedication = await related("medication", owner.medication.id); + const labs = fromMedication.filter((r) => r.kind === "labResult"); + expect(labs).toEqual([ + expect.objectContaining({ + id: owner.labResult.id, + via: "efficacyTarget", + }), + ]); + }); + + it("never lists another account's records, nor a deleted one", async () => { + const prisma = getPrismaClient(); + await linkTargets(prisma, { + userId: OWNER, + sourceKind: "document", + sourceId: owner.document.id, + targetKind: "labResult", + targetIds: [owner.labResult.id, stranger.labResult.id], + }); + expect(await related("document", owner.document.id)).toEqual([ + expect.objectContaining({ kind: "labResult", id: owner.labResult.id }), + ]); + + await prisma.labResult.update({ + where: { id: owner.labResult.id }, + data: { deletedAt: new Date() }, + }); + expect(await related("document", owner.document.id)).toEqual([]); + }); + + it("hides a kind whose module is off", async () => { + const prisma = getPrismaClient(); + await linkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "medication", + targetIds: [owner.medication.id], + }); + expect( + (await related("encounter", owner.encounter.id)).map((r) => r.kind), + ).toContain("medication"); + + await prisma.user.update({ + where: { id: OWNER }, + data: { modulePreferencesJson: { medications: false } }, + }); + expect( + (await related("encounter", owner.encounter.id)).map((r) => r.kind), + ).not.toContain("medication"); + }); + + it("hides a kind the caller's grant does not cover", async () => { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { id: READER, username: READER, email: `${READER}@example.test` }, + }); + const grant = await prisma.accountGrant.create({ + data: { + grantorId: OWNER, + granteeId: READER, + access: "READ", + acceptedAt: new Date(), + scopeJson: ["documents"], + }, + }); + await linkTargets(prisma, { + userId: OWNER, + sourceKind: "document", + sourceId: owner.document.id, + targetKind: "medication", + targetIds: [owner.medication.id], + }); + + const asOwner = await related("document", owner.document.id); + expect(asOwner.map((r) => r.kind)).toContain("medication"); + const asReader = await buildRelated(prisma, { + userId: OWNER, + grantId: grant.id, + kind: "document", + id: owner.document.id, + }); + expect(asReader).toEqual([]); + }); +}); + +describe("backup of the five relation link tables", () => { + it("carries every table out and back, and reports a link whose end is gone", async () => { + const prisma = getPrismaClient(); + const link = ( + sourceKind: Parameters[1]["sourceKind"], + sourceId: string, + targetKind: Parameters[1]["targetKind"], + targetId: string, + ) => + linkTargets(prisma, { + userId: OWNER, + sourceKind, + sourceId, + targetKind, + targetIds: [targetId], + }); + await link("labResult", owner.labResult.id, "document", owner.document.id); + await link( + "encounter", + owner.encounter.id, + "medication", + owner.medication.id, + ); + await link( + "encounter", + owner.encounter.id, + "vaccination", + owner.vaccination.id, + ); + await link( + "medication", + owner.medication.id, + "document", + owner.document.id, + ); + await link( + "medication", + owner.medication.id, + "conditionEpisode", + owner.episode.id, + ); + + const section = await buildRelationLinksBackupSection(prisma, OWNER, { + purpose: "disaster-recovery", + }); + for (const [key, rows] of Object.entries(section)) { + expect(rows, key).toHaveLength(1); + } + + // A file naming a medication this account does not have. + const edited = { + ...section, + medicationDocumentLinks: [ + ...section.medicationDocumentLinks, + { + medicationId: "medication-not-in-this-file", + documentId: owner.document.id, + createdAt: "2026-07-01T00:00:00.000Z", + }, + ], + }; + const skips: RestoreSkipLog = []; + const cleared = await prisma.$transaction((tx) => + restoreRelationLinksData(tx, OWNER, edited, skips), + ); + expect(cleared.relationLinks).toBe(5); + expect(await prisma.labResultDocumentLink.count()).toBe(1); + expect(await prisma.encounterMedicationLink.count()).toBe(1); + expect(await prisma.encounterVaccinationLink.count()).toBe(1); + expect(await prisma.medicationDocumentLink.count()).toBe(1); + expect(await prisma.medicationConditionLink.count()).toBe(1); + // The original filing instant survives the trip. + const restored = await prisma.medicationConditionLink.findFirstOrThrow(); + expect(restored.createdAt.toISOString()).toBe( + section.medicationConditionLinks[0].createdAt, + ); + expect(skips).toEqual([ + { + catalogue: "relationLink", + key: "medication-not-in-this-file", + links: 1, + }, + ]); + }); +});