Add new Frontend API folder

This commit is contained in:
Urtzi Alfaro
2025-08-03 17:48:34 +02:00
parent 935f45a283
commit 03e9dc6469
50 changed files with 4737 additions and 3321 deletions

View File

@@ -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<void> | 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<void> {
// 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<void> {
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<string | null> {
// 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<void> {
// 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<void> {
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<TokenPayload>(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();

View File

@@ -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<string, any>;
timeout?: number;
retry?: boolean;
retryAttempts?: number;
}
type Interceptor<T> = (value: T) => T | Promise<T>;
class ApiClient {
private config: ApiConfig;
private requestInterceptors: Interceptor<RequestConfig>[] = [];
private responseInterceptors: {
fulfilled: Interceptor<Response>;
rejected: Interceptor<any>;
}[] = [];
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<RequestConfig>): void {
this.requestInterceptors.push(interceptor);
}
addResponseInterceptor(
fulfilled: Interceptor<Response>,
rejected: Interceptor<any>
): void {
this.responseInterceptors.push({ fulfilled, rejected });
}
private async applyRequestInterceptors(config: RequestConfig): Promise<RequestConfig> {
let processedConfig = config;
for (const interceptor of this.requestInterceptors) {
processedConfig = await interceptor(processedConfig);
}
return processedConfig;
}
private async applyResponseInterceptors(
response: Response | Promise<Response>
): Promise<Response> {
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, any>): 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<never> {
return new Promise((_, reject) => {
setTimeout(() => {
reject(new Error('Request timeout'));
}, timeout);
});
}
private async executeWithRetry(
fn: () => Promise<Response>,
attempts: number,
delay: number
): Promise<Response> {
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<T = any>(endpoint: string, config: RequestConfig = {}): Promise<T> {
const processedConfig = await this.applyRequestInterceptors(config);
const url = this.buildURL(endpoint, processedConfig.params);
const timeout = processedConfig.timeout || this.config.timeout!;
const makeRequest = async (): Promise<Response> => {
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<T = any>(endpoint: string, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, { ...config, method: 'GET' });
}
post<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, {
...config,
method: 'POST',
body: data ? JSON.stringify(data) : undefined
});
}
put<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, {
...config,
method: 'PUT',
body: data ? JSON.stringify(data) : undefined
});
}
patch<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, {
...config,
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined
});
}
delete<T = any>(endpoint: string, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, { ...config, method: 'DELETE' });
}
// File upload
upload<T = any>(
endpoint: string,
file: File,
additionalData?: Record<string, any>,
config?: RequestConfig
): Promise<T> {
const formData = new FormData();
formData.append('file', file);
if (additionalData) {
Object.entries(additionalData).forEach(([key, value]) => {
formData.append(key, value);
});
}
return this.request<T>(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'
});

View File

@@ -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<T>(fn: () => Promise<T>): Promise<T> {
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;
}
}

View File

@@ -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',
};

View File

