From 37e9d933e2e28475bfeef96358a04495b60fe0ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 12:25:45 +0200 Subject: [PATCH] feat(documents): vault list contract on the client URL-backed facets (q / kind / episode / year) parse and serialise through one pure helper pair so deep links round-trip byte-stable; the API search builder pins the timeline sort. Month bucketing + row chunking feed the virtualized grid as a flat item list. The upload-response parser owns the duplicate contract: HTTP 200 with envelope-level meta.duplicate returns the existing row, every 413/415 reason maps to a translated-copy class, and proxy-stripped bodies fall back to the HTTP status. Query keys move to the vault filter shape and gain the usage read. Plan-Task: W2-T1 (URL state), W2-T5 (keys) --- .../documents/__tests__/vault-utils.test.ts | 254 +++++++++++++++ .../documents/document-kind-meta.ts | 48 +++ src/components/documents/vault-utils.ts | 305 ++++++++++++++++++ src/lib/query-keys/documents.ts | 53 +-- 4 files changed, 636 insertions(+), 24 deletions(-) create mode 100644 src/components/documents/__tests__/vault-utils.test.ts create mode 100644 src/components/documents/document-kind-meta.ts create mode 100644 src/components/documents/vault-utils.ts diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts new file mode 100644 index 000000000..224b03cae --- /dev/null +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from "vitest"; + +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; +import { + buildTimelineItems, + buildVaultListApiSearch, + classifyUploadFailure, + countActiveFilters, + documentDateKey, + formatBytes, + formatMonthLabel, + parseUploadResponse, + parseVaultSearchParams, + vaultFiltersToSearch, +} from "../vault-utils"; + +function doc(overrides: Partial = {}): InboundDocumentDto { + return { + id: "doc-1", + kind: "DOCTOR_REPORT", + title: null, + filename: "letter.pdf", + mimeType: "application/pdf", + byteSize: 1024, + status: "STORED", + providerType: null, + reportDate: null, + documentDate: "2026-03-10", + errorReason: null, + factCount: 0, + pendingCount: 0, + conditionLinks: [], + servingClass: "inline", + createdAt: "2026-03-11T08:00:00.000Z", + updatedAt: "2026-03-11T08:00:00.000Z", + ...overrides, + }; +} + +describe("URL facet round-trip", () => { + it("parses q / kind / episode / year and serialises back identically", () => { + const search = "q=MRT&kind=IMAGING%2CLAB_RESULT&episode=ep1&year=2025"; + const filters = parseVaultSearchParams(new URLSearchParams(search)); + expect(filters).toEqual({ + q: "MRT", + kinds: ["IMAGING", "LAB_RESULT"], + episodeId: "ep1", + year: 2025, + }); + expect(vaultFiltersToSearch(filters)).toBe( + "q=MRT&kind=IMAGING%2CLAB_RESULT&episode=ep1&year=2025", + ); + }); + + it("accepts repeated kind params and drops unknown kinds", () => { + const sp = new URLSearchParams(); + sp.append("kind", "IMAGING"); + sp.append("kind", "NOT_A_KIND"); + sp.append("kind", "IMAGING"); // duplicate collapses + const filters = parseVaultSearchParams(sp); + expect(filters.kinds).toEqual(["IMAGING"]); + }); + + it("normalises kind order so the cache key is stable", () => { + const a = parseVaultSearchParams( + new URLSearchParams("kind=LAB_RESULT,IMAGING"), + ); + const b = parseVaultSearchParams( + new URLSearchParams("kind=IMAGING,LAB_RESULT"), + ); + expect(a).toEqual(b); + }); + + it("ignores a malformed year and returns the empty default view", () => { + expect(parseVaultSearchParams(new URLSearchParams("year=20x5"))).toEqual( + {}, + ); + expect(vaultFiltersToSearch({})).toBe(""); + }); + + it("counts active facets per chip (search counts once)", () => { + expect(countActiveFilters({})).toBe(0); + expect( + countActiveFilters({ + q: "a", + kinds: ["IMAGING", "LAB_RESULT"], + episodeId: "e", + year: 2026, + }), + ).toBe(5); + }); +}); + +describe("API list search", () => { + it("pins timeline sort, maps episode → episodeId, threads the cursor", () => { + const search = buildVaultListApiSearch( + { kinds: ["IMAGING"], episodeId: "ep1", year: 2025 }, + "cursor-9", + 50, + ); + const sp = new URLSearchParams(search); + expect(sp.get("kind")).toBe("IMAGING"); + expect(sp.get("episodeId")).toBe("ep1"); + expect(sp.get("year")).toBe("2025"); + expect(sp.get("sort")).toBe("documentDate"); + expect(sp.get("order")).toBe("desc"); + expect(sp.get("cursor")).toBe("cursor-9"); + expect(sp.get("limit")).toBe("50"); + }); +}); + +describe("timeline items", () => { + it("buckets by month and chunks rows to the column count", () => { + const docs = [ + doc({ id: "a", documentDate: "2026-03-20" }), + doc({ id: "b", documentDate: "2026-03-15" }), + doc({ id: "c", documentDate: "2026-03-10" }), + doc({ id: "d", documentDate: "2026-02-28" }), + ]; + const items = buildTimelineItems(docs, 2); + expect(items.map((i) => i.type)).toEqual([ + "month", + "row", + "row", + "month", + "row", + ]); + expect(items[0]).toEqual({ type: "month", key: "2026-03" }); + expect(items[1]).toMatchObject({ documents: [docs[0], docs[1]] }); + expect(items[2]).toMatchObject({ documents: [docs[2]] }); + expect(items[4]).toMatchObject({ documents: [docs[3]] }); + }); + + it("files an undated document under its upload day", () => { + const d = doc({ documentDate: null, createdAt: "2026-01-05T10:00:00Z" }); + expect(documentDateKey(d)).toBe("2026-01-05"); + const items = buildTimelineItems([d], 3); + expect(items[0]).toEqual({ type: "month", key: "2026-01" }); + }); + + it("keeps the DOM bounded: item count scales with rows, not documents", () => { + const docs = Array.from({ length: 1000 }, (_, i) => + doc({ id: `d${i}`, documentDate: "2026-03-01" }), + ); + const items = buildTimelineItems(docs, 4); + // 1 month header + 250 rows — the virtualizer then windows over these. + expect(items).toHaveLength(251); + }); + + it("formats a month key in the viewer's locale", () => { + expect(formatMonthLabel("2026-03", "de")).toBe("März 2026"); + expect(formatMonthLabel("not-a-month", "de")).toBe("not-a-month"); + }); +}); + +describe("byte formatting", () => { + it("scales units and keeps one decimal below 10", () => { + expect(formatBytes(0, "en")).toBe("0 B"); + expect(formatBytes(512, "en")).toBe("512 B"); + expect(formatBytes(26_214_400, "en")).toBe("25 MB"); + expect(formatBytes(1_288_490_189, "en")).toBe("1.2 GB"); + }); +}); + +describe("upload response contract (§3.2)", () => { + it("HTTP 200 + meta.duplicate returns the EXISTING row as a duplicate", () => { + const body = JSON.stringify({ + data: doc({ id: "existing-row" }), + error: null, + meta: { duplicate: true }, + }); + const result = parseUploadResponse(200, body); + expect(result).toMatchObject({ + ok: true, + duplicate: true, + document: { id: "existing-row" }, + }); + }); + + it("a plain 201 success is not a duplicate", () => { + const body = JSON.stringify({ data: doc({ id: "fresh" }), error: null }); + expect(parseUploadResponse(201, body)).toMatchObject({ + ok: true, + duplicate: false, + document: { id: "fresh" }, + }); + }); + + it("maps every meta.reason to its translated-copy class", () => { + expect( + parseUploadResponse( + 413, + JSON.stringify({ + data: null, + error: "too large", + meta: { reason: "fileTooLarge", maxFileBytes: 26_214_400 }, + }), + ), + ).toEqual({ + ok: false, + reason: "fileTooLarge", + maxFileBytes: 26_214_400, + quotaBytes: undefined, + usedBytes: undefined, + existingId: undefined, + }); + + expect( + parseUploadResponse( + 413, + JSON.stringify({ + data: null, + error: "quota", + meta: { + reason: "quotaExceeded", + quotaBytes: 1_073_741_824, + usedBytes: 1_000_000_000, + }, + }), + ), + ).toMatchObject({ + ok: false, + reason: "quotaExceeded", + quotaBytes: 1_073_741_824, + usedBytes: 1_000_000_000, + }); + + expect( + classifyUploadFailure(415, { reason: "unsupportedType" }), + ).toMatchObject({ reason: "unsupportedType" }); + expect(classifyUploadFailure(409, { reason: "purged" })).toMatchObject({ + reason: "purged", + }); + expect( + classifyUploadFailure(409, { + reason: "duplicateExists", + existingId: "row-1", + }), + ).toMatchObject({ reason: "duplicateExists", existingId: "row-1" }); + }); + + it("falls back to the HTTP status when a proxy strips the body", () => { + expect(parseUploadResponse(413, "")).toMatchObject({ + reason: "fileTooLarge", + }); + expect(parseUploadResponse(415, "")).toMatchObject({ + reason: "unsupportedType", + }); + expect(parseUploadResponse(429, "")).toMatchObject({ + reason: "rateLimited", + }); + expect(parseUploadResponse(500, "")).toMatchObject({ reason: "generic" }); + }); +}); diff --git a/src/components/documents/document-kind-meta.ts b/src/components/documents/document-kind-meta.ts new file mode 100644 index 000000000..31569128c --- /dev/null +++ b/src/components/documents/document-kind-meta.ts @@ -0,0 +1,48 @@ +/** + * The vault's kind vocabulary: one Lucide glyph per document kind, in the + * fixed display order the type-chip rail and the cards share. Icons are + * passed as components — the consuming primitive owns size and colour per + * the design standards. + */ +import type { ComponentType } from "react"; +import { + ArrowRightLeft, + File, + FileText, + FlaskConical, + Pill, + ScanLine, + ShieldCheck, + Stethoscope, + Syringe, +} from "lucide-react"; + +import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents"; + +/** Display order for the type-chip rail (everyday kinds first, OTHER last). */ +export const DOCUMENT_KIND_ORDER: readonly InboundDocumentKindValue[] = [ + "DOCTOR_REPORT", + "LAB_RESULT", + "IMAGING", + "DISCHARGE_LETTER", + "PRESCRIPTION", + "REFERRAL", + "VACCINATION", + "INSURANCE", + "OTHER", +]; + +export const DOCUMENT_KIND_ICONS: Record< + InboundDocumentKindValue, + ComponentType<{ className?: string }> +> = { + DOCTOR_REPORT: Stethoscope, + DISCHARGE_LETTER: FileText, + LAB_RESULT: FlaskConical, + IMAGING: ScanLine, + PRESCRIPTION: Pill, + REFERRAL: ArrowRightLeft, + INSURANCE: ShieldCheck, + VACCINATION: Syringe, + OTHER: File, +}; diff --git a/src/components/documents/vault-utils.ts b/src/components/documents/vault-utils.ts new file mode 100644 index 000000000..fb5f96f50 --- /dev/null +++ b/src/components/documents/vault-utils.ts @@ -0,0 +1,305 @@ +/** + * Pure helpers for the Dokumente vault surface: URL-facet parsing and + * serialisation (the page's filter state lives in the URL so every view is + * deep-linkable and back-button-safe), the API query-string builder, the + * month bucketing + row chunking the virtualized timeline renders from, the + * upload-response contract (including the HTTP-200 `meta.duplicate` path), + * and byte formatting. Kept out of the client components so they stay + * trivially unit-testable without a render harness. + */ +import { + DOCUMENT_LIST_DEFAULT_LIMIT, + INBOUND_DOCUMENT_KINDS, + type InboundDocumentDto, + type InboundDocumentKindValue, +} from "@/lib/validations/inbound-documents"; +import type { DocumentVaultFilters } from "@/lib/query-keys/documents"; + +const KIND_SET = new Set(INBOUND_DOCUMENT_KINDS); + +/** + * Parse the vault's URL search params (`?q&kind&episode&year`) into the + * filter object. Unknown kinds are dropped (a hand-edited URL never 422s the + * page), `kind` accepts repeats AND comma-separated values, and the result is + * normalised (kinds sorted, empties omitted) so the same view always produces + * the same TanStack cache key. + */ +export function parseVaultSearchParams( + params: URLSearchParams, +): DocumentVaultFilters { + const filters: DocumentVaultFilters = {}; + + const q = params.get("q")?.trim(); + if (q) filters.q = q.slice(0, 100); + + const kinds = params + .getAll("kind") + .flatMap((v) => v.split(",")) + .map((v) => v.trim()) + .filter((v): v is InboundDocumentKindValue => KIND_SET.has(v)); + if (kinds.length > 0) filters.kinds = [...new Set(kinds)].sort(); + + const episode = params.get("episode")?.trim(); + if (episode) filters.episodeId = episode.slice(0, 40); + + const yearRaw = params.get("year"); + if (yearRaw && /^\d{4}$/.test(yearRaw)) { + const year = Number(yearRaw); + if (year >= 1900 && year <= 9999) filters.year = year; + } + + return filters; +} + +/** + * Serialise the filter object back into the page URL's search string (no + * leading `?`; empty string for the default view). Inverse of + * `parseVaultSearchParams` — round-tripping is pinned by test so deep links + * from the illness page / labs (`?episode=`, `?kind=LAB_RESULT`) stay stable. + */ +export function vaultFiltersToSearch(filters: DocumentVaultFilters): string { + const sp = new URLSearchParams(); + if (filters.q) sp.set("q", filters.q); + if (filters.kinds && filters.kinds.length > 0) { + sp.set("kind", [...filters.kinds].sort().join(",")); + } + if (filters.episodeId) sp.set("episode", filters.episodeId); + if (filters.year !== undefined) sp.set("year", String(filters.year)); + return sp.toString(); +} + +/** Count of active facets (search counts as one) for the clear-all pill. */ +export function countActiveFilters(filters: DocumentVaultFilters): number { + let count = 0; + if (filters.q) count += 1; + if (filters.kinds && filters.kinds.length > 0) count += filters.kinds.length; + if (filters.episodeId) count += 1; + if (filters.year !== undefined) count += 1; + return count; +} + +/** + * Build the `/api/documents/inbound` query string from the active facets and + * a keyset cursor. The sort is pinned to the filing date, newest first — the + * timeline IS the sort order. The API takes `episodeId` (the page URL uses + * the shorter `episode`). + */ +export function buildVaultListApiSearch( + filters: DocumentVaultFilters, + cursor: string | null, + limit: number = DOCUMENT_LIST_DEFAULT_LIMIT, +): string { + const sp = new URLSearchParams(); + if (filters.q) sp.set("q", filters.q); + if (filters.kinds && filters.kinds.length > 0) { + sp.set("kind", filters.kinds.join(",")); + } + if (filters.episodeId) sp.set("episodeId", filters.episodeId); + if (filters.year !== undefined) sp.set("year", String(filters.year)); + sp.set("sort", "documentDate"); + sp.set("order", "desc"); + sp.set("limit", String(limit)); + if (cursor) sp.set("cursor", cursor); + return sp.toString(); +} + +/** The date a document files under — its user filing date, else upload day. */ +export function documentDateKey(doc: InboundDocumentDto): string { + if (doc.documentDate) return doc.documentDate; + // createdAt is an ISO timestamp; the leading YYYY-MM-DD is the upload day. + return doc.createdAt.slice(0, 10); +} + +/** The YYYY-MM month bucket a document files under. */ +export function documentMonthKey(doc: InboundDocumentDto): string { + return documentDateKey(doc).slice(0, 7); +} + +/** + * One flat item in the virtualized timeline: a month section label or one + * grid row of up to `columns` documents. The virtualizer windows over this + * flat list so the mounted DOM stays bounded regardless of corpus size. + */ +export type TimelineItem = + | { type: "month"; key: string } + | { type: "row"; key: string; documents: InboundDocumentDto[] }; + +/** + * Bucket an already-sorted document list into consecutive YYYY-MM sections + * and chunk each section into rows of `columns` documents. Order is preserved + * exactly as received — the server stays the single source of truth for + * sorting; a month opens the first time its key appears. + */ +export function buildTimelineItems( + documents: InboundDocumentDto[], + columns: number, +): TimelineItem[] { + const cols = Math.max(1, columns); + const items: TimelineItem[] = []; + let month: string | null = null; + let row: InboundDocumentDto[] = []; + + const flushRow = () => { + if (row.length > 0 && month !== null) { + items.push({ type: "row", key: `${month}:${row[0].id}`, documents: row }); + row = []; + } + }; + + for (const doc of documents) { + const key = documentMonthKey(doc); + if (key !== month) { + flushRow(); + month = key; + items.push({ type: "month", key }); + } + row.push(doc); + if (row.length === cols) flushRow(); + } + flushRow(); + return items; +} + +/** + * Format a YYYY-MM month key as a locale month label ("März 2026"). Handed + * over as a noon-UTC instant so the month never shifts under the display + * timezone. + */ +export function formatMonthLabel(key: string, locale: string): string { + if (!/^\d{4}-\d{2}$/.test(key)) return key; + return new Intl.DateTimeFormat(locale, { + month: "long", + year: "numeric", + timeZone: "UTC", + }).format(new Date(`${key}-15T12:00:00.000Z`)); +} + +/** Locale byte formatting: 0 B / 412 KB / 1.2 MB / 2.4 GB. */ +export function formatBytes(bytes: number, locale: string): string { + const units = ["B", "KB", "MB", "GB"] as const; + let value = Math.max(0, bytes); + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + const digits = unit === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 1; + return `${new Intl.NumberFormat(locale, { + maximumFractionDigits: digits, + }).format(value)} ${units[unit]}`; +} + +// ─── Upload response contract (§3.2) ─────────────────────────────────────── + +/** + * The §3.2 failure reasons the upload/restore paths translate client-side, + * plus the transport-level fallbacks. `rateLimited` maps 429; `generic` + * covers everything unclassifiable (proxy-stripped bodies included). + */ +export type UploadFailureReason = + | "fileTooLarge" + | "quotaExceeded" + | "unsupportedType" + | "purged" + | "duplicateExists" + | "rateLimited" + | "generic"; + +export interface UploadFailure { + ok: false; + reason: UploadFailureReason; + /** §3.2 — present on `fileTooLarge`. */ + maxFileBytes?: number; + /** §3.2 — present on `quotaExceeded`. */ + quotaBytes?: number; + usedBytes?: number; + /** §3.2 — present on `duplicateExists` (restore-conflict shape). */ + existingId?: string; +} + +export interface UploadSuccess { + ok: true; + document: InboundDocumentDto; + /** + * §3.2 — a same-user re-upload is HTTP 200 with envelope-level + * `meta.duplicate: true` and the EXISTING live row as `data`. Not an + * error: the UI toasts "already stored" and highlights that row. + */ + duplicate: boolean; +} + +export type UploadResult = UploadSuccess | UploadFailure; + +const REASONS: ReadonlySet = new Set([ + "fileTooLarge", + "quotaExceeded", + "unsupportedType", + "purged", + "duplicateExists", +]); + +function numberOrUndefined(v: unknown): number | undefined { + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} + +/** Map a `{ status, meta }` failure onto the translated-reason union. */ +export function classifyUploadFailure( + status: number, + meta: Record | undefined, +): UploadFailure { + const reason = typeof meta?.reason === "string" ? meta.reason : ""; + if (REASONS.has(reason)) { + return { + ok: false, + reason: reason as UploadFailureReason, + maxFileBytes: numberOrUndefined(meta?.maxFileBytes), + quotaBytes: numberOrUndefined(meta?.quotaBytes), + usedBytes: numberOrUndefined(meta?.usedBytes), + existingId: + typeof meta?.existingId === "string" ? meta.existingId : undefined, + }; + } + // A reverse proxy can strip the JSON body (and its `meta`); fall back to + // the HTTP status so size / type / rate-limit still classify. + if (status === 413) return { ok: false, reason: "fileTooLarge" }; + if (status === 415) return { ok: false, reason: "unsupportedType" }; + if (status === 429) return { ok: false, reason: "rateLimited" }; + return { ok: false, reason: "generic" }; +} + +/** + * Parse a completed upload's raw response (status + body text) against the + * envelope contract. This is the ONE place the duplicate contract lives: + * unlike every other read (which unwraps `data` and ignores `meta`), the + * upload MUST also read the success envelope's `meta.duplicate` flag. + */ +export function parseUploadResponse( + status: number, + bodyText: string, +): UploadResult { + let envelope: { + data?: unknown; + meta?: Record; + } | null = null; + try { + const parsed: unknown = JSON.parse(bodyText); + if (parsed && typeof parsed === "object") { + envelope = parsed as { data?: unknown; meta?: Record }; + } + } catch { + envelope = null; + } + + if (status >= 200 && status < 300) { + const doc = envelope?.data; + if (doc && typeof doc === "object" && "id" in doc) { + return { + ok: true, + document: doc as InboundDocumentDto, + duplicate: envelope?.meta?.duplicate === true, + }; + } + return { ok: false, reason: "generic" }; + } + return classifyUploadFailure(status, envelope?.meta); +} diff --git a/src/lib/query-keys/documents.ts b/src/lib/query-keys/documents.ts index 73774354f..f86b147a5 100644 --- a/src/lib/query-keys/documents.ts +++ b/src/lib/query-keys/documents.ts @@ -1,42 +1,47 @@ /** - * Query keys — clinical documents library (v1.25, W-DOCS-IN): the browsable - * document list (search / category filter / sort, keyset-paginated) and a - * single document's detail (document + staged facts). Part of the centralized - * factory; aggregated in `./index.ts`. Any document write (upload / edit / - * extract / confirm / discard) invalidates the `["documents"]` prefix so the - * list and the open detail repaint in lockstep. + * Query keys — the Dokumente vault: the filtered timeline list (keyset- + * paginated), a single document's detail, and the usage/limits read the + * upload path pre-flights against. Part of the centralized factory; + * aggregated in `./index.ts`. Any document write (upload / edit / link / + * delete / restore / bulk) invalidates the `["documents"]` prefix so the + * timeline, an open detail sheet, and the quota bar repaint in lockstep. */ -import type { - DocumentListSort, - InboundDocumentKindValue, -} from "@/lib/validations/inbound-documents"; +import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents"; /** - * The parameter set that distinguishes one library list view from another. - * Lives in the key so two different filter/sort combinations never share a - * cache bucket. `undefined` fields are dropped by TanStack's stable hash, so a - * bare `{ sort, order }` and `{ q: undefined, kind: undefined, sort, order }` - * resolve to the same key. + * The parameter set that distinguishes one vault timeline view from another — + * mirrors the URL facets (`?q&kind&episode&year`) one-to-one so a deep link + * and an in-page filter change resolve to the same cache bucket. `undefined` + * fields are dropped by TanStack's stable hash, so an all-defaults view and + * an explicit `{ q: undefined, … }` share a key. Sort is pinned server-side + * (filing date, newest first) and is deliberately NOT part of the key. */ -export interface DocumentListParams { +export interface DocumentVaultFilters { q?: string; - kind?: InboundDocumentKindValue; - from?: string; - to?: string; - sort: DocumentListSort; - order: "asc" | "desc"; + /** Type facet — OR inside the facet, sorted before keying by the caller. */ + kinds?: InboundDocumentKindValue[]; + /** Only documents linked to this illness/condition episode. */ + episodeId?: string; + /** Only documents whose filing date falls in this calendar year. */ + year?: number; } export const documentKeys = { documents: () => ["documents"] as const, inboundDocuments: () => ["documents", "inbound"] as const, /** - * The parameterised library list (keyset-paginated via `useInfiniteQuery`). + * The parameterised vault list (keyset-paginated via `useInfiniteQuery`). * The literal `"list"` segment keeps it distinct from a single-document key * (`inboundDocument(id)`); cuid ids never collide with it. Stays under the * `["documents", "inbound"]` prefix so prefix invalidation still reaches it. */ - inboundDocumentList: (params: DocumentListParams) => - ["documents", "inbound", "list", params] as const, + inboundDocumentList: (filters: DocumentVaultFilters) => + ["documents", "inbound", "list", filters] as const, inboundDocument: (id: string) => ["documents", "inbound", id] as const, + /** + * Storage usage + effective limits (`GET /api/documents/inbound/usage`). + * Invalidated through the `["documents"]` prefix after every upload / + * delete so the quota bar tracks reality. + */ + inboundDocumentUsage: () => ["documents", "inbound", "usage"] as const, };