From 06cbe3f4e85fb678c967965e7322e82bb0f9b0c4 Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Tue, 22 Jul 2025 17:01:12 +0200 Subject: [PATCH] Add new frontend - fix 9 --- docker-compose.yml | 69 ++++ frontend/src/api/services/api.ts | 49 --- frontend/src/api/services/authService.ts | 194 ++++----- frontend/src/api/services/dataService.ts | 208 ++++++++++ .../src/api/services/forecastingService.ts | 296 ++++++++++++++ frontend/src/api/services/index.ts | 275 +++++++++++++ .../src/api/services/notificationServices.ts | 351 +++++++++++++++++ frontend/src/api/services/tenantServices.ts | 369 ++++++++++++++++++ frontend/src/api/services/trainingService.ts | 221 +++++++++++ frontend/src/contexts/AuthContext.tsx | 20 +- frontend/src/pages/dashboard/index.tsx | 28 +- frontend/src/pages/onboarding.tsx | 79 +++- gateway/app/main.py | 3 +- gateway/app/middleware/auth.py | 3 +- gateway/app/routes/nominatim.py | 48 +++ shared/config/base.py | 1 + 16 files changed, 2048 insertions(+), 166 deletions(-) delete mode 100644 frontend/src/api/services/api.ts create mode 100644 frontend/src/api/services/dataService.ts create mode 100644 frontend/src/api/services/forecastingService.ts create mode 100644 frontend/src/api/services/index.ts create mode 100644 frontend/src/api/services/notificationServices.ts create mode 100644 frontend/src/api/services/tenantServices.ts create mode 100644 frontend/src/api/services/trainingService.ts create mode 100644 gateway/app/routes/nominatim.py diff --git a/docker-compose.yml b/docker-compose.yml index 871e87fc..9b62bfe9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,9 @@ volumes: grafana_data: model_storage: log_storage: + nominatim_db_data: + nominatim_data: + # ================================================================ # SERVICES - USING ONLY .env FILE @@ -215,6 +218,72 @@ services: timeout: 5s retries: 5 + + # ================================================================ + # LOCATION SERVICES (NEW SECTION) + # ================================================================ + + nominatim-db: + image: postgis/postgis:15-3.3 # Use PostGIS enabled PostgreSQL image + container_name: bakery-nominatim-db + restart: unless-stopped + environment: + - POSTGRES_DB=${NOMINATIM_DB_NAME} + - POSTGRES_USER=${NOMINATIM_DB_USER} + - POSTGRES_PASSWORD=${NOMINATIM_DB_PASSWORD} + - PGDATA=/var/lib/postgresql/data/pgdata + - POSTGRES_INITDB_ARGS="--auth-host=scram-sha-256" # Recommended for PostGIS + volumes: + - nominatim_db_data:/var/lib/postgresql/data + profiles: + - development + networks: + bakery-network: + ipv4_address: 172.20.0.30 # Assign a static IP for Nominatim to find it + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${NOMINATIM_DB_USER} -d ${NOMINATIM_DB_NAME}"] + interval: 10s + timeout: 5s + retries: 5 + + nominatim: + image: mediagis/nominatim:4.2 # A pre-built Nominatim image + container_name: bakery-nominatim + restart: unless-stopped + env_file: .env # Load environment variables from .env file + environment: + # Database connection details for Nominatim + - POSTGRES_HOST=nominatim-db + - POSTGRES_PORT=5432 + - POSTGRES_USER=${NOMINATIM_DB_USER} + - POSTGRES_PASSWORD=${NOMINATIM_DB_PASSWORD} + - POSTGRES_DB=${NOMINATIM_DB_NAME} + - PBF_URL=${NOMINATIM_PBF_URL} # URL to your OpenStreetMap PBF data (e.g., Spain) + ports: + - "${NOMINATIM_PORT}:8080" # Expose Nominatim web interface + volumes: + - nominatim_data:/var/lib/nominatim # Persistent storage for Nominatim data and configuration + networks: + bakery-network: + ipv4_address: 172.20.0.120 # Assign a static IP for Nominatim service + depends_on: + nominatim-db: + condition: service_healthy # Ensure database is ready before Nominatim starts + # By default, mediagis/nominatim image will try to import data on first run + # if PBF_URL is set and the database is empty. + profiles: + - development + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/nominatim/status.php"] + interval: 30s + timeout: 10s + retries: 3 + deploy: + resources: + limits: + memory: ${NOMINATIM_MEMORY_LIMIT:-8G} # Nominatim is memory-intensive for import + cpus: '${NOMINATIM_CPU_LIMIT:-4}' # Adjust based on your system and data + # ================================================================ # MICROSERVICES - CLEAN APPROACH # ================================================================ diff --git a/frontend/src/api/services/api.ts b/frontend/src/api/services/api.ts deleted file mode 100644 index b1744a7c..00000000 --- a/frontend/src/api/services/api.ts +++ /dev/null @@ -1,49 +0,0 @@ -// src/api/services/api.ts -import { apiClient } from '../base/apiClient'; -import { - ApiResponse, - LoginRequest, - RegisterRequest, - TokenResponse, - UserProfile, - TenantInfo, - SalesRecord, - TrainingRequest, - TrainedModel, - ForecastRecord, - ForecastRequest, - WeatherData, - TrafficData, - NotificationSettings, - // ... other types from your api.ts file -} from '../types/api'; // This should point to your main types file (api.ts) - -// Assuming your api.ts defines these interfaces: -// interface DashboardStats { ... } -// interface ApiResponse { ... } - - -// Define DashboardStats interface here or ensure it's imported from your main types file -export interface DashboardStats { - totalSales: number; - totalRevenue: number; - lastTrainingDate: string | null; - forecastAccuracy: number; // e.g., MAPE or RMSE -} - - -export const dataApi = { - uploadSalesHistory: (file: File, additionalData?: Record) => - apiClient.upload>('/data/upload-sales', file, additionalData), - getDashboardStats: () => - apiClient.get>('/dashboard/stats'), -}; - -export const forecastingApi = { - getForecast: (params: ForecastRequest) => - apiClient.get>('/forecast', { params }), -}; - - -// Re-export all types from the original api.ts file -export * from '../types/api' \ No newline at end of file diff --git a/frontend/src/api/services/authService.ts b/frontend/src/api/services/authService.ts index 3b0280c7..74d67d2b 100644 --- a/frontend/src/api/services/authService.ts +++ b/frontend/src/api/services/authService.ts @@ -1,116 +1,126 @@ -// frontend/src/api/services/authService.ts - UPDATED TO HANDLE TOKENS FROM REGISTRATION -import { tokenManager } from '../auth/tokenManager'; +// src/api/services/AuthService.ts import { apiClient } from '../base/apiClient'; +import { + ApiResponse, + LoginRequest, + RegisterRequest, + TokenResponse, + UserProfile, +} from '../types/api'; -export interface LoginCredentials { - email: string; - password: string; -} - -export interface RegisterData { - email: string; - password: string; - full_name: string; - tenant_name?: string; -} - -export interface UserProfile { - id: string; - email: string; - full_name: string; - tenant_id?: string; - role?: string; - is_active: boolean; - is_verified?: boolean; - created_at: string; -} - -export interface TokenResponse { - access_token: string; - refresh_token?: string; - token_type: string; - expires_in?: number; - user?: UserProfile; -} - -class AuthService { - async register(data: RegisterData): Promise { - // NEW: Registration now returns tokens directly - no auto-login needed! - const response: TokenResponse = await apiClient.post('/api/v1/auth/register', data); - - // Store tokens immediately from registration response - await tokenManager.storeTokens(response); - - // Return user profile from registration response - if (response.user) { - return response.user; - } else { - // Fallback: get user profile if not included in response - return this.getCurrentUser(); - } +export class AuthService { + /** + * User login + */ + async login(credentials: LoginRequest): Promise { + const response = await apiClient.post>( + '/auth/login', + credentials + ); + return response.data!; } - async login(credentials: LoginCredentials): Promise { - // UPDATED: Use correct endpoint and unified response handling - const response: TokenResponse = await apiClient.post('/api/v1/auth/login', credentials); - - // Store tokens from login response - await tokenManager.storeTokens(response); - - // Return user profile from login response - if (response.user) { - return response.user; - } else { - // Fallback: get user profile if not included in response - return this.getCurrentUser(); - } + /** + * User registration + */ + async register(userData: RegisterRequest): Promise { + const response = await apiClient.post>( + '/auth/register', + userData + ); + return response.data!; } - async logout(): Promise { - try { - // Get refresh token for logout request - const refreshToken = tokenManager.getRefreshToken(); - if (refreshToken) { - await apiClient.post('/api/v1/auth/logout', { - refresh_token: refreshToken - }); - } - } catch (error) { - console.error('Logout API call failed:', error); - // Continue with local cleanup even if API fails - } finally { - tokenManager.clearTokens(); - window.location.href = '/login'; - } + /** + * Refresh access token + */ + async refreshToken(refreshToken: string): Promise { + const response = await apiClient.post>( + '/auth/refresh', + { refresh_token: refreshToken } + ); + return response.data!; } - async getCurrentUser(): Promise { - return apiClient.get('/api/v1/auth/me'); + /** + * Get current user profile + */ + async getProfile(): Promise { + const response = await apiClient.get>('/users/me'); + return response.data!; } + /** + * Update user profile + */ async updateProfile(updates: Partial): Promise { - return apiClient.patch('/api/v1/auth/profile', updates); + const response = await apiClient.put>( + '/users/me', + updates + ); + return response.data!; } - async changePassword(currentPassword: string, newPassword: string): Promise { - await apiClient.post('/api/v1/auth/change-password', { + /** + * Change password + */ + async changePassword( + currentPassword: string, + newPassword: string + ): Promise { + await apiClient.post('/auth/change-password', { current_password: currentPassword, - new_password: newPassword + new_password: newPassword, }); } - async refreshToken(): Promise { - await tokenManager.refreshAccessToken(); + /** + * Request password reset + */ + async requestPasswordReset(email: string): Promise { + await apiClient.post('/auth/reset-password', { email }); } - isAuthenticated(): boolean { - return tokenManager.isAuthenticated(); + /** + * Confirm password reset + */ + async confirmPasswordReset( + token: string, + newPassword: string + ): Promise { + await apiClient.post('/auth/confirm-reset', { + token, + new_password: newPassword, + }); } - getUser(): UserProfile | null { - // This method would need to be implemented to return cached user data - // For now, it returns null and components should use getCurrentUser() - return null; + /** + * Verify email + */ + async verifyEmail(token: string): Promise { + await apiClient.post('/auth/verify-email', { token }); + } + + /** + * Resend verification email + */ + async resendVerification(): Promise { + await apiClient.post('/auth/resend-verification'); + } + + /** + * Logout (invalidate tokens) + */ + async logout(): Promise { + await apiClient.post('/auth/logout'); + } + + /** + * Get user permissions + */ + async getPermissions(): Promise { + const response = await apiClient.get>('/auth/permissions'); + return response.data!; } } diff --git a/frontend/src/api/services/dataService.ts b/frontend/src/api/services/dataService.ts new file mode 100644 index 00000000..a5ffec9e --- /dev/null +++ b/frontend/src/api/services/dataService.ts @@ -0,0 +1,208 @@ +// src/api/services/DataService.ts +import { apiClient } from '../base/apiClient'; +import { + ApiResponse, + SalesRecord, + CreateSalesRequest, + WeatherData, + TrafficData, +} 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 { + valid: boolean; + errors: string[]; + warnings: string[]; + recordCount: number; + duplicates: number; +} + +export class DataService { + /** + * Upload sales history file + */ + async uploadSalesHistory( + file: File, + additionalData?: Record + ): Promise { + const response = await apiClient.upload>( + '/data/upload-sales', + file, + additionalData + ); + return response.data!; + } + + /** + * Validate sales data before upload + */ + async validateSalesData(file: File): Promise { + const response = await apiClient.upload>( + '/data/validate-sales', + file + ); + return response.data!; + } + + /** + * Get dashboard statistics + */ + async getDashboardStats(): Promise { + const response = await apiClient.get>( + '/data/dashboard/stats' + ); + return response.data!; + } + + /** + * Get sales records + */ + async getSalesRecords(params?: { + startDate?: string; + endDate?: string; + productName?: string; + page?: number; + limit?: number; + }): Promise<{ records: SalesRecord[]; total: number; page: number; pages: number }> { + const response = await apiClient.get>('/data/sales', { params }); + return response.data!; + } + + /** + * Create single sales record + */ + async createSalesRecord(record: CreateSalesRequest): Promise { + const response = await apiClient.post>( + '/data/sales', + record + ); + return response.data!; + } + + /** + * Update sales record + */ + async updateSalesRecord( + id: string, + updates: Partial + ): Promise { + const response = await apiClient.put>( + `/data/sales/${id}`, + updates + ); + return response.data!; + } + + /** + * Delete sales record + */ + async deleteSalesRecord(id: string): Promise { + await apiClient.delete(`/data/sales/${id}`); + } + + /** + * Get weather data + */ + async getWeatherData(params?: { + startDate?: string; + endDate?: string; + location?: string; + }): Promise { + const response = await apiClient.get>( + '/data/weather', + { params } + ); + return response.data!; + } + + /** + * Get traffic data + */ + async getTrafficData(params?: { + startDate?: string; + endDate?: string; + location?: string; + }): Promise { + const response = await apiClient.get>( + '/data/traffic', + { params } + ); + return response.data!; + } + + /** + * Get data quality report + */ + async getDataQuality(): Promise<{ + salesData: { completeness: number; quality: number; lastUpdate: string }; + weatherData: { completeness: number; quality: number; lastUpdate: string }; + trafficData: { completeness: number; quality: number; lastUpdate: string }; + }> { + const response = await apiClient.get>('/data/quality'); + return response.data!; + } + + /** + * Export sales data + */ + async exportSalesData(params?: { + startDate?: string; + endDate?: string; + format?: 'csv' | 'excel'; + }): Promise { + const response = await apiClient.get('/data/sales/export', { + params, + responseType: 'blob', + }); + return response as unknown as Blob; + } + + /** + * Get product list + */ + async getProducts(): Promise { + const response = await apiClient.get>('/data/products'); + return response.data!; + } + + /** + * Get data sync status + */ + async getSyncStatus(): Promise<{ + weather: { lastSync: string; status: 'ok' | 'error'; nextSync: string }; + traffic: { lastSync: string; status: 'ok' | 'error'; nextSync: string }; + }> { + const response = await apiClient.get>('/data/sync/status'); + return response.data!; + } + + /** + * Trigger manual data sync + */ + async triggerSync(dataType: 'weather' | 'traffic' | 'all'): Promise { + await apiClient.post('/data/sync/trigger', { data_type: dataType }); + } +} + +export const dataService = new DataService(); \ No newline at end of file diff --git a/frontend/src/api/services/forecastingService.ts b/frontend/src/api/services/forecastingService.ts new file mode 100644 index 00000000..5b11967e --- /dev/null +++ b/frontend/src/api/services/forecastingService.ts @@ -0,0 +1,296 @@ +// src/api/services/ForecastingService.ts +import { apiClient } from '../base/apiClient'; +import { + ApiResponse, + ForecastRecord, + ForecastRequest, +} from '../types/api'; + +export interface SingleForecastRequest { + product_name: string; + forecast_date: string; + include_weather?: boolean; + include_traffic?: boolean; + confidence_level?: number; +} + +export interface BatchForecastRequest { + products: string[]; + start_date: string; + end_date: string; + include_weather?: boolean; + include_traffic?: boolean; + confidence_level?: number; + batch_name?: string; +} + +export interface ForecastAlert { + id: string; + forecast_id: string; + alert_type: 'high_demand' | 'low_demand' | 'anomaly' | 'model_drift'; + severity: 'low' | 'medium' | 'high'; + message: string; + threshold_value?: number; + actual_value?: number; + is_active: boolean; + created_at: string; + acknowledged_at?: string; + notification_sent: boolean; +} + +export interface QuickForecast { + product_name: string; + forecasts: { + date: string; + predicted_quantity: number; + confidence_lower: number; + confidence_upper: number; + }[]; + model_info: { + model_id: string; + algorithm: string; + accuracy: number; + }; +} + +export interface BatchForecastStatus { + id: string; + batch_name: string; + status: 'queued' | 'running' | 'completed' | 'failed'; + total_products: number; + completed_products: number; + failed_products: number; + progress: number; + created_at: string; + completed_at?: string; + error_message?: string; +} + +export class ForecastingService { + /** + * Generate single forecast + */ + async createSingleForecast(request: SingleForecastRequest): Promise { + const response = await apiClient.post>( + '/forecasting/single', + request + ); + return response.data!; + } + + /** + * Generate batch forecasts + */ + async createBatchForecast(request: BatchForecastRequest): Promise { + const response = await apiClient.post>( + '/forecasting/batch', + request + ); + return response.data!; + } + + /** + * Get forecast records + */ + async getForecasts(params?: { + productName?: string; + startDate?: string; + endDate?: string; + page?: number; + limit?: number; + }): Promise<{ + forecasts: ForecastRecord[]; + total: number; + page: number; + pages: number; + }> { + const response = await apiClient.get>('/forecasting/list', { params }); + return response.data!; + } + + /** + * Get specific forecast + */ + async getForecast(forecastId: string): Promise { + const response = await apiClient.get>( + `/forecasting/forecasts/${forecastId}` + ); + return response.data!; + } + + /** + * Get forecast alerts + */ + async getForecastAlerts(params?: { + active?: boolean; + severity?: string; + alertType?: string; + page?: number; + limit?: number; + }): Promise<{ + alerts: ForecastAlert[]; + total: number; + page: number; + pages: number; + }> { + const response = await apiClient.get>('/forecasting/alerts', { params }); + return response.data!; + } + + /** + * Acknowledge alert + */ + async acknowledgeAlert(alertId: string): Promise { + const response = await apiClient.put>( + `/forecasting/alerts/${alertId}/acknowledge` + ); + return response.data!; + } + + /** + * Get quick forecast for product (next 7 days) + */ + async getQuickForecast(productName: string, days: number = 7): Promise { + const response = await apiClient.get>( + `/forecasting/quick/${productName}`, + { params: { days } } + ); + return response.data!; + } + + /** + * Get real-time prediction + */ + async getRealtimePrediction( + productName: string, + date: string, + includeWeather: boolean = true, + includeTraffic: boolean = true + ): Promise<{ + product_name: string; + forecast_date: string; + predicted_quantity: number; + confidence_lower: number; + confidence_upper: number; + external_factors: { + weather?: any; + traffic?: any; + holidays?: any; + }; + processing_time_ms: number; + }> { + const response = await apiClient.post>( + '/forecasting/realtime', + { + product_name: productName, + forecast_date: date, + include_weather: includeWeather, + include_traffic: includeTraffic, + } + ); + return response.data!; + } + + /** + * Get batch forecast status + */ + async getBatchStatus(batchId: string): Promise { + const response = await apiClient.get>( + `/forecasting/batch/${batchId}/status` + ); + return response.data!; + } + + /** + * Cancel batch forecast + */ + async cancelBatchForecast(batchId: string): Promise { + await apiClient.post(`/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>('/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>('/forecasting/compare', { params }); + return response.data!; + } + + /** + * Export forecasts + */ + async exportForecasts(params?: { + productName?: string; + startDate?: string; + endDate?: string; + format?: 'csv' | 'excel'; + }): Promise { + const response = await apiClient.get('/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>('/forecasting/insights', { params }); + return response.data!; + } +} + +export const forecastingService = new ForecastingService(); \ No newline at end of file diff --git a/frontend/src/api/services/index.ts b/frontend/src/api/services/index.ts new file mode 100644 index 00000000..dede1fd8 --- /dev/null +++ b/frontend/src/api/services/index.ts @@ -0,0 +1,275 @@ +// src/api/services/index.ts +/** + * Main API Services Index + * Central import point for all service modules + */ + +// Import all service classes +export { AuthService, authService } from './AuthService'; +export { DataService, dataService } from './DataService'; +export { TrainingService, trainingService } from './TrainingService'; +export { ForecastingService, forecastingService } from './ForecastingService'; +export { NotificationService, notificationService } from './NotificationService'; +export { TenantService, tenantService } from './TenantService'; + +// Import base API client for custom implementations +export { apiClient } from '../base/apiClient'; + +// Re-export all types from the main types file +export * from '../types/api'; + +// Export additional service-specific types +export type { + DashboardStats, + UploadResponse, + DataValidation, +} from './DataService'; + +export type { + TrainingJobProgress, + ModelMetrics, + TrainingConfiguration, +} 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, +} from './TenantService'; + +// Create a unified API object for convenience +export const api = { + auth: authService, + data: dataService, + training: trainingService, + forecasting: forecastingService, + notifications: notificationService, + tenant: tenantService, +} as const; + +// Type for the unified API object +export type ApiServices = typeof api; + +// Service status type for monitoring +export interface ServiceStatus { + service: string; + status: 'healthy' | 'degraded' | 'down'; + lastChecked: Date; + responseTime?: number; + error?: string; +} + +// Health check utilities +export class ApiHealthChecker { + private static healthCheckEndpoints = { + auth: '/auth/health', + data: '/data/health', + training: '/training/health', + forecasting: '/forecasting/health', + notifications: '/notifications/health', + tenant: '/tenants/health', + }; + + /** + * Check health of all services + */ + static async checkAllServices(): Promise> { + const results: Record = {}; + + for (const [serviceName, endpoint] of Object.entries(this.healthCheckEndpoints)) { + results[serviceName] = await this.checkService(serviceName, endpoint); + } + + return results; + } + + /** + * Check health of a specific service + */ + static async checkService(serviceName: string, endpoint: string): Promise { + const startTime = Date.now(); + + try { + const response = await apiClient.get(endpoint, { timeout: 5000 }); + const responseTime = Date.now() - startTime; + + return { + service: serviceName, + status: response.status === 200 ? 'healthy' : 'degraded', + lastChecked: new Date(), + responseTime, + }; + } catch (error: any) { + return { + service: serviceName, + status: 'down', + lastChecked: new Date(), + responseTime: Date.now() - startTime, + error: error.message || 'Unknown error', + }; + } + } + + /** + * Check if core services are available + */ + static async checkCoreServices(): Promise { + const coreServices = ['auth', 'data', 'forecasting']; + const results = await this.checkAllServices(); + + return coreServices.every( + service => results[service]?.status === 'healthy' + ); + } +} + +// Error handling utilities +export class ApiErrorHandler { + /** + * Handle common API errors + */ + static handleError(error: any): never { + if (error.response) { + // Server responded with error status + const { status, data } = error.response; + + switch (status) { + case 401: + throw new Error('Authentication required. Please log in again.'); + case 403: + throw new Error('You do not have permission to perform this action.'); + case 404: + throw new Error('The requested resource was not found.'); + case 429: + throw new Error('Too many requests. Please try again later.'); + case 500: + throw new Error('Server error. Please try again later.'); + default: + throw new Error(data?.message || `Request failed with status ${status}`); + } + } else if (error.request) { + // Network error + throw new Error('Network error. Please check your connection.'); + } else { + // Other error + throw new Error(error.message || 'An unexpected error occurred.'); + } + } + + /** + * Retry failed requests with exponential backoff + */ + static async retryRequest( + requestFn: () => Promise, + maxRetries: number = 3, + baseDelay: number = 1000 + ): Promise { + let lastError: any; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await requestFn(); + } catch (error: any) { + lastError = error; + + // Don't retry on certain errors + if (error.response?.status === 401 || error.response?.status === 403) { + throw error; + } + + // Don't retry on last attempt + if (attempt === maxRetries) { + break; + } + + // Wait before retrying with exponential backoff + const delay = baseDelay * Math.pow(2, attempt); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + throw lastError; + } +} + +// Request cache utilities for performance optimization +export class ApiCache { + private static cache = new Map(); + + /** + * Get cached response + */ + static get(key: string): T | null { + const cached = this.cache.get(key); + + if (cached && cached.expires > Date.now()) { + return cached.data; + } + + // Remove expired cache entry + if (cached) { + this.cache.delete(key); + } + + return null; + } + + /** + * Set cached response + */ + static set(key: string, data: any, ttlMs: number = 300000): void { // 5 minutes default + const expires = Date.now() + ttlMs; + this.cache.set(key, { data, expires }); + } + + /** + * Clear cache + */ + static clear(): void { + this.cache.clear(); + } + + /** + * Clear expired entries + */ + static cleanup(): void { + const now = Date.now(); + for (const [key, value] of this.cache.entries()) { + if (value.expires <= now) { + this.cache.delete(key); + } + } + } + + /** + * Generate cache key + */ + static generateKey(method: string, url: string, params?: any): string { + const paramStr = params ? JSON.stringify(params) : ''; + return `${method}:${url}:${paramStr}`; + } +} + +// Export default as the unified API object +export default api; \ No newline at end of file diff --git a/frontend/src/api/services/notificationServices.ts b/frontend/src/api/services/notificationServices.ts new file mode 100644 index 00000000..ca53c973 --- /dev/null +++ b/frontend/src/api/services/notificationServices.ts @@ -0,0 +1,351 @@ +// src/api/services/NotificationService.ts +import { apiClient } from '../base/apiClient'; +import { + ApiResponse, + NotificationSettings, +} from '../types/api'; + +export interface NotificationCreate { + type: 'email' | 'whatsapp' | 'push'; + recipient_email?: string; + recipient_phone?: string; + recipient_push_token?: string; + subject?: string; + message: string; + template_id?: string; + template_data?: Record; + scheduled_for?: string; + broadcast?: boolean; + priority?: 'low' | 'normal' | 'high'; +} + +export interface NotificationResponse { + id: string; + type: string; + recipient_email?: string; + recipient_phone?: string; + subject?: string; + message: string; + status: 'pending' | 'sent' | 'delivered' | 'failed'; + created_at: string; + sent_at?: string; + delivered_at?: string; + error_message?: string; +} + +export interface NotificationHistory { + id: string; + type: string; + recipient: string; + subject?: string; + status: string; + created_at: string; + sent_at?: string; + delivered_at?: string; + opened_at?: string; + clicked_at?: string; + error_message?: string; +} + +export interface NotificationTemplate { + id: string; + name: string; + description: string; + type: 'email' | 'whatsapp' | 'push'; + subject?: string; + content: string; + variables: string[]; + is_system: boolean; + is_active: boolean; + created_at: string; +} + +export interface NotificationStats { + total_sent: number; + total_delivered: number; + total_failed: number; + delivery_rate: number; + open_rate: number; + click_rate: number; + by_type: { + email: { sent: number; delivered: number; opened: number; clicked: number }; + whatsapp: { sent: number; delivered: number; read: number }; + push: { sent: number; delivered: number; opened: number }; + }; +} + +export interface BulkNotificationRequest { + type: 'email' | 'whatsapp' | 'push'; + recipients: { + email?: string; + phone?: string; + push_token?: string; + template_data?: Record; + }[]; + template_id?: string; + subject?: string; + message?: string; + scheduled_for?: string; + batch_name?: string; +} + +export interface BulkNotificationStatus { + id: string; + batch_name?: string; + total_recipients: number; + sent: number; + delivered: number; + failed: number; + status: 'queued' | 'processing' | 'completed' | 'failed'; + created_at: string; + completed_at?: string; +} + +export class NotificationService { + /** + * Send single notification + */ + async sendNotification(notification: NotificationCreate): Promise { + const response = await apiClient.post>( + '/notifications/send', + notification + ); + return response.data!; + } + + /** + * Send bulk notifications + */ + async sendBulkNotifications(request: BulkNotificationRequest): Promise { + const response = await apiClient.post>( + '/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>('/notifications/history', { params }); + return response.data!; + } + + /** + * Get notification by ID + */ + async getNotification(notificationId: string): Promise { + const response = await apiClient.get>( + `/notifications/${notificationId}` + ); + return response.data!; + } + + /** + * Retry failed notification + */ + async retryNotification(notificationId: string): Promise { + const response = await apiClient.post>( + `/notifications/${notificationId}/retry` + ); + return response.data!; + } + + /** + * Cancel scheduled notification + */ + async cancelNotification(notificationId: string): Promise { + await apiClient.post(`/notifications/${notificationId}/cancel`); + } + + /** + * Get notification statistics + */ + async getNotificationStats(params?: { + startDate?: string; + endDate?: string; + type?: string; + }): Promise { + const response = await apiClient.get>( + '/notifications/stats', + { params } + ); + return response.data!; + } + + /** + * Get bulk notification status + */ + async getBulkStatus(batchId: string): Promise { + const response = await apiClient.get>( + `/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>('/notifications/templates', { params }); + return response.data!; + } + + /** + * Get template by ID + */ + async getTemplate(templateId: string): Promise { + const response = await apiClient.get>( + `/notifications/templates/${templateId}` + ); + return response.data!; + } + + /** + * Create notification template + */ + async createTemplate(template: { + name: string; + description: string; + type: 'email' | 'whatsapp' | 'push'; + subject?: string; + content: string; + variables?: string[]; + }): Promise { + const response = await apiClient.post>( + '/notifications/templates', + template + ); + return response.data!; + } + + /** + * Update notification template + */ + async updateTemplate( + templateId: string, + updates: Partial + ): Promise { + const response = await apiClient.put>( + `/notifications/templates/${templateId}`, + updates + ); + return response.data!; + } + + /** + * Delete notification template + */ + async deleteTemplate(templateId: string): Promise { + await apiClient.delete(`/notifications/templates/${templateId}`); + } + + /** + * Get user notification preferences + */ + async getPreferences(): Promise { + const response = await apiClient.get>( + '/notifications/preferences' + ); + return response.data!; + } + + /** + * Update user notification preferences + */ + async updatePreferences(preferences: Partial): Promise { + const response = await apiClient.put>( + '/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>( + '/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>('/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>('/notifications/subscribe', { + events, + webhook_url: webhookUrl, + }); + return response.data!; + } + + /** + * Unsubscribe from notification events + */ + async unsubscribeFromEvents(subscriptionId: string): Promise { + await apiClient.delete(`/notifications/subscribe/${subscriptionId}`); + } +} + +export const notificationService = new NotificationService(); \ No newline at end of file diff --git a/frontend/src/api/services/tenantServices.ts b/frontend/src/api/services/tenantServices.ts new file mode 100644 index 00000000..e1bbd339 --- /dev/null +++ b/frontend/src/api/services/tenantServices.ts @@ -0,0 +1,369 @@ +// src/api/services/TenantService.ts +import { apiClient } from '../base/apiClient'; +import { + ApiResponse, + TenantInfo, +} from '../types/api'; + +export interface TenantCreate { + name: string; + email: string; + phone: string; + address: string; + latitude?: number; + longitude?: number; + business_type: 'individual_bakery' | 'central_workshop'; + subscription_plan?: string; + settings?: Record; +} + +export interface TenantUpdate extends Partial { + is_active?: boolean; +} + +export interface TenantSettings { + business_hours: { + monday: { open: string; close: string; closed: boolean }; + tuesday: { open: string; close: string; closed: boolean }; + wednesday: { open: string; close: string; closed: boolean }; + thursday: { open: string; close: string; closed: boolean }; + friday: { open: string; close: string; closed: boolean }; + saturday: { open: string; close: string; closed: boolean }; + sunday: { open: string; close: string; closed: boolean }; + }; + timezone: string; + currency: string; + language: string; + notification_preferences: { + email_enabled: boolean; + whatsapp_enabled: boolean; + forecast_alerts: boolean; + training_notifications: boolean; + weekly_reports: boolean; + }; + forecast_preferences: { + default_forecast_days: number; + confidence_level: number; + include_weather: boolean; + include_traffic: boolean; + alert_thresholds: { + high_demand_increase: number; + low_demand_decrease: number; + }; + }; + data_retention_days: number; +} + +export interface TenantStats { + total_users: number; + active_users: number; + total_sales_records: number; + total_forecasts: number; + total_notifications_sent: number; + storage_used_mb: number; + api_calls_this_month: number; + last_activity: string; + subscription_status: 'active' | 'inactive' | 'suspended'; + subscription_expires: string; +} + +export interface TenantUser { + id: string; + email: string; + full_name: string; + role: string; + is_active: boolean; + last_login: string | null; + created_at: string; +} + +export interface InviteUser { + email: string; + role: 'admin' | 'manager' | 'user'; + full_name?: string; + send_invitation_email?: boolean; +} + +export class TenantService { + /** + * Get current tenant info + */ + async getCurrentTenant(): Promise { + const response = await apiClient.get>('/tenants/current'); + return response.data!; + } + + /** + * Update current tenant + */ + async updateCurrentTenant(updates: TenantUpdate): Promise { + const response = await apiClient.put>('/tenants/current', updates); + return response.data!; + } + + /** + * Get tenant settings + */ + async getTenantSettings(): Promise { + const response = await apiClient.get>('/tenants/settings'); + return response.data!; + } + + /** + * Update tenant settings + */ + async updateTenantSettings(settings: Partial): Promise { + const response = await apiClient.put>( + '/tenants/settings', + settings + ); + return response.data!; + } + + /** + * Get tenant statistics + */ + async getTenantStats(): Promise { + const response = await apiClient.get>('/tenants/stats'); + return response.data!; + } + + /** + * Get tenant users + */ + async getTenantUsers(params?: { + role?: string; + active?: boolean; + page?: number; + limit?: number; + }): Promise<{ + users: TenantUser[]; + total: number; + page: number; + pages: number; + }> { + const response = await apiClient.get>('/tenants/users', { params }); + return response.data!; + } + + /** + * Invite user to tenant + */ + async inviteUser(invitation: InviteUser): Promise<{ + invitation_id: string; + email: string; + role: string; + expires_at: string; + invitation_token: string; + }> { + const response = await apiClient.post>('/tenants/users/invite', invitation); + return response.data!; + } + + /** + * Update user role + */ + async updateUserRole(userId: string, role: string): Promise { + const response = await apiClient.patch>( + `/tenants/users/${userId}`, + { role } + ); + return response.data!; + } + + /** + * Deactivate user + */ + async deactivateUser(userId: string): Promise { + const response = await apiClient.patch>( + `/tenants/users/${userId}`, + { is_active: false } + ); + return response.data!; + } + + /** + * Reactivate user + */ + async reactivateUser(userId: string): Promise { + const response = await apiClient.patch>( + `/tenants/users/${userId}`, + { is_active: true } + ); + return response.data!; + } + + /** + * Remove user from tenant + */ + async removeUser(userId: string): Promise { + await apiClient.delete(`/tenants/users/${userId}`); + } + + /** + * Get pending invitations + */ + async getPendingInvitations(): Promise<{ + id: string; + email: string; + role: string; + invited_at: string; + expires_at: string; + invited_by: string; + }[]> { + const response = await apiClient.get>('/tenants/invitations'); + return response.data!; + } + + /** + * Cancel invitation + */ + async cancelInvitation(invitationId: string): Promise { + await apiClient.delete(`/tenants/invitations/${invitationId}`); + } + + /** + * Resend invitation + */ + async resendInvitation(invitationId: string): Promise { + await apiClient.post(`/tenants/invitations/${invitationId}/resend`); + } + + /** + * Get tenant activity log + */ + async getActivityLog(params?: { + userId?: string; + action?: string; + startDate?: string; + endDate?: string; + page?: number; + limit?: number; + }): Promise<{ + activities: { + id: string; + user_id: string; + user_name: string; + action: string; + resource: string; + resource_id: string; + details?: Record; + ip_address?: string; + user_agent?: string; + created_at: string; + }[]; + total: number; + page: number; + pages: number; + }> { + const response = await apiClient.get>('/tenants/activity', { params }); + return response.data!; + } + + /** + * Get tenant billing info + */ + async getBillingInfo(): Promise<{ + subscription_plan: string; + billing_cycle: 'monthly' | 'yearly'; + next_billing_date: string; + amount: number; + currency: string; + payment_method: { + type: string; + last_four: string; + expires: string; + }; + usage: { + api_calls: number; + storage_mb: number; + users: number; + limits: { + api_calls_per_month: number; + storage_mb: number; + max_users: number; + }; + }; + }> { + const response = await apiClient.get>('/tenants/billing'); + return response.data!; + } + + /** + * Update billing info + */ + async updateBillingInfo(billingData: { + payment_method_token?: string; + billing_address?: { + street: string; + city: string; + state: string; + zip: string; + country: string; + }; + }): Promise { + await apiClient.put('/tenants/billing', billingData); + } + + /** + * Change subscription plan + */ + async changeSubscriptionPlan( + planId: string, + billingCycle: 'monthly' | 'yearly' + ): Promise<{ + subscription_id: string; + plan: string; + billing_cycle: string; + next_billing_date: string; + proration_amount?: number; + }> { + const response = await apiClient.post>('/tenants/subscription/change', { + plan_id: planId, + billing_cycle: billingCycle, + }); + return response.data!; + } + + /** + * Cancel subscription + */ + async cancelSubscription(cancelAt: 'immediately' | 'end_of_period'): Promise<{ + cancelled_at: string; + will_cancel_at: string; + refund_amount?: number; + }> { + const response = await apiClient.post>('/tenants/subscription/cancel', { + cancel_at: cancelAt, + }); + return response.data!; + } + + /** + * Export tenant data + */ + async exportTenantData(dataTypes: string[], format: 'json' | 'csv'): Promise { + const response = await apiClient.post('/tenants/export', { + data_types: dataTypes, + format, + responseType: 'blob', + }); + return response as unknown as Blob; + } + + /** + * Delete tenant (GDPR compliance) + */ + async deleteTenant(confirmationToken: string): Promise<{ + deletion_scheduled_at: string; + data_retention_until: string; + recovery_period_days: number; + }> { + const response = await apiClient.delete>('/tenants/current', { + data: { confirmation_token: confirmationToken }, + }); + return response.data!; + } +} + +export const tenantService = new TenantService(); \ No newline at end of file diff --git a/frontend/src/api/services/trainingService.ts b/frontend/src/api/services/trainingService.ts new file mode 100644 index 00000000..bb5cfe24 --- /dev/null +++ b/frontend/src/api/services/trainingService.ts @@ -0,0 +1,221 @@ +// src/api/services/TrainingService.ts +import { apiClient } from '../base/apiClient'; +import { + ApiResponse, + TrainingRequest, + TrainingJobStatus, + TrainedModel, +} from '../types/api'; + +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(config: TrainingConfiguration): Promise { + const response = await apiClient.post>( + '/training/jobs', + config + ); + return response.data!; + } + + /** + * Get training job status + */ + async getTrainingStatus(jobId: string): Promise { + const response = await apiClient.get>( + `/training/jobs/${jobId}` + ); + return response.data!; + } + + /** + * Get all training jobs + */ + async getTrainingHistory(params?: { + page?: number; + limit?: number; + status?: string; + }): Promise<{ + jobs: TrainingJobStatus[]; + total: number; + page: number; + pages: number; + }> { + const response = await apiClient.get>('/training/jobs', { params }); + return response.data!; + } + + /** + * Cancel training job + */ + async cancelTraining(jobId: string): Promise { + await apiClient.post(`/training/jobs/${jobId}/cancel`); + } + + /** + * Get trained models + */ + async getModels(params?: { + productName?: string; + active?: boolean; + page?: number; + limit?: number; + }): Promise<{ + models: TrainedModel[]; + total: number; + page: number; + pages: number; + }> { + const response = await apiClient.get>('/training/models', { params }); + return response.data!; + } + + /** + * Get specific model details + */ + async getModel(modelId: string): Promise { + const response = await apiClient.get>( + `/training/models/${modelId}` + ); + return response.data!; + } + + /** + * Get model metrics + */ + async getModelMetrics(modelId: string): Promise { + const response = await apiClient.get>( + `/training/models/${modelId}/metrics` + ); + return response.data!; + } + + /** + * Activate/deactivate model + */ + async toggleModelStatus(modelId: string, active: boolean): Promise { + const response = await apiClient.patch>( + `/training/models/${modelId}`, + { is_active: active } + ); + return response.data!; + } + + /** + * Delete model + */ + async deleteModel(modelId: string): Promise { + await apiClient.delete(`/training/models/${modelId}`); + } + + /** + * Train specific product + */ + async trainProduct(productName: string, config?: Partial): Promise { + const response = await apiClient.post>( + '/training/products/train', + { + product_name: productName, + ...config, + } + ); + return response.data!; + } + + /** + * Get training statistics + */ + async getTrainingStats(): 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>('/training/stats'); + return response.data!; + } + + /** + * Validate training data + */ + async validateTrainingData(products?: string[]): Promise<{ + valid: boolean; + errors: string[]; + warnings: string[]; + product_data_points: Record; + recommendation: string; + }> { + const response = await apiClient.post>('/training/validate', { + products, + }); + return response.data!; + } + + /** + * Get training recommendations + */ + async getTrainingRecommendations(): Promise<{ + should_retrain: boolean; + reasons: string[]; + recommended_products: string[]; + optimal_config: TrainingConfiguration; + }> { + const response = await apiClient.get>('/training/recommendations'); + return response.data!; + } + + /** + * Get training logs + */ + async getTrainingLogs(jobId: string): Promise { + const response = await apiClient.get>(`/training/jobs/${jobId}/logs`); + return response.data!; + } + + /** + * Export model + */ + async exportModel(modelId: string, format: 'pickle' | 'onnx' = 'pickle'): Promise { + const response = await apiClient.get(`/training/models/${modelId}/export`, { + params: { format }, + responseType: 'blob', + }); + return response as unknown as Blob; + } +} + +export const trainingService = new TrainingService(); \ No newline at end of file diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index b32af5dd..d8c5674c 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,8 +1,14 @@ // frontend/src/contexts/AuthContext.tsx - UPDATED TO USE NEW REGISTRATION FLOW import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'; -import { authService, UserProfile, RegisterData } from '../api/services/authService'; +import { authService } from '../api/services/authService'; import { tokenManager } from '../api/auth/tokenManager'; +import { + UserProfile +} from '@/api/services'; + +import api from '@/api/services'; + interface AuthContextType { user: UserProfile | null; isAuthenticated: boolean; @@ -34,7 +40,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children try { await tokenManager.initialize(); - if (authService.isAuthenticated()) { + if (api.auth.isAuthenticated()) { // Get user from token first (faster), then validate with API const tokenUser = tokenManager.getUserFromToken(); if (tokenUser) { @@ -50,7 +56,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children // Validate with API and get complete profile try { - const profile = await authService.getCurrentUser(); + const profile = await api.auth.getCurrentUser(); setUser(profile); } catch (error) { console.error('Failed to fetch user profile:', error); @@ -124,10 +130,10 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }, [user]); const refreshUser = useCallback(async () => { - if (!authService.isAuthenticated()) return; + if (!api.auth.isAuthenticated()) return; try { - const profile = await authService.getCurrentUser(); + const profile = await api.auth.getCurrentUser(); setUser(profile); } catch (error) { console.error('User refresh error:', error); @@ -160,7 +166,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children if (!user) return; const checkTokenValidity = () => { - if (!authService.isAuthenticated()) { + if (!api.auth.isAuthenticated()) { console.warn('Token became invalid, logging out user'); logout(); } @@ -173,7 +179,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children const contextValue = { user, - isAuthenticated: !!user && authService.isAuthenticated(), + isAuthenticated: !!user && api.auth.isAuthenticated(), isLoading, login, register, diff --git a/frontend/src/pages/dashboard/index.tsx b/frontend/src/pages/dashboard/index.tsx index bb2c4a93..25e3d3a5 100644 --- a/frontend/src/pages/dashboard/index.tsx +++ b/frontend/src/pages/dashboard/index.tsx @@ -9,6 +9,7 @@ import { ArrowPathIcon, ScaleIcon, // For accuracy CalendarDaysIcon, // For last training date + CurrencyEuroIcon } from '@heroicons/react/24/outline'; import { useAuth } from '../../contexts/AuthContext'; import { useTrainingProgress } from '../../api/hooks/useTrainingProgress'; // Path corrected @@ -17,14 +18,17 @@ import { ForecastChart } from '../../components/charts/ForecastChart'; import { SalesUploader } from '../../components/data/SalesUploader'; import { NotificationToast } from '../../components/common/NotificationToast'; import { ErrorBoundary } from '../../components/common/ErrorBoundary'; +import { defaultProducts } from '../../components/common/ProductSelector'; import { - dataApi, - forecastingApi, ApiResponse, ForecastRecord, - SalesRecord, TrainingRequest, -} from '../../api/services/api'; // Consolidated API services and types + TrainingJobProgress +} from '@/api/services'; + + +import api from '@/api/services'; + // Dashboard specific types interface DashboardStats { @@ -141,7 +145,7 @@ const DashboardPage: React.FC = () => { setLoadingData(true); try { // Fetch Dashboard Stats - const statsResponse: ApiResponse = await dataApi.getDashboardStats(); + const statsResponse: ApiResponse = await api.data.dataApi.getDashboardStats(); if (statsResponse.data) { setStats(statsResponse.data); } else if (statsResponse.message) { @@ -149,7 +153,7 @@ const DashboardPage: React.FC = () => { } // Fetch initial forecasts (e.g., for a default product or the first available product) - const forecastResponse: ApiResponse = await forecastingApi.getForecast({ + const forecastResponse: ApiResponse = await api.forecasting.getForecast({ forecast_days: 7, // Example: 7 days forecast product_name: user?.tenant_id ? 'pan' : undefined, // Default to 'pan' or first product }); @@ -177,7 +181,7 @@ const DashboardPage: React.FC = () => { const handleSalesUpload = async (file: File) => { try { addNotification('info', 'Subiendo archivo', 'Cargando historial de ventas...'); - const response = await dataApi.uploadSalesHistory(file); + const response = await api.data.dataApi.uploadSalesHistory(file); addNotification('success', 'Subida Completa', 'Historial de ventas cargado exitosamente.'); // After upload, trigger a new training (assuming this is the flow) @@ -186,9 +190,9 @@ const DashboardPage: React.FC = () => { // You might want to specify products if the uploader supports it, // or let the backend determine based on the uploaded data. }; - const trainingTask: TrainingTask = await trainingApi.startTraining(trainingRequest); - setActiveJobId(trainingTask.job_id); - addNotification('info', 'Entrenamiento iniciado', `Un nuevo entrenamiento ha comenzado (ID: ${trainingTask.job_id}).`); + const trainingTask: TrainingJobProgress = await api.training.trainingApi.startTraining(trainingRequest); + setActiveJobId(trainingTask.id); + addNotification('info', 'Entrenamiento iniciado', `Un nuevo entrenamiento ha comenzado (ID: ${trainingTask.id}).`); // No need to fetch dashboard data here, as useEffect for isTrainingComplete will handle it } catch (error: any) { console.error('Error uploading sales or starting training:', error); @@ -199,7 +203,7 @@ const DashboardPage: React.FC = () => { const handleForecastProductChange = async (productName: string) => { setLoadingData(true); try { - const forecastResponse: ApiResponse = await forecastingApi.getForecast({ + const forecastResponse: ApiResponse = await api.forecasting.forecastingApi.getForecast({ forecast_days: 7, product_name: productName, }); @@ -280,7 +284,7 @@ const DashboardPage: React.FC = () => { diff --git a/frontend/src/pages/onboarding.tsx b/frontend/src/pages/onboarding.tsx index 0254dd78..645df8a4 100644 --- a/frontend/src/pages/onboarding.tsx +++ b/frontend/src/pages/onboarding.tsx @@ -1,5 +1,5 @@ // frontend/src/pages/onboarding.tsx - ORIGINAL DESIGN WITH AUTH FIXES ONLY -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useRef, useEffect, useCallback } from 'react'; import { useRouter } from 'next/router'; import Head from 'next/head'; import { @@ -14,7 +14,6 @@ import { import { SalesUploader } from '../components/data/SalesUploader'; import { TrainingProgressCard } from '../components/training/TrainingProgressCard'; import { useAuth } from '../contexts/AuthContext'; -import { RegisterData } from '../api/services/authService'; import { dataApi, TrainingRequest, TrainingTask } from '../api/services/api'; import { NotificationToast } from '../components/common/NotificationToast'; import { Product, defaultProducts } from '../components/common/ProductSelector'; @@ -76,6 +75,76 @@ const OnboardingPage: React.FC = () => { const [errors, setErrors] = useState>({}); + const addressInputRef = useRef(null); // Ref for the address input + let autocompleteTimeout: NodeJS.Timeout | null = null; // For debouncing API calls + + const handleAddressInputChange = useCallback((e: React.ChangeEvent) => { + const query = e.target.value; + setFormData(prevData => ({ ...prevData, address: query })); // Update address immediately + + if (autocompleteTimeout) { + clearTimeout(autocompleteTimeout); + } + + if (query.length < 3) { // Only search if at least 3 characters are typed + return; + } + + autocompleteTimeout = setTimeout(async () => { + try { + // Construct the Nominatim API URL + // Make sure NOMINATIM_PORT matches your .env file, default is 8080 + const gatewayNominatimApiUrl = `/api/v1/nominatim/search`; // Relative path if frontend serves from gateway's domain/port + + const params = new URLSearchParams({ + q: query, + format: 'json', + addressdetails: '1', // Request detailed address components + limit: '5', // Number of results to return + 'accept-language': 'es', // Request results in Spanish + countrycodes: 'es' // Restrict search to Spain + }); + + const response = await fetch(`${gatewayNominatimApiUrl}?${params.toString()}`); + const data = await response.json(); + + // Process Nominatim results and update form data + if (data && data.length > 0) { + // Take the first result or let the user choose from suggestions if you implement a dropdown + const place = data[0]; // For simplicity, take the first result + + let address = ''; + let city = ''; + let postal_code = ''; + + // Nominatim's 'address' object contains components + if (place.address) { + const addr = place.address; + + // Reconstruct the address in a common format + const street = addr.road || ''; + const houseNumber = addr.house_number || ''; + address = `${street} ${houseNumber}`.trim(); + + city = addr.city || addr.town || addr.village || ''; + postal_code = addr.postcode || ''; + } + + setFormData(prevData => ({ + ...prevData, + address: address || query, // Use parsed address or fall back to user input + city: city || prevData.city, + postal_code: postal_code || prevData.postal_code, + })); + } + } catch (error) { + console.error('Error fetching Nominatim suggestions:', error); + // Optionally show an error notification + // showNotification('error', 'Error de Autocompletado', 'No se pudieron cargar las sugerencias de dirección.'); + } + }, 500); // Debounce time: 500ms + }, []); // Re-create if dependencies change, none for now + useEffect(() => { // If user is already authenticated and on onboarding, redirect to dashboard if (user && currentStep === 1) { @@ -106,7 +175,7 @@ const OnboardingPage: React.FC = () => { setLoading(true); try { - const registerData: RegisterData = { + const registerData: dataApi.auth.RegisterData = { full_name: formData.full_name, email: formData.email, password: formData.password, @@ -287,9 +356,11 @@ const OnboardingPage: React.FC = () => { setFormData({ ...formData, address: e.target.value })} + // Use the new handler for changes to trigger autocomplete + onChange={handleAddressInputChange} required /> {errors.address &&

