ADD new frontend

This commit is contained in:
Urtzi Alfaro
2025-08-28 10:41:04 +02:00
parent 9c247a5f99
commit 0fd273cfce
492 changed files with 114979 additions and 1632 deletions

View File

@@ -1,142 +0,0 @@
// frontend/src/api/client/config.ts
/**
* API Client Configuration
* Centralized configuration for all API clients
*/
export interface ApiConfig {
baseURL: string;
timeout: number;
retries: number;
retryDelay: number;
enableLogging: boolean;
enableCaching: boolean;
cacheTimeout: number;
}
export interface ServiceEndpoints {
auth: string;
tenant: string;
data: string;
training: string;
forecasting: string;
notification: string;
}
// Environment-based configuration
const getEnvironmentConfig = (): ApiConfig => {
// Use import.meta.env instead of process.env for Vite
const isDevelopment = import.meta.env.DEV;
const isProduction = import.meta.env.PROD;
return {
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1',
timeout: parseInt(import.meta.env.VITE_API_TIMEOUT || '30000'),
retries: parseInt(import.meta.env.VITE_API_RETRIES || '3'),
retryDelay: parseInt(import.meta.env.VITE_API_RETRY_DELAY || '1000'),
enableLogging: isDevelopment || import.meta.env.VITE_API_LOGGING === 'true',
enableCaching: import.meta.env.VITE_API_CACHING !== 'false',
cacheTimeout: parseInt(import.meta.env.VITE_API_CACHE_TIMEOUT || '300000'), // 5 minutes
};
};
export const apiConfig: ApiConfig = getEnvironmentConfig();
// Service endpoint configuration
export const serviceEndpoints: ServiceEndpoints = {
auth: '/auth',
tenant: '/tenants',
data: '/tenants', // Data operations are tenant-scoped
training: '/tenants', // Training operations are tenant-scoped
forecasting: '/tenants', // Forecasting operations are tenant-scoped
notification: '/tenants', // Notification operations are tenant-scoped
};
// HTTP status codes
export const HttpStatus = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
UNPROCESSABLE_ENTITY: 422,
INTERNAL_SERVER_ERROR: 500,
BAD_GATEWAY: 502,
SERVICE_UNAVAILABLE: 503,
} as const;
// Request timeout configuration
export const RequestTimeouts = {
SHORT: 5000, // 5 seconds - for quick operations
MEDIUM: 15000, // 15 seconds - for normal operations
LONG: 60000, // 1 minute - for file uploads
EXTENDED: 300000, // 5 minutes - for training operations
} as const;
// Cache configuration
export interface CacheConfig {
defaultTTL: number;
maxSize: number;
strategies: {
user: number;
tenant: number;
data: number;
forecast: number;
};
}
export const cacheConfig: CacheConfig = {
defaultTTL: 300000, // 5 minutes
maxSize: 100, // Maximum cached items
strategies: {
user: 600000, // 10 minutes
tenant: 1800000, // 30 minutes
data: 300000, // 5 minutes
forecast: 600000, // 10 minutes
},
};
// Retry configuration
export interface RetryConfig {
attempts: number;
delay: number;
backoff: number;
retryCondition: (error: any) => boolean;
}
export const retryConfig: RetryConfig = {
attempts: 3,
delay: 1000,
backoff: 2, // Exponential backoff multiplier
retryCondition: (error: any) => {
// Retry on network errors and specific HTTP status codes
if (!error.response) return true; // Network error
const status = error.response.status;
return status >= 500 || status === 408 || status === 429;
},
};
// API versioning
export const ApiVersion = {
V1: 'v1',
CURRENT: 'v1',
} as const;
export interface FeatureFlags {
enableWebSockets: boolean;
enableOfflineMode: boolean;
enableOptimisticUpdates: boolean;
enableRequestDeduplication: boolean;
enableMetrics: boolean;
}
export const featureFlags: FeatureFlags = {
enableWebSockets: import.meta.env.VITE_ENABLE_WEBSOCKETS === 'true',
enableOfflineMode: import.meta.env.VITE_ENABLE_OFFLINE === 'true',
enableOptimisticUpdates: import.meta.env.VITE_ENABLE_OPTIMISTIC_UPDATES !== 'false',
enableRequestDeduplication: import.meta.env.VITE_ENABLE_DEDUPLICATION !== 'false',
enableMetrics: import.meta.env.VITE_ENABLE_METRICS === 'true',
};

