/** * 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'; import { getApiUrl } from '../../config/runtime'; 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 demoSessionId: 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 = getApiUrl() + '/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) => { // Public endpoints that don't require authentication const publicEndpoints = [ '/demo/accounts', '/demo/session/create', ]; // Endpoints that require authentication but not a tenant ID (user-level endpoints) const noTenantEndpoints = [ '/auth/me/onboarding', // Onboarding endpoints - tenant is created during onboarding '/auth/me', // User profile endpoints '/auth/register', // Registration '/auth/login', // Login '/geocoding', // Geocoding/address search - utility service, no tenant context '/tenants/register', // Tenant registration - creating new tenant, no existing tenant context ]; const isPublicEndpoint = publicEndpoints.some(endpoint => config.url?.includes(endpoint) ); const isNoTenantEndpoint = noTenantEndpoints.some(endpoint => config.url?.includes(endpoint) ); // Only add auth token for non-public endpoints if (this.authToken && !isPublicEndpoint) { config.headers.Authorization = `Bearer ${this.authToken}`; } // Add tenant ID only for endpoints that require it if (this.tenantId && !isPublicEndpoint && !isNoTenantEndpoint) { config.headers['X-Tenant-ID'] = this.tenantId; console.log('🔍 [API Client] Adding X-Tenant-ID header:', this.tenantId, 'for URL:', config.url); } else if (!isPublicEndpoint && !isNoTenantEndpoint) { console.warn('⚠️ [API Client] No tenant ID set for endpoint:', config.url); } // Check demo session ID from memory OR localStorage const demoSessionId = this.demoSessionId || localStorage.getItem('demo_session_id'); if (demoSessionId) { config.headers['X-Demo-Session-Id'] = demoSessionId; console.log('🔍 [API Client] Adding X-Demo-Session-Id header:', demoSessionId); } return config; }, (error) => { return Promise.reject(this.handleError(error)); } ); // Response interceptor for error handling and automatic token refresh this.client.interceptors.response.use( (response) => { // Enhanced logging for token refresh header detection const refreshSuggested = response.headers['x-token-refresh-suggested']; if (refreshSuggested) { console.log('🔍 TOKEN REFRESH HEADER DETECTED:', { url: response.config?.url, method: response.config?.method, status: response.status, refreshSuggested, hasRefreshToken: !!this.refreshToken, currentTokenLength: this.authToken?.length || 0 }); } // Check if server suggests token refresh if (refreshSuggested === 'true' && this.refreshToken) { console.log('🔄 Server suggests token refresh - refreshing proactively'); this.proactiveTokenRefresh(); } return 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 proactiveTokenRefresh() { // Avoid multiple simultaneous proactive refreshes if (this.isRefreshing) { return; } try { this.isRefreshing = true; console.log('🔄 Proactively refreshing token...'); const response = await this.client.post('/auth/refresh', { refresh_token: this.refreshToken }); const { access_token, refresh_token } = response.data; // Update tokens this.setAuthToken(access_token); if (refresh_token) { this.setRefreshToken(refresh_token); } // Update auth store await this.updateAuthStore(access_token, refresh_token); console.log('✅ Proactive token refresh successful'); } catch (error) { console.warn('⚠️ Proactive token refresh failed:', error); // Don't handle as auth failure here - let the next 401 handle it } finally { this.isRefreshing = false; } } 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; } setDemoSessionId(sessionId: string | null) { this.demoSessionId = sessionId; if (sessionId) { localStorage.setItem('demo_session_id', sessionId); } else { localStorage.removeItem('demo_session_id'); } } getDemoSessionId(): string | null { return this.demoSessionId || localStorage.getItem('demo_session_id'); } getAuthToken(): string | null { return this.authToken; } getRefreshToken(): string | null { return this.refreshToken; } getTenantId(): string | null { return this.tenantId; } // Token synchronization methods for WebSocket connections getCurrentValidToken(): string | null { return this.authToken; } async ensureValidToken(): Promise { const originalToken = this.authToken; const originalTokenShort = originalToken ? `${originalToken.slice(0, 20)}...${originalToken.slice(-10)}` : 'null'; console.log('🔍 ensureValidToken() called:', { hasToken: !!this.authToken, tokenPreview: originalTokenShort, isRefreshing: this.isRefreshing, hasRefreshToken: !!this.refreshToken }); // If we have a valid token, return it if (this.authToken && !this.isTokenNearExpiry(this.authToken)) { const expiryInfo = this.getTokenExpiryInfo(this.authToken); console.log('✅ Token is valid, returning current token:', { tokenPreview: originalTokenShort, expiryInfo }); return this.authToken; } // If token is near expiry or expired, try to refresh if (this.refreshToken && !this.isRefreshing) { console.log('🔄 Token needs refresh, attempting proactive refresh:', { reason: this.authToken ? 'near expiry' : 'no token', expiryInfo: this.authToken ? this.getTokenExpiryInfo(this.authToken) : 'N/A' }); try { await this.proactiveTokenRefresh(); const newTokenShort = this.authToken ? `${this.authToken.slice(0, 20)}...${this.authToken.slice(-10)}` : 'null'; const tokenChanged = originalToken !== this.authToken; console.log('✅ Token refresh completed:', { tokenChanged, oldTokenPreview: originalTokenShort, newTokenPreview: newTokenShort, newExpiryInfo: this.authToken ? this.getTokenExpiryInfo(this.authToken) : 'N/A' }); return this.authToken; } catch (error) { console.warn('❌ Failed to refresh token in ensureValidToken:', error); return null; } } console.log('⚠️ Returning current token without refresh:', { reason: this.isRefreshing ? 'already refreshing' : 'no refresh token', tokenPreview: originalTokenShort }); return this.authToken; } private getTokenExpiryInfo(token: string): any { try { const payload = JSON.parse(atob(token.split('.')[1])); const exp = payload.exp; const iat = payload.iat; if (!exp) return { error: 'No expiry in token' }; const now = Math.floor(Date.now() / 1000); const timeUntilExpiry = exp - now; const tokenLifetime = exp - iat; return { issuedAt: new Date(iat * 1000).toISOString(), expiresAt: new Date(exp * 1000).toISOString(), lifetimeMinutes: Math.floor(tokenLifetime / 60), secondsUntilExpiry: timeUntilExpiry, minutesUntilExpiry: Math.floor(timeUntilExpiry / 60), isNearExpiry: timeUntilExpiry < 300, isExpired: timeUntilExpiry <= 0 }; } catch (error) { return { error: 'Failed to parse token', details: error }; } } private isTokenNearExpiry(token: string): boolean { try { const payload = JSON.parse(atob(token.split('.')[1])); const exp = payload.exp; if (!exp) return false; const now = Math.floor(Date.now() / 1000); const timeUntilExpiry = exp - now; // Consider token near expiry if less than 5 minutes remaining const isNear = timeUntilExpiry < 300; if (isNear) { console.log('⏰ Token is near expiry:', { secondsUntilExpiry: timeUntilExpiry, minutesUntilExpiry: Math.floor(timeUntilExpiry / 60), expiresAt: new Date(exp * 1000).toISOString() }); } return isNear; } catch (error) { console.warn('Failed to parse token for expiry check:', error); return true; // Assume expired if we can't parse } } // 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;