Add new frontend - fix 9

This commit is contained in:
Urtzi Alfaro
2025-07-22 17:01:12 +02:00
parent 5959eb6e15
commit 06cbe3f4e8
16 changed files with 2048 additions and 166 deletions

View File

@@ -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
# ================================================================

View File

@@ -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<T> { ... }
// 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<string, any>) =>
apiClient.upload<ApiResponse<any>>('/data/upload-sales', file, additionalData),
getDashboardStats: () =>
apiClient.get<ApiResponse<DashboardStats>>('/dashboard/stats'),
};
export const forecastingApi = {
getForecast: (params: ForecastRequest) =>
apiClient.get<ApiResponse<ForecastRecord[]>>('/forecast', { params }),
};
// Re-export all types from the original api.ts file
export * from '../types/api'

View File

@@ -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 class AuthService {
/**
* User login
*/
async login(credentials: LoginRequest): Promise<TokenResponse> {
const response = await apiClient.post<ApiResponse<TokenResponse>>(
'/auth/login',
credentials
);
return response.data!;
}
export interface RegisterData {
email: string;
password: string;
full_name: string;
tenant_name?: string;
/**
* User registration
*/
async register(userData: RegisterRequest): Promise<UserProfile> {
const response = await apiClient.post<ApiResponse<UserProfile>>(
'/auth/register',
userData
);
return response.data!;
}
export interface UserProfile {
id: string;
email: string;
full_name: string;
tenant_id?: string;
role?: string;
is_active: boolean;
is_verified?: boolean;
created_at: string;
/**
* Refresh access token
*/
async refreshToken(refreshToken: string): Promise<TokenResponse> {
const response = await apiClient.post<ApiResponse<TokenResponse>>(
'/auth/refresh',
{ refresh_token: refreshToken }
);
return response.data!;
}
export interface TokenResponse {
access_token: string;
refresh_token?: string;
token_type: string;
expires_in?: number;
user?: UserProfile;
}
class AuthService {
async register(data: RegisterData): Promise<UserProfile> {
// 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();
}
}
async login(credentials: LoginCredentials): Promise<UserProfile> {
// 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();
}
}
async logout(): Promise<void> {
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';
}
}
async getCurrentUser(): Promise<UserProfile> {
return apiClient.get('/api/v1/auth/me');
/**
* Get current user profile
*/
async getProfile(): Promise<UserProfile> {
const response = await apiClient.get<ApiResponse<UserProfile>>('/users/me');
return response.data!;
}
/**
* Update user profile
*/
async updateProfile(updates: Partial<UserProfile>): Promise<UserProfile> {
return apiClient.patch('/api/v1/auth/profile', updates);
const response = await apiClient.put<ApiResponse<UserProfile>>(
'/users/me',
updates
);
return response.data!;
}
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
await apiClient.post('/api/v1/auth/change-password', {
/**
* Change password
*/
async changePassword(
currentPassword: string,
newPassword: string
): Promise<void> {
await apiClient.post('/auth/change-password', {
current_password: currentPassword,
new_password: newPassword
new_password: newPassword,
});
}
async refreshToken(): Promise<void> {
await tokenManager.refreshAccessToken();
/**
* Request password reset
*/
async requestPasswordReset(email: string): Promise<void> {
await apiClient.post('/auth/reset-password', { email });
}
isAuthenticated(): boolean {
return tokenManager.isAuthenticated();
/**
* Confirm password reset
*/
async confirmPasswordReset(
token: string,
newPassword: string
): Promise<void> {
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<void> {
await apiClient.post('/auth/verify-email', { token });
}
/**
* Resend verification email
*/
async resendVerification(): Promise<void> {
await apiClient.post('/auth/resend-verification');
}
/**
* Logout (invalidate tokens)
*/
async logout(): Promise<void> {
await apiClient.post('/auth/logout');
}
/**
* Get user permissions
*/
async getPermissions(): Promise<string[]> {
const response = await apiClient.get<ApiResponse<string[]>>('/auth/permissions');
return response.data!;
}
}

View File

@@ -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<string, any>
): Promise<UploadResponse> {
const response = await apiClient.upload<ApiResponse<UploadResponse>>(
'/data/upload-sales',
file,
additionalData
);
return response.data!;
}
/**
* Validate sales data before upload
*/
async validateSalesData(file: File): Promise<DataValidation> {
const response = await apiClient.upload<ApiResponse<DataValidation>>(
'/data/validate-sales',
file
);
return response.data!;
}
/**
* Get dashboard statistics
*/
async getDashboardStats(): Promise<DashboardStats> {
const response = await apiClient.get<ApiResponse<DashboardStats>>(
'/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<ApiResponse<{
records: SalesRecord[];
total: number;
page: number;
pages: number;
}>>('/data/sales', { params });
return response.data!;
}
/**
* Create single sales record
*/
async createSalesRecord(record: CreateSalesRequest): Promise<SalesRecord> {
const response = await apiClient.post<ApiResponse<SalesRecord>>(
'/data/sales',
record
);
return response.data!;
}
/**
* Update sales record
*/
async updateSalesRecord(
id: string,
updates: Partial<CreateSalesRequest>
): Promise<SalesRecord> {
const response = await apiClient.put<ApiResponse<SalesRecord>>(
`/data/sales/${id}`,
updates
);
return response.data!;
}
/**
* Delete sales record
*/
async deleteSalesRecord(id: string): Promise<void> {
await apiClient.delete(`/data/sales/${id}`);
}
/**
* Get weather data
*/
async getWeatherData(params?: {
startDate?: string;
endDate?: string;
location?: string;
}): Promise<WeatherData[]> {
const response = await apiClient.get<ApiResponse<WeatherData[]>>(
'/data/weather',
{ params }
);
return response.data!;
}
/**
* Get traffic data
*/
async getTrafficData(params?: {
startDate?: string;
endDate?: string;
location?: string;
}): Promise<TrafficData[]> {
const response = await apiClient.get<ApiResponse<TrafficData[]>>(
'/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<ApiResponse<any>>('/data/quality');
return response.data!;
}
/**
* Export sales data
*/
async exportSalesData(params?: {
startDate?: string;
endDate?: string;
format?: 'csv' | 'excel';
}): Promise<Blob> {
const response = await apiClient.get('/data/sales/export', {
params,
responseType: 'blob',
});
return response as unknown as Blob;
}
/**
* Get product list
*/
async getProducts(): Promise<string[]> {
const response = await apiClient.get<ApiResponse<string[]>>('/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<ApiResponse<any>>('/data/sync/status');
return response.data!;
}
/**
* Trigger manual data sync
*/
async triggerSync(dataType: 'weather' | 'traffic' | 'all'): Promise<void> {
await apiClient.post('/data/sync/trigger', { data_type: dataType });
}
}
export const dataService = new DataService();

View File

@@ -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<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>>(
`/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>>(
`/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>>(
`/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>>(
`/forecasting/batch/${batchId}/status`
);
return response.data!;
}
/**
* Cancel batch forecast
*/
async cancelBatchForecast(batchId: string): Promise<void> {
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<ApiResponse<any>>('/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>>('/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('/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>>('/forecasting/insights', { params });
return response.data!;
}
}
export const forecastingService = new ForecastingService();

View File

@@ -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<Record<string, ServiceStatus>> {
const results: Record<string, ServiceStatus> = {};
for (const [serviceName, endpoint] of Object.entries(this.healthCheckEndpoints)) {
results[serviceName] = await this.checkService(serviceName, endpoint);
}
return results;
}
/**
* Check health of a specific service
*/
static async checkService(serviceName: string, endpoint: string): Promise<ServiceStatus> {
const startTime = Date.now();
try {
const response = await apiClient.get(endpoint, { timeout: 5000 });
const responseTime = Date.now() - startTime;
return {
service: serviceName,
status: response.status === 200 ? 'healthy' : 'degraded',
lastChecked: new Date(),
responseTime,
};
} catch (error: any) {
return {
service: serviceName,
status: 'down',
lastChecked: new Date(),
responseTime: Date.now() - startTime,
error: error.message || 'Unknown error',
};
}
}
/**
* Check if core services are available
*/
static async checkCoreServices(): Promise<boolean> {
const coreServices = ['auth', 'data', 'forecasting'];
const results = await this.checkAllServices();
return coreServices.every(
service => results[service]?.status === 'healthy'
);
}
}
// Error handling utilities
export class ApiErrorHandler {
/**
* Handle common API errors
*/
static handleError(error: any): never {
if (error.response) {
// Server responded with error status
const { status, data } = error.response;
switch (status) {
case 401:
throw new Error('Authentication required. Please log in again.');
case 403:
throw new Error('You do not have permission to perform this action.');
case 404:
throw new Error('The requested resource was not found.');
case 429:
throw new Error('Too many requests. Please try again later.');
case 500:
throw new Error('Server error. Please try again later.');
default:
throw new Error(data?.message || `Request failed with status ${status}`);
}
} else if (error.request) {
// Network error
throw new Error('Network error. Please check your connection.');
} else {
// Other error
throw new Error(error.message || 'An unexpected error occurred.');
}
}
/**
* Retry failed requests with exponential backoff
*/
static async retryRequest<T>(
requestFn: () => Promise<T>,
maxRetries: number = 3,
baseDelay: number = 1000
): Promise<T> {
let lastError: any;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await requestFn();
} catch (error: any) {
lastError = error;
// Don't retry on certain errors
if (error.response?.status === 401 || error.response?.status === 403) {
throw error;
}
// Don't retry on last attempt
if (attempt === maxRetries) {
break;
}
// Wait before retrying with exponential backoff
const delay = baseDelay * Math.pow(2, attempt);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError;
}
}
// Request cache utilities for performance optimization
export class ApiCache {
private static cache = new Map<string, { data: any; expires: number }>();
/**
* Get cached response
*/
static get<T>(key: string): T | null {
const cached = this.cache.get(key);
if (cached && cached.expires > Date.now()) {
return cached.data;
}
// Remove expired cache entry
if (cached) {
this.cache.delete(key);
}
return null;
}
/**
* Set cached response
*/
static set(key: string, data: any, ttlMs: number = 300000): void { // 5 minutes default
const expires = Date.now() + ttlMs;
this.cache.set(key, { data, expires });
}
/**
* Clear cache
*/
static clear(): void {
this.cache.clear();
}
/**
* Clear expired entries
*/
static cleanup(): void {
const now = Date.now();
for (const [key, value] of this.cache.entries()) {
if (value.expires <= now) {
this.cache.delete(key);
}
}
}
/**
* Generate cache key
*/
static generateKey(method: string, url: string, params?: any): string {
const paramStr = params ? JSON.stringify(params) : '';
return `${method}:${url}:${paramStr}`;
}
}
// Export default as the unified API object
export default api;

View File

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

View File

@@ -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<string, any>;
}
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 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<TenantInfo> {
const response = await apiClient.get<ApiResponse<TenantInfo>>('/tenants/current');
return response.data!;
}
/**
* Update current tenant
*/
async updateCurrentTenant(updates: TenantUpdate): Promise<TenantInfo> {
const response = await apiClient.put<ApiResponse<TenantInfo>>('/tenants/current', updates);
return response.data!;
}
/**
* Get tenant settings
*/
async getTenantSettings(): Promise<TenantSettings> {
const response = await apiClient.get<ApiResponse<TenantSettings>>('/tenants/settings');
return response.data!;
}
/**
* Update tenant settings
*/
async updateTenantSettings(settings: Partial<TenantSettings>): Promise<TenantSettings> {
const response = await apiClient.put<ApiResponse<TenantSettings>>(
'/tenants/settings',
settings
);
return response.data!;
}
/**
* Get tenant statistics
*/
async getTenantStats(): Promise<TenantStats> {
const response = await apiClient.get<ApiResponse<TenantStats>>('/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<ApiResponse<any>>('/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<ApiResponse<any>>('/tenants/users/invite', invitation);
return response.data!;
}
/**
* Update user role
*/
async updateUserRole(userId: string, role: string): Promise<TenantUser> {
const response = await apiClient.patch<ApiResponse<TenantUser>>(
`/tenants/users/${userId}`,
{ role }
);
return response.data!;
}
/**
* Deactivate user
*/
async deactivateUser(userId: string): Promise<TenantUser> {
const response = await apiClient.patch<ApiResponse<TenantUser>>(
`/tenants/users/${userId}`,
{ is_active: false }
);
return response.data!;
}
/**
* Reactivate user
*/
async reactivateUser(userId: string): Promise<TenantUser> {
const response = await apiClient.patch<ApiResponse<TenantUser>>(
`/tenants/users/${userId}`,
{ is_active: true }
);
return response.data!;
}
/**
* Remove user from tenant
*/
async removeUser(userId: string): Promise<void> {
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<ApiResponse<any>>('/tenants/invitations');
return response.data!;
}
/**
* Cancel invitation
*/
async cancelInvitation(invitationId: string): Promise<void> {
await apiClient.delete(`/tenants/invitations/${invitationId}`);
}
/**
* Resend invitation
*/
async resendInvitation(invitationId: string): Promise<void> {
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<string, any>;
ip_address?: string;
user_agent?: string;
created_at: string;
}[];
total: number;
page: number;
pages: number;
}> {
const response = await apiClient.get<ApiResponse<any>>('/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<ApiResponse<any>>('/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<void> {
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<ApiResponse<any>>('/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<ApiResponse<any>>('/tenants/subscription/cancel', {
cancel_at: cancelAt,
});
return response.data!;
}
/**
* Export tenant data
*/
async exportTenantData(dataTypes: string[], format: 'json' | 'csv'): Promise<Blob> {
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<ApiResponse<any>>('/tenants/current', {
data: { confirmation_token: confirmationToken },
});
return response.data!;
}
}
export const tenantService = new TenantService();

View File

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

View File

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

View File

@@ -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<DashboardStats> = await dataApi.getDashboardStats();
const statsResponse: ApiResponse<DashboardStats> = 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<ForecastRecord[]> = await forecastingApi.getForecast({
const forecastResponse: ApiResponse<ForecastRecord[]> = 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<ForecastRecord[]> = await forecastingApi.getForecast({
const forecastResponse: ApiResponse<ForecastRecord[]> = await api.forecasting.forecastingApi.getForecast({
forecast_days: 7,
product_name: productName,
});
@@ -280,7 +284,7 @@ const DashboardPage: React.FC = () => {
<StatsCard
title="Ingresos Totales"
value={stats?.totalRevenue}
icon={CurrencyEuroIcon} {/* Assuming CurrencyEuroIcon from heroicons */}
icon={CurrencyEuroIcon}
format="currency"
loading={loadingData}
/>

View File

@@ -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<Partial<OnboardingFormData>>({});
const addressInputRef = useRef<HTMLInputElement>(null); // Ref for the address input
let autocompleteTimeout: NodeJS.Timeout | null = null; // For debouncing API calls
const handleAddressInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
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 = () => {
<input
type="text"
id="address"
ref={addressInputRef}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-pania-blue focus:border-pania-blue sm:text-sm"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
// Use the new handler for changes to trigger autocomplete
onChange={handleAddressInputChange}
required
/>
{errors.address && <p className="mt-1 text-sm text-red-600">{errors.address}</p>}

View File

@@ -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():

View File

@@ -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):

View File

@@ -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")

View File

@@ -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"))