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
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 14:05:58 +02:00
1 parent e44b1615e1
commit e22c7ddfcc
2 files changed
+203 -2

No files matched your search

+18 -1
View File
@@ -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<OwnedRowDelegate>(tx.inboundDocument),
relationField: "document",
},
vaccination: {
delegate: (tx) => narrow<OwnedRowDelegate>(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<LinkRowDelegate>(tx.vaccinationDocumentLink),
sourceColumn: "vaccinationId",
targetColumn: "documentId",
target: DOCUMENT_ENDPOINT,
},
};
function tableFor(
+185 -1
View File
@@ -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<ReturnType<typeof seedAccount>>;
@@ -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/);
});
});