Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
270 lines
8.5 KiB
TypeScript
270 lines
8.5 KiB
TypeScript
/**
|
|
* Central service error handler.
|
|
* Default: service shows toast and swallows error; errorMode 'parent' rethrows for caller.
|
|
* Returns ServiceResult<T> 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<ServiceErrorData>): 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<T> {
|
|
ok: true
|
|
data: T
|
|
}
|
|
|
|
export interface ServiceErrorResult {
|
|
ok: false
|
|
error: ServiceError
|
|
}
|
|
|
|
export type ServiceResult<T> = ServiceSuccessResult<T> | 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<string, unknown>
|
|
|
|
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<string, unknown>).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<ServiceError, 'detailMessage' | 'message' | 'messageKey'>,
|
|
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<ServiceErrorData> => {
|
|
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<string, unknown>).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<string, unknown>).messageKey : undefined
|
|
const payloadCode = payload && typeof payload === 'object' ? (payload as Record<string, unknown>).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 = <T>(data: T): ServiceResult<T> => ({
|
|
ok: true,
|
|
data,
|
|
})
|
|
|
|
export const errorResult = (error: ServiceError): ServiceResult<never> => ({
|
|
ok: false,
|
|
error,
|
|
})
|