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

+47 -3
View File
@@ -16410,7 +16410,8 @@ paths:
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
in: query
required: false
@@ -16744,8 +16745,11 @@ paths:
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."
(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:
"200":
description: Usage + limits.
@@ -68514,6 +68518,45 @@ components:
- episodeId
- name
additionalProperties: false
linkedProcedures:
type: array
items:
type: object
properties:
encounterId:
type: string
occurredAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
reason:
anyOf:
- type: string
- type: "null"
bodySite:
anyOf:
- type: string
- type: "null"
laterality:
anyOf:
- type: string
enum:
- LEFT
- RIGHT
- BOTH
- type: "null"
practitionerName:
anyOf:
- type: string
- type: "null"
required:
- encounterId
- occurredAt
- reason
- bodySite
- laterality
- practitionerName
additionalProperties: false
assistAvailable:
type: boolean
description: "Whether a document read can run for this record now: a provider can serve it and the `documentAi`
@@ -68539,6 +68582,7 @@ components:
- maxFileBytes
- acceptedExtensions
- linkedEpisodes
- linkedProcedures
- assistAvailable
- contentIndex
additionalProperties: false
+131
View File
@@ -0,0 +1,131 @@
/**
* Document vault — the procedure filter.
*
* The facet beside the condition filter: it lists the procedures that hold a
* document, labelled the way the visits page heads them, narrows the list to
* one procedure's documents through the real `encounterId` filter, writes the
* choice to the URL (`?encounter=`), comes back from a deep link, and clears
* with the one clear control. Run at a 1440 px desktop and a 390 px phone,
* where the filter row must stay a single line with no sideways scroll.
*
* Fixture: `ensureProcedureFixture` (two procedures over a namespaced trio of
* documents), upserted before every test because the visits spec clears the
* account's visits; the dates sit far outside that spec's ±7-day window.
*/
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
ensureProcedureFixture,
ensureVaultFixture,
PROCEDURE_DOC_PREFIX,
PROCEDURE_KNEE_ID,
} from "./setup/vault-fixture";
function card(page: Page, n: number) {
return page
.getByRole("button", {
name: `Open ${PROCEDURE_DOC_PREFIX} ${String(n).padStart(3, "0")}`,
})
.first();
}
/** The filter row is one line and never scrolls sideways. */
async function expectOneRow(page: Page) {
const bar = page.locator('[data-slot="document-filter-bar"]');
const row = bar.locator("> div").first();
const box = await row.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).toBeLessThan(48);
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
}
async function shot(page: Page, name: string, isMobile: boolean) {
const dir = process.env.E2E_SHOT_DIR;
if (!dir) return;
await page.screenshot({
path: `${dir}/${name}-${isMobile ? "390" : "1440"}.png`,
animations: "disabled",
});
}
test.describe("document vault — procedure filter", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeAll(async () => {
await ensureVaultFixture();
});
test.beforeEach(async ({ page, isMobile }) => {
await ensureProcedureFixture();
await page.setViewportSize(
isMobile ? { width: 390, height: 844 } : { width: 1440, height: 900 },
);
});
test("picks a procedure, narrows to its documents, and clears", async ({
page,
isMobile,
}) => {
await page.goto(`/documents?q=${PROCEDURE_DOC_PREFIX}`);
await expect(card(page, 3)).toBeVisible();
await expect(card(page, 1)).toBeVisible();
const trigger = page.locator('[data-slot="document-procedure-filter"]');
await expect(trigger).toBeVisible();
await expect(trigger).toHaveAttribute("aria-label", "All procedures");
await expectOneRow(page);
await shot(page, "procedure-filter-idle", isMobile);
await trigger.click();
const menu = page.getByRole("menu");
await expect(menu).toBeVisible();
await expect(menu).toContainText("Procedures and surgeries");
// Headed like the visits page, with the body site and side beneath.
const knee = menu.getByRole("menuitemcheckbox", {
name: /Knee arthroscopy/,
});
await expect(knee).toContainText("Knee · Left");
await expect(
menu.getByRole("menuitemcheckbox", { name: /Appendectomy/ }),
).toBeVisible();
await shot(page, "procedure-filter-menu", isMobile);
await knee.click();
await expect(page).toHaveURL(new RegExp(`encounter=${PROCEDURE_KNEE_ID}`));
await expect(card(page, 1)).toBeVisible();
await expect(card(page, 2)).toBeVisible();
await expect(card(page, 3)).toHaveCount(0);
await expect(trigger).toHaveAttribute("aria-label", "Knee arthroscopy");
// Picked: the label shows at every width, phone included.
await expect(trigger).toContainText("Knee arthroscopy");
await expectOneRow(page);
// The search field never collapses under the facets.
const search = await page
.getByRole("searchbox", { name: "Search documents" })
.boundingBox();
expect(search!.width).toBeGreaterThanOrEqual(48);
await shot(page, "procedure-filter-active", isMobile);
await page.getByRole("button", { name: "Clear filters" }).click();
await expect(page).not.toHaveURL(/encounter=/);
await expect(trigger).toHaveAttribute("aria-label", "All procedures");
});
test("a deep link lands filtered and the facet names the procedure", async ({
page,
}) => {
await page.goto(
`/documents?q=${PROCEDURE_DOC_PREFIX}&encounter=${PROCEDURE_KNEE_ID}`,
);
await expect(card(page, 2)).toBeVisible();
await expect(card(page, 3)).toHaveCount(0);
await expect(
page.locator('[data-slot="document-procedure-filter"]'),
).toHaveAttribute("aria-label", "Knee arthroscopy");
});
});
+75
View File
@@ -550,6 +550,81 @@ export async function seedNamespaceDocs(
}
}
/** The procedure-filter fixture: two procedures, each with its own files. */
export const PROCEDURE_KNEE_ID = "e2eprocknee0000000000001";
export const PROCEDURE_APPENDIX_ID = "e2eprocappx0000000000001";
export const PROCEDURE_DOC_PREFIX = "procfilter";
/**
* Seed the procedure-filter fixture: three namespaced documents and two
* procedures — a knee arthroscopy (reason + body site + side) holding two of
* them, and an appendectomy holding the third. Dated far from the MRT report
* so the visits spec's ±7-day window never sees them. Idempotent: the rows
* upsert by fixed id and come back live, so a `resetEncounters` from the visits
* spec between runs is repaired by the next call.
*/
export async function ensureProcedureFixture(): Promise<void> {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
await seedNamespaceDocs(PROCEDURE_DOC_PREFIX, 3);
const pool = new pg.Pool({ connectionString: url });
try {
const userId = await getUserId(pool);
const procedures = [
{
id: PROCEDURE_KNEE_ID,
at: "2024-03-12T08:00:00Z",
reason: "Knee arthroscopy",
site: "Knee",
side: "LEFT",
docs: [0, 1],
},
{
id: PROCEDURE_APPENDIX_ID,
at: "2023-06-20T08:00:00Z",
reason: "Appendectomy",
site: null,
side: null,
docs: [2],
},
];
for (const p of procedures) {
await pool.query(
`INSERT INTO encounters
(id, user_id, occurred_at, status, kind, reason_encrypted,
body_site_encrypted, laterality, created_at, updated_at)
VALUES ($1, $2, $3, 'DONE', 'PROCEDURE', $4, $5, $6::laterality, NOW(), NOW())
ON CONFLICT (id) DO UPDATE SET
occurred_at = EXCLUDED.occurred_at, kind = 'PROCEDURE',
status = 'DONE', reason_encrypted = EXCLUDED.reason_encrypted,
body_site_encrypted = EXCLUDED.body_site_encrypted,
laterality = EXCLUDED.laterality, deleted_at = NULL,
updated_at = NOW()`,
[
p.id,
userId,
p.at,
encryptStringToBytes(p.reason),
p.site ? encryptStringToBytes(p.site) : null,
p.side,
],
);
for (const n of p.docs) {
const docId = `e2e${PROCEDURE_DOC_PREFIX}${String(n).padStart(8, "0")}`;
await pool.query(
`INSERT INTO encounter_document_links
(id, encounter_id, document_id, user_id, created_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (encounter_id, document_id) DO NOTHING`,
[`${p.id.slice(0, 16)}lnk${n}`, p.id, docId, userId],
);
}
}
} finally {
await pool.end();
}
}
/**
* v1.28 — seed the clinician-share document trio (see the `SHARE_*_DOC_ID`
* constants). Enables the vault module for the e2e user and upserts one
+2
View File
@@ -10450,11 +10450,13 @@
"searchPlaceholder": "Titel oder Dateiname suchen…",
"typeGroup": "Dokumenttyp",
"conditionGroup": "Erkrankung",
"procedureGroup": "Eingriffe und Operationen",
"yearGroup": "Jahr",
"clear": "Filter zurücksetzen",
"typeAll": "Alle Typen",
"typeCount": "{count} Typen",
"conditionAll": "Alle Erkrankungen",
"procedureAll": "Alle Eingriffe",
"yearAll": "Alle Jahre"
},
"card": {
+2
View File
@@ -10450,11 +10450,13 @@
"searchPlaceholder": "Search title or filename…",
"typeGroup": "Document type",
"conditionGroup": "Condition",
"procedureGroup": "Procedures and surgeries",
"yearGroup": "Year",
"clear": "Clear filters",
"typeAll": "All types",
"typeCount": "{count} types",
"conditionAll": "All conditions",
"procedureAll": "All procedures",
"yearAll": "All years"
},
"card": {
+2
View File
@@ -10450,11 +10450,13 @@
"searchPlaceholder": "Buscar por título o nombre de archivo…",
"typeGroup": "Tipo de documento",
"conditionGroup": "Afección",
"procedureGroup": "Procedimientos y cirugías",
"yearGroup": "Año",
"clear": "Borrar filtros",
"typeAll": "Todos los tipos",
"typeCount": "{count} tipos",
"conditionAll": "Todas las afecciones",
"procedureAll": "Todos los procedimientos",
"yearAll": "Todos los años"
},
"card": {
+2
View File
@@ -10450,11 +10450,13 @@
"searchPlaceholder": "Rechercher un titre ou un nom de fichier…",
"typeGroup": "Type de document",
"conditionGroup": "Affection",
"procedureGroup": "Interventions et chirurgies",
"yearGroup": "Année",
"clear": "Effacer les filtres",
"typeAll": "Tous les types",
"typeCount": "{count} types",
"conditionAll": "Toutes les affections",
"procedureAll": "Toutes les interventions",
"yearAll": "Toutes les années"
},
"card": {
+2
View File
@@ -10450,11 +10450,13 @@
"searchPlaceholder": "Cerca titolo o nome file…",
"typeGroup": "Tipo di documento",
"conditionGroup": "Patologia",
"procedureGroup": "Procedure e interventi chirurgici",
"yearGroup": "Anno",
"clear": "Cancella filtri",
"typeAll": "Tutti i tipi",
"typeCount": "{count} tipi",
"conditionAll": "Tutte le patologie",
"procedureAll": "Tutte le procedure",
"yearAll": "Tutti gli anni"
},
"card": {
+2
View File
@@ -10450,11 +10450,13 @@
"searchPlaceholder": "제목이나 파일명 검색…",
"typeGroup": "문서 종류",
"conditionGroup": "질환",
"procedureGroup": "시술 및 수술",
"yearGroup": "연도",
"clear": "필터 해제",
"typeAll": "모든 종류",
"typeCount": "{count}개 종류",
"conditionAll": "모든 질환",
"procedureAll": "모든 시술",
"yearAll": "모든 연도"
},
"card": {
+2
View File
@@ -10450,11 +10450,13 @@
"searchPlaceholder": "Szukaj tytułu lub nazwy pliku…",
"typeGroup": "Typ dokumentu",
"conditionGroup": "Schorzenie",
"procedureGroup": "Zabiegi i operacje",
"yearGroup": "Rok",
"clear": "Wyczyść filtry",
"typeAll": "Wszystkie typy",
"typeCount": "{count} typy",
"conditionAll": "Wszystkie schorzenia",
"procedureAll": "Wszystkie zabiegi",
"yearAll": "Wszystkie lata"
},
"card": {
@@ -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
+227
View File
@@ -20,6 +20,9 @@
* 8. Bulk endpoint per-id semantics.
* 9. Usage endpoint reflects overrides + tombstone-inclusive usage.
* 10. The purge job hard-deletes only past-grace tombstones.
* 11. The procedure filter: the usage read lists the record's linked
* procedures (and nothing else, and nobody else's), and the list's
* `encounterId` filter narrows, combines and pages.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -28,6 +31,7 @@ process.env.ENCRYPTION_KEY ??=
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
@@ -774,3 +778,226 @@ describe("document vault — the sick note kind", () => {
expect((await others.json()).data.documents).toHaveLength(0);
});
});
describe("document vault — the procedure filter", () => {
it("lists linked procedures in usage and filters, combines and pages by encounterId", async () => {
const user = await seedVaultUser("vault-procedures");
const prisma = getPrismaClient();
const { post, get, del, getUsage } = await routes();
const clinic = await prisma.practitioner.create({
data: { userId: user.id, name: "Day clinic" },
});
const arthroscopy = await prisma.encounter.create({
data: {
userId: user.id,
kind: "PROCEDURE",
status: "DONE",
occurredAt: new Date("2025-10-02T08:00:00Z"),
reasonEncrypted: encryptToBytes("Knee arthroscopy"),
bodySiteEncrypted: encryptToBytes("Knee"),
laterality: "LEFT",
},
});
// No reason: the visits page heads it with the practice.
const unnamed = await prisma.encounter.create({
data: {
userId: user.id,
kind: "PROCEDURE",
status: "DONE",
occurredAt: new Date("2024-05-10T08:00:00Z"),
practitionerId: clinic.id,
},
});
// Not a procedure: linked, but never a procedure choice.
const routine = await prisma.encounter.create({
data: {
userId: user.id,
kind: "ROUTINE",
status: "DONE",
occurredAt: new Date("2025-03-01T08:00:00Z"),
},
});
// A deleted procedure and one whose only document is tombstoned drop out.
const deletedProcedure = await prisma.encounter.create({
data: {
userId: user.id,
kind: "PROCEDURE",
status: "DONE",
occurredAt: new Date("2023-01-10T08:00:00Z"),
deletedAt: new Date(),
},
});
const orphaned = await prisma.encounter.create({
data: {
userId: user.id,
kind: "PROCEDURE",
status: "DONE",
occurredAt: new Date("2022-01-10T08:00:00Z"),
},
});
// Linked to nothing: not a choice either.
await prisma.encounter.create({
data: {
userId: user.id,
kind: "PROCEDURE",
status: "DONE",
occurredAt: new Date("2021-01-10T08:00:00Z"),
},
});
const knee = await prisma.illnessEpisode.create({
data: {
userId: user.id,
label: "Knee",
type: "INJURY",
onsetAt: new Date("2025-09-01T00:00:00Z"),
},
});
const upload = async (
bytes: Buffer,
filename: string,
fields: Record<string, string | string[]>,
) => {
const res = await post(uploadRequest(bytes, filename, fields));
expect(res.status).toBe(201);
return (await res.json()).data.id as string;
};
const imaging = await upload(PDF_SMALL, "mri.pdf", {
kind: "IMAGING",
documentDate: "2025-10-01",
encounterIds: [arthroscopy.id],
episodeIds: [knee.id],
});
const lab = await upload(PNG_1X1, "lab.png", {
kind: "LAB_RESULT",
documentDate: "2024-11-03",
encounterIds: [arthroscopy.id],
});
await upload(Buffer.from("%PDF-1.7\n%unnamed\n%%EOF\n"), "op.pdf", {
kind: "OTHER",
documentDate: "2024-05-10",
encounterIds: [unnamed.id],
});
await upload(Buffer.from("%PDF-1.7\n%routine\n%%EOF\n"), "r.pdf", {
documentDate: "2025-03-01",
encounterIds: [routine.id],
});
await upload(Buffer.from("%PDF-1.7\n%loose\n%%EOF\n"), "loose.pdf", {
documentDate: "2025-06-01",
});
// The deleted procedure's link is written below the API, which refuses a
// tombstoned visit; the orphan's only document is deleted after linking.
const forDeleted = await upload(
Buffer.from("%PDF-1.7\n%deleted\n%%EOF\n"),
"d.pdf",
{},
);
await prisma.encounterDocumentLink.create({
data: {
encounterId: deletedProcedure.id,
documentId: forDeleted,
userId: user.id,
},
});
const forOrphan = await upload(
Buffer.from("%PDF-1.7\n%orphan\n%%EOF\n"),
"o.pdf",
{ encounterIds: [orphaned.id] },
);
await del(
new Request(`http://localhost/api/documents/inbound/${forOrphan}`, {
method: "DELETE",
}),
ctx(forOrphan),
);
// Usage: exactly the two live, linked procedures, newest first, with
// the reason and site decrypted and the practice for the unnamed one.
const usage = (
await (
await getUsage(
new Request("http://localhost/api/documents/inbound/usage"),
)
).json()
).data;
expect(usage.linkedProcedures).toEqual([
{
encounterId: arthroscopy.id,
occurredAt: "2025-10-02T08:00:00.000Z",
reason: "Knee arthroscopy",
bodySite: "Knee",
laterality: "LEFT",
practitionerName: null,
},
{
encounterId: unnamed.id,
occurredAt: "2024-05-10T08:00:00.000Z",
reason: null,
bodySite: null,
laterality: null,
practitionerName: "Day clinic",
},
]);
const list = async (query: string) => {
const res = await get(
new Request(`http://localhost/api/documents/inbound?${query}`),
);
expect(res.status).toBe(200);
return (await res.json()).data as {
documents: { id: string }[];
nextCursor: string | null;
};
};
const ids = (page: { documents: { id: string }[] }) =>
page.documents.map((d) => d.id).sort();
// The filter alone, then AND-combined with kind, year and condition.
expect(ids(await list(`encounterId=${arthroscopy.id}`))).toEqual(
[imaging, lab].sort(),
);
expect(
ids(await list(`encounterId=${arthroscopy.id}&kind=IMAGING`)),
).toEqual([imaging]);
expect(ids(await list(`encounterId=${arthroscopy.id}&year=2024`))).toEqual([
lab,
]);
expect(
ids(await list(`encounterId=${arthroscopy.id}&episodeId=${knee.id}`)),
).toEqual([imaging]);
expect(
ids(await list(`encounterId=${unnamed.id}&episodeId=${knee.id}`)),
).toEqual([]);
// Keyset pagination stays inside the filter: newest filing date first.
const first = await list(`encounterId=${arthroscopy.id}&limit=1`);
expect(first.documents.map((d) => d.id)).toEqual([imaging]);
expect(first.nextCursor).toBe(imaging);
const second = await list(
`encounterId=${arthroscopy.id}&limit=1&cursor=${first.nextCursor}`,
);
expect(second.documents.map((d) => d.id)).toEqual([lab]);
expect(second.nextCursor).toBeNull();
// A tombstoned document never answers the filter.
expect(ids(await list(`encounterId=${orphaned.id}`))).toEqual([]);
// Another account: the owner's procedure id matches nothing of theirs,
// and their usage read lists no procedure of the owner's.
cookieJar.clear();
await seedVaultUser("vault-procedures-stranger");
await upload(Buffer.from("%PDF-1.7\n%stranger\n%%EOF\n"), "s.pdf", {});
const foreign = await list(`encounterId=${arthroscopy.id}`);
expect(foreign.documents).toEqual([]);
expect(foreign.nextCursor).toBeNull();
const strangerUsage = (
await (
await getUsage(
new Request("http://localhost/api/documents/inbound/usage"),
)
).json()
).data;
expect(strangerUsage.linkedProcedures).toEqual([]);
});
});