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,
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* The vault filter rail's procedure facet, pinned via single-pass static
* renders (the project's component-test convention; the open-and-pick path is
* the vault e2e). The facet mirrors the condition facet beside it: absent when
* nothing is filed against a procedure, an icon-only trigger on a phone while
* inactive, the procedure's own heading once one is picked, placed between the
* condition and the year, and counted by the one clear control.
*
* Mutation checks (each run, each seen red):
* - render the trigger with no choices → "renders no procedure control"
* goes red;
* - drop the active tint → "shows the picked procedure" goes red.
*/
import { I18nProvider } from "@/lib/i18n/context";
import { DocumentFilterBar } from "../document-filter-bar";
import type { ProcedureFilterChoice } from "../vault-utils";
const KNEE: ProcedureFilterChoice = {
encounterId: "enc-knee",
name: "Knee arthroscopy",
detail: "Knee (left) · 2 Oct 2025",
};
function render({
procedures = [KNEE],
activeEncounterId,
activeCount = 0,
}: {
procedures?: ProcedureFilterChoice[];
activeEncounterId?: string;
activeCount?: number;
} = {}) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentFilterBar
searchValue=""
onSearchChange={() => {}}
searchInputRef={{ current: null }}
activeKinds={new Set()}
onToggleKind={() => {}}
conditionChips={[{ episodeId: "ep-1", name: "Knee" }]}
activeEpisodeId={undefined}
onToggleEpisode={() => {}}
procedureChips={procedures}
activeEncounterId={activeEncounterId}
onToggleEncounter={() => {}}
years={[2025]}
activeYear={undefined}
onToggleYear={() => {}}
activeCount={activeCount}
onClearAll={() => {}}
/>
</I18nProvider>,
);
}
/** The opening tag of the element carrying `data-slot="<slot>"`. */
function triggerTag(html: string, slot: string): string {
const at = html.indexOf(`data-slot="${slot}"`);
expect(at).toBeGreaterThan(-1);
return html.slice(html.lastIndexOf("<", at), html.indexOf(">", at) + 1);
}
describe("<DocumentFilterBar> procedure facet", () => {
it("renders no procedure control when nothing is filed against a procedure", () => {
const html = render({ procedures: [] });
expect(html).not.toContain('data-slot="document-procedure-filter"');
// The positive control: the neighbouring facets still render.
expect(html).toContain('data-slot="document-condition-filter"');
expect(html).toContain('data-slot="document-year-filter"');
});
it("sits between the condition and the year facet on the one row", () => {
const html = render();
const condition = html.indexOf('data-slot="document-condition-filter"');
const procedure = html.indexOf('data-slot="document-procedure-filter"');
const year = html.indexOf('data-slot="document-year-filter"');
expect(condition).toBeGreaterThan(-1);
expect(procedure).toBeGreaterThan(condition);
expect(year).toBeGreaterThan(procedure);
expect(html).toContain("flex flex-nowrap items-center gap-2");
});
it("is an icon-only trigger on a phone while inactive, named for the facet", () => {
const html = render();
const tag = triggerTag(html, "document-procedure-filter");
expect(tag).toContain('aria-label="All procedures"');
expect(tag).not.toContain("border-primary/40");
// Same trigger chrome as the condition facet: the label hides below `sm`.
const conditionTag = triggerTag(html, "document-condition-filter");
expect(tag.match(/class="([^"]*)"/)?.[1]).toBe(
conditionTag.match(/class="([^"]*)"/)?.[1],
);
expect(html).toContain(
'<span class="max-w-28 min-w-0 truncate hidden sm:inline">All procedures</span>',
);
});
it("shows the picked procedure's heading, tinted, and visible on a phone", () => {
const html = render({ activeEncounterId: "enc-knee", activeCount: 1 });
const tag = triggerTag(html, "document-procedure-filter");
expect(tag).toContain('aria-label="Knee arthroscopy"');
expect(tag).toContain("border-primary/40 bg-primary/10 text-foreground");
expect(html).toContain(
'<span class="max-w-28 min-w-0 truncate inline">Knee arthroscopy</span>',
);
// The one clear control appears for it like for any other facet.
expect(html).toContain('aria-label="Clear filters"');
});
it("leaves the trigger at rest for a filtered visit that is not a choice", () => {
// A routine visit reached from its own row filters the list but is not a
// procedure: the facet must not claim it.
const html = render({ activeEncounterId: "enc-routine", activeCount: 1 });
const tag = triggerTag(html, "document-procedure-filter");
expect(tag).toContain('aria-label="All procedures"');
expect(tag).not.toContain("border-primary/40");
});
});
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import {
buildProcedureChoices,
buildTimelineItems,
buildVaultListApiSearch,
classifyUploadFailure,
@@ -457,3 +458,63 @@ describe("documentCardKeyAction", () => {
}
});
});
describe("buildProcedureChoices", () => {
const labels = {
kindName: "Procedure or surgery",
siteText: (site: string | null, side: string | null) =>
site ? (side ? `${site} (${side.toLowerCase()})` : site) : null,
formatDate: (iso: string) => iso.slice(0, 10),
};
const knee = {
encounterId: "enc-knee",
occurredAt: "2025-10-02T08:00:00.000Z",
reason: "Knee arthroscopy",
bodySite: "Knee",
laterality: "LEFT" as const,
practitionerName: "Day clinic",
};
it("heads each choice like the visits page: reason, else practice, else kind", () => {
const choices = buildProcedureChoices(
[
knee,
{ ...knee, encounterId: "enc-2", reason: null, bodySite: null },
{
...knee,
encounterId: "enc-3",
reason: " ",
practitionerName: null,
laterality: null,
},
],
null,
labels,
);
expect(choices).toEqual([
{
encounterId: "enc-knee",
name: "Knee arthroscopy",
detail: "Knee (left) · 2025-10-02",
},
{ encounterId: "enc-2", name: "Day clinic", detail: "2025-10-02" },
{
encounterId: "enc-3",
name: "Procedure or surgery",
detail: "Knee · 2025-10-02",
},
]);
});
it("keeps an active procedure the usage list no longer holds, once", () => {
const gone = { ...knee, encounterId: "enc-gone", reason: "Appendectomy" };
const choices = buildProcedureChoices([knee], gone, labels);
expect(choices.map((c) => c.encounterId)).toEqual(["enc-knee", "enc-gone"]);
// Already listed: not added a second time.
expect(buildProcedureChoices([knee], knee, labels)).toHaveLength(1);
});
it("offers nothing when no procedure carries a document", () => {
expect(buildProcedureChoices([], null, labels)).toEqual([]);
});
});
@@ -1,11 +1,12 @@
"use client";
/**
* The vault's sticky filter rail: debounced title/filename search plus three
* The vault's sticky filter rail: debounced title/filename search plus four
* compact dropdown facets — type (multi-select, OR inside the facet),
* condition (episodes that actually carry links, single-select), and year
* (years present in the corpus, single-select) — and a one-tap clear with
* the active-facet count.
* condition (episodes that actually carry links, single-select), procedure
* (procedures and surgeries that actually carry links, single-select), and
* year (years present in the corpus, single-select) — and a one-tap clear
* with the active-facet count.
*
* The controls sit on ONE row at every width: the search flexes and can
* shrink to nothing while each dropdown trigger stays a fixed compact
@@ -24,6 +25,7 @@ import {
ChevronDown,
ListFilter,
ScanSearch,
Scissors,
Search,
X,
} from "lucide-react";
@@ -43,14 +45,21 @@ import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS, DOCUMENT_KIND_ORDER } from "./document-kind-meta";
import type { ProcedureFilterChoice } from "./vault-utils";
/** Active-facet tint on a dropdown trigger — mirrors the app pill vocabulary. */
const TRIGGER_ACTIVE = "border-primary/40 bg-primary/10 text-foreground";
/**
* Active-facet tint on a dropdown trigger — mirrors the app pill vocabulary.
* An active trigger may also shrink (its label truncates further), so on a
* phone with several facets set the row gives up label width before it gives
* up the search field.
*/
const TRIGGER_ACTIVE =
"border-primary/40 bg-primary/10 text-foreground min-w-0 shrink";
/** Shared trigger chrome: compact, fixed, never shrinks below its label. */
const TRIGGER_CLASSES = "shrink-0 gap-1.5 font-normal";
/**
* The facet label is the whole point of the trigger — but three text labels
* The facet label is the whole point of the trigger — but four text labels
* plus the search will not fit a 360px phone. So the label shows from `sm`
* up, and on a phone only when the facet is ACTIVE (truncated), while the
* leading icon always carries the facet identity. The trailing chevron is
@@ -59,7 +68,10 @@ const TRIGGER_CLASSES = "shrink-0 gap-1.5 font-normal";
* pinned via `aria-label` so an icon-only trigger still announces its state.
*/
function facetLabelClass(active: boolean): string {
return cn("max-w-28 truncate", active ? "inline" : "hidden sm:inline");
return cn(
"max-w-28 min-w-0 truncate",
active ? "inline" : "hidden sm:inline",
);
}
export interface ConditionChip {
@@ -76,6 +88,9 @@ export function DocumentFilterBar({
conditionChips,
activeEpisodeId,
onToggleEpisode,
procedureChips,
activeEncounterId,
onToggleEncounter,
years,
activeYear,
onToggleYear,
@@ -94,6 +109,9 @@ export function DocumentFilterBar({
conditionChips: ConditionChip[];
activeEpisodeId: string | undefined;
onToggleEpisode: (episodeId: string) => void;
procedureChips: ProcedureFilterChoice[];
activeEncounterId: string | undefined;
onToggleEncounter: (encounterId: string) => void;
years: number[];
activeYear: number | undefined;
onToggleYear: (year: number) => void;
@@ -120,6 +138,12 @@ export function DocumentFilterBar({
const conditionLabel =
activeCondition?.name ?? t("documents.filter.conditionAll");
const activeProcedure = procedureChips.find(
(chip) => chip.encounterId === activeEncounterId,
);
const procedureLabel =
activeProcedure?.name ?? t("documents.filter.procedureAll");
const yearLabel =
activeYear !== undefined
? String(activeYear)
@@ -132,10 +156,12 @@ export function DocumentFilterBar({
>
{/* One row at every width: the search flexes (min-w-0 → it can shrink to
nothing), every dropdown trigger stays a fixed compact control, and
the clear pins to the trailing edge. `flex-nowrap` guarantees the bar
the clear pins to the trailing edge. The search keeps at least its
icon plus its padding (the `/` hint and its padding are desktop
only), so it never bleeds under the next control. `flex-nowrap` guarantees the bar
never breaks to a second line. */}
<div className="flex flex-nowrap items-center gap-2">
<div className="relative min-w-0 flex-1">
<div className="relative min-w-12 flex-1">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden
@@ -147,7 +173,7 @@ export function DocumentFilterBar({
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("documents.filter.searchPlaceholder")}
aria-label={t("documents.filter.searchLabel")}
className="pr-8 pl-9"
className="min-w-0 pr-3 pl-9 sm:pr-8"
aria-keyshortcuts="/"
/>
<kbd
@@ -252,6 +278,58 @@ export function DocumentFilterBar({
</DropdownMenu>
) : null}
{/* Procedure — single-select; only rendered when procedures carry
links. Same cadence as the condition facet beside it. */}
{procedureChips.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
data-slot="document-procedure-filter"
aria-label={procedureLabel}
className={cn(
TRIGGER_CLASSES,
activeProcedure && TRIGGER_ACTIVE,
)}
>
<Scissors className="size-4 shrink-0" aria-hidden />
<span className={facetLabelClass(Boolean(activeProcedure))}>
{procedureLabel}
</span>
<ChevronDown
className="hidden size-3.5 opacity-60 sm:inline"
aria-hidden
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
aria-label={t("documents.filter.procedureGroup")}
>
<DropdownMenuLabel>
{t("documents.filter.procedureGroup")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{procedureChips.map((chip) => (
<DropdownMenuCheckboxItem
key={chip.encounterId}
checked={chip.encounterId === activeEncounterId}
onCheckedChange={() => onToggleEncounter(chip.encounterId)}
>
<span className="flex min-w-0 flex-col">
<span className="max-w-56 truncate">{chip.name}</span>
<span className="text-muted-foreground max-w-56 truncate text-xs">
{chip.detail}
</span>
</span>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{/* Year — single-select; only rendered when the corpus spans years. */}
{years.length > 0 ? (
<DropdownMenu>
+63 -4
View File
@@ -3,7 +3,7 @@
/**
* The Dokumente vault — one wide, calm, filing-cabinet-fast surface.
*
* Filter state lives in the URL (`?q&kind&episode&year`) so every view is
* Filter state lives in the URL (`?q&kind&episode&encounter&year`) so every view is
* shareable, back-button-safe, and deep-linkable from the illness page and
* labs. Search debounces 200 ms and `/` focuses it. The timeline below is
* virtualized; uploads appear optimistically above it in < 100 ms.
@@ -44,7 +44,10 @@ import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { useUrlFilterSync } from "@/hooks/use-url-filter-sync";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { encounterKindText } from "@/components/encounters/encounter-labels";
import {
bodySiteText,
encounterKindText,
} from "@/components/encounters/encounter-labels";
import { useEncounters } from "@/hooks/use-encounters";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { EncounterKind } from "@/generated/prisma/client";
@@ -73,12 +76,14 @@ import { useReindexAll } from "./use-content-index";
import { useDocumentUpload } from "./use-document-upload";
import { usePageFileDrop } from "./use-page-file-drop";
import {
buildProcedureChoices,
buildVaultListApiSearch,
countActiveFilters,
documentDateKey,
expandRangeSelection,
hasProcessingDocument,
parseVaultSearchParams,
type ProcedureFilterChoice,
resolveBulkShareDocuments,
SHARE_LINK_MAX_DOCUMENTS,
vaultFiltersToSearch,
@@ -345,6 +350,17 @@ export function DocumentsView() {
);
};
const toggleEncounter = (encounterId: string) => {
applyFilters(
{
...filters,
encounterId:
filters.encounterId === encounterId ? undefined : encounterId,
},
"push",
);
};
const toggleYear = (year: number) => {
applyFilters(
{ ...filters, year: filters.year === year ? undefined : year },
@@ -418,15 +434,19 @@ export function DocumentsView() {
enqueueRef.current = upload.enqueue;
}, [upload.enqueue]);
const episodeIdFilter = filters.episodeId;
const encounterIdFilter = filters.encounterId;
const enqueueFiles = useCallback(
(files: File[]) => {
if (files.length === 0) return;
enqueueRef.current(files, { episodeId: episodeIdFilter });
enqueueRef.current(files, {
episodeId: episodeIdFilter,
encounterId: encounterIdFilter,
});
setUploadAnnouncement(
t("documents.upload.queuedAnnouncement", { count: files.length }),
);
},
[episodeIdFilter, t],
[episodeIdFilter, encounterIdFilter, t],
);
const { dropActive } = usePageFileDrop(
canManageDocuments ? enqueueFiles : undefined,
@@ -504,6 +524,42 @@ export function DocumentsView() {
[visits.data, t, format],
);
// Procedure choices: every procedure carrying at least one live document
// link, served by the usage endpoint for the same reason as the condition
// chips, plus the actively filtered visit when it is a procedure (see
// `buildProcedureChoices`).
const procedureChips = useMemo<ProcedureFilterChoice[]>(() => {
const activeVisit = filters.encounterId
? [...(visits.data?.upcoming ?? []), ...(visits.data?.past ?? [])].find(
(v) => v.id === filters.encounterId && v.kind === "PROCEDURE",
)
: undefined;
return buildProcedureChoices(
usage.data?.linkedProcedures ?? [],
activeVisit
? {
encounterId: activeVisit.id,
occurredAt: activeVisit.occurredAt,
reason: activeVisit.reason,
bodySite: activeVisit.bodySite,
laterality: activeVisit.laterality,
practitionerName: activeVisit.practitioner?.name ?? null,
}
: null,
{
kindName: encounterKindText(t, "PROCEDURE"),
siteText: (site, laterality) => bodySiteText(t, site, laterality),
formatDate: (iso) => format.date(iso),
},
);
}, [
usage.data?.linkedProcedures,
filters.encounterId,
visits.data,
t,
format,
]);
// Year segmenter: years present in the loaded corpus (+ the active year).
const years = useMemo(() => {
const set = new Set<number>();
@@ -921,6 +977,9 @@ export function DocumentsView() {
conditionChips={conditionChips}
activeEpisodeId={filters.episodeId}
onToggleEpisode={toggleEpisode}
procedureChips={procedureChips}
activeEncounterId={filters.encounterId}
onToggleEncounter={toggleEncounter}
years={years}
activeYear={filters.year}
onToggleYear={toggleYear}
@@ -59,6 +59,11 @@ export interface UploadQueueItem {
export interface EnqueueOptions {
/** Pre-link every uploaded document to this episode (deep-link uploads). */
episodeId?: string;
/**
* Pre-link every uploaded document to this visit — the active procedure
* filter, so a file dropped into a filtered view stays in it.
*/
encounterId?: string;
}
interface UploadLimits {
@@ -139,6 +144,7 @@ export function uploadViaXhr(
const fd = new FormData();
fd.append("file", file);
if (options.episodeId) fd.append("episodeIds", options.episodeId);
if (options.encounterId) fd.append("encounterIds", options.encounterId);
xhr.send(fd);
});
}
+51
View File
@@ -10,6 +10,7 @@
import {
DOCUMENT_LIST_DEFAULT_LIMIT,
INBOUND_DOCUMENT_KINDS,
type DocumentLinkedProcedureDto,
type InboundDocumentDto,
type InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
@@ -455,3 +456,53 @@ export function documentCardKeyAction(key: string): DocumentCardKeyAction {
if (key === "Delete") return "delete";
return null;
}
/** One procedure choice in the filter bar, labelled for the reader. */
export interface ProcedureFilterChoice {
encounterId: string;
/** The heading the visits page shows for it. */
name: string;
/** Body site and date, the line that tells two same-named ones apart. */
detail: string;
}
/**
* The procedure filter's choices: the usage read's linked procedures, plus the
* actively filtered visit when it is a procedure that list does not hold (its
* last link was just removed, or a deep link names it). A filtered visit that
* is not a procedure adds nothing — it is not a procedure choice.
*
* Each is labelled the way the visits page heads the row: the reason, else the
* practice, else the kind's name. The body site (with its side) and the date
* ride the second line. Order is the server's, newest first, with the
* appended active visit last.
*/
export function buildProcedureChoices(
linked: readonly DocumentLinkedProcedureDto[],
active: DocumentLinkedProcedureDto | null,
labels: {
kindName: string;
siteText: (
bodySite: string | null,
laterality: DocumentLinkedProcedureDto["laterality"],
) => string | null;
formatDate: (iso: string) => string;
},
): ProcedureFilterChoice[] {
const sources = [...linked];
if (active && !sources.some((p) => p.encounterId === active.encounterId)) {
sources.push(active);
}
return sources.map((procedure) => {
const site = labels.siteText(procedure.bodySite, procedure.laterality);
const date = labels.formatDate(procedure.occurredAt);
return {
encounterId: procedure.encounterId,
name:
procedure.reason?.trim() ||
procedure.practitionerName?.trim() ||
labels.kindName,
detail: site ? `${site} · ${date}` : date,
};
});
}
+47
View File
@@ -7,10 +7,13 @@
*/
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { decryptField } from "@/lib/encounters/dto";
import { MAX_PROCEDURES } from "@/lib/encounters/procedures";
import { listTargetsBySource, replaceTargets } from "@/lib/links";
import type {
DocumentConditionLinkDto,
DocumentEncounterLinkDto,
DocumentLinkedProcedureDto,
DocumentVaccinationLinkDto,
} from "@/lib/validations/inbound-documents";
import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog";
@@ -77,6 +80,50 @@ export async function loadDocumentEncounterLinks(
return map;
}
/**
* The record's procedures and surgeries that hold at least one LIVE document
* link, newest first — the vault filter bar's procedure choices.
*
* Every live PROCEDURE visit counts whatever its status: a pre-op letter filed
* against a booked operation belongs to it as much as the discharge letter
* belongs to one that happened. Tombstoned visits and tombstoned documents
* drop out, the same honest default `loadDocumentEncounterLinks` uses. The
* reason and body site are ciphertext at rest and decrypt through the visit
* DTO's fail-soft path, so one row with a key gap reads as unnamed rather than
* failing the usage read. Only the label's columns are selected. Bounded like the procedure history itself.
*/
export async function loadLinkedProcedures(
userId: string,
): Promise<DocumentLinkedProcedureDto[]> {
const rows = await prisma.encounter.findMany({
where: {
userId,
deletedAt: null,
kind: "PROCEDURE",
documentLinks: { some: { userId, document: { deletedAt: null } } },
},
orderBy: [{ occurredAt: "desc" }, { id: "asc" }],
take: MAX_PROCEDURES,
// Only what the label needs: no outcome, no practitioner contact fields.
select: {
id: true,
occurredAt: true,
reasonEncrypted: true,
bodySiteEncrypted: true,
laterality: true,
practitioner: { select: { name: true } },
},
});
return rows.map((row) => ({
encounterId: row.id,
occurredAt: row.occurredAt.toISOString(),
reason: decryptField(row.reasonEncrypted, "reason"),
bodySite: decryptField(row.bodySiteEncrypted, "bodySite"),
laterality: row.laterality,
practitionerName: row.practitioner?.name ?? null,
}));
}
/**
* Narrow a client-sent visit-id list to the caller's LIVE visits.
*
+1 -1
View File
@@ -97,7 +97,7 @@ export interface EncounterDTO {
updatedAt: string;
}
function decryptField(
export function decryptField(
value: Uint8Array | null,
field: "reason" | "outcome" | "bodySite",
): string | null {
+15 -2
View File
@@ -379,7 +379,8 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
in: "query",
required: false,
schema: { type: "string" },
description: "Only documents linked to this visit.",
description:
"Only documents linked to this visit. The procedure filter sends a `linkedProcedures` id from the usage read; an id that is not one of the caller's visits matches nothing.",
},
{
name: "year",
@@ -665,7 +666,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Documents"],
summary: "Storage usage + effective limits",
description:
"The caller's vault usage and effective limits: `usedBytes` (every non-purged row — tombstones inside the 30-day undo grace still count), `quotaBytes` (per-user override ?? instance default), `maxFileBytes` (admin-tunable per-file cap), `acceptedExtensions` for picker `accept` lists, and `linkedEpisodes` (episodes carrying at least one live document link — the condition-filter chips). Read this before offering an upload.",
"The caller's vault usage and effective limits: `usedBytes` (every non-purged row — tombstones inside the 30-day undo grace still count), `quotaBytes` (per-user override ?? instance default), `maxFileBytes` (admin-tunable per-file cap), `acceptedExtensions` for picker `accept` lists, `linkedEpisodes` (episodes carrying at least one live document link — the condition-filter chips), and `linkedProcedures` (visits of kind PROCEDURE carrying at least one live document link, newest first — the procedure-filter choices; pass one's `encounterId` to the list's `encounterId` filter). `linkedProcedures` is empty when a sharing grant does not cover the visits section. Read this before offering an upload.",
responses: {
...recordRefusal(),
"200": {
@@ -685,6 +686,18 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
name: z.string(),
}),
),
linkedProcedures: z.array(
z.object({
encounterId: z.string(),
occurredAt: z.iso.datetime({ offset: true }),
reason: z.string().nullable(),
bodySite: z.string().nullable(),
laterality: z
.enum(["LEFT", "RIGHT", "BOTH"])
.nullable(),
practitionerName: z.string().nullable(),
}),
),
assistAvailable: z
.boolean()
.describe(
+23
View File
@@ -349,6 +349,21 @@ export interface DocumentConditionLinkDto {
name: string;
}
/**
* One procedure the vault can be filtered by. The visit's own words, decrypted
* server-side; the client composes the label the visits page shows (the
* reason, else the practice, else the kind) in the reader's locale.
*/
export interface DocumentLinkedProcedureDto {
encounterId: string;
/** ISO-8601 instant of the procedure. */
occurredAt: string;
reason: string | null;
bodySite: string | null;
laterality: "LEFT" | "RIGHT" | "BOTH" | null;
practitionerName: string | null;
}
/**
* One visit link on a document DTO — "this letter belongs to that appointment".
*
@@ -958,6 +973,14 @@ export interface DocumentUsageDto {
* every linked document sits pages deep in the timeline.
*/
linkedEpisodes: DocumentConditionLinkDto[];
/**
* Procedures and surgeries (visits of kind PROCEDURE) carrying at least one
* LIVE document link — the filter bar's procedure choices, sourced
* server-side for the same reason as `linkedEpisodes`. Empty when the
* caller's grant does not cover the visits section, because the label is
* the visit's own text.
*/
linkedProcedures: DocumentLinkedProcedureDto[];
/**
* Whether the AI "Suggest details" action can run for this caller — true when
* a provider (vision, or text + local OCR) is configured. The UI hides the