From 2b7a6544d677cc00f8b27e019095db62d8ca7257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 12:26:07 +0200 Subject: [PATCH] feat(documents): retire the v1.25 extraction-review surface The parked inbound-documents view (upload form + staged-fact review UI) and its helpers leave the tree; /documents renders only the vault. The extraction routes stay dark server-side with no UI entry points, per the release plan. Orphaned i18n keys left with the locale rewrite in the previous commit; the reverse-coverage guard pins the result. Plan-Task: W2-T7 --- .../__tests__/inbound-documents-view.test.tsx | 375 ----- .../documents/__tests__/library-utils.test.ts | 285 ---- .../documents/inbound-documents-view.tsx | 1324 ----------------- src/components/documents/library-utils.ts | 197 --- 4 files changed, 2181 deletions(-) delete mode 100644 src/components/documents/__tests__/inbound-documents-view.test.tsx delete mode 100644 src/components/documents/__tests__/library-utils.test.ts delete mode 100644 src/components/documents/inbound-documents-view.tsx delete mode 100644 src/components/documents/library-utils.ts diff --git a/src/components/documents/__tests__/inbound-documents-view.test.tsx b/src/components/documents/__tests__/inbound-documents-view.test.tsx deleted file mode 100644 index 5df3dbabe..000000000 --- a/src/components/documents/__tests__/inbound-documents-view.test.tsx +++ /dev/null @@ -1,375 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import type { - ExtractedFactDto, - InboundDocumentDetailDto, - InboundDocumentDto, -} from "@/lib/validations/inbound-documents"; - -/** - * v1.25 — the Documents library. - * - * The harness is SSR-only (node env, no jsdom): we seed the TanStack cache and - * assert the static markup. Interaction-driven paths (the exact request params, - * the provider-unsupported classification) are covered by the pure-helper unit - * tests in `library-utils.test.ts`; here we assert the rendered library, the - * detail/review surface, the edit form, and the inline provider note. - */ - -vi.mock("@/lib/api/api-fetch", async () => { - const actual = await vi.importActual( - "@/lib/api/api-fetch", - ); - return { - ...actual, - apiFetch: () => new Promise(() => {}), - apiPost: vi.fn(), - apiPatch: vi.fn(), - apiDelete: vi.fn(), - }; -}); - -import { - AlreadyConfirmedNote, - DocumentDetail, - DocumentMetaEditForm, - InboundDocumentsView, - ProviderUnsupportedNote, -} from "../inbound-documents-view"; - -function doc( - id: string, - over: Partial = {}, -): InboundDocumentDto { - return { - id, - kind: "LAB_RESULT", - title: null, - filename: `${id}.pdf`, - mimeType: "application/pdf", - byteSize: 1000, - status: "STORED", - providerType: null, - reportDate: null, - documentDate: "2026-06-20", - errorReason: null, - factCount: 0, - pendingCount: 0, - conditionLinks: [], - servingClass: "inline", - createdAt: "2026-06-20T08:00:00.000Z", - updatedAt: "2026-06-20T08:00:00.000Z", - ...over, - }; -} - -function render(node: React.ReactNode, seed?: (qc: QueryClient) => void) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - seed?.(queryClient); - return renderToStaticMarkup( - - {node} - , - ); -} - -describe(" library", () => { - it("renders the grouped list with titles, badges, and load-more", () => { - const html = render(, (qc) => { - qc.setQueryData( - queryKeys.inboundDocumentList({ - q: undefined, - kind: undefined, - sort: "documentDate", - order: "desc", - }), - { - pages: [ - { - documents: [ - doc("d1", { - title: "Cardiology report", - documentDate: "2026-06-20", - pendingCount: 2, - factCount: 3, - }), - doc("d2", { - title: "Blood panel", - documentDate: "2026-06-20", - }), - ], - nextCursor: "cursor-next", - }, - ], - pageParams: [null], - }, - ); - }); - - // Library + toolbar shells render. - expect(html).toContain('data-slot="documents-library"'); - expect(html).toContain('data-slot="documents-toolbar"'); - // Date group header + rows. - expect(html).toContain('data-slot="documents-date-group"'); - expect(html).toContain('data-slot="documents-row"'); - // Titles surface (title preferred over filename). - expect(html).toContain("Cardiology report"); - expect(html).toContain("Blood panel"); - // Kind + status badges from the EN bundle. - expect(html).toContain("Lab result"); - expect(html).toContain("Stored"); - // pendingCount badge. - expect(html).toContain("2 to review"); - // nextCursor → load-more button. - expect(html).toContain("Load more"); - // A11Y-M2: the row's aria-controls points at the detail region id. - expect(html).toContain('aria-controls="documents-detail-d1"'); - // QoL: accepted formats + size ceiling are shown by the file field. - expect(html).toContain("JPEG, PNG, WebP or PDF · up to 12 MB"); - }); - - it("disables the upload button and shows a hint until a file is chosen", () => { - const html = render(, (qc) => { - qc.setQueryData( - queryKeys.inboundDocumentList({ - q: undefined, - kind: undefined, - sort: "documentDate", - order: "desc", - }), - { pages: [{ documents: [], nextCursor: null }], pageParams: [null] }, - ); - }); - // A11Y-M4: with no file staged, an inline hint explains why upload is - // disabled (the hint only renders on the !hasFile / not-pending path). - expect(html).toContain("Choose a file to upload."); - }); - - it("renders the document-date range filter inputs", () => { - const html = render(, (qc) => { - qc.setQueryData( - queryKeys.inboundDocumentList({ - q: undefined, - kind: undefined, - sort: "documentDate", - order: "desc", - }), - { pages: [{ documents: [], nextCursor: null }], pageParams: [null] }, - ); - }); - // PERF-L: from/to date inputs are wired into the toolbar. - expect(html).toContain('id="doc-from"'); - expect(html).toContain('id="doc-to"'); - }); - - it("shows the empty state when there are no documents", () => { - const html = render(, (qc) => { - qc.setQueryData( - queryKeys.inboundDocumentList({ - q: undefined, - kind: undefined, - sort: "documentDate", - order: "desc", - }), - { pages: [{ documents: [], nextCursor: null }], pageParams: [null] }, - ); - }); - expect(html).toContain("No documents yet."); - expect(html).not.toContain('data-slot="documents-row"'); - }); -}); - -function fact(id: string): ExtractedFactDto { - return { - id, - factType: "OBSERVATION", - status: "PENDING", - confidence: 0.9, - needsReview: false, - data: { - label: "LDL Cholesterol", - code: null, - codeSystem: null, - value: 95, - valueText: null, - unit: "mg/dL", - referenceLow: null, - referenceHigh: null, - effectiveDate: "2026-06-20", - }, - provenance: { sourceText: "LDL 95 mg/dL", page: 0, confidence: 0.9 }, - committedRecordId: null, - committedRecordType: null, - }; -} - -describe("", () => { - function detail(over: Partial = {}) { - return { - ...doc("d1", { title: "Cardiology report", status: "EXTRACTED" }), - facts: [fact("f1")], - ...over, - } as InboundDocumentDetailDto; - } - - it("renders the original link, edit + extract controls, and the review facts", () => { - const html = render( - {}} />, - (qc) => { - qc.setQueryData(queryKeys.inboundDocument("d1"), detail()); - }, - ); - expect(html).toContain('data-slot="documents-detail"'); - // Original view link points at the raw-bytes route. - expect(html).toContain("/api/documents/inbound/d1/original"); - expect(html).toContain('data-slot="documents-edit-trigger"'); - // Extract button is present (status is not CONFIRMED). - expect(html).toContain('data-slot="documents-extract"'); - expect(html).toContain("Extract facts"); - // The staged fact's transcription summary renders for review. - expect(html).toContain("LDL Cholesterol"); - expect(html).toContain("Save approved facts"); - }); - - it("hides the extract button once the document is confirmed", () => { - const html = render( - {}} />, - (qc) => { - qc.setQueryData( - queryKeys.inboundDocument("d1"), - detail({ status: "CONFIRMED", facts: [] }), - ); - }, - ); - expect(html).not.toContain('data-slot="documents-extract"'); - }); - - it("hides the extract button once any fact is approved (PERF-H1 gate)", () => { - const approved: ExtractedFactDto = { ...fact("f1"), status: "APPROVED" }; - const html = render( - {}} />, - (qc) => { - qc.setQueryData( - queryKeys.inboundDocument("d1"), - detail({ status: "EXTRACTED", factCount: 1, facts: [approved] }), - ); - }, - ); - expect(html).not.toContain('data-slot="documents-extract"'); - }); - - it("threads the region id onto the detail card for aria-controls", () => { - const html = render( - {}} - />, - (qc) => { - qc.setQueryData(queryKeys.inboundDocument("d1"), detail()); - }, - ); - expect(html).toContain('id="documents-detail-d1"'); - }); - - it("falls back to the kind label when title and filename are absent", () => { - const html = render( - {}} />, - (qc) => { - qc.setQueryData( - queryKeys.inboundDocument("d1"), - detail({ title: null, filename: null, kind: "IMAGING" }), - ); - }, - ); - expect(html).toContain("Imaging"); - }); - - it("shows the committed facts summary once approved, not the empty note", () => { - const approved: ExtractedFactDto = { ...fact("f1"), status: "APPROVED" }; - const html = render( - {}} />, - (qc) => { - qc.setQueryData( - queryKeys.inboundDocument("d1"), - detail({ status: "CONFIRMED", factCount: 1, facts: [approved] }), - ); - }, - ); - // M6: provenance stays visible — the approved facts are listed read-only… - expect(html).toContain('data-slot="documents-committed"'); - expect(html).toContain("Approved from this document"); - expect(html).toContain("LDL Cholesterol"); - // …instead of stranding the user on "Nothing left to review." - expect(html).not.toContain("Nothing left to review."); - }); - - it("explains why a low-confidence fact can't be approved yet", () => { - const lowConf: ExtractedFactDto = { ...fact("f1"), needsReview: true }; - const html = render( - {}} />, - (qc) => { - qc.setQueryData( - queryKeys.inboundDocument("d1"), - detail({ facts: [lowConf] }), - ); - }, - ); - // M7: the disabled Approve is tied to a plain-language reason. - expect(html).toContain("Edit this fact to approve it."); - expect(html).toContain("Needs review"); - }); - - it("offers a confirm step before discarding", () => { - const html = render( - {}} />, - (qc) => { - qc.setQueryData(queryKeys.inboundDocument("d1"), detail()); - }, - ); - // A11Y-M3: discard is gated behind an AlertDialog trigger, not one click. - expect(html).toContain('data-slot="documents-discard-trigger"'); - }); -}); - -describe("", () => { - it("renders the metadata fields seeded from the document", () => { - const html = render( - {}} - onCancel={() => {}} - />, - ); - expect(html).toContain('data-slot="documents-edit-form"'); - expect(html).toContain('value="Old title"'); - expect(html).toContain('value="2026-06-20"'); - expect(html).toContain("Save"); - }); -}); - -describe("", () => { - it("renders the calm inline note as a polite live region (A11Y-H1)", () => { - const html = render(); - expect(html).toContain('data-slot="documents-provider-note"'); - expect(html).toContain("Configure a document-scan provider"); - expect(html).toContain('role="status"'); - expect(html).toContain('aria-live="polite"'); - }); -}); - -describe("", () => { - it("renders a calm polite live region when extraction is refused", () => { - const html = render(); - expect(html).toContain('data-slot="documents-already-confirmed-note"'); - expect(html).toContain('role="status"'); - expect(html).toContain('aria-live="polite"'); - expect(html).toContain("already been reviewed"); - }); -}); diff --git a/src/components/documents/__tests__/library-utils.test.ts b/src/components/documents/__tests__/library-utils.test.ts deleted file mode 100644 index b8e55a6e8..000000000 --- a/src/components/documents/__tests__/library-utils.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { describe, it, expect } from "vitest"; - -import { ApiError } from "@/lib/api/api-fetch"; -import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; -import enMessages from "../../../../messages/en.json"; -import { - buildDocumentListSearch, - classifyUploadError, - commitFailureReasonKey, - confirmFailureReasonKey, - documentDateKey, - formatDateGroupLabel, - groupDocumentsByDate, - isAlreadyConfirmedError, - isLocalOcrDisabledError, - isProviderUnsupportedError, -} from "../library-utils"; - -/** Resolve a dotted i18n key against a bundle (the dynamic reason keys aren't - * literal `t()` call sites, so the call-site coverage guard can't see them — - * assert here that each one actually resolves to a string). */ -function resolveKey(bundle: unknown, key: string): unknown { - return key - .split(".") - .reduce( - (acc, part) => - acc && typeof acc === "object" - ? (acc as Record)[part] - : undefined, - bundle, - ); -} - -function doc( - id: string, - over: Partial = {}, -): InboundDocumentDto { - return { - id, - kind: "OTHER", - title: null, - filename: `${id}.pdf`, - mimeType: "application/pdf", - byteSize: 1000, - status: "STORED", - providerType: null, - reportDate: null, - documentDate: null, - errorReason: null, - factCount: 0, - pendingCount: 0, - conditionLinks: [], - servingClass: "inline", - createdAt: "2026-06-20T08:00:00.000Z", - updatedAt: "2026-06-20T08:00:00.000Z", - ...over, - }; -} - -describe("buildDocumentListSearch", () => { - it("emits the active filters, sort, order, and limit", () => { - const qs = buildDocumentListSearch( - { - q: "cardio", - kind: "LAB_RESULT", - from: "2026-01-01", - to: "2026-06-01", - sort: "title", - order: "asc", - }, - null, - 25, - ); - const sp = new URLSearchParams(qs); - expect(sp.get("q")).toBe("cardio"); - expect(sp.get("kind")).toBe("LAB_RESULT"); - expect(sp.get("from")).toBe("2026-01-01"); - expect(sp.get("to")).toBe("2026-06-01"); - expect(sp.get("sort")).toBe("title"); - expect(sp.get("order")).toBe("asc"); - expect(sp.get("limit")).toBe("25"); - expect(sp.has("cursor")).toBe(false); - }); - - it("omits empty filters and appends the keyset cursor when present", () => { - const qs = buildDocumentListSearch( - { sort: "documentDate", order: "desc" }, - "cursor-123", - ); - const sp = new URLSearchParams(qs); - expect(sp.has("q")).toBe(false); - expect(sp.has("kind")).toBe(false); - expect(sp.get("sort")).toBe("documentDate"); - expect(sp.get("order")).toBe("desc"); - expect(sp.get("cursor")).toBe("cursor-123"); - }); -}); - -describe("documentDateKey", () => { - it("prefers the user filing date", () => { - expect(documentDateKey(doc("a", { documentDate: "2026-03-15" }))).toBe( - "2026-03-15", - ); - }); - - it("falls back to the upload day from createdAt", () => { - expect( - documentDateKey(doc("a", { createdAt: "2026-06-20T08:00:00.000Z" })), - ).toBe("2026-06-20"); - }); -}); - -describe("groupDocumentsByDate", () => { - it("buckets consecutive same-date documents, preserving server order", () => { - const groups = groupDocumentsByDate([ - doc("a", { documentDate: "2026-06-20" }), - doc("b", { documentDate: "2026-06-20" }), - doc("c", { documentDate: "2026-06-19" }), - ]); - expect(groups.map((g) => g.key)).toEqual(["2026-06-20", "2026-06-19"]); - expect(groups[0].documents.map((d) => d.id)).toEqual(["a", "b"]); - expect(groups[1].documents.map((d) => d.id)).toEqual(["c"]); - }); - - it("returns an empty array for no documents", () => { - expect(groupDocumentsByDate([])).toEqual([]); - }); -}); - -describe("isProviderUnsupportedError", () => { - it("matches the extract 422 provider-unsupported signal", () => { - const err = new ApiError("no provider", 422, { - errorCode: "documents.inbound.providerUnsupported", - }); - expect(isProviderUnsupportedError(err)).toBe(true); - }); - - it("rejects other API errors and non-errors", () => { - expect( - isProviderUnsupportedError( - new ApiError("rate", 429, { - errorCode: "documents.inbound.rateLimited", - }), - ), - ).toBe(false); - expect(isProviderUnsupportedError(new Error("boom"))).toBe(false); - expect(isProviderUnsupportedError(null)).toBe(false); - }); -}); - -describe("isAlreadyConfirmedError", () => { - it("matches the extract 409 partly-confirmed signal", () => { - const err = new ApiError("already confirmed", 409, { - errorCode: "documents.inbound.alreadyPartlyConfirmed", - }); - expect(isAlreadyConfirmedError(err)).toBe(true); - }); - - it("matches the extract 422 fully-confirmed signal", () => { - const err = new ApiError("already confirmed", 422, { - errorCode: "documents.inbound.alreadyConfirmed", - }); - expect(isAlreadyConfirmedError(err)).toBe(true); - }); - - it("rejects other API errors and non-errors", () => { - expect( - isAlreadyConfirmedError( - new ApiError("no provider", 422, { - errorCode: "documents.inbound.providerUnsupported", - }), - ), - ).toBe(false); - expect(isAlreadyConfirmedError(new Error("boom"))).toBe(false); - expect(isAlreadyConfirmedError(null)).toBe(false); - }); -}); - -describe("classifyUploadError", () => { - it("maps each upload error code to its kind", () => { - const code = (c: string) => - classifyUploadError(new ApiError("x", 400, { errorCode: c })); - expect(code("documents.inbound.fileTooLarge")).toBe("tooLarge"); - expect(code("documents.inbound.fileType")).toBe("fileType"); - expect(code("documents.inbound.rateLimited")).toBe("rateLimited"); - expect(code("documents.inbound.invalidMetadata")).toBe("invalidMetadata"); - }); - - it("falls back to the HTTP status when the body carries no error code", () => { - expect(classifyUploadError(new ApiError("too big", 413))).toBe("tooLarge"); - expect(classifyUploadError(new ApiError("wrong type", 415))).toBe( - "fileType", - ); - expect(classifyUploadError(new ApiError("slow down", 429))).toBe( - "rateLimited", - ); - }); - - it("returns generic for unknown errors and non-API errors", () => { - expect(classifyUploadError(new ApiError("boom", 500))).toBe("generic"); - expect(classifyUploadError(new Error("network"))).toBe("generic"); - expect(classifyUploadError(null)).toBe("generic"); - }); -}); - -describe("isLocalOcrDisabledError", () => { - it("matches the extract 422 local-OCR-disabled signal", () => { - const err = new ApiError("local ocr off", 422, { - errorCode: "documents.inbound.localOcrDisabled", - }); - expect(isLocalOcrDisabledError(err)).toBe(true); - }); - - it("rejects other API errors and non-errors", () => { - expect( - isLocalOcrDisabledError( - new ApiError("no provider", 422, { - errorCode: "documents.inbound.providerUnsupported", - }), - ), - ).toBe(false); - expect(isLocalOcrDisabledError(new Error("boom"))).toBe(false); - expect(isLocalOcrDisabledError(null)).toBe(false); - }); -}); - -describe("commitFailureReasonKey", () => { - it("maps each commit-failure code to a reason key that resolves", () => { - const cases: Record = { - "observation.unitMismatch": "documents.review.commitError.unitMismatch", - "observation.unitRequired": "documents.review.commitError.unitRequired", - "something.unknown": "documents.review.commitError.generic", - }; - for (const [code, key] of Object.entries(cases)) { - expect(commitFailureReasonKey(code)).toBe(key); - // Every reason the toast can surface resolves to a real string. - expect(typeof resolveKey(enMessages, key)).toBe("string"); - } - }); -}); - -describe("confirmFailureReasonKey", () => { - it("surfaces the shared reason when every failed fact agrees", () => { - const key = confirmFailureReasonKey([ - { reason: "observation.unitMismatch" }, - { reason: "observation.unitMismatch" }, - ]); - expect(key).toBe("documents.review.commitError.unitMismatch"); - expect(typeof resolveKey(enMessages, key)).toBe("string"); - }); - - it("falls back to the generic reason when failures disagree", () => { - const key = confirmFailureReasonKey([ - { reason: "observation.unitMismatch" }, - { reason: "observation.unitRequired" }, - ]); - expect(key).toBe("documents.review.commitError.generic"); - }); - - it("uses the single failure's reason for a one-item batch", () => { - expect( - confirmFailureReasonKey([{ reason: "observation.unitRequired" }]), - ).toBe("documents.review.commitError.unitRequired"); - }); -}); - -describe("formatDateGroupLabel", () => { - it("delegates the calendar day to the supplied app formatter", () => { - const seen: string[] = []; - const formatDate = (value: string) => { - seen.push(value); - return "FORMATTED"; - }; - expect(formatDateGroupLabel("2026-06-20", formatDate)).toBe("FORMATTED"); - // Handed over as a noon-UTC instant so the day never shifts under the tz. - expect(seen).toEqual(["2026-06-20T12:00:00.000Z"]); - }); - - it("returns a malformed key unchanged without calling the formatter", () => { - const formatDate = () => { - throw new Error("should not be called"); - }; - expect(formatDateGroupLabel("not-a-date", formatDate)).toBe("not-a-date"); - }); -}); diff --git a/src/components/documents/inbound-documents-view.tsx b/src/components/documents/inbound-documents-view.tsx deleted file mode 100644 index aa24edcce..000000000 --- a/src/components/documents/inbound-documents-view.tsx +++ /dev/null @@ -1,1324 +0,0 @@ -"use client"; - -/** - * v1.25 — Documents library. - * - * A self-hoster files any clinical document (doctor report, discharge letter, - * lab result, imaging, prescription, …). Upload is STORE-ONLY and always works, - * even with no AI provider configured — a file is filed with optional title / - * category / filing date. The library groups documents by date, with search, a - * category filter, sort, and keyset pagination. - * - * Each document opens a detail panel: view / download the original, edit its - * metadata, optionally run AI extraction (the explicit, separate enhancement), - * then review-then-confirm the STRUCTURED FACTS the provider transcribed before - * any reaches the structured stores. The screen reproduces what the document - * stated — it never interprets. A low-confidence fact fails closed: it cannot - * be approved until the user edits it. Absent a provider, extraction surfaces a - * calm inline note (configure one) — never a hard error; the stored document is - * untouched. - */ -import { Fragment, useEffect, useRef, useState } from "react"; -import { - useInfiniteQuery, - useMutation, - useQuery, - useQueryClient, -} from "@tanstack/react-query"; -import { toast } from "sonner"; -import { - ArrowDownAZ, - ArrowUpAZ, - ExternalLink, - FileScan, - Info, - Loader2, - Pencil, - RefreshCw, - Search, - Sparkles, - Upload, -} from "lucide-react"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { DateField } from "@/components/ui/date-field"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { NativeSelect } from "@/components/ui/native-select"; -import { Separator } from "@/components/ui/separator"; -import { Skeleton } from "@/components/ui/skeleton"; -import { apiDelete, apiFetch, apiPatch, apiPost } from "@/lib/api/api-fetch"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; -import { invalidateKeys, queryKeys } from "@/lib/query-keys"; -import type { DocumentListParams } from "@/lib/query-keys/documents"; -import { - DOCUMENT_LIST_SORTS, - INBOUND_DOCUMENT_KINDS, - type ConditionFactData, - type DocumentListSort, - type ExtractedFactDto, - type InboundDocumentDetailDto, - type InboundDocumentDto, - type InboundDocumentKindValue, - type MedicationStatementFactData, - type ObservationFactData, -} from "@/lib/validations/inbound-documents"; -import { - buildDocumentListSearch, - classifyUploadError, - confirmFailureReasonKey, - formatDateGroupLabel, - groupDocumentsByDate, - isAlreadyConfirmedError, - isLocalOcrDisabledError, - isProviderUnsupportedError, - MAX_UPLOAD_BYTES, -} from "./library-utils"; - -type ListResponse = { - documents: InboundDocumentDto[]; - nextCursor: string | null; -}; -/** Shape the confirm route returns (HTTP 200) — `failed[]` carries per-fact - * commit misses the client must surface, not silently swallow. */ -type ConfirmResponse = { - approved: { factId: string; recordType: string; recordId: string }[]; - rejected: string[]; - needsReview: string[]; - failed: { factId: string; reason: string }[]; -}; -type Decision = "approve" | "reject"; - -export function InboundDocumentsView() { - const { t } = useTranslations(); - const format = useFormatters(); - const queryClient = useQueryClient(); - const fileRef = useRef(null); - - // Upload form metadata (filed with the document immediately). - const [uploadTitle, setUploadTitle] = useState(""); - const [uploadKind, setUploadKind] = - useState("DOCTOR_REPORT"); - const [uploadDate, setUploadDate] = useState(""); - // Tracks whether a file is staged in the (uncontrolled) file input so the - // upload button can stay disabled until there's something to upload. - const [hasFile, setHasFile] = useState(false); - - // Library toolbar state. - const [q, setQ] = useState(""); - const [debouncedQ, setDebouncedQ] = useState(""); - const [kindFilter, setKindFilter] = useState<"" | InboundDocumentKindValue>( - "", - ); - const [fromDate, setFromDate] = useState(""); - const [toDate, setToDate] = useState(""); - const [sort, setSort] = useState("documentDate"); - const [order, setOrder] = useState<"asc" | "desc">("desc"); - - const [selectedId, setSelectedId] = useState(null); - - // Debounce the search box so a query fires per pause, not per keystroke. - useEffect(() => { - const handle = setTimeout(() => setDebouncedQ(q), 300); - return () => clearTimeout(handle); - }, [q]); - - const filters: DocumentListParams = { - q: debouncedQ.trim() || undefined, - kind: kindFilter || undefined, - from: fromDate || undefined, - to: toDate || undefined, - sort, - order, - }; - - const list = useInfiniteQuery({ - queryKey: queryKeys.inboundDocumentList(filters), - queryFn: ({ pageParam }) => - apiFetch( - `/api/documents/inbound?${buildDocumentListSearch(filters, pageParam)}`, - { method: "GET" }, - ), - initialPageParam: null as string | null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - }); - - const documents = list.data?.pages.flatMap((p) => p.documents) ?? []; - const groups = groupDocumentsByDate(documents); - const isFiltered = Boolean( - filters.q || filters.kind || filters.from || filters.to, - ); - - // Reset search + every filter to the default view. Used by the no-results - // "Clear filters" affordance and after an upload (so the just-stored document - // is in the list the detail panel opens against). - const clearFilters = () => { - setQ(""); - setDebouncedQ(""); - setKindFilter(""); - setFromDate(""); - setToDate(""); - }; - - const upload = useMutation({ - mutationFn: async (file: File) => { - const fd = new FormData(); - fd.append("file", file); - fd.append("kind", uploadKind); - if (uploadTitle.trim()) fd.append("title", uploadTitle.trim()); - if (uploadDate) fd.append("documentDate", uploadDate); - return apiFetch("/api/documents/inbound", { - method: "POST", - body: fd, - signal: null, - }); - }, - onSuccess: (doc) => { - toast.success(t("documents.toast.uploaded")); - // Drop any active filter / search so the just-stored document is in the - // list — otherwise selecting it opens a detail panel for a row the current - // filter excludes, and the user lands on a dead end. Sort by date-added - // (createdAt) desc, NOT documentDate: a user-set past documentDate would - // otherwise file the fresh upload deep in the list, off page 1, where the - // detail panel never renders. The newest upload always has the newest - // createdAt, so this guarantees it lands first and its panel opens. - clearFilters(); - setSort("createdAt"); - setOrder("desc"); - void invalidateKeys(queryClient, [queryKeys.documents()]); - setSelectedId(doc.id); - setUploadTitle(""); - setUploadDate(""); - setHasFile(false); - if (fileRef.current) fileRef.current.value = ""; - }, - onError: (error) => { - switch (classifyUploadError(error)) { - case "tooLarge": - toast.error(t("documents.toast.uploadTooLarge")); - break; - case "fileType": - toast.error(t("documents.toast.uploadFileType")); - break; - case "rateLimited": - toast.error(t("documents.toast.uploadRateLimited")); - break; - case "invalidMetadata": - toast.error(t("documents.toast.uploadInvalidMetadata")); - break; - default: - toast.error(t("documents.toast.uploadFailed")); - } - }, - }); - - return ( -
-
- -
-

- {t("documents.title")} -

-

- {t("documents.subtitle")} -

-
-
- - {/* Upload — store-only, always available. */} - - - - {t("documents.upload.label")} - - - -
- {/* The one required field goes first and full-width, with the - accepted formats + size ceiling beside it (so the limit is known - before an upload fails). */} -
- - setHasFile(Boolean(e.target.files?.length))} - /> -

- {t("documents.upload.accepts")} -

-
-
- - setUploadTitle(e.target.value)} - placeholder={t("documents.upload.titlePlaceholder")} - disabled={upload.isPending} - /> -
-
- - - setUploadKind(e.target.value as InboundDocumentKindValue) - } - disabled={upload.isPending} - > - {INBOUND_DOCUMENT_KINDS.map((k) => ( - - ))} - -
-
- - -
-
-
- - {!hasFile && !upload.isPending ? ( -

- {t("documents.upload.fileRequired")} -

- ) : null} -
-
-
- - {/* Toolbar — search / category filter / sort. */} -
-
- -
- - setQ(e.target.value)} - placeholder={t("documents.toolbar.searchPlaceholder")} - className="pl-8" - /> -
-
-
- - - setKindFilter(e.target.value as "" | InboundDocumentKindValue) - } - > - - {INBOUND_DOCUMENT_KINDS.map((k) => ( - - ))} - -
-
- - -
-
- - -
-
- -
- setSort(e.target.value as DocumentListSort)} - > - {DOCUMENT_LIST_SORTS.map((s) => ( - - ))} - - -
-
-
- - {/* Library list — grouped by date, keyset-paginated. */} -
- {list.isLoading ? ( - // Skeleton rows (not a centered spinner) so the resolved list lands - // in place without a layout jump — the app's loading convention. -
- {[0, 1, 2].map((i) => ( - - ))} -
- ) : list.isError ? ( - // A failed list load is its own state with a retry — never the - // "no documents yet" empty state, which would falsely imply the - // library is empty. -
- {t("documents.list.loadError")} - -
- ) : documents.length === 0 ? ( - isFiltered ? ( -
-

- {t("documents.list.noResults")} -

- -
- ) : ( -

- {t("documents.list.empty")} -

- ) - ) : ( - <> - {groups.map((group) => ( -
-

- {formatDateGroupLabel(group.key, format.date)} -

- {group.documents.map((doc) => { - const expanded = selectedId === doc.id; - // Shared id ties the row's aria-controls to the detail - // region so screen readers know the row owns the panel; the - // panel renders directly after its row for correct order. - const detailId = `documents-detail-${doc.id}`; - return ( - - setSelectedId(expanded ? null : doc.id)} - /> - {expanded ? ( - setSelectedId(null)} - /> - ) : null} - - ); - })} -
- ))} - - {list.hasNextPage ? ( - - ) : null} - - )} -
-
- ); -} - -/** A single library row — title (or filename), kind + status badges, counts. */ -function DocumentRow({ - doc, - expanded, - regionId, - onToggle, -}: { - doc: InboundDocumentDto; - expanded: boolean; - regionId: string; - onToggle: () => void; -}) { - const { t } = useTranslations(); - const display = doc.title ?? doc.filename ?? t(`documents.kind.${doc.kind}`); - - return ( - - ); -} - -export function DocumentDetail({ - documentId, - onClosed, - regionId, -}: { - documentId: string; - onClosed: () => void; - regionId?: string; -}) { - const { t } = useTranslations(); - const format = useFormatters(); - const queryClient = useQueryClient(); - const [decisions, setDecisions] = useState>({}); - const [editOpen, setEditOpen] = useState(false); - - const detail = useQuery({ - queryKey: queryKeys.inboundDocument(documentId), - queryFn: () => - apiFetch( - `/api/documents/inbound/${documentId}`, - { method: "GET" }, - ), - }); - - const extract = useMutation({ - // Empty body → vision mode over the stored original. - mutationFn: () => - apiPost(`/api/documents/inbound/${documentId}/extract`, undefined), - onSuccess: () => { - toast.success(t("documents.toast.extracted")); - void invalidateKeys(queryClient, [queryKeys.documents()]); - }, - onError: (err) => { - // A missing provider — or a document already confirmed elsewhere — is not - // an error the user acts on here. Surface the calm inline note (rendered - // below) instead of an alarming toast. - if (isProviderUnsupportedError(err) || isAlreadyConfirmedError(err)) { - return; - } - // Local OCR switched off in settings: name the cause rather than the - // generic "couldn't extract". - if (isLocalOcrDisabledError(err)) { - toast.error(t("documents.toast.localOcrDisabled")); - return; - } - toast.error(t("documents.toast.extractFailed")); - }, - }); - - const confirm = useMutation({ - mutationFn: (): Promise => - apiPost(`/api/documents/inbound/${documentId}/confirm`, { - decisions: Object.entries(decisions).map(([factId, action]) => ({ - factId, - action, - })), - }), - onSuccess: (data) => { - // The route returns HTTP 200 even when it rejects a fact into `failed[]` - // (e.g. a stated unit that disagrees with the saved marker). A green - // "saved" toast would lie — the fact stays PENDING. Surface the count + - // reason so the user knows to reconcile it, and only show the plain - // success when nothing failed. - const failed = data.failed ?? []; - if (failed.length > 0) { - toast.warning( - t("documents.toast.confirmedPartial", { - count: failed.length, - reason: t(confirmFailureReasonKey(failed)), - }), - ); - } else { - toast.success(t("documents.toast.confirmed")); - } - setDecisions({}); - void invalidateKeys(queryClient, [queryKeys.documents()]); - }, - onError: () => toast.error(t("documents.toast.confirmFailed")), - }); - - const discard = useMutation({ - mutationFn: () => apiDelete(`/api/documents/inbound/${documentId}`), - onSuccess: () => { - toast.success(t("documents.toast.discarded")); - void invalidateKeys(queryClient, [queryKeys.documents()]); - onClosed(); - }, - onError: () => toast.error(t("documents.toast.discardFailed")), - }); - - if (detail.isLoading) { - return ( - - - - - - - - - - - ); - } - if (!detail.data) return null; - - const doc = detail.data; - const pending = doc.facts.filter((f) => f.status === "PENDING"); - // Facts already approved from this document and pushed to the structured - // stores — surfaced read-only below so the document ↔ committed-record link - // stays visible after review (instead of the panel going blank). - const approved = doc.facts.filter((f) => f.status === "APPROVED"); - const decidedCount = Object.keys(decisions).length; - // Mirror the backend's hard guard: extraction is gone once the document is - // confirmed OR any fact on it has already been approved (re-extracting would - // clash with facts already committed to the structured stores). - const hasApprovedFact = doc.facts.some((f) => f.status === "APPROVED"); - const canExtract = doc.status !== "CONFIRMED" && !hasApprovedFact; - const showProviderNote = isProviderUnsupportedError(extract.error); - const showAlreadyConfirmedNote = isAlreadyConfirmedError(extract.error); - const detailTitle = - doc.title ?? doc.filename ?? t(`documents.kind.${doc.kind}`); - - return ( - - - {detailTitle} -
- - - {canExtract ? ( - - ) : null} - - - - - - - - {t("documents.detail.discardConfirmTitle")} - - - {t("documents.detail.discardConfirmBody")} - - - - - {t("documents.detail.discardCancel")} - - discard.mutate()} - > - {t("documents.detail.discardConfirmAction")} - - - - -
-
- - {showProviderNote ? : null} - {showAlreadyConfirmedNote ? : null} - - {pending.length > 0 ? ( - <> -

- {t("documents.review.title")} -

- {pending.map((fact) => ( - - setDecisions((prev) => { - const next = { ...prev }; - if (d === null) delete next[fact.id]; - else next[fact.id] = d; - return next; - }) - } - /> - ))} - - - - ) : approved.length === 0 ? ( -

- {doc.factCount > 0 - ? t("documents.empty.noFacts") - : t("documents.detail.notExtracted")} -

- ) : null} - - {approved.length > 0 ? ( -
- -

- {t("documents.committed.title")} -

-
    - {approved.map((fact) => { - const when = factDate(fact); - return ( -
  • - {factSummary(fact)} - {when ? ` · ${format.date(`${when}T12:00:00.000Z`)}` : ""} -
  • - ); - })} -
-
- ) : null} -
- - - - - {t("documents.edit.title")} - - {t("documents.edit.subtitle")} - - - setEditOpen(false)} - onCancel={() => setEditOpen(false)} - /> - - -
- ); -} - -/** - * A calm inline note. Carries `role="status"` + `aria-live="polite"` so that - * keyboard / screen-reader users hear it when it appears — these notes stand in - * for a toast that the mutation deliberately suppresses. - */ -function InlineNote({ slot, children }: { slot: string; children: string }) { - return ( -
- -

{children}

-
- ); -} - -/** The calm inline note shown when extraction needs a provider that's absent. */ -export function ProviderUnsupportedNote() { - const { t } = useTranslations(); - return ( - - {t("documents.note.providerUnsupported")} - - ); -} - -/** Calm inline note when extraction is refused — the document is confirmed. */ -export function AlreadyConfirmedNote() { - const { t } = useTranslations(); - return ( - - {t("documents.note.alreadyConfirmed")} - - ); -} - -/** Metadata edit (rename / recategorise / set the filing date). PATCHes. */ -export function DocumentMetaEditForm({ - doc, - onSaved, - onCancel, -}: { - doc: InboundDocumentDto; - onSaved: () => void; - onCancel: () => void; -}) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [title, setTitle] = useState(doc.title ?? ""); - const [kind, setKind] = useState(doc.kind); - const [date, setDate] = useState(doc.documentDate ?? ""); - - const save = useMutation({ - mutationFn: () => - apiPatch(`/api/documents/inbound/${doc.id}`, { - title: title.trim() === "" ? null : title.trim(), - kind, - documentDate: date === "" ? null : date, - }), - onSuccess: () => { - toast.success(t("documents.toast.updated")); - void invalidateKeys(queryClient, [queryKeys.documents()]); - onSaved(); - }, - onError: () => toast.error(t("documents.toast.updateFailed")), - }); - - return ( -
-
- - setTitle(e.target.value)} - placeholder={t("documents.upload.titlePlaceholder")} - /> -
-
- - setKind(e.target.value as InboundDocumentKindValue)} - > - {INBOUND_DOCUMENT_KINDS.map((k) => ( - - ))} - -
-
- - -
- - - - -
- ); -} - -function FactCard({ - documentId, - fact, - decision, - onDecision, -}: { - documentId: string; - fact: ExtractedFactDto; - decision: Decision | undefined; - onDecision: (d: Decision | null) => void; -}) { - const { t } = useTranslations(); - const format = useFormatters(); - const queryClient = useQueryClient(); - const [editing, setEditing] = useState(false); - - const summary = factSummary(fact); - - const save = useMutation({ - mutationFn: (payload: unknown) => - apiPatch( - `/api/documents/inbound/${documentId}/facts/${fact.id}`, - payload, - ), - onSuccess: () => { - toast.success(t("documents.toast.saved")); - setEditing(false); - void invalidateKeys(queryClient, [queryKeys.inboundDocument(documentId)]); - }, - }); - - return ( -
-
-
-
- - {t(`documents.factType.${fact.factType}`)} - - {fact.needsReview ? ( - // A calm, neutral "please check" — not an alarming red, and not - // amber (the app is moving away from amber warning tints). A - // low-confidence fact is a gentle nudge to review, not an error. - - {t("documents.review.needsReview")} - - ) : null} - - - {t("documents.review.confidenceLabel")}:{" "} - - {format.percent(fact.confidence)} - -
-

{summary}

- {fact.provenance.sourceText ? ( -

- “{fact.provenance.sourceText}” -

- ) : null} - {fact.needsReview ? ( - // Ties the disabled Approve button to the "Needs review" badge: a - // low-confidence fact must be edited before it can be approved. -

- {t("documents.review.needsReviewHint")} -

- ) : null} -
-
- - -
-
- -
- -
- - {editing ? ( - save.mutate(payload)} - /> - ) : null} -
- ); -} - -function FactEditForm({ - fact, - pending, - onSubmit, -}: { - fact: ExtractedFactDto; - pending: boolean; - onSubmit: (payload: unknown) => void; -}) { - const { t } = useTranslations(); - - if (fact.factType === "CONDITION") { - const d = fact.data as ConditionFactData; - return ( - - onSubmit({ - factType: "CONDITION", - label: vals.label, - onsetDate: date, - }) - } - /> - ); - } - if (fact.factType === "OBSERVATION") { - const d = fact.data as ObservationFactData; - return ( - { - const numeric = Number(vals.value); - const isNum = vals.value.trim() !== "" && !Number.isNaN(numeric); - onSubmit({ - factType: "OBSERVATION", - label: vals.label, - value: isNum ? numeric : null, - valueText: isNum ? null : vals.value || null, - unit: vals.unit || null, - effectiveDate: date, - }); - }} - /> - ); - } - const d = fact.data as MedicationStatementFactData; - return ( - - onSubmit({ - factType: "MEDICATION_STATEMENT", - name: vals.name, - dose: vals.dose || null, - effectiveDate: date, - }) - } - /> - ); -} - -function EditFields({ - idPrefix, - fields, - dateValue, - pending, - onSubmit, -}: { - // Per-fact id namespace so simultaneously-open editors never share DOM ids - // (which would break every label's htmlFor → focus association). - idPrefix: string; - fields: { key: string; label: string; value: string }[]; - dateValue: string | null; - pending: boolean; - onSubmit: (vals: Record, date: string | null) => void; -}) { - const { t } = useTranslations(); - const [vals, setVals] = useState>(() => - Object.fromEntries(fields.map((f) => [f.key, f.value])), - ); - const [date, setDate] = useState(dateValue ?? ""); - - return ( -
- {fields.map((f) => ( -
- - - setVals((p) => ({ ...p, [f.key]: e.target.value })) - } - /> -
- ))} -
- - -
- -
- ); -} - -/** A one-line transcription summary of a staged fact (no interpretation). */ -function factSummary(fact: ExtractedFactDto): string { - if (fact.factType === "OBSERVATION") { - const d = fact.data as ObservationFactData; - const v = - d.value !== null - ? `${d.value}${d.unit ? ` ${d.unit}` : ""}` - : d.valueText; - return v ? `${d.label}: ${v}` : d.label; - } - if (fact.factType === "MEDICATION_STATEMENT") { - const d = fact.data as MedicationStatementFactData; - return d.dose ? `${d.name} — ${d.dose}` : d.name; - } - const d = fact.data as ConditionFactData; - return d.label; -} - -/** The effective / onset date a staged fact carries, if any (YYYY-MM-DD). */ -function factDate(fact: ExtractedFactDto): string | null { - if (fact.factType === "CONDITION") { - return (fact.data as ConditionFactData).onsetDate; - } - if (fact.factType === "OBSERVATION") { - return (fact.data as ObservationFactData).effectiveDate; - } - return (fact.data as MedicationStatementFactData).effectiveDate; -} diff --git a/src/components/documents/library-utils.ts b/src/components/documents/library-utils.ts deleted file mode 100644 index db3bb94d5..000000000 --- a/src/components/documents/library-utils.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * v1.25 — pure helpers for the documents library (search-param building, date - * grouping, provider-unsupported classification). Kept out of the client - * component so they stay trivially unit-testable without a render harness. - */ -import { ApiError } from "@/lib/api/api-fetch"; -import type { DocumentListParams } from "@/lib/query-keys/documents"; -import { - DOCUMENT_LIST_DEFAULT_LIMIT, - type InboundDocumentDto, -} from "@/lib/validations/inbound-documents"; - -/** - * Build the `/api/documents/inbound` query string from the active filters and - * a keyset cursor. Empty / undefined fields are omitted so the URL stays clean - * and the server applies its own defaults. The cursor is the `nextCursor` the - * previous page returned (null on the first page). - */ -export function buildDocumentListSearch( - params: DocumentListParams, - cursor: string | null, - limit: number = DOCUMENT_LIST_DEFAULT_LIMIT, -): string { - const sp = new URLSearchParams(); - if (params.q && params.q.trim() !== "") sp.set("q", params.q.trim()); - if (params.kind) sp.set("kind", params.kind); - if (params.from) sp.set("from", params.from); - if (params.to) sp.set("to", params.to); - sp.set("sort", params.sort); - sp.set("order", params.order); - 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 its 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); -} - -export interface DocumentDateGroup { - /** The YYYY-MM-DD key the group files under. */ - key: string; - documents: InboundDocumentDto[]; -} - -/** - * Group an already-sorted document list into consecutive date buckets, - * preserving the server's sort order. A document's bucket is its `documentDate` - * (fallback: its upload day). The first time a date appears it opens a new - * group; later documents on the same date join it. Order is preserved exactly - * as received, so the server stays the single source of truth for sorting. - */ -export function groupDocumentsByDate( - documents: InboundDocumentDto[], -): DocumentDateGroup[] { - const groups: DocumentDateGroup[] = []; - let current: DocumentDateGroup | null = null; - for (const doc of documents) { - const key = documentDateKey(doc); - if (!current || current.key !== key) { - current = { key, documents: [doc] }; - groups.push(current); - } else { - current.documents.push(doc); - } - } - return groups; -} - -/** - * True when an error is the extract route's 422 "no document-scan provider - * configured" signal. The UI surfaces this as a calm inline note (configure a - * provider) rather than a hard error toast — the stored document is untouched. - */ -export function isProviderUnsupportedError(error: unknown): boolean { - return ( - error instanceof ApiError && - error.meta?.errorCode === "documents.inbound.providerUnsupported" - ); -} - -/** - * True when an error is the extract route's "already confirmed" signal. Two - * shapes reach here: - * - 409 `alreadyPartlyConfirmed` — some facts approved, the rest still pending; - * - 422 `alreadyConfirmed` — the whole document is CONFIRMED (a cross-tab race - * can fire Extract against a document a second tab just finished confirming). - * Re-extraction is refused server-side in both cases; the UI surfaces a calm - * inline note rather than a hard error toast, and hides the Extract control to - * match. - */ -export function isAlreadyConfirmedError(error: unknown): boolean { - if (!(error instanceof ApiError)) return false; - const code = error.meta?.errorCode; - return ( - code === "documents.inbound.alreadyPartlyConfirmed" || - code === "documents.inbound.alreadyConfirmed" - ); -} - -/** - * True when an extract failure is the route's "local OCR not enabled" signal - * (`documents.inbound.localOcrDisabled`, 422). The TEXT extract path refuses - * when in-browser OCR is switched off in settings; surface a clear message that - * names the setting rather than the generic "couldn't extract" toast. - */ -export function isLocalOcrDisabledError(error: unknown): boolean { - return ( - error instanceof ApiError && - error.meta?.errorCode === "documents.inbound.localOcrDisabled" - ); -} - -/** - * Map a per-fact commit-failure code to the i18n key for its plain-language - * reason. The confirm route returns HTTP 200 even when it rejects a fact into - * its `failed[]` array (e.g. a stated unit that disagrees with the saved - * marker), so the client must surface why — otherwise the fact silently stays - * PENDING with the user told nothing. - */ -export function commitFailureReasonKey(code: string): string { - switch (code) { - case "observation.unitMismatch": - return "documents.review.commitError.unitMismatch"; - case "observation.unitRequired": - return "documents.review.commitError.unitRequired"; - default: - return "documents.review.commitError.generic"; - } -} - -/** - * Pick the single reason key that best describes a batch of failed facts: the - * shared reason when every failure agrees, else a generic fallback. Used to - * build the partial-failure toast after a confirm that rejected one or more - * facts server-side. - */ -export function confirmFailureReasonKey(failed: { reason: string }[]): string { - const codes = new Set(failed.map((f) => f.reason)); - if (codes.size === 1) return commitFailureReasonKey([...codes][0]); - return "documents.review.commitError.generic"; -} - -/** - * Client-side upload ceiling. Mirrors the server's `OCR_MAX_BYTES` (12 MB) so an - * oversized file is rejected before it is sent, instead of after a full upload - * round-trips and returns 413. - */ -export const MAX_UPLOAD_BYTES = 12 * 1024 * 1024; - -/** - * The class of an upload (`POST /api/documents/inbound`) failure. The route - * reports precise, actionable codes the generic toast would throw away; this - * maps them to a stable kind so the view can show the right message and keep - * every `t()` key a literal call site. - */ -export type UploadErrorKind = - "tooLarge" | "fileType" | "rateLimited" | "invalidMetadata" | "generic"; - -/** Classify an upload failure by the route's `errorCode`, then HTTP status. */ -export function classifyUploadError(error: unknown): UploadErrorKind { - if (!(error instanceof ApiError)) return "generic"; - switch (error.meta?.errorCode) { - case "documents.inbound.fileTooLarge": - return "tooLarge"; - case "documents.inbound.fileType": - return "fileType"; - case "documents.inbound.rateLimited": - return "rateLimited"; - case "documents.inbound.invalidMetadata": - return "invalidMetadata"; - } - // A reverse proxy can strip the JSON body (and its `meta`) on some statuses; - // fall back to the HTTP code so size / type / rate-limit still classify. - if (error.status === 413) return "tooLarge"; - if (error.status === 415) return "fileType"; - if (error.status === 429) return "rateLimited"; - return "generic"; -} - -/** - * Format a YYYY-MM-DD group key into a date label via the app's own date - * formatter (`useFormatters().date`), so the group headers follow the user's - * date-format preference (numeric) like the rest of the app instead of a textual - * `dateStyle: "medium"` month. The key is handed over as a noon-UTC instant so - * the calendar day never shifts under the display timezone. - */ -export function formatDateGroupLabel( - key: string, - formatDate: (value: string) => string, -): string { - if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) return key; - return formatDate(`${key}T12:00:00.000Z`); -}