import { useCallback } from "react"; import { toast } from "sonner"; import axios from "axios"; // Logs an error with a flat one-liner header (method/url/status) when it's an axios error, // then the full error object on the next line for DevTools expansion. const logError = (err: unknown): void => { if (axios.isAxiosError(err)) { const method = err.config?.method?.toUpperCase() ?? "?"; const url = err.config?.url ?? "?"; const status = err.response?.status ?? "no-response"; console.error( `[API ERROR] ${method} ${url} → ${status}`, err.response?.data, ); return; } console.error("Error caught:", err); }; // Returns the first non-empty string found in value (string, array, or nested object with message/error/detail). Never throws. const extractMessage = (value: unknown): string | null => { try { if (value == null) return null; if (typeof value === "string") { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } if (Array.isArray(value)) { const parts: string[] = []; for (const item of value) { const part = extractMessage(item); if (part) parts.push(part); } return parts.length > 0 ? parts.join(", ") : null; } if (typeof value === "object") { const obj = value as Record; return ( extractMessage(obj.message) ?? extractMessage(obj.error) ?? extractMessage(obj.detail) ?? null ); } return null; } catch (extractError) { console.warn("extractMessage: unexpected failure", extractError); return null; } }; // IAM (@tria-plc/iamapi-common) throws BadRequestException with a raw, // untranslated code string as the message — no i18n on that side — so it // would otherwise reach the UI verbatim (e.g. "user_role_not_found"). Map // known codes to a friendly, translated message before falling back to the // raw text. `unit_employee_limit_reached` carries its configured limit after // a colon (e.g. "unit_employee_limit_reached:5"). const UNIT_EMPLOYEE_LIMIT_PREFIX = "unit_employee_limit_reached:"; const mapIamErrorCode = ( raw: string | null, t: (key: string, options?: Record) => string, ): string | null => { if (!raw) return null; if (raw.startsWith(UNIT_EMPLOYEE_LIMIT_PREFIX)) { return t("msg.unitEmployeeLimitReached", { limit: raw.slice(UNIT_EMPLOYEE_LIMIT_PREFIX.length), }); } if (raw === "user_role_not_found") return t("msg.userRoleNotFound"); return null; }; // Maps an HTTP status code to the i18n key used when no backend message is available. const statusKeyFor = (status: number | undefined): string => { if (status === 400 || status === 422) return "msg.validationError"; if (status === 401) return "msg.authError"; if (status === 403) return "msg.permissionError"; if (status === 404) return "msg.notFoundError"; if (status === 413) return "msg.fileTooLarge"; if (status !== undefined && status >= 500 && status < 600) return "msg.serverError"; return "msg.genericError"; }; // Detects the bulk-upload structured error shape returned by the backend: // { message: "duplicate_values_found", errors: { fieldName: "code: value" } } // Returns { title, description } when matched, null otherwise. Description is // one "field: value" pair per line, suitable for a Sonner toast `description`. const extractBulkUploadInfo = ( data: unknown, t: (key: string) => string, ): { title: string; description: string } | null => { if (!data || typeof data !== "object") return null; const d = data as { message?: unknown; errors?: unknown }; if (d.message !== "duplicate_values_found") return null; if (!d.errors || typeof d.errors !== "object") return null; const lines: string[] = []; for (const [field, value] of Object.entries( d.errors as Record, )) { if (typeof value === "string" && value.length > 0) { lines.push(`${field}: ${value}`); } } if (lines.length === 0) return null; // If `msg.duplicateValuesFound` isn't in translation files yet, t() returns // the raw key. Detect that and fall back to a readable English string. const translatedTitle = t("msg.duplicateValuesFound"); const title = translatedTitle === "msg.duplicateValuesFound" ? "Duplicate values found" : translatedTitle; return { title, description: lines.join("\n") }; }; // Reads a Blob as JSON. Returns null on any failure. const parseBlobBody = async (blob: Blob): Promise => { try { const text = await blob.text(); return JSON.parse(text); } catch (parseError) { console.warn("useErrorHandler: failed to parse Blob error body", parseError); return null; } }; export const useErrorHandler = ( t: (key: string, options?: Record) => string, ) => { const getErrorMessage = useCallback( async (err: unknown): Promise => { try { let status: number | undefined; if (typeof err === "object" && err !== null && "response" in err) { const response = (err as any).response; status = response?.status; let data: unknown = response?.data; if (data instanceof Blob) { data = await parseBlobBody(data); } // Bulk-upload structured error short-circuit (highest priority). const bulk = extractBulkUploadInfo(data, t); if (bulk) return bulk.title; const fromException = extractMessage((data as any)?.exception?.response) ?? extractMessage((data as any)?.exception); if (fromException) return mapIamErrorCode(fromException, t) ?? fromException; const fromData = extractMessage(data); if (fromData) return mapIamErrorCode(fromData, t) ?? fromData; } if (err instanceof Error) { const fromError = extractMessage(err.message); if (fromError) return mapIamErrorCode(fromError, t) ?? fromError; } return t(statusKeyFor(status)); } catch (handlerError) { console.error( "useErrorHandler: unexpected error while extracting message", handlerError ); return t("msg.genericError"); } }, [t] ); const handleError = useCallback( async (err: unknown): Promise => { logError(err); // Bulk-upload short-circuit: emit one toast with title + description, // skipping the normal extraction path. if (typeof err === "object" && err !== null && "response" in err) { let data: unknown = (err as any).response?.data; if (data instanceof Blob) { data = await parseBlobBody(data); } const bulk = extractBulkUploadInfo(data, t); if (bulk) { toast.error(bulk.title, { description: bulk.description }); return bulk.title; } } const message = await getErrorMessage(err); toast.error(message); return message; }, [getErrorMessage, t] ); return { getErrorMessage, handleError }; }; export const useClientErrorHandler = ( t: (key: string, options?: Record) => string, ) => { const getErrorMessage = useCallback( (err: unknown): string => { try { let status: number | undefined; if (typeof err === "object" && err !== null) { const response = (err as any).response; status = response?.status; const data = response?.data; // Bulk-upload structured error short-circuit (highest priority). const bulk = extractBulkUploadInfo(data, t); if (bulk) return bulk.title; const fromException = extractMessage(data?.exception?.response) ?? extractMessage(data?.exception); if (fromException) return mapIamErrorCode(fromException, t) ?? fromException; const fromData = extractMessage(data); if (fromData) return mapIamErrorCode(fromData, t) ?? fromData; const fromError = extractMessage((err as any).message); if (fromError) return mapIamErrorCode(fromError, t) ?? fromError; } return t(statusKeyFor(status)); } catch (handlerError) { console.error( "useClientErrorHandler: unexpected error while extracting message", handlerError ); return t("msg.genericError"); } }, [t] ); const handleError = useCallback( (err: unknown): string => { logError(err); // Bulk-upload short-circuit: emit one toast with title + description, // skipping the normal extraction path. if (typeof err === "object" && err !== null) { const data = (err as any).response?.data; const bulk = extractBulkUploadInfo(data, t); if (bulk) { toast.error(bulk.title, { description: bulk.description }); return bulk.title; } } const message = getErrorMessage(err); toast.error(message); return message; }, [getErrorMessage, t] ); return { getErrorMessage, handleError }; };