mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
ffd46b017b
commit
b4bc039d16
25 files changed
+1569
-82
No files matched your search
+115
-8
@@ -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, "")),
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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") } };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user