/** * Central service error handler. * Default: service shows toast and swallows error; errorMode 'parent' rethrows for caller. * Returns ServiceResult with ok/data or ok/error. */ import { AxiosError } from 'axios' import { addToast } from '@/lib/toast' import { texts } from '@/texts' import { ensurePersianApiErrorMessage } from '@/services/apiErrorLocalization' import { getServiceErrorCopy } from '@/helpers/serviceErrorCopy' export const NETWORK_ERROR_MESSAGE_KEY = 'messages.networkError' as const const isAxiosNetworkFailure = (error: AxiosError): boolean => { if (error.response) return false if (error.code === 'ERR_CANCELED') return false return true } export type ServiceErrorMode = 'service' | 'parent' | 'silent' export interface ServiceCallOptions { /** * `'service'` (default): toast + return `{ ok: false }`. * `'silent'`: return `{ ok: false }` with no toast. * `'parent'`: **throw** the `ServiceError` (no `{ ok: false }`). Caller must * `try/catch` or use it inside a TanStack `queryFn`/`mutationFn`. */ errorMode?: ServiceErrorMode /** Cancels the underlying HTTP request when the consumer becomes stale. */ signal?: AbortSignal } export interface ServiceSuccessResult { ok: true data: T } export interface ServiceErrorResult { ok: false error: ServiceError } export type ServiceResult = ServiceSuccessResult | ServiceErrorResult interface ServiceErrorData { success?: boolean message?: string messageKey?: string code?: string } interface ServiceErrorResponse { data?: ServiceErrorData status?: number } export interface ServiceError extends Error { kind: 'validation' | 'network' | 'timeout' | 'auth' | 'permission' | 'server' | 'cancelled' messageKey?: string apiCode?: string /** Server body.message (array or string) for direct toast display */ detailMessage?: string statusCode?: number response?: ServiceErrorResponse } /** Supports { body: { message: string[] } } and common variants */ export const extractServerErrorDetail = (data: unknown, status?: number): string | null => { if (data === null || data === undefined || typeof data !== 'object') return null const d = data as Record const code = typeof d.code === 'string' ? d.code : undefined const pickMessage = (m: unknown): string | null => { if (Array.isArray(m)) { const parts = m .filter((x): x is string => typeof x === 'string' && x.trim().length > 0) .map((item) => ensurePersianApiErrorMessage(item, status, code)) return parts.length ? [...new Set(parts)].join('\n') : null } if (typeof m === 'string' && m.trim()) return ensurePersianApiErrorMessage(m, status, code) return null } const body = d.body if (body && typeof body === 'object') { const fromBody = pickMessage((body as Record).message) if (fromBody) return fromBody } return pickMessage(d.message) } const isGenericHttpClientMessage = (message: string): boolean => /^Request failed with status code \d+$/i.test(message) || /^Network Error$/i.test(message) /** Toast/error text — server detailMessage / message first */ export const resolveServiceErrorDisplayMessage = ( error: Pick, copy: { apiError: string; networkError: string } ): string => { if (error.messageKey === NETWORK_ERROR_MESSAGE_KEY) { return copy.networkError } const detail = error.detailMessage?.trim() if (detail) return ensurePersianApiErrorMessage(detail) const message = error.message?.trim() if (message && !isGenericHttpClientMessage(message)) { return ensurePersianApiErrorMessage(message) } return copy.apiError } export const createServiceError = (message: string, messageKey?: string): ServiceError => { const localizedMessage = ensurePersianApiErrorMessage(message) const error = new Error(localizedMessage) as ServiceError error.kind = 'server' if (messageKey) { error.messageKey = messageKey } error.response = { data: { success: false, message: localizedMessage, messageKey, }, } return error } const isAxiosLikeError = (error: unknown): error is AxiosError => { return error instanceof AxiosError || (typeof error === 'object' && error !== null && 'isAxiosError' in error) } export const normalizeServiceError = (error: unknown, fallbackMessageKey = 'messages.mockServerError'): ServiceError => { const { apiError: defaultApiError, networkError: localizedNetworkError } = getServiceErrorCopy() if (isAxiosLikeError(error)) { if (error.code === 'ERR_CANCELED') { const cancelledError = new Error(texts.common.requestCancelled) as ServiceError cancelledError.kind = 'cancelled' return cancelledError } if (isAxiosNetworkFailure(error)) { const normalizedNetwork = new Error(localizedNetworkError) as ServiceError normalizedNetwork.kind = error.code === 'ECONNABORTED' ? 'timeout' : 'network' normalizedNetwork.messageKey = NETWORK_ERROR_MESSAGE_KEY normalizedNetwork.response = { data: { message: localizedNetworkError, messageKey: NETWORK_ERROR_MESSAGE_KEY, }, } return normalizedNetwork } const payload = error.response?.data const detailMessage = extractServerErrorDetail(payload, error.response?.status) const rawMessage = payload && typeof payload === 'object' ? (payload as Record).message : undefined const legacyString = typeof rawMessage === 'string' ? rawMessage : typeof error.message === 'string' ? error.message : undefined const status = error.response?.status const payloadMessageKey = payload && typeof payload === 'object' ? (payload as Record).messageKey : undefined const payloadCode = payload && typeof payload === 'object' ? (payload as Record).code : undefined const axiosMessage = detailMessage || ensurePersianApiErrorMessage(legacyString, status, typeof payloadCode === 'string' ? payloadCode : undefined) const axiosMessageKey: string = typeof payloadMessageKey === 'string' ? payloadMessageKey || fallbackMessageKey : fallbackMessageKey const normalizedError = new Error(axiosMessage) as ServiceError normalizedError.kind = status === 401 ? 'auth' : status === 403 ? 'permission' : status === 400 || status === 422 ? 'validation' : 'server' if (detailMessage) { normalizedError.detailMessage = detailMessage } if (typeof payloadCode === 'string') { normalizedError.apiCode = payloadCode } normalizedError.messageKey = detailMessage ? undefined : axiosMessageKey normalizedError.statusCode = error.response?.status normalizedError.response = { ...error.response, data: { ...(payload && typeof payload === 'object' ? payload : {}), message: axiosMessage, messageKey: normalizedError.messageKey, }, } return normalizedError } if (error instanceof Error) { const maybeServiceError = error as ServiceError const localizedMessage = ensurePersianApiErrorMessage( maybeServiceError.message, maybeServiceError.statusCode, maybeServiceError.apiCode ) maybeServiceError.message = localizedMessage maybeServiceError.kind ||= 'server' maybeServiceError.messageKey = maybeServiceError.messageKey || fallbackMessageKey maybeServiceError.response ??= { data: { message: localizedMessage, messageKey: maybeServiceError.messageKey, }, } return maybeServiceError } return createServiceError(defaultApiError, fallbackMessageKey) } /** True when the service must throw instead of returning `{ ok: false }`. */ export const shouldBubbleErrorToParent = (options?: ServiceCallOptions): boolean => { return options?.errorMode === 'parent' } export const handleServiceError = (error: unknown, options?: ServiceCallOptions): ServiceError => { const normalizedError = normalizeServiceError(error) const { apiError, networkError } = getServiceErrorCopy() if (normalizedError.kind !== 'cancelled' && options?.errorMode !== 'silent' && !shouldBubbleErrorToParent(options)) { addToast({ title: resolveServiceErrorDisplayMessage(normalizedError, { apiError, networkError }), color: 'danger', }) } return normalizedError } export const successResult = (data: T): ServiceResult => ({ ok: true, data, }) export const errorResult = (error: ServiceError): ServiceResult => ({ ok: false, error, })