Files
edr-platform/apps/edr-freight-web/backoffice/src/shared/hooks/useErrorHandler.ts
Nathnael f39ae77401 fix(backoffice): map raw IAM error codes to friendly messages (EDRFREIGHT-223, EDRFREIGHT-225)
@tria-plc/iamapi-common throws BadRequestException with a raw,
untranslated code as the message (e.g. "user_role_not_found",
"unit_employee_limit_reached:5") — useErrorHandler's extractMessage
returned that string verbatim, so removing an org/unit admin whose
role row didn't match, or inviting past a unit's employee cap, toasted
the raw backend code instead of an explanation. Adds a small code→i18n
map checked before the raw-message fallback, shared by both the async
and sync error handlers so every caller (org-admin removal, employee
invites, etc.) picks it up for free.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:28:11 +00:00

275 lines
9.2 KiB
TypeScript

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<string, unknown>;
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, unknown>) => 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<string, unknown>,
)) {
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<unknown> => {
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, unknown>) => string,
) => {
const getErrorMessage = useCallback(
async (err: unknown): Promise<string> => {
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<string> => {
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, unknown>) => 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 };
};