Files
Marc-André Bombeck b480ac4543 feat(documents): link a document to visits and vaccinations from its own sheet
A document could show its visits read-only and its vaccinations not at
all, so a childhood record covering a dozen doses had to be attached
from a dozen dose forms. The document's detail sheet now lists the
visits and vaccinations it is filed against and, for someone who may
manage the vault, links and unlinks them in one place, with the records
dated near the document on top. Both ends write the same link tables
through the link service, so the dose's form and the document's sheet
cannot disagree about what is filed where.

The document detail response carries the linked doses, named by
catalogue slug or the person's own wording. It is null rather than
empty for a grant that opens the vault but not the health background,
so a shared page never claims to record no doses. The update route
takes a replace-set of vaccination ids, up to 100 so one record can
cover a whole primary schedule; ownership is re-checked, a foreign id
answers 404 and the change lands in the audit log beside the other link
fields. Linking from the dose suggestion now refreshes the sheet.

Refs #1024, #1005
2026-09-24 09:39:27 +02:00

736 lines
22 KiB
TypeScript

/**
* The link service against real Postgres.
*
* Almost nothing asserted here is unit-testable, and that is the reason the
* file exists rather than a mocked sibling. Ownership refusal is a `where`
* clause; the duplicate no-op is a unique index plus `skipDuplicates`; the
* soft-delete exclusion is another `where`. A fake client that ignored `where`
* would report every one of them as working, which is precisely the shape of
* green test this repository has been burned by.
*
* Two accounts are seeded for every case, because "scoped to the owner" is a
* claim about the SECOND account and a single-account fixture cannot see it.
*/
import { beforeEach, describe, expect, it } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import {
MAX_TARGETS_PER_CALL,
linkTargets,
listTargets,
listDistinctTargets,
listTargetsBySource,
replaceTargets,
unlinkTargets,
} from "@/lib/links";
import { getPrismaClient, truncateAllTables } from "./setup";
const OWNER = "link-owner";
const STRANGER = "link-stranger";
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 labResult = await prisma.labResult.create({
data: {
userId: id,
analyte: "Ferritin",
panel: "Iron panel",
value: 91,
unit: "ng/mL",
takenAt: new Date("2026-06-30T09:00:00.000Z"),
},
});
const episode = await prisma.illnessEpisode.create({
data: {
userId: id,
label: `${id} condition`,
type: "INFECTION",
onsetAt: new Date("2026-06-20T00:00:00.000Z"),
},
});
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>>;
let stranger: Awaited<ReturnType<typeof seedAccount>>;
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
owner = await seedAccount(OWNER);
stranger = await seedAccount(STRANGER);
});
describe("link service — ownership", () => {
it("refuses a target the caller does not own, and writes nothing", async () => {
const prisma = getPrismaClient();
const result = await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
targetIds: [stranger.document.id],
});
expect(result.changed).toBe(0);
expect(result.unknownTargetIds).toEqual([stranger.document.id]);
// The refusal has to be visible in the table, not only in the return
// value: a service that reported zero and wrote the row anyway would
// satisfy the assertion above.
expect(await prisma.encounterDocumentLink.count()).toBe(0);
});
it("refuses a source the caller does not own", async () => {
const prisma = getPrismaClient();
const result = await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: stranger.encounter.id,
targetKind: "document",
targetIds: [owner.document.id],
});
expect(result.unknownSource).toBe(true);
expect(await prisma.encounterDocumentLink.count()).toBe(0);
});
it("links the owned ids and names the foreign ones in the same call", async () => {
const prisma = getPrismaClient();
const result = await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
targetIds: [owner.document.id, stranger.document.id],
});
expect(result.changed).toBe(1);
expect(result.unknownTargetIds).toEqual([stranger.document.id]);
expect(await prisma.encounterDocumentLink.count()).toBe(1);
});
});
describe("link service — idempotency", () => {
it("linking the same pair twice succeeds and changes nothing the second time", async () => {
const prisma = getPrismaClient();
const request = {
userId: OWNER,
sourceKind: "encounter" as const,
sourceId: owner.encounter.id,
targetKind: "labResult" as const,
targetIds: [owner.labResult.id],
};
const first = await linkTargets(prisma, request);
const second = await linkTargets(prisma, request);
expect(first.changed).toBe(1);
expect(second.changed).toBe(0);
expect(await prisma.encounterLabLink.count()).toBe(1);
});
it("unlinking a pair that was never linked is a success", async () => {
const prisma = getPrismaClient();
const result = await unlinkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "labResult",
targetIds: [owner.labResult.id],
});
expect(result.changed).toBe(0);
});
it("counts a duplicated id in one call once", async () => {
const prisma = getPrismaClient();
const result = await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "conditionEpisode",
targetIds: [owner.episode.id, owner.episode.id],
});
expect(result.changed).toBe(1);
expect(await prisma.encounterConditionLink.count()).toBe(1);
});
});
describe("link service — the cap", () => {
it("refuses a call naming more targets than the cap", async () => {
const prisma = getPrismaClient();
const tooMany = Array.from(
{ length: MAX_TARGETS_PER_CALL + 1 },
(_, i) => `target-${i}`,
);
await expect(
linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
targetIds: tooMany,
}),
).rejects.toThrow(/above the cap/);
});
it("accepts a call exactly at the cap", async () => {
const prisma = getPrismaClient();
const atCap = Array.from(
{ length: MAX_TARGETS_PER_CALL },
(_, i) => `target-${i}`,
);
// Every id is unknown, so nothing is written — the point is that the cap
// itself does not reject a call sitting on the boundary.
const result = await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
targetIds: atCap,
});
expect(result.unknownTargetIds).toHaveLength(MAX_TARGETS_PER_CALL);
});
});
describe("link service — soft deletes", () => {
it("will not link a tombstoned target", async () => {
const prisma = getPrismaClient();
await prisma.inboundDocument.update({
where: { id: owner.document.id },
data: { deletedAt: new Date() },
});
const result = await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
targetIds: [owner.document.id],
});
expect(result.unknownTargetIds).toEqual([owner.document.id]);
expect(await prisma.encounterDocumentLink.count()).toBe(0);
});
it("will not link onto a tombstoned source", async () => {
const prisma = getPrismaClient();
await prisma.encounter.update({
where: { id: owner.encounter.id },
data: { deletedAt: new Date() },
});
const result = await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
targetIds: [owner.document.id],
});
expect(result.unknownSource).toBe(true);
});
it("still unlinks a target the account has since deleted", async () => {
const prisma = getPrismaClient();
await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
targetIds: [owner.document.id],
});
await prisma.inboundDocument.update({
where: { id: owner.document.id },
data: { deletedAt: new Date() },
});
// Refusing here would leave a filing nobody can clear.
const result = await unlinkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
targetIds: [owner.document.id],
});
expect(result.changed).toBe(1);
});
it("drops a tombstoned target out of the resolved list", async () => {
const prisma = getPrismaClient();
await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
targetIds: [owner.document.id],
});
await prisma.inboundDocument.update({
where: { id: owner.document.id },
data: { deletedAt: new Date() },
});
const listed = await listTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
});
expect(listed).toEqual([]);
});
});
describe("link service — replace", () => {
it("drops what is no longer named and adds what is missing", async () => {
const prisma = getPrismaClient();
const second = await prisma.illnessEpisode.create({
data: {
userId: OWNER,
label: "Second condition",
type: "INFECTION",
onsetAt: new Date("2026-06-25T00:00:00.000Z"),
},
});
await replaceTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "conditionEpisode",
targetIds: [owner.episode.id],
});
await replaceTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "conditionEpisode",
targetIds: [second.id],
});
const linked = await prisma.encounterConditionLink.findMany({
where: { encounterId: owner.encounter.id },
select: { episodeId: true },
});
expect(linked.map((row) => row.episodeId)).toEqual([second.id]);
});
it("clears every link when the set is empty", async () => {
const prisma = getPrismaClient();
await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "conditionEpisode",
targetIds: [owner.episode.id],
});
await replaceTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "conditionEpisode",
targetIds: [],
});
expect(await prisma.encounterConditionLink.count()).toBe(0);
});
});
describe("link service — resolved reads", () => {
it("returns a label and a date, never a bare id", async () => {
const prisma = getPrismaClient();
await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "labResult",
targetIds: [owner.labResult.id],
});
const listed = await listTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "labResult",
});
expect(listed).toEqual([
{
id: owner.labResult.id,
label: "Iron panel · Ferritin",
date: "2026-06-30T09:00:00.000Z",
},
]);
});
it("keys a batched read by source and omits sources with no links", async () => {
const prisma = getPrismaClient();
const other = await prisma.encounter.create({
data: {
userId: OWNER,
occurredAt: new Date("2026-07-02T09:00:00.000Z"),
status: "DONE",
kind: "ROUTINE",
},
});
await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
targetIds: [owner.document.id],
});
const bySource = await listTargetsBySource(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceIds: [owner.encounter.id, other.id],
targetKind: "document",
});
expect(bySource.get(owner.encounter.id)).toHaveLength(1);
expect(bySource.has(other.id)).toBe(false);
});
it("offers a distinct target only while a live source still points at it", async () => {
const prisma = getPrismaClient();
await linkTargets(prisma, {
userId: OWNER,
sourceKind: "document",
sourceId: owner.document.id,
targetKind: "conditionEpisode",
targetIds: [owner.episode.id],
});
expect(
await listDistinctTargets(prisma, {
userId: OWNER,
sourceKind: "document",
targetKind: "conditionEpisode",
}),
).toHaveLength(1);
await prisma.inboundDocument.update({
where: { id: owner.document.id },
data: { deletedAt: new Date() },
});
expect(
await listDistinctTargets(prisma, {
userId: OWNER,
sourceKind: "document",
targetKind: "conditionEpisode",
}),
).toEqual([]);
});
it("does not leak another account's links into a read", async () => {
const prisma = getPrismaClient();
await linkTargets(prisma, {
userId: STRANGER,
sourceKind: "encounter",
sourceId: stranger.encounter.id,
targetKind: "document",
targetIds: [stranger.document.id],
});
const listed = await listTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: stranger.encounter.id,
targetKind: "document",
});
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/);
});
});
describe("link service — a document filed against many doses (#1024)", () => {
async function seedDoses(userId: string, count: number) {
const prisma = getPrismaClient();
const ids: string[] = [];
for (let i = 0; i < count; i++) {
const dose = await prisma.vaccinationRecord.create({
data: {
userId,
occurredAt: new Date(Date.UTC(1991, i % 12, 1 + i)),
antigenSlug: i % 2 === 0 ? "dtap" : "polio",
},
});
ids.push(dose.id);
}
return ids;
}
it("files one page against a whole childhood schedule from the document side", async () => {
const prisma = getPrismaClient();
const doses = await seedDoses(OWNER, 12);
const result = await replaceTargets(prisma, {
userId: OWNER,
sourceKind: "document",
sourceId: owner.document.id,
targetKind: "vaccination",
targetIds: doses,
});
expect(result.changed).toBe(12);
// One table, read from either end: every dose now lists the page.
for (const dose of doses) {
const pages = await listTargets(prisma, {
userId: OWNER,
sourceKind: "vaccination",
sourceId: dose,
targetKind: "document",
});
expect(pages.map((page) => page.id)).toEqual([owner.document.id]);
}
// And the rows landed in the right columns.
const row = await prisma.vaccinationDocumentLink.findFirstOrThrow({
where: { vaccinationId: doses[0] },
});
expect(row.documentId).toBe(owner.document.id);
expect(row.userId).toBe(OWNER);
});
it("refuses another account's dose from the document side, and writes nothing", async () => {
const prisma = getPrismaClient();
const result = await linkTargets(prisma, {
userId: OWNER,
sourceKind: "document",
sourceId: owner.document.id,
targetKind: "vaccination",
targetIds: [stranger.vaccination.id],
});
expect(result.unknownTargetIds).toEqual([stranger.vaccination.id]);
expect(await prisma.vaccinationDocumentLink.count()).toBe(0);
});
it("unlinking from the document side leaves the other doses filed", async () => {
const prisma = getPrismaClient();
const doses = await seedDoses(OWNER, 3);
await replaceTargets(prisma, {
userId: OWNER,
sourceKind: "document",
sourceId: owner.document.id,
targetKind: "vaccination",
targetIds: doses,
});
await replaceTargets(prisma, {
userId: OWNER,
sourceKind: "document",
sourceId: owner.document.id,
targetKind: "vaccination",
targetIds: [doses[0]!, doses[2]!],
});
const linked = await listTargets(prisma, {
userId: OWNER,
sourceKind: "document",
sourceId: owner.document.id,
targetKind: "vaccination",
});
expect(linked.map((dose) => dose.id).sort()).toEqual(
[doses[0]!, doses[2]!].sort(),
);
});
});