diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e23328ba3..9d4b4256f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/e2e/documents-procedure-filter.spec.ts b/e2e/documents-procedure-filter.spec.ts new file mode 100644 index 000000000..f3d8a0450 --- /dev/null +++ b/e2e/documents-procedure-filter.spec.ts @@ -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"); + }); +}); diff --git a/e2e/setup/vault-fixture.ts b/e2e/setup/vault-fixture.ts index 79d2e41a1..60ce0b522 100644 --- a/e2e/setup/vault-fixture.ts +++ b/e2e/setup/vault-fixture.ts @@ -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 { + 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 diff --git a/messages/de.json b/messages/de.json index 0ec6c98d7..6c4b616a3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 89c86fb86..1d675b96e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index c68a12575..d27a826f6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 7b7e46fe0..2cc7ec696 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index ea95716c2..9996233b3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index a4c1dcfed..0737b8cd5 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10450,11 +10450,13 @@ "searchPlaceholder": "제목이나 파일명 검색…", "typeGroup": "문서 종류", "conditionGroup": "질환", + "procedureGroup": "시술 및 수술", "yearGroup": "연도", "clear": "필터 해제", "typeAll": "모든 종류", "typeCount": "{count}개 종류", "conditionAll": "모든 질환", + "procedureAll": "모든 시술", "yearAll": "모든 연도" }, "card": { diff --git a/messages/pl.json b/messages/pl.json index bbd6af47f..9b4ce672a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/app/api/documents/inbound/__tests__/route.test.ts b/src/app/api/documents/inbound/__tests__/route.test.ts index a83d87578..81319039f 100644 --- a/src/app/api/documents/inbound/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/__tests__/route.test.ts @@ -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"), diff --git a/src/app/api/documents/inbound/usage/__tests__/route.test.ts b/src/app/api/documents/inbound/usage/__tests__/route.test.ts new file mode 100644 index 000000000..266365777 --- /dev/null +++ b/src/app/api/documents/inbound/usage/__tests__/route.test.ts @@ -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; + +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 }), + ]); + }); +}); diff --git a/src/app/api/documents/inbound/usage/route.ts b/src/app/api/documents/inbound/usage/route.ts index ca2524bb8..74a96dc1c 100644 --- a/src/app/api/documents/inbound/usage/route.ts +++ b/src/app/api/documents/inbound/usage/route.ts @@ -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>` + const visible = await actingDomainVisibility(prisma, grantId); + const [ + limits, + rows, + linkRows, + linkedProcedures, + capability, + indexedCount, + totalCount, + ] = await Promise.all([ + resolveDocumentLimits(user.id), + prisma.$queryRaw>` 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, diff --git a/src/components/documents/__tests__/document-filter-bar.test.tsx b/src/components/documents/__tests__/document-filter-bar.test.tsx new file mode 100644 index 000000000..993ea6d4f --- /dev/null +++ b/src/components/documents/__tests__/document-filter-bar.test.tsx @@ -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( + + {}} + 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={() => {}} + /> + , + ); +} + +/** The opening tag of the element carrying `data-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(" 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( + '', + ); + }); + + 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( + 'Knee arthroscopy', + ); + // 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"); + }); +}); diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index fff5fc7bf..ad9ddb2e9 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -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([]); + }); +}); diff --git a/src/components/documents/document-filter-bar.tsx b/src/components/documents/document-filter-bar.tsx index 9febb0244..2d21d72fe 100644 --- a/src/components/documents/document-filter-bar.tsx +++ b/src/components/documents/document-filter-bar.tsx @@ -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. */}
-
+
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="/" /> ) : null} + {/* Procedure — single-select; only rendered when procedures carry + links. Same cadence as the condition facet beside it. */} + {procedureChips.length > 0 ? ( + + + + + + + {t("documents.filter.procedureGroup")} + + + {procedureChips.map((chip) => ( + onToggleEncounter(chip.encounterId)} + > + + {chip.name} + + {chip.detail} + + + + ))} + + + ) : null} + {/* Year — single-select; only rendered when the corpus spans years. */} {years.length > 0 ? ( diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index f35c9c7c3..bc4ad5be6 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -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(() => { + 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(); @@ -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} diff --git a/src/components/documents/use-document-upload.ts b/src/components/documents/use-document-upload.ts index d58919ef2..bd7a9e1c7 100644 --- a/src/components/documents/use-document-upload.ts +++ b/src/components/documents/use-document-upload.ts @@ -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); }); } diff --git a/src/components/documents/vault-utils.ts b/src/components/documents/vault-utils.ts index 10cfabe53..2c363d9bf 100644 --- a/src/components/documents/vault-utils.ts +++ b/src/components/documents/vault-utils.ts @@ -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, + }; + }); +} diff --git a/src/lib/documents/links.ts b/src/lib/documents/links.ts index 70e028ed4..92d26419c 100644 --- a/src/lib/documents/links.ts +++ b/src/lib/documents/links.ts @@ -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 { + 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. * diff --git a/src/lib/encounters/dto.ts b/src/lib/encounters/dto.ts index d2fed17d2..f12c18d0d 100644 --- a/src/lib/encounters/dto.ts +++ b/src/lib/encounters/dto.ts @@ -97,7 +97,7 @@ export interface EncounterDTO { updatedAt: string; } -function decryptField( +export function decryptField( value: Uint8Array | null, field: "reason" | "outcome" | "bodySite", ): string | null { diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 4d413fd4f..1c585fa2d 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -379,7 +379,8 @@ export const inboundDocumentPaths: NonNullable = { 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 = { 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 = { 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( diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index a787761f5..1768dfe99 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -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 diff --git a/tests/integration/documents-vault.test.ts b/tests/integration/documents-vault.test.ts index f9e0d8a7f..54abfc2aa 100644 --- a/tests/integration/documents-vault.test.ts +++ b/tests/integration/documents-vault.test.ts @@ -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, + ) => { + 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([]); + }); +});