/** * Core HTTP client for React Query integration * * Architecture: * - Axios: HTTP client for making requests * - This Client: Handles auth tokens, tenant context, and error formatting * - Services: Business logic that uses this client * - React Query Hooks: Data fetching layer that uses services * * React Query doesn't replace HTTP clients - it manages data fetching/caching/sync */ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'; export interface ApiError { message: string; status?: number; code?: string; details?: any; } export interface SubscriptionError { error: string; message: string; code: string; details: { required_feature: string; required_level: string; current_plan: string; upgrade_url: string; }; } // Subscription error event emitter class SubscriptionErrorEmitter extends EventTarget { emitSubscriptionError(error: SubscriptionError) { this.dispatchEvent(new CustomEvent('subscriptionError', { detail: error })); } } export const subscriptionErrorEmitter = new SubscriptionErrorEmitter(); class ApiClient { private client: AxiosInstance; private baseURL: string; private authToken: string | null = null; private tenantId: string | null = null; private refreshToken: string | null = null; private isRefreshing: boolean = false; private refreshAttempts: number = 0; private maxRefreshAttempts: number = 3; private lastRefreshAttempt: number = 0; private failedQueue: Array<{ resolve: (value?: any) => void; reject: (error?: any) => void; config: AxiosRequestConfig; }> = []; constructor(baseURL: string = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1') { this.baseURL = baseURL; this.client = axios.create({ baseURL: this.baseURL, timeout: 30000, headers: { 'Content-Type': 'application/json', }, }); this.setupInterceptors(); } private setupInterceptors() { // Request interceptor to add auth headers this.client.interceptors.request.use( (config) => { if (this.authToken) { config.headers.Authorization = `Bearer ${this.authToken}`; } if (this.tenantId) { config.headers['X-Tenant-ID'] = this.tenantId; } return config; }, (error) => { return Promise.reject(this.handleError(error)); } ); // Response interceptor for error handling and automatic token refresh this.client.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; // Check if error is 401 and we have a refresh token if (error.response?.status === 401 && this.refreshToken && !originalRequest._retry) { // Check if we've exceeded max refresh attempts in a short time const now = Date.now(); if (this.refreshAttempts >= this.maxRefreshAttempts && (now - this.lastRefreshAttempt) < 30000) { console.log('Max refresh attempts exceeded, logging out'); await this.handleAuthFailure(); return Promise.reject(this.handleError(error)); } if (this.isRefreshing) { // If already refreshing, queue this request return new Promise((resolve, reject) => { this.failedQueue.push({ resolve, reject, config: originalRequest }); }); } originalRequest._retry = true; this.isRefreshing = true; this.refreshAttempts++; this.lastRefreshAttempt = now; try { console.log(`Attempting token refresh (attempt ${this.refreshAttempts})...`); // Attempt to refresh the token const response = await this.client.post('/auth/refresh', { refresh_token: this.refreshToken }); const { access_token, refresh_token } = response.data; console.log('Token refresh successful'); // Reset refresh attempts on success this.refreshAttempts = 0; // Update tokens this.setAuthToken(access_token); if (refresh_token) { this.setRefreshToken(refresh_token); } // Update auth store if available await this.updateAuthStore(access_token, refresh_token); // Process failed queue this.processQueue(null, access_token); // Retry original request with new token originalRequest.headers.Authorization = `Bearer ${access_token}`; return this.client(originalRequest); } catch (refreshError) { console.error(`Token refresh failed (attempt ${this.refreshAttempts}):`, refreshError); // Refresh failed, clear tokens and redirect to login this.processQueue(refreshError, null); await this.handleAuthFailure(); return Promise.reject(this.handleError(refreshError as AxiosError)); } finally { this.isRefreshing = false; } } return Promise.reject(this.handleError(error)); } ); } private handleError(error: AxiosError): ApiError { if (error.response) { // Server responded with error status const { status, data } = error.response; // Check for subscription errors if (status === 403 && (data as any)?.code === 'SUBSCRIPTION_UPGRADE_REQUIRED') { const subscriptionError = data as SubscriptionError; subscriptionErrorEmitter.emitSubscriptionError(subscriptionError); } return { message: (data as any)?.detail || (data as any)?.message || `Request failed with status ${status}`, status, code: (data as any)?.code, details: data, }; } else if (error.request) { // Network error return { message: 'Network error - please check your connection', status: 0, }; } else { // Other error return { message: error.message || 'Unknown error occurred', }; } } private processQueue(error: any, token: string | null = null) { this.failedQueue.forEach(({ resolve, reject, config }) => { if (error) { reject(error); } else { if (token) { config.headers = config.headers || {}; config.headers.Authorization = `Bearer ${token}`; } resolve(this.client(config)); } }); this.failedQueue = []; } private async updateAuthStore(accessToken: string, refreshToken?: string) { try { // Dynamically import to avoid circular dependency const { useAuthStore } = await import('../../stores/auth.store'); const setState = useAuthStore.setState; // Update the store with new tokens setState(state => ({ ...state, token: accessToken, refreshToken: refreshToken || state.refreshToken, })); } catch (error) { console.warn('Failed to update auth store:', error); } } private async handleAuthFailure() { try { // Clear tokens this.setAuthToken(null); this.setRefreshToken(null); // Dynamically import to avoid circular dependency const { useAuthStore } = await import('../../stores/auth.store'); const store = useAuthStore.getState(); // Logout user store.logout(); // Redirect to login if not already there if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) { window.location.href = '/login'; } } catch (error) { console.warn('Failed to handle auth failure:', error); } } // Configuration methods setAuthToken(token: string | null) { this.authToken = token; } setRefreshToken(token: string | null) { this.refreshToken = token; } setTenantId(tenantId: string | null) { this.tenantId = tenantId; } getAuthToken(): string | null { return this.authToken; } getRefreshToken(): string | null { return this.refreshToken; } getTenantId(): string | null { return this.tenantId; } // HTTP Methods - Return direct data for React Query async get(url: string, config?: AxiosRequestConfig): Promise { const response: AxiosResponse = await this.client.get(url, config); return response.data; } async post( url: string, data?: D, config?: AxiosRequestConfig ): Promise { const response: AxiosResponse = await this.client.post(url, data, config); return response.data; } async put( url: string, data?: D, config?: AxiosRequestConfig ): Promise { const response: AxiosResponse = await this.client.put(url, data, config); return response.data; } async patch( url: string, data?: D, config?: AxiosRequestConfig ): Promise { const response: AxiosResponse = await this.client.patch(url, data, config); return response.data; } async delete(url: string, config?: AxiosRequestConfig): Promise { const response: AxiosResponse = await this.client.delete(url, config); return response.data; } // File upload helper async uploadFile( url: string, file: File | FormData, config?: AxiosRequestConfig ): Promise { const formData = file instanceof FormData ? file : new FormData(); if (file instanceof File) { formData.append('file', file); } return this.post(url, formData, { ...config, headers: { ...config?.headers, 'Content-Type': 'multipart/form-data', }, }); } // Raw axios instance for advanced usage getAxiosInstance(): AxiosInstance { return this.client; } } // Create and export singleton instance export const apiClient = new ApiClient(); export default apiClient;