import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; const GENERIC_ERROR_MESSAGE = 'Something went wrong. Please try again.'; const NETWORK_ERROR_MESSAGE = 'Could not reach the server. Please check your connection and try again.'; const FORBIDDEN_MESSAGE = 'You do not have permission to do that. Ask an administrator if you need access.'; /** * Extracts a user-facing message from a failed request. Prefers a real backend-provided message * (joining a NestJS validation array into one line); otherwise falls back to a friendly generic * message — never raw client/network text like "Request failed with status code 500" or * "Network Error", which is what axios puts in `error.message` when there's nothing better. * Every page in this app should use this (or rely on the response interceptor below, which * normalizes the same error in place) instead of reading `err.message` directly. */ export function getErrorMessage(error: unknown, fallback: string = GENERIC_ERROR_MESSAGE): string { const err = error as any; const raw = err?.response?.data?.message; if (Array.isArray(raw) && raw.length > 0) { const joined = raw.filter((m: unknown) => typeof m === 'string' && m.trim()).join('; '); if (joined) return joined; } else if (typeof raw === 'string' && raw.trim()) { return raw; } if (err?.isAxiosError && !err.response) return NETWORK_ERROR_MESSAGE; return fallback; } class ApiClient { private client: AxiosInstance; constructor() { this.client = axios.create({ baseURL: API_URL, headers: { 'Content-Type': 'application/json', }, }); this.client.interceptors.request.use((config) => { const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null; if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); this.client.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { if (typeof window !== 'undefined') { localStorage.removeItem('auth_token'); window.location.href = '/login'; } } // A 403 from this API is always a permission check, and the server's own text // names raw permission keys ("Missing permission. Required one of: …") which // means nothing to a user. Replace it with something actionable, but only when // the server did not send a more specific message of its own. if (error.response?.status === 403) { const body = error.response.data; const serverMessage = typeof body?.message === 'string' ? body.message : ''; if (!serverMessage || serverMessage.startsWith('Missing permission')) { const friendly = FORBIDDEN_MESSAGE; if (body && typeof body === 'object') body.message = friendly; error.message = friendly; return Promise.reject(error); } } // Normalize in place so every existing `err?.response?.data?.message || err?.message || // ''` call site across the app picks up a friendly message automatically, // instead of raw axios/network text or an unjoined NestJS validation array. try { const friendly = getErrorMessage(error); if (error.response?.data && typeof error.response.data === 'object') { error.response.data.message = friendly; } error.message = friendly; } catch { // Best-effort — never let normalization itself break the original rejection. } return Promise.reject(error); }, ); } async get(url: string, config?: AxiosRequestConfig): Promise { const response = await this.client.get<{ success: boolean; data: T }>(url, config); return response.data.data; } /** * GET without the `{ success, data }` unwrap. Binary endpoints (file streams) * have no envelope to unwrap, so `get` would hand back `undefined`. */ async getRaw(url: string, config?: AxiosRequestConfig): Promise { const response = await this.client.get(url, config); return response.data; } async post(url: string, data?: any, config?: AxiosRequestConfig): Promise { const response = await this.client.post<{ success: boolean; data: T }>(url, data, config); return response.data.data; } async put(url: string, data?: any, config?: AxiosRequestConfig): Promise { const response = await this.client.put<{ success: boolean; data: T }>(url, data, config); return response.data.data; } async patch(url: string, data?: any, config?: AxiosRequestConfig): Promise { const response = await this.client.patch<{ success: boolean; data: T }>(url, data, config); return response.data.data; } async delete(url: string, config?: AxiosRequestConfig): Promise { const response = await this.client.delete<{ success: boolean; data: T }>(url, config); return response.data.data; } } export const apiClient = new ApiClient();