@@ -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<string, CacheEntry>();
private pendingRequests = new Map<string, Promise<any>>();
private requestInterceptors: RequestInterceptor[] = [];
private responseInterceptors: ResponseInterceptor[] = [];
private metrics: RequestMetrics[] = [];
constructor(baseURL?: string) {
this.baseURL = baseURL || apiConfig.baseURL;
}
/**
* Add request interceptor
*/
addRequestInterceptor(interceptor: RequestInterceptor): void {
this.requestInterceptors.push(interceptor);
}
/**
* Add response interceptor
*/
addResponseInterceptor(interceptor: ResponseInterceptor): void {
this.responseInterceptors.push(interceptor);
}
/**
* Generate cache key for request
*/
private getCacheKey(url: string, config?: RequestConfig): string {
const method = config?.method || 'GET';
const params = config?.params ? JSON.stringify(config.params) : '';
return `${method}:${url}:${params}`;
}
/**
* Check if response is cached and valid
*/
private getCachedResponse<T>(key: string): T | null {
if (!featureFlags.enableRequestDeduplication && !apiConfig.enableCaching) {
return null;
}
const cached = this.cache.get(key);
if (!cached) return null;
const now = Date.now();
if (now - cached.timestamp > cached.ttl) {
this.cache.delete(key);
return null;
}
return cached.data;
}
/**
* Cache response data
*/
private setCachedResponse<T>(key: string, data: T, ttl?: number): void {
if (!apiConfig.enableCaching) return;
const cacheTTL = ttl || cacheConfig.defaultTTL;
this.cache.set(key, {
data,
timestamp: Date.now(),
ttl: cacheTTL,
key,
});
// Cleanup old cache entries if cache is full
if (this.cache.size > cacheConfig.maxSize) {
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
}
/**
* Apply request interceptors
*/
private async applyRequestInterceptors(config: RequestConfig): Promise<RequestConfig> {
let modifiedConfig = { ...config };
for (const interceptor of this.requestInterceptors) {
if (interceptor.onRequest) {
try {
modifiedConfig = await interceptor.onRequest(modifiedConfig);
} catch (error) {
if (interceptor.onRequestError) {
await interceptor.onRequestError(error);
}
throw error;
}
}
}
return modifiedConfig;
}
/**
* Apply response interceptors
*/
private async applyResponseInterceptors<T>(response: ApiResponse<T>): Promise<ApiResponse<T>> {
let modifiedResponse = { ...response };
for (const interceptor of this.responseInterceptors) {
if (interceptor.onResponse) {
try {
modifiedResponse = await interceptor.onResponse(modifiedResponse);
} catch (error) {
if (interceptor.onResponseError) {
await interceptor.onResponseError(error);
}
throw error;
}
}
}
return modifiedResponse;
}
/**
* Retry failed requests with exponential backoff
*/
private async retryRequest<T>(
requestFn: () => Promise<T>,
attempts: number = retryConfig.attempts
): Promise<T> {
try {
return await requestFn();
} catch (error) {
if (attempts <= 0 || !retryConfig.retryCondition(error)) {
throw error;
}
const delay = retryConfig.delay * Math.pow(retryConfig.backoff, retryConfig.attempts - attempts);
await new Promise(resolve => setTimeout(resolve, delay));
return this.retryRequest(requestFn, attempts - 1);
}
}
/**
* Record request metrics
*/
private recordMetrics(metrics: Partial<RequestMetrics>): void {
if (!featureFlags.enableMetrics) return;
const completeMetrics: RequestMetrics = {
url: '',
method: 'GET',
duration: 0,
status: 0,
size: 0,
timestamp: Date.now(),
cached: false,
retries: 0,
...metrics,
};
this.metrics.push(completeMetrics);
// Keep only recent metrics (last 1000 requests)
if (this.metrics.length > 1000) {
this.metrics = this.metrics.slice(-1000);
}
}
/**
* Core request method with all features
*/
async request<T = any>(endpoint: string, config: RequestConfig = {}): Promise<T> {
const startTime = Date.now();
const url = `${this.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<T>(cacheKey);
if (cached) {
this.recordMetrics({
url: endpoint,
method,
duration: Date.now() - startTime,
status: 200,
cached: true,
});
return cached;
}
}
// Request deduplication for concurrent requests
if (featureFlags.enableRequestDeduplication && method === 'GET') {
const pendingRequest = this.pendingRequests.get(cacheKey);
if (pendingRequest) {
return pendingRequest;
}
}
// Create request promise
const requestPromise = this.retryRequest(async () => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...modifiedConfig.headers,
};
const fetchConfig: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(modifiedConfig.timeout || apiConfig.timeout),
};
// Add body for non-GET requests
if (method !== 'GET' && modifiedConfig.body) {
if (modifiedConfig.body instanceof FormData) {
// Remove Content-Type for FormData (let browser set it with boundary)
delete headers['Content-Type'];
fetchConfig.body = modifiedConfig.body;
} else {
fetchConfig.body = typeof modifiedConfig.body === 'string'
? modifiedConfig.body
: JSON.stringify(modifiedConfig.body);
}
}
// Add query parameters
const urlWithParams = new URL(url);
if (modifiedConfig.params) {
Object.entries(modifiedConfig.params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
urlWithParams.searchParams.append(key, String(value));
}
});
}
const response = await fetch(urlWithParams.toString(), fetchConfig);
if (!response.ok) {
const errorText = await response.text();
let errorData: ApiError;
try {
errorData = JSON.parse(errorText);
} catch {
errorData = {
message: `HTTP ${response.status}: ${response.statusText}`,
detail: errorText,
code: `HTTP_${response.status}`,
};
}
const error = new Error(errorData.message || 'Request failed');
(error as any).response = { status: response.status, data: errorData };
throw error;
}
const responseData = await response.json();
// 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<T = any>(endpoint: string, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, { ...config, method: 'GET' });
}
async post<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, {
...config,
method: 'POST',
body: data
});
}
async put<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, {
...config,
method: 'PUT',
body: data
});
}
async patch<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, {
...config,
method: 'PATCH',
body: data
});
}
async delete<T = any>(endpoint: string, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, { ...config, method: 'DELETE' });
}
/**
* File upload with progress tracking
*/
async upload<T = any>(
endpoint: string,
file: File,
additionalData?: Record<string, any>,
config?: UploadConfig
): Promise<T> {
const formData = new FormData();
formData.append('file', file);
if (additionalData) {
Object.entries(additionalData).forEach(([key, value]) => {
formData.append(key, String(value));
});
}
// For file uploads, we need to use XMLHttpRequest for progress tracking
if (config?.onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable && config.onProgress) {
const progress: UploadProgress = {
loaded: event.loaded,
total: event.total,
percentage: Math.round((event.loaded / event.total) * 100),
};
config.onProgress(progress);
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const result = JSON.parse(xhr.responseText);
resolve(result);
} catch {
resolve(xhr.responseText as any);
}
} else {
reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`));
}
});
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'));
});
xhr.open('POST', `${this.baseURL}${endpoint}`);
// Add headers (excluding Content-Type for FormData)
if (config?.headers) {
Object.entries(config.headers).forEach(([key, value]) => {
if (key.toLowerCase() !== 'content-type') {
xhr.setRequestHeader(key, value);
}
});
}
xhr.send(formData);
});
}
// Fallback to regular request for uploads without progress
return this.request<T>(endpoint, {
...config,
method: 'POST',
body: formData,
});
}
/**
* Clear cache
*/
clearCache(pattern?: string): void {
if (pattern) {
// Clear cache entries matching pattern
const regex = new RegExp(pattern);
Array.from(this.cache.keys())
.filter(key => regex.test(key))
.forEach(key => this.cache.delete(key));
} else {
// Clear all cache
this.cache.clear();
}
}
/**
* Get client metrics
*/
getMetrics() {
if (!featureFlags.enableMetrics) {
return {
totalRequests: 0,
successfulRequests: 0,
failedRequests: 0,
averageResponseTime: 0,
cacheHitRate: 0,
errorRate: 0,
};
}
const total = this.metrics.length;
const successful = this.metrics.filter(m => m.status >= 200 && m.status < 300).length;
const cached = this.metrics.filter(m => m.cached).length;
const averageTime = total > 0
? this.metrics.reduce((sum, m) => sum + m.duration, 0) / total
: 0;
return {
totalRequests: total,
successfulRequests: successful,
failedRequests: total - successful,
averageResponseTime: Math.round(averageTime),
cacheHitRate: total > 0 ? Math.round((cached / total) * 100) : 0,
errorRate: total > 0 ? Math.round(((total - successful) / total) * 100) : 0,
};
}
}
// Default API client instance
export const apiClient = new ApiClient();

