mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge remote-tracking branch 'origin/feat/documents-procedure-filter' into release/v1.40.2
This commit is contained in:
commit
03c43ef70a
24 files changed
+1308
-54
No files matched your search
+47
-3
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -10450,11 +10450,13 @@
|
||||
"searchPlaceholder": "제목이나 파일명 검색…",
|
||||
"typeGroup": "문서 종류",
|
||||
"conditionGroup": "질환",
|
||||
"procedureGroup": "시술 및 수술",
|
||||
"yearGroup": "연도",
|
||||
"clear": "필터 해제",
|
||||
"typeAll": "모든 종류",
|
||||
"typeCount": "{count}개 종류",
|
||||
"conditionAll": "모든 질환",
|
||||
"procedureAll": "모든 시술",
|
||||
"yearAll": "모든 연도"
|
||||
},
|
||||
"card": {
|
||||
|
||||
@@ -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 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -97,7 +97,7 @@ export interface EncounterDTO {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function decryptField(
|
||||
export function decryptField(
|
||||
value: Uint8Array | null,
|
||||
field: "reason" | "outcome" | "bodySite",
|
||||
): string | null {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user