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
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 12:26:07 +02:00
1 parent b8009d2e73
commit 2b7a6544d6
4 files changed
-2181

No files matched your search

@@ -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<typeof import("@/lib/api/api-fetch")>(
"@/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> = {},
): 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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
describe("<InboundDocumentsView> library", () => {
it("renders the grouped list with titles, badges, and load-more", () => {
const html = render(<InboundDocumentsView />, (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(<InboundDocumentsView />, (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(<InboundDocumentsView />, (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(<InboundDocumentsView />, (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("<DocumentDetail>", () => {
function detail(over: Partial<InboundDocumentDetailDto> = {}) {
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(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(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(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(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(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(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(
<DocumentDetail
documentId="d1"
regionId="documents-detail-d1"
onClosed={() => {}}
/>,
(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(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(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(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(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(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(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(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(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("<DocumentMetaEditForm>", () => {
it("renders the metadata fields seeded from the document", () => {
const html = render(
<DocumentMetaEditForm
doc={doc("d1", { title: "Old title", documentDate: "2026-06-20" })}
onSaved={() => {}}
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("<ProviderUnsupportedNote>", () => {
it("renders the calm inline note as a polite live region (A11Y-H1)", () => {
const html = render(<ProviderUnsupportedNote />);
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("<AlreadyConfirmedNote>", () => {
it("renders a calm polite live region when extraction is refused", () => {
const html = render(<AlreadyConfirmedNote />);
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");
});
});
@@ -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<unknown>(
(acc, part) =>
acc && typeof acc === "object"
? (acc as Record<string, unknown>)[part]
: undefined,
bundle,
);
}
function doc(
id: string,
over: Partial<InboundDocumentDto> = {},
): 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<string, string> = {
"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");
});
});
File diff suppressed because it is too large. Load diff
-197
View File
@@ -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`);
}