View File

@@ -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 <T>(response: ApiResponse<T>) => {
const requestId = response.meta?.requestId || 'unknown';
console.group(`✅ API Response [${requestId}]`);
console.log('Status:', response.status);
console.log('Data:', response.data);
if (response.message) {
console.log('Message:', response.message);
}
console.groupEnd();
return response;
},
onResponseError: async (error: any) => {
const requestId = error?.config?.headers?.[`X-Request-ID`] || 'unknown';
console.group(`❌ API Error [${requestId}]`);
console.error('Status:', error?.response?.status);
console.error('Error:', ApiErrorHandler.formatError(error));
console.error('Full Error:', error);
console.groupEnd();
throw error;
},
});
}
}
/**
* Tenant Context Interceptor
* Automatically adds tenant context to tenant-scoped requests
*/
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 <T>(response: ApiResponse<T>) => {
const startTime = response.metadata?.startTime;
if (startTime) {
const duration = Date.now() - startTime;
this.recordMetric({
url: response.metadata?.url || 'unknown',
method: response.metadata?.method || 'unknown',
duration,
status: 200,
timestamp: Date.now(),
});
}
return response;
},
onResponseError: async (error: any) => {
const startTime = error.config?.metadata?.startTime;
if (startTime) {
const duration = Date.now() - startTime;
this.recordMetric({
url: error.config?.url || 'unknown',
method: error.config?.method || 'unknown',
duration,
status: error?.response?.status || 0,
timestamp: Date.now(),
});
}
throw error;
},
});
}
private static recordMetric(metric: any) {
this.metrics.push(metric);
// Keep only last 1000 metrics
if (this.metrics.length > 1000) {
this.metrics = this.metrics.slice(-1000);
}
}
static getMetrics() {
return [...this.metrics];
}
static getAverageResponseTime(): number {
if (this.metrics.length === 0) return 0;
const total = this.metrics.reduce((sum, metric) => sum + metric.duration, 0);
return Math.round(total / this.metrics.length);
}
static getErrorRate(): number {
if (this.metrics.length === 0) return 0;
const errorCount = this.metrics.filter(metric => metric.status >= 400).length;
return Math.round((errorCount / this.metrics.length) * 100);
}
}
/**
* Setup all interceptors
*/
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,
};

View File

@@ -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<string, string>;
params?: Record<string, any>;
timeout?: number;
retries?: number;
cache?: boolean;
cacheTTL?: number;
optimistic?: boolean;
background?: boolean;
}
export interface ApiResponse<T = any> {
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<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
}
export interface UploadProgress {
loaded: number;
total: number;
percentage: number;
}
export interface UploadConfig extends RequestConfig {
onProgress?: (progress: UploadProgress) => void;
maxFileSize?: number;
allowedTypes?: string[];
}
// Request/Response interceptor types
export interface RequestInterceptor {
onRequest?: (config: RequestConfig) => RequestConfig | Promise<RequestConfig>;
onRequestError?: (error: any) => any;
}
export interface ResponseInterceptor {
onResponse?: <T>(response: ApiResponse<T>) => ApiResponse<T> | Promise<ApiResponse<T>>;
onResponseError?: (error: any) => any;
}
// Cache types
export interface CacheEntry<T = any> {
data: T;
timestamp: number;
ttl: number;
key: string;
}
export interface CacheStrategy {
key: (url: string, params?: any) => string;
ttl: number;
enabled: boolean;
}
// Metrics types
export interface RequestMetrics {
url: string;
method: string;
duration: number;
status: number;
size: number;
timestamp: number;
cached: boolean;
retries: number;
}
export interface ClientMetrics {
totalRequests: number;
successfulRequests: number;
failedRequests: number;
averageResponseTime: number;
cacheHitRate: number;
errorRate: number;
}

View File

@@ -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,
};
};

View File

