Merge branch 'feat/v139-targets-and-links' into release/v1.39.0

This commit is contained in:
Marc-André Bombeck committed 2026-09-24 09:53:41 +02:00
commit e89f41710a
58 files changed
+2785 -214

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();
});
});
+69 -25
View File
@@ -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) ?? [],
),
);
},
+29 -1
View File
@@ -49,9 +49,11 @@ import {
resolveSeriesFor,
} from "@/lib/vaccinations/service";
import { satisfyBoostersForDose } from "@/lib/vaccinations/satisfy-matching";
import { resolveRenewals } from "@/lib/vaccinations/renewal";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
const { user, grantId } = await requireRecordAuth("read", "profile");
const params = new URL(request.url).searchParams;
const parsed = vaccinationListQuerySchema.safeParse({
@@ -96,6 +98,31 @@ export const GET = apiHandler(async (request: NextRequest) => {
resolveSeriesFor(prisma, user.id),
]);
// v1.39 (#1005) — the renewal state of each antigen, from the booster the
// person confirmed. That reminder lives in the measurements section, so a
// grant that opened only the health background gets null rather than a
// reminder it was never given.
const visible = await actingDomainVisibility(prisma, grantId);
const renewals = visible("measurements")
? resolveRenewals(
await prisma.measurementReminder.findMany({
where: {
userId: user.id,
deletedAt: null,
vaccinationAntigen: { not: null },
},
select: {
id: true,
vaccinationAntigen: true,
nextDueAt: true,
enabled: true,
},
}),
new Date(),
await resolveOwnerTimezone(user.id),
)
: null;
annotate({
action: { name: "vaccination.record.list", entity_type: "vaccination" },
meta: { count: rows.length, filtered: Boolean(query.antigenSlug) },
@@ -105,6 +132,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
vaccinations: rows.map((row) =>
toVaccinationDTO(row, series.get(row.id) ?? []),
),
renewals,
};
return apiSuccess(body);
});