mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(documents): filter the archive by procedure or surgery
The vault's filter row gains a procedure facet beside the condition facet. It lists the record's procedures and surgeries that hold at least one live document, headed the way the visits page heads them (the reason, else the practice, else the kind) with the body site and date beneath, and narrows the list through the existing `encounterId` filter. Same control, placement and clear behaviour as the condition facet; the choice rides the URL as `?encounter=`, and an upload dropped into the filtered view is filed against the procedure. The usage read publishes the choices as `linkedProcedures` (additive), decrypting only the label columns, and returns none when a sharing grant does not cover the visits section. On a phone an active facet now gives up label width before the search field does, and the search keeps room for its icon and padding.
This commit is contained in:
1 parent
dc7061634e
commit
0219a85e92
24 files changed
+1308
-54
No files matched your search
+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
|
||||
|
||||
@@ -10431,11 +10431,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": {
|
||||
|
||||
@@ -10431,11 +10431,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": {
|
||||
|
||||
@@ -10431,11 +10431,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": {
|
||||
|
||||
@@ -10431,11 +10431,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": {
|
||||
|
||||
@@ -10431,11 +10431,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": {
|
||||
|
||||
@@ -10431,11 +10431,13 @@
|
||||
"searchPlaceholder": "제목이나 파일명 검색…",
|
||||
"typeGroup": "문서 종류",
|
||||
"conditionGroup": "질환",
|
||||
"procedureGroup": "시술 및 수술",
|
||||
"yearGroup": "연도",
|
||||
"clear": "필터 해제",
|
||||
"typeAll": "모든 종류",
|
||||
"typeCount": "{count}개 종류",
|
||||
"conditionAll": "모든 질환",
|
||||
"procedureAll": "모든 시술",
|
||||
"yearAll": "모든 연도"
|
||||
},
|
||||
"card": {
|
||||
|
||||
@@ -10431,11 +10431,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