mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
e44b1615e1
commit
e22c7ddfcc
2 files changed
+203
-2
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user