Add new Frontend API folder
This commit is contained in:
@@ -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();
|
|
||||||
@@ -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'
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
142
frontend/src/api/client/config.ts
Normal file
142
frontend/src/api/client/config.ts
Normal 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',
|
||||||
|
};
|
||||||
489
frontend/src/api/client/index.ts
Normal file
489
frontend/src/api/client/index.ts
Normal 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();
|
||||||
370
frontend/src/api/client/interceptors.ts
Normal file
370
frontend/src/api/client/interceptors.ts
Normal 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,
|
||||||
|
};
|
||||||
110
frontend/src/api/client/types.ts
Normal file
110
frontend/src/api/client/types.ts
Normal 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;
|
||||||
|
}
|
||||||
30
frontend/src/api/hooks/index.ts
Normal file
30
frontend/src/api/hooks/index.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
193
frontend/src/api/hooks/useAuth.ts
Normal file
193
frontend/src/api/hooks/useAuth.ts
Normal 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 };
|
||||||
|
};
|
||||||
172
frontend/src/api/hooks/useData.ts
Normal file
172
frontend/src/api/hooks/useData.ts
Normal 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),
|
||||||
|
};
|
||||||
|
};
|
||||||
212
frontend/src/api/hooks/useForecast.ts
Normal file
212
frontend/src/api/hooks/useForecast.ts
Normal 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),
|
||||||
|
};
|
||||||
|
};
|
||||||
151
frontend/src/api/hooks/useNotification.ts
Normal file
151
frontend/src/api/hooks/useNotification.ts
Normal 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),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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 };
|
|
||||||
};
|
|
||||||
203
frontend/src/api/hooks/useTenant.ts
Normal file
203
frontend/src/api/hooks/useTenant.ts
Normal 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),
|
||||||
|
};
|
||||||
|
};
|
||||||
226
frontend/src/api/hooks/useTraining.ts
Normal file
226
frontend/src/api/hooks/useTraining.ts
Normal 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),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -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
71
frontend/src/api/index.ts
Normal 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;
|
||||||
107
frontend/src/api/services/auth.service.ts
Normal file
107
frontend/src/api/services/auth.service.ts
Normal 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();
|
||||||
@@ -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();
|
|
||||||
182
frontend/src/api/services/data.service.ts
Normal file
182
frontend/src/api/services/data.service.ts
Normal 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();
|
||||||
@@ -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();
|
|
||||||
198
frontend/src/api/services/forecasting.service.ts
Normal file
198
frontend/src/api/services/forecasting.service.ts
Normal 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();
|
||||||
@@ -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();
|
|
||||||
@@ -1,80 +1,36 @@
|
|||||||
// src/api/services/index.ts
|
// frontend/src/api/services/index.ts
|
||||||
/**
|
/**
|
||||||
* Main API Services Index
|
* Main Services Export
|
||||||
* Central import point for all service modules
|
* Central export point for all API services
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Import all service classes
|
// Import all services
|
||||||
import { AuthService, authService } from './authService';
|
export { AuthService, authService } from './auth.service';
|
||||||
import { DataService, dataService } from './dataService';
|
export { TenantService, tenantService } from './tenant.service';
|
||||||
import { TrainingService, trainingService } from './trainingService';
|
export { DataService, dataService } from './data.service';
|
||||||
import { ForecastingService, forecastingService } from './forecastingService';
|
export { TrainingService, trainingService } from './training.service';
|
||||||
import { NotificationService, notificationService } from './notificationService';
|
export { ForecastingService, forecastingService } from './forecasting.service';
|
||||||
import { TenantService, tenantService } from './tenantService';
|
export { NotificationService, notificationService } from './notification.service';
|
||||||
|
|
||||||
// Import base API client for custom implementations
|
// Import base client
|
||||||
export { apiClient } from '../base/apiClient';
|
export { apiClient } from '../client';
|
||||||
|
|
||||||
// Re-export all types from the main types file
|
// Re-export all types
|
||||||
export * from '../types/api';
|
export * from '../types';
|
||||||
|
|
||||||
// Export additional service-specific types
|
// Create unified API object
|
||||||
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
|
|
||||||
export const api = {
|
export const api = {
|
||||||
auth: authService,
|
auth: authService,
|
||||||
|
tenant: tenantService,
|
||||||
data: dataService,
|
data: dataService,
|
||||||
training: trainingService,
|
training: trainingService,
|
||||||
forecasting: forecastingService,
|
forecasting: forecastingService,
|
||||||
notifications: notificationService,
|
notification: notificationService,
|
||||||
tenant: tenantService,
|
client: apiClient,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Type for the unified API object
|
// Service status checking
|
||||||
export type ApiServices = typeof api;
|
export interface ServiceHealth {
|
||||||
|
|
||||||
// Service status type for monitoring
|
|
||||||
export interface ServiceStatus {
|
|
||||||
service: string;
|
service: string;
|
||||||
status: 'healthy' | 'degraded' | 'down';
|
status: 'healthy' | 'degraded' | 'down';
|
||||||
lastChecked: Date;
|
lastChecked: Date;
|
||||||
@@ -82,196 +38,52 @@ export interface ServiceStatus {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health check utilities
|
export class HealthService {
|
||||||
export class ApiHealthChecker {
|
async checkServiceHealth(): Promise<ServiceHealth[]> {
|
||||||
private static healthCheckEndpoints = {
|
const services = [
|
||||||
auth: '/auth/health',
|
{ name: 'Auth', endpoint: '/auth/health' },
|
||||||
data: '/data/health',
|
{ name: 'Tenant', endpoint: '/tenants/health' },
|
||||||
training: '/training/health',
|
{ name: 'Data', endpoint: '/data/health' },
|
||||||
forecasting: '/forecasting/health',
|
{ name: 'Training', endpoint: '/training/health' },
|
||||||
notifications: '/notifications/health',
|
{ name: 'Forecasting', endpoint: '/forecasting/health' },
|
||||||
tenant: '/tenants/health',
|
{ name: 'Notification', endpoint: '/notifications/health' },
|
||||||
};
|
];
|
||||||
|
|
||||||
/**
|
const healthChecks = await Promise.allSettled(
|
||||||
* Check health of all services
|
services.map(async (service) => {
|
||||||
*/
|
const startTime = Date.now();
|
||||||
static async checkAllServices(): Promise<Record<string, ServiceStatus>> {
|
try {
|
||||||
const results: Record<string, ServiceStatus> = {};
|
await apiClient.get(service.endpoint, { timeout: 5000 });
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
for (const [serviceName, endpoint] of Object.entries(this.healthCheckEndpoints)) {
|
|
||||||
results[serviceName] = await this.checkService(serviceName, endpoint);
|
return {
|
||||||
}
|
service: service.name,
|
||||||
|
status: 'healthy' as const,
|
||||||
return results;
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
return healthChecks.map((result, index) =>
|
||||||
* Check health of a specific service
|
result.status === 'fulfilled'
|
||||||
*/
|
? result.value
|
||||||
static async checkService(serviceName: string, endpoint: string): Promise<ServiceStatus> {
|
: {
|
||||||
const startTime = Date.now();
|
service: services[index].name,
|
||||||
|
status: 'down' as const,
|
||||||
try {
|
lastChecked: new Date(),
|
||||||
const response = await apiClient.get(endpoint, { timeout: 5000 });
|
error: 'Health check failed',
|
||||||
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'
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error handling utilities
|
export const healthService = new HealthService();
|
||||||
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;
|
|
||||||
185
frontend/src/api/services/notification.service.ts
Normal file
185
frontend/src/api/services/notification.service.ts
Normal 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();
|
||||||
@@ -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();
|
|
||||||
101
frontend/src/api/services/tenant.service.ts
Normal file
101
frontend/src/api/services/tenant.service.ts
Normal 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();
|
||||||
@@ -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!;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
160
frontend/src/api/services/training.service.ts
Normal file
160
frontend/src/api/services/training.service.ts
Normal 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();
|
||||||
@@ -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();
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
83
frontend/src/api/types/auth.ts
Normal file
83
frontend/src/api/types/auth.ts
Normal 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;
|
||||||
|
}
|
||||||
42
frontend/src/api/types/common.ts
Normal file
42
frontend/src/api/types/common.ts
Normal 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';
|
||||||
|
}
|
||||||
103
frontend/src/api/types/data.ts
Normal file
103
frontend/src/api/types/data.ts
Normal 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>;
|
||||||
|
}
|
||||||
79
frontend/src/api/types/forecasting.ts
Normal file
79
frontend/src/api/types/forecasting.ts
Normal 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;
|
||||||
|
}
|
||||||
13
frontend/src/api/types/index.ts
Normal file
13
frontend/src/api/types/index.ts
Normal 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';
|
||||||
108
frontend/src/api/types/notification.ts
Normal file
108
frontend/src/api/types/notification.ts
Normal 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;
|
||||||
|
}
|
||||||
104
frontend/src/api/types/tenant.ts
Normal file
104
frontend/src/api/types/tenant.ts
Normal 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;
|
||||||
|
}
|
||||||
126
frontend/src/api/types/training.ts
Normal file
126
frontend/src/api/types/training.ts
Normal 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;
|
||||||
|
}
|
||||||
50
frontend/src/api/utils/error.ts
Normal file
50
frontend/src/api/utils/error.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
9
frontend/src/api/utils/index.ts
Normal file
9
frontend/src/api/utils/index.ts
Normal 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';
|
||||||
34
frontend/src/api/utils/response.ts
Normal file
34
frontend/src/api/utils/response.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
frontend/src/api/utils/transform.ts
Normal file
70
frontend/src/api/utils/transform.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
72
frontend/src/api/utils/validation.ts
Normal file
72
frontend/src/api/utils/validation.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
151
frontend/src/api/websocket/hooks.ts
Normal file
151
frontend/src/api/websocket/hooks.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
18
frontend/src/api/websocket/index.ts
Normal file
18
frontend/src/api/websocket/index.ts
Normal 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';
|
||||||
265
frontend/src/api/websocket/manager.ts
Normal file
265
frontend/src/api/websocket/manager.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
frontend/src/api/websocket/types.ts
Normal file
45
frontend/src/api/websocket/types.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user