mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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
This commit is contained in:
1 parent
f46ed99def
commit
b480ac4543
23 files changed
+1012
-87
No files matched your search
@@ -28,6 +28,14 @@ vi.mock("@/lib/db", () => ({
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
vaccinationDocumentLink: {
|
||||
findMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
vaccinationRecord: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
documentContentIndex: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
@@ -73,6 +81,10 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
// Owner by default (every section open); the redaction case narrows it.
|
||||
vi.mock("@/lib/sharing/acting-domains", () => ({
|
||||
actingDomainVisibility: vi.fn(async () => () => true),
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -89,10 +101,12 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PATCH } from "../route";
|
||||
import { GET, PATCH } from "../route";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -141,6 +155,16 @@ beforeEach(() => {
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.vaccinationDocumentLink.deleteMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.vaccinationDocumentLink.createMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
@@ -266,4 +290,141 @@ describe("PATCH /api/documents/inbound/[id]", () => {
|
||||
expect(prisma.documentConditionLink.deleteMany).not.toHaveBeenCalled();
|
||||
expect(prisma.documentConditionLink.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replace-sets vaccination links from vaccinationIds and audits the change", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([
|
||||
{ id: "dose-1" },
|
||||
{ id: "dose-2" },
|
||||
] as never);
|
||||
vi.mocked(prisma.inboundDocument.findFirstOrThrow).mockResolvedValue(
|
||||
docRow() as never,
|
||||
);
|
||||
|
||||
const res = await PATCH(
|
||||
mkReq("doc-1", { vaccinationIds: ["dose-1", "dose-2"] }) as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
// The owner-narrowing read is scoped by the session user and the
|
||||
// tombstone, never by anything the body carried.
|
||||
const narrowWhere = vi.mocked(prisma.vaccinationRecord.findMany).mock
|
||||
.calls[0]![0]!.where!;
|
||||
expect(narrowWhere).toMatchObject({ userId: "user-1", deletedAt: null });
|
||||
expect(prisma.vaccinationDocumentLink.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId: "user-1",
|
||||
documentId: "doc-1",
|
||||
vaccinationId: { notIn: ["dose-1", "dose-2"] },
|
||||
},
|
||||
});
|
||||
expect(prisma.vaccinationDocumentLink.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{ documentId: "doc-1", vaccinationId: "dose-1", userId: "user-1" },
|
||||
{ documentId: "doc-1", vaccinationId: "dose-2", userId: "user-1" },
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
expect(prisma.inboundDocument.update).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(auditLog)).toHaveBeenCalledWith(
|
||||
"documents.inbound.update",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({ fields: ["vaccinationIds"] }),
|
||||
}),
|
||||
);
|
||||
const body = await res.json();
|
||||
expect(body.data.vaccinationLinks).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a foreign dose with a 404-shaped response and writes nothing", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([] as never);
|
||||
|
||||
const res = await PATCH(
|
||||
mkReq("doc-1", { vaccinationIds: ["dose-foreign"] }) as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.vaccinationDocumentLink.deleteMany).not.toHaveBeenCalled();
|
||||
expect(prisma.vaccinationDocumentLink.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses more dose links than one request may carry", async () => {
|
||||
const ids = Array.from({ length: 101 }, (_, i) => `dose-${i}`);
|
||||
const res = await PATCH(
|
||||
mkReq("doc-1", { vaccinationIds: ids }) as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.inboundDocument.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/documents/inbound/[id] — vaccination links", () => {
|
||||
function getReq(id: string): NextRequest {
|
||||
return new NextRequest(
|
||||
new URL(`http://localhost/api/documents/inbound/${id}`),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(
|
||||
docRow() as never,
|
||||
);
|
||||
vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue([
|
||||
{ documentId: "doc-1", vaccinationId: "dose-1" },
|
||||
{ documentId: "doc-1", vaccinationId: "dose-2" },
|
||||
] as never);
|
||||
// First read: the link service's target re-read; second: the identity
|
||||
// read that lets the client name each dose.
|
||||
vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "dose-1",
|
||||
antigenSlug: "tetanus",
|
||||
vaccineName: null,
|
||||
occurredAt: new Date("1991-04-02T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "dose-2",
|
||||
antigenSlug: "no-longer-in-catalogue",
|
||||
vaccineName: "DTP",
|
||||
occurredAt: new Date("1991-06-02T00:00:00.000Z"),
|
||||
},
|
||||
] as never);
|
||||
});
|
||||
|
||||
it("lists every dose the page is filed against, named for the reader's bundle", async () => {
|
||||
const res = await GET(getReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.vaccinationLinks).toEqual([
|
||||
{
|
||||
vaccinationId: "dose-1",
|
||||
occurredAt: "1991-04-02T00:00:00.000Z",
|
||||
catalogSlug: "tetanus",
|
||||
vaccineName: null,
|
||||
},
|
||||
{
|
||||
vaccinationId: "dose-2",
|
||||
occurredAt: "1991-06-02T00:00:00.000Z",
|
||||
catalogSlug: null,
|
||||
vaccineName: "DTP",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("withholds the doses from a grant that does not cover the health background", async () => {
|
||||
vi.mocked(actingDomainVisibility).mockResolvedValueOnce(
|
||||
(domain) => domain !== "profile",
|
||||
);
|
||||
const res = await GET(getReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.vaccinationLinks).toBeNull();
|
||||
expect(prisma.vaccinationDocumentLink.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -23,11 +23,15 @@ import { prisma } from "@/lib/db";
|
||||
import {
|
||||
loadConditionLinks,
|
||||
loadDocumentEncounterLinks,
|
||||
loadDocumentVaccinationLinks,
|
||||
narrowOwnedEncounterIds,
|
||||
narrowOwnedEpisodeIds,
|
||||
narrowOwnedVaccinationIds,
|
||||
replaceConditionLinks,
|
||||
replaceEncounterLinks,
|
||||
replaceVaccinationLinks,
|
||||
} from "@/lib/documents/links";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
import { serialiseDocumentDetail } from "@/lib/documents/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
@@ -81,7 +85,7 @@ async function enforceWriteRateLimit(userId: string): Promise<Response | null> {
|
||||
*/
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read", "documents");
|
||||
const { user, grantId } = await requireRecordAuth("read", "documents");
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -97,18 +101,26 @@ export const GET = apiHandler(
|
||||
});
|
||||
}
|
||||
|
||||
const [links, visitLinks, contentIndex, thumbnail] = await Promise.all([
|
||||
loadConditionLinks(user.id, [document.id]),
|
||||
loadDocumentEncounterLinks(user.id, [document.id]),
|
||||
prisma.documentContentIndex.findUnique({
|
||||
where: { documentId: document.id },
|
||||
select: { source: true },
|
||||
}),
|
||||
prisma.documentThumbnail.findUnique({
|
||||
where: { documentId: document.id },
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
// The doses a page records live in the health background, not in the
|
||||
// vault. A grant that opened the vault was never consent for that side
|
||||
// of the seam, so the list is withheld (null) rather than read.
|
||||
const visible = await actingDomainVisibility(prisma, grantId);
|
||||
const [links, visitLinks, doseLinks, contentIndex, thumbnail] =
|
||||
await Promise.all([
|
||||
loadConditionLinks(user.id, [document.id]),
|
||||
loadDocumentEncounterLinks(user.id, [document.id]),
|
||||
visible("profile")
|
||||
? loadDocumentVaccinationLinks(user.id, [document.id])
|
||||
: Promise.resolve(null),
|
||||
prisma.documentContentIndex.findUnique({
|
||||
where: { documentId: document.id },
|
||||
select: { source: true },
|
||||
}),
|
||||
prisma.documentThumbnail.findUnique({
|
||||
where: { documentId: document.id },
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.inbound.get" },
|
||||
@@ -124,6 +136,7 @@ export const GET = apiHandler(
|
||||
toContentIndexSource(contentIndex?.source),
|
||||
thumbnail !== null,
|
||||
visitLinks.get(document.id) ?? [],
|
||||
doseLinks === null ? null : (doseLinks.get(document.id) ?? []),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -196,6 +209,22 @@ export const PATCH = apiHandler(
|
||||
nextEncounterIds = narrowed;
|
||||
}
|
||||
|
||||
// Replace-set vaccination links, same semantics and same refusal. The
|
||||
// table is the one the dose's own form writes from the other end.
|
||||
let nextVaccinationIds: string[] | undefined;
|
||||
if (parsed.data.vaccinationIds !== undefined) {
|
||||
const narrowed = await narrowOwnedVaccinationIds(
|
||||
user.id,
|
||||
parsed.data.vaccinationIds,
|
||||
);
|
||||
if (narrowed === null) {
|
||||
return apiError("Vaccination not found", 404, {
|
||||
errorCode: "documents.inbound.vaccinationNotFound",
|
||||
});
|
||||
}
|
||||
nextVaccinationIds = narrowed;
|
||||
}
|
||||
|
||||
// No mass assignment — each editable column is set explicitly only when
|
||||
// the client sent it. `userId` is never a body field.
|
||||
const data: Prisma.InboundDocumentUpdateInput = {};
|
||||
@@ -225,28 +254,42 @@ export const PATCH = apiHandler(
|
||||
);
|
||||
}
|
||||
|
||||
if (nextVaccinationIds !== undefined) {
|
||||
await replaceVaccinationLinks(
|
||||
prisma,
|
||||
user.id,
|
||||
existing.id,
|
||||
nextVaccinationIds,
|
||||
);
|
||||
}
|
||||
|
||||
const document = await prisma.inboundDocument.findFirstOrThrow({
|
||||
where: { id: existing.id },
|
||||
omit: { contentEncrypted: true },
|
||||
include: { facts: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
const [links, visitLinks, contentIndex, thumbnail] = await Promise.all([
|
||||
loadConditionLinks(user.id, [document.id]),
|
||||
loadDocumentEncounterLinks(user.id, [document.id]),
|
||||
prisma.documentContentIndex.findUnique({
|
||||
where: { documentId: document.id },
|
||||
select: { source: true },
|
||||
}),
|
||||
prisma.documentThumbnail.findUnique({
|
||||
where: { documentId: document.id },
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
// PATCH is owner-only (bare `requireAuth()`), so every section is the
|
||||
// caller's own and the dose list is always readable here.
|
||||
const [links, visitLinks, doseLinks, contentIndex, thumbnail] =
|
||||
await Promise.all([
|
||||
loadConditionLinks(user.id, [document.id]),
|
||||
loadDocumentEncounterLinks(user.id, [document.id]),
|
||||
loadDocumentVaccinationLinks(user.id, [document.id]),
|
||||
prisma.documentContentIndex.findUnique({
|
||||
where: { documentId: document.id },
|
||||
select: { source: true },
|
||||
}),
|
||||
prisma.documentThumbnail.findUnique({
|
||||
where: { documentId: document.id },
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const touched = [
|
||||
...Object.keys(data),
|
||||
...(nextEpisodeIds !== undefined ? ["episodeIds"] : []),
|
||||
...(nextEncounterIds !== undefined ? ["encounterIds"] : []),
|
||||
...(nextVaccinationIds !== undefined ? ["vaccinationIds"] : []),
|
||||
];
|
||||
|
||||
await auditLog("documents.inbound.update", {
|
||||
@@ -272,6 +315,7 @@ export const PATCH = apiHandler(
|
||||
toContentIndexSource(contentIndex?.source),
|
||||
thumbnail !== null,
|
||||
visitLinks.get(document.id) ?? [],
|
||||
doseLinks.get(document.id) ?? [],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user