From e22c7ddfccdde4b2254c88eca8005e553ac9d56f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:05:58 +0200 Subject: [PATCH] feat(vaccinations): teach the link facade the dose-to-document pair - one new source and one new pair; the module's invariants carry over unchanged, so ownership, idempotency, the batch cap and the soft-delete refusal need no new code - each invariant gets its own case against the new table, because every one of them is a where clause on a different table and a pair wired to the wrong column would pass all the existing ones - the ceiling is written down and asserted: the vaccination source owns exactly one pair, and asking for a second is refused loudly --- src/lib/links/link-service.ts | 19 ++- tests/integration/link-service.test.ts | 186 ++++++++++++++++++++++++- 2 files changed, 203 insertions(+), 2 deletions(-) diff --git a/src/lib/links/link-service.ts b/src/lib/links/link-service.ts index e52c226d3..19ff8423f 100644 --- a/src/lib/links/link-service.ts +++ b/src/lib/links/link-service.ts @@ -12,7 +12,7 @@ import type { Prisma } from "@/generated/prisma/client"; export const MAX_TARGETS_PER_CALL = 100; /** The record a link hangs off. */ -export type LinkSourceKind = "encounter" | "document"; +export type LinkSourceKind = "encounter" | "document" | "vaccination"; /** The record a link points at. */ export type LinkTargetKind = "document" | "labResult" | "conditionEpisode"; @@ -137,6 +137,10 @@ const SOURCES: Record< delegate: (tx) => narrow(tx.inboundDocument), relationField: "document", }, + vaccination: { + delegate: (tx) => narrow(tx.vaccinationRecord), + relationField: "vaccination", + }, }; const DOCUMENT_ENDPOINT: EndpointSpec = { @@ -183,6 +187,13 @@ const CONDITION_ENDPOINT: EndpointSpec = { * 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. + * + * 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. */ const LINK_TABLES: Partial< Record<`${LinkSourceKind}:${LinkTargetKind}`, LinkTableSpec> @@ -211,6 +222,12 @@ const LINK_TABLES: Partial< targetColumn: "episodeId", target: CONDITION_ENDPOINT, }, + "vaccination:document": { + delegate: (tx) => narrow(tx.vaccinationDocumentLink), + sourceColumn: "vaccinationId", + targetColumn: "documentId", + target: DOCUMENT_ENDPOINT, + }, }; function tableFor( diff --git a/tests/integration/link-service.test.ts b/tests/integration/link-service.test.ts index 19b12f81f..94dc0dd1d 100644 --- a/tests/integration/link-service.test.ts +++ b/tests/integration/link-service.test.ts @@ -74,7 +74,14 @@ async function seedAccount(id: string) { onsetAt: new Date("2026-06-20T00:00:00.000Z"), }, }); - return { encounter, document, labResult, episode }; + const vaccination = await prisma.vaccinationRecord.create({ + data: { + userId: id, + occurredAt: new Date("2026-05-14T00:00:00.000Z"), + antigenSlug: "tdap", + }, + }); + return { encounter, document, labResult, episode, vaccination }; } let owner: Awaited>; @@ -461,3 +468,180 @@ describe("link service — resolved reads", () => { expect(listed).toEqual([]); }); }); + +/** + * The `vaccination:document` pair, one case per invariant the facade claims. + * + * The pair is new; the invariants are not. Repeating them here rather than + * trusting the encounter cases is the point — every one of them is a `where` + * clause or an index on a DIFFERENT table, and a pair wired to the wrong + * column or the wrong ownership probe would pass every test above while + * exposing this one. + */ +describe("link service — the vaccination pair", () => { + it("links a dose to the page it was transcribed from", async () => { + const prisma = getPrismaClient(); + const result = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: owner.vaccination.id, + targetKind: "document", + targetIds: [owner.document.id], + }); + + expect(result.changed).toBe(1); + expect(result.unknownTargetIds).toEqual([]); + expect(await prisma.vaccinationDocumentLink.count()).toBe(1); + // Written against the right columns, not merely written: a pair wired to + // the wrong column would still increment the count above. + const row = await prisma.vaccinationDocumentLink.findFirstOrThrow(); + expect({ + vaccinationId: row.vaccinationId, + documentId: row.documentId, + userId: row.userId, + }).toEqual({ + vaccinationId: owner.vaccination.id, + documentId: owner.document.id, + userId: OWNER, + }); + }); + + it("refuses a document another account owns", async () => { + const prisma = getPrismaClient(); + const result = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: owner.vaccination.id, + targetKind: "document", + targetIds: [stranger.document.id], + }); + + expect(result.changed).toBe(0); + expect(result.unknownTargetIds).toEqual([stranger.document.id]); + expect(await prisma.vaccinationDocumentLink.count()).toBe(0); + }); + + it("refuses a dose another account owns", async () => { + const prisma = getPrismaClient(); + const result = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: stranger.vaccination.id, + targetKind: "document", + targetIds: [owner.document.id], + }); + + expect(result.unknownSource).toBe(true); + expect(await prisma.vaccinationDocumentLink.count()).toBe(0); + }); + + it("links the same pair twice without writing a second row", async () => { + const prisma = getPrismaClient(); + const request = { + userId: OWNER, + sourceKind: "vaccination" as const, + sourceId: owner.vaccination.id, + targetKind: "document" as const, + targetIds: [owner.document.id], + }; + expect((await linkTargets(prisma, request)).changed).toBe(1); + expect((await linkTargets(prisma, request)).changed).toBe(0); + expect(await prisma.vaccinationDocumentLink.count()).toBe(1); + }); + + it("refuses a call naming more targets than the cap", async () => { + const prisma = getPrismaClient(); + await expect( + linkTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: owner.vaccination.id, + targetKind: "document", + targetIds: Array.from( + { length: MAX_TARGETS_PER_CALL + 1 }, + (_, index) => `doc-${index}`, + ), + }), + ).rejects.toThrow(/cap/); + expect(await prisma.vaccinationDocumentLink.count()).toBe(0); + }); + + it("will not link onto a dose the account has deleted", async () => { + const prisma = getPrismaClient(); + await prisma.vaccinationRecord.update({ + where: { id: owner.vaccination.id }, + data: { deletedAt: new Date() }, + }); + + const result = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: owner.vaccination.id, + targetKind: "document", + targetIds: [owner.document.id], + }); + expect(result.unknownSource).toBe(true); + expect(await prisma.vaccinationDocumentLink.count()).toBe(0); + }); + + it("resolves the linked page to a label and a date, never a bare id", async () => { + const prisma = getPrismaClient(); + await linkTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: owner.vaccination.id, + targetKind: "document", + targetIds: [owner.document.id], + }); + + const listed = await listTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: owner.vaccination.id, + targetKind: "document", + }); + expect(listed).toEqual([ + { + id: owner.document.id, + label: `${OWNER} report`, + date: "2026-06-30T00:00:00.000Z", + }, + ]); + }); + + it("does not leak another account's dose links into a read", async () => { + const prisma = getPrismaClient(); + await linkTargets(prisma, { + userId: STRANGER, + sourceKind: "vaccination", + sourceId: stranger.vaccination.id, + targetKind: "document", + targetIds: [stranger.document.id], + }); + + expect( + await listTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: stranger.vaccination.id, + targetKind: "document", + }), + ).toEqual([]); + }); + + it("has no table joining a dose to a lab result", async () => { + // The ceiling, asserted rather than only commented: the vaccination + // source owns exactly one pair, and asking for a second is a programming + // error the facade refuses loudly instead of inventing a table for. + const prisma = getPrismaClient(); + await expect( + linkTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: owner.vaccination.id, + targetKind: "labResult", + targetIds: [owner.labResult.id], + }), + ).rejects.toThrow(/No link table joins a vaccination to a labResult/); + }); +});