mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
feat(documents): filter the archive by procedure or surgery
The vault's filter row gains a procedure facet beside the condition facet. It lists the record's procedures and surgeries that hold at least one live document, headed the way the visits page heads them (the reason, else the practice, else the kind) with the body site and date beneath, and narrows the list through the existing `encounterId` filter. Same control, placement and clear behaviour as the condition facet; the choice rides the URL as `?encounter=`, and an upload dropped into the filtered view is filed against the procedure. The usage read publishes the choices as `linkedProcedures` (additive), decrypting only the label columns, and returns none when a sharing grant does not cover the visits section. On a phone an active facet now gives up label width before the search field does, and the search keeps room for its icon and padding.
This commit is contained in:
1 parent
dc7061634e
commit
0219a85e92
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