mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Merge remote-tracking branch 'origin/feat/documents-procedure-filter' into release/v1.40.2
This commit is contained in:
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 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user