{errors.address}

} diff --git a/gateway/app/main.py b/gateway/app/main.py index 1d233701..2050c12a 100644 --- a/gateway/app/main.py +++ b/gateway/app/main.py @@ -17,7 +17,7 @@ from app.core.service_discovery import ServiceDiscovery from app.middleware.auth import AuthMiddleware from app.middleware.logging import LoggingMiddleware from app.middleware.rate_limit import RateLimitMiddleware -from app.routes import auth, training, forecasting, data, tenant, notification +from app.routes import auth, training, forecasting, data, tenant, notification, nominatim from shared.monitoring.logging import setup_logging from shared.monitoring.metrics import MetricsCollector @@ -61,6 +61,7 @@ app.include_router(forecasting.router, prefix="/api/v1/forecasting", tags=["fore app.include_router(data.router, prefix="/api/v1/data", tags=["data"]) app.include_router(tenant.router, prefix="/api/v1/tenants", tags=["tenants"]) app.include_router(notification.router, prefix="/api/v1/notifications", tags=["notifications"]) +app.include_router(nominatim.router, prefix="/api/v1/nominatim", tags=["location"]) @app.on_event("startup") async def startup_event(): diff --git a/gateway/app/middleware/auth.py b/gateway/app/middleware/auth.py index c767a81b..63c8d141 100644 --- a/gateway/app/middleware/auth.py +++ b/gateway/app/middleware/auth.py @@ -31,7 +31,8 @@ PUBLIC_ROUTES = [ "/api/v1/auth/login", "/api/v1/auth/register", "/api/v1/auth/refresh", - "/api/v1/auth/verify" + "/api/v1/auth/verify", + "/api/v1/nominatim/search" ] class AuthMiddleware(BaseHTTPMiddleware): diff --git a/gateway/app/routes/nominatim.py b/gateway/app/routes/nominatim.py new file mode 100644 index 00000000..55e504a5 --- /dev/null +++ b/gateway/app/routes/nominatim.py @@ -0,0 +1,48 @@ +# gateway/app/routes/nominatim.py + +from fastapi import APIRouter, Request, HTTPException +from fastapi.responses import JSONResponse +import httpx +import structlog +from app.core.config import settings + +logger = structlog.get_logger() +router = APIRouter() + +@router.get("/search") +async def proxy_nominatim_search(request: Request): + """ + Proxies requests to the Nominatim geocoding search API. + """ + try: + # Construct the internal Nominatim URL + # All query parameters from the client request are forwarded + nominatim_url = f"{settings.NOMINATIM_SERVICE_URL}/nominatim/search" + + # httpx client for making async HTTP requests + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + nominatim_url, + params=request.query_params # Forward all query parameters from frontend + ) + response.raise_for_status() # Raise an exception for HTTP errors (4xx or 5xx) + + # Return the JSON response from Nominatim directly + return JSONResponse(content=response.json()) + + except httpx.RequestError as exc: + logger.error("Nominatim service request failed", error=str(exc)) + raise HTTPException( + status_code=503, + detail=f"Nominatim service unavailable: {exc}" + ) + except httpx.HTTPStatusError as exc: + logger.error(f"Nominatim service responded with error {exc.response.status_code}", + detail=exc.response.text) + raise HTTPException( + status_code=exc.response.status_code, + detail=f"Nominatim service error: {exc.response.text}" + ) + except Exception as exc: + logger.error("Unexpected error in Nominatim proxy", error=str(exc)) + raise HTTPException(status_code=500, detail="Internal server error in Nominatim proxy") \ No newline at end of file diff --git a/shared/config/base.py b/shared/config/base.py index ea17df52..6f7d595b 100644 --- a/shared/config/base.py +++ b/shared/config/base.py @@ -120,6 +120,7 @@ class BaseServiceSettings(BaseSettings): DATA_SERVICE_URL: str = os.getenv("DATA_SERVICE_URL", "http://data-service:8000") TENANT_SERVICE_URL: str = os.getenv("TENANT_SERVICE_URL", "http://tenant-service:8000") NOTIFICATION_SERVICE_URL: str = os.getenv("NOTIFICATION_SERVICE_URL", "http://notification-service:8000") + NOMINATIM_SERVICE_URL: str = os.getenv("NOMINATIM_SERVICE_URL", "http://nominatim:8080") # HTTP Client Settings HTTP_TIMEOUT: int = int(os.getenv("HTTP_TIMEOUT", "30"))