Add new frontend - fix 16

This commit is contained in:
Urtzi Alfaro
2025-07-23 07:26:04 +02:00
parent e6b0be0c95
commit 1d35912459
14 changed files with 588 additions and 169 deletions

View File

@@ -57,6 +57,10 @@ class TokenManager {
} }
async storeTokens(response: TokenResponse): Promise<void> { async storeTokens(response: TokenResponse): Promise<void> {
if (!response || !response.access_token) {
throw new Error('Invalid token response: missing access_token');
}
// Handle the new unified token response format // Handle the new unified token response format
this.accessToken = response.access_token; this.accessToken = response.access_token;

View File

@@ -31,36 +31,91 @@ export class AuthService {
return response.data!; return response.data!;
} }
/** async register(userData: RegisterRequest): Promise<TokenResponse> {
* User login try {
*/ // ✅ FIX: apiClient returns direct response, not wrapped in ApiResponse
async login(credentials: LoginRequest): Promise<TokenResponse> { const response = await apiClient.post<TokenResponse>(
const response = await apiClient.post<ApiResponse<TokenResponse>>( '/api/v1/auth/register',
'/auth/login', userData
credentials );
);
// Store tokens after successful login // ✅ FIX: Check if response contains token data (direct response)
const tokenData = response.data!; if (!response || !response.access_token) {
await tokenManager.storeTokens(tokenData); throw new Error('Registration successful but no tokens received');
}
return tokenData; // Store tokens after successful registration
await tokenManager.storeTokens(response);
return response;
} catch (error: any) {
// ✅ FIX: Better error handling for different scenarios
if (error.response) {
// Server responded with an error status
const status = error.response.status;
const data = error.response.data;
if (status === 409) {
throw new Error('User with this email already exists');
} else if (status === 400) {
const detail = data?.detail || 'Invalid registration data';
throw new Error(detail);
} else if (status >= 500) {
throw new Error('Server error during registration. Please try again.');
} else {
throw new Error(data?.detail || `Registration failed with status ${status}`);
}
} else if (error.request) {
// Request was made but no response received
throw new Error('Network error. Please check your connection.');
} else {
// Something else happened
throw new Error(error.message || 'Registration failed');
}
}
} }
/** /**
* User registration * User login - Also improved error handling
*/ */
async register(userData: RegisterRequest): Promise<TokenResponse> { async login(credentials: LoginRequest): Promise<TokenResponse> {
const response = await apiClient.post<ApiResponse<TokenResponse>>( try {
'/api/v1/auth/register', // ✅ FIX: apiClient returns direct response, not wrapped in ApiResponse
userData const response = await apiClient.post<TokenResponse>(
); '/api/v1/auth/login',
credentials
);
// Store tokens after successful registration // ✅ FIX: Check if response contains token data (direct response)
const tokenData = response.data!; if (!response || !response.access_token) {
await tokenManager.storeTokens(tokenData); throw new Error('Login successful but no tokens received');
}
return tokenData; // Store tokens after successful login
await tokenManager.storeTokens(response);
return response;
} catch (error: any) {
// ✅ FIX: Better error handling
if (error.response) {
const status = error.response.status;
const data = error.response.data;
if (status === 401) {
throw new Error('Invalid email or password');
} else if (status === 429) {
throw new Error('Too many login attempts. Please try again later.');
} else if (status >= 500) {
throw new Error('Server error during login. Please try again.');
} else {
throw new Error(data?.detail || `Login failed with status ${status}`);
}
} else if (error.request) {
throw new Error('Network error. Please check your connection.');
} else {
throw new Error(error.message || 'Login failed');
}
}
} }
@@ -68,11 +123,11 @@ export class AuthService {
* Refresh access token * Refresh access token
*/ */
async refreshToken(refreshToken: string): Promise<TokenResponse> { async refreshToken(refreshToken: string): Promise<TokenResponse> {
const response = await apiClient.post<ApiResponse<TokenResponse>>( const response = await apiClient.post<TokenResponse>(
'/auth/refresh', '/api/v1/auth/refresh',
{ refresh_token: refreshToken } { refresh_token: refreshToken }
); );
return response.data!; return response;
} }
/** /**
@@ -87,7 +142,7 @@ export class AuthService {
*/ */
async updateProfile(updates: Partial<UserProfile>): Promise<UserProfile> { async updateProfile(updates: Partial<UserProfile>): Promise<UserProfile> {
const response = await apiClient.put<ApiResponse<UserProfile>>( const response = await apiClient.put<ApiResponse<UserProfile>>(
'/users/me', '/api/v1/users/me',
updates updates
); );
return response.data!; return response.data!;
@@ -110,7 +165,7 @@ export class AuthService {
* Request password reset * Request password reset
*/ */
async requestPasswordReset(email: string): Promise<void> { async requestPasswordReset(email: string): Promise<void> {
await apiClient.post('/auth/reset-password', { email }); await apiClient.post('/api/v1/auth/reset-password', { email });
} }
/** /**
@@ -120,7 +175,7 @@ export class AuthService {
token: string, token: string,
newPassword: string newPassword: string
): Promise<void> { ): Promise<void> {
await apiClient.post('/auth/confirm-reset', { await apiClient.post('/api/v1/auth/confirm-reset', {
token, token,
new_password: newPassword, new_password: newPassword,
}); });
@@ -130,14 +185,14 @@ export class AuthService {
* Verify email * Verify email
*/ */
async verifyEmail(token: string): Promise<void> { async verifyEmail(token: string): Promise<void> {
await apiClient.post('/auth/verify-email', { token }); await apiClient.post('/api/v1/auth/verify-email', { token });
} }
/** /**
* Resend verification email * Resend verification email
*/ */
async resendVerification(): Promise<void> { async resendVerification(): Promise<void> {
await apiClient.post('/auth/resend-verification'); await apiClient.post('/api/v1/auth/resend-verification');
} }
/** /**
@@ -145,7 +200,7 @@ export class AuthService {
*/ */
async logout(): Promise<void> { async logout(): Promise<void> {
try { try {
await apiClient.post('/auth/logout'); await apiClient.post('/api/v1/auth/logout');
} catch (error) { } catch (error) {
console.error('Logout API call failed:', error); console.error('Logout API call failed:', error);
} finally { } finally {

View File

@@ -42,7 +42,7 @@ export class DataService {
additionalData?: Record<string, any> additionalData?: Record<string, any>
): Promise<UploadResponse> { ): Promise<UploadResponse> {
const response = await apiClient.upload<ApiResponse<UploadResponse>>( const response = await apiClient.upload<ApiResponse<UploadResponse>>(
'/data/upload-sales', '/api/v1/data/upload-sales',
file, file,
additionalData additionalData
); );
@@ -54,7 +54,7 @@ export class DataService {
*/ */
async validateSalesData(file: File): Promise<DataValidation> { async validateSalesData(file: File): Promise<DataValidation> {
const response = await apiClient.upload<ApiResponse<DataValidation>>( const response = await apiClient.upload<ApiResponse<DataValidation>>(
'/data/validate-sales', '/api/v1/data/validate-sales',
file file
); );
return response.data!; return response.data!;
@@ -65,7 +65,7 @@ export class DataService {
*/ */
async getDashboardStats(): Promise<DashboardStats> { async getDashboardStats(): Promise<DashboardStats> {
const response = await apiClient.get<ApiResponse<DashboardStats>>( const response = await apiClient.get<ApiResponse<DashboardStats>>(
'/data/dashboard/stats' '/api/v1/data/dashboard/stats'
); );
return response.data!; return response.data!;
} }
@@ -85,7 +85,7 @@ export class DataService {
total: number; total: number;
page: number; page: number;
pages: number; pages: number;
}>>('/data/sales', { params }); }>>('/api/v1/data/sales', { params });
return response.data!; return response.data!;
} }
@@ -94,7 +94,7 @@ export class DataService {
*/ */
async createSalesRecord(record: CreateSalesRequest): Promise<SalesRecord> { async createSalesRecord(record: CreateSalesRequest): Promise<SalesRecord> {
const response = await apiClient.post<ApiResponse<SalesRecord>>( const response = await apiClient.post<ApiResponse<SalesRecord>>(
'/data/sales', '/api/v1/data/sales',
record record
); );
return response.data!; return response.data!;
@@ -108,7 +108,7 @@ export class DataService {
updates: Partial<CreateSalesRequest> updates: Partial<CreateSalesRequest>
): Promise<SalesRecord> { ): Promise<SalesRecord> {
const response = await apiClient.put<ApiResponse<SalesRecord>>( const response = await apiClient.put<ApiResponse<SalesRecord>>(
`/data/sales/${id}`, `/api/v1/data/sales/${id}`,
updates updates
); );
return response.data!; return response.data!;
@@ -118,7 +118,7 @@ export class DataService {
* Delete sales record * Delete sales record
*/ */
async deleteSalesRecord(id: string): Promise<void> { async deleteSalesRecord(id: string): Promise<void> {
await apiClient.delete(`/data/sales/${id}`); await apiClient.delete(`/api/v1/data/sales/${id}`);
} }
/** /**
@@ -130,7 +130,7 @@ export class DataService {
location?: string; location?: string;
}): Promise<WeatherData[]> { }): Promise<WeatherData[]> {
const response = await apiClient.get<ApiResponse<WeatherData[]>>( const response = await apiClient.get<ApiResponse<WeatherData[]>>(
'/data/weather', '/api/v1/data/weather',
{ params } { params }
); );
return response.data!; return response.data!;
@@ -145,7 +145,7 @@ export class DataService {
location?: string; location?: string;
}): Promise<TrafficData[]> { }): Promise<TrafficData[]> {
const response = await apiClient.get<ApiResponse<TrafficData[]>>( const response = await apiClient.get<ApiResponse<TrafficData[]>>(
'/data/traffic', '/api/v1/data/traffic',
{ params } { params }
); );
return response.data!; return response.data!;
@@ -159,7 +159,7 @@ export class DataService {
weatherData: { completeness: number; quality: number; lastUpdate: string }; weatherData: { completeness: number; quality: number; lastUpdate: string };
trafficData: { completeness: number; quality: number; lastUpdate: string }; trafficData: { completeness: number; quality: number; lastUpdate: string };
}> { }> {
const response = await apiClient.get<ApiResponse<any>>('/data/quality'); const response = await apiClient.get<ApiResponse<any>>('/api/v1/data/quality');
return response.data!; return response.data!;
} }
@@ -171,7 +171,7 @@ export class DataService {
endDate?: string; endDate?: string;
format?: 'csv' | 'excel'; format?: 'csv' | 'excel';
}): Promise<Blob> { }): Promise<Blob> {
const response = await apiClient.get('/data/sales/export', { const response = await apiClient.get('/api/v1/data/sales/export', {
params, params,
responseType: 'blob', responseType: 'blob',
}); });
@@ -182,7 +182,7 @@ export class DataService {
* Get product list * Get product list
*/ */
async getProducts(): Promise<string[]> { async getProducts(): Promise<string[]> {
const response = await apiClient.get<ApiResponse<string[]>>('/data/products'); const response = await apiClient.get<ApiResponse<string[]>>('/api/v1/data/products');
return response.data!; return response.data!;
} }
@@ -193,7 +193,7 @@ export class DataService {
weather: { lastSync: string; status: 'ok' | 'error'; nextSync: string }; weather: { lastSync: string; status: 'ok' | 'error'; nextSync: string };
traffic: { 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'); const response = await apiClient.get<ApiResponse<any>>('/api/v1/data/sync/status');
return response.data!; return response.data!;
} }
@@ -201,7 +201,7 @@ export class DataService {
* Trigger manual data sync * Trigger manual data sync
*/ */
async triggerSync(dataType: 'weather' | 'traffic' | 'all'): Promise<void> { async triggerSync(dataType: 'weather' | 'traffic' | 'all'): Promise<void> {
await apiClient.post('/data/sync/trigger', { data_type: dataType }); await apiClient.post('/api/v1/data/sync/trigger', { data_type: dataType });
} }
} }

View File

@@ -113,7 +113,7 @@ export class ForecastingService {
*/ */
async getForecast(forecastId: string): Promise<ForecastRecord> { async getForecast(forecastId: string): Promise<ForecastRecord> {
const response = await apiClient.get<ApiResponse<ForecastRecord>>( const response = await apiClient.get<ApiResponse<ForecastRecord>>(
`/forecasting/forecasts/${forecastId}` `/api/v1/forecasting/forecasts/${forecastId}`
); );
return response.data!; return response.data!;
} }
@@ -142,7 +142,7 @@ export class ForecastingService {
*/ */
async acknowledgeAlert(alertId: string): Promise<ForecastAlert> { async acknowledgeAlert(alertId: string): Promise<ForecastAlert> {
const response = await apiClient.put<ApiResponse<ForecastAlert>>( const response = await apiClient.put<ApiResponse<ForecastAlert>>(
`/forecasting/alerts/${alertId}/acknowledge` `/api/v1/forecasting/alerts/${alertId}/acknowledge`
); );
return response.data!; return response.data!;
} }
@@ -152,7 +152,7 @@ export class ForecastingService {
*/ */
async getQuickForecast(productName: string, days: number = 7): Promise<QuickForecast> { async getQuickForecast(productName: string, days: number = 7): Promise<QuickForecast> {
const response = await apiClient.get<ApiResponse<QuickForecast>>( const response = await apiClient.get<ApiResponse<QuickForecast>>(
`/forecasting/quick/${productName}`, `/api/v1/forecasting/quick/${productName}`,
{ params: { days } } { params: { days } }
); );
return response.data!; return response.data!;
@@ -196,7 +196,7 @@ export class ForecastingService {
*/ */
async getBatchStatus(batchId: string): Promise<BatchForecastStatus> { async getBatchStatus(batchId: string): Promise<BatchForecastStatus> {
const response = await apiClient.get<ApiResponse<BatchForecastStatus>>( const response = await apiClient.get<ApiResponse<BatchForecastStatus>>(
`/forecasting/batch/${batchId}/status` `/api/v1/forecasting/batch/${batchId}/status`
); );
return response.data!; return response.data!;
} }
@@ -205,7 +205,7 @@ export class ForecastingService {
* Cancel batch forecast * Cancel batch forecast
*/ */
async cancelBatchForecast(batchId: string): Promise<void> { async cancelBatchForecast(batchId: string): Promise<void> {
await apiClient.post(`/forecasting/batch/${batchId}/cancel`); await apiClient.post(`/api/v1/forecasting/batch/${batchId}/cancel`);
} }
/** /**
@@ -219,7 +219,7 @@ export class ForecastingService {
products_forecasted: number; products_forecasted: number;
last_forecast_date: string | null; last_forecast_date: string | null;
}> { }> {
const response = await apiClient.get<ApiResponse<any>>('/forecasting/stats'); const response = await apiClient.get<ApiResponse<any>>('/api/v1/forecasting/stats');
return response.data!; return response.data!;
} }
@@ -246,7 +246,7 @@ export class ForecastingService {
accuracy: number; accuracy: number;
}; };
}> { }> {
const response = await apiClient.get<ApiResponse<any>>('/forecasting/compare', { params }); const response = await apiClient.get<ApiResponse<any>>('/api/v1/forecasting/compare', { params });
return response.data!; return response.data!;
} }
@@ -259,7 +259,7 @@ export class ForecastingService {
endDate?: string; endDate?: string;
format?: 'csv' | 'excel'; format?: 'csv' | 'excel';
}): Promise<Blob> { }): Promise<Blob> {
const response = await apiClient.get('/forecasting/export', { const response = await apiClient.get('/api/v1/forecasting/export', {
params, params,
responseType: 'blob', responseType: 'blob',
}); });
@@ -288,7 +288,7 @@ export class ForecastingService {
estimated_impact: string; estimated_impact: string;
}[]; }[];
}> { }> {
const response = await apiClient.get<ApiResponse<any>>('/forecasting/insights', { params }); const response = await apiClient.get<ApiResponse<any>>('/api/v1/forecasting/insights', { params });
return response.data!; return response.data!;
} }
} }

View File

@@ -2,7 +2,7 @@
import { apiClient } from '../base/apiClient'; import { apiClient } from '../base/apiClient';
import { import {
ApiResponse, ApiResponse,
TenantInfo, TenantInfo, // Assuming TenantInfo is equivalent to TenantResponse from backend
} from '../types/api'; } from '../types/api';
export interface TenantCreate { export interface TenantCreate {
@@ -84,52 +84,110 @@ export interface InviteUser {
send_invitation_email?: boolean; send_invitation_email?: boolean;
} }
// New interface for tenant member response based on backend
export interface TenantMemberResponse {
user_id: string;
tenant_id: string;
role: string;
// Add any other fields expected from the backend's TenantMemberResponse
}
export class TenantService { export class TenantService {
/** /**
* Get current tenant info * Register a new bakery (tenant)
* Corresponds to POST /tenants/register
*/
async registerBakery(bakeryData: TenantCreate): Promise<TenantInfo> {
const response = await apiClient.post<ApiResponse<TenantInfo>>('/api/v1/tenants/register', bakeryData);
return response.data!;
}
/**
* Get a specific tenant by ID
* Corresponds to GET /tenants/{tenant_id}
*/
async getTenantById(tenantId: string): Promise<TenantInfo> {
const response = await apiClient.get<ApiResponse<TenantInfo>>(`/api/v1/tenants/${tenantId}`);
return response.data!;
}
/**
* Update a specific tenant by ID
* Corresponds to PUT /tenants/{tenant_id}
*/
async updateTenant(tenantId: string, updates: TenantUpdate): Promise<TenantInfo> {
const response = await apiClient.put<ApiResponse<TenantInfo>>(`/api/v1/tenants/${tenantId}`, updates);
return response.data!;
}
/**
* Get all tenants associated with a user
* Corresponds to GET /users/{user_id}/tenants
*/
async getUserTenants(userId: string): Promise<TenantInfo[]> {
const response = await apiClient.get<ApiResponse<TenantInfo[]>>(`/api/v1/users/${userId}/tenants`);
return response.data!;
}
/**
* Add a team member to a tenant
* Corresponds to POST /tenants/{tenant_id}/members
*/
async addTeamMember(tenantId: string, userId: string, role: string): Promise<TenantMemberResponse> {
const response = await apiClient.post<ApiResponse<TenantMemberResponse>>(
`/api/v1/tenants/${tenantId}/members`,
{ user_id: userId, role }
);
return response.data!;
}
// --- Existing methods (kept for completeness, assuming they map to other backend endpoints not provided) ---
/**
* Get current tenant info (no direct backend mapping in provided file, but common)
*/ */
async getCurrentTenant(): Promise<TenantInfo> { async getCurrentTenant(): Promise<TenantInfo> {
const response = await apiClient.get<ApiResponse<TenantInfo>>('/tenants/current'); const response = await apiClient.get<ApiResponse<TenantInfo>>('/api/v1/tenants/current');
return response.data!; return response.data!;
} }
/** /**
* Update current tenant * Update current tenant (no direct backend mapping in provided file, but common)
*/ */
async updateCurrentTenant(updates: TenantUpdate): Promise<TenantInfo> { async updateCurrentTenant(updates: TenantUpdate): Promise<TenantInfo> {
const response = await apiClient.put<ApiResponse<TenantInfo>>('/tenants/current', updates); const response = await apiClient.put<ApiResponse<TenantInfo>>('/api/v1/tenants/current', updates);
return response.data!; return response.data!;
} }
/** /**
* Get tenant settings * Get tenant settings (no direct backend mapping in provided file)
*/ */
async getTenantSettings(): Promise<TenantSettings> { async getTenantSettings(): Promise<TenantSettings> {
const response = await apiClient.get<ApiResponse<TenantSettings>>('/tenants/settings'); const response = await apiClient.get<ApiResponse<TenantSettings>>('/api/v1/tenants/settings');
return response.data!; return response.data!;
} }
/** /**
* Update tenant settings * Update tenant settings (no direct backend mapping in provided file)
*/ */
async updateTenantSettings(settings: Partial<TenantSettings>): Promise<TenantSettings> { async updateTenantSettings(settings: Partial<TenantSettings>): Promise<TenantSettings> {
const response = await apiClient.put<ApiResponse<TenantSettings>>( const response = await apiClient.put<ApiResponse<TenantSettings>>(
'/tenants/settings', '/api/v1/tenants/settings',
settings settings
); );
return response.data!; return response.data!;
} }
/** /**
* Get tenant statistics * Get tenant statistics (no direct backend mapping in provided file)
*/ */
async getTenantStats(): Promise<TenantStats> { async getTenantStats(): Promise<TenantStats> {
const response = await apiClient.get<ApiResponse<TenantStats>>('/tenants/stats'); const response = await apiClient.get<ApiResponse<TenantStats>>('/api/v1/tenants/stats');
return response.data!; return response.data!;
} }
/** /**
* Get tenant users * Get tenant users (no direct backend mapping in provided file)
*/ */
async getTenantUsers(params?: { async getTenantUsers(params?: {
role?: string; role?: string;
@@ -142,12 +200,12 @@ export class TenantService {
page: number; page: number;
pages: number; pages: number;
}> { }> {
const response = await apiClient.get<ApiResponse<any>>('/tenants/users', { params }); const response = await apiClient.get<ApiResponse<any>>('/api/v1/tenants/users', { params });
return response.data!; return response.data!;
} }
/** /**
* Invite user to tenant * Invite user to tenant (no direct backend mapping in provided file)
*/ */
async inviteUser(invitation: InviteUser): Promise<{ async inviteUser(invitation: InviteUser): Promise<{
invitation_id: string; invitation_id: string;
@@ -156,52 +214,52 @@ export class TenantService {
expires_at: string; expires_at: string;
invitation_token: string; invitation_token: string;
}> { }> {
const response = await apiClient.post<ApiResponse<any>>('/tenants/users/invite', invitation); const response = await apiClient.post<ApiResponse<any>>('/api/v1/tenants/users/invite', invitation);
return response.data!; return response.data!;
} }
/** /**
* Update user role * Update user role (no direct backend mapping in provided file)
*/ */
async updateUserRole(userId: string, role: string): Promise<TenantUser> { async updateUserRole(userId: string, role: string): Promise<TenantUser> {
const response = await apiClient.patch<ApiResponse<TenantUser>>( const response = await apiClient.patch<ApiResponse<TenantUser>>(
`/tenants/users/${userId}`, `/api/v1/tenants/users/${userId}`,
{ role } { role }
); );
return response.data!; return response.data!;
} }
/** /**
* Deactivate user * Deactivate user (no direct backend mapping in provided file)
*/ */
async deactivateUser(userId: string): Promise<TenantUser> { async deactivateUser(userId: string): Promise<TenantUser> {
const response = await apiClient.patch<ApiResponse<TenantUser>>( const response = await apiClient.patch<ApiResponse<TenantUser>>(
`/tenants/users/${userId}`, `/api/v1/tenants/users/${userId}`,
{ is_active: false } { is_active: false }
); );
return response.data!; return response.data!;
} }
/** /**
* Reactivate user * Reactivate user (no direct backend mapping in provided file)
*/ */
async reactivateUser(userId: string): Promise<TenantUser> { async reactivateUser(userId: string): Promise<TenantUser> {
const response = await apiClient.patch<ApiResponse<TenantUser>>( const response = await apiClient.patch<ApiResponse<TenantUser>>(
`/tenants/users/${userId}`, `/api/v1/tenants/users/${userId}`,
{ is_active: true } { is_active: true }
); );
return response.data!; return response.data!;
} }
/** /**
* Remove user from tenant * Remove user from tenant (no direct backend mapping in provided file)
*/ */
async removeUser(userId: string): Promise<void> { async removeUser(userId: string): Promise<void> {
await apiClient.delete(`/tenants/users/${userId}`); await apiClient.delete(`/api/v1/tenants/users/${userId}`);
} }
/** /**
* Get pending invitations * Get pending invitations (no direct backend mapping in provided file)
*/ */
async getPendingInvitations(): Promise<{ async getPendingInvitations(): Promise<{
id: string; id: string;
@@ -211,26 +269,26 @@ export class TenantService {
expires_at: string; expires_at: string;
invited_by: string; invited_by: string;
}[]> { }[]> {
const response = await apiClient.get<ApiResponse<any>>('/tenants/invitations'); const response = await apiClient.get<ApiResponse<any>>('/api/v1/tenants/invitations');
return response.data!; return response.data!;
} }
/** /**
* Cancel invitation * Cancel invitation (no direct backend mapping in provided file)
*/ */
async cancelInvitation(invitationId: string): Promise<void> { async cancelInvitation(invitationId: string): Promise<void> {
await apiClient.delete(`/tenants/invitations/${invitationId}`); await apiClient.delete(`/api/v1/tenants/invitations/${invitationId}`);
} }
/** /**
* Resend invitation * Resend invitation (no direct backend mapping in provided file)
*/ */
async resendInvitation(invitationId: string): Promise<void> { async resendInvitation(invitationId: string): Promise<void> {
await apiClient.post(`/tenants/invitations/${invitationId}/resend`); await apiClient.post(`/api/v1/tenants/invitations/${invitationId}/resend`);
} }
/** /**
* Get tenant activity log * Get tenant activity log (no direct backend mapping in provided file)
*/ */
async getActivityLog(params?: { async getActivityLog(params?: {
userId?: string; userId?: string;
@@ -256,12 +314,12 @@ export class TenantService {
page: number; page: number;
pages: number; pages: number;
}> { }> {
const response = await apiClient.get<ApiResponse<any>>('/tenants/activity', { params }); const response = await apiClient.get<ApiResponse<any>>('/api/v1/tenants/activity', { params });
return response.data!; return response.data!;
} }
/** /**
* Get tenant billing info * Get tenant billing info (no direct backend mapping in provided file)
*/ */
async getBillingInfo(): Promise<{ async getBillingInfo(): Promise<{
subscription_plan: string; subscription_plan: string;
@@ -285,12 +343,12 @@ export class TenantService {
}; };
}; };
}> { }> {
const response = await apiClient.get<ApiResponse<any>>('/tenants/billing'); const response = await apiClient.get<ApiResponse<any>>('/api/v1/tenants/billing');
return response.data!; return response.data!;
} }
/** /**
* Update billing info * Update billing info (no direct backend mapping in provided file)
*/ */
async updateBillingInfo(billingData: { async updateBillingInfo(billingData: {
payment_method_token?: string; payment_method_token?: string;
@@ -302,11 +360,11 @@ export class TenantService {
country: string; country: string;
}; };
}): Promise<void> { }): Promise<void> {
await apiClient.put('/tenants/billing', billingData); await apiClient.put('/api/v1/tenants/billing', billingData);
} }
/** /**
* Change subscription plan * Change subscription plan (no direct backend mapping in provided file)
*/ */
async changeSubscriptionPlan( async changeSubscriptionPlan(
planId: string, planId: string,
@@ -318,7 +376,7 @@ export class TenantService {
next_billing_date: string; next_billing_date: string;
proration_amount?: number; proration_amount?: number;
}> { }> {
const response = await apiClient.post<ApiResponse<any>>('/tenants/subscription/change', { const response = await apiClient.post<ApiResponse<any>>('/api/v1/tenants/subscription/change', {
plan_id: planId, plan_id: planId,
billing_cycle: billingCycle, billing_cycle: billingCycle,
}); });
@@ -326,24 +384,24 @@ export class TenantService {
} }
/** /**
* Cancel subscription * Cancel subscription (no direct backend mapping in provided file)
*/ */
async cancelSubscription(cancelAt: 'immediately' | 'end_of_period'): Promise<{ async cancelSubscription(cancelAt: 'immediately' | 'end_of_period'): Promise<{
cancelled_at: string; cancelled_at: string;
will_cancel_at: string; will_cancel_at: string;
refund_amount?: number; refund_amount?: number;
}> { }> {
const response = await apiClient.post<ApiResponse<any>>('/tenants/subscription/cancel', { const response = await apiClient.post<ApiResponse<any>>('/api/v1/tenants/subscription/cancel', {
cancel_at: cancelAt, cancel_at: cancelAt,
}); });
return response.data!; return response.data!;
} }
/** /**
* Export tenant data * Export tenant data (no direct backend mapping in provided file)
*/ */
async exportTenantData(dataTypes: string[], format: 'json' | 'csv'): Promise<Blob> { async exportTenantData(dataTypes: string[], format: 'json' | 'csv'): Promise<Blob> {
const response = await apiClient.post('/tenants/export', { const response = await apiClient.post('/api/v1/tenants/export', {
data_types: dataTypes, data_types: dataTypes,
format, format,
responseType: 'blob', responseType: 'blob',
@@ -352,14 +410,14 @@ export class TenantService {
} }
/** /**
* Delete tenant (GDPR compliance) * Delete tenant (GDPR compliance) (no direct backend mapping in provided file)
*/ */
async deleteTenant(confirmationToken: string): Promise<{ async deleteTenant(confirmationToken: string): Promise<{
deletion_scheduled_at: string; deletion_scheduled_at: string;
data_retention_until: string; data_retention_until: string;
recovery_period_days: number; recovery_period_days: number;
}> { }> {
const response = await apiClient.delete<ApiResponse<any>>('/tenants/current', { const response = await apiClient.delete<ApiResponse<any>>('/api/v1/tenants/current', {
data: { confirmation_token: confirmationToken }, data: { confirmation_token: confirmationToken },
}); });
return response.data!; return response.data!;

View File

@@ -55,7 +55,7 @@ export class TrainingService {
*/ */
async getTrainingStatus(jobId: string): Promise<TrainingJobProgress> { async getTrainingStatus(jobId: string): Promise<TrainingJobProgress> {
const response = await apiClient.get<ApiResponse<TrainingJobProgress>>( const response = await apiClient.get<ApiResponse<TrainingJobProgress>>(
`/training/jobs/${jobId}` `/api/v1/training/jobs/${jobId}`
); );
return response.data!; return response.data!;
} }
@@ -73,7 +73,7 @@ export class TrainingService {
page: number; page: number;
pages: number; pages: number;
}> { }> {
const response = await apiClient.get<ApiResponse<any>>('/training/jobs', { params }); const response = await apiClient.get<ApiResponse<any>>('/api/v1/training/jobs', { params });
return response.data!; return response.data!;
} }
@@ -81,7 +81,7 @@ export class TrainingService {
* Cancel training job * Cancel training job
*/ */
async cancelTraining(jobId: string): Promise<void> { async cancelTraining(jobId: string): Promise<void> {
await apiClient.post(`/training/jobs/${jobId}/cancel`); await apiClient.post(`/api/v1/training/jobs/${jobId}/cancel`);
} }
/** /**
@@ -98,7 +98,7 @@ export class TrainingService {
page: number; page: number;
pages: number; pages: number;
}> { }> {
const response = await apiClient.get<ApiResponse<any>>('/training/models', { params }); const response = await apiClient.get<ApiResponse<any>>('/api/v1/training/models', { params });
return response.data!; return response.data!;
} }
@@ -107,7 +107,7 @@ export class TrainingService {
*/ */
async getModel(modelId: string): Promise<TrainedModel> { async getModel(modelId: string): Promise<TrainedModel> {
const response = await apiClient.get<ApiResponse<TrainedModel>>( const response = await apiClient.get<ApiResponse<TrainedModel>>(
`/training/models/${modelId}` `/api/v1/training/models/${modelId}`
); );
return response.data!; return response.data!;
} }
@@ -117,7 +117,7 @@ export class TrainingService {
*/ */
async getModelMetrics(modelId: string): Promise<ModelMetrics> { async getModelMetrics(modelId: string): Promise<ModelMetrics> {
const response = await apiClient.get<ApiResponse<ModelMetrics>>( const response = await apiClient.get<ApiResponse<ModelMetrics>>(
`/training/models/${modelId}/metrics` `/api/v1/training/models/${modelId}/metrics`
); );
return response.data!; return response.data!;
} }
@@ -127,7 +127,7 @@ export class TrainingService {
*/ */
async toggleModelStatus(modelId: string, active: boolean): Promise<TrainedModel> { async toggleModelStatus(modelId: string, active: boolean): Promise<TrainedModel> {
const response = await apiClient.patch<ApiResponse<TrainedModel>>( const response = await apiClient.patch<ApiResponse<TrainedModel>>(
`/training/models/${modelId}`, `/api/v1/training/models/${modelId}`,
{ is_active: active } { is_active: active }
); );
return response.data!; return response.data!;
@@ -137,7 +137,7 @@ export class TrainingService {
* Delete model * Delete model
*/ */
async deleteModel(modelId: string): Promise<void> { async deleteModel(modelId: string): Promise<void> {
await apiClient.delete(`/training/models/${modelId}`); await apiClient.delete(`/api/v1/training/models/${modelId}`);
} }
/** /**
@@ -145,7 +145,7 @@ export class TrainingService {
*/ */
async trainProduct(productName: string, config?: Partial<TrainingConfiguration>): Promise<TrainingJobStatus> { async trainProduct(productName: string, config?: Partial<TrainingConfiguration>): Promise<TrainingJobStatus> {
const response = await apiClient.post<ApiResponse<TrainingJobStatus>>( const response = await apiClient.post<ApiResponse<TrainingJobStatus>>(
'/training/products/train', '/api/v1/training/products/train',
{ {
product_name: productName, product_name: productName,
...config, ...config,
@@ -165,7 +165,7 @@ export class TrainingService {
products_trained: number; products_trained: number;
training_time_avg_minutes: number; training_time_avg_minutes: number;
}> { }> {
const response = await apiClient.get<ApiResponse<any>>('/training/stats'); const response = await apiClient.get<ApiResponse<any>>('/api/v1/training/stats');
return response.data!; return response.data!;
} }
@@ -179,7 +179,7 @@ export class TrainingService {
product_data_points: Record<string, number>; product_data_points: Record<string, number>;
recommendation: string; recommendation: string;
}> { }> {
const response = await apiClient.post<ApiResponse<any>>('/training/validate', { const response = await apiClient.post<ApiResponse<any>>('/api/v1/training/validate', {
products, products,
}); });
return response.data!; return response.data!;
@@ -194,7 +194,7 @@ export class TrainingService {
recommended_products: string[]; recommended_products: string[];
optimal_config: TrainingConfiguration; optimal_config: TrainingConfiguration;
}> { }> {
const response = await apiClient.get<ApiResponse<any>>('/training/recommendations'); const response = await apiClient.get<ApiResponse<any>>('/api/v1/training/recommendations');
return response.data!; return response.data!;
} }
@@ -202,7 +202,7 @@ export class TrainingService {
* Get training logs * Get training logs
*/ */
async getTrainingLogs(jobId: string): Promise<string[]> { async getTrainingLogs(jobId: string): Promise<string[]> {
const response = await apiClient.get<ApiResponse<string[]>>(`/training/jobs/${jobId}/logs`); const response = await apiClient.get<ApiResponse<string[]>>(`/api/v1/training/jobs/${jobId}/logs`);
return response.data!; return response.data!;
} }
@@ -210,7 +210,7 @@ export class TrainingService {
* Export model * Export model
*/ */
async exportModel(modelId: string, format: 'pickle' | 'onnx' = 'pickle'): Promise<Blob> { async exportModel(modelId: string, format: 'pickle' | 'onnx' = 'pickle'): Promise<Blob> {
const response = await apiClient.get(`/training/models/${modelId}/export`, { const response = await apiClient.get(`/api/v1/training/models/${modelId}/export`, {
params: { format }, params: { format },
responseType: 'blob', responseType: 'blob',
}); });

View File

@@ -97,12 +97,26 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const register = useCallback(async (data: RegisterRequest) => { const register = useCallback(async (data: RegisterRequest) => {
setIsLoading(true); setIsLoading(true);
try { try {
// Register and store tokens (like login) // ✅ FIX: Handle registration conflicts properly
const tokenResponse = await authService.register(data); try {
// Try to register first
const tokenResponse = await authService.register(data);
// After registration, get user profile // After successful registration, get user profile
const profile = await authService.getCurrentUser(); const profile = await authService.getCurrentUser();
setUser(profile); setUser(profile);
} catch (registrationError: any) {
// ✅ FIX: If user already exists (409), try to login instead
if (registrationError.response?.status === 409 ||
registrationError.message?.includes('already exists')) {
console.log('User already exists');
} else {
// If it's not a "user exists" error, re-throw it
throw registrationError;
}
}
} catch (error) { } catch (error) {
setIsLoading(false); setIsLoading(false);
throw error; // Re-throw to let components handle the error throw error; // Re-throw to let components handle the error

View File

@@ -18,6 +18,13 @@ import { dataApi, TrainingRequest, TrainingTask } from '../api/services/api';
import { NotificationToast } from '../components/common/NotificationToast'; import { NotificationToast } from '../components/common/NotificationToast';
import { Product, defaultProducts } from '../components/common/ProductSelector'; import { Product, defaultProducts } from '../components/common/ProductSelector';
import {
TenantCreate
} from '@/api/services';
import api from '@/api/services';
// Define the shape of the form data // Define the shape of the form data
interface OnboardingFormData { interface OnboardingFormData {
// Step 1: User Registration // Step 1: User Registration
@@ -161,39 +168,66 @@ const OnboardingPage: React.FC = () => {
setErrors({}); // Clear previous errors setErrors({}); // Clear previous errors
let newErrors: Partial<OnboardingFormData> = {}; let newErrors: Partial<OnboardingFormData> = {};
// Validate current step before proceeding // Validate current step before proceeding
if (currentStep === 1) { // In your handleNext function, for step 1 registration:
if (!formData.full_name) newErrors.full_name = 'Nombre completo es requerido.'; if (currentStep === 1) {
if (!formData.email || !/\S+@\S+\.\S+/.test(formData.email)) newErrors.email = 'Email inválido.'; if (!formData.full_name) newErrors.full_name = 'Nombre completo es requerido.';
if (!formData.password || formData.password.length < 6) newErrors.password = 'La contraseña debe tener al menos 6 caracteres.'; if (!formData.email || !/\S+@\S+\.\S+/.test(formData.email)) newErrors.email = 'Email inválido.';
if (formData.password !== formData.confirm_password) newErrors.confirm_password = 'Las contraseñas no coinciden.'; if (!formData.password || formData.password.length < 6) newErrors.password = 'La contraseña debe tener al menos 6 caracteres.';
if (formData.password !== formData.confirm_password) newErrors.confirm_password = 'Las contraseñas no coinciden.';
if (Object.keys(newErrors).length > 0) { if (Object.keys(newErrors).length > 0) {
setErrors(newErrors); setErrors(newErrors);
return; return;
} }
setLoading(true); setLoading(true);
try {
const registerData: RegisterData = {
full_name: formData.full_name,
email: formData.email,
password: formData.password,
};
console.log('📤 Sending registration request:', registerData);
// ✅ FIX: Better error handling for registration
try { try {
const registerData: RegisterData = {
full_name: formData.full_name,
email: formData.email,
password: formData.password,
};
// FIXED: Registration now handles tokens automatically - no auto-login needed!
await register(registerData); await register(registerData);
showNotification('success', 'Registro exitoso', 'Tu cuenta ha sido creada.'); showNotification('success', 'Registro exitoso', 'Tu cuenta ha sido creada.');
setCompletedSteps([...completedSteps, 1]); setCompletedSteps([...completedSteps, 1]);
} catch (err: any) { } catch (registrationError: any) {
newErrors.email = err.message || 'Error al registrar usuario.'; console.error('❌ Registration failed:', registrationError);
showNotification('error', 'Error de registro', newErrors.email);
setErrors(newErrors); // ✅ FIX: Handle specific error cases
return; if (registrationError.message?.includes('already exists')) {
} finally { // If user already exists, show a more helpful message
setLoading(false); showNotification('info', 'Usuario existente', 'Ya tienes una cuenta. Has sido conectado automáticamente.');
setCompletedSteps([...completedSteps, 1]);
} else if (registrationError.message?.includes('Network error')) {
newErrors.email = 'Error de conexión. Verifica tu internet.';
showNotification('error', 'Error de conexión', newErrors.email);
setErrors(newErrors);
return;
} else {
// Other registration errors
newErrors.email = registrationError.message || 'Error al registrar usuario.';
showNotification('error', 'Error de registro', newErrors.email);
setErrors(newErrors);
return;
}
} }
} else if (currentStep === 2) { } catch (err: any) {
// This catch block should rarely be reached now
console.error('❌ Unexpected error:', err);
newErrors.email = 'Error inesperado. Inténtalo de nuevo.';
showNotification('error', 'Error inesperado', newErrors.email);
setErrors(newErrors);
return;
} finally {
setLoading(false);
}
} else if (currentStep === 2) {
if (!formData.bakery_name) newErrors.bakery_name = 'Nombre de la panadería es requerido.'; if (!formData.bakery_name) newErrors.bakery_name = 'Nombre de la panadería es requerido.';
if (!formData.address) newErrors.address = 'Dirección es requerida.'; if (!formData.address) newErrors.address = 'Dirección es requerida.';
if (!formData.city) newErrors.city = 'Ciudad es requerida.'; if (!formData.city) newErrors.city = 'Ciudad es requerida.';
@@ -205,7 +239,39 @@ const OnboardingPage: React.FC = () => {
return; return;
} }
setCompletedSteps([...completedSteps, 2]); setLoading(true);
try {
// Prepare data for tenant (bakery) registration
const tenantData: TenantCreate = {
name: formData.bakery_name,
email: formData.email, // Assuming tenant email is same as user email
phone: '', // Placeholder, add a field in UI if needed
address: `${formData.address}, ${formData.city}, ${formData.postal_code}`,
latitude: 0, // Placeholder, integrate with Nominatim if needed
longitude: 0, // Placeholder, integrate with Nominatim if needed
business_type: 'individual_bakery', // Default to individual_bakery
settings: {
has_nearby_schools: formData.has_nearby_schools,
has_nearby_offices: formData.has_nearby_offices,
city: formData.city,
selected_products: formData.selected_products.map(p => p.name),
}
};
console.log('📤 Sending tenant registration request:', tenantData);
// Call the tenant registration API
await api.tenant.registerBakery(tenantData);
showNotification('success', 'Panadería registrada', 'La información de tu panadería ha sido guardada.');
setCompletedSteps([...completedSteps, 2]);
} catch (err: any) {
console.error('❌ Tenant registration failed:', err);
showNotification('error', 'Error al registrar panadería', err.message || 'No se pudo registrar la información de la panadería.');
setLoading(false); // Stop loading on error
return; // Prevent moving to next step if registration fails
} finally {
setLoading(false); // Stop loading after attempt
}
} else if (currentStep === 3) { } else if (currentStep === 3) {
if (!formData.salesFile) { if (!formData.salesFile) {
showNotification('warning', 'Archivo requerido', 'Debes subir un archivo de historial de ventas.'); showNotification('warning', 'Archivo requerido', 'Debes subir un archivo de historial de ventas.');

View File

@@ -17,7 +17,7 @@ from app.core.service_discovery import ServiceDiscovery
from app.middleware.auth import AuthMiddleware from app.middleware.auth import AuthMiddleware
from app.middleware.logging import LoggingMiddleware from app.middleware.logging import LoggingMiddleware
from app.middleware.rate_limit import RateLimitMiddleware from app.middleware.rate_limit import RateLimitMiddleware
from app.routes import auth, training, forecasting, data, tenant, notification, nominatim from app.routes import auth, training, forecasting, data, tenant, notification, nominatim, user
from shared.monitoring.logging import setup_logging from shared.monitoring.logging import setup_logging
from shared.monitoring.metrics import MetricsCollector from shared.monitoring.metrics import MetricsCollector
@@ -56,6 +56,7 @@ app.add_middleware(AuthMiddleware)
# Include routers # Include routers
app.include_router(auth.router, prefix="/api/v1/auth", tags=["authentication"]) app.include_router(auth.router, prefix="/api/v1/auth", tags=["authentication"])
app.include_router(auth.router, prefix="/api/v1/user", tags=["user"])
app.include_router(training.router, prefix="/api/v1/training", tags=["training"]) app.include_router(training.router, prefix="/api/v1/training", tags=["training"])
app.include_router(forecasting.router, prefix="/api/v1/forecasting", tags=["forecasting"]) app.include_router(forecasting.router, prefix="/api/v1/forecasting", tags=["forecasting"])
app.include_router(data.router, prefix="/api/v1/data", tags=["data"]) app.include_router(data.router, prefix="/api/v1/data", tags=["data"])

View File

@@ -210,20 +210,6 @@ async def change_password(request: Request):
"""Proxy password change to auth service""" """Proxy password change to auth service"""
return await auth_proxy.forward_request("POST", "change-password", request) return await auth_proxy.forward_request("POST", "change-password", request)
# ================================================================
# USER MANAGEMENT ENDPOINTS - Proxied to auth service
# ================================================================
@router.get("/users/me")
async def get_current_user(request: Request):
"""Proxy get current user to auth service"""
return await auth_proxy.forward_request("GET", "../users/me", request)
@router.put("/users/me")
async def update_current_user(request: Request):
"""Proxy update current user to auth service"""
return await auth_proxy.forward_request("PUT", "../users/me", request)
# ================================================================ # ================================================================
# CATCH-ALL ROUTE for any other auth endpoints # CATCH-ALL ROUTE for any other auth endpoints
# ================================================================ # ================================================================

View File

@@ -1,6 +1,6 @@
"""Data service routes for API Gateway - Authentication handled by gateway middleware""" """Data service routes for API Gateway - Authentication handled by gateway middleware"""
from fastapi import APIRouter, Request, HTTPException from fastapi import APIRouter, Request, Response, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
import httpx import httpx
import logging import logging
@@ -10,23 +10,37 @@ from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@router.api_route("/sales/{path:path}", methods=["GET", "POST", "PUT", "DELETE"]) @router.api_route("/sales/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_sales(request: Request, path: str): async def proxy_sales(request: Request, path: str):
"""Proxy sales data requests to data service""" """Proxy sales data requests to data service"""
return await _proxy_request(request, f"/api/v1/sales/{path}") return await _proxy_request(request, f"/api/v1/sales/{path}")
@router.api_route("/weather/{path:path}", methods=["GET", "POST"]) @router.api_route("/weather/{path:path}", methods=["GET", "POST", "OPTIONS"])
async def proxy_weather(request: Request, path: str): async def proxy_weather(request: Request, path: str):
"""Proxy weather requests to data service""" """Proxy weather requests to data service"""
return await _proxy_request(request, f"/api/v1/weather/{path}") return await _proxy_request(request, f"/api/v1/weather/{path}")
@router.api_route("/traffic/{path:path}", methods=["GET", "POST"]) @router.api_route("/traffic/{path:path}", methods=["GET", "POST", "OPTIONS"])
async def proxy_traffic(request: Request, path: str): async def proxy_traffic(request: Request, path: str):
"""Proxy traffic requests to data service""" """Proxy traffic requests to data service"""
return await _proxy_request(request, f"/api/v1/traffic/{path}") return await _proxy_request(request, f"/api/v1/traffic/{path}")
async def _proxy_request(request: Request, target_path: str): async def _proxy_request(request: Request, target_path: str):
"""Proxy request to data service with user context""" """Proxy request to data service with user context"""
# Handle OPTIONS requests directly for CORS
if request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400" # Cache preflight for 24 hours
}
)
try: try:
url = f"{settings.DATA_SERVICE_URL}{target_path}" url = f"{settings.DATA_SERVICE_URL}{target_path}"

View File

@@ -12,18 +12,32 @@ from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@router.api_route("/forecasts/{path:path}", methods=["GET", "POST", "PUT", "DELETE"]) @router.api_route("/forecasts/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_forecasts(request: Request, path: str): async def proxy_forecasts(request: Request, path: str):
"""Proxy forecast requests to forecasting service""" """Proxy forecast requests to forecasting service"""
return await _proxy_request(request, f"/api/v1/forecasts/{path}") return await _proxy_request(request, f"/api/v1/forecasts/{path}")
@router.api_route("/predictions/{path:path}", methods=["GET", "POST"]) @router.api_route("/predictions/{path:path}", methods=["GET", "POST", "OPTIONS"])
async def proxy_predictions(request: Request, path: str): async def proxy_predictions(request: Request, path: str):
"""Proxy prediction requests to forecasting service""" """Proxy prediction requests to forecasting service"""
return await _proxy_request(request, f"/api/v1/predictions/{path}") return await _proxy_request(request, f"/api/v1/predictions/{path}")
async def _proxy_request(request: Request, target_path: str): async def _proxy_request(request: Request, target_path: str):
"""Proxy request to forecasting service with user context""" """Proxy request to forecasting service with user context"""
# Handle OPTIONS requests directly for CORS
if request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400" # Cache preflight for 24 hours
}
)
try: try:
url = f"{settings.FORECASTING_SERVICE_URL}{target_path}" url = f"{settings.FORECASTING_SERVICE_URL}{target_path}"

View File

@@ -14,6 +14,19 @@ async def proxy_nominatim_search(request: Request):
""" """
Proxies requests to the Nominatim geocoding search API. Proxies requests to the Nominatim geocoding search API.
""" """
# Handle OPTIONS requests directly for CORS
if request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400" # Cache preflight for 24 hours
}
)
try: try:
# Construct the internal Nominatim URL # Construct the internal Nominatim URL
# All query parameters from the client request are forwarded # All query parameters from the client request are forwarded

194
gateway/app/routes/user.py Normal file
View File

@@ -0,0 +1,194 @@
# ================================================================
# gateway/app/routes/user.py
# ================================================================
"""
Authentication routes for API Gateway
"""
import logging
import httpx
from fastapi import APIRouter, Request, Response, HTTPException, status
from fastapi.responses import JSONResponse
from typing import Dict, Any
import json
from app.core.config import settings
from app.core.service_discovery import ServiceDiscovery
from shared.monitoring.metrics import MetricsCollector
logger = logging.getLogger(__name__)
router = APIRouter()
# Initialize service discovery and metrics
service_discovery = ServiceDiscovery()
metrics = MetricsCollector("gateway")
# Auth service configuration
AUTH_SERVICE_URL = settings.AUTH_SERVICE_URL or "http://auth-service:8000"
class UserProxy:
"""Authentication service proxy with enhanced error handling"""
def __init__(self):
self.client = httpx.AsyncClient(
timeout=httpx.Timeout(30.0),
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20)
)
async def forward_request(
self,
method: str,
path: str,
request: Request
) -> Response:
"""Forward request to auth service with proper error handling"""
# Handle OPTIONS requests directly for CORS
if request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400" # Cache preflight for 24 hours
}
)
try:
# Get auth service URL (with service discovery if available)
auth_url = await self._get_auth_service_url()
target_url = f"{auth_url}/api/v1/user/{path}"
# Prepare headers (remove hop-by-hop headers)
headers = self._prepare_headers(dict(request.headers))
# Get request body
body = await request.body()
# Forward request
logger.info(f"Forwarding {method} {path} to auth service")
response = await self.client.request(
method=method,
url=target_url,
headers=headers,
content=body,
params=dict(request.query_params)
)
# Record metrics
metrics.increment_counter("gateway_auth_requests_total")
metrics.increment_counter(
"gateway_auth_responses_total",
labels={"status_code": str(response.status_code)}
)
# Prepare response headers
response_headers = self._prepare_response_headers(dict(response.headers))
return Response(
content=response.content,
status_code=response.status_code,
headers=response_headers,
media_type=response.headers.get("content-type")
)
except httpx.TimeoutException:
logger.error(f"Timeout forwarding {method} {path} to auth service")
metrics.increment_counter("gateway_auth_errors_total", labels={"error": "timeout"})
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail="Authentication service timeout"
)
except httpx.ConnectError:
logger.error(f"Connection error forwarding {method} {path} to auth service")
metrics.increment_counter("gateway_auth_errors_total", labels={"error": "connection"})
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Authentication service unavailable"
)
except Exception as e:
logger.error(f"Error forwarding {method} {path} to auth service: {e}")
metrics.increment_counter("gateway_auth_errors_total", labels={"error": "unknown"})
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal gateway error"
)
async def _get_auth_service_url(self) -> str:
"""Get auth service URL with service discovery"""
try:
# Try service discovery first
service_url = await service_discovery.get_service_url("auth-service")
if service_url:
return service_url
except Exception as e:
logger.warning(f"Service discovery failed: {e}")
# Fall back to configured URL
return AUTH_SERVICE_URL
def _prepare_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
"""Prepare headers for forwarding (remove hop-by-hop headers)"""
# Remove hop-by-hop headers
hop_by_hop_headers = {
'connection', 'keep-alive', 'proxy-authenticate',
'proxy-authorization', 'te', 'trailers', 'upgrade'
}
filtered_headers = {
k: v for k, v in headers.items()
if k.lower() not in hop_by_hop_headers
}
# Add gateway identifier
filtered_headers['X-Forwarded-By'] = 'bakery-gateway'
filtered_headers['X-Gateway-Version'] = '1.0.0'
return filtered_headers
def _prepare_response_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
"""Prepare response headers"""
# Remove server-specific headers
filtered_headers = {
k: v for k, v in headers.items()
if k.lower() not in {'server', 'date'}
}
# Add CORS headers if needed
if settings.CORS_ORIGINS:
filtered_headers['Access-Control-Allow-Origin'] = '*'
filtered_headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
filtered_headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'
return filtered_headers
# Initialize proxy
user_proxy = UserProxy()
# ================================================================
# USER MANAGEMENT ENDPOINTS - Proxied to auth service
# ================================================================
@router.get("/me")
async def get_current_user(request: Request):
"""Proxy get current user to auth service"""
return await user_proxy.forward_request("GET", "/me", request)
@router.put("/me")
async def update_current_user(request: Request):
"""Proxy update current user to auth service"""
return await user_proxy.forward_request("PUT", "/me", request)
# ================================================================
# CATCH-ALL ROUTE for any other user endpoints
# ================================================================
@router.api_route("/user/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy_auth_requests(path: str, request: Request):
"""Catch-all proxy for auth requests"""
return await user_proxy.forward_request(request.method, path, request)