fix(documents): surface confirm failures and harden the library edges

Close the gaps the final verification pass flagged on the documents surface:

- Confirm no longer claims success when the route rejects a fact into its
  `failed[]` array (HTTP 200). A unit-mismatch fact left silently PENDING with
  a green toast now raises a warning naming how many couldn't be saved and why.
- Unify the German documents copy on "Angaben": the discard-confirm body, the
  provider-unsupported note, and the module description still said "Fakten".
- After upload, sort by date-added rather than document date so a back-dated
  upload always lands on the first page where its detail panel can open.
- Give the list its own load-error state with a retry instead of rendering the
  "no documents yet" empty state on a failed fetch.
- Classify the local-OCR-disabled extract error to a clear message.
- Drop the orphaned settings.sections.notifications.inboxLink key (no call site).
This commit is contained in:
Marc-André Bombeck committed 2026-06-29 01:03:44 +02:00
1 parent 2ec4828c1f
commit 679f8a05c9
9 files changed
+254 -33

No files matched your search

+13 -6
View File
@@ -4066,7 +4066,6 @@
"description": "Alle Erinnerungen an einem Ort — was wann erinnert wird. Die Zustellkanäle liegen unter Integrationen.",
"channelsHint": "Telegram, ntfy, Web-Push, Webhook und E-Mail richtest du jetzt unter",
"channelsHintLink": "Integrationen → Kanäle",
"inboxLink": "Posteingang öffnen",
"subtitle": "Wähle, welche Erinnerungen du erhältst."
},
"layout": {
@@ -7361,7 +7360,7 @@
},
"inboundDocuments": {
"label": "Dokumente",
"description": "Speichere deine medizinischen Dokumente; optional ihre Fakten extrahieren und prüfen, bevor sie gespeichert werden. Standardmäßig aus."
"description": "Speichere deine medizinischen Dokumente; optional ihre Angaben auslesen und prüfen, bevor sie gespeichert werden. Standardmäßig aus."
},
"mentalHealth": {
"label": "Seelisches Wohlbefinden",
@@ -7869,7 +7868,8 @@
"loadingMore": "Wird geladen…",
"pending": "{count} zu prüfen",
"facts": "{count} Angaben",
"clearFilters": "Filter zurücksetzen"
"clearFilters": "Filter zurücksetzen",
"loadError": "Deine Dokumente konnten nicht geladen werden."
},
"status": {
"EXTRACTING": "Wird gelesen",
@@ -7890,7 +7890,12 @@
"cancel": "Abbrechen",
"save": "Speichern",
"needsReviewHint": "Bearbeite diese Angabe, um sie zu übernehmen.",
"confidenceLabel": "Erkennungssicherheit"
"confidenceLabel": "Erkennungssicherheit",
"commitError": {
"unitMismatch": "die Einheit stimmt nicht mit der gespeicherten überein",
"unitRequired": "ein Zahlenwert braucht eine Einheit",
"generic": "es konnte nicht gespeichert werden"
}
},
"empty": {
"noFacts": "Nichts mehr zu prüfen."
@@ -7911,11 +7916,13 @@
"uploaded": "Dokument gespeichert.",
"uploadFailed": "Dokument konnte nicht gespeichert werden.",
"confirmed": "Bestätigte Angaben gespeichert.",
"confirmedPartial": "Gespeichert, aber {count} konnten nicht gespeichert werden — {reason}.",
"confirmFailed": "Deine Entscheidungen konnten nicht gespeichert werden.",
"saved": "Angabe aktualisiert.",
"discarded": "Dokument verworfen.",
"extracted": "Angaben ausgelesen. Prüfe sie unten.",
"extractFailed": "Angaben konnten nicht ausgelesen werden.",
"localOcrDisabled": "Aktiviere lokale OCR in den Einstellungen, um dieses Dokument auf dem Gerät zu lesen.",
"updated": "Dokument aktualisiert.",
"updateFailed": "Dokument konnte nicht aktualisiert werden.",
"discardFailed": "Dokument konnte nicht verworfen werden.",
@@ -7949,7 +7956,7 @@
"notExtracted": "Noch keine Angaben ausgelesen. Nutze „Angaben auslesen“, um dieses Dokument zu lesen.",
"viewOriginalNewTab": "(öffnet in einem neuen Tab)",
"discardConfirmTitle": "Dieses Dokument verwerfen?",
"discardConfirmBody": "Das löscht das Dokument, die Originaldatei und alle vorbereiteten Fakten dauerhaft. Das lässt sich nicht rückgängig machen.",
"discardConfirmBody": "Das löscht das Dokument, die Originaldatei und alle vorbereiteten Angaben dauerhaft. Das lässt sich nicht rückgängig machen.",
"discardConfirmAction": "Verwerfen",
"discardCancel": "Abbrechen"
},
@@ -7964,7 +7971,7 @@
"cancel": "Abbrechen"
},
"note": {
"providerUnsupported": "Richte in den KI-Einstellungen einen Anbieter für Dokumentenscans ein, um Fakten automatisch zu extrahieren.",
"providerUnsupported": "Richte in den KI-Einstellungen einen Anbieter für Dokumentenscans ein, um Angaben automatisch auszulesen.",
"alreadyConfirmed": "Dieses Dokument wurde bereits geprüft und kann nicht erneut ausgelesen werden."
},
"committed": {
+10 -3
View File
@@ -4066,7 +4066,6 @@
"description": "Every reminder in one place — what gets reminded and when. The delivery channels live under Integrations.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook, and Email are now set up under",
"channelsHintLink": "Integrations → Channels",
"inboxLink": "Open the inbox",
"subtitle": "Choose which reminders you receive."
},
"layout": {
@@ -7869,7 +7868,8 @@
"loadingMore": "Loading…",
"pending": "{count} to review",
"facts": "{count} facts",
"clearFilters": "Clear filters"
"clearFilters": "Clear filters",
"loadError": "Couldn't load your documents."
},
"status": {
"EXTRACTING": "Reading",
@@ -7890,7 +7890,12 @@
"cancel": "Cancel",
"save": "Save",
"needsReviewHint": "Edit this fact to approve it.",
"confidenceLabel": "Extraction confidence"
"confidenceLabel": "Extraction confidence",
"commitError": {
"unitMismatch": "the unit doesn't match the saved one",
"unitRequired": "a numeric value needs a unit",
"generic": "it couldn't be saved"
}
},
"empty": {
"noFacts": "Nothing left to review."
@@ -7911,11 +7916,13 @@
"uploaded": "Document stored.",
"uploadFailed": "Couldn't store the document.",
"confirmed": "Approved facts saved.",
"confirmedPartial": "Saved, but {count} couldn't be saved — {reason}.",
"confirmFailed": "Couldn't save your decisions.",
"saved": "Fact updated.",
"discarded": "Document discarded.",
"extracted": "Facts extracted. Review them below.",
"extractFailed": "Couldn't extract facts.",
"localOcrDisabled": "Enable local OCR in settings to read this document on-device.",
"updated": "Document updated.",
"updateFailed": "Couldn't update the document.",
"discardFailed": "Couldn't discard the document.",
+10 -3
View File
@@ -4066,7 +4066,6 @@
"description": "Todos los recordatorios en un solo lugar: qué se recuerda y cuándo. Los canales de entrega están en Integraciones.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook y correo se configuran ahora en",
"channelsHintLink": "Integraciones → Canales",
"inboxLink": "Abrir la bandeja de entrada",
"subtitle": "Elige qué recordatorios recibes."
},
"layout": {
@@ -7869,7 +7868,8 @@
"loadingMore": "Cargando…",
"pending": "{count} por revisar",
"facts": "{count} datos",
"clearFilters": "Borrar filtros"
"clearFilters": "Borrar filtros",
"loadError": "No se pudieron cargar tus documentos."
},
"status": {
"EXTRACTING": "Leyendo",
@@ -7890,7 +7890,12 @@
"cancel": "Cancelar",
"save": "Guardar",
"needsReviewHint": "Edita este dato para poder aprobarlo.",
"confidenceLabel": "Confianza de la extracción"
"confidenceLabel": "Confianza de la extracción",
"commitError": {
"unitMismatch": "la unidad no coincide con la guardada",
"unitRequired": "un valor numérico necesita una unidad",
"generic": "no se pudo guardar"
}
},
"empty": {
"noFacts": "Nada más que revisar."
@@ -7911,11 +7916,13 @@
"uploaded": "Documento guardado.",
"uploadFailed": "No se pudo guardar el documento.",
"confirmed": "Datos aprobados guardados.",
"confirmedPartial": "Guardado, pero {count} no se pudieron guardar: {reason}.",
"confirmFailed": "No se pudieron guardar tus decisiones.",
"saved": "Dato actualizado.",
"discarded": "Documento descartado.",
"extracted": "Datos extraídos. Revísalos abajo.",
"extractFailed": "No se pudieron extraer los datos.",
"localOcrDisabled": "Activa el OCR local en los ajustes para leer este documento en el dispositivo.",
"updated": "Documento actualizado.",
"updateFailed": "No se pudo actualizar el documento.",
"discardFailed": "No se pudo descartar el documento.",
+10 -3
View File
@@ -4066,7 +4066,6 @@
"description": "Tous les rappels au même endroit — quoi et quand. Les canaux de diffusion se trouvent dans Intégrations.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook et e-mail se configurent désormais dans",
"channelsHintLink": "Intégrations → Canaux",
"inboxLink": "Ouvrir la boîte de réception",
"subtitle": "Choisissez les rappels que vous recevez."
},
"layout": {
@@ -7869,7 +7868,8 @@
"loadingMore": "Chargement…",
"pending": "{count} à vérifier",
"facts": "{count} éléments",
"clearFilters": "Effacer les filtres"
"clearFilters": "Effacer les filtres",
"loadError": "Impossible de charger vos documents."
},
"status": {
"EXTRACTING": "Lecture",
@@ -7890,7 +7890,12 @@
"cancel": "Annuler",
"save": "Enregistrer",
"needsReviewHint": "Modifie cette donnée pour pouvoir la valider.",
"confidenceLabel": "Confiance d'extraction"
"confidenceLabel": "Confiance d'extraction",
"commitError": {
"unitMismatch": "l'unité ne correspond pas à celle enregistrée",
"unitRequired": "une valeur numérique nécessite une unité",
"generic": "il n'a pas pu être enregistré"
}
},
"empty": {
"noFacts": "Plus rien à vérifier."
@@ -7911,11 +7916,13 @@
"uploaded": "Document enregistré.",
"uploadFailed": "Impossible d'enregistrer le document.",
"confirmed": "Données approuvées enregistrées.",
"confirmedPartial": "Enregistré, mais {count} n'ont pas pu être enregistrés — {reason}.",
"confirmFailed": "Impossible d'enregistrer vos décisions.",
"saved": "Donnée mise à jour.",
"discarded": "Document écarté.",
"extracted": "Éléments extraits. Vérifiez-les ci-dessous.",
"extractFailed": "Impossible d'extraire les éléments.",
"localOcrDisabled": "Activez l'OCR local dans les paramètres pour lire ce document sur l'appareil.",
"updated": "Document mis à jour.",
"updateFailed": "Impossible de mettre à jour le document.",
"discardFailed": "Impossible de supprimer le document.",
+10 -3
View File
@@ -4066,7 +4066,6 @@
"description": "Tutti i promemoria in un unico posto — cosa e quando. I canali di recapito si trovano in Integrazioni.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook ed e-mail ora si configurano in",
"channelsHintLink": "Integrazioni → Canali",
"inboxLink": "Apri la posta in arrivo",
"subtitle": "Scegli quali promemoria ricevere."
},
"layout": {
@@ -7869,7 +7868,8 @@
"loadingMore": "Caricamento…",
"pending": "{count} da rivedere",
"facts": "{count} dati",
"clearFilters": "Cancella i filtri"
"clearFilters": "Cancella i filtri",
"loadError": "Impossibile caricare i tuoi documenti."
},
"status": {
"EXTRACTING": "Lettura",
@@ -7890,7 +7890,12 @@
"cancel": "Annulla",
"save": "Salva",
"needsReviewHint": "Modifica questo dato per poterlo approvare.",
"confidenceLabel": "Affidabilità dell'estrazione"
"confidenceLabel": "Affidabilità dell'estrazione",
"commitError": {
"unitMismatch": "l'unità non corrisponde a quella salvata",
"unitRequired": "un valore numerico richiede un'unità",
"generic": "non è stato possibile salvarlo"
}
},
"empty": {
"noFacts": "Niente altro da rivedere."
@@ -7911,11 +7916,13 @@
"uploaded": "Documento salvato.",
"uploadFailed": "Impossibile salvare il documento.",
"confirmed": "Dati approvati salvati.",
"confirmedPartial": "Salvato, ma {count} non è stato possibile salvare: {reason}.",
"confirmFailed": "Impossibile salvare le tue scelte.",
"saved": "Dato aggiornato.",
"discarded": "Documento scartato.",
"extracted": "Dati estratti. Rivedili qui sotto.",
"extractFailed": "Impossibile estrarre i dati.",
"localOcrDisabled": "Attiva l'OCR locale nelle impostazioni per leggere questo documento sul dispositivo.",
"updated": "Documento aggiornato.",
"updateFailed": "Impossibile aggiornare il documento.",
"discardFailed": "Impossibile eliminare il documento.",
+10 -3
View File
@@ -4066,7 +4066,6 @@
"description": "Wszystkie przypomnienia w jednym miejscu — co i kiedy. Kanały dostarczania znajdują się w Integracjach.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook i e-mail konfigurujesz teraz w",
"channelsHintLink": "Integracje → Kanały",
"inboxLink": "Otwórz skrzynkę odbiorczą",
"subtitle": "Wybierz, które przypomnienia otrzymujesz."
},
"layout": {
@@ -7869,7 +7868,8 @@
"loadingMore": "Ładowanie…",
"pending": "{count} do przeglądu",
"facts": "{count} faktów",
"clearFilters": "Wyczyść filtry"
"clearFilters": "Wyczyść filtry",
"loadError": "Nie udało się wczytać Twoich dokumentów."
},
"status": {
"EXTRACTING": "Odczyt",
@@ -7890,7 +7890,12 @@
"cancel": "Anuluj",
"save": "Zapisz",
"needsReviewHint": "Edytuj tę pozycję, aby ją zatwierdzić.",
"confidenceLabel": "Pewność rozpoznania"
"confidenceLabel": "Pewność rozpoznania",
"commitError": {
"unitMismatch": "jednostka nie zgadza się z zapisaną",
"unitRequired": "wartość liczbowa wymaga jednostki",
"generic": "nie udało się zapisać"
}
},
"empty": {
"noFacts": "Nic więcej do przejrzenia."
@@ -7911,11 +7916,13 @@
"uploaded": "Dokument zapisany.",
"uploadFailed": "Nie udało się zapisać dokumentu.",
"confirmed": "Zatwierdzone dane zapisane.",
"confirmedPartial": "Zapisano, ale {count} nie udało się zapisać — {reason}.",
"confirmFailed": "Nie udało się zapisać Twoich decyzji.",
"saved": "Dane zaktualizowane.",
"discarded": "Dokument odrzucony.",
"extracted": "Fakty wyodrębnione. Przejrzyj je poniżej.",
"extractFailed": "Nie udało się wyodrębnić faktów.",
"localOcrDisabled": "Włącz lokalne OCR w ustawieniach, aby odczytać ten dokument na urządzeniu.",
"updated": "Dokument zaktualizowany.",
"updateFailed": "Nie udało się zaktualizować dokumentu.",
"discardFailed": "Nie udało się odrzucić dokumentu.",
@@ -2,16 +2,35 @@ 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> = {},
@@ -182,6 +201,67 @@ describe("classifyUploadError", () => {
});
});
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[] = [];
@@ -34,6 +34,7 @@ import {
Info,
Loader2,
Pencil,
RefreshCw,
Search,
Sparkles,
Upload,
@@ -85,9 +86,11 @@ import {
import {
buildDocumentListSearch,
classifyUploadError,
confirmFailureReasonKey,
formatDateGroupLabel,
groupDocumentsByDate,
isAlreadyConfirmedError,
isLocalOcrDisabledError,
isProviderUnsupportedError,
MAX_UPLOAD_BYTES,
} from "./library-utils";
@@ -96,6 +99,14 @@ 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() {
@@ -184,13 +195,15 @@ export function InboundDocumentsView() {
},
onSuccess: (doc) => {
toast.success(t("documents.toast.uploaded"));
// Drop any active filter / search / sort 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. A fresh
// upload files under today, so the default documentDate-desc view shows it
// at the top.
// 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("documentDate");
setSort("createdAt");
setOrder("desc");
void invalidateKeys(queryClient, [queryKeys.documents()]);
setSelectedId(doc.id);
@@ -447,6 +460,27 @@ export function InboundDocumentsView() {
<Skeleton key={i} className="h-12 w-full rounded-md" />
))}
</div>
) : 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.
<div
role="alert"
data-slot="documents-list-error"
className="text-muted-foreground flex flex-col items-start gap-3 py-4 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("documents.list.loadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void list.refetch()}
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden />
<span>{t("common.retry")}</span>
</Button>
</div>
) : documents.length === 0 ? (
isFiltered ? (
<div
@@ -610,22 +644,44 @@ export function DocumentDetail({
// 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)) {
toast.error(t("documents.toast.extractFailed"));
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: () =>
apiPost(`/api/documents/inbound/${documentId}/confirm`, {
mutationFn: (): Promise<ConfirmResponse> =>
apiPost<ConfirmResponse>(`/api/documents/inbound/${documentId}/confirm`, {
decisions: Object.entries(decisions).map(([factId, action]) => ({
factId,
action,
})),
}),
onSuccess: () => {
toast.success(t("documents.toast.confirmed"));
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()]);
},
+43
View File
@@ -101,6 +101,49 @@ export function isAlreadyConfirmedError(error: unknown): boolean {
);
}
/**
* 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