diff --git a/frontend/src/api/auth/tokenManager.ts b/frontend/src/api/auth/tokenManager.ts deleted file mode 100644 index e2a63132..00000000 --- a/frontend/src/api/auth/tokenManager.ts +++ /dev/null @@ -1,258 +0,0 @@ -// frontend/src/api/auth/tokenManager.ts - UPDATED TO HANDLE NEW TOKEN RESPONSE FORMAT -import { jwtDecode } from 'jwt-decode'; - -interface TokenPayload { - sub: string; - user_id: string; - email: string; - full_name: string; - is_verified: boolean; - exp: number; - iat: number; -} - -interface TokenResponse { - access_token: string; - refresh_token?: string; - token_type: string; - expires_in?: number; - user?: any; // User data from registration/login response -} - -class TokenManager { - private static instance: TokenManager; - private accessToken: string | null = null; - private refreshToken: string | null = null; - private refreshPromise: Promise | null = null; - private tokenExpiry: Date | null = null; - - private constructor() {} - - static getInstance(): TokenManager { - if (!TokenManager.instance) { - TokenManager.instance = new TokenManager(); - } - return TokenManager.instance; - } - - async initialize(): Promise { - // Try to restore tokens from secure storage - const stored = this.getStoredTokens(); - if (stored) { - this.accessToken = stored.accessToken; - this.refreshToken = stored.refreshToken; - this.tokenExpiry = new Date(stored.expiry); - - // Check if token needs refresh - if (this.isTokenExpired()) { - try { - await this.refreshAccessToken(); - } catch (error) { - console.error('Failed to refresh token on init:', error); - // If refresh fails on init, clear tokens - this.clearTokens(); - } - } - } - } - - async storeTokens(response: TokenResponse): Promise { - - if (!response || !response.access_token) { - throw new Error('Invalid token response: missing access_token'); - } - // Handle the new unified token response format - this.accessToken = response.access_token; - - // Store refresh token if provided (it might be optional in some flows) - if (response.refresh_token) { - this.refreshToken = response.refresh_token; - } - - // Calculate expiry time from expires_in or use default - const expiresIn = response.expires_in || 3600; // Default 1 hour - this.tokenExpiry = new Date(Date.now() + expiresIn * 1000); - - // Store securely - this.secureStore({ - accessToken: this.accessToken, - refreshToken: this.refreshToken, - expiry: this.tokenExpiry.toISOString() - }); - } - - async getAccessToken(): Promise { - // Check if token is expired or will expire soon (5 min buffer) - if (this.shouldRefreshToken() && this.refreshToken) { - try { - await this.refreshAccessToken(); - } catch (error) { - console.error('Token refresh failed:', error); - // Return current token even if refresh failed (might still be valid) - return this.accessToken; - } - } - return this.accessToken; - } - - getRefreshToken(): string | null { - return this.refreshToken; - } - - async refreshAccessToken(): Promise { - // Prevent multiple simultaneous refresh attempts - if (this.refreshPromise) { - return this.refreshPromise; - } - - this.refreshPromise = this.performTokenRefresh(); - - try { - await this.refreshPromise; - } finally { - this.refreshPromise = null; - } - } - - private async performTokenRefresh(): Promise { - if (!this.refreshToken) { - throw new Error('No refresh token available'); - } - - try { - // FIXED: Use correct refresh endpoint - const response = await fetch('/api/v1/auth/refresh', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - refresh_token: this.refreshToken - }) - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || `HTTP ${response.status}: Token refresh failed`); - } - - const data: TokenResponse = await response.json(); - - // Update only the access token from refresh response - // Refresh token typically stays the same unless using token rotation - this.accessToken = data.access_token; - - if (data.refresh_token) { - this.refreshToken = data.refresh_token; - } - - const expiresIn = data.expires_in || 3600; - this.tokenExpiry = new Date(Date.now() + expiresIn * 1000); - - // Update storage - this.secureStore({ - accessToken: this.accessToken, - refreshToken: this.refreshToken, - expiry: this.tokenExpiry.toISOString() - }); - - } catch (error) { - console.error('Token refresh error:', error); - // Clear tokens on refresh failure - this.clearTokens(); - throw error; - } - } - - clearTokens(): void { - this.accessToken = null; - this.refreshToken = null; - this.tokenExpiry = null; - this.clearSecureStore(); - } - - isAuthenticated(): boolean { - return !!this.accessToken && !this.isTokenExpired(); - } - - private isTokenExpired(): boolean { - if (!this.tokenExpiry) return true; - return new Date() >= this.tokenExpiry; - } - - private shouldRefreshToken(): boolean { - if (!this.tokenExpiry || !this.refreshToken) return false; - // Refresh if token expires in less than 5 minutes - const bufferTime = 5 * 60 * 1000; // 5 minutes - return new Date(Date.now() + bufferTime) >= this.tokenExpiry; - } - - // Secure storage implementation - private secureStore(data: any): void { - try { - const encrypted = this.encrypt(JSON.stringify(data)); - sessionStorage.setItem('auth_tokens', encrypted); - } catch (error) { - console.error('Failed to store tokens:', error); - } - } - - private getStoredTokens(): any { - try { - const stored = sessionStorage.getItem('auth_tokens'); - if (!stored) return null; - - const decrypted = this.decrypt(stored); - return JSON.parse(decrypted); - } catch (error) { - console.error('Failed to retrieve stored tokens:', error); - // Clear corrupted storage - this.clearSecureStore(); - return null; - } - } - - private clearSecureStore(): void { - try { - sessionStorage.removeItem('auth_tokens'); - } catch (error) { - console.error('Failed to clear stored tokens:', error); - } - } - - // Simple encryption for demo (use proper encryption in production) - private encrypt(data: string): string { - return btoa(data); - } - - private decrypt(data: string): string { - return atob(data); - } - - // Get decoded token payload - getTokenPayload(): TokenPayload | null { - if (!this.accessToken) return null; - - try { - return jwtDecode(this.accessToken); - } catch (error) { - console.error('Failed to decode token:', error); - return null; - } - } - - // Get user information from token - getUserFromToken(): { user_id: string; email: string; full_name: string; is_verified: boolean } | null { - const payload = this.getTokenPayload(); - if (!payload) return null; - - return { - user_id: payload.user_id, - email: payload.email, - full_name: payload.full_name, - is_verified: payload.is_verified - }; - } -} - -export const tokenManager = TokenManager.getInstance(); \ No newline at end of file diff --git a/frontend/src/api/base/apiClient.ts b/frontend/src/api/base/apiClient.ts deleted file mode 100644 index 56b99638..00000000 --- a/frontend/src/api/base/apiClient.ts +++ /dev/null @@ -1,324 +0,0 @@ -// frontend/src/api/base/apiClient.ts - UPDATED WITH FIXED BASE URL AND ERROR HANDLING - -import { tokenManager } from '../auth/tokenManager'; - -export interface ApiConfig { - baseURL: string; - timeout?: number; - retryAttempts?: number; - retryDelay?: number; -} - -export interface ApiError { - message: string; - code?: string; - status?: number; - details?: any; -} - -export interface RequestConfig extends RequestInit { - params?: Record; - timeout?: number; - retry?: boolean; - retryAttempts?: number; -} - -type Interceptor = (value: T) => T | Promise; - -class ApiClient { - private config: ApiConfig; - private requestInterceptors: Interceptor[] = []; - private responseInterceptors: { - fulfilled: Interceptor; - rejected: Interceptor; - }[] = []; - - constructor(config: ApiConfig) { - this.config = { - timeout: 30000, - retryAttempts: 3, - retryDelay: 1000, - ...config - }; - - this.setupDefaultInterceptors(); - } - - private setupDefaultInterceptors(): void { - // Request interceptor for authentication - this.addRequestInterceptor(async (config) => { - const token = await tokenManager.getAccessToken(); - if (token) { - config.headers = { - ...config.headers, - 'Authorization': `Bearer ${token}` - }; - } - return config; - }); - - // Request interceptor for content type - this.addRequestInterceptor((config) => { - if (config.body && !(config.body instanceof FormData)) { - config.headers = { - ...config.headers, - 'Content-Type': 'application/json' - }; - } - return config; - }); - - // Response interceptor for error handling and token refresh - this.addResponseInterceptor( - (response) => response, - async (error) => { - if (error.response?.status === 401) { - // Try to refresh token - try { - await tokenManager.refreshAccessToken(); - // Retry original request with new token - const newToken = await tokenManager.getAccessToken(); - if (newToken && error.config) { - error.config.headers = { - ...error.config.headers, - 'Authorization': `Bearer ${newToken}` - }; - return this.request(error.config); - } - } catch (refreshError) { - console.error('Token refresh failed during request retry:', refreshError); - // Clear tokens and redirect to login - tokenManager.clearTokens(); - window.location.href = '/login'; - throw refreshError; - } - } - throw this.transformError(error); - } - ); - } - - addRequestInterceptor(interceptor: Interceptor): void { - this.requestInterceptors.push(interceptor); - } - - addResponseInterceptor( - fulfilled: Interceptor, - rejected: Interceptor - ): void { - this.responseInterceptors.push({ fulfilled, rejected }); - } - - private async applyRequestInterceptors(config: RequestConfig): Promise { - let processedConfig = config; - for (const interceptor of this.requestInterceptors) { - processedConfig = await interceptor(processedConfig); - } - return processedConfig; - } - - private async applyResponseInterceptors( - response: Response | Promise - ): Promise { - let processedResponse = await response; - - for (const { fulfilled, rejected } of this.responseInterceptors) { - try { - processedResponse = await fulfilled(processedResponse); - } catch (error) { - processedResponse = await rejected(error); - } - } - - return processedResponse; - } - - private buildURL(endpoint: string, params?: Record): string { - const url = new URL(endpoint, this.config.baseURL); - - if (params) { - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - url.searchParams.append(key, String(value)); - } - }); - } - - return url.toString(); - } - - private createTimeoutPromise(timeout: number): Promise { - return new Promise((_, reject) => { - setTimeout(() => { - reject(new Error('Request timeout')); - }, timeout); - }); - } - - private async executeWithRetry( - fn: () => Promise, - attempts: number, - delay: number - ): Promise { - try { - return await fn(); - } catch (error) { - if (attempts <= 1) throw error; - - // Check if error is retryable - const isRetryable = this.isRetryableError(error); - if (!isRetryable) throw error; - - // Wait before retry - await new Promise(resolve => setTimeout(resolve, delay)); - - return this.executeWithRetry(fn, attempts - 1, delay * 1.5); - } - } - - private isRetryableError(error: any): boolean { - // Retry on network errors or 5xx server errors - return !error.response || (error.response.status >= 500 && error.response.status < 600); - } - - private transformError(error: any): ApiError { - if (error.response) { - return { - message: error.response.data?.detail || error.response.data?.message || 'Request failed', - status: error.response.status, - code: error.response.data?.code, - details: error.response.data - }; - } - - return { - message: error.message || 'Network error', - code: 'NETWORK_ERROR' - }; - } - - async request(endpoint: string, config: RequestConfig = {}): Promise { - const processedConfig = await this.applyRequestInterceptors(config); - const url = this.buildURL(endpoint, processedConfig.params); - const timeout = processedConfig.timeout || this.config.timeout!; - - const makeRequest = async (): Promise => { - const requestPromise = fetch(url, { - ...processedConfig, - signal: AbortSignal.timeout(timeout) - }); - - return Promise.race([ - requestPromise, - this.createTimeoutPromise(timeout) - ]); - }; - - try { - let response: Response; - - if (config.retry !== false) { - response = await this.executeWithRetry( - makeRequest, - this.config.retryAttempts!, - this.config.retryDelay! - ); - } else { - response = await makeRequest(); - } - - response = await this.applyResponseInterceptors(response); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw { - response: { - status: response.status, - statusText: response.statusText, - data: errorData - } - }; - } - - // Handle empty responses (like 204 No Content) - if (response.status === 204 || response.headers.get('content-length') === '0') { - return {} as T; - } - - return await response.json(); - } catch (error) { - throw this.transformError(error); - } - } - - // Convenience methods - get(endpoint: string, config?: RequestConfig): Promise { - return this.request(endpoint, { ...config, method: 'GET' }); - } - - post(endpoint: string, data?: any, config?: RequestConfig): Promise { - return this.request(endpoint, { - ...config, - method: 'POST', - body: data ? JSON.stringify(data) : undefined - }); - } - - put(endpoint: string, data?: any, config?: RequestConfig): Promise { - return this.request(endpoint, { - ...config, - method: 'PUT', - body: data ? JSON.stringify(data) : undefined - }); - } - - patch(endpoint: string, data?: any, config?: RequestConfig): Promise { - return this.request(endpoint, { - ...config, - method: 'PATCH', - body: data ? JSON.stringify(data) : undefined - }); - } - - delete(endpoint: string, config?: RequestConfig): Promise { - return this.request(endpoint, { ...config, method: 'DELETE' }); - } - - // File upload - upload( - endpoint: string, - file: File, - additionalData?: Record, - config?: RequestConfig - ): Promise { - const formData = new FormData(); - formData.append('file', file); - - if (additionalData) { - Object.entries(additionalData).forEach(([key, value]) => { - formData.append(key, value); - }); - } - - return this.request(endpoint, { - ...config, - method: 'POST', - body: formData - }); - } - - // WebSocket connection - createWebSocket(endpoint: string): WebSocket { - const wsUrl = this.config.baseURL.replace(/^http/, 'ws'); - return new WebSocket(`${wsUrl}${endpoint}`); - } - - private generateRequestId(): string { - return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - } -} - -// FIXED: Create default instance with correct base URL (removed /api suffix) -export const apiClient = new ApiClient({ - baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1' -}); \ No newline at end of file diff --git a/frontend/src/api/base/circuitBreaker.ts b/frontend/src/api/base/circuitBreaker.ts deleted file mode 100644 index aae5e459..00000000 --- a/frontend/src/api/base/circuitBreaker.ts +++ /dev/null @@ -1,48 +0,0 @@ -// src/api/base/circuitBreaker.ts -export class CircuitBreaker { - private failures: number = 0; - private lastFailureTime: number = 0; - private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED'; - - constructor( - private threshold: number = 5, - private timeout: number = 60000 // 1 minute - ) {} - - async execute(fn: () => Promise): Promise { - if (this.state === 'OPEN') { - if (Date.now() - this.lastFailureTime > this.timeout) { - this.state = 'HALF_OPEN'; - } else { - throw new Error('Circuit breaker is OPEN'); - } - } - - try { - const result = await fn(); - this.onSuccess(); - return result; - } catch (error) { - this.onFailure(); - throw error; - } - } - - private onSuccess(): void { - this.failures = 0; - this.state = 'CLOSED'; - } - - private onFailure(): void { - this.failures++; - this.lastFailureTime = Date.now(); - - if (this.failures >= this.threshold) { - this.state = 'OPEN'; - } - } - - getState(): string { - return this.state; - } -} \ No newline at end of file diff --git a/frontend/src/api/client/config.ts b/frontend/src/api/client/config.ts new file mode 100644 index 00000000..40a2f72f --- /dev/null +++ b/frontend/src/api/client/config.ts @@ -0,0 +1,142 @@ +// 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 => { + const isProduction = process.env.NODE_ENV === 'production'; + const isDevelopment = process.env.NODE_ENV === 'development'; + + return { + baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1', + timeout: parseInt(process.env.NEXT_PUBLIC_API_TIMEOUT || '30000'), + retries: parseInt(process.env.NEXT_PUBLIC_API_RETRIES || '3'), + retryDelay: parseInt(process.env.NEXT_PUBLIC_API_RETRY_DELAY || '1000'), + enableLogging: isDevelopment || process.env.NEXT_PUBLIC_API_LOGGING === 'true', + enableCaching: process.env.NEXT_PUBLIC_API_CACHING !== 'false', + cacheTimeout: parseInt(process.env.NEXT_PUBLIC_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; + +// Feature flags for API behavior +export interface FeatureFlags { + enableWebSockets: boolean; + enableOfflineMode: boolean; + enableOptimisticUpdates: boolean; + enableRequestDeduplication: boolean; + enableMetrics: boolean; +} + +export const featureFlags: FeatureFlags = { + enableWebSockets: process.env.NEXT_PUBLIC_ENABLE_WEBSOCKETS === 'true', + enableOfflineMode: process.env.NEXT_PUBLIC_ENABLE_OFFLINE === 'true', + enableOptimisticUpdates: process.env.NEXT_PUBLIC_ENABLE_OPTIMISTIC_UPDATES !== 'false', + enableRequestDeduplication: process.env.NEXT_PUBLIC_ENABLE_DEDUPLICATION !== 'false', + enableMetrics: process.env.NEXT_PUBLIC_ENABLE_METRICS === 'true', +}; diff --git a/frontend/src/api/client/index.ts b/frontend/src/api/client/index.ts new file mode 100644 index 00000000..644bb67d --- /dev/null +++ b/frontend/src/api/client/index.ts @@ -0,0 +1,489 @@ +// 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(); + private pendingRequests = new Map>(); + private requestInterceptors: RequestInterceptor[] = []; + private responseInterceptors: ResponseInterceptor[] = []; + private metrics: RequestMetrics[] = []; + + constructor(baseURL?: string) { + this.baseURL = baseURL || apiConfig.baseURL; + } + + /** + * 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(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(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 { + 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(response: ApiResponse): Promise> { + 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( + requestFn: () => Promise, + attempts: number = retryConfig.attempts + ): Promise { + 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): 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(endpoint: string, config: RequestConfig = {}): Promise { + const startTime = Date.now(); + const url = `${this.baseURL}${endpoint}`; + const method = config.method || 'GET'; + + // 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(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 = { + '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(); + + // Apply response interceptors + const processedResponse = await this.applyResponseInterceptors(responseData); + + 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, + }); + + return result; + } 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(endpoint: string, config?: RequestConfig): Promise { + return this.request(endpoint, { ...config, method: 'GET' }); + } + + async post(endpoint: string, data?: any, config?: RequestConfig): Promise { + return this.request(endpoint, { + ...config, + method: 'POST', + body: data + }); + } + + async put(endpoint: string, data?: any, config?: RequestConfig): Promise { + return this.request(endpoint, { + ...config, + method: 'PUT', + body: data + }); + } + + async patch(endpoint: string, data?: any, config?: RequestConfig): Promise { + return this.request(endpoint, { + ...config, + method: 'PATCH', + body: data + }); + } + + async delete(endpoint: string, config?: RequestConfig): Promise { + return this.request(endpoint, { ...config, method: 'DELETE' }); + } + + /** + * File upload with progress tracking + */ + async upload( + endpoint: string, + file: File, + additionalData?: Record, + config?: UploadConfig + ): Promise { + 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(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 +export const apiClient = new ApiClient(); \ No newline at end of file diff --git a/frontend/src/api/client/interceptors.ts b/frontend/src/api/client/interceptors.ts new file mode 100644 index 00000000..e9a6b234 --- /dev/null +++ b/frontend/src/api/client/interceptors.ts @@ -0,0 +1,370 @@ +// 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 + */ +export class AuthInterceptor { + static setup() { + apiClient.addRequestInterceptor({ + onRequest: async (config: RequestConfig) => { + const token = localStorage.getItem('auth_token'); + + if (token) { + config.headers = { + ...config.headers, + Authorization: `Bearer ${token}`, + }; + } + + return config; + }, + + onRequestError: async (error: any) => { + console.error('Request interceptor error:', error); + throw error; + }, + }); + + apiClient.addResponseInterceptor({ + onResponseError: async (error: any) => { + // Handle 401 Unauthorized - redirect to login + if (error?.response?.status === 401) { + localStorage.removeItem('auth_token'); + localStorage.removeItem('refresh_token'); + localStorage.removeItem('user_data'); + + // Redirect to login page + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + } + + throw error; + }, + }); + } +} + +/** + * Logging Interceptor + * Logs API requests and responses for debugging + */ +export 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 (response: ApiResponse) => { + 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 + */ +export 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 + */ +export 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 => { + originalRequest.headers['Authorization'] = `Bearer ${token}`; + return apiClient.request(originalRequest.url, originalRequest); + }).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'); + } + + // Attempt to refresh token + const response = await fetch(`${apiClient['baseURL']}/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + + if (!response.ok) { + throw new Error('Token refresh failed'); + } + + const data = await response.json(); + const newToken = data.access_token; + + localStorage.setItem('auth_token', newToken); + + // Process failed queue + this.processQueue(null, newToken); + + // Retry original request + originalRequest.headers['Authorization'] = `Bearer ${newToken}`; + return apiClient.request(originalRequest.url, originalRequest); + + } catch (refreshError) { + this.processQueue(refreshError, null); + + // Clear auth data and redirect to login + localStorage.removeItem('auth_token'); + localStorage.removeItem('refresh_token'); + localStorage.removeItem('user_data'); + + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + + throw refreshError; + } finally { + this.isRefreshing = false; + } + } + + throw error; + }, + }); + } + + 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 + */ +export 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 (response: ApiResponse) => { + 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 + */ +export const setupInterceptors = () => { + AuthInterceptor.setup(); + + if (process.env.NODE_ENV === 'development') { + LoggingInterceptor.setup(); + PerformanceInterceptor.setup(); + } + + TenantInterceptor.setup(); + ErrorRecoveryInterceptor.setup(); +}; + +// Export interceptor classes for manual setup if needed +export { + AuthInterceptor, + LoggingInterceptor, + TenantInterceptor, + ErrorRecoveryInterceptor, + PerformanceInterceptor, +}; \ No newline at end of file diff --git a/frontend/src/api/client/types.ts b/frontend/src/api/client/types.ts new file mode 100644 index 00000000..89f1af8e --- /dev/null +++ b/frontend/src/api/client/types.ts @@ -0,0 +1,110 @@ +// frontend/src/api/client/types.ts +/** + * Core API Client Types + */ + +export interface RequestConfig { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + headers?: Record; + params?: Record; + timeout?: number; + retries?: number; + cache?: boolean; + cacheTTL?: number; + optimistic?: boolean; + background?: boolean; +} + +export interface ApiResponse { + data: T; + message?: string; + status: string; + timestamp?: string; + meta?: { + page?: number; + limit?: number; + total?: number; + hasNext?: boolean; + hasPrev?: boolean; + }; +} + +export interface ApiError { + message: string; + detail?: string; + code?: string; + field?: string; + timestamp?: string; + service?: string; + requestId?: string; +} + +export interface PaginatedResponse { + 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; + onRequestError?: (error: any) => any; +} + +export interface ResponseInterceptor { + onResponse?: (response: ApiResponse) => ApiResponse | Promise>; + onResponseError?: (error: any) => any; +} + +// Cache types +export interface CacheEntry { + 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; +} \ No newline at end of file diff --git a/frontend/src/api/hooks/index.ts b/frontend/src/api/hooks/index.ts new file mode 100644 index 00000000..016de4e2 --- /dev/null +++ b/frontend/src/api/hooks/index.ts @@ -0,0 +1,30 @@ +// frontend/src/api/hooks/index.ts +/** + * Main Hooks Export + */ + +export { useAuth, useAuthHeaders } from './useAuth'; +export { useTenant } from './useTenant'; +export { useData } from './useData'; +export { useTraining } from './useTraining'; +export { useForecast } from './useForecast'; +export { useNotification } from './useNotification'; + +// Combined hook for common operations +export const useApiHooks = () => { + const auth = useAuth(); + const tenant = useTenant(); + const data = useData(); + const training = useTraining(); + const forecast = useForecast(); + const notification = useNotification(); + + return { + auth, + tenant, + data, + training, + forecast, + notification, + }; +}; \ No newline at end of file diff --git a/frontend/src/api/hooks/useAuth.ts b/frontend/src/api/hooks/useAuth.ts new file mode 100644 index 00000000..e0127ccf --- /dev/null +++ b/frontend/src/api/hooks/useAuth.ts @@ -0,0 +1,193 @@ +// frontend/src/api/hooks/useAuth.ts +/** + * Authentication Hooks + * React hooks for authentication operations + */ + +import { useState, useEffect, useCallback } from 'react'; +import { authService } from '../services'; +import type { + LoginRequest, + LoginResponse, + RegisterRequest, + UserResponse, + PasswordResetRequest, +} from '../types'; + +// Token management +const TOKEN_KEY = 'auth_token'; +const REFRESH_TOKEN_KEY = 'refresh_token'; +const USER_KEY = 'user_data'; + +export const useAuth = () => { + const [user, setUser] = useState(null); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Initialize auth state from localStorage + useEffect(() => { + const initializeAuth = async () => { + try { + const token = localStorage.getItem(TOKEN_KEY); + const userData = localStorage.getItem(USER_KEY); + + if (token && userData) { + setUser(JSON.parse(userData)); + setIsAuthenticated(true); + + // Verify token is still valid + try { + const currentUser = await authService.getCurrentUser(); + setUser(currentUser); + } catch (error) { + // Token expired or invalid, clear auth state + logout(); + } + } + } catch (error) { + console.error('Auth initialization error:', error); + logout(); + } finally { + setIsLoading(false); + } + }; + + initializeAuth(); + }, []); + + const login = useCallback(async (credentials: LoginRequest): Promise => { + try { + setIsLoading(true); + setError(null); + + const response = await authService.login(credentials); + + // Store tokens and user data + localStorage.setItem(TOKEN_KEY, response.access_token); + if (response.refresh_token) { + localStorage.setItem(REFRESH_TOKEN_KEY, response.refresh_token); + } + if (response.user) { + localStorage.setItem(USER_KEY, JSON.stringify(response.user)); + setUser(response.user); + } + + setIsAuthenticated(true); + } catch (error) { + const message = error instanceof Error ? error.message : 'Login failed'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const register = useCallback(async (data: RegisterRequest): Promise => { + try { + setIsLoading(true); + setError(null); + + const response = await authService.register(data); + + // Auto-login after successful registration + if (response.user) { + await login({ email: data.email, password: data.password }); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Registration failed'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, [login]); + + const logout = useCallback(async (): Promise => { + try { + // Call logout endpoint if authenticated + if (isAuthenticated) { + await authService.logout(); + } + } catch (error) { + console.error('Logout error:', error); + } finally { + // Clear local state regardless of API call success + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem(USER_KEY); + setUser(null); + setIsAuthenticated(false); + setError(null); + } + }, [isAuthenticated]); + + const updateProfile = useCallback(async (data: Partial): Promise => { + try { + setIsLoading(true); + setError(null); + + const updatedUser = await authService.updateProfile(data); + setUser(updatedUser); + localStorage.setItem(USER_KEY, JSON.stringify(updatedUser)); + } catch (error) { + const message = error instanceof Error ? error.message : 'Profile update failed'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const requestPasswordReset = useCallback(async (data: PasswordResetRequest): Promise => { + try { + setIsLoading(true); + setError(null); + await authService.requestPasswordReset(data); + } catch (error) { + const message = error instanceof Error ? error.message : 'Password reset request failed'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const changePassword = useCallback(async (currentPassword: string, newPassword: string): Promise => { + try { + setIsLoading(true); + setError(null); + await authService.changePassword(currentPassword, newPassword); + } catch (error) { + const message = error instanceof Error ? error.message : 'Password change failed'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + return { + user, + isAuthenticated, + isLoading, + error, + login, + register, + logout, + updateProfile, + requestPasswordReset, + changePassword, + clearError: () => setError(null), + }; +}; + +// Hook for getting authentication headers +export const useAuthHeaders = () => { + const getAuthHeaders = useCallback(() => { + const token = localStorage.getItem(TOKEN_KEY); + return token ? { Authorization: `Bearer ${token}` } : {}; + }, []); + + return { getAuthHeaders }; +}; \ No newline at end of file diff --git a/frontend/src/api/hooks/useData.ts b/frontend/src/api/hooks/useData.ts new file mode 100644 index 00000000..9a6a26d4 --- /dev/null +++ b/frontend/src/api/hooks/useData.ts @@ -0,0 +1,172 @@ +// frontend/src/api/hooks/useData.ts +/** + * Data Management Hooks + */ + +import { useState, useCallback } from 'react'; +import { dataService } from '../services'; +import type { + SalesData, + SalesDataQuery, + SalesImportResult, + DashboardStats, + ActivityItem, +} from '../types'; + +export const useData = () => { + const [salesData, setSalesData] = useState([]); + const [dashboardStats, setDashboardStats] = useState(null); + const [recentActivity, setRecentActivity] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [uploadProgress, setUploadProgress] = useState(0); + + const uploadSalesHistory = useCallback(async ( + tenantId: string, + file: File, + additionalData?: Record + ): Promise => { + try { + setIsLoading(true); + setError(null); + setUploadProgress(0); + + const result = await dataService.uploadSalesHistory(tenantId, file, { + ...additionalData, + onProgress: (progress) => { + setUploadProgress(progress.percentage); + }, + }); + + return result; + } catch (error) { + const message = error instanceof Error ? error.message : 'Upload failed'; + setError(message); + throw error; + } finally { + setIsLoading(false); + setUploadProgress(0); + } + }, []); + + const validateSalesData = useCallback(async ( + tenantId: string, + file: File + ): Promise => { + try { + setIsLoading(true); + setError(null); + + const result = await dataService.validateSalesData(tenantId, file); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : 'Validation failed'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const getSalesData = useCallback(async ( + tenantId: string, + query?: SalesDataQuery + ): Promise => { + try { + setIsLoading(true); + setError(null); + + const response = await dataService.getSalesData(tenantId, query); + setSalesData(response.data); + + return response.data; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get sales data'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const getDashboardStats = useCallback(async (tenantId: string): Promise => { + try { + setIsLoading(true); + setError(null); + + const stats = await dataService.getDashboardStats(tenantId); + setDashboardStats(stats); + + return stats; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get dashboard stats'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const getRecentActivity = useCallback(async (tenantId: string, limit?: number): Promise => { + try { + setIsLoading(true); + setError(null); + + const activity = await dataService.getRecentActivity(tenantId, limit); + setRecentActivity(activity); + + return activity; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get recent activity'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const exportSalesData = useCallback(async ( + tenantId: string, + format: 'csv' | 'excel' | 'json', + query?: SalesDataQuery + ): Promise => { + try { + setIsLoading(true); + setError(null); + + const blob = await dataService.exportSalesData(tenantId, format, query); + + // Create download link + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `sales-data.${format}`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } catch (error) { + const message = error instanceof Error ? error.message : 'Export failed'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + return { + salesData, + dashboardStats, + recentActivity, + isLoading, + error, + uploadProgress, + uploadSalesHistory, + validateSalesData, + getSalesData, + getDashboardStats, + getRecentActivity, + exportSalesData, + clearError: () => setError(null), + }; +}; diff --git a/frontend/src/api/hooks/useForecast.ts b/frontend/src/api/hooks/useForecast.ts new file mode 100644 index 00000000..6db6618f --- /dev/null +++ b/frontend/src/api/hooks/useForecast.ts @@ -0,0 +1,212 @@ +// frontend/src/api/hooks/useForecast.ts +/** + * Forecasting Operations Hooks + */ + +import { useState, useCallback } from 'react'; +import { forecastingService } from '../services'; +import type { + SingleForecastRequest, + BatchForecastRequest, + ForecastResponse, + BatchForecastResponse, + ForecastAlert, + QuickForecast, +} from '../types'; + +export const useForecast = () => { + const [forecasts, setForecasts] = useState([]); + const [batchForecasts, setBatchForecasts] = useState([]); + const [quickForecasts, setQuickForecasts] = useState([]); + const [alerts, setAlerts] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const createSingleForecast = useCallback(async ( + tenantId: string, + request: SingleForecastRequest + ): Promise => { + try { + setIsLoading(true); + setError(null); + + const newForecasts = await forecastingService.createSingleForecast(tenantId, request); + setForecasts(prev => [...newForecasts, ...prev]); + + return newForecasts; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to create forecast'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const createBatchForecast = useCallback(async ( + tenantId: string, + request: BatchForecastRequest + ): Promise => { + try { + setIsLoading(true); + setError(null); + + const batchForecast = await forecastingService.createBatchForecast(tenantId, request); + setBatchForecasts(prev => [batchForecast, ...prev]); + + return batchForecast; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to create batch forecast'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const getForecasts = useCallback(async (tenantId: string): Promise => { + try { + setIsLoading(true); + setError(null); + + const response = await forecastingService.getForecasts(tenantId); + setForecasts(response.data); + + return response.data; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get forecasts'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const getBatchForecastStatus = useCallback(async ( + tenantId: string, + batchId: string + ): Promise => { + try { + const batchForecast = await forecastingService.getBatchForecastStatus(tenantId, batchId); + + // Update batch forecast in state + setBatchForecasts(prev => prev.map(bf => + bf.id === batchId ? batchForecast : bf + )); + + return batchForecast; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get batch forecast status'; + setError(message); + throw error; + } + }, []); + + const getQuickForecasts = useCallback(async (tenantId: string): Promise => { + try { + setIsLoading(true); + setError(null); + + const quickForecastData = await forecastingService.getQuickForecasts(tenantId); + setQuickForecasts(quickForecastData); + + return quickForecastData; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get quick forecasts'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const getForecastAlerts = useCallback(async (tenantId: string): Promise => { + try { + setIsLoading(true); + setError(null); + + const response = await forecastingService.getForecastAlerts(tenantId); + setAlerts(response.data); + + return response.data; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get forecast alerts'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const acknowledgeForecastAlert = useCallback(async ( + tenantId: string, + alertId: string + ): Promise => { + try { + setIsLoading(true); + setError(null); + + const acknowledgedAlert = await forecastingService.acknowledgeForecastAlert(tenantId, alertId); + setAlerts(prev => prev.map(alert => + alert.id === alertId ? acknowledgedAlert : alert + )); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to acknowledge alert'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const exportForecasts = useCallback(async ( + tenantId: string, + format: 'csv' | 'excel' | 'json', + params?: { + product_name?: string; + start_date?: string; + end_date?: string; + } + ): Promise => { + try { + setIsLoading(true); + setError(null); + + const blob = await forecastingService.exportForecasts(tenantId, format, params); + + // Create download link + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `forecasts.${format}`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } catch (error) { + const message = error instanceof Error ? error.message : 'Export failed'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + return { + forecasts, + batchForecasts, + quickForecasts, + alerts, + isLoading, + error, + createSingleForecast, + createBatchForecast, + getForecasts, + getBatchForecastStatus, + getQuickForecasts, + getForecastAlerts, + acknowledgeForecastAlert, + exportForecasts, + clearError: () => setError(null), + }; +}; diff --git a/frontend/src/api/hooks/useNotification.ts b/frontend/src/api/hooks/useNotification.ts new file mode 100644 index 00000000..43b059e7 --- /dev/null +++ b/frontend/src/api/hooks/useNotification.ts @@ -0,0 +1,151 @@ +// frontend/src/api/hooks/useNotification.ts +/** + * Notification Operations Hooks + */ + +import { useState, useCallback } from 'react'; +import { notificationService } from '../services'; +import type { + NotificationCreate, + NotificationResponse, + NotificationTemplate, + NotificationStats, + BulkNotificationRequest, +} from '../types'; + +export const useNotification = () => { + const [notifications, setNotifications] = useState([]); + const [templates, setTemplates] = useState([]); + const [stats, setStats] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const sendNotification = useCallback(async ( + tenantId: string, + notification: NotificationCreate + ): Promise => { + try { + setIsLoading(true); + setError(null); + + const sentNotification = await notificationService.sendNotification(tenantId, notification); + setNotifications(prev => [sentNotification, ...prev]); + + return sentNotification; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to send notification'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const sendBulkNotifications = useCallback(async ( + tenantId: string, + request: BulkNotificationRequest + ): Promise => { + try { + setIsLoading(true); + setError(null); + + await notificationService.sendBulkNotifications(tenantId, request); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to send bulk notifications'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const getNotifications = useCallback(async (tenantId: string): Promise => { + try { + setIsLoading(true); + setError(null); + + const response = await notificationService.getNotifications(tenantId); + setNotifications(response.data); + + return response.data; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get notifications'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const getTemplates = useCallback(async (tenantId: string): Promise => { + try { + setIsLoading(true); + setError(null); + + const response = await notificationService.getTemplates(tenantId); + setTemplates(response.data); + + return response.data; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get templates'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const createTemplate = useCallback(async ( + tenantId: string, + template: Omit + ): Promise => { + try { + setIsLoading(true); + setError(null); + + const newTemplate = await notificationService.createTemplate(tenantId, template); + setTemplates(prev => [newTemplate, ...prev]); + + return newTemplate; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to create template'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const getNotificationStats = useCallback(async (tenantId: string): Promise => { + try { + setIsLoading(true); + setError(null); + + const notificationStats = await notificationService.getNotificationStats(tenantId); + setStats(notificationStats); + + return notificationStats; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get notification stats'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + return { + notifications, + templates, + stats, + isLoading, + error, + sendNotification, + sendBulkNotifications, + getNotifications, + getTemplates, + createTemplate, + getNotificationStats, + clearError: () => setError(null), + }; +}; \ No newline at end of file diff --git a/frontend/src/api/hooks/useSessionTimeout.ts b/frontend/src/api/hooks/useSessionTimeout.ts deleted file mode 100644 index e2eff53d..00000000 --- a/frontend/src/api/hooks/useSessionTimeout.ts +++ /dev/null @@ -1,71 +0,0 @@ -// src/hooks/useSessionTimeout.ts -import { useEffect, useRef } from 'react'; -import { useAuth } from '../../contexts/AuthContext'; - -interface SessionTimeoutOptions { - timeout: number; // milliseconds - onTimeout?: () => void; - warningTime?: number; // Show warning before timeout - onWarning?: () => void; -} - -export const useSessionTimeout = ({ - timeout = 30 * 60 * 1000, // 30 minutes default - onTimeout, - warningTime = 5 * 60 * 1000, // 5 minutes warning - onWarning -}: SessionTimeoutOptions) => { - const { logout } = useAuth(); - const timeoutRef = useRef(); - const warningRef = useRef(); - - const resetTimeout = () => { - // Clear existing timeouts - if (timeoutRef.current) clearTimeout(timeoutRef.current); - if (warningRef.current) clearTimeout(warningRef.current); - - // Set warning timeout - if (warningTime && onWarning) { - warningRef.current = setTimeout(() => { - onWarning(); - }, timeout - warningTime); - } - - // Set session timeout - timeoutRef.current = setTimeout(() => { - if (onTimeout) { - onTimeout(); - } else { - logout(); - } - }, timeout); - }; - - useEffect(() => { - // Activity events to reset timeout - const events = ['mousedown', 'keypress', 'scroll', 'touchstart']; - - const handleActivity = () => { - resetTimeout(); - }; - - // Add event listeners - events.forEach(event => { - document.addEventListener(event, handleActivity); - }); - - // Start timeout - resetTimeout(); - - // Cleanup - return () => { - events.forEach(event => { - document.removeEventListener(event, handleActivity); - }); - if (timeoutRef.current) clearTimeout(timeoutRef.current); - if (warningRef.current) clearTimeout(warningRef.current); - }; - }, [timeout, warningTime]); - - return { resetTimeout }; -}; \ No newline at end of file diff --git a/frontend/src/api/hooks/useTenant.ts b/frontend/src/api/hooks/useTenant.ts new file mode 100644 index 00000000..8c892671 --- /dev/null +++ b/frontend/src/api/hooks/useTenant.ts @@ -0,0 +1,203 @@ +// frontend/src/api/hooks/useTenant.ts +/** + * Tenant Management Hooks + */ + +import { useState, useCallback } from 'react'; +import { tenantService } from '../services'; +import type { + TenantInfo, + TenantCreate, + TenantUpdate, + TenantMember, + InviteUser, + TenantStats, +} from '../types'; + +export const useTenant = () => { + const [tenants, setTenants] = useState([]); + const [currentTenant, setCurrentTenant] = useState(null); + const [members, setMembers] = useState([]); + const [stats, setStats] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const createTenant = useCallback(async (data: TenantCreate): Promise => { + try { + setIsLoading(true); + setError(null); + + const tenant = await tenantService.createTenant(data); + setTenants(prev => [...prev, tenant]); + setCurrentTenant(tenant); + + return tenant; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to create tenant'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const getTenant = useCallback(async (tenantId: string): Promise => { + try { + setIsLoading(true); + setError(null); + + const tenant = await tenantService.getTenant(tenantId); + setCurrentTenant(tenant); + + return tenant; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get tenant'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const updateTenant = useCallback(async (tenantId: string, data: TenantUpdate): Promise => { + try { + setIsLoading(true); + setError(null); + + const updatedTenant = await tenantService.updateTenant(tenantId, data); + setCurrentTenant(updatedTenant); + setTenants(prev => prev.map(t => t.id === tenantId ? updatedTenant : t)); + + return updatedTenant; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to update tenant'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const getUserTenants = useCallback(async (): Promise => { + try { + setIsLoading(true); + setError(null); + + const userTenants = await tenantService.getUserTenants(); + setTenants(userTenants); + + return userTenants; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get user tenants'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const getTenantMembers = useCallback(async (tenantId: string): Promise => { + try { + setIsLoading(true); + setError(null); + + const response = await tenantService.getTenantMembers(tenantId); + setMembers(response.data); + + return response.data; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get tenant members'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const inviteUser = useCallback(async (tenantId: string, invitation: InviteUser): Promise => { + try { + setIsLoading(true); + setError(null); + + await tenantService.inviteUser(tenantId, invitation); + + // Refresh members list + await getTenantMembers(tenantId); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to invite user'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, [getTenantMembers]); + + const removeMember = useCallback(async (tenantId: string, userId: string): Promise => { + try { + setIsLoading(true); + setError(null); + + await tenantService.removeMember(tenantId, userId); + setMembers(prev => prev.filter(m => m.user_id !== userId)); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to remove member'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const updateMemberRole = useCallback(async (tenantId: string, userId: string, role: string): Promise => { + try { + setIsLoading(true); + setError(null); + + const updatedMember = await tenantService.updateMemberRole(tenantId, userId, role); + setMembers(prev => prev.map(m => m.user_id === userId ? updatedMember : m)); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to update member role'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const getTenantStats = useCallback(async (tenantId: string): Promise => { + try { + setIsLoading(true); + setError(null); + + const tenantStats = await tenantService.getTenantStats(tenantId); + setStats(tenantStats); + + return tenantStats; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get tenant stats'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + return { + tenants, + currentTenant, + members, + stats, + isLoading, + error, + createTenant, + getTenant, + updateTenant, + getUserTenants, + getTenantMembers, + inviteUser, + removeMember, + updateMemberRole, + getTenantStats, + clearError: () => setError(null), + }; +}; diff --git a/frontend/src/api/hooks/useTraining.ts b/frontend/src/api/hooks/useTraining.ts new file mode 100644 index 00000000..03ce26aa --- /dev/null +++ b/frontend/src/api/hooks/useTraining.ts @@ -0,0 +1,226 @@ +// frontend/src/api/hooks/useTraining.ts +/** + * Training Operations Hooks + */ + +import { useState, useCallback, useEffect } from 'react'; +import { trainingService } from '../services'; +import type { + TrainingJobRequest, + TrainingJobResponse, + ModelInfo, + ModelTrainingStats, + SingleProductTrainingRequest, +} from '../types'; + +export const useTraining = () => { + const [jobs, setJobs] = useState([]); + const [currentJob, setCurrentJob] = useState(null); + const [models, setModels] = useState([]); + const [stats, setStats] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const startTrainingJob = useCallback(async ( + tenantId: string, + request: TrainingJobRequest + ): Promise => { + try { + setIsLoading(true); + setError(null); + + const job = await trainingService.startTrainingJob(tenantId, request); + setCurrentJob(job); + setJobs(prev => [job, ...prev]); + + return job; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to start training job'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const startSingleProductTraining = useCallback(async ( + tenantId: string, + request: SingleProductTrainingRequest + ): Promise => { + try { + setIsLoading(true); + setError(null); + + const job = await trainingService.startSingleProductTraining(tenantId, request); + setCurrentJob(job); + setJobs(prev => [job, ...prev]); + + return job; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to start product training'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const getTrainingJobStatus = useCallback(async ( + tenantId: string, + jobId: string + ): Promise => { + try { + const job = await trainingService.getTrainingJobStatus(tenantId, jobId); + + // Update job in state + setJobs(prev => prev.map(j => j.job_id === jobId ? job : j)); + if (currentJob?.job_id === jobId) { + setCurrentJob(job); + } + + return job; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get job status'; + setError(message); + throw error; + } + }, [currentJob]); + + const cancelTrainingJob = useCallback(async ( + tenantId: string, + jobId: string + ): Promise => { + try { + setIsLoading(true); + setError(null); + + await trainingService.cancelTrainingJob(tenantId, jobId); + + // Update job status in state + setJobs(prev => prev.map(j => + j.job_id === jobId ? { ...j, status: 'cancelled' } : j + )); + if (currentJob?.job_id === jobId) { + setCurrentJob({ ...currentJob, status: 'cancelled' }); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to cancel job'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, [currentJob]); + + const getTrainingJobs = useCallback(async (tenantId: string): Promise => { + try { + setIsLoading(true); + setError(null); + + const response = await trainingService.getTrainingJobs(tenantId); + setJobs(response.data); + + return response.data; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get training jobs'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const getModels = useCallback(async (tenantId: string): Promise => { + try { + setIsLoading(true); + setError(null); + + const response = await trainingService.getModels(tenantId); + setModels(response.data); + + return response.data; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get models'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const validateTrainingData = useCallback(async (tenantId: string): Promise<{ + is_valid: boolean; + message: string; + details?: any; + }> => { + try { + setIsLoading(true); + setError(null); + + const result = await trainingService.validateTrainingData(tenantId); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : 'Data validation failed'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + const getTrainingStats = useCallback(async (tenantId: string): Promise => { + try { + setIsLoading(true); + setError(null); + + const trainingStats = await trainingService.getTrainingStats(tenantId); + setStats(trainingStats); + + return trainingStats; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get training stats'; + setError(message); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + // Auto-refresh job status for running jobs + useEffect(() => { + const runningJobs = jobs.filter(job => job.status === 'running' || job.status === 'pending'); + + if (runningJobs.length === 0) return; + + const interval = setInterval(async () => { + for (const job of runningJobs) { + try { + const tenantId = job.tenant_id; + await getTrainingJobStatus(tenantId, job.job_id); + } catch (error) { + console.error('Failed to refresh job status:', error); + } + } + }, 5000); // Refresh every 5 seconds + + return () => clearInterval(interval); + }, [jobs, getTrainingJobStatus]); + + return { + jobs, + currentJob, + models, + stats, + isLoading, + error, + startTrainingJob, + startSingleProductTraining, + getTrainingJobStatus, + cancelTrainingJob, + getTrainingJobs, + getModels, + validateTrainingData, + getTrainingStats, + clearError: () => setError(null), + }; +}; diff --git a/frontend/src/api/hooks/useTrainingProgress.ts b/frontend/src/api/hooks/useTrainingProgress.ts deleted file mode 100644 index dd5fc144..00000000 --- a/frontend/src/api/hooks/useTrainingProgress.ts +++ /dev/null @@ -1,83 +0,0 @@ -// src/hooks/useTrainingProgress.ts -import { useState, useEffect } from 'react'; -import { useWebSocket } from '../hooks/useWebSocket'; - -export interface TrainingProgress { - job_id: string; - status: 'pending' | 'running' | 'completed' | 'failed'; - progress: number; - current_step: string; - total_steps: number; - estimated_time_remaining?: number; - metrics?: Record; -} - -export interface TrainingProgressUpdate { - type: 'training_progress' | 'training_completed' | 'training_error'; - job_id: string; - progress?: TrainingProgress; - results?: any; - error?: string; -} - -export const useTrainingProgress = (jobId: string | null) => { - const [progress, setProgress] = useState(null); - const [error, setError] = useState(null); - const [isComplete, setIsComplete] = useState(false); - - const handleMessage = (data: TrainingProgressUpdate) => { - switch (data.type) { - case 'training_progress': - setProgress(data.progress!); - setError(null); - break; - - case 'training_completed': - setProgress(prev => ({ - ...prev!, - status: 'completed', - progress: 100 - })); - setIsComplete(true); - break; - - case 'training_error': - setError(data.error || 'Training failed'); - setProgress(prev => prev ? { ...prev, status: 'failed' } : null); - break; - } - }; - - const { isConnected } = useWebSocket({ - endpoint: jobId ? `/api/v1/training/progress/${jobId}` : '', - onMessage: handleMessage, - onError: () => setError('Connection lost'), - autoConnect: !!jobId - }); - - // Fetch initial status when job ID changes - useEffect(() => { - if (jobId) { - fetchTrainingStatus(jobId); - } - }, [jobId]); - - const fetchTrainingStatus = async (id: string) => { - try { - const response = await fetch(`/api/v1/training/status/${id}`); - if (response.ok) { - const data = await response.json(); - setProgress(data); - } - } catch (err) { - console.error('Failed to fetch training status:', err); - } - }; - - return { - progress, - error, - isComplete, - isConnected - }; -}; \ No newline at end of file diff --git a/frontend/src/api/hooks/useWebSocket.ts b/frontend/src/api/hooks/useWebSocket.ts deleted file mode 100644 index 89c8008a..00000000 --- a/frontend/src/api/hooks/useWebSocket.ts +++ /dev/null @@ -1,72 +0,0 @@ -// src/hooks/useWebSocket.ts -import { useEffect, useRef, useCallback } from 'react'; -import { wsManager, WebSocketHandlers } from '../websocket/WebSocketManager'; - -export interface UseWebSocketOptions { - endpoint: string; - onMessage: (data: any) => void; - onError?: (error: Event) => void; - onConnect?: () => void; - onDisconnect?: () => void; - onReconnect?: () => void; - autoConnect?: boolean; -} - -export const useWebSocket = ({ - endpoint, - onMessage, - onError, - onConnect, - onDisconnect, - onReconnect, - autoConnect = true -}: UseWebSocketOptions) => { - const wsRef = useRef(null); - - const connect = useCallback(async () => { - if (wsRef.current) return; - - const handlers: WebSocketHandlers = { - onOpen: onConnect, - onMessage, - onError, - onClose: onDisconnect, - onReconnect - }; - - try { - wsRef.current = await wsManager.connect(endpoint, handlers); - } catch (error) { - console.error('WebSocket connection failed:', error); - onError?.(new Event('Connection failed')); - } - }, [endpoint, onMessage, onError, onConnect, onDisconnect, onReconnect]); - - const disconnect = useCallback(() => { - if (wsRef.current) { - wsManager.disconnect(endpoint); - wsRef.current = null; - } - }, [endpoint]); - - const send = useCallback((data: any) => { - wsManager.send(endpoint, data); - }, [endpoint]); - - useEffect(() => { - if (autoConnect) { - connect(); - } - - return () => { - disconnect(); - }; - }, [autoConnect, connect, disconnect]); - - return { - connect, - disconnect, - send, - isConnected: wsManager.isConnected(endpoint) - }; -}; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 00000000..f9f83b35 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,71 @@ +// frontend/src/api/index.ts +/** + * Main API Export + * Central entry point for all API functionality + */ + +// Setup interceptors on import +import { setupInterceptors } from './client/interceptors'; +setupInterceptors(); + +// Export main API client and services +export { apiClient } from './client'; +export { api } from './services'; + +// Export all services individually +export { + authService, + tenantService, + dataService, + trainingService, + forecastingService, + notificationService, + healthService, +} from './services'; + +// Export all hooks +export { + useAuth, + useAuthHeaders, + useTenant, + useData, + useTraining, + useForecast, + useNotification, + useApiHooks, +} from './hooks'; + +// Export WebSocket functionality +export { + WebSocketManager, + useWebSocket, + useTrainingWebSocket, + useForecastWebSocket, +} from './websocket'; + +// Export utilities +export { + ApiErrorHandler, + ResponseProcessor, + RequestValidator, + DataTransformer, +} from './utils'; + +// Export types +export * from './types'; + +// Export interceptors for manual control +export { + AuthInterceptor, + LoggingInterceptor, + TenantInterceptor, + ErrorRecoveryInterceptor, + PerformanceInterceptor, + setupInterceptors, +} from './client/interceptors'; + +// Export configuration +export { apiConfig, serviceEndpoints, featureFlags } from './client/config'; + +// Default export for convenience +export default api; \ No newline at end of file diff --git a/frontend/src/api/services/auth.service.ts b/frontend/src/api/services/auth.service.ts new file mode 100644 index 00000000..d9f7d162 --- /dev/null +++ b/frontend/src/api/services/auth.service.ts @@ -0,0 +1,107 @@ +// frontend/src/api/services/auth.service.ts +/** + * Authentication Service + * Handles all authentication-related API calls + */ + +import { apiClient } from '../client'; +import { serviceEndpoints } from '../client/config'; +import type { + LoginRequest, + LoginResponse, + RegisterRequest, + UserResponse, + PasswordResetRequest, + PasswordResetResponse, + PasswordResetConfirmRequest, + TokenVerification, + LogoutResponse, +} from '../types'; + +export class AuthService { + private baseEndpoint = serviceEndpoints.auth; + + /** + * User Registration + */ + async register(data: RegisterRequest): Promise<{ user: UserResponse }> { + return apiClient.post(`${this.baseEndpoint}/register`, data); + } + + /** + * User Login + */ + async login(credentials: LoginRequest): Promise { + return apiClient.post(`${this.baseEndpoint}/login`, credentials); + } + + /** + * User Logout + */ + async logout(): Promise { + return apiClient.post(`${this.baseEndpoint}/logout`); + } + + /** + * Get Current User Profile + */ + async getCurrentUser(): Promise { + return apiClient.get(`/users/me`); + } + + /** + * Update User Profile + */ + async updateProfile(data: Partial): Promise { + return apiClient.put(`/users/me`, data); + } + + /** + * Verify Token + */ + async verifyToken(token: string): Promise { + return apiClient.post(`${this.baseEndpoint}/verify-token`, { token }); + } + + /** + * Refresh Access Token + */ + async refreshToken(refreshToken: string): Promise { + return apiClient.post(`${this.baseEndpoint}/refresh`, { + refresh_token: refreshToken, + }); + } + + /** + * Request Password Reset + */ + async requestPasswordReset(data: PasswordResetRequest): Promise { + return apiClient.post(`${this.baseEndpoint}/password-reset`, data); + } + + /** + * Confirm Password Reset + */ + async confirmPasswordReset(data: PasswordResetConfirmRequest): Promise<{ message: string }> { + return apiClient.post(`${this.baseEndpoint}/password-reset/confirm`, data); + } + + /** + * Change Password (for authenticated users) + */ + async changePassword(currentPassword: string, newPassword: string): Promise<{ message: string }> { + return apiClient.post(`/users/me/change-password`, { + current_password: currentPassword, + new_password: newPassword, + }); + } + + /** + * Delete User Account + */ + async deleteAccount(): Promise<{ message: string }> { + return apiClient.delete(`/users/me`); + } +} + +export const authService = new AuthService(); \ No newline at end of file diff --git a/frontend/src/api/services/authService.ts b/frontend/src/api/services/authService.ts deleted file mode 100644 index e185c908..00000000 --- a/frontend/src/api/services/authService.ts +++ /dev/null @@ -1,254 +0,0 @@ -// src/api/services/AuthService.ts - UPDATED with missing methods -import { apiClient } from '../base/apiClient'; -import { tokenManager } from '../auth/tokenManager'; -import { - ApiResponse -} from '../types/api'; - - -// Auth types -export interface LoginRequest { - email: string; - password: string; -} - -export interface RegisterRequest { - email: string; - password: string; - full_name: string; - phone?: string; - language?: string; -} - -export interface TokenResponse { - access_token: string; - refresh_token: string; - token_type: string; - expires_in: number; -} - -export interface UserProfile { - id: string; - email: string; - full_name: string; - is_active: boolean; - is_verified: boolean; - tenant_id?: string; - role: string; - phone?: string; - language: string; - timezone: string; - created_at?: string; - last_login?: string; -} - -export class AuthService { - /** - * Check if user is authenticated (has valid token) - * Note: This is a synchronous check using the tokenManager's isAuthenticated method - */ - isAuthenticated(): boolean { - try { - return tokenManager.isAuthenticated(); - } catch (error) { - console.error('Error checking authentication status:', error); - return false; - } - } - - /** - * Get current user profile - */ - async getCurrentUser(): Promise { - const response = await apiClient.get('/users/me'); - return response; - } - - async register(userData: RegisterRequest): Promise { - try { - // ✅ FIX: apiClient returns direct response, not wrapped in ApiResponse - const response = await apiClient.post( - '/api/v1/auth/register', - userData - ); - - // ✅ FIX: Check if response contains token data (direct response) - if (!response || !response.access_token) { - throw new Error('Registration successful but no tokens received'); - } - - // Store tokens after successful registration - await tokenManager.storeTokens(response); - - return response; - } catch (error: any) { - // ✅ FIX: Better error handling for different scenarios - if (error.response) { - // Server responded with an error status - const status = error.response.status; - const data = error.response.data; - - if (status === 409) { - throw new Error('User with this email already exists'); - } else if (status === 400) { - const detail = data?.detail || 'Invalid registration data'; - throw new Error(detail); - } else if (status >= 500) { - throw new Error('Server error during registration. Please try again.'); - } else { - throw new Error(data?.detail || `Registration failed with status ${status}`); - } - } else if (error.request) { - // Request was made but no response received - throw new Error('Network error. Please check your connection.'); - } else { - // Something else happened - throw new Error(error.message || 'Registration failed'); - } - } - } - - /** - * User login - Also improved error handling - */ - async login(credentials: LoginRequest): Promise { - try { - // ✅ FIX: apiClient returns direct response, not wrapped in ApiResponse - const response = await apiClient.post( - '/api/v1/auth/login', - credentials - ); - - // ✅ FIX: Check if response contains token data (direct response) - if (!response || !response.access_token) { - throw new Error('Login successful but no tokens received'); - } - - // Store tokens after successful login - await tokenManager.storeTokens(response); - - return response; - } catch (error: any) { - // ✅ FIX: Better error handling - if (error.response) { - const status = error.response.status; - const data = error.response.data; - - if (status === 401) { - throw new Error('Invalid email or password'); - } else if (status === 429) { - throw new Error('Too many login attempts. Please try again later.'); - } else if (status >= 500) { - throw new Error('Server error during login. Please try again.'); - } else { - throw new Error(data?.detail || `Login failed with status ${status}`); - } - } else if (error.request) { - throw new Error('Network error. Please check your connection.'); - } else { - throw new Error(error.message || 'Login failed'); - } - } - } - - - /** - * Refresh access token - */ - async refreshToken(refreshToken: string): Promise { - const response = await apiClient.post( - '/api/v1/auth/refresh', - { refresh_token: refreshToken } - ); - return response; - } - - /** - * Get current user profile (alias for getCurrentUser) - */ - async getProfile(): Promise { - return this.getCurrentUser(); - } - - /** - * Update user profile - */ - async updateProfile(updates: Partial): Promise { - const response = await apiClient.put( - '/api/v1/users/me', - updates - ); - return response; - } - - /** - * Change password - */ - async changePassword( - currentPassword: string, - newPassword: string - ): Promise { - await apiClient.post('/auth/change-password', { - current_password: currentPassword, - new_password: newPassword, - }); - } - - /** - * Request password reset - */ - async requestPasswordReset(email: string): Promise { - await apiClient.post('/api/v1/auth/reset-password', { email }); - } - - /** - * Confirm password reset - */ - async confirmPasswordReset( - token: string, - newPassword: string - ): Promise { - await apiClient.post('/api/v1/auth/confirm-reset', { - token, - new_password: newPassword, - }); - } - - /** - * Verify email - */ - async verifyEmail(token: string): Promise { - await apiClient.post('/api/v1/auth/verify-email', { token }); - } - - /** - * Resend verification email - */ - async resendVerification(): Promise { - await apiClient.post('/api/v1/auth/resend-verification'); - } - - /** - * Logout (invalidate tokens) - */ - async logout(): Promise { - try { - await apiClient.post('/api/v1/auth/logout'); - } catch (error) { - console.error('Logout API call failed:', error); - } finally { - // Always clear tokens regardless of API call success - tokenManager.clearTokens(); - } - } - - /** - * Get user permissions - */ - async getPermissions(): Promise { - const response = await apiClient.get>('/auth/permissions'); - return response.data!; - } -} - -export const authService = new AuthService(); \ No newline at end of file diff --git a/frontend/src/api/services/data.service.ts b/frontend/src/api/services/data.service.ts new file mode 100644 index 00000000..f238f53c --- /dev/null +++ b/frontend/src/api/services/data.service.ts @@ -0,0 +1,182 @@ +// frontend/src/api/services/data.service.ts +/** + * Data Management Service + * Handles sales data operations + */ + +import { apiClient } from '../client'; +import { RequestTimeouts } from '../client/config'; +import type { + SalesData, + SalesDataQuery, + SalesDataImport, + SalesImportResult, + DashboardStats, + PaginatedResponse, + ActivityItem, +} from '../types'; + +export class DataService { + /** + * Upload Sales History File + */ + async uploadSalesHistory( + tenantId: string, + file: File, + additionalData?: Record + ): Promise { + // Determine file format + const fileName = file.name.toLowerCase(); + let fileFormat: string; + + if (fileName.endsWith('.csv')) { + fileFormat = 'csv'; + } else if (fileName.endsWith('.json')) { + fileFormat = 'json'; + } else if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) { + fileFormat = 'excel'; + } else { + fileFormat = 'csv'; // Default fallback + } + + const uploadData = { + file_format: fileFormat, + ...additionalData, + }; + + return apiClient.upload( + `/tenants/${tenantId}/sales/import`, + file, + uploadData, + { + timeout: RequestTimeouts.LONG, + } + ); + } + + /** + * Validate Sales Data + */ + async validateSalesData( + tenantId: string, + file: File + ): Promise { + const fileName = file.name.toLowerCase(); + let fileFormat: string; + + if (fileName.endsWith('.csv')) { + fileFormat = 'csv'; + } else if (fileName.endsWith('.json')) { + fileFormat = 'json'; + } else if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) { + fileFormat = 'excel'; + } else { + fileFormat = 'csv'; + } + + return apiClient.upload( + `/tenants/${tenantId}/sales/validate`, + file, + { + file_format: fileFormat, + validate_only: true, + }, + { + timeout: RequestTimeouts.MEDIUM, + } + ); + } + + /** + * Get Sales Data + */ + async getSalesData( + tenantId: string, + query?: SalesDataQuery + ): Promise> { + return apiClient.get(`/tenants/${tenantId}/sales`, { params: query }); + } + + /** + * Get Single Sales Record + */ + async getSalesRecord(tenantId: string, recordId: string): Promise { + return apiClient.get(`/tenants/${tenantId}/sales/${recordId}`); + } + + /** + * Update Sales Record + */ + async updateSalesRecord( + tenantId: string, + recordId: string, + data: Partial + ): Promise { + return apiClient.put(`/tenants/${tenantId}/sales/${recordId}`, data); + } + + /** + * Delete Sales Record + */ + async deleteSalesRecord(tenantId: string, recordId: string): Promise<{ message: string }> { + return apiClient.delete(`/tenants/${tenantId}/sales/${recordId}`); + } + + /** + * Get Dashboard Statistics + */ + async getDashboardStats(tenantId: string): Promise { + return apiClient.get(`/tenants/${tenantId}/sales/stats`); + } + + /** + * Get Analytics Data + */ + async getAnalytics( + tenantId: string, + params?: { + start_date?: string; + end_date?: string; + product_names?: string[]; + metrics?: string[]; + } + ): Promise { + return apiClient.get(`/tenants/${tenantId}/analytics`, { params }); + } + + /** + * Export Sales Data + */ + async exportSalesData( + tenantId: string, + format: 'csv' | 'excel' | 'json', + query?: SalesDataQuery + ): Promise { + const response = await apiClient.request(`/tenants/${tenantId}/sales/export`, { + method: 'GET', + params: { ...query, format }, + headers: { + 'Accept': format === 'csv' ? 'text/csv' : + format === 'excel' ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' : + 'application/json', + }, + }); + + return new Blob([response], { + type: format === 'csv' ? 'text/csv' : + format === 'excel' ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' : + 'application/json', + }); + } + + /** + * Get Recent Activity + */ + async getRecentActivity(tenantId: string, limit?: number): Promise { + return apiClient.get(`/tenants/${tenantId}/activity`, { + params: { limit }, + }); + } +} + +export const dataService = new DataService(); diff --git a/frontend/src/api/services/dataService.ts b/frontend/src/api/services/dataService.ts deleted file mode 100644 index cb0b4aa1..00000000 --- a/frontend/src/api/services/dataService.ts +++ /dev/null @@ -1,619 +0,0 @@ -// frontend/src/api/services/dataService.ts - COMPLETE WORKING FIX -import { apiClient } from '../base/apiClient'; -import { ApiResponse } from '../types/api'; - -export interface DashboardStats { - totalSales: number; - totalRevenue: number; - lastTrainingDate: string | null; - forecastAccuracy: number; - totalProducts: number; - activeTenants: number; - lastDataUpdate: string; -} - -export interface UploadResponse { - message: string; - records_processed: number; - errors?: string[]; - upload_id?: string; -} - -export interface DataValidation { - // ✅ NEW: Backend SalesValidationResult schema fields - is_valid: boolean; - total_records: number; - valid_records: number; - invalid_records: number; - errors: Array<{ - type: string; - message: string; - field?: string; - row?: number; - code?: string; - }>; - warnings: Array<{ - type: string; - message: string; - field?: string; - row?: number; - code?: string; - }>; - summary: { - status: string; - file_format?: string; - file_size_bytes?: number; - file_size_mb?: number; - estimated_processing_time_seconds?: number; - validation_timestamp?: string; - suggestions: string[]; - [key: string]: any; - }; -} - -// Data types -export interface WeatherData { - date: string; - temperature: number; - humidity: number; - precipitation: number; - wind_speed: number; -} - -export interface TrafficData { - date: string; - traffic_volume: number; - pedestrian_count: number; -} - -export interface SalesRecord { - id: string; - tenant_id: string; - product_name: string; - quantity_sold: number; - revenue: number; - date: string; - created_at: string; -} - -export interface CreateSalesRequest { - product_name: string; - quantity_sold: number; - revenue: number; - date: string; -} - -// ✅ FIXED: Interface for import data that matches backend SalesDataImport schema -export interface SalesDataImportRequest { - tenant_id: string; - data: string; // File content as string - data_format: 'csv' | 'json' | 'excel'; - source?: string; - validate_only?: boolean; -} - -export class DataService { - /** - * ✅ FIXED: Upload sales history file to the correct backend endpoint - * Backend expects: UploadFile + Form data at /api/v1/data/sales/import - */ - async uploadSalesHistory( - file: File, - tenantId: string, // Tenant ID is now a required path parameter - additionalData?: Record - ): Promise { - try { - console.log('Uploading sales file:', file.name); - - // ✅ CRITICAL FIX: Use the correct endpoint that exists in backend - // Backend endpoint: @router.post("/import", response_model=SalesImportResult) - // Full path: /api/v1/tenants/{tenant_id}/sales/import - - // Determine file format - const fileName = file.name.toLowerCase(); - let fileFormat: string; - - if (fileName.endsWith('.csv')) { - fileFormat = 'csv'; - } else if (fileName.endsWith('.json')) { - fileFormat = 'json'; - } else if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) { - fileFormat = 'excel'; - } else { - fileFormat = 'csv'; // Default fallback - } - - // ✅ FIXED: Create FormData manually to match backend expectations - const formData = new FormData(); - formData.append('file', file); - formData.append('file_format', fileFormat); - - // tenantId is no longer appended to FormData as it's a path parameter - // if (tenantId) { - // formData.append('tenant_id', tenantId); - // } - - // Add additional data if provided - if (additionalData) { - Object.entries(additionalData).forEach(([key, value]) => { - formData.append(key, String(value)); - }); - } - - console.log('Uploading with file_format:', fileFormat); - - // ✅ FIXED: Use the correct endpoint that exists in the backend - const response = await apiClient.request>( - `/api/v1/tenants/${tenantId}/sales/import`, // Correct endpoint path with tenant_id - { - method: 'POST', - body: formData, - // Don't set Content-Type header - let browser set it with boundary - headers: {} // Empty headers to avoid setting Content-Type manually - } - ); - - console.log('Upload response:', response); - - // ✅ Handle the SalesImportResult response structure - if (response && typeof response === 'object') { - // Handle API errors - if ('detail' in response) { - throw new Error(typeof response.detail === 'string' ? response.detail : 'Upload failed'); - } - - // Extract data from response - let uploadResult: any; - if ('data' in response && response.data) { - uploadResult = response.data; - } else { - uploadResult = response; - } - - // ✅ FIXED: Map backend SalesImportResult to frontend UploadResponse - return { - message: uploadResult.success - ? `Successfully processed ${uploadResult.records_created || uploadResult.records_processed || 0} records` - : 'Upload completed with issues', - records_processed: uploadResult.records_created || uploadResult.records_processed || 0, - errors: uploadResult.errors ? - (Array.isArray(uploadResult.errors) ? - uploadResult.errors.map((err: any) => - typeof err === 'string' ? err : (err.message || String(err)) - ) : [String(uploadResult.errors)] - ) : [], - upload_id: uploadResult.id || undefined - }; - } - - throw new Error('Invalid response format from upload service'); - - } catch (error: any) { - console.error('Error uploading file:', error); - - let errorMessage = 'Error al subir el archivo'; - if (error.response?.status === 422) { - errorMessage = 'Formato de archivo inválido'; - } else if (error.response?.status === 400) { - errorMessage = 'El archivo no se puede procesar'; - } else if (error.response?.status === 500) { - errorMessage = 'Error del servidor. Inténtalo más tarde.'; - } else if (error.message) { - errorMessage = error.message; - } - - // Throw structured error that can be caught by the frontend - throw { - message: errorMessage, - status: error.response?.status || 0, - code: error.code, - details: error.response?.data || {} - }; - } - } - - // ✅ Alternative method: Upload using the import JSON endpoint instead of file upload - /** - * ✅ ALTERNATIVE: Upload sales data using the JSON import endpoint - * This uses the same endpoint as validation but with validate_only: false - */ - async uploadSalesDataAsJson(file: File, tenantId: string): Promise { // tenantId made required - try { - console.log('Uploading sales data as JSON:', file.name); - - const fileContent = await this.readFileAsText(file); - - if (!fileContent) { - throw new Error('Failed to read file content'); - } - - // Determine file format - const fileName = file.name.toLowerCase(); - let dataFormat: 'csv' | 'json' | 'excel'; - - if (fileName.endsWith('.csv')) { - dataFormat = 'csv'; - } else if (fileName.endsWith('.json')) { - dataFormat = 'json'; - } else if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) { - dataFormat = 'excel'; - } else { - dataFormat = 'csv'; - } - - // ✅ Use the same structure as validation but with validate_only: false - const importData: SalesDataImportRequest = { - tenant_id: tenantId, // Use the provided tenantId - data: fileContent, - data_format: dataFormat, - validate_only: false, // This makes it actually import the data - source: 'onboarding_upload' - }; - - console.log('Uploading data with validate_only: false'); - - // ✅ OPTION: Add a new JSON import endpoint to the backend - // Current backend sales.py does not have a /import/json endpoint, - // it only has a file upload endpoint. - // If a JSON import endpoint is desired, it needs to be added to sales.py - // For now, this method will target the existing /import endpoint with a JSON body - // This will require the backend to support JSON body for /import, which it currently - // does not for the direct file upload endpoint. - // THIS ALTERNATIVE METHOD IS LEFT AS-IS, ASSUMING A FUTURE BACKEND ENDPOINT - // OR A MODIFICATION TO THE EXISTING /import ENDPOINT TO ACCEPT JSON BODY. - const response = await apiClient.post>( - `/api/v1/tenants/${tenantId}/sales/import/json`, // This endpoint does not exist in sales.py - importData - ); - - console.log('JSON upload response:', response); - - // Handle response similar to file upload - if (response && typeof response === 'object') { - if ('detail' in response) { - throw new Error(typeof response.detail === 'string' ? response.detail : 'Upload failed'); - } - - let uploadResult: any; - if ('data' in response && response.data) { - uploadResult = response.data; - } else { - uploadResult = response; - } - - return { - message: uploadResult.success - ? `Successfully processed ${uploadResult.records_created || uploadResult.records_processed || 0} records` - : 'Upload completed with issues', - records_processed: uploadResult.records_created || uploadResult.records_processed || 0, - errors: uploadResult.errors ? - (Array.isArray(uploadResult.errors) ? - uploadResult.errors.map((err: any) => - typeof err === 'string' ? err : (err.message || String(err)) - ) : [String(uploadResult.errors)] - ) : [], - upload_id: uploadResult.id || undefined - }; - } - - throw new Error('Invalid response format from upload service'); - - } catch (error: any) { - console.error('Error uploading JSON data:', error); - - let errorMessage = 'Error al subir los datos'; - if (error.response?.status === 422) { - errorMessage = 'Formato de datos inválido'; - } else if (error.response?.status === 400) { - errorMessage = 'Los datos no se pueden procesar'; - } else if (error.response?.status === 500) { - errorMessage = 'Error del servidor. Inténtalo más tarde.'; - } else if (error.message) { - errorMessage = error.message; - } - - throw { - message: errorMessage, - status: error.response?.status || 0, - code: error.code, - details: error.response?.data || {} - }; - } - } - - async validateSalesData(file: File, tenantId: string): Promise { // tenantId made required - try { - console.log('Reading file content...', file.name); - - const fileContent = await this.readFileAsText(file); - - if (!fileContent) { - throw new Error('Failed to read file content'); - } - - console.log('File content read successfully, length:', fileContent.length); - - // Determine file format from extension - const fileName = file.name.toLowerCase(); - let dataFormat: 'csv' | 'json' | 'excel'; - - if (fileName.endsWith('.csv')) { - dataFormat = 'csv'; - } else if (fileName.endsWith('.json')) { - dataFormat = 'json'; - } else if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) { - dataFormat = 'excel'; - } else { - dataFormat = 'csv'; // Default fallback - } - - console.log('Detected file format:', dataFormat); - - // ✅ FIXED: Use proper tenant ID when available - const importData: SalesDataImportRequest = { - tenant_id: tenantId, // Use the provided tenantId - data: fileContent, - data_format: dataFormat, - validate_only: true - }; - - console.log('Sending validation request with tenant_id:', importData.tenant_id); - - const response = await apiClient.post>( - `/api/v1/tenants/${tenantId}/sales/import/validate`, // Correct endpoint with tenant_id - importData - ); - - console.log('Raw response from API:', response); - - // ✅ ENHANCED: Handle the new backend response structure - if (response && typeof response === 'object') { - // Handle API errors - if ('detail' in response) { - console.error('API returned error:', response.detail); - - if (Array.isArray(response.detail)) { - // Handle Pydantic validation errors - const errorMessages = response.detail.map(err => ({ - type: 'pydantic_error', - message: `${err.loc ? err.loc.join('.') + ': ' : ''}${err.msg}`, - field: err.loc ? err.loc[err.loc.length - 1] : null, - code: err.type - })); - - return { - is_valid: false, - total_records: 0, - valid_records: 0, - invalid_records: 0, - errors: errorMessages, - warnings: [], - summary: { - status: 'error', - suggestions: ['Revisa el formato de los datos enviados'] - } - }; - } - - // Handle simple error messages - return { - is_valid: false, - total_records: 0, - valid_records: 0, - invalid_records: 0, - errors: [{ - type: 'api_error', - message: typeof response.detail === 'string' ? response.detail : 'Error de validación', - code: 'API_ERROR' - }], - warnings: [], - summary: { - status: 'error', - suggestions: ['Verifica el archivo y vuelve a intentar'] - } - }; - } - - // ✅ SUCCESS: Handle successful validation response - let validationResult: DataValidation; - - // Check if response has nested data - if ('data' in response && response.data) { - validationResult = response.data; - } else if ('is_valid' in response) { - // Direct response - validationResult = response as DataValidation; - } else { - throw new Error('Invalid response format from validation service'); - } - - // ✅ ENHANCED: Normalize the response to ensure all required fields exist - return { - is_valid: validationResult.is_valid, - total_records: validationResult.total_records || 0, - valid_records: validationResult.valid_records || 0, - invalid_records: validationResult.invalid_records || 0, - errors: validationResult.errors || [], - warnings: validationResult.warnings || [], - summary: validationResult.summary || { status: 'unknown', suggestions: [] }, - - // Backward compatibility fields - valid: validationResult.is_valid, // Map for legacy code - recordCount: validationResult.total_records, - suggestions: validationResult.summary?.suggestions || [] - }; - } - - throw new Error('Invalid response format from validation service'); - - } catch (error: any) { - console.error('Error validating file:', error); - - let errorMessage = 'Error al validar el archivo'; - let errorCode = 'UNKNOWN_ERROR'; - - if (error.response?.status === 422) { - errorMessage = 'Formato de archivo inválido'; - errorCode = 'INVALID_FORMAT'; - } else if (error.response?.status === 400) { - errorMessage = 'El archivo no se puede procesar'; - errorCode = 'PROCESSING_ERROR'; - } else if (error.response?.status === 500) { - errorMessage = 'Error del servidor. Inténtalo más tarde.'; - errorCode = 'SERVER_ERROR'; - } else if (error.message) { - errorMessage = error.message; - errorCode = 'CLIENT_ERROR'; - } - - // Return properly structured error response matching new schema - return { - is_valid: false, - total_records: 0, - valid_records: 0, - invalid_records: 0, - errors: [{ - type: 'client_error', - message: errorMessage, - code: errorCode - }], - warnings: [], - summary: { - status: 'error', - suggestions: ['Intenta con un archivo diferente o contacta soporte'] - }, - - // Backward compatibility - valid: false, - recordCount: 0, - suggestions: ['Intenta con un archivo diferente o contacta soporte'] - }; - } - } - - /** - * ✅ FIXED: Proper helper method to read file as text with error handling - */ - private readFileAsText(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onload = (event) => { - const result = event.target?.result; - if (typeof result === 'string') { - resolve(result); - } else { - reject(new Error('Failed to read file as text')); - } - }; - - reader.onerror = () => { - reject(new Error('Failed to read file')); - }; - - reader.onabort = () => { - reject(new Error('File reading was aborted')); - }; - - // Read the file as text - reader.readAsText(file); - }); - } - - /** - * Get dashboard statistics - */ - async getDashboardStats(): Promise { - const response = await apiClient.get>( - '/api/v1/data/dashboard/stats' - ); - return response.data!; - } - - /** - * Get sales records - */ - async getSalesRecords(tenantId: string, params?: { // Add tenantId - startDate?: string; - endDate?: string; - productName?: string; - page?: number; - limit?: number; - }): Promise<{ records: SalesRecord[]; total: number; page: number; pages: number }> { - const response = await apiClient.get>(`/api/v1/tenants/${tenantId}/sales`, { params }); // Use tenantId in path - return response.data!; - } - - /** - * Create single sales record - */ - async createSalesRecord(tenantId: string, record: CreateSalesRequest): Promise { // Add tenantId - const response = await apiClient.post>( - `/api/v1/tenants/${tenantId}/sales`, // Use tenantId in path - record - ); - return response.data!; - } - - /** - * Update sales record - */ - async updateSalesRecord(tenantId: string, id: string, record: Partial): Promise { // Add tenantId - const response = await apiClient.put>( - `/api/v1/tenants/${tenantId}/sales/${id}`, // Use tenantId in path - record - ); - return response.data!; - } - - /** - * Delete sales record - */ - async deleteSalesRecord(tenantId: string, id: string): Promise { // Add tenantId - await apiClient.delete(`/api/v1/tenants/${tenantId}/sales/${id}`); // Use tenantId in path - } - - /** - * Get weather data - */ - async getWeatherData(params?: { - startDate?: string; - endDate?: string; - page?: number; - limit?: number; - }): Promise<{ data: WeatherData[]; total: number; page: number; pages: number }> { - const response = await apiClient.get>('/api/v1/data/weather', { params }); - return response.data!; - } - - /** - * Get traffic data - */ - async getTrafficData(params?: { - startDate?: string; - endDate?: string; - page?: number; - limit?: number; - }): Promise<{ data: TrafficData[]; total: number; page: number; pages: number }> { - const response = await apiClient.get>('/api/v1/data/traffic', { params }); - return response.data!; - } -} - -// ✅ CRITICAL FIX: Export the instance that index.ts expects -export const dataService = new DataService(); \ No newline at end of file diff --git a/frontend/src/api/services/forecasting.service.ts b/frontend/src/api/services/forecasting.service.ts new file mode 100644 index 00000000..15dd1a2c --- /dev/null +++ b/frontend/src/api/services/forecasting.service.ts @@ -0,0 +1,198 @@ +// frontend/src/api/services/forecasting.service.ts +/** + * Forecasting Service + * Handles forecast operations and predictions + */ + +import { apiClient } from '../client'; +import { RequestTimeouts } from '../client/config'; +import type { + SingleForecastRequest, + BatchForecastRequest, + ForecastResponse, + BatchForecastResponse, + ForecastAlert, + QuickForecast, + PaginatedResponse, + BaseQueryParams, +} from '../types'; + +export class ForecastingService { + /** + * Create Single Product Forecast + */ + async createSingleForecast( + tenantId: string, + request: SingleForecastRequest + ): Promise { + return apiClient.post( + `/tenants/${tenantId}/forecasts/single`, + request, + { + timeout: RequestTimeouts.MEDIUM, + } + ); + } + + /** + * Create Batch Forecast + */ + async createBatchForecast( + tenantId: string, + request: BatchForecastRequest + ): Promise { + return apiClient.post( + `/tenants/${tenantId}/forecasts/batch`, + request, + { + timeout: RequestTimeouts.LONG, + } + ); + } + + /** + * Get Forecast by ID + */ + async getForecast(tenantId: string, forecastId: string): Promise { + return apiClient.get(`/tenants/${tenantId}/forecasts/${forecastId}`); + } + + /** + * Get Forecasts + */ + async getForecasts( + tenantId: string, + params?: BaseQueryParams & { + product_name?: string; + start_date?: string; + end_date?: string; + model_id?: string; + } + ): Promise> { + return apiClient.get(`/tenants/${tenantId}/forecasts`, { params }); + } + + /** + * Get Batch Forecast Status + */ + async getBatchForecastStatus( + tenantId: string, + batchId: string + ): Promise { + return apiClient.get(`/tenants/${tenantId}/forecasts/batch/${batchId}/status`); + } + + /** + * Get Batch Forecasts + */ + async getBatchForecasts( + tenantId: string, + params?: BaseQueryParams & { + status?: string; + start_date?: string; + end_date?: string; + } + ): Promise> { + return apiClient.get(`/tenants/${tenantId}/forecasts/batch`, { params }); + } + + /** + * Cancel Batch Forecast + */ + async cancelBatchForecast(tenantId: string, batchId: string): Promise<{ message: string }> { + return apiClient.post(`/tenants/${tenantId}/forecasts/batch/${batchId}/cancel`); + } + + /** + * Get Quick Forecasts for Dashboard + */ + async getQuickForecasts(tenantId: string, limit?: number): Promise { + return apiClient.get(`/tenants/${tenantId}/forecasts/quick`, { + params: { limit }, + }); + } + + /** + * Get Forecast Alerts + */ + async getForecastAlerts( + tenantId: string, + params?: BaseQueryParams & { + is_active?: boolean; + severity?: string; + alert_type?: string; + } + ): Promise> { + return apiClient.get(`/tenants/${tenantId}/forecasts/alerts`, { params }); + } + + /** + * Acknowledge Forecast Alert + */ + async acknowledgeForecastAlert( + tenantId: string, + alertId: string + ): Promise { + return apiClient.post(`/tenants/${tenantId}/forecasts/alerts/${alertId}/acknowledge`); + } + + /** + * Delete Forecast + */ + async deleteForecast(tenantId: string, forecastId: string): Promise<{ message: string }> { + return apiClient.delete(`/tenants/${tenantId}/forecasts/${forecastId}`); + } + + /** + * Export Forecasts + */ + async exportForecasts( + tenantId: string, + format: 'csv' | 'excel' | 'json', + params?: { + product_name?: string; + start_date?: string; + end_date?: string; + } + ): Promise { + const response = await apiClient.request(`/tenants/${tenantId}/forecasts/export`, { + method: 'GET', + params: { ...params, format }, + headers: { + 'Accept': format === 'csv' ? 'text/csv' : + format === 'excel' ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' : + 'application/json', + }, + }); + + return new Blob([response], { + type: format === 'csv' ? 'text/csv' : + format === 'excel' ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' : + 'application/json', + }); + } + + /** + * Get Forecast Accuracy Metrics + */ + async getForecastAccuracy( + tenantId: string, + params?: { + product_name?: string; + model_id?: string; + start_date?: string; + end_date?: string; + } + ): Promise<{ + overall_accuracy: number; + product_accuracy: Array<{ + product_name: string; + accuracy: number; + sample_size: number; + }>; + }> { + return apiClient.get(`/tenants/${tenantId}/forecasts/accuracy`, { params }); + } +} + +export const forecastingService = new ForecastingService(); \ No newline at end of file diff --git a/frontend/src/api/services/forecastingService.ts b/frontend/src/api/services/forecastingService.ts deleted file mode 100644 index c8e93c2f..00000000 --- a/frontend/src/api/services/forecastingService.ts +++ /dev/null @@ -1,315 +0,0 @@ -// src/api/services/ForecastingService.ts -import { apiClient } from '../base/apiClient'; -import { - ApiResponse -} from '../types/api'; - -// Forecast types -export interface ForecastRecord { - id: string; - tenant_id: string; - product_name: string; - forecast_date: string; - predicted_quantity: number; - confidence_lower: number; - confidence_upper: number; - model_version: string; - created_at: string; -} - -export interface ForecastRequest { - product_name?: string; - forecast_days?: number; - include_confidence?: boolean; -} - - - -export interface SingleForecastRequest { - product_name: string; - forecast_date: string; - include_weather?: boolean; - include_traffic?: boolean; - confidence_level?: number; -} - -export interface BatchForecastRequest { - products: string[]; - start_date: string; - end_date: string; - include_weather?: boolean; - include_traffic?: boolean; - confidence_level?: number; - batch_name?: string; -} - -export interface ForecastAlert { - id: string; - forecast_id: string; - alert_type: 'high_demand' | 'low_demand' | 'anomaly' | 'model_drift'; - severity: 'low' | 'medium' | 'high'; - message: string; - threshold_value?: number; - actual_value?: number; - is_active: boolean; - created_at: string; - acknowledged_at?: string; - notification_sent: boolean; -} - -export interface QuickForecast { - product_name: string; - forecasts: { - date: string; - predicted_quantity: number; - confidence_lower: number; - confidence_upper: number; - }[]; - model_info: { - model_id: string; - algorithm: string; - accuracy: number; - }; -} - -export interface BatchForecastStatus { - id: string; - batch_name: string; - status: 'queued' | 'running' | 'completed' | 'failed'; - total_products: number; - completed_products: number; - failed_products: number; - progress: number; - created_at: string; - completed_at?: string; - error_message?: string; -} - -export class ForecastingService { - /** - * Generate single forecast - */ - async createSingleForecast(request: SingleForecastRequest): Promise { - const response = await apiClient.post>( - '/forecasting/single', - request - ); - return response.data!; - } - - /** - * Generate batch forecasts - */ - async createBatchForecast(request: BatchForecastRequest): Promise { - const response = await apiClient.post>( - '/forecasting/batch', - request - ); - return response.data!; - } - - /** - * Get forecast records - */ - async getForecasts(params?: { - productName?: string; - startDate?: string; - endDate?: string; - page?: number; - limit?: number; - }): Promise<{ - forecasts: ForecastRecord[]; - total: number; - page: number; - pages: number; - }> { - const response = await apiClient.get>('/forecasting/list', { params }); - return response.data!; - } - - /** - * Get specific forecast - */ - async getForecast(forecastId: string): Promise { - const response = await apiClient.get>( - `/api/v1/forecasting/forecasts/${forecastId}` - ); - return response.data!; - } - - /** - * Get forecast alerts - */ - async getForecastAlerts(params?: { - active?: boolean; - severity?: string; - alertType?: string; - page?: number; - limit?: number; - }): Promise<{ - alerts: ForecastAlert[]; - total: number; - page: number; - pages: number; - }> { - const response = await apiClient.get>('/forecasting/alerts', { params }); - return response.data!; - } - - /** - * Acknowledge alert - */ - async acknowledgeAlert(alertId: string): Promise { - const response = await apiClient.put>( - `/api/v1/forecasting/alerts/${alertId}/acknowledge` - ); - return response.data!; - } - - /** - * Get quick forecast for product (next 7 days) - */ - async getQuickForecast(productName: string, days: number = 7): Promise { - const response = await apiClient.get>( - `/api/v1/forecasting/quick/${productName}`, - { params: { days } } - ); - return response.data!; - } - - /** - * Get real-time prediction - */ - async getRealtimePrediction( - productName: string, - date: string, - includeWeather: boolean = true, - includeTraffic: boolean = true - ): Promise<{ - product_name: string; - forecast_date: string; - predicted_quantity: number; - confidence_lower: number; - confidence_upper: number; - external_factors: { - weather?: any; - traffic?: any; - holidays?: any; - }; - processing_time_ms: number; - }> { - const response = await apiClient.post>( - '/forecasting/realtime', - { - product_name: productName, - forecast_date: date, - include_weather: includeWeather, - include_traffic: includeTraffic, - } - ); - return response.data!; - } - - /** - * Get batch forecast status - */ - async getBatchStatus(batchId: string): Promise { - const response = await apiClient.get>( - `/api/v1/forecasting/batch/${batchId}/status` - ); - return response.data!; - } - - /** - * Cancel batch forecast - */ - async cancelBatchForecast(batchId: string): Promise { - await apiClient.post(`/api/v1/forecasting/batch/${batchId}/cancel`); - } - - /** - * Get forecasting statistics - */ - async getForecastingStats(): Promise<{ - total_forecasts: number; - accuracy_avg: number; - active_alerts: number; - forecasts_today: number; - products_forecasted: number; - last_forecast_date: string | null; - }> { - const response = await apiClient.get>('/api/v1/forecasting/stats'); - return response.data!; - } - - /** - * Compare forecast vs actual - */ - async compareForecastActual(params?: { - productName?: string; - startDate?: string; - endDate?: string; - }): Promise<{ - comparisons: { - date: string; - product_name: string; - predicted: number; - actual: number; - error: number; - percentage_error: number; - }[]; - summary: { - mape: number; - rmse: number; - mae: number; - accuracy: number; - }; - }> { - const response = await apiClient.get>('/api/v1/forecasting/compare', { params }); - return response.data!; - } - - /** - * Export forecasts - */ - async exportForecasts(params?: { - productName?: string; - startDate?: string; - endDate?: string; - format?: 'csv' | 'excel'; - }): Promise { - const response = await apiClient.get('/api/v1/forecasting/export', { - params, - responseType: 'blob', - }); - return response as unknown as Blob; - } - - /** - * Get business insights - */ - async getBusinessInsights(params?: { - period?: 'week' | 'month' | 'quarter'; - products?: string[]; - }): Promise<{ - insights: { - type: 'trend' | 'seasonality' | 'anomaly' | 'opportunity'; - title: string; - description: string; - confidence: number; - impact: 'low' | 'medium' | 'high'; - products_affected: string[]; - }[]; - recommendations: { - title: string; - description: string; - priority: number; - estimated_impact: string; - }[]; - }> { - const response = await apiClient.get>('/api/v1/forecasting/insights', { params }); - return response.data!; - } -} - -export const forecastingService = new ForecastingService(); \ No newline at end of file diff --git a/frontend/src/api/services/index.ts b/frontend/src/api/services/index.ts index cbaeae47..f5f3cc84 100644 --- a/frontend/src/api/services/index.ts +++ b/frontend/src/api/services/index.ts @@ -1,80 +1,36 @@ -// src/api/services/index.ts +// frontend/src/api/services/index.ts /** - * Main API Services Index - * Central import point for all service modules + * Main Services Export + * Central export point for all API services */ -// Import all service classes -import { AuthService, authService } from './authService'; -import { DataService, dataService } from './dataService'; -import { TrainingService, trainingService } from './trainingService'; -import { ForecastingService, forecastingService } from './forecastingService'; -import { NotificationService, notificationService } from './notificationService'; -import { TenantService, tenantService } from './tenantService'; +// Import all services +export { AuthService, authService } from './auth.service'; +export { TenantService, tenantService } from './tenant.service'; +export { DataService, dataService } from './data.service'; +export { TrainingService, trainingService } from './training.service'; +export { ForecastingService, forecastingService } from './forecasting.service'; +export { NotificationService, notificationService } from './notification.service'; -// Import base API client for custom implementations -export { apiClient } from '../base/apiClient'; +// Import base client +export { apiClient } from '../client'; -// Re-export all types from the main types file -export * from '../types/api'; +// Re-export all types +export * from '../types'; -// Export additional service-specific types -export type { - DashboardStats, - UploadResponse, - DataValidation, -} from './dataService'; - -export type { - TrainingJobProgress, - ModelMetrics, - TrainingConfiguration, - TrainingJobStatus -} from './trainingService'; - -export type { - SingleForecastRequest, - BatchForecastRequest, - ForecastAlert, - QuickForecast, - BatchForecastStatus, -} from './forecastingService'; - -export type { - NotificationCreate, - NotificationResponse, - NotificationHistory, - NotificationTemplate, - NotificationStats, - BulkNotificationRequest, - BulkNotificationStatus, -} from './notificationService'; - -export type { - TenantCreate, - TenantUpdate, - TenantSettings, - TenantStats, - TenantUser, - InviteUser, - TenantInfo -} from './tenantService'; - -// Create a unified API object for convenience +// Create unified API object export const api = { auth: authService, + tenant: tenantService, data: dataService, training: trainingService, forecasting: forecastingService, - notifications: notificationService, - tenant: tenantService, + notification: notificationService, + client: apiClient, } as const; -// Type for the unified API object -export type ApiServices = typeof api; - -// Service status type for monitoring -export interface ServiceStatus { +// Service status checking +export interface ServiceHealth { service: string; status: 'healthy' | 'degraded' | 'down'; lastChecked: Date; @@ -82,196 +38,52 @@ export interface ServiceStatus { error?: string; } -// Health check utilities -export class ApiHealthChecker { - private static healthCheckEndpoints = { - auth: '/auth/health', - data: '/data/health', - training: '/training/health', - forecasting: '/forecasting/health', - notifications: '/notifications/health', - tenant: '/tenants/health', - }; +export class HealthService { + async checkServiceHealth(): Promise { + const services = [ + { name: 'Auth', endpoint: '/auth/health' }, + { name: 'Tenant', endpoint: '/tenants/health' }, + { name: 'Data', endpoint: '/data/health' }, + { name: 'Training', endpoint: '/training/health' }, + { name: 'Forecasting', endpoint: '/forecasting/health' }, + { name: 'Notification', endpoint: '/notifications/health' }, + ]; - /** - * Check health of all services - */ - static async checkAllServices(): Promise> { - const results: Record = {}; - - for (const [serviceName, endpoint] of Object.entries(this.healthCheckEndpoints)) { - results[serviceName] = await this.checkService(serviceName, endpoint); - } - - return results; - } + const healthChecks = await Promise.allSettled( + services.map(async (service) => { + const startTime = Date.now(); + try { + await apiClient.get(service.endpoint, { timeout: 5000 }); + const responseTime = Date.now() - startTime; + + return { + service: service.name, + status: 'healthy' as const, + lastChecked: new Date(), + responseTime, + }; + } catch (error) { + return { + service: service.name, + status: 'down' as const, + lastChecked: new Date(), + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }) + ); - /** - * Check health of a specific service - */ - static async checkService(serviceName: string, endpoint: string): Promise { - const startTime = Date.now(); - - try { - const response = await apiClient.get(endpoint, { timeout: 5000 }); - const responseTime = Date.now() - startTime; - - return { - service: serviceName, - status: response.status === 200 ? 'healthy' : 'degraded', - lastChecked: new Date(), - responseTime, - }; - } catch (error: any) { - return { - service: serviceName, - status: 'down', - lastChecked: new Date(), - responseTime: Date.now() - startTime, - error: error.message || 'Unknown error', - }; - } - } - - /** - * Check if core services are available - */ - static async checkCoreServices(): Promise { - const coreServices = ['auth', 'data', 'forecasting']; - const results = await this.checkAllServices(); - - return coreServices.every( - service => results[service]?.status === 'healthy' + return healthChecks.map((result, index) => + result.status === 'fulfilled' + ? result.value + : { + service: services[index].name, + status: 'down' as const, + lastChecked: new Date(), + error: 'Health check failed', + } ); } } -// Error handling utilities -export class ApiErrorHandler { - /** - * Handle common API errors - */ - static handleError(error: any): never { - if (error.response) { - // Server responded with error status - const { status, data } = error.response; - - switch (status) { - case 401: - throw new Error('Authentication required. Please log in again.'); - case 403: - throw new Error('You do not have permission to perform this action.'); - case 404: - throw new Error('The requested resource was not found.'); - case 429: - throw new Error('Too many requests. Please try again later.'); - case 500: - throw new Error('Server error. Please try again later.'); - default: - throw new Error(data?.message || `Request failed with status ${status}`); - } - } else if (error.request) { - // Network error - throw new Error('Network error. Please check your connection.'); - } else { - // Other error - throw new Error(error.message || 'An unexpected error occurred.'); - } - } - - /** - * Retry failed requests with exponential backoff - */ - static async retryRequest( - requestFn: () => Promise, - maxRetries: number = 3, - baseDelay: number = 1000 - ): Promise { - let lastError: any; - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - return await requestFn(); - } catch (error: any) { - lastError = error; - - // Don't retry on certain errors - if (error.response?.status === 401 || error.response?.status === 403) { - throw error; - } - - // Don't retry on last attempt - if (attempt === maxRetries) { - break; - } - - // Wait before retrying with exponential backoff - const delay = baseDelay * Math.pow(2, attempt); - await new Promise(resolve => setTimeout(resolve, delay)); - } - } - - throw lastError; - } -} - -// Request cache utilities for performance optimization -export class ApiCache { - private static cache = new Map(); - - /** - * Get cached response - */ - static get(key: string): T | null { - const cached = this.cache.get(key); - - if (cached && cached.expires > Date.now()) { - return cached.data; - } - - // Remove expired cache entry - if (cached) { - this.cache.delete(key); - } - - return null; - } - - /** - * Set cached response - */ - static set(key: string, data: any, ttlMs: number = 300000): void { // 5 minutes default - const expires = Date.now() + ttlMs; - this.cache.set(key, { data, expires }); - } - - /** - * Clear cache - */ - static clear(): void { - this.cache.clear(); - } - - /** - * Clear expired entries - */ - static cleanup(): void { - const now = Date.now(); - for (const [key, value] of this.cache.entries()) { - if (value.expires <= now) { - this.cache.delete(key); - } - } - } - - /** - * Generate cache key - */ - static generateKey(method: string, url: string, params?: any): string { - const paramStr = params ? JSON.stringify(params) : ''; - return `${method}:${url}:${paramStr}`; - } -} - -// Export default as the unified API object -export default api; \ No newline at end of file +export const healthService = new HealthService(); \ No newline at end of file diff --git a/frontend/src/api/services/notification.service.ts b/frontend/src/api/services/notification.service.ts new file mode 100644 index 00000000..ead1c16a --- /dev/null +++ b/frontend/src/api/services/notification.service.ts @@ -0,0 +1,185 @@ +// frontend/src/api/services/notification.service.ts +/** + * Notification Service + * Handles notification operations + */ + +import { apiClient } from '../client'; +import type { + NotificationCreate, + NotificationResponse, + NotificationTemplate, + NotificationHistory, + NotificationStats, + BulkNotificationRequest, + BulkNotificationStatus, + PaginatedResponse, + BaseQueryParams, +} from '../types'; + +export class NotificationService { + /** + * Send Notification + */ + async sendNotification( + tenantId: string, + notification: NotificationCreate + ): Promise { + return apiClient.post(`/tenants/${tenantId}/notifications`, notification); + } + + /** + * Send Bulk Notifications + */ + async sendBulkNotifications( + tenantId: string, + request: BulkNotificationRequest + ): Promise { + return apiClient.post(`/tenants/${tenantId}/notifications/bulk`, request); + } + + /** + * Get Notifications + */ + async getNotifications( + tenantId: string, + params?: BaseQueryParams & { + channel?: string; + status?: string; + recipient_email?: string; + start_date?: string; + end_date?: string; + } + ): Promise> { + return apiClient.get(`/tenants/${tenantId}/notifications`, { params }); + } + + /** + * Get Notification by ID + */ + async getNotification(tenantId: string, notificationId: string): Promise { + return apiClient.get(`/tenants/${tenantId}/notifications/${notificationId}`); + } + + /** + * Get Notification History + */ + async getNotificationHistory( + tenantId: string, + notificationId: string + ): Promise { + return apiClient.get(`/tenants/${tenantId}/notifications/${notificationId}/history`); + } + + /** + * Cancel Scheduled Notification + */ + async cancelNotification( + tenantId: string, + notificationId: string + ): Promise<{ message: string }> { + return apiClient.post(`/tenants/${tenantId}/notifications/${notificationId}/cancel`); + } + + /** + * Get Bulk Notification Status + */ + async getBulkNotificationStatus( + tenantId: string, + batchId: string + ): Promise { + return apiClient.get(`/tenants/${tenantId}/notifications/bulk/${batchId}/status`); + } + + /** + * Get Notification Templates + */ + async getTemplates( + tenantId: string, + params?: BaseQueryParams & { + channel?: string; + is_active?: boolean; + } + ): Promise> { + return apiClient.get(`/tenants/${tenantId}/notifications/templates`, { params }); + } + + /** + * Create Notification Template + */ + async createTemplate( + tenantId: string, + template: Omit + ): Promise { + return apiClient.post(`/tenants/${tenantId}/notifications/templates`, template); + } + + /** + * Update Notification Template + */ + async updateTemplate( + tenantId: string, + templateId: string, + template: Partial + ): Promise { + return apiClient.put(`/tenants/${tenantId}/notifications/templates/${templateId}`, template); + } + + /** + * Delete Notification Template + */ + async deleteTemplate(tenantId: string, templateId: string): Promise<{ message: string }> { + return apiClient.delete(`/tenants/${tenantId}/notifications/templates/${templateId}`); + } + + /** + * Get Notification Statistics + */ + async getNotificationStats( + tenantId: string, + params?: { + start_date?: string; + end_date?: string; + channel?: string; + } + ): Promise { + return apiClient.get(`/tenants/${tenantId}/notifications/stats`, { params }); + } + + /** + * Test Notification Configuration + */ + async testNotificationConfig( + tenantId: string, + config: { + channel: string; + recipient: string; + test_message: string; + } + ): Promise<{ success: boolean; message: string }> { + return apiClient.post(`/tenants/${tenantId}/notifications/test`, config); + } + + /** + * Get User Notification Preferences + */ + async getUserPreferences(tenantId: string, userId: string): Promise> { + return apiClient.get(`/tenants/${tenantId}/notifications/preferences/${userId}`); + } + + /** + * Update User Notification Preferences + */ + async updateUserPreferences( + tenantId: string, + userId: string, + preferences: Record + ): Promise<{ message: string }> { + return apiClient.put( + `/tenants/${tenantId}/notifications/preferences/${userId}`, + preferences + ); + } +} + +export const notificationService = new NotificationService(); diff --git a/frontend/src/api/services/notificationService.ts b/frontend/src/api/services/notificationService.ts deleted file mode 100644 index 60abfed5..00000000 --- a/frontend/src/api/services/notificationService.ts +++ /dev/null @@ -1,363 +0,0 @@ -// src/api/services/NotificationService.ts -import { apiClient } from '../base/apiClient'; -import { - ApiResponse -} from '@/api/services'; - -export interface NotificationCreate { - type: 'email' | 'whatsapp' | 'push'; - recipient_email?: string; - recipient_phone?: string; - recipient_push_token?: string; - subject?: string; - message: string; - template_id?: string; - template_data?: Record; - scheduled_for?: string; - broadcast?: boolean; - priority?: 'low' | 'normal' | 'high'; -} - -export interface NotificationResponse { - id: string; - type: string; - recipient_email?: string; - recipient_phone?: string; - subject?: string; - message: string; - status: 'pending' | 'sent' | 'delivered' | 'failed'; - created_at: string; - sent_at?: string; - delivered_at?: string; - error_message?: string; -} - -export interface NotificationHistory { - id: string; - type: string; - recipient: string; - subject?: string; - status: string; - created_at: string; - sent_at?: string; - delivered_at?: string; - opened_at?: string; - clicked_at?: string; - error_message?: string; -} - -export interface NotificationTemplate { - id: string; - name: string; - description: string; - type: 'email' | 'whatsapp' | 'push'; - subject?: string; - content: string; - variables: string[]; - is_system: boolean; - is_active: boolean; - created_at: string; -} - -export interface NotificationStats { - total_sent: number; - total_delivered: number; - total_failed: number; - delivery_rate: number; - open_rate: number; - click_rate: number; - by_type: { - email: { sent: number; delivered: number; opened: number; clicked: number }; - whatsapp: { sent: number; delivered: number; read: number }; - push: { sent: number; delivered: number; opened: number }; - }; -} - -export interface BulkNotificationRequest { - type: 'email' | 'whatsapp' | 'push'; - recipients: { - email?: string; - phone?: string; - push_token?: string; - template_data?: Record; - }[]; - template_id?: string; - subject?: string; - message?: string; - scheduled_for?: string; - batch_name?: string; -} - -export interface BulkNotificationStatus { - id: string; - batch_name?: string; - total_recipients: number; - sent: number; - delivered: number; - failed: number; - status: 'queued' | 'processing' | 'completed' | 'failed'; - created_at: string; - completed_at?: string; -} - -// Notification types -export interface NotificationSettings { - email_enabled: boolean; - whatsapp_enabled: boolean; - training_notifications: boolean; - forecast_notifications: boolean; - alert_thresholds: { - low_stock_percentage: number; - high_demand_increase: number; - }; -} - - -export class NotificationService { - /** - * Send single notification - */ - async sendNotification(notification: NotificationCreate): Promise { - const response = await apiClient.post>( - '/api/v1/notifications/send', - notification - ); - return response.data!; - } - - /** - * Send bulk notifications - */ - async sendBulkNotifications(request: BulkNotificationRequest): Promise { - const response = await apiClient.post>( - '/api/v1/notifications/bulk', - request - ); - return response.data!; - } - - /** - * Get notification history - */ - async getNotificationHistory(params?: { - type?: string; - status?: string; - startDate?: string; - endDate?: string; - page?: number; - limit?: number; - }): Promise<{ - notifications: NotificationHistory[]; - total: number; - page: number; - pages: number; - }> { - const response = await apiClient.get>('/api/v1/notifications/history', { params }); - return response.data!; - } - - /** - * Get notification by ID - */ - async getNotification(notificationId: string): Promise { - const response = await apiClient.get>( - `/api/v1/notifications/${notificationId}` - ); - return response.data!; - } - - /** - * Retry failed notification - */ - async retryNotification(notificationId: string): Promise { - const response = await apiClient.post>( - `/api/v1/notifications/${notificationId}/retry` - ); - return response.data!; - } - - /** - * Cancel scheduled notification - */ - async cancelNotification(notificationId: string): Promise { - await apiClient.post(`/api/v1/notifications/${notificationId}/cancel`); - } - - /** - * Get notification statistics - */ - async getNotificationStats(params?: { - startDate?: string; - endDate?: string; - type?: string; - }): Promise { - const response = await apiClient.get>( - '/api/v1/notifications/stats', - { params } - ); - return response.data!; - } - - /** - * Get bulk notification status - */ - async getBulkStatus(batchId: string): Promise { - const response = await apiClient.get>( - `/api/v1/notifications/bulk/${batchId}/status` - ); - return response.data!; - } - - /** - * Get notification templates - */ - async getTemplates(params?: { - type?: string; - active?: boolean; - page?: number; - limit?: number; - }): Promise<{ - templates: NotificationTemplate[]; - total: number; - page: number; - pages: number; - }> { - const response = await apiClient.get>('/api/v1/notifications/templates', { params }); - return response.data!; - } - - /** - * Get template by ID - */ - async getTemplate(templateId: string): Promise { - const response = await apiClient.get>( - `/api/v1/notifications/templates/${templateId}` - ); - return response.data!; - } - - /** - * Create notification template - */ - async createTemplate(template: { - name: string; - description: string; - type: 'email' | 'whatsapp' | 'push'; - subject?: string; - content: string; - variables?: string[]; - }): Promise { - const response = await apiClient.post>( - '/api/v1/notifications/templates', - template - ); - return response.data!; - } - - /** - * Update notification template - */ - async updateTemplate( - templateId: string, - updates: Partial - ): Promise { - const response = await apiClient.put>( - `/api/v1/notifications/templates/${templateId}`, - updates - ); - return response.data!; - } - - /** - * Delete notification template - */ - async deleteTemplate(templateId: string): Promise { - await apiClient.delete(`/api/v1/notifications/templates/${templateId}`); - } - - /** - * Get user notification preferences - */ - async getPreferences(): Promise { - const response = await apiClient.get>( - '/api/v1/notifications/preferences' - ); - return response.data!; - } - - /** - * Update user notification preferences - */ - async updatePreferences(preferences: Partial): Promise { - const response = await apiClient.put>( - '/api/v1/notifications/preferences', - preferences - ); - return response.data!; - } - - /** - * Test notification delivery - */ - async testNotification(type: 'email' | 'whatsapp' | 'push', recipient: string): Promise<{ - success: boolean; - message: string; - delivery_time_ms?: number; - }> { - const response = await apiClient.post>( - '/api/v1/notifications/test', - { type, recipient } - ); - return response.data!; - } - - /** - * Get delivery webhooks - */ - async getWebhooks(params?: { - type?: string; - status?: string; - page?: number; - limit?: number; - }): Promise<{ - webhooks: { - id: string; - notification_id: string; - event_type: string; - status: string; - payload: any; - received_at: string; - }[]; - total: number; - page: number; - pages: number; - }> { - const response = await apiClient.get>('/api/v1/notifications/webhooks', { params }); - return response.data!; - } - - /** - * Subscribe to notification events - */ - async subscribeToEvents(events: string[], webhookUrl: string): Promise<{ - subscription_id: string; - events: string[]; - webhook_url: string; - created_at: string; - }> { - const response = await apiClient.post>('/api/v1/notifications/subscribe', { - events, - webhook_url: webhookUrl, - }); - return response.data!; - } - - /** - * Unsubscribe from notification events - */ - async unsubscribeFromEvents(subscriptionId: string): Promise { - await apiClient.delete(`/api/v1/notifications/subscribe/${subscriptionId}`); - } -} - -export const notificationService = new NotificationService(); \ No newline at end of file diff --git a/frontend/src/api/services/tenant.service.ts b/frontend/src/api/services/tenant.service.ts new file mode 100644 index 00000000..ecca4083 --- /dev/null +++ b/frontend/src/api/services/tenant.service.ts @@ -0,0 +1,101 @@ +// frontend/src/api/services/tenant.service.ts +/** + * Tenant Management Service + * Handles all tenant-related operations + */ + +import { apiClient } from '../client'; +import { serviceEndpoints } from '../client/config'; +import type { + TenantInfo, + TenantCreate, + TenantUpdate, + TenantMember, + InviteUser, + TenantStats, + PaginatedResponse, + BaseQueryParams, +} from '../types'; + +export class TenantService { + private baseEndpoint = serviceEndpoints.tenant; + + /** + * Create New Tenant + */ + async createTenant(data: TenantCreate): Promise { + return apiClient.post(`${this.baseEndpoint}/register`, data); + } + + /** + * Get Tenant Details + */ + async getTenant(tenantId: string): Promise { + return apiClient.get(`${this.baseEndpoint}/${tenantId}`); + } + + /** + * Update Tenant + */ + async updateTenant(tenantId: string, data: TenantUpdate): Promise { + return apiClient.put(`${this.baseEndpoint}/${tenantId}`, data); + } + + /** + * Delete Tenant + */ + async deleteTenant(tenantId: string): Promise<{ message: string }> { + return apiClient.delete(`${this.baseEndpoint}/${tenantId}`); + } + + /** + * Get Tenant Members + */ + async getTenantMembers( + tenantId: string, + params?: BaseQueryParams + ): Promise> { + return apiClient.get(`${this.baseEndpoint}/${tenantId}/members`, { params }); + } + + /** + * Invite User to Tenant + */ + async inviteUser(tenantId: string, invitation: InviteUser): Promise<{ message: string }> { + return apiClient.post(`${this.baseEndpoint}/${tenantId}/invite`, invitation); + } + + /** + * Remove Member from Tenant + */ + async removeMember(tenantId: string, userId: string): Promise<{ message: string }> { + return apiClient.delete(`${this.baseEndpoint}/${tenantId}/members/${userId}`); + } + + /** + * Update Member Role + */ + async updateMemberRole( + tenantId: string, + userId: string, + role: string + ): Promise { + return apiClient.patch(`${this.baseEndpoint}/${tenantId}/members/${userId}`, { role }); + } + + /** + * Get Tenant Statistics + */ + async getTenantStats(tenantId: string): Promise { + return apiClient.get(`${this.baseEndpoint}/${tenantId}/stats`); + } + + /** + * Get User's Tenants + */ + async getUserTenants(): Promise { + return apiClient.get(`/users/me/tenants`); + } +} + +export const tenantService = new TenantService(); \ No newline at end of file diff --git a/frontend/src/api/services/tenantService.ts b/frontend/src/api/services/tenantService.ts deleted file mode 100644 index a292729c..00000000 --- a/frontend/src/api/services/tenantService.ts +++ /dev/null @@ -1,149 +0,0 @@ -// src/api/services/TenantService.ts -import { apiClient } from '../base/apiClient'; -import { - ApiResponse -} from '@/api/services'; - -export interface TenantCreate { - name: string; - address: string; - city?: string; // Optional with default "Madrid" - postal_code: string; // Required, must match pattern ^\d{5}$ - phone: string; // Required, validated for Spanish format - business_type?: string; // Optional with default "bakery", must be one of: ['bakery', 'coffee_shop', 'pastry_shop', 'restaurant'] -} - -export interface TenantUpdate extends Partial { - is_active?: boolean; -} - -export interface TenantSettings { - business_hours: { - monday: { open: string; close: string; closed: boolean }; - tuesday: { open: string; close: string; closed: boolean }; - wednesday: { open: string; close: string; closed: boolean }; - thursday: { open: string; close: string; closed: boolean }; - friday: { open: string; close: string; closed: boolean }; - saturday: { open: string; close: string; closed: boolean }; - sunday: { open: string; close: string; closed: boolean }; - }; - timezone: string; - currency: string; - language: string; - notification_preferences: { - email_enabled: boolean; - whatsapp_enabled: boolean; - forecast_alerts: boolean; - training_notifications: boolean; - weekly_reports: boolean; - }; - forecast_preferences: { - default_forecast_days: number; - confidence_level: number; - include_weather: boolean; - include_traffic: boolean; - alert_thresholds: { - high_demand_increase: number; - low_demand_decrease: number; - }; - }; - data_retention_days: number; -} - -export interface TenantStats { - total_users: number; - active_users: number; - total_sales_records: number; - total_forecasts: number; - total_notifications_sent: number; - storage_used_mb: number; - api_calls_this_month: number; - last_activity: string; - subscription_status: 'active' | 'inactive' | 'suspended'; - subscription_expires: string; -} - -export interface TenantInfo { - id: string; - name: string; - subdomain?: string; - business_type: string; - address: string; - city: string; - postal_code: string; - phone?: string; - is_active: boolean; - subscription_tier: string; - model_trained: boolean; - last_training_date?: string; - owner_id: string; - created_at: string; -} - -export interface InviteUser { - email: string; - role: 'admin' | 'manager' | 'user'; - full_name?: string; - send_invitation_email?: boolean; -} - -// New interface for tenant member response based on backend -export interface TenantMemberResponse { - user_id: string; - tenant_id: string; - role: string; - // Add any other fields expected from the backend's TenantMemberResponse -} - -// Tenant types -export interface TenantInfo { - id: string; - name: string; - email: string; - phone: string; - address: string; - latitude: number; - longitude: number; - business_type: string; - is_active: boolean; - created_at: string; -} - -export class TenantService { - /** - * Register a new bakery (tenant) - * Corresponds to POST /tenants/register - */ - async registerBakery(bakeryData: TenantCreate): Promise { - const response = await apiClient.post('/api/v1/tenants/register', bakeryData); - return response; - } - - /** - * Get a specific tenant by ID - * Corresponds to GET /tenants/{tenant_id} - */ - async getTenantById(tenantId: string): Promise { - const response = await apiClient.get>(`/api/v1/tenants/${tenantId}`); - return response.data!; - } - - /** - * Update a specific tenant by ID - * Corresponds to PUT /tenants/{tenant_id} - */ - async updateTenant(tenantId: string, updates: TenantUpdate): Promise { - const response = await apiClient.put>(`/api/v1/tenants/${tenantId}`, updates); - return response.data!; - } - - /** - * Get all tenants associated with a user - * Corresponds to GET /users/{user_id}/tenants - */ - async getUserTenants(userId: string): Promise { - const response = await apiClient.get>(`/api/v1/tenants/user/${userId}`); - return response.data!; - } - -} \ No newline at end of file diff --git a/frontend/src/api/services/training.service.ts b/frontend/src/api/services/training.service.ts new file mode 100644 index 00000000..f7382a23 --- /dev/null +++ b/frontend/src/api/services/training.service.ts @@ -0,0 +1,160 @@ +// frontend/src/api/services/training.service.ts +/** + * Training Service + * Handles ML model training operations + */ + +import { apiClient } from '../client'; +import { RequestTimeouts } from '../client/config'; +import type { + TrainingJobRequest, + TrainingJobResponse, + SingleProductTrainingRequest, + ModelInfo, + ModelTrainingStats, + PaginatedResponse, + BaseQueryParams, +} from '../types'; + +export class TrainingService { + /** + * Start Training Job for All Products + */ + async startTrainingJob( + tenantId: string, + request: TrainingJobRequest + ): Promise { + return apiClient.post( + `/tenants/${tenantId}/training/jobs`, + request, + { + timeout: RequestTimeouts.EXTENDED, + } + ); + } + + /** + * Start Training for Single Product + */ + async startSingleProductTraining( + tenantId: string, + request: SingleProductTrainingRequest + ): Promise { + return apiClient.post( + `/tenants/${tenantId}/training/single`, + request, + { + timeout: RequestTimeouts.EXTENDED, + } + ); + } + + /** + * Get Training Job Status + */ + async getTrainingJobStatus(tenantId: string, jobId: string): Promise { + return apiClient.get(`/tenants/${tenantId}/training/jobs/${jobId}/status`); + } + + /** + * Get Training Job Logs + */ + async getTrainingJobLogs(tenantId: string, jobId: string): Promise<{ logs: string[] }> { + return apiClient.get(`/tenants/${tenantId}/training/jobs/${jobId}/logs`); + } + + /** + * Cancel Training Job + */ + async cancelTrainingJob(tenantId: string, jobId: string): Promise<{ message: string }> { + return apiClient.post(`/tenants/${tenantId}/training/jobs/${jobId}/cancel`); + } + + /** + * Get Training Jobs + */ + async getTrainingJobs( + tenantId: string, + params?: BaseQueryParams & { + status?: string; + start_date?: string; + end_date?: string; + } + ): Promise> { + return apiClient.get(`/tenants/${tenantId}/training/jobs`, { params }); + } + + /** + * Validate Data for Training + */ + async validateTrainingData(tenantId: string): Promise<{ + is_valid: boolean; + message: string; + details?: any; + }> { + return apiClient.post(`/tenants/${tenantId}/training/validate`); + } + + /** + * Get Trained Models + */ + async getModels( + tenantId: string, + params?: BaseQueryParams & { + product_name?: string; + is_active?: boolean; + } + ): Promise> { + return apiClient.get(`/tenants/${tenantId}/models`, { params }); + } + + /** + * Get Model Details + */ + async getModel(tenantId: string, modelId: string): Promise { + return apiClient.get(`/tenants/${tenantId}/models/${modelId}`); + } + + /** + * Update Model Status + */ + async updateModelStatus( + tenantId: string, + modelId: string, + isActive: boolean + ): Promise { + return apiClient.patch(`/tenants/${tenantId}/models/${modelId}`, { + is_active: isActive, + }); + } + + /** + * Delete Model + */ + async deleteModel(tenantId: string, modelId: string): Promise<{ message: string }> { + return apiClient.delete(`/tenants/${tenantId}/models/${modelId}`); + } + + /** + * Get Training Statistics + */ + async getTrainingStats(tenantId: string): Promise { + return apiClient.get(`/tenants/${tenantId}/training/stats`); + } + + /** + * Download Model File + */ + async downloadModel(tenantId: string, modelId: string): Promise { + const response = await apiClient.request(`/tenants/${tenantId}/models/${modelId}/download`, { + method: 'GET', + headers: { + 'Accept': 'application/octet-stream', + }, + }); + + return new Blob([response]); + } +} + +export const trainingService = new TrainingService(); \ No newline at end of file diff --git a/frontend/src/api/services/trainingService.ts b/frontend/src/api/services/trainingService.ts deleted file mode 100644 index f541ff36..00000000 --- a/frontend/src/api/services/trainingService.ts +++ /dev/null @@ -1,254 +0,0 @@ -// src/api/services/TrainingService.ts -import { apiClient } from '../base/apiClient'; -import { - ApiResponse -} from '../types/api'; - -export interface TrainingJobStatus { - job_id: string; - tenant_id: string; - status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; - progress: number; - current_step?: string; - started_at: string; - completed_at?: string; - duration_seconds?: number; - models_trained?: Record; - metrics?: Record; - error_message?: string; -} - -export interface TrainingRequest { - force_retrain?: boolean; - products?: string[]; - training_days?: number; -} - -export interface TrainedModel { - id: string; - product_name: string; - model_type: string; - model_version: string; - mape?: number; - rmse?: number; - mae?: number; - r2_score?: number; - training_samples?: number; - features_used?: string[]; - is_active: boolean; - created_at: string; - last_used_at?: string; -} - -export interface TrainingJobProgress { - id: string; - status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; - progress: number; - current_step?: string; - total_steps?: number; - step_details?: string; - estimated_completion?: string; - logs?: string[]; -} - -export interface ModelMetrics { - mape: number; - rmse: number; - mae: number; - r2_score: number; - training_samples: number; - validation_samples: number; - features_used: string[]; -} - -export interface TrainingConfiguration { - include_weather: boolean; - include_traffic: boolean; - min_data_points: number; - forecast_horizon_days: number; - cross_validation_folds: number; - hyperparameter_tuning: boolean; - products?: string[]; -} - -export class TrainingService { - /** - * Start new training job - */ - async startTraining(tenantId: string, config: TrainingConfiguration): Promise { - const response = await apiClient.post( - `/api/v1/tenants/${tenantId}/training/jobs`, - config - ); - return response.data!; - } - - /** - * Get training job status - */ - async getTrainingStatus(tenantId: string, jobId: string): Promise { - const response = await apiClient.get>( - `/api/v1/tenants/${tenantId}/training/jobs/${jobId}` - ); - return response.data!; - } - - /** - * Get all training jobs - */ - async getTrainingHistory(tenantId: string, params?: { - page?: number; - limit?: number; - status?: string; - }): Promise<{ - jobs: TrainingJobStatus[]; - total: number; - page: number; - pages: number; - }> { - const response = await apiClient.get>('/api/v1/training/jobs', { params }); - return response.data!; - } - - /** - * Cancel training job - */ - async cancelTraining(tenantId: string, jobId: string): Promise { - await apiClient.post(`/api/v1/tenants/${tenantId}/training/jobs/${jobId}/cancel`); - } - - /** - * Get trained models - */ - async getModels(tenantId: string, params?: { - productName?: string; - active?: boolean; - page?: number; - limit?: number; - }): Promise<{ - models: TrainedModel[]; - total: number; - page: number; - pages: number; - }> { - const response = await apiClient.get>(`/api/v1/tenants/${tenantId}/training/models`, { params }); - return response.data!; - } - - /** - * Get specific model details - */ - async getModel(tenantId: string, modelId: string): Promise { - const response = await apiClient.get>( - `/api/v1/training/models/${modelId}` - ); - return response.data!; - } - - /** - * Get model metrics - */ - async getModelMetrics(tenantId: string, modelId: string): Promise { - const response = await apiClient.get>( - `/api/v1/tenants/${tenantId}/training/models/${modelId}/metrics` - ); - return response.data!; - } - - /** - * Activate/deactivate model - */ - async toggleModelStatus(tenantId: string, modelId: string, active: boolean): Promise { - const response = await apiClient.patch>( - `/api/v1/tenants/${tenantId}/training/models/${modelId}`, - { is_active: active } - ); - return response.data!; - } - - /** - * Delete model - */ - async deleteModel(tenantId: string, modelId: string): Promise { - await apiClient.delete(`/api/v1/training/models/${modelId}`); - } - - /** - * Train specific product - */ - async trainProduct(tenantId: string, productName: string, config?: Partial): Promise { - const response = await apiClient.post>( - `/api/v1/tenants/${tenantId}/training/products/train`, - { - product_name: productName, - ...config, - } - ); - return response.data!; - } - - /** - * Get training statistics - */ - async getTrainingStats(tenantId: string): Promise<{ - total_models: number; - active_models: number; - avg_accuracy: number; - last_training_date: string | null; - products_trained: number; - training_time_avg_minutes: number; - }> { - const response = await apiClient.get>(`/api/v1/tenants/${tenantId}/training/stats`); - return response.data!; - } - - /** - * Validate training data - */ - async validateTrainingData(tenantId: string, products?: string[]): Promise<{ - valid: boolean; - errors: string[]; - warnings: string[]; - product_data_points: Record; - recommendation: string; - }> { - const response = await apiClient.post>(`/api/v1/tenants/${tenantId}/training/validate`, { - products, - }); - return response.data!; - } - - /** - * Get training recommendations - */ - async getTrainingRecommendations(tenantId: string): Promise<{ - should_retrain: boolean; - reasons: string[]; - recommended_products: string[]; - optimal_config: TrainingConfiguration; - }> { - const response = await apiClient.get>(`/api/v1/tenants/${tenantId}/training/recommendations`); - return response.data!; - } - - /** - * Get training logs - */ - async getTrainingLogs(tenantId: string, jobId: string): Promise { - const response = await apiClient.get>(`/api/v1/tenants/${tenantId}/training/jobs/${jobId}/logs`); - return response.data!; - } - - /** - * Export model - */ - async exportModel(tenantId: string, modelId: string, format: 'pickle' | 'onnx' = 'pickle'): Promise { - const response = await apiClient.get(`/api/v1/tenants/${tenantId}/training/models/${modelId}/export`, { - params: { format }, - responseType: 'blob', - }); - return response as unknown as Blob; - } -} - -export const trainingService = new TrainingService(); \ No newline at end of file diff --git a/frontend/src/api/types/api.ts b/frontend/src/api/types/api.ts deleted file mode 100644 index 48dff4d7..00000000 --- a/frontend/src/api/types/api.ts +++ /dev/null @@ -1,20 +0,0 @@ -// frontend/dashboard/src/types/api.ts -/** - * Shared TypeScript interfaces for API communication - */ - -// Base response types -export interface ApiResponse { - data?: T; - message?: string; - status: string; - timestamp?: string; -} - -export interface ApiError { - detail: string; - service?: string; - error_code?: string; - timestamp?: string; -} - diff --git a/frontend/src/api/types/auth.ts b/frontend/src/api/types/auth.ts new file mode 100644 index 00000000..ffff0444 --- /dev/null +++ b/frontend/src/api/types/auth.ts @@ -0,0 +1,83 @@ +// frontend/src/api/types/auth.ts +/** + * Authentication Types + */ + +export interface UserData { + id: string; + email: string; + full_name: string; + is_active: boolean; + is_verified: boolean; + created_at: string; + last_login?: string; + phone?: string; + language?: string; + timezone?: string; + tenant_id?: string; + role: string; +} + +export interface LoginRequest { + email: string; + password: string; +} + +export interface RegisterRequest { + email: string; + password: string; + full_name: string; + role?: string; + phone?: string; + language?: string; +} + +export interface LoginResponse { + access_token: string; + refresh_token?: string; + token_type: string; + expires_in: number; + user?: UserData; +} + +export interface UserResponse { + id: string; + email: string; + full_name: string; + is_active: boolean; + is_verified: boolean; + created_at: string; + last_login?: string; + phone?: string; + language?: string; + timezone?: string; + tenant_id?: string; + role?: string; +} + +export interface TokenVerification { + valid: boolean; + user_id?: string; + email?: string; + exp?: number; + message?: string; +} + +export interface PasswordResetRequest { + email: string; +} + +export interface PasswordResetResponse { + message: string; + reset_token?: string; +} + +export interface PasswordResetConfirmRequest { + token: string; + new_password: string; +} + +export interface LogoutResponse { + message: string; + success: boolean; +} diff --git a/frontend/src/api/types/common.ts b/frontend/src/api/types/common.ts new file mode 100644 index 00000000..c0957f35 --- /dev/null +++ b/frontend/src/api/types/common.ts @@ -0,0 +1,42 @@ +// frontend/src/api/types/common.ts +/** + * Common API Types + */ + +export interface ApiResponse { + data?: T; + message?: string; + status: string; + timestamp?: string; + meta?: PaginationMeta; +} + +export interface ApiError { + detail: string; + service?: string; + error_code?: string; + timestamp?: string; + field?: string; +} + +export interface PaginationMeta { + page: number; + limit: number; + total: number; + totalPages: number; + hasNext: boolean; + hasPrev: boolean; +} + +export interface PaginatedResponse { + data: T[]; + pagination: PaginationMeta; +} + +export interface BaseQueryParams { + page?: number; + limit?: number; + search?: string; + sort?: string; + order?: 'asc' | 'desc'; +} \ No newline at end of file diff --git a/frontend/src/api/types/data.ts b/frontend/src/api/types/data.ts new file mode 100644 index 00000000..e3583a63 --- /dev/null +++ b/frontend/src/api/types/data.ts @@ -0,0 +1,103 @@ +// frontend/src/api/types/data.ts +/** + * Data Management Types + */ + +export interface SalesData { + id: string; + tenant_id: string; + date: string; + product_name: string; + category?: string; + quantity: number; + unit_price: number; + total_revenue: number; + location_id?: string; + source: string; + created_at: string; + external_factors?: ExternalFactors; +} + +export interface ExternalFactors { + weather_temperature?: number; + weather_precipitation?: number; + weather_description?: string; + traffic_volume?: number; + is_holiday?: boolean; + is_weekend?: boolean; + day_of_week?: number; +} + +export interface SalesDataQuery extends BaseQueryParams { + tenant_id: string; + start_date?: string; + end_date?: string; + product_names?: string[]; + location_ids?: string[]; + sources?: string[]; + min_quantity?: number; + max_quantity?: number; + min_revenue?: number; + max_revenue?: number; +} + +export interface SalesDataImport { + tenant_id?: string; + data: string; + data_format: 'csv' | 'json' | 'excel'; + source?: string; + validate_only?: boolean; +} + +export interface SalesImportResult { + success: boolean; + message: string; + imported_count: number; + skipped_count: number; + error_count: number; + validation_errors?: ValidationError[]; + preview_data?: SalesData[]; + file_info?: FileInfo; +} + +export interface ValidationError { + row: number; + field: string; + value: any; + message: string; +} + +export interface FileInfo { + filename: string; + size: number; + rows: number; + format: string; +} + +export interface DashboardStats { + total_sales: number; + total_revenue: number; + total_products: number; + date_range: { + start: string; + end: string; + }; + top_products: ProductStats[]; + recent_activity: ActivityItem[]; +} + +export interface ProductStats { + product_name: string; + total_quantity: number; + total_revenue: number; + avg_price: number; + sales_trend: number; +} + +export interface ActivityItem { + id: string; + type: string; + description: string; + timestamp: string; + metadata?: Record; +} \ No newline at end of file diff --git a/frontend/src/api/types/forecasting.ts b/frontend/src/api/types/forecasting.ts new file mode 100644 index 00000000..6115ed5a --- /dev/null +++ b/frontend/src/api/types/forecasting.ts @@ -0,0 +1,79 @@ +// frontend/src/api/types/forecasting.ts +/** + * Forecasting Service Types + */ + +export interface SingleForecastRequest { + product_name: string; + forecast_days: number; + include_external_factors?: boolean; + confidence_intervals?: boolean; +} + +export interface BatchForecastRequest { + product_names?: string[]; + forecast_days: number; + include_external_factors?: boolean; + confidence_intervals?: boolean; + batch_name?: string; +} + +export interface ForecastResponse { + id: string; + tenant_id: string; + product_name: string; + forecast_date: string; + predicted_quantity: number; + confidence_lower?: number; + confidence_upper?: number; + model_id: string; + model_accuracy?: number; + external_factors?: ExternalFactors; + created_at: string; + processing_time_ms?: number; + features_used?: Record; +} + +export interface BatchForecastResponse { + id: string; + tenant_id: string; + batch_name: string; + status: BatchForecastStatus; + total_products: number; + completed_products: number; + failed_products: number; + requested_at: string; + completed_at?: string; + processing_time_ms?: number; + forecasts?: ForecastResponse[]; + error_message?: string; +} + +export type BatchForecastStatus = + | 'pending' + | 'processing' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface ForecastAlert { + id: string; + tenant_id: string; + forecast_id: string; + alert_type: string; + severity: 'low' | 'medium' | 'high' | 'critical'; + message: string; + is_active: boolean; + created_at: string; + acknowledged_at?: string; + notification_sent: boolean; +} + +export interface QuickForecast { + product_name: string; + next_day_prediction: number; + next_week_avg: number; + trend_direction: 'up' | 'down' | 'stable'; + confidence_score: number; + last_updated: string; +} diff --git a/frontend/src/api/types/index.ts b/frontend/src/api/types/index.ts new file mode 100644 index 00000000..2f63612b --- /dev/null +++ b/frontend/src/api/types/index.ts @@ -0,0 +1,13 @@ +// frontend/src/api/types/index.ts +/** + * Main Types Export + */ + +// Re-export all types +export * from './common'; +export * from './auth'; +export * from './tenant'; +export * from './data'; +export * from './training'; +export * from './forecasting'; +export * from './notification'; \ No newline at end of file diff --git a/frontend/src/api/types/notification.ts b/frontend/src/api/types/notification.ts new file mode 100644 index 00000000..e395db5b --- /dev/null +++ b/frontend/src/api/types/notification.ts @@ -0,0 +1,108 @@ +// frontend/src/api/types/notification.ts +/** + * Notification Service Types + */ + +export interface NotificationCreate { + recipient_id?: string; + recipient_email?: string; + recipient_phone?: string; + channel: NotificationChannel; + template_id?: string; + subject?: string; + message: string; + data?: Record; + scheduled_for?: string; + priority?: NotificationPriority; +} + +export interface NotificationResponse { + id: string; + tenant_id: string; + recipient_id?: string; + recipient_email?: string; + recipient_phone?: string; + channel: NotificationChannel; + template_id?: string; + subject?: string; + message: string; + status: NotificationStatus; + priority: NotificationPriority; + data?: Record; + scheduled_for?: string; + sent_at?: string; + delivered_at?: string; + failed_at?: string; + error_message?: string; + created_at: string; +} + +export type NotificationChannel = 'email' | 'whatsapp' | 'push' | 'sms'; +export type NotificationStatus = + | 'pending' + | 'scheduled' + | 'sent' + | 'delivered' + | 'failed' + | 'cancelled'; +export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent'; + +export interface NotificationTemplate { + id: string; + tenant_id: string; + name: string; + channel: NotificationChannel; + subject_template?: string; + body_template: string; + variables: string[]; + is_system: boolean; + is_active: boolean; + created_at: string; + updated_at?: string; +} + +export interface NotificationHistory { + id: string; + notification_id: string; + status: NotificationStatus; + timestamp: string; + details?: string; + metadata?: Record; +} + +export interface NotificationStats { + total_sent: number; + total_delivered: number; + total_failed: number; + delivery_rate: number; + channels_breakdown: Record; + recent_activity: NotificationResponse[]; +} + +export interface BulkNotificationRequest { + recipients: BulkRecipient[]; + channel: NotificationChannel; + template_id?: string; + subject?: string; + message: string; + scheduled_for?: string; + priority?: NotificationPriority; +} + +export interface BulkRecipient { + recipient_id?: string; + recipient_email?: string; + recipient_phone?: string; + data?: Record; +} + +export interface BulkNotificationStatus { + batch_id: string; + total_recipients: number; + sent_count: number; + failed_count: number; + pending_count: number; + status: 'processing' | 'completed' | 'failed'; + created_at: string; + completed_at?: string; +} \ No newline at end of file diff --git a/frontend/src/api/types/tenant.ts b/frontend/src/api/types/tenant.ts new file mode 100644 index 00000000..afb26ad1 --- /dev/null +++ b/frontend/src/api/types/tenant.ts @@ -0,0 +1,104 @@ +// frontend/src/api/types/tenant.ts +/** + * Tenant Management Types + */ + +export interface TenantInfo { + id: string; + name: string; + description?: string; + owner_id: string; + is_active: boolean; + created_at: string; + updated_at?: string; + settings?: TenantSettings; + subscription?: TenantSubscription; + location?: TenantLocation; +} + +export interface TenantSettings { + language: string; + timezone: string; + currency: string; + date_format: string; + notification_preferences: Record; + business_hours: BusinessHours; +} + +export interface BusinessHours { + monday: DaySchedule; + tuesday: DaySchedule; + wednesday: DaySchedule; + thursday: DaySchedule; + friday: DaySchedule; + saturday: DaySchedule; + sunday: DaySchedule; +} + +export interface DaySchedule { + open: string; + close: string; + closed: boolean; +} + +export interface TenantLocation { + address: string; + city: string; + country: string; + postal_code: string; + latitude?: number; + longitude?: number; +} + +export interface TenantSubscription { + plan: string; + status: string; + billing_cycle: string; + current_period_start: string; + current_period_end: string; + cancel_at_period_end: boolean; +} + +export interface TenantCreate { + name: string; + description?: string; + settings?: Partial; + location?: TenantLocation; +} + +export interface TenantUpdate { + name?: string; + description?: string; + settings?: Partial; + location?: TenantLocation; +} + +export interface TenantMember { + user_id: string; + tenant_id: string; + role: 'owner' | 'admin' | 'member' | 'viewer'; + is_active: boolean; + joined_at: string; + user: { + id: string; + email: string; + full_name: string; + }; +} + +export interface InviteUser { + email: string; + role: 'admin' | 'member' | 'viewer'; + message?: string; +} + +export interface TenantStats { + tenant_id: string; + total_members: number; + active_members: number; + total_predictions: number; + models_trained: number; + last_training_date?: string; + subscription_plan: string; + subscription_status: string; +} diff --git a/frontend/src/api/types/training.ts b/frontend/src/api/types/training.ts new file mode 100644 index 00000000..809f05b1 --- /dev/null +++ b/frontend/src/api/types/training.ts @@ -0,0 +1,126 @@ +// frontend/src/api/types/training.ts +/** + * Training Service Types + */ + +export interface TrainingJobRequest { + config?: TrainingJobConfig; + priority?: number; + schedule_time?: string; +} + +export interface SingleProductTrainingRequest { + product_name: string; + config?: TrainingJobConfig; + priority?: number; +} + +export interface TrainingJobConfig { + external_data?: ExternalDataConfig; + prophet_params?: Record; + data_filters?: Record; + validation_params?: Record; +} + +export interface ExternalDataConfig { + weather_enabled: boolean; + traffic_enabled: boolean; + weather_features: string[]; + traffic_features: string[]; +} + +export interface TrainingJobResponse { + job_id: string; + tenant_id: string; + status: TrainingJobStatus; + config: TrainingJobConfig; + priority: number; + created_at: string; + started_at?: string; + completed_at?: string; + error_message?: string; + progress?: TrainingJobProgress; + results?: TrainingJobResults; +} + +export type TrainingJobStatus = + | 'pending' + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface TrainingJobProgress { + current_step: string; + total_steps: number; + completed_steps: number; + percentage: number; + current_product?: string; + total_products?: number; + completed_products?: number; + estimated_completion?: string; + detailed_progress?: StepProgress[]; +} + +export interface StepProgress { + step_name: string; + status: 'pending' | 'running' | 'completed' | 'failed'; + progress_percentage: number; + start_time?: string; + end_time?: string; + duration_seconds?: number; +} + +export interface TrainingJobResults { + models_trained: number; + models_failed: number; + total_training_time_seconds: number; + average_model_accuracy?: number; + trained_models: TrainedModelInfo[]; + failed_products?: string[]; +} + +export interface TrainedModelInfo { + product_name: string; + model_id: string; + model_type: string; + accuracy_metrics: TrainingMetrics; + training_time_seconds: number; + data_points_used: number; + model_path: string; +} + +export interface TrainingMetrics { + mae: number; + mse: number; + rmse: number; + mape: number; + r2_score: number; + mean_actual: number; + mean_predicted: number; +} + +export interface ModelInfo { + model_id: string; + tenant_id: string; + product_name: string; + model_type: string; + model_path: string; + version: number; + training_samples: number; + features: string[]; + hyperparameters: Record; + training_metrics: Record; + is_active: boolean; + created_at: string; + data_period_start?: string; + data_period_end?: string; +} + +export interface ModelTrainingStats { + total_models: number; + active_models: number; + last_training_date?: string; + avg_training_time_minutes: number; + success_rate: number; +} \ No newline at end of file diff --git a/frontend/src/api/utils/error.ts b/frontend/src/api/utils/error.ts new file mode 100644 index 00000000..45d5411d --- /dev/null +++ b/frontend/src/api/utils/error.ts @@ -0,0 +1,50 @@ +// frontend/src/api/utils/error.ts +/** + * Error Handling Utilities + */ + +import type { ApiError } from '../types'; + +export class ApiErrorHandler { + static formatError(error: any): string { + if (error?.response?.data) { + const errorData = error.response.data as ApiError; + return errorData.detail || errorData.message || 'An error occurred'; + } + + if (error?.message) { + return error.message; + } + + return 'An unexpected error occurred'; + } + + static getErrorCode(error: any): string | undefined { + return error?.response?.data?.error_code; + } + + static isNetworkError(error: any): boolean { + return !error?.response && error?.message?.includes('Network'); + } + + static isAuthError(error: any): boolean { + const status = error?.response?.status; + return status === 401 || status === 403; + } + + static isValidationError(error: any): boolean { + return error?.response?.status === 422; + } + + static isServerError(error: any): boolean { + const status = error?.response?.status; + return status >= 500; + } + + static shouldRetry(error: any): boolean { + if (this.isNetworkError(error)) return true; + if (this.isServerError(error)) return true; + const status = error?.response?.status; + return status === 408 || status === 429; // Timeout or Rate limited + } +} \ No newline at end of file diff --git a/frontend/src/api/utils/index.ts b/frontend/src/api/utils/index.ts new file mode 100644 index 00000000..7a8945f5 --- /dev/null +++ b/frontend/src/api/utils/index.ts @@ -0,0 +1,9 @@ +// frontend/src/api/utils/index.ts +/** + * Main Utils Export + */ + +export { ApiErrorHandler } from './error'; +export { ResponseProcessor } from './response'; +export { RequestValidator } from './validation'; +export { DataTransformer } from './transform'; \ No newline at end of file diff --git a/frontend/src/api/utils/response.ts b/frontend/src/api/utils/response.ts new file mode 100644 index 00000000..39805b5c --- /dev/null +++ b/frontend/src/api/utils/response.ts @@ -0,0 +1,34 @@ +// frontend/src/api/utils/response.ts +/** + * Response Processing Utilities + */ + +import type { ApiResponse, PaginatedResponse } from '../types'; + +export class ResponseProcessor { + static extractData(response: ApiResponse): T { + return response.data; + } + + static extractPaginatedData(response: PaginatedResponse): { + data: T[]; + pagination: PaginatedResponse['pagination']; + } { + return { + data: response.data, + pagination: response.pagination, + }; + } + + static isSuccessResponse(response: ApiResponse): boolean { + return response.status === 'success' || response.status === 'ok'; + } + + static extractMessage(response: ApiResponse): string | undefined { + return response.message; + } + + static extractMeta(response: ApiResponse): any { + return response.meta; + } +} diff --git a/frontend/src/api/utils/transform.ts b/frontend/src/api/utils/transform.ts new file mode 100644 index 00000000..fc682823 --- /dev/null +++ b/frontend/src/api/utils/transform.ts @@ -0,0 +1,70 @@ +// frontend/src/api/utils/transform.ts +/** + * Data Transformation Utilities + */ + +export class DataTransformer { + static formatDate(date: string | Date): string { + const d = new Date(date); + return d.toLocaleDateString(); + } + + static formatDateTime(date: string | Date): string { + const d = new Date(date); + return d.toLocaleString(); + } + + static formatCurrency(amount: number, currency = 'EUR'): string { + return new Intl.NumberFormat('es-ES', { + style: 'currency', + currency, + }).format(amount); + } + + static formatPercentage(value: number, decimals = 1): string { + return `${(value * 100).toFixed(decimals)}%`; + } + + static formatFileSize(bytes: number): string { + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + if (bytes === 0) return '0 Bytes'; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${Math.round(bytes / Math.pow(1024, i) * 100) / 100} ${sizes[i]}`; + } + + static slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^\w ]+/g, '') + .replace(/ +/g, '-'); + } + + static truncate(text: string, length: number): string { + if (text.length <= length) return text; + return `${text.substring(0, length)}...`; + } + + static camelToKebab(str: string): string { + return str.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase(); + } + + static kebabToCamel(str: string): string { + return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); + } + + static deepClone(obj: T): T { + return JSON.parse(JSON.stringify(obj)); + } + + static removeEmpty(obj: Record): Record { + const cleaned: Record = {}; + + Object.keys(obj).forEach(key => { + if (obj[key] !== null && obj[key] !== undefined && obj[key] !== '') { + cleaned[key] = obj[key]; + } + }); + + return cleaned; + } +} diff --git a/frontend/src/api/utils/validation.ts b/frontend/src/api/utils/validation.ts new file mode 100644 index 00000000..3c252bc6 --- /dev/null +++ b/frontend/src/api/utils/validation.ts @@ -0,0 +1,72 @@ +// frontend/src/api/utils/validation.ts +/** + * Request Validation Utilities + */ + +export class RequestValidator { + static validateEmail(email: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + } + + static validatePassword(password: string): { + valid: boolean; + errors: string[]; + } { + const errors: string[] = []; + + if (password.length < 8) { + errors.push('Password must be at least 8 characters long'); + } + + if (!/(?=.*[a-z])/.test(password)) { + errors.push('Password must contain at least one lowercase letter'); + } + + if (!/(?=.*[A-Z])/.test(password)) { + errors.push('Password must contain at least one uppercase letter'); + } + + if (!/(?=.*\d)/.test(password)) { + errors.push('Password must contain at least one number'); + } + + return { + valid: errors.length === 0, + errors, + }; + } + + static validateFile(file: File, allowedTypes: string[], maxSize: number): { + valid: boolean; + errors: string[]; + } { + const errors: string[] = []; + + if (!allowedTypes.includes(file.type)) { + errors.push(`File type ${file.type} is not allowed`); + } + + if (file.size > maxSize) { + errors.push(`File size exceeds maximum of ${maxSize} bytes`); + } + + return { + valid: errors.length === 0, + errors, + }; + } + + static validatePhoneNumber(phone: string): boolean { + // Basic international phone number validation + const phoneRegex = /^\+?[1-9]\d{1,14}$/; + return phoneRegex.test(phone.replace(/\s/g, '')); + } + + static validateRequired(value: any, fieldName: string): string | null { + if (value === null || value === undefined || value === '') { + return `${fieldName} is required`; + } + return null; + } +} diff --git a/frontend/src/api/websocket/WebSocketManager.ts b/frontend/src/api/websocket/WebSocketManager.ts deleted file mode 100644 index d969911b..00000000 --- a/frontend/src/api/websocket/WebSocketManager.ts +++ /dev/null @@ -1,240 +0,0 @@ -// src/api/websocket/WebSocketManager.ts -import { tokenManager } from '../auth/tokenManager'; -import { EventEmitter } from 'events'; - -export interface WebSocketConfig { - url: string; - protocols?: string[]; - reconnect?: boolean; - reconnectInterval?: number; - maxReconnectAttempts?: number; - heartbeatInterval?: number; -} - -export interface WebSocketHandlers { - onOpen?: () => void; - onMessage?: (data: any) => void; - onError?: (error: Event) => void; - onClose?: (event: CloseEvent) => void; - onReconnect?: () => void; - onReconnectFailed?: () => void; -} - -interface WebSocketConnection { - ws: WebSocket; - config: WebSocketConfig; - handlers: WebSocketHandlers; - reconnectAttempts: number; - heartbeatTimer?: NodeJS.Timeout; - reconnectTimer?: NodeJS.Timeout; -} - -class WebSocketManager extends EventEmitter { - private static instance: WebSocketManager; - private connections: Map = new Map(); - private baseUrl: string; - - private constructor() { - super(); - this.baseUrl = this.getWebSocketBaseUrl(); - } - - static getInstance(): WebSocketManager { - if (!WebSocketManager.instance) { - WebSocketManager.instance = new WebSocketManager(); - } - return WebSocketManager.instance; - } - - async connect( - endpoint: string, - handlers: WebSocketHandlers, - config: Partial = {} - ): Promise { - // Get authentication token - const token = await tokenManager.getAccessToken(); - if (!token) { - throw new Error('Authentication required for WebSocket connection'); - } - - const fullConfig: WebSocketConfig = { - url: `${this.baseUrl}${endpoint}`, - reconnect: true, - reconnectInterval: 1000, - maxReconnectAttempts: 5, - heartbeatInterval: 30000, - ...config - }; - - // Add token to URL as query parameter - const urlWithAuth = `${fullConfig.url}?token=${token}`; - - const ws = new WebSocket(urlWithAuth, fullConfig.protocols); - - const connection: WebSocketConnection = { - ws, - config: fullConfig, - handlers, - reconnectAttempts: 0 - }; - - this.setupWebSocketHandlers(endpoint, connection); - this.connections.set(endpoint, connection); - - return ws; - } - - disconnect(endpoint: string): void { - const connection = this.connections.get(endpoint); - if (connection) { - this.cleanupConnection(connection); - this.connections.delete(endpoint); - } - } - - disconnectAll(): void { - this.connections.forEach((connection, endpoint) => { - this.cleanupConnection(connection); - }); - this.connections.clear(); - } - - send(endpoint: string, data: any): void { - const connection = this.connections.get(endpoint); - if (connection && connection.ws.readyState === WebSocket.OPEN) { - connection.ws.send(JSON.stringify(data)); - } else { - console.error(`WebSocket not connected for endpoint: ${endpoint}`); - } - } - - private setupWebSocketHandlers(endpoint: string, connection: WebSocketConnection): void { - const { ws, handlers, config } = connection; - - ws.onopen = () => { - console.log(`WebSocket connected: ${endpoint}`); - connection.reconnectAttempts = 0; - - // Start heartbeat - if (config.heartbeatInterval) { - this.startHeartbeat(connection); - } - - handlers.onOpen?.(); - this.emit('connected', endpoint); - }; - - ws.onmessage = (event: MessageEvent) => { - try { - const data = JSON.parse(event.data); - - // Handle heartbeat response - if (data.type === 'pong') { - return; - } - - handlers.onMessage?.(data); - this.emit('message', { endpoint, data }); - } catch (error) { - console.error('Failed to parse WebSocket message:', error); - } - }; - - ws.onerror = (error: Event) => { - console.error(`WebSocket error on ${endpoint}:`, error); - handlers.onError?.(error); - this.emit('error', { endpoint, error }); - }; - - ws.onclose = (event: CloseEvent) => { - console.log(`WebSocket closed: ${endpoint}`, event.code, event.reason); - - // Clear heartbeat - if (connection.heartbeatTimer) { - clearInterval(connection.heartbeatTimer); - } - - handlers.onClose?.(event); - this.emit('disconnected', endpoint); - - // Attempt reconnection - if (config.reconnect && connection.reconnectAttempts < config.maxReconnectAttempts!) { - this.scheduleReconnect(endpoint, connection); - } else if (connection.reconnectAttempts >= config.maxReconnectAttempts!) { - handlers.onReconnectFailed?.(); - this.emit('reconnectFailed', endpoint); - } - }; - } - - private scheduleReconnect(endpoint: string, connection: WebSocketConnection): void { - const { config, handlers, reconnectAttempts } = connection; - - // Exponential backoff - const delay = Math.min( - config.reconnectInterval! * Math.pow(2, reconnectAttempts), - 30000 // Max 30 seconds - ); - - console.log(`Scheduling reconnect for ${endpoint} in ${delay}ms`); - - connection.reconnectTimer = setTimeout(async () => { - connection.reconnectAttempts++; - - try { - await this.connect(endpoint, handlers, config); - handlers.onReconnect?.(); - this.emit('reconnected', endpoint); - } catch (error) { - console.error(`Reconnection failed for ${endpoint}:`, error); - } - }, delay); - } - - private startHeartbeat(connection: WebSocketConnection): void { - connection.heartbeatTimer = setInterval(() => { - if (connection.ws.readyState === WebSocket.OPEN) { - connection.ws.send(JSON.stringify({ type: 'ping' })); - } - }, connection.config.heartbeatInterval!); - } - - private cleanupConnection(connection: WebSocketConnection): void { - if (connection.heartbeatTimer) { - clearInterval(connection.heartbeatTimer); - } - - if (connection.reconnectTimer) { - clearTimeout(connection.reconnectTimer); - } - - if (connection.ws.readyState === WebSocket.OPEN) { - connection.ws.close(); - } - } - - private getWebSocketBaseUrl(): string { - if (typeof window !== 'undefined') { // Check if window is defined - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const host = process.env.NEXT_PUBLIC_WS_URL || window.location.host; - return `${protocol}//${host}/ws`; - } else { - // Provide a fallback for server-side or non-browser environments - // You might want to get this from environment variables or a config file - // depending on your setup. - return process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:3000/ws'; - } - } - - // Get connection status - getConnectionStatus(endpoint: string): number { - const connection = this.connections.get(endpoint); - return connection ? connection.ws.readyState : WebSocket.CLOSED; - } - - isConnected(endpoint: string): boolean { - return this.getConnectionStatus(endpoint) === WebSocket.OPEN; - } -} - -export const wsManager = WebSocketManager.getInstance(); \ No newline at end of file diff --git a/frontend/src/api/websocket/hooks.ts b/frontend/src/api/websocket/hooks.ts new file mode 100644 index 00000000..e305b343 --- /dev/null +++ b/frontend/src/api/websocket/hooks.ts @@ -0,0 +1,151 @@ +// frontend/src/api/websocket/hooks.ts +/** + * WebSocket React Hooks + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { WebSocketManager } from './manager'; +import type { + WebSocketConfig, + WebSocketMessage, + WebSocketHandlers, + WebSocketStatus, +} from './types'; + +export const useWebSocket = (config: WebSocketConfig) => { + const [status, setStatus] = useState('disconnected'); + const [lastMessage, setLastMessage] = useState(null); + const [error, setError] = useState(null); + const wsManagerRef = useRef(null); + + // Initialize WebSocket manager + useEffect(() => { + wsManagerRef.current = new WebSocketManager(config); + + const handlers: WebSocketHandlers = { + onOpen: () => { + setStatus('connected'); + setError(null); + }, + onMessage: (message) => { + setLastMessage(message); + }, + onError: (error) => { + setError('WebSocket connection error'); + setStatus('failed'); + }, + onClose: () => { + setStatus('disconnected'); + }, + onReconnect: () => { + setStatus('reconnecting'); + setError(null); + }, + onReconnectFailed: () => { + setStatus('failed'); + setError('Failed to reconnect'); + }, + }; + + wsManagerRef.current.setHandlers(handlers); + + return () => { + wsManagerRef.current?.disconnect(); + }; + }, [config.url]); + + const connect = useCallback(async () => { + try { + setError(null); + await wsManagerRef.current?.connect(); + } catch (error) { + setError(error instanceof Error ? error.message : 'Connection failed'); + } + }, []); + + const disconnect = useCallback(() => { + wsManagerRef.current?.disconnect(); + }, []); + + const sendMessage = useCallback((message: Omit) => { + return wsManagerRef.current?.send(message) ?? false; + }, []); + + const addMessageHandler = useCallback((handler: (message: WebSocketMessage) => void) => { + const currentHandlers = wsManagerRef.current?.['handlers'] || {}; + wsManagerRef.current?.setHandlers({ + ...currentHandlers, + onMessage: (message) => { + setLastMessage(message); + handler(message); + }, + }); + }, []); + + return { + status, + lastMessage, + error, + connect, + disconnect, + sendMessage, + addMessageHandler, + isConnected: status === 'connected', + }; +}; + +// Hook for training job updates +export const useTrainingWebSocket = (tenantId: string) => { + const config: WebSocketConfig = { + url: `ws://localhost:8000/api/v1/ws/training/${tenantId}`, + reconnect: true, + }; + + const [jobUpdates, setJobUpdates] = useState([]); + + const { status, connect, disconnect, addMessageHandler, isConnected } = useWebSocket(config); + + useEffect(() => { + addMessageHandler((message) => { + if (message.type === 'training_progress' || message.type === 'training_completed') { + setJobUpdates(prev => [message.data, ...prev.slice(0, 99)]); // Keep last 100 updates + } + }); + }, [addMessageHandler]); + + return { + status, + jobUpdates, + connect, + disconnect, + isConnected, + }; +}; + +// Hook for forecast alerts +export const useForecastWebSocket = (tenantId: string) => { + const config: WebSocketConfig = { + url: `ws://localhost:8000/api/v1/ws/forecasts/${tenantId}`, + reconnect: true, + }; + + const [alerts, setAlerts] = useState([]); + + const { status, connect, disconnect, addMessageHandler, isConnected } = useWebSocket(config); + + useEffect(() => { + addMessageHandler((message) => { + if (message.type === 'forecast_alert') { + setAlerts(prev => [message.data, ...prev]); + } + }); + }, [addMessageHandler]); + + return { + status, + alerts, + connect, + disconnect, + isConnected, + }; +}; \ No newline at end of file diff --git a/frontend/src/api/websocket/index.ts b/frontend/src/api/websocket/index.ts new file mode 100644 index 00000000..b351bfd3 --- /dev/null +++ b/frontend/src/api/websocket/index.ts @@ -0,0 +1,18 @@ +// frontend/src/api/websocket/index.ts +/** + * Main WebSocket Export + */ + +export { WebSocketManager } from './manager'; +export { + useWebSocket, + useTrainingWebSocket, + useForecastWebSocket, +} from './hooks'; +export type { + WebSocketConfig, + WebSocketMessage, + WebSocketHandlers, + WebSocketStatus, + WebSocketMetrics, +} from './types'; \ No newline at end of file diff --git a/frontend/src/api/websocket/manager.ts b/frontend/src/api/websocket/manager.ts new file mode 100644 index 00000000..2f59420d --- /dev/null +++ b/frontend/src/api/websocket/manager.ts @@ -0,0 +1,265 @@ +// frontend/src/api/websocket/manager.ts +/** + * WebSocket Manager + * Handles WebSocket connections with auto-reconnection and heartbeat + */ + +import { apiConfig } from '../client/config'; +import type { + WebSocketConfig, + WebSocketMessage, + WebSocketHandlers, + WebSocketStatus, + WebSocketMetrics, +} from './types'; + +export class WebSocketManager { + private ws: WebSocket | null = null; + private config: WebSocketConfig; + private handlers: WebSocketHandlers = {}; + private status: WebSocketStatus = 'disconnected'; + private reconnectTimer: NodeJS.Timeout | null = null; + private heartbeatTimer: NodeJS.Timeout | null = null; + private reconnectAttempts = 0; + private metrics: WebSocketMetrics = { + reconnectAttempts: 0, + messagesReceived: 0, + messagesSent: 0, + lastActivity: new Date(), + }; + + constructor(config: WebSocketConfig) { + this.config = { + reconnect: true, + reconnectInterval: 5000, + maxReconnectAttempts: 10, + heartbeatInterval: 30000, + enableLogging: apiConfig.enableLogging, + ...config, + }; + } + + /** + * Connect to WebSocket + */ + connect(): Promise { + return new Promise((resolve, reject) => { + try { + this.status = 'connecting'; + this.log('Connecting to WebSocket:', this.config.url); + + // Add authentication token to URL if available + const token = localStorage.getItem('auth_token'); + const wsUrl = token + ? `${this.config.url}?token=${encodeURIComponent(token)}` + : this.config.url; + + this.ws = new WebSocket(wsUrl, this.config.protocols); + + this.ws.onopen = (event) => { + this.status = 'connected'; + this.reconnectAttempts = 0; + this.metrics.connectionTime = Date.now(); + this.metrics.lastActivity = new Date(); + + this.log('WebSocket connected'); + this.startHeartbeat(); + + this.handlers.onOpen?.(event); + resolve(); + }; + + this.ws.onmessage = (event) => { + this.metrics.messagesReceived++; + this.metrics.lastActivity = new Date(); + + try { + const message: WebSocketMessage = JSON.parse(event.data); + this.log('WebSocket message received:', message.type); + this.handlers.onMessage?.(message); + } catch (error) { + this.log('Failed to parse WebSocket message:', error); + } + }; + + this.ws.onerror = (error) => { + this.log('WebSocket error:', error); + this.handlers.onError?.(error); + + if (this.status === 'connecting') { + reject(new Error('WebSocket connection failed')); + } + }; + + this.ws.onclose = (event) => { + this.log('WebSocket closed:', event.code, event.reason); + this.status = 'disconnected'; + this.stopHeartbeat(); + + this.handlers.onClose?.(event); + + // Auto-reconnect if enabled and not manually closed + if (this.config.reconnect && event.code !== 1000) { + this.scheduleReconnect(); + } + }; + + } catch (error) { + this.status = 'failed'; + reject(error); + } + }); + } + + /** + * Disconnect from WebSocket + */ + disconnect(): void { + this.config.reconnect = false; // Disable auto-reconnect + this.clearReconnectTimer(); + this.stopHeartbeat(); + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.close(1000, 'Manual disconnect'); + } + + this.ws = null; + this.status = 'disconnected'; + } + + /** + * Send message through WebSocket + */ + send(message: Omit): boolean { + if (!this.isConnected()) { + this.log('Cannot send message: WebSocket not connected'); + return false; + } + + try { + const fullMessage: WebSocketMessage = { + ...message, + timestamp: new Date().toISOString(), + id: this.generateMessageId(), + }; + + this.ws!.send(JSON.stringify(fullMessage)); + this.metrics.messagesSent++; + this.metrics.lastActivity = new Date(); + + this.log('WebSocket message sent:', message.type); + return true; + } catch (error) { + this.log('Failed to send WebSocket message:', error); + return false; + } + } + + /** + * Set event handlers + */ + setHandlers(handlers: WebSocketHandlers): void { + this.handlers = { ...this.handlers, ...handlers }; + } + + /** + * Get connection status + */ + getStatus(): WebSocketStatus { + return this.status; + } + + /** + * Check if connected + */ + isConnected(): boolean { + return this.ws?.readyState === WebSocket.OPEN; + } + + /** + * Get connection metrics + */ + getMetrics(): WebSocketMetrics { + return { ...this.metrics }; + } + + /** + * Schedule reconnection attempt + */ + private scheduleReconnect(): void { + if (this.reconnectAttempts >= this.config.maxReconnectAttempts!) { + this.status = 'failed'; + this.log('Max reconnection attempts reached'); + this.handlers.onReconnectFailed?.(); + return; + } + + this.status = 'reconnecting'; + this.reconnectAttempts++; + this.metrics.reconnectAttempts++; + + this.log(`Scheduling reconnection attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts}`); + + this.reconnectTimer = setTimeout(async () => { + try { + this.handlers.onReconnect?.(); + await this.connect(); + } catch (error) { + this.log('Reconnection failed:', error); + this.scheduleReconnect(); + } + }, this.config.reconnectInterval); + } + + /** + * Clear reconnection timer + */ + private clearReconnectTimer(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + + /** + * Start heartbeat mechanism + */ + private startHeartbeat(): void { + if (!this.config.heartbeatInterval) return; + + this.heartbeatTimer = setInterval(() => { + if (this.isConnected()) { + this.send({ + type: 'ping', + data: { timestamp: Date.now() }, + }); + } + }, this.config.heartbeatInterval); + } + + /** + * Stop heartbeat mechanism + */ + private stopHeartbeat(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + } + + /** + * Generate unique message ID + */ + private generateMessageId(): string { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + } + + /** + * Log message if logging enabled + */ + private log(...args: any[]): void { + if (this.config.enableLogging) { + console.log('[WebSocket]', ...args); + } + } +} diff --git a/frontend/src/api/websocket/types.ts b/frontend/src/api/websocket/types.ts new file mode 100644 index 00000000..fe536c39 --- /dev/null +++ b/frontend/src/api/websocket/types.ts @@ -0,0 +1,45 @@ +// frontend/src/api/websocket/types.ts +/** + * WebSocket Types + */ + +export interface WebSocketConfig { + url: string; + protocols?: string[]; + reconnect?: boolean; + reconnectInterval?: number; + maxReconnectAttempts?: number; + heartbeatInterval?: number; + enableLogging?: boolean; +} + +export interface WebSocketMessage { + type: string; + data: any; + timestamp: string; + id?: string; +} + +export interface WebSocketHandlers { + onOpen?: (event: Event) => void; + onMessage?: (message: WebSocketMessage) => void; + onError?: (error: Event) => void; + onClose?: (event: CloseEvent) => void; + onReconnect?: () => void; + onReconnectFailed?: () => void; +} + +export type WebSocketStatus = + | 'connecting' + | 'connected' + | 'disconnected' + | 'reconnecting' + | 'failed'; + +export interface WebSocketMetrics { + connectionTime?: number; + reconnectAttempts: number; + messagesReceived: number; + messagesSent: number; + lastActivity: Date; +} \ No newline at end of file