Files
bakery-ia/frontend/src/api/client/apiClient.ts
2025-11-30 09:12:40 +01:00

564 lines
18 KiB
TypeScript

/**
* 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}`;
console.log('🔑 [API Client] Adding Authorization header for:', config.url);
} else if (!isPublicEndpoint) {
console.warn('⚠️ [API Client] No auth token available for:', config.url, 'authToken:', this.authToken ? 'exists' : 'missing');
}
// 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) {
console.log('🔧 [API Client] setAuthToken called:', token ? `${token.substring(0, 20)}...` : 'null');
this.authToken = token;
console.log('✅ [API Client] authToken is now:', this.authToken ? 'set' : 'null');
}
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<string | null> {
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<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response: AxiosResponse<T> = await this.client.get(url, config);
return response.data;
}
async post<T = any, D = any>(
url: string,
data?: D,
config?: AxiosRequestConfig
): Promise<T> {
const response: AxiosResponse<T> = await this.client.post(url, data, config);
return response.data;
}
async put<T = any, D = any>(
url: string,
data?: D,
config?: AxiosRequestConfig
): Promise<T> {
const response: AxiosResponse<T> = await this.client.put(url, data, config);
return response.data;
}
async patch<T = any, D = any>(
url: string,
data?: D,
config?: AxiosRequestConfig
): Promise<T> {
const response: AxiosResponse<T> = await this.client.patch(url, data, config);
return response.data;
}
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response: AxiosResponse<T> = await this.client.delete(url, config);
return response.data;
}
// File upload helper
async uploadFile<T = any>(
url: string,
file: File | FormData,
config?: AxiosRequestConfig
): Promise<T> {
const formData = file instanceof FormData ? file : new FormData();
if (file instanceof File) {
formData.append('file', file);
}
return this.post<T>(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;