diff --git a/messages/de.json b/messages/de.json index 0edd57f47..89affb4d7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10825,7 +10825,9 @@ "selectAll": "Alle auswählen", "searchPlaceholder": "Suchen", "searchNoMatch": "Kein Treffer", - "remove": "Entfernen" + "remove": "Entfernen", + "suggested": "Um dieses Datum", + "loadError": "Die Liste konnte nicht geladen werden." } } } diff --git a/messages/en.json b/messages/en.json index fc22621e5..3b6c0da04 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10825,7 +10825,9 @@ "selectAll": "Select all", "searchPlaceholder": "Search", "searchNoMatch": "No match", - "remove": "Remove" + "remove": "Remove", + "suggested": "Around this date", + "loadError": "The list could not be loaded." } } } diff --git a/messages/es.json b/messages/es.json index 865f35997..23bf967d6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10825,7 +10825,9 @@ "selectAll": "Seleccionar todo", "searchPlaceholder": "Buscar", "searchNoMatch": "Sin coincidencias", - "remove": "Quitar" + "remove": "Quitar", + "suggested": "En torno a esta fecha", + "loadError": "No se pudo cargar la lista." } } } diff --git a/messages/fr.json b/messages/fr.json index 8fb4f9d19..ca61704d8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10825,7 +10825,9 @@ "selectAll": "Tout sélectionner", "searchPlaceholder": "Rechercher", "searchNoMatch": "Aucun résultat", - "remove": "Retirer" + "remove": "Retirer", + "suggested": "Autour de cette date", + "loadError": "Impossible de charger la liste." } } } diff --git a/messages/it.json b/messages/it.json index 3f1777ff9..2eda19ed3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10825,7 +10825,9 @@ "selectAll": "Seleziona tutto", "searchPlaceholder": "Cerca", "searchNoMatch": "Nessun risultato", - "remove": "Rimuovi" + "remove": "Rimuovi", + "suggested": "Intorno a questa data", + "loadError": "Impossibile caricare l’elenco." } } } diff --git a/messages/ko.json b/messages/ko.json index 30f388897..1d86c656e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10825,7 +10825,9 @@ "selectAll": "전체 선택", "searchPlaceholder": "검색", "searchNoMatch": "결과 없음", - "remove": "제거" + "remove": "제거", + "suggested": "이 날짜 전후", + "loadError": "목록을 불러오지 못했습니다." } } } diff --git a/messages/pl.json b/messages/pl.json index 69afeac71..5fa6e6c98 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10825,7 +10825,9 @@ "selectAll": "Zaznacz wszystko", "searchPlaceholder": "Szukaj", "searchNoMatch": "Brak wyników", - "remove": "Usuń" + "remove": "Usuń", + "suggested": "Wokół tej daty", + "loadError": "Nie udało się wczytać listy." } } } diff --git a/src/components/encounters/__tests__/encounter-link-picker-limits.test.ts b/src/components/encounters/__tests__/encounter-link-picker-limits.test.ts new file mode 100644 index 000000000..9cae124b8 --- /dev/null +++ b/src/components/encounters/__tests__/encounter-link-picker-limits.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { illnessEpisodeListQuerySchema } from "@/lib/validations/illness"; +import { listLabResultsSchema } from "@/lib/validations/labs"; +import { + EPISODE_FETCH_LIMIT, + PICKER_FETCH_LIMIT, +} from "../encounter-link-pickers"; + +/** + * Each picker's page size must be one its list route accepts. A refused size + * comes back as a 422, and a picker that treats a failed read as an empty + * list tells the person there is nothing to link. That is how the visit + * form's condition picker and both document pickers showed nothing at all. + */ +describe("visit link picker page sizes", () => { + it("the condition picker asks for a page the episode route accepts", () => { + expect( + illnessEpisodeListQuerySchema.safeParse({ + limit: String(EPISODE_FETCH_LIMIT), + includeResolved: "true", + }).success, + ).toBe(true); + }); + + it("the lab picker asks for a page the lab route accepts", () => { + expect( + listLabResultsSchema.safeParse({ limit: String(PICKER_FETCH_LIMIT) }) + .success, + ).toBe(true); + }); +}); diff --git a/src/components/encounters/encounter-form.tsx b/src/components/encounters/encounter-form.tsx index 5c611a276..d46a5ec63 100644 --- a/src/components/encounters/encounter-form.tsx +++ b/src/components/encounters/encounter-form.tsx @@ -273,6 +273,7 @@ export function EncounterForm({ ; } -interface DocumentListPage { - documents: InboundDocumentDto[]; -} - export function EncounterLinkPickers({ modules, + anchor, documentIds, labResultIds, episodeIds, onChange, }: { modules: Partial> | undefined; + /** The visit's own date, for the document suggestions on top. */ + anchor: string | null; documentIds: string[]; labResultIds: string[]; episodeIds: string[]; @@ -73,13 +78,12 @@ export function EncounterLinkPickers({ const labsOn = modules?.labs === true; const illnessOn = modules?.illness === true; - const documents = useQuery({ - queryKey: queryKeys.inboundDocumentPicker("encounter-form"), + // The whole vault, suggestions near the visit's date on top — see + // `useVaultDocumentOptions` for why this no longer asks for one oversized + // page. + const documents = useVaultDocumentOptions({ enabled: documentsOn, - queryFn: () => - apiGet( - `/api/documents/inbound?sort=documentDate&order=desc&limit=${PICKER_FETCH_LIMIT}`, - ), + anchor, }); const labs = useQuery({ @@ -103,30 +107,12 @@ export function EncounterLinkPickers({ enabled: illnessOn, queryFn: () => apiGet( - `/api/illness/episodes?includeResolved=true&limit=${PICKER_FETCH_LIMIT}`, + `/api/illness/episodes?includeResolved=true&limit=${EPISODE_FETCH_LIMIT}`, ), }); if (!documentsOn && !labsOn && !illnessOn) return null; - // Documents group by month; the caller localizes the heading. - const documentOptions: EntityLinkOption[] = ( - documents.data?.documents ?? [] - ).map((doc) => { - const date = doc.documentDate ?? doc.reportDate ?? doc.createdAt; - return { - id: doc.id, - label: doc.title ?? doc.filename ?? doc.id, - dateLabel: date ? format.date(date) : null, - group: date - ? { - key: date.slice(0, 7), - label: `${format.monthShort(date)} ${date.slice(0, 4)}`, - } - : null, - }; - }); - // Labs group by sample date + panel: "Blutbild · 12.05.2026", the analyte // underneath. A visit links the whole panel from a day in one "select all". const labOptions: EntityLinkOption[] = (labs.data?.results ?? []).map( @@ -163,10 +149,13 @@ export function EncounterLinkPickers({ icon={FolderOpen} title={t("encounters.form.linkDocuments")} slot="encounter-link-documents" - pending={documents.isPending} + pending={documents.pending} + error={documents.error} + errorLabel={t("links.picker.loadError")} + onRetry={documents.retry} selected={documentIds} onChange={(ids) => onChange({ documentIds: ids })} - options={documentOptions} + options={documents.options} searchPlaceholder={t("links.picker.searchPlaceholder")} emptyLabel={t("encounters.form.linkNothingToOffer")} /> @@ -178,6 +167,9 @@ export function EncounterLinkPickers({ title={t("encounters.form.linkLabResults")} slot="encounter-link-labs" pending={labs.isPending} + error={labs.isError} + errorLabel={t("links.picker.loadError")} + onRetry={() => void labs.refetch()} selected={labResultIds} onChange={(ids) => onChange({ labResultIds: ids })} options={labOptions} @@ -192,6 +184,9 @@ export function EncounterLinkPickers({ title={t("encounters.form.linkConditions")} slot="encounter-link-conditions" pending={episodes.isPending} + error={episodes.isError} + errorLabel={t("links.picker.loadError")} + onRetry={() => void episodes.refetch()} selected={episodeIds} onChange={(ids) => onChange({ episodeIds: ids })} options={episodeOptions} diff --git a/src/components/links/__tests__/entity-link-picker.test.tsx b/src/components/links/__tests__/entity-link-picker.test.tsx index b4658f8ce..35b384895 100644 --- a/src/components/links/__tests__/entity-link-picker.test.tsx +++ b/src/components/links/__tests__/entity-link-picker.test.tsx @@ -140,6 +140,24 @@ describe(" inline summary", () => { expect(html).not.toContain('data-slot="test-link-add"'); }); + it("a failed read shows an error row with retry, never the empty label", () => { + const html = render( + undefined} + selected={[]} + />, + ); + expect(html).toContain('data-slot="test-link-error"'); + expect(html).toContain("Could not load documents"); + expect(html).toContain('role="alert"'); + expect(html).not.toContain("Nothing to link"); + }); + it("renders a skeleton while the options load", () => { const html = render( , diff --git a/src/components/links/__tests__/vault-document-options.test.ts b/src/components/links/__tests__/vault-document-options.test.ts new file mode 100644 index 000000000..e0dbbace6 --- /dev/null +++ b/src/components/links/__tests__/vault-document-options.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + documentOptionsWithSuggestions, + isNearAnchor, + type VaultDocument, +} from "../vault-document-options"; + +function doc( + id: string, + documentDate: string | null, + extra: Partial = {}, +): VaultDocument { + return { + id, + title: `Doc ${id}`, + filename: null, + documentDate, + reportDate: null, + createdAt: "2026-01-01T10:00:00.000Z", + ...extra, + }; +} + +const LABELS = { + suggested: "Suggested", + month: (iso: string) => iso.slice(0, 7), + date: (iso: string) => iso.slice(0, 10), +}; + +describe("isNearAnchor", () => { + const anchor = "2026-05-10T00:00:00.000Z"; + it("is inclusive at seven days either side", () => { + expect(isNearAnchor("2026-05-03", anchor)).toBe(true); + expect(isNearAnchor("2026-05-17", anchor)).toBe(true); + expect(isNearAnchor("2026-05-02", anchor)).toBe(false); + expect(isNearAnchor("2026-05-18", anchor)).toBe(false); + }); + it("never matches without an anchor or a date", () => { + expect(isNearAnchor(null, anchor)).toBe(false); + expect(isNearAnchor("2026-05-10", null)).toBe(false); + }); +}); + +describe("documentOptionsWithSuggestions", () => { + it("offers the WHOLE vault, not only the documents near the record's date", () => { + // A childhood record dated years before the dose it also covers is the + // case the old picker could not reach. + const options = documentOptionsWithSuggestions( + [doc("near", "2026-05-12"), doc("old", "1991-03-01")], + "2026-05-10T00:00:00.000Z", + LABELS, + ); + expect(options.map((o) => o.id)).toEqual(["near", "old"]); + }); + + it("puts the documents within the window on top under one suggestion group", () => { + const options = documentOptionsWithSuggestions( + [ + doc("recent", "2026-09-01"), + doc("near", "2026-05-12"), + doc("old", "1991-03-01"), + ], + "2026-05-10T00:00:00.000Z", + LABELS, + ); + expect(options[0]).toMatchObject({ + id: "near", + group: { key: "suggested", label: "Suggested" }, + }); + expect(options.slice(1).map((o) => o.id)).toEqual(["recent", "old"]); + expect(options[1]!.group).toEqual({ key: "2026-09", label: "2026-09" }); + }); + + it("reads a report date when the filing date is missing", () => { + const options = documentOptionsWithSuggestions( + [doc("lab", null, { reportDate: "2026-05-09" })], + "2026-05-10T00:00:00.000Z", + LABELS, + ); + expect(options[0]!.group?.key).toBe("suggested"); + }); + + it("without an anchor keeps the plain month grouping", () => { + const options = documentOptionsWithSuggestions( + [doc("a", "2026-05-12")], + null, + LABELS, + ); + expect(options[0]!.group?.key).toBe("2026-05"); + }); +}); + +describe("fetchWholeVault", () => { + it("asks only for pages the list route accepts, and walks every cursor", async () => { + const { documentListQuerySchema } = + await import("@/lib/validations/inbound-documents"); + const apiFetch = await import("@/lib/api/api-fetch"); + const urls: string[] = []; + const pages = [ + { documents: [doc("a", "2026-05-01")], nextCursor: "a" }, + { documents: [doc("b", "1991-03-01")], nextCursor: null }, + ]; + const spy = vi + .spyOn(apiFetch, "apiGet") + .mockImplementation(async (url: string) => { + urls.push(url); + return pages.shift() as never; + }); + try { + const { fetchWholeVault } = await import("../vault-document-options"); + const documents = await fetchWholeVault(); + expect(documents.map((d) => d.id)).toEqual(["a", "b"]); + expect(urls).toHaveLength(2); + for (const url of urls) { + // The route parses its query with this schema and answers a 422 to + // anything it refuses; the picker used to read that 422 as an empty + // vault. + const params = Object.fromEntries( + new URL(url, "http://local").searchParams, + ); + expect(documentListQuerySchema.safeParse(params).success).toBe(true); + } + expect(urls[1]).toContain("cursor=a"); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/src/components/links/entity-link-picker.tsx b/src/components/links/entity-link-picker.tsx index ba4494617..296863af3 100644 --- a/src/components/links/entity-link-picker.tsx +++ b/src/components/links/entity-link-picker.tsx @@ -29,6 +29,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; @@ -131,6 +132,9 @@ export function EntityLinkPicker({ onChange, searchPlaceholder, emptyLabel, + error = false, + errorLabel, + onRetry, }: { icon: LucideIcon; title: string; @@ -142,6 +146,14 @@ export function EntityLinkPicker({ onChange: (ids: string[]) => void; searchPlaceholder: string; emptyLabel: string; + /** + * The option read failed. Renders an error row with Retry instead of the + * empty label: a failed read must never pass for "nothing to link", which + * is exactly how a refused page size once hid a whole vault. + */ + error?: boolean; + errorLabel?: string; + onRetry?: () => void; }) { const { t } = useTranslations(); const [open, setOpen] = useState(false); @@ -175,8 +187,14 @@ export function EntityLinkPicker({ ) : null} - {pending ? ( + {pending && !error ? ( + ) : error ? ( + ) : options.length === 0 ? (

{emptyLabel}

) : ( diff --git a/src/components/links/vault-document-options.ts b/src/components/links/vault-document-options.ts new file mode 100644 index 000000000..83422ae4a --- /dev/null +++ b/src/components/links/vault-document-options.ts @@ -0,0 +1,170 @@ +"use client"; + +/** + * The vault as a link-picker option list, with the documents near the + * record's own date on top. + * + * The visit and vaccination forms used to fetch their document options with a + * page size the list route refuses (`limit=200` against a ceiling of 100). The + * 422 read as an empty list, so the picker said "No documents yet." to a + * person with a full vault, and the only way a scan ever got attached was the + * ±7-day suggestion offered at upload. A childhood record covering eight doses + * from one sheet of paper could therefore be filed against exactly one of + * them. + * + * Now the whole vault is offered, paged through at the route's own ceiling, + * and a read failure says so instead of pretending the vault is empty. The + * documents dated within the same ±{@link ENCOUNTER_SUGGEST_WINDOW_DAYS}-day + * window the upload suggestion uses stay on top under their own heading, so + * the common case is still one tap; everything else follows, grouped by month + * and searchable. + */ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; + +import type { EntityLinkOption } from "@/components/links/entity-link-picker"; +import { apiGet } from "@/lib/api/api-fetch"; +import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { + DOCUMENT_LIST_MAX_LIMIT, + type InboundDocumentDto, +} from "@/lib/validations/inbound-documents"; + +/** + * How many pages the picker walks. At the route's ceiling of 100 that is a + * thousand documents, past which the vault's own search is the better tool. + */ +const VAULT_PICKER_MAX_PAGES = 10; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** The fields the option list reads off a document. */ +export type VaultDocument = Pick< + InboundDocumentDto, + "id" | "title" | "filename" | "documentDate" | "reportDate" | "createdAt" +>; + +interface ListPage { + documents: InboundDocumentDto[]; + nextCursor: string | null; +} + +/** Walk the vault newest filing date first, at the route's own page ceiling. */ +export async function fetchWholeVault(): Promise { + const documents: VaultDocument[] = []; + let cursor: string | null = null; + for (let page = 0; page < VAULT_PICKER_MAX_PAGES; page++) { + const params = new URLSearchParams({ + sort: "documentDate", + order: "desc", + limit: String(DOCUMENT_LIST_MAX_LIMIT), + }); + if (cursor) params.set("cursor", cursor); + const result: ListPage = await apiGet( + `/api/documents/inbound?${params.toString()}`, + ); + documents.push(...result.documents); + cursor = result.nextCursor; + if (!cursor) break; + } + return documents; +} + +/** + * Is a document dated within the suggestion window of the record's anchor? + * Day-granular and inclusive on both edges, like the upload suggestion. A + * document with no date of its own is never "near" anything. + */ +export function isNearAnchor( + documentDay: string | null, + anchorIso: string | null, +): boolean { + if (!documentDay || !anchorIso) return false; + const day = Date.parse(`${documentDay.slice(0, 10)}T00:00:00.000Z`); + const anchor = Date.parse(`${anchorIso.slice(0, 10)}T00:00:00.000Z`); + if (Number.isNaN(day) || Number.isNaN(anchor)) return false; + return Math.abs(day - anchor) <= ENCOUNTER_SUGGEST_WINDOW_DAYS * DAY_MS; +} + +/** + * Build the option list: suggestions first, under one heading, then the rest + * of the vault in the route's order (newest filing date first), by month. + * + * `labels` carries the already-localised strings, so this stays a pure + * function a test can call without a provider. + */ +export function documentOptionsWithSuggestions( + documents: readonly VaultDocument[], + anchorIso: string | null, + labels: { + suggested: string; + month: (iso: string) => string; + date: (iso: string) => string; + }, +): EntityLinkOption[] { + const suggested: EntityLinkOption[] = []; + const rest: EntityLinkOption[] = []; + for (const doc of documents) { + const own = doc.documentDate ?? doc.reportDate; + const date = own ?? doc.createdAt; + const base = { + id: doc.id, + label: doc.title ?? doc.filename ?? doc.id, + dateLabel: date ? labels.date(date) : null, + }; + if (isNearAnchor(own, anchorIso)) { + suggested.push({ + ...base, + group: { key: "suggested", label: labels.suggested }, + }); + } else { + rest.push({ + ...base, + group: date + ? { key: date.slice(0, 7), label: labels.month(date) } + : null, + }); + } + } + return [...suggested, ...rest]; +} + +/** + * The picker's data: the whole vault as options, the suggestion group on top + * when the record has a date, and the query state an error row needs. + */ +export function useVaultDocumentOptions({ + enabled, + anchor, +}: { + enabled: boolean; + /** The record's own date (ISO), or null when it has none yet. */ + anchor: string | null; +}) { + const { t } = useTranslations(); + const format = useFormatters(); + const query = useQuery({ + queryKey: queryKeys.inboundDocumentVaultPicker(), + enabled, + queryFn: fetchWholeVault, + }); + + const options = useMemo( + () => + documentOptionsWithSuggestions(query.data ?? [], anchor, { + suggested: t("links.picker.suggested"), + month: (iso) => `${format.monthShort(iso)} ${iso.slice(0, 4)}`, + date: (iso) => format.date(iso), + }), + [query.data, anchor, t, format], + ); + + return { + options, + pending: query.isPending, + error: query.isError, + retry: () => void query.refetch(), + }; +} diff --git a/src/components/vaccinations/vaccination-document-picker.tsx b/src/components/vaccinations/vaccination-document-picker.tsx index d8efc7dab..baf8f2da5 100644 --- a/src/components/vaccinations/vaccination-document-picker.tsx +++ b/src/components/vaccinations/vaccination-document-picker.tsx @@ -5,6 +5,11 @@ * transcribed from. The shared {@link EntityLinkPicker} — an inline summary * (removable chips + an add button) over a searchable, month-grouped sheet. * + * The whole vault is offered, not only the pages dated near the dose: one + * childhood record commonly covers a dozen doses given years apart, and it + * belongs on every one of them. The pages dated within the upload + * suggestion's window of this dose sit on top as suggestions. + * * **The gate blanks the block, it does not post-filter it.** When the * `inboundDocuments` module is off, this renders nothing — no heading, no empty * list — because an empty picker for a switched-off module advertises a feature @@ -13,75 +18,43 @@ * Nothing here can block a save: the list starts empty and stays valid empty, * and linking is optional, capped and idempotent behind the link facade. */ -import { useQuery } from "@tanstack/react-query"; import { FolderOpen } from "lucide-react"; -import { apiGet } from "@/lib/api/api-fetch"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; -import { - EntityLinkPicker, - type EntityLinkOption, -} from "@/components/links/entity-link-picker"; - -const PICKER_FETCH_LIMIT = 200; - -interface DocumentListPage { - documents: InboundDocumentDto[]; -} +import { useTranslations } from "@/lib/i18n/context"; +import { EntityLinkPicker } from "@/components/links/entity-link-picker"; +import { useVaultDocumentOptions } from "@/components/links/vault-document-options"; export function VaccinationDocumentPicker({ enabled, + anchor, documentIds, onChange, }: { /** The `inboundDocuments` module flag — false blanks the block entirely. */ enabled: boolean; + /** The dose's own date (ISO), for the suggestions on top. */ + anchor: string | null; documentIds: string[]; onChange: (documentIds: string[]) => void; }) { const { t } = useTranslations(); - const format = useFormatters(); - - const documents = useQuery({ - queryKey: queryKeys.inboundDocumentPicker("vaccination-form"), - enabled, - queryFn: () => - apiGet( - `/api/documents/inbound?sort=documentDate&order=desc&limit=${PICKER_FETCH_LIMIT}`, - ), - }); + const vault = useVaultDocumentOptions({ enabled, anchor }); if (!enabled) return null; - const options: EntityLinkOption[] = (documents.data?.documents ?? []).map( - (doc) => { - const date = doc.documentDate ?? doc.reportDate ?? doc.createdAt; - return { - id: doc.id, - label: doc.title ?? doc.filename ?? doc.id, - dateLabel: date ? format.date(date) : null, - group: date - ? { - key: date.slice(0, 7), - label: `${format.monthShort(date)} ${date.slice(0, 4)}`, - } - : null, - }; - }, - ); - return (
diff --git a/src/components/vaccinations/vaccination-form.tsx b/src/components/vaccinations/vaccination-form.tsx index c9b5885d9..67d192faa 100644 --- a/src/components/vaccinations/vaccination-form.tsx +++ b/src/components/vaccinations/vaccination-form.tsx @@ -305,6 +305,7 @@ export function VaccinationForm({ patch({ documentIds })} /> diff --git a/src/lib/query-keys/documents.ts b/src/lib/query-keys/documents.ts index b2d973a0f..2a407a2dc 100644 --- a/src/lib/query-keys/documents.ts +++ b/src/lib/query-keys/documents.ts @@ -54,6 +54,13 @@ export const documentKeys = { */ inboundDocumentPicker: (q: string) => ["documents", "inbound", "picker", q] as const, + /** + * The whole vault as a link-picker option list (every page, walked at the + * list route's ceiling), shared by the visit and vaccination forms. Own + * segment because the shape is a flat document array, not a page. + */ + inboundDocumentVaultPicker: () => + ["documents", "inbound", "vault-picker"] as const, /** * Storage usage + effective limits (`GET /api/documents/inbound/usage`). * Invalidated through the `["documents"]` prefix after every upload /