View File

@@ -1,578 +0,0 @@
// frontend/src/api/client/index.ts
/**
* Enhanced API Client with modern features
* Supports caching, retries, optimistic updates, and more
*/
import {
ApiResponse,
ApiError,
RequestConfig,
UploadConfig,
UploadProgress,
RequestInterceptor,
ResponseInterceptor,
CacheEntry,
RequestMetrics,
} from './types';
import { apiConfig, retryConfig, cacheConfig, featureFlags } from './config';
export class ApiClient {
private baseURL: string;
private cache = new Map<string, CacheEntry>();
private pendingRequests = new Map<string, Promise<any>>();
private requestInterceptors: RequestInterceptor[] = [];
private responseInterceptors: ResponseInterceptor[] = [];
private metrics: RequestMetrics[] = [];
constructor(baseURL?: string) {
this.baseURL = baseURL || apiConfig.baseURL;
// ✅ CRITICAL FIX: Remove trailing slash
this.baseURL = this.baseURL.replace(/\/+$/, '');
console.log('🔧 API Client initialized with baseURL:', this.baseURL);
}
private buildURL(endpoint: string): string {
// Remove leading slash from endpoint if present to avoid double slashes
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
const fullURL = `${this.baseURL}${cleanEndpoint}`;
// ✅ DEBUG: Log URL construction
console.log('🔗 Building URL:', {
baseURL: this.baseURL,
endpoint: cleanEndpoint,
fullURL: fullURL
});
return fullURL;
}
/**
* Add request interceptor
*/
addRequestInterceptor(interceptor: RequestInterceptor): void {
this.requestInterceptors.push(interceptor);
}
/**
* Add response interceptor
*/
addResponseInterceptor(interceptor: ResponseInterceptor): void {
this.responseInterceptors.push(interceptor);
}
/**
* Generate cache key for request
*/
private getCacheKey(url: string, config?: RequestConfig): string {
const method = config?.method || 'GET';
const params = config?.params ? JSON.stringify(config.params) : '';
return `${method}:${url}:${params}`;
}
/**
* Check if response is cached and valid
*/
private getCachedResponse<T>(key: string): T | null {
if (!featureFlags.enableRequestDeduplication && !apiConfig.enableCaching) {
return null;
}
const cached = this.cache.get(key);
if (!cached) return null;
const now = Date.now();
if (now - cached.timestamp > cached.ttl) {
this.cache.delete(key);
return null;
}
return cached.data;
}
/**
* Cache response data
*/
private setCachedResponse<T>(key: string, data: T, ttl?: number): void {
if (!apiConfig.enableCaching) return;
const cacheTTL = ttl || cacheConfig.defaultTTL;
this.cache.set(key, {
data,
timestamp: Date.now(),
ttl: cacheTTL,
key,
});
// Cleanup old cache entries if cache is full
if (this.cache.size > cacheConfig.maxSize) {
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
}
/**
* Apply request interceptors
*/
private async applyRequestInterceptors(config: RequestConfig): Promise<RequestConfig> {
let modifiedConfig = { ...config };
for (const interceptor of this.requestInterceptors) {
if (interceptor.onRequest) {
try {
modifiedConfig = await interceptor.onRequest(modifiedConfig);
} catch (error) {
if (interceptor.onRequestError) {
await interceptor.onRequestError(error);
}
throw error;
}
}
}
return modifiedConfig;
}
/**
* Apply response interceptors
*/
private async applyResponseInterceptors<T>(response: ApiResponse<T>): Promise<ApiResponse<T>> {
let modifiedResponse = { ...response };
for (const interceptor of this.responseInterceptors) {
if (interceptor.onResponse) {
try {
modifiedResponse = await interceptor.onResponse(modifiedResponse);
} catch (error) {
if (interceptor.onResponseError) {
await interceptor.onResponseError(error);
}
throw error;
}
}
}
return modifiedResponse;
}
/**
* Retry failed requests with exponential backoff
*/
private async retryRequest<T>(
requestFn: () => Promise<T>,
attempts: number = retryConfig.attempts
): Promise<T> {
try {
return await requestFn();
} catch (error) {
if (attempts <= 0 || !retryConfig.retryCondition(error)) {
throw error;
}
const delay = retryConfig.delay * Math.pow(retryConfig.backoff, retryConfig.attempts - attempts);
await new Promise(resolve => setTimeout(resolve, delay));
return this.retryRequest(requestFn, attempts - 1);
}
}
/**
* Record request metrics
*/
private recordMetrics(metrics: Partial<RequestMetrics>): void {
if (!featureFlags.enableMetrics) return;
const completeMetrics: RequestMetrics = {
url: '',
method: 'GET',
duration: 0,
status: 0,
size: 0,
timestamp: Date.now(),
cached: false,
retries: 0,
...metrics,
};
this.metrics.push(completeMetrics);
// Keep only recent metrics (last 1000 requests)
if (this.metrics.length > 1000) {
this.metrics = this.metrics.slice(-1000);
}
}
/**
* Core request method with all features
*/
async request<T = any>(endpoint: string, config: RequestConfig = {}): Promise<T> {
const startTime = Date.now();
const url = this.buildURL(endpoint);
const method = config.method || 'GET';
console.log('🚀 Making API request:', {
method,
endpoint,
url,
config
});
// Apply request interceptors
const modifiedConfig = await this.applyRequestInterceptors(config);
// Generate cache key
const cacheKey = this.getCacheKey(endpoint, modifiedConfig);
// Check cache for GET requests
if (method === 'GET' && (config.cache !== false)) {
const cached = this.getCachedResponse<T>(cacheKey);
if (cached) {
this.recordMetrics({
url: endpoint,
method,
duration: Date.now() - startTime,
status: 200,
cached: true,
});
return cached;
}
}
// Request deduplication for concurrent requests
if (featureFlags.enableRequestDeduplication && method === 'GET') {
const pendingRequest = this.pendingRequests.get(cacheKey);
if (pendingRequest) {
return pendingRequest;
}
}
// Create request promise
const requestPromise = this.retryRequest(async () => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...modifiedConfig.headers,
};
const fetchConfig: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(modifiedConfig.timeout || apiConfig.timeout),
};
// Add body for non-GET requests
if (method !== 'GET' && modifiedConfig.body) {
if (modifiedConfig.body instanceof FormData) {
// Remove Content-Type for FormData (let browser set it with boundary)
delete headers['Content-Type'];
fetchConfig.body = modifiedConfig.body;
} else {
fetchConfig.body = typeof modifiedConfig.body === 'string'
? modifiedConfig.body
: JSON.stringify(modifiedConfig.body);
}
}
// Add query parameters
const urlWithParams = new URL(url);
if (modifiedConfig.params) {
Object.entries(modifiedConfig.params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
urlWithParams.searchParams.append(key, String(value));
}
});
}
const response = await fetch(urlWithParams.toString(), fetchConfig);
if (!response.ok) {
const errorText = await response.text();
let errorData: ApiError;
try {
errorData = JSON.parse(errorText);
} catch {
errorData = {
message: `HTTP ${response.status}: ${response.statusText}`,
detail: errorText,
code: `HTTP_${response.status}`,
};
}
const error = new Error(errorData.message || 'Request failed');
(error as any).response = { status: response.status, data: errorData };
throw error;
}
const responseData = await response.json();
console.log('🔍 Raw responseData from fetch:', responseData);
// Apply response interceptors
const processedResponse = await this.applyResponseInterceptors(responseData);
console.log('🔍 processedResponse after interceptors:', processedResponse);
return processedResponse;
});
// Store pending request for deduplication
if (featureFlags.enableRequestDeduplication && method === 'GET') {
this.pendingRequests.set(cacheKey, requestPromise);
}
try {
const result = await requestPromise;
// Cache successful GET responses
if (method === 'GET' && config.cache !== false) {
this.setCachedResponse(cacheKey, result, config.cacheTTL);
}
// Record metrics
this.recordMetrics({
url: endpoint,
method,
duration: Date.now() - startTime,
status: 200,
size: JSON.stringify(result).length,
});
// Handle both wrapped and unwrapped responses
// If result has a 'data' property, return it; otherwise return the result itself
console.log('🔍 Final result before return:', result);
console.log('🔍 Result has data property?', result && typeof result === 'object' && 'data' in result);
if (result && typeof result === 'object' && 'data' in result) {
console.log('🔍 Returning result.data:', result.data);
return result.data as T;
}
console.log('🔍 Returning raw result:', result);
return result as T;
} catch (error) {
// Record error metrics
this.recordMetrics({
url: endpoint,
method,
duration: Date.now() - startTime,
status: (error as any).response?.status || 0,
});
throw error;
} finally {
// Clean up pending request
if (featureFlags.enableRequestDeduplication && method === 'GET') {
this.pendingRequests.delete(cacheKey);
}
}
}
/**
* Convenience methods for HTTP verbs
*/
async get<T = any>(endpoint: string, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, { ...config, method: 'GET' });
}
async post<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, {
...config,
method: 'POST',
body: data
});
}
async put<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, {
...config,
method: 'PUT',
body: data
});
}
async patch<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, {
...config,
method: 'PATCH',
body: data
});
}
async delete<T = any>(endpoint: string, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, { ...config, method: 'DELETE' });
}
/**
* Raw request that returns the Response object for binary data
*/
async getRaw(endpoint: string, config?: RequestConfig): Promise<Response> {
const url = this.buildURL(endpoint);
const modifiedConfig = await this.applyRequestInterceptors(config || {});
const headers: Record<string, string> = {
...modifiedConfig.headers,
};
const fetchConfig: RequestInit = {
method: 'GET',
headers,
signal: AbortSignal.timeout(modifiedConfig.timeout || apiConfig.timeout),
};
// Add query parameters
const urlWithParams = new URL(url);
if (modifiedConfig.params) {
Object.entries(modifiedConfig.params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
urlWithParams.searchParams.append(key, String(value));
}
});
}
const response = await fetch(urlWithParams.toString(), fetchConfig);
if (!response.ok) {
const errorText = await response.text();
let errorData: ApiError;
try {
errorData = JSON.parse(errorText);
} catch {
errorData = {
message: `HTTP ${response.status}: ${response.statusText}`,
detail: errorText,
code: `HTTP_${response.status}`,
};
}
const error = new Error(errorData.message || 'Request failed');
(error as any).response = { status: response.status, data: errorData };
throw error;
}
return response;
}
/**
* File upload with progress tracking
*/
async upload<T = any>(
endpoint: string,
file: File,
additionalData?: Record<string, any>,
config?: UploadConfig
): Promise<T> {
const formData = new FormData();
formData.append('file', file);
if (additionalData) {
Object.entries(additionalData).forEach(([key, value]) => {
formData.append(key, String(value));
});
}
// For file uploads, we need to use XMLHttpRequest for progress tracking
if (config?.onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable && config.onProgress) {
const progress: UploadProgress = {
loaded: event.loaded,
total: event.total,
percentage: Math.round((event.loaded / event.total) * 100),
};
config.onProgress(progress);
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const result = JSON.parse(xhr.responseText);
resolve(result);
} catch {
resolve(xhr.responseText as any);
}
} else {
reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`));
}
});
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'));
});
xhr.open('POST', `${this.baseURL}${endpoint}`);
// Add headers (excluding Content-Type for FormData)
if (config?.headers) {
Object.entries(config.headers).forEach(([key, value]) => {
if (key.toLowerCase() !== 'content-type') {
xhr.setRequestHeader(key, value);
}
});
}
xhr.send(formData);
});
}
// Fallback to regular request for uploads without progress
return this.request<T>(endpoint, {
...config,
method: 'POST',
body: formData,
});
}
/**
* Clear cache
*/
clearCache(pattern?: string): void {
if (pattern) {
// Clear cache entries matching pattern
const regex = new RegExp(pattern);
Array.from(this.cache.keys())
.filter(key => regex.test(key))
.forEach(key => this.cache.delete(key));
} else {
// Clear all cache
this.cache.clear();
}
}
/**
* Get client metrics
*/
getMetrics() {
if (!featureFlags.enableMetrics) {
return {
totalRequests: 0,
successfulRequests: 0,
failedRequests: 0,
averageResponseTime: 0,
cacheHitRate: 0,
errorRate: 0,
};
}
const total = this.metrics.length;
const successful = this.metrics.filter(m => m.status >= 200 && m.status < 300).length;
const cached = this.metrics.filter(m => m.cached).length;
const averageTime = total > 0
? this.metrics.reduce((sum, m) => sum + m.duration, 0) / total
: 0;
return {
totalRequests: total,
successfulRequests: successful,
failedRequests: total - successful,
averageResponseTime: Math.round(averageTime),
cacheHitRate: total > 0 ? Math.round((cached / total) * 100) : 0,
errorRate: total > 0 ? Math.round(((total - successful) / total) * 100) : 0,
};
}
}
// Default API client instance
console.log('🔧 Creating default API client...');
export const apiClient = new ApiClient();

View File

@@ -1,488 +0,0 @@
// frontend/src/api/client/interceptors.ts
/**
* Request and Response Interceptors
* Handles authentication, logging, error handling, etc.
*/
import { apiClient } from './index';
import type { RequestConfig, ApiResponse } from './types';
import { ApiErrorHandler } from '../utils';
/**
* Authentication Interceptor
* Automatically adds authentication headers to requests
*/
class AuthInterceptor {
static isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const currentTime = Math.floor(Date.now() / 1000);
return payload.exp <= currentTime;
} catch (error) {
console.warn('Error parsing token:', error);
return true; // Treat invalid tokens as expired
}
}
static isTokenExpiringSoon(token: string, bufferMinutes: number = 5): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const currentTime = Math.floor(Date.now() / 1000);
const bufferSeconds = bufferMinutes * 60;
return payload.exp <= (currentTime + bufferSeconds);
} catch (error) {
console.warn('Error parsing token for expiration check:', error);
return true;
}
}
static async refreshTokenIfNeeded(): Promise<void> {
const token = localStorage.getItem('auth_token');
const refreshToken = localStorage.getItem('refresh_token');
if (!token || !refreshToken) {
return;
}
// If token is expiring within 5 minutes, proactively refresh
if (this.isTokenExpiringSoon(token, 5)) {
try {
const baseURL = (apiClient as any).baseURL || window.location.origin;
const response = await fetch(`${baseURL}/api/v1/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('auth_token', data.access_token);
if (data.refresh_token) {
localStorage.setItem('refresh_token', data.refresh_token);
}
} else {
console.warn('Token refresh failed:', response.status);
}
} catch (error) {
console.warn('Token refresh error:', error);
}
}
}
static setup() {
apiClient.addRequestInterceptor({
onRequest: async (config: RequestConfig) => {
// Proactively refresh token if needed
await this.refreshTokenIfNeeded();
let token = localStorage.getItem('auth_token');
if (token) {
// Check if token is expired
if (this.isTokenExpired(token)) {
console.warn('Token expired, removing from storage');
localStorage.removeItem('auth_token');
token = null;
}
}
if (token) {
config.headers = {
...config.headers,
Authorization: `Bearer ${token}`,
};
} else {
console.warn('No valid auth token found - authentication required');
}
return config;
},
onRequestError: async (error: any) => {
console.error('Request interceptor error:', error);
throw error;
},
});
}
}
/**
* Logging Interceptor
* Logs API requests and responses for debugging
*/
class LoggingInterceptor {
static setup() {
apiClient.addRequestInterceptor({
onRequest: async (config: RequestConfig) => {
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
console.group(`🚀 API Request [${requestId}]`);
console.log('Method:', config.method);
console.log('URL:', config.url);
console.log('Headers:', config.headers);
if (config.body && config.method !== 'GET') {
console.log('Body:', config.body);
}
if (config.params) {
console.log('Params:', config.params);
}
console.groupEnd();
// Add request ID to config for response correlation
config.headers = {
...config.headers,
'X-Request-ID': requestId,
};
return config;
},
});
apiClient.addResponseInterceptor({
onResponse: async <T>(response: ApiResponse<T>) => {
const requestId = response.meta?.requestId || 'unknown';
console.group(`✅ API Response [${requestId}]`);
console.log('Status:', response.status);
console.log('Data:', response.data);
if (response.message) {
console.log('Message:', response.message);
}
console.groupEnd();
return response;
},
onResponseError: async (error: any) => {
const requestId = error?.config?.headers?.[`X-Request-ID`] || 'unknown';
console.group(`❌ API Error [${requestId}]`);
console.error('Status:', error?.response?.status);
console.error('Error:', ApiErrorHandler.formatError(error));
console.error('Full Error:', error);
console.groupEnd();
throw error;
},
});
}
}
/**
* Tenant Context Interceptor
* Automatically adds tenant context to tenant-scoped requests
*/
class TenantInterceptor {
private static currentTenantId: string | null = null;
static setCurrentTenant(tenantId: string | null) {
this.currentTenantId = tenantId;
}
static getCurrentTenant(): string | null {
return this.currentTenantId;
}
static setup() {
apiClient.addRequestInterceptor({
onRequest: async (config: RequestConfig) => {
// Add tenant context to tenant-scoped endpoints
if (this.currentTenantId && this.isTenantScopedEndpoint(config.url)) {
config.headers = {
...config.headers,
'X-Tenant-ID': this.currentTenantId,
};
}
return config;
},
});
}
private static isTenantScopedEndpoint(url?: string): boolean {
if (!url) return false;
return url.includes('/tenants/') ||
url.includes('/training/') ||
url.includes('/forecasts/') ||
url.includes('/notifications/');
}
}
/**
* Error Recovery Interceptor
* Handles automatic token refresh and retry logic
*/
class ErrorRecoveryInterceptor {
private static isRefreshing = false;
private static failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: any) => void;
}> = [];
static setup() {
apiClient.addResponseInterceptor({
onResponseError: async (error: any) => {
const originalRequest = error.config;
// Handle 401 errors with token refresh
if (error?.response?.status === 401 && !originalRequest._retry) {
if (this.isRefreshing) {
// Queue the request while refresh is in progress
return new Promise((resolve, reject) => {
this.failedQueue.push({ resolve, reject });
}).then(token => {
return this.retryRequestWithNewToken(originalRequest, token as string);
}).catch(err => {
throw err;
});
}
originalRequest._retry = true;
this.isRefreshing = true;
try {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) {
throw new Error('No refresh token available');
}
// Use direct fetch to avoid interceptor recursion
const baseURL = (apiClient as any).baseURL || window.location.origin;
const response = await fetch(`${baseURL}/api/v1/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!response.ok) {
throw new Error(`Token refresh failed: ${response.status}`);
}
const data = await response.json();
const newToken = data.access_token;
if (!newToken) {
throw new Error('No access token received');
}
localStorage.setItem('auth_token', newToken);
// Update new refresh token if provided
if (data.refresh_token) {
localStorage.setItem('refresh_token', data.refresh_token);
}
// Process failed queue
this.processQueue(null, newToken);
// Retry original request with new token
return this.retryRequestWithNewToken(originalRequest, newToken);
} catch (refreshError) {
console.warn('Token refresh failed:', refreshError);
this.processQueue(refreshError, null);
// Clear auth data and redirect to login
localStorage.removeItem('auth_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user_data');
// Only redirect if we're not already on the login page
if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) {
window.location.href = '/login';
}
throw refreshError;
} finally {
this.isRefreshing = false;
}
}
throw error;
},
});
}
private static async retryRequestWithNewToken(originalRequest: any, token: string) {
try {
// Use direct fetch instead of apiClient to avoid interceptor recursion
const url = originalRequest.url || originalRequest.endpoint;
const method = originalRequest.method || 'GET';
const fetchOptions: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
...originalRequest.headers
}
};
// Add body for non-GET requests
if (method !== 'GET' && originalRequest.body) {
fetchOptions.body = typeof originalRequest.body === 'string'
? originalRequest.body
: JSON.stringify(originalRequest.body);
}
// Add query parameters if present
let fullUrl = url;
if (originalRequest.params) {
const urlWithParams = new URL(fullUrl, (apiClient as any).baseURL);
Object.entries(originalRequest.params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
urlWithParams.searchParams.append(key, String(value));
}
});
fullUrl = urlWithParams.toString();
}
// Retry request with refreshed token
const response = await fetch(fullUrl, fetchOptions);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return await response.json();
} catch (retryError) {
console.warn('Request retry failed:', retryError);
throw retryError;
}
}
private static processQueue(error: any, token: string | null) {
this.failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error);
} else {
resolve(token!);
}
});
this.failedQueue = [];
}
}
/**
* Performance Monitoring Interceptor
* Tracks API performance metrics
*/
class PerformanceInterceptor {
private static metrics: Array<{
url: string;
method: string;
duration: number;
status: number;
timestamp: number;
}> = [];
static setup() {
apiClient.addRequestInterceptor({
onRequest: async (config: RequestConfig) => {
config.metadata = {
...config.metadata,
startTime: Date.now(),
};
return config;
},
});
apiClient.addResponseInterceptor({
onResponse: async <T>(response: ApiResponse<T>) => {
const startTime = response.metadata?.startTime;
if (startTime) {
const duration = Date.now() - startTime;
this.recordMetric({
url: response.metadata?.url || 'unknown',
method: response.metadata?.method || 'unknown',
duration,
status: 200,
timestamp: Date.now(),
});
}
return response;
},
onResponseError: async (error: any) => {
const startTime = error.config?.metadata?.startTime;
if (startTime) {
const duration = Date.now() - startTime;
this.recordMetric({
url: error.config?.url || 'unknown',
method: error.config?.method || 'unknown',
duration,
status: error?.response?.status || 0,
timestamp: Date.now(),
});
}
throw error;
},
});
}
private static recordMetric(metric: any) {
this.metrics.push(metric);
// Keep only last 1000 metrics
if (this.metrics.length > 1000) {
this.metrics = this.metrics.slice(-1000);
}
}
static getMetrics() {
return [...this.metrics];
}
static getAverageResponseTime(): number {
if (this.metrics.length === 0) return 0;
const total = this.metrics.reduce((sum, metric) => sum + metric.duration, 0);
return Math.round(total / this.metrics.length);
}
static getErrorRate(): number {
if (this.metrics.length === 0) return 0;
const errorCount = this.metrics.filter(metric => metric.status >= 400).length;
return Math.round((errorCount / this.metrics.length) * 100);
}
}
/**
* Setup all interceptors
* IMPORTANT: Order matters! ErrorRecoveryInterceptor must be first to handle token refresh
*/
export const setupInterceptors = () => {
// 1. Error recovery first (handles 401 and token refresh)
ErrorRecoveryInterceptor.setup();
// 2. Authentication (adds Bearer tokens)
AuthInterceptor.setup();
// 3. Tenant context
TenantInterceptor.setup();
// 4. Development-only interceptors
const isDevelopment = true; // Temporarily set to true for development
if (isDevelopment) {
LoggingInterceptor.setup();
PerformanceInterceptor.setup();
}
};
// Export interceptor classes for manual setup if needed
export {
AuthInterceptor,
LoggingInterceptor,
TenantInterceptor,
ErrorRecoveryInterceptor,
PerformanceInterceptor,
};

View File

@@ -1,115 +0,0 @@
// frontend/src/api/client/types.ts
/**
* Core API Client Types
*/
export interface RequestConfig {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
headers?: Record<string, string>;
params?: Record<string, any>;
body?: any;
url?: string;
timeout?: number;
retries?: number;
cache?: boolean;
cacheTTL?: number;
optimistic?: boolean;
background?: boolean;
metadata?: any;
}
export interface ApiResponse<T = any> {
data: T;
message?: string;
status: string;
timestamp?: string;
metadata?: any;
meta?: {
page?: number;
limit?: number;
total?: number;
hasNext?: boolean;
hasPrev?: boolean;
requestId?: string;
};
}
export interface ApiError {
message: string;
detail?: string;
code?: string;
field?: string;
timestamp?: string;
service?: string;
requestId?: string;
}
export interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
}
export interface UploadProgress {
loaded: number;
total: number;
percentage: number;
}
export interface UploadConfig extends RequestConfig {
onProgress?: (progress: UploadProgress) => void;
maxFileSize?: number;
allowedTypes?: string[];
}
// Request/Response interceptor types
export interface RequestInterceptor {
onRequest?: (config: RequestConfig) => RequestConfig | Promise<RequestConfig>;
onRequestError?: (error: any) => any;
}
export interface ResponseInterceptor {
onResponse?: <T>(response: ApiResponse<T>) => ApiResponse<T> | Promise<ApiResponse<T>>;
onResponseError?: (error: any) => any;
}
// Cache types
export interface CacheEntry<T = any> {
data: T;
timestamp: number;
ttl: number;
key: string;
}
export interface CacheStrategy {
key: (url: string, params?: any) => string;
ttl: number;
enabled: boolean;
}
// Metrics types
export interface RequestMetrics {
url: string;
method: string;
duration: number;
status: number;
size: number;
timestamp: number;
cached: boolean;
retries: number;
}
export interface ClientMetrics {
totalRequests: number;
successfulRequests: number;
failedRequests: number;
averageResponseTime: number;
cacheHitRate: number;
errorRate: number;
}