Merge remote-tracking branch 'origin/feat/documents-procedure-filter' into release/v1.40.2

This commit is contained in:
Marc-André Bombeck committed 2026-10-05 07:17:17 +02:00
commit 03c43ef70a
24 files changed
+1308 -54

No files matched your search

@@ -580,6 +580,82 @@ describe("GET /api/documents/inbound (list)", () => {
expect(body.data.nextCursor).toBe("d1");
});
it("narrows to one procedure through the visit link, owner-scoped", async () => {
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
docRow({ id: "d1" }),
] as never);
const res = await get(
new Request("http://localhost/api/documents/inbound?encounterId=enc-1"),
);
expect(res.status).toBe(200);
const where = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0]![0]!
.where!;
// The link filter rides beside the owner and the tombstone predicates, so
// another account's visit id can only ever match nothing of this one.
expect(where).toEqual({
userId: "user-1",
deletedAt: null,
encounterLinks: { some: { encounterId: "enc-1" } },
});
});
it("AND-combines the procedure filter with kind, year and condition", async () => {
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never);
const res = await get(
new Request(
"http://localhost/api/documents/inbound?encounterId=enc-1&kind=IMAGING&year=2025&episodeId=ep-1",
),
);
expect(res.status).toBe(200);
const where = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0]![0]!
.where!;
expect(where.encounterLinks).toEqual({ some: { encounterId: "enc-1" } });
expect(where.conditionLinks).toEqual({ some: { episodeId: "ep-1" } });
expect(where.kind).toEqual({ in: ["IMAGING"] });
expect(where.documentDate).toEqual({
gte: new Date(Date.UTC(2025, 0, 1)),
lt: new Date(Date.UTC(2026, 0, 1)),
});
expect(where.userId).toBe("user-1");
});
it("pages a procedure-filtered list on the keyset cursor", async () => {
const rows = Array.from({ length: 3 }, (_, i) => docRow({ id: `d${i}` }));
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue(rows as never);
const res = await get(
new Request(
"http://localhost/api/documents/inbound?encounterId=enc-1&limit=2&cursor=d9",
),
);
const body = await res.json();
expect(body.data.documents.map((d: { id: string }) => d.id)).toEqual([
"d0",
"d1",
]);
expect(body.data.nextCursor).toBe("d1");
const arg = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0]![0]!;
expect(arg.where!.encounterLinks).toEqual({
some: { encounterId: "enc-1" },
});
expect(arg.cursor).toEqual({ id: "d9" });
expect(arg.skip).toBe(1);
expect(arg.take).toBe(3);
});
it("422s an empty or over-long encounterId", async () => {
let res = await get(
new Request("http://localhost/api/documents/inbound?encounterId="),
);
expect(res.status).toBe(422);
res = await get(
new Request(
`http://localhost/api/documents/inbound?encounterId=${"x".repeat(41)}`,
),
);
expect(res.status).toBe(422);
expect(prisma.inboundDocument.findMany).not.toHaveBeenCalled();
});
it("422s on a bad sort value", async () => {
const res = await get(
new Request("http://localhost/api/documents/inbound?sort=nope"),
@@ -0,0 +1,202 @@
/**
* `GET /api/documents/inbound/usage` — the procedure choices of the vault's
* filter bar.
*
* The reason and body site are ciphertext in the fixture as they are in the
* database, so a route that published the stored bytes, or skipped the
* decrypt, goes red. The fake honours nothing about the `where`, so the test
* asserts the `where` it was handed: a read that forgot the kind, the owner,
* or the live-document condition would otherwise pass against a fixture of
* linked procedures only.
*
* Mutation checks (each run, each seen red):
* - drop `kind: "PROCEDURE"` from the read → "reads only the owner's live
* procedures with a live document" goes red;
* - read the procedures without the `visible("profile")` check → "a grant
* without the visits section gets no procedure" goes red.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const mocks = vi.hoisted(() => ({
encounterFindMany: vi.fn(),
requireRecordAuth: vi.fn(),
visible: vi.fn((_domain: string) => true),
actingDomainVisibility: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: (handler: unknown) => handler,
requireRecordAuth: mocks.requireRecordAuth,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => undefined,
}));
vi.mock("@/lib/db", () => ({
prisma: {
encounter: { findMany: mocks.encounterFindMany },
$queryRaw: vi.fn(async () => [{ used: BigInt(0) }]),
documentContentIndex: { count: vi.fn(async () => 0) },
inboundDocument: { count: vi.fn(async () => 0) },
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
}));
vi.mock("@/lib/documents/upload-policy", () => ({
DOCUMENT_ACCEPTED_EXTENSIONS: [".pdf"],
resolveDocumentLimits: vi.fn(async () => ({
quotaBytes: 1000,
maxFileBytes: 100,
})),
}));
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentAiCapability: vi.fn(async () => ({ available: false })),
}));
vi.mock("@/lib/links", () => ({
listDistinctTargets: vi.fn(async () => []),
listTargetsBySource: vi.fn(async () => new Map()),
replaceTargets: vi.fn(),
}));
vi.mock("@/lib/sharing/acting-domains", () => ({
actingDomainVisibility: mocks.actingDomainVisibility,
}));
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { GET } from "../route";
const handler = GET as unknown as () => Promise<Response>;
beforeEach(() => {
vi.clearAllMocks();
mocks.requireRecordAuth.mockResolvedValue({
user: { id: "owner-1" },
grantId: null,
});
mocks.visible.mockImplementation(() => true);
mocks.actingDomainVisibility.mockImplementation(async () => mocks.visible);
mocks.encounterFindMany.mockResolvedValue([
{
id: "enc-knee",
occurredAt: new Date("2025-10-02T08:00:00Z"),
reasonEncrypted: encryptToBytes("Knee arthroscopy"),
bodySiteEncrypted: encryptToBytes("Knee"),
laterality: "LEFT",
practitioner: null,
},
{
id: "enc-unnamed",
occurredAt: new Date("2024-05-10T08:00:00Z"),
reasonEncrypted: null,
bodySiteEncrypted: null,
laterality: null,
practitioner: { name: "Day clinic" },
},
]);
});
async function read() {
const res = await handler();
expect(res.status).toBe(200);
return (await res.json()).data;
}
describe("GET /api/documents/inbound/usage — linked procedures", () => {
it("publishes the linked procedures with the reason and site decrypted", async () => {
const data = await read();
expect(data.linkedProcedures).toEqual([
{
encounterId: "enc-knee",
occurredAt: "2025-10-02T08:00:00.000Z",
reason: "Knee arthroscopy",
bodySite: "Knee",
laterality: "LEFT",
practitionerName: null,
},
{
encounterId: "enc-unnamed",
occurredAt: "2024-05-10T08:00:00.000Z",
reason: null,
bodySite: null,
laterality: null,
practitionerName: "Day clinic",
},
]);
});
it("reads only the owner's live procedures with a live document, label columns only", async () => {
await read();
expect(mocks.encounterFindMany).toHaveBeenCalledTimes(1);
const arg = mocks.encounterFindMany.mock.calls[0]![0];
expect(arg.where).toEqual({
userId: "owner-1",
deletedAt: null,
kind: "PROCEDURE",
documentLinks: {
some: { userId: "owner-1", document: { deletedAt: null } },
},
});
// No outcome, no practitioner contact fields leave the database.
expect(Object.keys(arg.select).sort()).toEqual([
"bodySiteEncrypted",
"id",
"laterality",
"occurredAt",
"practitioner",
"reasonEncrypted",
]);
expect(arg.select.practitioner).toEqual({ select: { name: true } });
expect(arg.take).toBeGreaterThan(0);
});
it("reads the record owner's procedures for a delegate whose grant covers visits", async () => {
mocks.requireRecordAuth.mockResolvedValue({
user: { id: "owner-1" },
grantId: "grant-1",
});
const data = await read();
expect(mocks.actingDomainVisibility).toHaveBeenCalledWith(
expect.anything(),
"grant-1",
);
expect(mocks.visible).toHaveBeenCalledWith("profile");
expect(data.linkedProcedures).toHaveLength(2);
expect(mocks.encounterFindMany.mock.calls[0]![0].where.userId).toBe(
"owner-1",
);
});
it("a grant without the visits section gets no procedure, and nothing is read", async () => {
mocks.requireRecordAuth.mockResolvedValue({
user: { id: "owner-1" },
grantId: "grant-1",
});
mocks.visible.mockImplementation((domain) => domain !== "profile");
const data = await read();
expect(data.linkedProcedures).toEqual([]);
expect(mocks.encounterFindMany).not.toHaveBeenCalled();
// The positive control: the rest of the payload is still served.
expect(data.quotaBytes).toBe(1000);
expect(data.linkedEpisodes).toEqual([]);
});
it("reads one undecryptable reason as unnamed instead of failing the vault", async () => {
mocks.encounterFindMany.mockResolvedValue([
{
id: "enc-broken",
occurredAt: new Date("2025-01-01T00:00:00Z"),
reasonEncrypted: new Uint8Array([1, 2, 3, 4]),
bodySiteEncrypted: null,
laterality: null,
practitioner: null,
},
]);
const data = await read();
expect(data.linkedProcedures).toEqual([
expect.objectContaining({ encounterId: "enc-broken", reason: null }),
]);
});
});
+58 -33
View File
@@ -15,9 +15,11 @@ import {
resolveDocumentLimits,
} from "@/lib/documents/upload-policy";
import { resolveDocumentAiCapability } from "@/lib/documents/provider-order";
import { loadLinkedProcedures } from "@/lib/documents/links";
import { listDistinctTargets } from "@/lib/links";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
import type { DocumentUsageDto } from "@/lib/validations/inbound-documents";
export const dynamic = "force-dynamic";
@@ -32,53 +34,75 @@ export const dynamic = "force-dynamic";
* credential, endpoint or token crosses the wire, and every AI action it would
* gate stays refused under a switch.
*
* The procedure choices are the one part that reads past the documents
* section: their labels are the visit's own reason and body site, which belong
* to the visits section (`("read", "profile")`, the procedure history's own
* declaration). A grant that does not cover it gets an empty list, and the
* filter bar renders no procedure control — the same shape an owner with no
* filed procedure sees.
*
* No write arm exists on this route.
*/
export const GET = apiHandler(async () => {
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;
const [limits, rows, linkRows, capability, indexedCount, totalCount] =
await Promise.all([
resolveDocumentLimits(user.id),
prisma.$queryRaw<Array<{ used: bigint }>>`
const visible = await actingDomainVisibility(prisma, grantId);
const [
limits,
rows,
linkRows,
linkedProcedures,
capability,
indexedCount,
totalCount,
] = await Promise.all([
resolveDocumentLimits(user.id),
prisma.$queryRaw<Array<{ used: bigint }>>`
SELECT COALESCE(SUM(byte_size), 0)::bigint AS used
FROM inbound_documents
WHERE user_id = ${user.id}
`,
// Episodes that carry at least one LIVE document link — the filter
// bar's condition chips. Sourced here (not from the loaded corpus) so
// a chip exists even when its documents sit pages deep in the
// timeline; one indexed grouped query, no blobs.
listDistinctTargets(prisma, {
userId: user.id,
sourceKind: "document",
targetKind: "conditionEpisode",
}),
// Whether an AI action can run for this caller (assist + indexing share
// the same provider precondition). Honest availability so the UI never
// offers what the endpoint would refuse: the document provider order and
// the `documentAi` capability for this record, the same answer the
// capability probe gives.
resolveDocumentAiCapability(user.id),
// Content-index coverage: how many live documents are indexed …
// Scope the count to LIVE documents: a soft-deleted document keeps its
// index row (cascade fires on hard purge only), so an unscoped count
// would drift above `totalCount` and read the gauge past 100 %.
prisma.documentContentIndex.count({
where: { userId: user.id, document: { deletedAt: null } },
}),
// … out of how many live documents there are.
prisma.inboundDocument.count({
where: { userId: user.id, deletedAt: null },
}),
]);
// Episodes that carry at least one LIVE document link — the filter
// bar's condition chips. Sourced here (not from the loaded corpus) so
// a chip exists even when its documents sit pages deep in the
// timeline; one indexed grouped query, no blobs.
listDistinctTargets(prisma, {
userId: user.id,
sourceKind: "document",
targetKind: "conditionEpisode",
}),
// Procedures that carry at least one live document link — the filter
// bar's procedure choices, only where the visits section is readable.
visible("profile") ? loadLinkedProcedures(user.id) : Promise.resolve([]),
// Whether an AI action can run for this caller (assist + indexing share
// the same provider precondition). Honest availability so the UI never
// offers what the endpoint would refuse: the document provider order and
// the `documentAi` capability for this record, the same answer the
// capability probe gives.
resolveDocumentAiCapability(user.id),
// Content-index coverage: how many live documents are indexed …
// Scope the count to LIVE documents: a soft-deleted document keeps its
// index row (cascade fires on hard purge only), so an unscoped count
// would drift above `totalCount` and read the gauge past 100 %.
prisma.documentContentIndex.count({
where: { userId: user.id, document: { deletedAt: null } },
}),
// … out of how many live documents there are.
prisma.inboundDocument.count({
where: { userId: user.id, deletedAt: null },
}),
]);
const usedBytes = Number(rows[0]?.used ?? 0);
annotate({
action: { name: "documents.vault.usage" },
meta: { usedBytes, quotaBytes: limits.quotaBytes },
meta: {
usedBytes,
quotaBytes: limits.quotaBytes,
linkedProcedures: linkedProcedures.length,
},
});
const payload: DocumentUsageDto = {
@@ -90,6 +114,7 @@ export const GET = apiHandler(async () => {
episodeId: row.id,
name: row.label,
})),
linkedProcedures,
assistAvailable: capability.available,
contentIndex: {
enabled: capability.available,