@@ -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<UserResponse | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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<void> => {
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<void> => {
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<void> => {
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<UserResponse>): Promise<void> => {
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<void> => {
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<void> => {
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 };
};

View File

@@ -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<SalesData[]>([]);
const [dashboardStats, setDashboardStats] = useState<DashboardStats | null>(null);
const [recentActivity, setRecentActivity] = useState<ActivityItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState<number>(0);
const uploadSalesHistory = useCallback(async (
tenantId: string,
file: File,
additionalData?: Record<string, any>
): Promise<SalesImportResult> => {
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<SalesImportResult> => {
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<SalesData[]> => {
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<DashboardStats> => {
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<ActivityItem[]> => {
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<void> => {
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),
};
};

View File

@@ -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<ForecastResponse[]>([]);
const [batchForecasts, setBatchForecasts] = useState<BatchForecastResponse[]>([]);
const [quickForecasts, setQuickForecasts] = useState<QuickForecast[]>([]);
const [alerts, setAlerts] = useState<ForecastAlert[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createSingleForecast = useCallback(async (
tenantId: string,
request: SingleForecastRequest
): Promise<ForecastResponse[]> => {
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<BatchForecastResponse> => {
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<ForecastResponse[]> => {
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<BatchForecastResponse> => {
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<QuickForecast[]> => {
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<ForecastAlert[]> => {
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<void> => {
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<void> => {
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),
};
};

View File

@@ -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<NotificationResponse[]>([]);
const [templates, setTemplates] = useState<NotificationTemplate[]>([]);
const [stats, setStats] = useState<NotificationStats | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const sendNotification = useCallback(async (
tenantId: string,
notification: NotificationCreate
): Promise<NotificationResponse> => {
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<void> => {
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<NotificationResponse[]> => {
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<NotificationTemplate[]> => {
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<NotificationTemplate, 'id' | 'tenant_id' | 'created_at' | 'updated_at'>
): Promise<NotificationTemplate> => {
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<NotificationStats> => {
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),
};
};

View File

@@ -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<NodeJS.Timeout>();
const warningRef = useRef<NodeJS.Timeout>();
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 };
};

View File

@@ -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<TenantInfo[]>([]);
const [currentTenant, setCurrentTenant] = useState<TenantInfo | null>(null);
const [members, setMembers] = useState<TenantMember[]>([]);
const [stats, setStats] = useState<TenantStats | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createTenant = useCallback(async (data: TenantCreate): Promise<TenantInfo> => {
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<TenantInfo> => {
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<TenantInfo> => {
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<TenantInfo[]> => {
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<TenantMember[]> => {
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<void> => {
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<void> => {
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<void> => {
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<TenantStats> => {
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),
};
};

View File

@@ -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<TrainingJobResponse[]>([]);
const [currentJob, setCurrentJob] = useState<TrainingJobResponse | null>(null);
const [models, setModels] = useState<ModelInfo[]>([]);
const [stats, setStats] = useState<ModelTrainingStats | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const startTrainingJob = useCallback(async (
tenantId: string,
request: TrainingJobRequest
): Promise<TrainingJobResponse> => {
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<TrainingJobResponse> => {
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<TrainingJobResponse> => {
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<void> => {
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<TrainingJobResponse[]> => {
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<ModelInfo[]> => {
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<ModelTrainingStats> => {
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),
};
};

View File

@@ -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<string, any>;
}
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<TrainingProgress | null>(null);
const [error, setError] = useState<string | null>(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
};
};

View File

@@ -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<WebSocket | null>(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)
};
};

71
frontend/src/api/index.ts Normal file
View File

@@ -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;

View File

@@ -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<LoginResponse> {
return apiClient.post(`${this.baseEndpoint}/login`, credentials);
}
/**
* User Logout
*/
async logout(): Promise<LogoutResponse> {
return apiClient.post(`${this.baseEndpoint}/logout`);
}
/**
* Get Current User Profile
*/
async getCurrentUser(): Promise<UserResponse> {
return apiClient.get(`/users/me`);
}
/**
* Update User Profile
*/
async updateProfile(data: Partial<UserResponse>): Promise<UserResponse> {
return apiClient.put(`/users/me`, data);
}
/**
* Verify Token
*/
async verifyToken(token: string): Promise<TokenVerification> {
return apiClient.post(`${this.baseEndpoint}/verify-token`, { token });
}
/**
* Refresh Access Token
*/
async refreshToken(refreshToken: string): Promise<LoginResponse> {
return apiClient.post(`${this.baseEndpoint}/refresh`, {
refresh_token: refreshToken,
});
}
/**
* Request Password Reset
*/
async requestPasswordReset(data: PasswordResetRequest): Promise<PasswordResetResponse> {
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();

View File

@@ -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<UserProfile> {
const response = await apiClient.get<UserProfile>('/users/me');
return response;
}
async register(userData: RegisterRequest): Promise<TokenResponse> {
try {
// ✅ FIX: apiClient returns direct response, not wrapped in ApiResponse
const response = await apiClient.post<TokenResponse>(
'/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<TokenResponse> {
try {
// ✅ FIX: apiClient returns direct response, not wrapped in ApiResponse
const response = await apiClient.post<TokenResponse>(
'/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<TokenResponse> {
const response = await apiClient.post<TokenResponse>(
'/api/v1/auth/refresh',
{ refresh_token: refreshToken }
);
return response;
}
/**
* Get current user profile (alias for getCurrentUser)
*/
async getProfile(): Promise<UserProfile> {
return this.getCurrentUser();
}
/**
* Update user profile
*/
async updateProfile(updates: Partial<UserProfile>): Promise<UserProfile> {
const response = await apiClient.put<UserProfile>(
'/api/v1/users/me',
updates
);
return response;
}
/**
* Change password
*/
async changePassword(
currentPassword: string,
newPassword: string
): Promise<void> {
await apiClient.post('/auth/change-password', {
current_password: currentPassword,
new_password: newPassword,
});
}
/**
* Request password reset
*/
async requestPasswordReset(email: string): Promise<void> {
await apiClient.post('/api/v1/auth/reset-password', { email });
}
/**
* Confirm password reset
*/
async confirmPasswordReset(
token: string,
newPassword: string
): Promise<void> {
await apiClient.post('/api/v1/auth/confirm-reset', {
token,
new_password: newPassword,
});
}
/**
* Verify email
*/
async verifyEmail(token: string): Promise<void> {
await apiClient.post('/api/v1/auth/verify-email', { token });
}
/**
* Resend verification email
*/
async resendVerification(): Promise<void> {
await apiClient.post('/api/v1/auth/resend-verification');
}
/**
* Logout (invalidate tokens)
*/
async logout(): Promise<void> {
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<string[]> {
const response = await apiClient.get<ApiResponse<string[]>>('/auth/permissions');
return response.data!;
}
}
export const authService = new AuthService();

View File

@@ -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<string, any>
): Promise<SalesImportResult> {
// 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<SalesImportResult> {
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<PaginatedResponse<SalesData>> {
return apiClient.get(`/tenants/${tenantId}/sales`, { params: query });
}
/**
* Get Single Sales Record
*/
async getSalesRecord(tenantId: string, recordId: string): Promise<SalesData> {
return apiClient.get(`/tenants/${tenantId}/sales/${recordId}`);
}
/**
* Update Sales Record
*/
async updateSalesRecord(
tenantId: string,
recordId: string,
data: Partial<SalesData>
): Promise<SalesData> {
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<DashboardStats> {
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<any> {
return apiClient.get(`/tenants/${tenantId}/analytics`, { params });
}
/**
* Export Sales Data
*/
async exportSalesData(
tenantId: string,
format: 'csv' | 'excel' | 'json',
query?: SalesDataQuery
): Promise<Blob> {
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<ActivityItem[]> {
return apiClient.get(`/tenants/${tenantId}/activity`, {
params: { limit },
});
}
}
export const dataService = new DataService();

View File

@@ -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<string, any>
): Promise<UploadResponse> {
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<ApiResponse<any>>(
`/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<UploadResponse> { // 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<ApiResponse<any>>(
`/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<DataValidation> { // 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<ApiResponse<DataValidation>>(
`/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<string> {
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<DashboardStats> {
const response = await apiClient.get<ApiResponse<DashboardStats>>(
'/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<ApiResponse<{
records: SalesRecord[];
total: number;
page: number;
pages: number;
}>>(`/api/v1/tenants/${tenantId}/sales`, { params }); // Use tenantId in path
return response.data!;
}
/**
* Create single sales record
*/
async createSalesRecord(tenantId: string, record: CreateSalesRequest): Promise<SalesRecord> { // Add tenantId
const response = await apiClient.post<ApiResponse<SalesRecord>>(
`/api/v1/tenants/${tenantId}/sales`, // Use tenantId in path
record
);
return response.data!;
}
/**
* Update sales record
*/
async updateSalesRecord(tenantId: string, id: string, record: Partial<CreateSalesRequest>): Promise<SalesRecord> { // Add tenantId
const response = await apiClient.put<ApiResponse<SalesRecord>>(
`/api/v1/tenants/${tenantId}/sales/${id}`, // Use tenantId in path
record
);
return response.data!;
}
/**
* Delete sales record
*/
async deleteSalesRecord(tenantId: string, id: string): Promise<void> { // 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<ApiResponse<{
data: WeatherData[];
total: number;
page: number;
pages: number;
}>>('/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<ApiResponse<{
data: TrafficData[];
total: number;
page: number;
pages: number;
}>>('/api/v1/data/traffic', { params });
return response.data!;
}
}
// ✅ CRITICAL FIX: Export the instance that index.ts expects
export const dataService = new DataService();

View File

@@ -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<ForecastResponse[]> {
return apiClient.post(
`/tenants/${tenantId}/forecasts/single`,
request,
{
timeout: RequestTimeouts.MEDIUM,
}
);
}
/**
* Create Batch Forecast
*/
async createBatchForecast(
tenantId: string,
request: BatchForecastRequest
): Promise<BatchForecastResponse> {
return apiClient.post(
`/tenants/${tenantId}/forecasts/batch`,
request,
{
timeout: RequestTimeouts.LONG,
}
);
}
/**
* Get Forecast by ID
*/
async getForecast(tenantId: string, forecastId: string): Promise<ForecastResponse> {
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<PaginatedResponse<ForecastResponse>> {
return apiClient.get(`/tenants/${tenantId}/forecasts`, { params });
}
/**
* Get Batch Forecast Status
*/
async getBatchForecastStatus(
tenantId: string,
batchId: string
): Promise<BatchForecastResponse> {
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<PaginatedResponse<BatchForecastResponse>> {
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<QuickForecast[]> {
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<PaginatedResponse<ForecastAlert>> {
return apiClient.get(`/tenants/${tenantId}/forecasts/alerts`, { params });
}
/**
* Acknowledge Forecast Alert
*/
async acknowledgeForecastAlert(
tenantId: string,
alertId: string
): Promise<ForecastAlert> {
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<Blob> {
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();

View File

@@ -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<ForecastRecord> {
const response = await apiClient.post<ApiResponse<ForecastRecord>>(
'/forecasting/single',
request
);
return response.data!;
}
/**
* Generate batch forecasts
*/
async createBatchForecast(request: BatchForecastRequest): Promise<BatchForecastStatus> {
const response = await apiClient.post<ApiResponse<BatchForecastStatus>>(
'/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<ApiResponse<any>>('/forecasting/list', { params });
return response.data!;
}
/**
* Get specific forecast
*/
async getForecast(forecastId: string): Promise<ForecastRecord> {
const response = await apiClient.get<ApiResponse<ForecastRecord>>(
`/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<ApiResponse<any>>('/forecasting/alerts', { params });
return response.data!;
}
/**
* Acknowledge alert
*/
async acknowledgeAlert(alertId: string): Promise<ForecastAlert> {
const response = await apiClient.put<ApiResponse<ForecastAlert>>(
`/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<QuickForecast> {
const response = await apiClient.get<ApiResponse<QuickForecast>>(
`/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<ApiResponse<any>>(
'/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<BatchForecastStatus> {
const response = await apiClient.get<ApiResponse<BatchForecastStatus>>(
`/api/v1/forecasting/batch/${batchId}/status`
);
return response.data!;
}
/**
* Cancel batch forecast
*/
async cancelBatchForecast(batchId: string): Promise<void> {
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<ApiResponse<any>>('/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<ApiResponse<any>>('/api/v1/forecasting/compare', { params });
return response.data!;
}
/**
* Export forecasts
*/
async exportForecasts(params?: {
productName?: string;
startDate?: string;
endDate?: string;
format?: 'csv' | 'excel';
}): Promise<Blob> {
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<ApiResponse<any>>('/api/v1/forecasting/insights', { params });
return response.data!;
}
}
export const forecastingService = new ForecastingService();

View File

@@ -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<ServiceHealth[]> {
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<Record<string, ServiceStatus>> {
const results: Record<string, ServiceStatus> = {};
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<ServiceStatus> {
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<boolean> {
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<T>(
requestFn: () => Promise<T>,
maxRetries: number = 3,
baseDelay: number = 1000
): Promise<T> {
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<string, { data: any; expires: number }>();
/**
* Get cached response
*/
static get<T>(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;
export const healthService = new HealthService();

View File

@@ -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<NotificationResponse> {
return apiClient.post(`/tenants/${tenantId}/notifications`, notification);
}
/**
* Send Bulk Notifications
*/
async sendBulkNotifications(
tenantId: string,
request: BulkNotificationRequest
): Promise<BulkNotificationStatus> {
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<PaginatedResponse<NotificationResponse>> {
return apiClient.get(`/tenants/${tenantId}/notifications`, { params });
}
/**
* Get Notification by ID
*/
async getNotification(tenantId: string, notificationId: string): Promise<NotificationResponse> {
return apiClient.get(`/tenants/${tenantId}/notifications/${notificationId}`);
}
/**
* Get Notification History
*/
async getNotificationHistory(
tenantId: string,
notificationId: string
): Promise<NotificationHistory[]> {
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<BulkNotificationStatus> {
return apiClient.get(`/tenants/${tenantId}/notifications/bulk/${batchId}/status`);
}
/**
* Get Notification Templates
*/
async getTemplates(
tenantId: string,
params?: BaseQueryParams & {
channel?: string;
is_active?: boolean;
}
): Promise<PaginatedResponse<NotificationTemplate>> {
return apiClient.get(`/tenants/${tenantId}/notifications/templates`, { params });
}
/**
* Create Notification Template
*/
async createTemplate(
tenantId: string,
template: Omit<NotificationTemplate, 'id' | 'tenant_id' | 'created_at' | 'updated_at'>
): Promise<NotificationTemplate> {
return apiClient.post(`/tenants/${tenantId}/notifications/templates`, template);
}
/**
* Update Notification Template
*/
async updateTemplate(
tenantId: string,
templateId: string,
template: Partial<NotificationTemplate>
): Promise<NotificationTemplate> {
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<NotificationStats> {
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<Record<string, boolean>> {
return apiClient.get(`/tenants/${tenantId}/notifications/preferences/${userId}`);
}
/**
* Update User Notification Preferences
*/
async updateUserPreferences(
tenantId: string,
userId: string,
preferences: Record<string, boolean>
): Promise<{ message: string }> {
return apiClient.put(
`/tenants/${tenantId}/notifications/preferences/${userId}`,
preferences
);
}
}
export const notificationService = new NotificationService();

View File

@@ -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<string, any>;
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<string, any>;
}[];
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<NotificationResponse> {
const response = await apiClient.post<ApiResponse<NotificationResponse>>(
'/api/v1/notifications/send',
notification
);
return response.data!;
}
/**
* Send bulk notifications
*/
async sendBulkNotifications(request: BulkNotificationRequest): Promise<BulkNotificationStatus> {
const response = await apiClient.post<ApiResponse<BulkNotificationStatus>>(
'/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<ApiResponse<any>>('/api/v1/notifications/history', { params });
return response.data!;
}
/**
* Get notification by ID
*/
async getNotification(notificationId: string): Promise<NotificationResponse> {
const response = await apiClient.get<ApiResponse<NotificationResponse>>(
`/api/v1/notifications/${notificationId}`
);
return response.data!;
}
/**
* Retry failed notification
*/
async retryNotification(notificationId: string): Promise<NotificationResponse> {
const response = await apiClient.post<ApiResponse<NotificationResponse>>(
`/api/v1/notifications/${notificationId}/retry`
);
return response.data!;
}
/**
* Cancel scheduled notification
*/
async cancelNotification(notificationId: string): Promise<void> {
await apiClient.post(`/api/v1/notifications/${notificationId}/cancel`);
}
/**
* Get notification statistics
*/
async getNotificationStats(params?: {
startDate?: string;
endDate?: string;
type?: string;
}): Promise<NotificationStats> {
const response = await apiClient.get<ApiResponse<NotificationStats>>(
'/api/v1/notifications/stats',
{ params }
);
return response.data!;
}
/**
* Get bulk notification status
*/
async getBulkStatus(batchId: string): Promise<BulkNotificationStatus> {
const response = await apiClient.get<ApiResponse<BulkNotificationStatus>>(
`/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<ApiResponse<any>>('/api/v1/notifications/templates', { params });
return response.data!;
}
/**
* Get template by ID
*/
async getTemplate(templateId: string): Promise<NotificationTemplate> {
const response = await apiClient.get<ApiResponse<NotificationTemplate>>(
`/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<NotificationTemplate> {
const response = await apiClient.post<ApiResponse<NotificationTemplate>>(
'/api/v1/notifications/templates',
template
);
return response.data!;
}
/**
* Update notification template
*/
async updateTemplate(
templateId: string,
updates: Partial<NotificationTemplate>
): Promise<NotificationTemplate> {
const response = await apiClient.put<ApiResponse<NotificationTemplate>>(
`/api/v1/notifications/templates/${templateId}`,
updates
);
return response.data!;
}
/**
* Delete notification template
*/
async deleteTemplate(templateId: string): Promise<void> {
await apiClient.delete(`/api/v1/notifications/templates/${templateId}`);
}
/**
* Get user notification preferences
*/
async getPreferences(): Promise<NotificationSettings> {
const response = await apiClient.get<ApiResponse<NotificationSettings>>(
'/api/v1/notifications/preferences'
);
return response.data!;
}
/**
* Update user notification preferences
*/
async updatePreferences(preferences: Partial<NotificationSettings>): Promise<NotificationSettings> {
const response = await apiClient.put<ApiResponse<NotificationSettings>>(
'/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<ApiResponse<any>>(
'/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<ApiResponse<any>>('/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<ApiResponse<any>>('/api/v1/notifications/subscribe', {
events,
webhook_url: webhookUrl,
});
return response.data!;
}
/**
* Unsubscribe from notification events
*/
async unsubscribeFromEvents(subscriptionId: string): Promise<void> {
await apiClient.delete(`/api/v1/notifications/subscribe/${subscriptionId}`);
}
}
export const notificationService = new NotificationService();

View File

@@ -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<TenantInfo> {
return apiClient.post(`${this.baseEndpoint}/register`, data);
}
/**
* Get Tenant Details
*/
async getTenant(tenantId: string): Promise<TenantInfo> {
return apiClient.get(`${this.baseEndpoint}/${tenantId}`);
}
/**
* Update Tenant
*/
async updateTenant(tenantId: string, data: TenantUpdate): Promise<TenantInfo> {
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<PaginatedResponse<TenantMember>> {
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<TenantMember> {
return apiClient.patch(`${this.baseEndpoint}/${tenantId}/members/${userId}`, { role });
}
/**
* Get Tenant Statistics
*/
async getTenantStats(tenantId: string): Promise<TenantStats> {
return apiClient.get(`${this.baseEndpoint}/${tenantId}/stats`);
}
/**
* Get User's Tenants
*/
async getUserTenants(): Promise<TenantInfo[]> {
return apiClient.get(`/users/me/tenants`);
}
}
export const tenantService = new TenantService();

View File

@@ -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<TenantCreate> {
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<TenantInfo> {
const response = await apiClient.post<TenantInfo>('/api/v1/tenants/register', bakeryData);
return response;
}
/**
* Get a specific tenant by ID
* Corresponds to GET /tenants/{tenant_id}
*/
async getTenantById(tenantId: string): Promise<TenantInfo> {
const response = await apiClient.get<ApiResponse<TenantInfo>>(`/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<TenantInfo> {
const response = await apiClient.put<ApiResponse<TenantInfo>>(`/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<TenantInfo[]> {
const response = await apiClient.get<ApiResponse<TenantInfo[]>>(`/api/v1/tenants/user/${userId}`);
return response.data!;
}
}

View File

@@ -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<TrainingJobResponse> {
return apiClient.post(
`/tenants/${tenantId}/training/jobs`,
request,
{
timeout: RequestTimeouts.EXTENDED,
}
);
}
/**
* Start Training for Single Product
*/
async startSingleProductTraining(
tenantId: string,
request: SingleProductTrainingRequest
): Promise<TrainingJobResponse> {
return apiClient.post(
`/tenants/${tenantId}/training/single`,
request,
{
timeout: RequestTimeouts.EXTENDED,
}
);
}
/**
* Get Training Job Status
*/
async getTrainingJobStatus(tenantId: string, jobId: string): Promise<TrainingJobResponse> {
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<PaginatedResponse<TrainingJobResponse>> {
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<PaginatedResponse<ModelInfo>> {
return apiClient.get(`/tenants/${tenantId}/models`, { params });
}
/**
* Get Model Details
*/
async getModel(tenantId: string, modelId: string): Promise<ModelInfo> {
return apiClient.get(`/tenants/${tenantId}/models/${modelId}`);
}
/**
* Update Model Status
*/
async updateModelStatus(
tenantId: string,
modelId: string,
isActive: boolean
): Promise<ModelInfo> {
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<ModelTrainingStats> {
return apiClient.get(`/tenants/${tenantId}/training/stats`);
}
/**
* Download Model File
*/
async downloadModel(tenantId: string, modelId: string): Promise<Blob> {
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();

View File

@@ -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<string, any>;
metrics?: Record<string, any>;
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<TrainingJobStatus> {
const response = await apiClient.post<TrainingJobStatus>(
`/api/v1/tenants/${tenantId}/training/jobs`,
config
);
return response.data!;
}
/**
* Get training job status
*/
async getTrainingStatus(tenantId: string, jobId: string): Promise<TrainingJobProgress> {
const response = await apiClient.get<ApiResponse<TrainingJobProgress>>(
`/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<ApiResponse<any>>('/api/v1/training/jobs', { params });
return response.data!;
}
/**
* Cancel training job
*/
async cancelTraining(tenantId: string, jobId: string): Promise<void> {
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<ApiResponse<any>>(`/api/v1/tenants/${tenantId}/training/models`, { params });
return response.data!;
}
/**
* Get specific model details
*/
async getModel(tenantId: string, modelId: string): Promise<TrainedModel> {
const response = await apiClient.get<ApiResponse<TrainedModel>>(
`/api/v1/training/models/${modelId}`
);
return response.data!;
}
/**
* Get model metrics
*/
async getModelMetrics(tenantId: string, modelId: string): Promise<ModelMetrics> {
const response = await apiClient.get<ApiResponse<ModelMetrics>>(
`/api/v1/tenants/${tenantId}/training/models/${modelId}/metrics`
);
return response.data!;
}
/**
* Activate/deactivate model
*/
async toggleModelStatus(tenantId: string, modelId: string, active: boolean): Promise<TrainedModel> {
const response = await apiClient.patch<ApiResponse<TrainedModel>>(
`/api/v1/tenants/${tenantId}/training/models/${modelId}`,
{ is_active: active }
);
return response.data!;
}
/**
* Delete model
*/
async deleteModel(tenantId: string, modelId: string): Promise<void> {
await apiClient.delete(`/api/v1/training/models/${modelId}`);
}
/**
* Train specific product
*/
async trainProduct(tenantId: string, productName: string, config?: Partial<TrainingConfiguration>): Promise<TrainingJobStatus> {
const response = await apiClient.post<ApiResponse<TrainingJobStatus>>(
`/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<ApiResponse<any>>(`/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<string, number>;
recommendation: string;
}> {
const response = await apiClient.post<ApiResponse<any>>(`/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<ApiResponse<any>>(`/api/v1/tenants/${tenantId}/training/recommendations`);
return response.data!;
}
/**
* Get training logs
*/
async getTrainingLogs(tenantId: string, jobId: string): Promise<string[]> {
const response = await apiClient.get<ApiResponse<string[]>>(`/api/v1/tenants/${tenantId}/training/jobs/${jobId}/logs`);
return response.data!;
}
/**
* Export model
*/
async exportModel(tenantId: string, modelId: string, format: 'pickle' | 'onnx' = 'pickle'): Promise<Blob> {
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();

View File

@@ -1,20 +0,0 @@
// frontend/dashboard/src/types/api.ts
/**
* Shared TypeScript interfaces for API communication
*/
// Base response types
export interface ApiResponse<T = any> {
data?: T;
message?: string;
status: string;
timestamp?: string;
}
export interface ApiError {
detail: string;
service?: string;
error_code?: string;
timestamp?: string;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,42 @@
// frontend/src/api/types/common.ts
/**
* Common API Types
*/
export interface ApiResponse<T = any> {
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<T> {
data: T[];
pagination: PaginationMeta;
}
export interface BaseQueryParams {
page?: number;
limit?: number;
search?: string;
sort?: string;
order?: 'asc' | 'desc';
}

View File

@@ -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<string, any>;
}

View File

@@ -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<string, any>;
}
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;
}

View File

@@ -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';

View File

@@ -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<string, any>;
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<string, any>;
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<string, any>;
}
export interface NotificationStats {
total_sent: number;
total_delivered: number;
total_failed: number;
delivery_rate: number;
channels_breakdown: Record<NotificationChannel, number>;
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<string, any>;
}
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;
}

View File

@@ -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<string, boolean>;
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<TenantSettings>;
location?: TenantLocation;
}
export interface TenantUpdate {
name?: string;
description?: string;
settings?: Partial<TenantSettings>;
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;
}

View File

@@ -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<string, any>;
data_filters?: Record<string, any>;
validation_params?: Record<string, any>;
}
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<string, any>;
training_metrics: Record<string, number>;
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;
}

View File

@@ -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
}
}

View File

@@ -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';

View File

@@ -0,0 +1,34 @@
// frontend/src/api/utils/response.ts
/**
* Response Processing Utilities
*/
import type { ApiResponse, PaginatedResponse } from '../types';
export class ResponseProcessor {
static extractData<T>(response: ApiResponse<T>): T {
return response.data;
}
static extractPaginatedData<T>(response: PaginatedResponse<T>): {
data: T[];
pagination: PaginatedResponse<T>['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;
}
}

View File

@@ -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<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
static removeEmpty(obj: Record<string, any>): Record<string, any> {
const cleaned: Record<string, any> = {};
Object.keys(obj).forEach(key => {
if (obj[key] !== null && obj[key] !== undefined && obj[key] !== '') {
cleaned[key] = obj[key];
}
});
return cleaned;
}
}

View File

@@ -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;
}
}

View File

@@ -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<string, WebSocketConnection> = 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<WebSocketConfig> = {}
): Promise<WebSocket> {
// 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();

View File

@@ -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<WebSocketStatus>('disconnected');
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
const [error, setError] = useState<string | null>(null);
const wsManagerRef = useRef<WebSocketManager | null>(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<WebSocketMessage, 'timestamp' | 'id'>) => {
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<any[]>([]);
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<any[]>([]);
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,
};
};

View File

@@ -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';

View File

@@ -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<void> {
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<WebSocketMessage, 'timestamp' | 'id'>): 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);
}
}
}

View File

@@ -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;
}