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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:57:34 +02:00
1 parent ffd46b017b
commit b4bc039d16
25 files changed
+1569 -82

No files matched your search

+115 -8
View File
@@ -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
@@ -51,17 +51,15 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
"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"] })
@@ -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, "")),
+16 -11
View File
@@ -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 },
),
);
},
+2 -1
View File
@@ -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
+9 -1
View File
@@ -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,
}),
);
},
);
+10 -2
View File
@@ -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,
}),
);
},
);
+43 -5
View File
@@ -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<Response> {
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(
+10 -5
View File
@@ -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 },
),
);
},
+11
View File
@@ -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
+43 -5
View File
@@ -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<Response> {
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(
+9 -1
View File
@@ -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,
}),
});
},
);
+2 -1
View File
@@ -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
+8 -1
View File
@@ -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 },
),
);
},
);
+1
View File
@@ -74,6 +74,7 @@ const TARGET_DOMAIN: Record<LinkTargetKind, ShareDomain> = {
encounter: "profile",
// A dose lives in the health background, like the visit itself.
vaccination: "profile",
medication: "medications",
};
/**
@@ -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(),
@@ -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<string, string> {
const start = SERVICE.indexOf("const LINK_TABLES");
expect(start).toBeGreaterThan(-1);
const body = SERVICE.slice(start, SERVICE.indexOf("\n};", start));
const out = new Map<string, string>();
for (const m of body.matchAll(
/"(\w+:\w+)":\s*\{\s*delegate:\s*\(tx\)\s*=>\s*narrow<LinkRowDelegate>\(\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();
}
});
});
+157 -20
View File
@@ -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<string, boolean>;
label: (row: Record<string, unknown>) => string;
@@ -114,6 +130,11 @@ function narrow<T>(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<OwnedRowDelegate>(tx.encounter),
relationField: "encounter",
softDeletes: true,
},
document: {
delegate: (tx) => narrow<OwnedRowDelegate>(tx.inboundDocument),
relationField: "document",
softDeletes: true,
},
vaccination: {
delegate: (tx) => narrow<OwnedRowDelegate>(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<OwnedRowDelegate>(tx.illnessEpisode),
relationField: "episode",
softDeletes: true,
},
// v1.43 — a medication files its indication, its documents and its visits.
medication: {
delegate: (tx) => narrow<OwnedRowDelegate>(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<OwnedRowDelegate>(tx.labResult),
relationField: "labResult",
softDeletes: true,
},
};
const DOCUMENT_ENDPOINT: EndpointSpec = {
delegate: (tx) => narrow<OwnedRowDelegate>(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<OwnedRowDelegate>(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<OwnedRowDelegate>(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<OwnedRowDelegate>(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<OwnedRowDelegate>(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<OwnedRowDelegate>(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<LinkRowDelegate>(tx.labResultDocumentLink),
sourceColumn: "labResultId",
targetColumn: "documentId",
target: DOCUMENT_ENDPOINT,
},
"document:labResult": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.labResultDocumentLink),
sourceColumn: "documentId",
targetColumn: "labResultId",
target: LAB_RESULT_ENDPOINT,
},
// `encounter_lab_links` read from the result's end.
"labResult:encounter": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.encounterLabLink),
sourceColumn: "labResultId",
targetColumn: "encounterId",
target: ENCOUNTER_ENDPOINT,
},
// `encounter_medication_links`: prescribed, changed or reviewed at a visit.
"encounter:medication": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.encounterMedicationLink),
sourceColumn: "encounterId",
targetColumn: "medicationId",
target: MEDICATION_ENDPOINT,
},
"medication:encounter": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.encounterMedicationLink),
sourceColumn: "medicationId",
targetColumn: "encounterId",
target: ENCOUNTER_ENDPOINT,
},
// `encounter_vaccination_links`: a dose given at a visit.
"encounter:vaccination": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.encounterVaccinationLink),
sourceColumn: "encounterId",
targetColumn: "vaccinationId",
target: VACCINATION_ENDPOINT,
},
"vaccination:encounter": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.encounterVaccinationLink),
sourceColumn: "vaccinationId",
targetColumn: "encounterId",
target: ENCOUNTER_ENDPOINT,
},
// `medication_document_links`: a prescription, a plan, a package insert.
"medication:document": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.medicationDocumentLink),
sourceColumn: "medicationId",
targetColumn: "documentId",
target: DOCUMENT_ENDPOINT,
},
"document:medication": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.medicationDocumentLink),
sourceColumn: "documentId",
targetColumn: "medicationId",
target: MEDICATION_ENDPOINT,
},
// `medication_condition_links`: the indication.
"medication:conditionEpisode": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.medicationConditionLink),
sourceColumn: "medicationId",
targetColumn: "episodeId",
target: CONDITION_ENDPOINT,
},
"conditionEpisode:medication": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.medicationConditionLink),
sourceColumn: "episodeId",
targetColumn: "medicationId",
target: MEDICATION_ENDPOINT,
},
};
function tableFor(
@@ -340,8 +470,9 @@ async function ownsSource(
sourceId,
}: Omit<LinkRequest, "targetKind" | "targetIds">,
): Promise<boolean> {
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<Set<string>> {
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<LinkedTarget[]> {
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" },
+492
View File
@@ -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<RelatedVia, "direct"> }
| { 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<Partial<Record<Pair, RelationCell>>> = {
"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<RelatedKind, ModuleKey | null>
> = {
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<Record<RelatedKind, ShareDomain>> = {
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<Map<string, Hydrated>> {
const out = new Map<string, Hydrated>();
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<Candidate[]> {
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<Candidate[]> {
const visits = await listTargets(tx, {
userId,
sourceKind: from,
sourceId,
targetKind: "encounter",
});
const seen = new Set<string>();
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<Candidate[]> {
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<Candidate[]> {
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<Candidate[]>;
/**
* 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<Partial<Record<Pair, Reader>>> = {
"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<Candidate[]> {
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<RelatedLink[]> {
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<T extends object>(
tx: Prisma.TransactionClient,
detail: T,
input: Parameters<typeof buildRelated>[1],
): Promise<T & { related: RelatedLink[] }> {
return { ...detail, related: await buildRelated(tx, input) };
}
+1 -1
View File
@@ -552,7 +552,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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,
+1 -7
View File
@@ -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") } };
+3 -3
View File
@@ -628,9 +628,9 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/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<ZodOpenApiObject["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.",
requestParams: idPath,
+3
View File
@@ -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
+4 -6
View File
@@ -253,13 +253,11 @@ export type CustomVaccineUpdate = z.infer<typeof customVaccineUpdateSchema>;
/* ── 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({
+481
View File
@@ -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<ReturnType<typeof seedAccount>>;
let stranger: Awaited<ReturnType<typeof seedAccount>>;
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
owner = await seedAccount(OWNER);
stranger = await seedAccount(STRANGER);
});
const related = (
kind: Parameters<typeof buildRelated>[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<typeof linkTargets>[1]["sourceKind"],
sourceId: string,
targetKind: Parameters<typeof linkTargets>[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,
},
]);
});
});