Start integrating the onboarding flow with backend 4

This commit is contained in:
Urtzi Alfaro
2025-09-05 12:55:26 +02:00
parent 0faaa25e58
commit 3fe1f17610
26 changed files with 2161 additions and 1002 deletions

View File

@@ -10,7 +10,9 @@ import {
PasswordResetConfirm,
TokenVerification,
UserResponse,
UserUpdate
UserUpdate,
OnboardingStatus,
OnboardingProgressRequest
} from '../../types/auth.types';
class AuthService {
@@ -200,6 +202,71 @@ class AuthService {
return true;
}
// Onboarding progress tracking (moved from onboarding)
async checkOnboardingStatus(): Promise<ApiResponse<OnboardingStatus>> {
try {
// Use the /me endpoint which gets proxied to auth service
const response = await apiClient.get<any>('/me');
if (response.success && response.data) {
// Extract onboarding status from user profile
const onboardingStatus = {
completed: response.data.onboarding_completed || false,
steps_completed: response.data.completed_steps || []
};
return {
success: true,
data: onboardingStatus,
message: 'Onboarding status retrieved successfully'
};
}
return {
success: false,
data: { completed: false, steps_completed: [] },
message: 'Could not retrieve onboarding status',
error: 'Invalid response data'
};
} catch (error) {
console.warn('Could not check onboarding status:', error);
return {
success: false,
data: { completed: false, steps_completed: [] },
message: 'Could not retrieve onboarding status',
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
async completeOnboarding(metadata?: any): Promise<ApiResponse<{ message: string }>> {
try {
// Update user profile to mark onboarding as complete
const response = await apiClient.patch<any>('/me', {
onboarding_completed: true,
completed_steps: ['setup', 'data-processing', 'review', 'inventory', 'suppliers', 'ml-training', 'completion'],
onboarding_metadata: metadata
});
if (response.success) {
return {
success: true,
data: { message: 'Onboarding completed successfully' },
message: 'Onboarding marked as complete'
};
}
return response;
} catch (error) {
console.warn('Could not mark onboarding as complete:', error);
return {
success: false,
data: { message: 'Failed to complete onboarding' },
message: 'Could not complete onboarding',
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
}
export const authService = new AuthService();

View File

@@ -56,6 +56,31 @@ class ApiClient {
this.setupInterceptors();
}
// Helper method to build tenant-scoped URLs
private buildTenantUrl(path: string): string {
// If path already starts with /tenants, return as-is
if (path.startsWith('/tenants/')) {
return path;
}
// If it's an auth endpoint, return as-is
if (path.startsWith('/auth')) {
return path;
}
// Get tenant ID from stores
const tenantData = getTenantData();
const authData = getAuthData();
const tenantId = tenantData?.currentTenant?.id || authData?.user?.tenant_id;
if (!tenantId) {
throw new Error('Tenant ID not available for API call');
}
// Build tenant-scoped URL: /tenants/{tenant-id}{original-path}
return `/tenants/${tenantId}${path}`;
}
private setupInterceptors(): void {
// Request interceptor - add auth token and tenant ID
this.axiosInstance.interceptors.request.use(
@@ -168,33 +193,38 @@ class ApiClient {
window.location.href = '/login';
}
// HTTP Methods with consistent response format
// HTTP Methods with consistent response format and automatic tenant scoping
async get<T = any>(url: string, config = {}): Promise<ApiResponse<T>> {
const response = await this.axiosInstance.get(url, config);
const tenantScopedUrl = this.buildTenantUrl(url);
const response = await this.axiosInstance.get(tenantScopedUrl, config);
return this.transformResponse(response);
}
async post<T = any>(url: string, data = {}, config = {}): Promise<ApiResponse<T>> {
const response = await this.axiosInstance.post(url, data, config);
const tenantScopedUrl = this.buildTenantUrl(url);
const response = await this.axiosInstance.post(tenantScopedUrl, data, config);
return this.transformResponse(response);
}
async put<T = any>(url: string, data = {}, config = {}): Promise<ApiResponse<T>> {
const response = await this.axiosInstance.put(url, data, config);
const tenantScopedUrl = this.buildTenantUrl(url);
const response = await this.axiosInstance.put(tenantScopedUrl, data, config);
return this.transformResponse(response);
}
async patch<T = any>(url: string, data = {}, config = {}): Promise<ApiResponse<T>> {
const response = await this.axiosInstance.patch(url, data, config);
const tenantScopedUrl = this.buildTenantUrl(url);
const response = await this.axiosInstance.patch(tenantScopedUrl, data, config);
return this.transformResponse(response);
}
async delete<T = any>(url: string, config = {}): Promise<ApiResponse<T>> {
const response = await this.axiosInstance.delete(url, config);
const tenantScopedUrl = this.buildTenantUrl(url);
const response = await this.axiosInstance.delete(tenantScopedUrl, config);
return this.transformResponse(response);
}
// File upload helper
// File upload helper with automatic tenant scoping
async uploadFile<T = any>(url: string, file: File, progressCallback?: (progress: number) => void): Promise<ApiResponse<T>> {
const formData = new FormData();
formData.append('file', file);
@@ -211,7 +241,8 @@ class ApiClient {
},
};
const response = await this.axiosInstance.post(url, formData, config);
const tenantScopedUrl = this.buildTenantUrl(url);
const response = await this.axiosInstance.post(tenantScopedUrl, formData, config);
return this.transformResponse(response);
}

View File

@@ -264,4 +264,5 @@ class ForecastingService {
}
}
export { ForecastingService };
export const forecastingService = new ForecastingService();

View File

@@ -9,7 +9,11 @@ import {
StockMovement,
StockAlert,
InventorySummary,
StockLevelSummary
StockLevelSummary,
ProductSuggestion,
ProductSuggestionsResponse,
InventoryCreationResponse,
BatchClassificationRequest
} from '../../types/inventory.types';
import { PaginatedResponse } from '../../types/api.types';
@@ -390,6 +394,92 @@ class InventoryService {
{ value: 'quarantine', label: 'Quarantine', color: 'purple' },
];
}
// AI-powered inventory classification and suggestions (moved from onboarding)
async generateInventorySuggestions(
productList: string[]
): Promise<ApiResponse<ProductSuggestionsResponse>> {
try {
if (!productList || !Array.isArray(productList) || productList.length === 0) {
throw new Error('Product list is empty or invalid');
}
// Transform product list into the expected format for BatchClassificationRequest
const products = productList.map(productName => ({
product_name: productName,
sales_data: {} // Additional context can be added later
}));
const requestData: BatchClassificationRequest = {
products: products
};
const response = await apiClient.post<ProductSuggestionsResponse>(
`${this.baseUrl}/classify-products-batch`,
requestData
);
return response;
} catch (error) {
console.error('Suggestion generation failed:', error);
throw error;
}
}
async createInventoryFromSuggestions(
approvedSuggestions: ProductSuggestion[]
): Promise<ApiResponse<InventoryCreationResponse>> {
try {
const createdItems: any[] = [];
const failedItems: any[] = [];
const inventoryMapping: { [productName: string]: string } = {};
// Create inventory items one by one using inventory service
for (const suggestion of approvedSuggestions) {
try {
const ingredientData = {
name: suggestion.suggested_name,
category: suggestion.category,
unit_of_measure: suggestion.unit_of_measure,
shelf_life_days: suggestion.estimated_shelf_life_days,
requires_refrigeration: suggestion.requires_refrigeration,
requires_freezing: suggestion.requires_freezing,
is_seasonal: suggestion.is_seasonal,
product_type: suggestion.product_type
};
const response = await apiClient.post<any>(
'/ingredients',
ingredientData
);
if (response.success) {
createdItems.push(response.data);
inventoryMapping[suggestion.original_name] = response.data.id;
} else {
failedItems.push({ suggestion, error: response.error });
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
failedItems.push({ suggestion, error: errorMessage });
}
}
const result = {
created_items: createdItems,
failed_items: failedItems,
total_approved: approvedSuggestions.length,
success_rate: createdItems.length / approvedSuggestions.length,
inventory_mapping: inventoryMapping
};
return { success: true, data: result };
} catch (error) {
console.error('Inventory creation failed:', error);
throw error;
}
}
}
export { InventoryService };
export const inventoryService = new InventoryService();

View File

@@ -1,496 +0,0 @@
/**
* Enhanced Onboarding API Service
* Provides integration with backend AI-powered onboarding endpoints
*/
import { apiClient } from './client';
export interface OnboardingFileValidationResponse {
is_valid: boolean;
total_records: number;
unique_products: number;
product_list: string[];
validation_errors: any[];
validation_warnings: any[];
summary: any;
}
export interface ProductSuggestion {
suggestion_id: string;
original_name: string;
suggested_name: string;
product_type: 'ingredient' | 'finished_product';
category: string;
unit_of_measure: string;
confidence_score: number;
estimated_shelf_life_days: number;
requires_refrigeration: boolean;
requires_freezing: boolean;
is_seasonal: boolean;
suggested_supplier?: string;
notes: string;
sales_data: {
total_quantity: number;
average_daily_sales: number;
peak_day: string;
frequency: number;
};
}
export interface BusinessModelAnalysis {
model: 'production' | 'retail' | 'hybrid';
confidence: number;
ingredient_count: number;
finished_product_count: number;
ingredient_ratio: number;
recommendations: string[];
}
export interface ProductSuggestionsResponse {
suggestions: ProductSuggestion[];
business_model_analysis: BusinessModelAnalysis;
total_products: number;
high_confidence_count: number;
low_confidence_count: number;
processing_time_seconds: number;
}
export interface InventoryCreationResponse {
created_items: any[];
failed_items: any[];
total_approved: number;
success_rate: number;
inventory_mapping?: { [productName: string]: string };
}
export interface SalesImportResponse {
import_job_id: string;
status: 'completed' | 'failed' | 'partial';
processed_rows: number;
successful_imports: number;
failed_imports: number;
errors: string[];
warnings: string[];
processing_time?: number;
}
export interface BusinessModelGuide {
title: string;
description: string;
next_steps: string[];
recommended_features: string[];
sample_workflows: string[];
}
class OnboardingApiService {
private readonly basePath = '/tenants';
private readonly salesBasePath = '/tenants';
/**
* Step 1: Validate uploaded file and extract unique products
* Now uses Sales Service directly
*/
async validateOnboardingFile(
tenantId: string,
file: File
): Promise<OnboardingFileValidationResponse> {
try {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post<OnboardingFileValidationResponse>(
`${this.salesBasePath}/${tenantId}/sales/import/validate`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
if (!response.success) {
throw new Error(`Validation failed: ${response.error || 'Unknown error'}`);
}
return response.data;
} catch (error) {
console.error('File validation failed:', error);
throw error;
}
}
/**
* Step 2: Generate AI-powered inventory suggestions
* Now uses Inventory Service directly
*/
async generateInventorySuggestions(
tenantId: string,
file: File,
productList: string[]
): Promise<ProductSuggestionsResponse> {
try {
if (!productList || !Array.isArray(productList) || productList.length === 0) {
throw new Error('Product list is empty or invalid');
}
// Transform product list into the expected format for BatchClassificationRequest
const products = productList.map(productName => ({
product_name: productName,
// sales_volume is optional, omit it if we don't have the data
sales_data: {} // Additional context can be added later
}));
const requestData = {
products: products
};
const response = await apiClient.post<ProductSuggestionsResponse>(
`${this.basePath}/${tenantId}/inventory/classify-products-batch`,
requestData
);
if (!response.success) {
throw new Error(`Suggestion generation failed: ${response.error || 'Unknown error'}`);
}
return response.data;
} catch (error) {
console.error('Suggestion generation failed:', error);
throw error;
}
}
/**
* Step 3: Create inventory items from approved suggestions
* Now uses Inventory Service directly
*/
async createInventoryFromSuggestions(
tenantId: string,
approvedSuggestions: any[]
): Promise<InventoryCreationResponse> {
try {
const createdItems: any[] = [];
const failedItems: any[] = [];
const inventoryMapping: { [productName: string]: string } = {};
// Create inventory items one by one using inventory service
for (const suggestion of approvedSuggestions) {
try {
const ingredientData = {
name: suggestion.suggested_name,
category: suggestion.category,
unit_of_measure: suggestion.unit_of_measure,
shelf_life_days: suggestion.estimated_shelf_life_days,
requires_refrigeration: suggestion.requires_refrigeration,
requires_freezing: suggestion.requires_freezing,
is_seasonal: suggestion.is_seasonal,
product_type: suggestion.product_type
};
const response = await apiClient.post<any>(
`${this.basePath}/${tenantId}/ingredients`,
ingredientData
);
if (response.success) {
createdItems.push(response.data);
inventoryMapping[suggestion.original_name] = response.data.id;
} else {
failedItems.push({ suggestion, error: response.error });
}
} catch (error) {
failedItems.push({ suggestion, error: error.message });
}
}
const result = {
created_items: createdItems,
failed_items: failedItems,
total_approved: approvedSuggestions.length,
success_rate: createdItems.length / approvedSuggestions.length,
inventory_mapping: inventoryMapping
};
return result;
} catch (error) {
console.error('Inventory creation failed:', error);
throw error;
}
}
/**
* Step 4: Import sales data with inventory mapping
* Now uses Sales Service directly with validation first
*/
async importSalesWithInventory(
tenantId: string,
file: File,
inventoryMapping: { [productName: string]: string }
): Promise<SalesImportResponse> {
try {
// First validate the file with inventory mapping
await this.validateSalesData(tenantId, file);
// Then import the sales data
const formData = new FormData();
formData.append('file', file);
formData.append('update_existing', 'true');
const response = await apiClient.post<SalesImportResponse>(
`${this.salesBasePath}/${tenantId}/sales/import`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
if (!response.success) {
throw new Error(`Sales import failed: ${response.error || 'Unknown error'}`);
}
return response.data;
} catch (error) {
console.error('Sales import failed:', error);
throw error;
}
}
/**
* Get business model specific recommendations
* Returns static recommendations since orchestration is removed
*/
async getBusinessModelGuide(
tenantId: string,
model: 'production' | 'retail' | 'hybrid'
): Promise<BusinessModelGuide> {
// Return static business model guides since we removed orchestration
const guides = {
production: {
title: 'Production Bakery Setup',
description: 'Your bakery focuses on creating products from raw ingredients.',
next_steps: [
'Set up ingredient inventory management',
'Configure recipe management',
'Set up production planning',
'Implement quality control processes'
],
recommended_features: [
'Inventory tracking for raw ingredients',
'Recipe costing and management',
'Production scheduling',
'Supplier management'
],
sample_workflows: [
'Daily production planning based on demand forecasts',
'Inventory reordering based on production schedules',
'Quality control checkpoints during production'
]
},
retail: {
title: 'Retail Bakery Setup',
description: 'Your bakery focuses on selling finished products to customers.',
next_steps: [
'Set up finished product inventory',
'Configure point-of-sale integration',
'Set up customer management',
'Implement sales analytics'
],
recommended_features: [
'Finished product inventory tracking',
'Sales analytics and reporting',
'Customer loyalty programs',
'Promotional campaign management'
],
sample_workflows: [
'Daily sales reporting and analysis',
'Inventory reordering based on sales velocity',
'Customer engagement and retention campaigns'
]
},
hybrid: {
title: 'Hybrid Bakery Setup',
description: 'Your bakery combines production and retail operations.',
next_steps: [
'Set up both ingredient and finished product inventory',
'Configure production-to-retail workflows',
'Set up integrated analytics',
'Implement comprehensive supplier management'
],
recommended_features: [
'Dual inventory management system',
'Production-to-sales analytics',
'Integrated supplier and customer management',
'Cross-channel reporting'
],
sample_workflows: [
'Production planning based on both wholesale and retail demand',
'Integrated inventory management across production and retail',
'Comprehensive business intelligence and reporting'
]
}
};
return guides[model] || guides.hybrid;
}
/**
* Validate sales data using the sales service (fallback)
*/
async validateSalesData(
tenantId: string,
file: File
): Promise<any> {
try {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post<any>(
`${this.salesBasePath}/${tenantId}/sales/import/validate`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
if (!response.success) {
throw new Error(`Sales validation failed: ${response.error || 'Unknown error'}`);
}
return response.data;
} catch (error) {
console.error('Sales validation failed:', error);
throw error;
}
}
/**
* Import sales data using the sales service (fallback)
*/
async importSalesData(
tenantId: string,
file: File,
updateExisting: boolean = false
): Promise<any> {
try {
const formData = new FormData();
formData.append('file', file);
formData.append('update_existing', updateExisting.toString());
const response = await apiClient.post<any>(
`${this.salesBasePath}/${tenantId}/sales/import`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
if (!response.success) {
throw new Error(`Sales import failed: ${response.error || 'Unknown error'}`);
}
return response.data;
} catch (error) {
console.error('Sales import failed:', error);
throw error;
}
}
/**
* Get sales data import template
*/
async getSalesImportTemplate(
tenantId: string,
format: 'csv' | 'json' = 'csv'
): Promise<any> {
try {
const response = await apiClient.get<any>(
`${this.salesBasePath}/${tenantId}/sales/import/template?format=${format}`
);
if (!response.success) {
throw new Error(`Failed to get template: ${response.error || 'Unknown error'}`);
}
return response.data;
} catch (error) {
console.error('Failed to get template:', error);
throw error;
}
}
/**
* Download template file (utility method)
*/
downloadTemplate(templateData: any, filename: string, format: 'csv' | 'json' = 'csv'): void {
let content: string;
let mimeType: string;
if (format === 'csv') {
content = templateData.template;
mimeType = 'text/csv';
} else {
content = JSON.stringify(templateData.template, null, 2);
mimeType = 'application/json';
}
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Utility: Check if a tenant has completed onboarding
* Uses Auth Service for user progress tracking
*/
async checkOnboardingStatus(tenantId: string): Promise<{ completed: boolean; steps_completed: string[] }> {
try {
const response = await apiClient.get<any>(
'/me/onboarding/progress'
);
return {
completed: response.data?.onboarding_completed || false,
steps_completed: response.data?.completed_steps || []
};
} catch (error) {
console.warn('Could not check onboarding status:', error);
return { completed: false, steps_completed: [] };
}
}
/**
* Utility: Mark onboarding as complete
* Uses Auth Service for user progress tracking
*/
async completeOnboarding(tenantId: string, metadata?: any): Promise<void> {
try {
await apiClient.post(
'/me/onboarding/complete',
{ metadata }
);
} catch (error) {
console.warn('Could not mark onboarding as complete:', error);
// Don't throw error, this is not critical
}
}
}
export const onboardingApiService = new OnboardingApiService();
export default OnboardingApiService;

View File

@@ -0,0 +1 @@
/Users/urtzialfaro/Documents/bakery-ia/frontend/src/services/api/orders.service.ts

View File

@@ -187,4 +187,6 @@ class OrdersService {
}
}
export { OrdersService };
export { OrdersService as OrderService }; // Alias for compatibility
export const ordersService = new OrdersService();

View File

@@ -37,8 +37,7 @@ class ProcurementService {
}
private getBaseUrl(): string {
const tenantId = this.getTenantId();
return `/tenants/${tenantId}`;
return '';
}
// Purchase Order management
@@ -216,4 +215,5 @@ class ProcurementService {
}
export { ProcurementService };
export const procurementService = new ProcurementService();

View File

@@ -464,4 +464,5 @@ class ProductionService {
}
}
export { ProductionService };
export const productionService = new ProductionService();

View File

@@ -1,4 +1,6 @@
import { apiClient, ApiResponse } from './client';
import { apiClient } from './client';
import { ApiResponse } from '../../types/api.types';
import { BusinessModelGuide, BusinessModelType, TemplateData } from '../../types/sales.types';
// Request/Response Types
export interface SalesData {
@@ -262,23 +264,44 @@ class SalesService {
}
// Data import and export
async importSalesData(file: File, progressCallback?: (progress: number) => void): Promise<ApiResponse<SalesImportResult>> {
return apiClient.uploadFile(`${this.baseUrl}/import`, file, progressCallback);
async importSalesData(file: File, progressCallback?: (progress: number) => void): Promise<{
status: 'completed' | 'failed' | 'partial';
records_processed: number;
records_created: number;
records_failed: number;
errors: string[];
warnings: string[];
processing_time?: number;
}> {
const response = await apiClient.uploadFile(`${this.baseUrl}/import`, file, progressCallback);
if (!response.success) {
throw new Error(`Sales import failed: ${response.error || response.detail || 'Unknown error'}`);
}
return response.data;
}
async validateSalesData(file: File): Promise<ApiResponse<{
valid_records: number;
invalid_records: number;
errors: Array<{
row: number;
field: string;
message: string;
}>;
preview: SalesData[];
}>> {
return apiClient.uploadFile(`${this.baseUrl}/validate`, file);
async validateSalesData(file: File): Promise<{
is_valid: boolean;
total_records: number;
unique_products: number;
product_list: string[];
errors: string[];
warnings: string[];
summary: {
date_range: string;
total_sales: number;
average_daily_sales: number;
};
}> {
const response = await apiClient.uploadFile(`${this.baseUrl}/import/validate`, file);
if (!response.success) {
throw new Error(`Validation failed: ${response.error || response.detail || 'Unknown error'}`);
}
return response.data;
}
async exportSalesData(params?: {
format?: 'csv' | 'xlsx';
start_date?: string;
@@ -438,6 +461,110 @@ class SalesService {
{ value: 'xlsx', label: 'Excel (XLSX)' },
];
}
// Business model guidance (moved from onboarding)
async getBusinessModelGuide(
model: BusinessModelType
): Promise<ApiResponse<BusinessModelGuide>> {
// Return static business model guides since we removed orchestration
const guides = {
[BusinessModelType.PRODUCTION]: {
title: 'Production Bakery Setup',
description: 'Your bakery focuses on creating products from raw ingredients.',
next_steps: [
'Set up ingredient inventory management',
'Configure recipe management',
'Set up production planning',
'Implement quality control processes'
],
recommended_features: [
'Inventory tracking for raw ingredients',
'Recipe costing and management',
'Production scheduling',
'Supplier management'
],
sample_workflows: [
'Daily production planning based on demand forecasts',
'Inventory reordering based on production schedules',
'Quality control checkpoints during production'
]
},
[BusinessModelType.RETAIL]: {
title: 'Retail Bakery Setup',
description: 'Your bakery focuses on selling finished products to customers.',
next_steps: [
'Set up finished product inventory',
'Configure point-of-sale integration',
'Set up customer management',
'Implement sales analytics'
],
recommended_features: [
'Finished product inventory tracking',
'Sales analytics and reporting',
'Customer loyalty programs',
'Promotional campaign management'
],
sample_workflows: [
'Daily sales reporting and analysis',
'Inventory reordering based on sales velocity',
'Customer engagement and retention campaigns'
]
},
[BusinessModelType.HYBRID]: {
title: 'Hybrid Bakery Setup',
description: 'Your bakery combines production and retail operations.',
next_steps: [
'Set up both ingredient and finished product inventory',
'Configure production-to-retail workflows',
'Set up integrated analytics',
'Implement comprehensive supplier management'
],
recommended_features: [
'Dual inventory management system',
'Production-to-sales analytics',
'Integrated supplier and customer management',
'Cross-channel reporting'
],
sample_workflows: [
'Production planning based on both wholesale and retail demand',
'Integrated inventory management across production and retail',
'Comprehensive business intelligence and reporting'
]
}
};
const guide = guides[model] || guides[BusinessModelType.HYBRID];
return { success: true, data: guide, message: 'Business model guide retrieved successfully' };
}
// Template download utility (moved from onboarding)
downloadTemplate(templateData: TemplateData, filename: string, format: 'csv' | 'json' = 'csv'): void {
let content: string;
let mimeType: string;
if (format === 'csv') {
content = typeof templateData.template === 'string' ? templateData.template : JSON.stringify(templateData.template);
mimeType = 'text/csv';
} else {
content = JSON.stringify(templateData.template, null, 2);
mimeType = 'application/json';
}
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
}
export { SalesService };
export const salesService = new SalesService();

View File

@@ -25,8 +25,7 @@ export class TrainingService {
}
private getBaseUrl(): string {
const tenantId = this.getTenantId();
return `/tenants/${tenantId}/training`;
return '/training';
}
async getTrainingJobs(modelId?: string): Promise<ApiResponse<TrainingJob[]>> {

View File

@@ -0,0 +1,397 @@
/**
* Storage Service - Provides secure and consistent local/session storage management
* with encryption, expiration, and type safety
*/
interface StorageOptions {
encrypt?: boolean;
expiresIn?: number; // milliseconds
storage?: 'local' | 'session';
}
interface StorageItem<T = any> {
value: T;
encrypted?: boolean;
expiresAt?: number;
createdAt: number;
}
class StorageService {
private readonly encryptionKey = 'bakery-app-key'; // In production, use proper key management
/**
* Store data in browser storage
*/
setItem<T>(key: string, value: T, options: StorageOptions = {}): boolean {
try {
const {
encrypt = false,
expiresIn,
storage = 'local'
} = options;
const storageInstance = storage === 'session' ? sessionStorage : localStorage;
const item: StorageItem = {
value: encrypt ? this.encrypt(JSON.stringify(value)) : value,
encrypted: encrypt,
createdAt: Date.now(),
...(expiresIn && { expiresAt: Date.now() + expiresIn })
};
storageInstance.setItem(key, JSON.stringify(item));
return true;
} catch (error) {
console.error(`Storage error setting item "${key}":`, error);
return false;
}
}
/**
* Retrieve data from browser storage
*/
getItem<T>(key: string, storage: 'local' | 'session' = 'local'): T | null {
try {
const storageInstance = storage === 'session' ? sessionStorage : localStorage;
const itemStr = storageInstance.getItem(key);
if (!itemStr) {
return null;
}
const item: StorageItem<T> = JSON.parse(itemStr);
// Check expiration
if (item.expiresAt && Date.now() > item.expiresAt) {
this.removeItem(key, storage);
return null;
}
// Handle encrypted data
if (item.encrypted && typeof item.value === 'string') {
try {
const decrypted = this.decrypt(item.value);
return JSON.parse(decrypted);
} catch (error) {
console.error(`Failed to decrypt item "${key}":`, error);
return null;
}
}
return item.value;
} catch (error) {
console.error(`Storage error getting item "${key}":`, error);
return null;
}
}
/**
* Remove item from storage
*/
removeItem(key: string, storage: 'local' | 'session' = 'local'): boolean {
try {
const storageInstance = storage === 'session' ? sessionStorage : localStorage;
storageInstance.removeItem(key);
return true;
} catch (error) {
console.error(`Storage error removing item "${key}":`, error);
return false;
}
}
/**
* Check if item exists and is not expired
*/
hasItem(key: string, storage: 'local' | 'session' = 'local'): boolean {
return this.getItem(key, storage) !== null;
}
/**
* Clear all items from storage
*/
clear(storage: 'local' | 'session' = 'local'): boolean {
try {
const storageInstance = storage === 'session' ? sessionStorage : localStorage;
storageInstance.clear();
return true;
} catch (error) {
console.error('Storage error clearing storage:', error);
return false;
}
}
/**
* Get all keys from storage with optional prefix filter
*/
getKeys(prefix?: string, storage: 'local' | 'session' = 'local'): string[] {
try {
const storageInstance = storage === 'session' ? sessionStorage : localStorage;
const keys: string[] = [];
for (let i = 0; i < storageInstance.length; i++) {
const key = storageInstance.key(i);
if (key && (!prefix || key.startsWith(prefix))) {
keys.push(key);
}
}
return keys;
} catch (error) {
console.error('Storage error getting keys:', error);
return [];
}
}
/**
* Get storage usage information
*/
getStorageInfo(storage: 'local' | 'session' = 'local'): {
used: number;
total: number;
available: number;
itemCount: number;
} {
try {
const storageInstance = storage === 'session' ? sessionStorage : localStorage;
// Calculate used space (approximate)
let used = 0;
for (let i = 0; i < storageInstance.length; i++) {
const key = storageInstance.key(i);
if (key) {
const value = storageInstance.getItem(key);
used += key.length + (value?.length || 0);
}
}
// Most browsers have ~5-10MB limit for localStorage
const estimated_total = 5 * 1024 * 1024; // 5MB in bytes
return {
used,
total: estimated_total,
available: estimated_total - used,
itemCount: storageInstance.length
};
} catch (error) {
console.error('Storage error getting storage info:', error);
return { used: 0, total: 0, available: 0, itemCount: 0 };
}
}
/**
* Clean expired items from storage
*/
cleanExpired(storage: 'local' | 'session' = 'local'): number {
let cleanedCount = 0;
try {
const storageInstance = storage === 'session' ? sessionStorage : localStorage;
const keysToRemove: string[] = [];
for (let i = 0; i < storageInstance.length; i++) {
const key = storageInstance.key(i);
if (key) {
try {
const itemStr = storageInstance.getItem(key);
if (itemStr) {
const item: StorageItem = JSON.parse(itemStr);
if (item.expiresAt && Date.now() > item.expiresAt) {
keysToRemove.push(key);
}
}
} catch (error) {
// If we can't parse the item, it might be corrupted
keysToRemove.push(key);
}
}
}
keysToRemove.forEach(key => {
storageInstance.removeItem(key);
cleanedCount++;
});
} catch (error) {
console.error('Storage error cleaning expired items:', error);
}
return cleanedCount;
}
/**
* Backup storage to JSON
*/
backup(storage: 'local' | 'session' = 'local'): string {
try {
const storageInstance = storage === 'session' ? sessionStorage : localStorage;
const backup: Record<string, any> = {};
for (let i = 0; i < storageInstance.length; i++) {
const key = storageInstance.key(i);
if (key) {
const value = storageInstance.getItem(key);
if (value) {
backup[key] = value;
}
}
}
return JSON.stringify({
timestamp: new Date().toISOString(),
storage: storage,
data: backup
}, null, 2);
} catch (error) {
console.error('Storage error creating backup:', error);
return '{}';
}
}
/**
* Restore storage from JSON backup
*/
restore(backupData: string, storage: 'local' | 'session' = 'local'): boolean {
try {
const backup = JSON.parse(backupData);
const storageInstance = storage === 'session' ? sessionStorage : localStorage;
if (backup.data) {
Object.entries(backup.data).forEach(([key, value]) => {
if (typeof value === 'string') {
storageInstance.setItem(key, value);
}
});
return true;
}
return false;
} catch (error) {
console.error('Storage error restoring backup:', error);
return false;
}
}
// Encryption utilities (basic implementation - use proper crypto in production)
private encrypt(text: string): string {
try {
// This is a simple XOR cipher - replace with proper encryption in production
let result = '';
for (let i = 0; i < text.length; i++) {
result += String.fromCharCode(
text.charCodeAt(i) ^ this.encryptionKey.charCodeAt(i % this.encryptionKey.length)
);
}
return btoa(result);
} catch (error) {
console.error('Encryption error:', error);
return text;
}
}
private decrypt(encryptedText: string): string {
try {
const text = atob(encryptedText);
let result = '';
for (let i = 0; i < text.length; i++) {
result += String.fromCharCode(
text.charCodeAt(i) ^ this.encryptionKey.charCodeAt(i % this.encryptionKey.length)
);
}
return result;
} catch (error) {
console.error('Decryption error:', error);
return encryptedText;
}
}
// Convenience methods for common operations
/**
* Store user authentication data
*/
setAuthData(data: {
access_token: string;
refresh_token?: string;
user_data?: any;
tenant_id?: string;
}): boolean {
const success = [
this.setItem('access_token', data.access_token, { encrypt: true }),
data.refresh_token ? this.setItem('refresh_token', data.refresh_token, { encrypt: true }) : true,
data.user_data ? this.setItem('user_data', data.user_data) : true,
data.tenant_id ? this.setItem('tenant_id', data.tenant_id) : true,
].every(Boolean);
return success;
}
/**
* Clear all authentication data
*/
clearAuthData(): boolean {
return [
this.removeItem('access_token'),
this.removeItem('refresh_token'),
this.removeItem('user_data'),
this.removeItem('tenant_id'),
].every(Boolean);
}
/**
* Store app preferences
*/
setPreferences(preferences: Record<string, any>): boolean {
return this.setItem('app_preferences', preferences);
}
/**
* Get app preferences
*/
getPreferences<T = Record<string, any>>(): T | null {
return this.getItem<T>('app_preferences');
}
/**
* Store temporary session data with automatic expiration
*/
setSessionData(key: string, data: any, expiresInMinutes: number = 30): boolean {
return this.setItem(key, data, {
storage: 'session',
expiresIn: expiresInMinutes * 60 * 1000
});
}
/**
* Get temporary session data
*/
getSessionData<T>(key: string): T | null {
return this.getItem<T>(key, 'session');
}
/**
* Check storage availability
*/
isStorageAvailable(storage: 'local' | 'session' = 'local'): boolean {
try {
const storageInstance = storage === 'session' ? sessionStorage : localStorage;
const test = '__storage_test__';
storageInstance.setItem(test, test);
storageInstance.removeItem(test);
return true;
} catch (error) {
return false;
}
}
}
// Export singleton instance
export const storageService = new StorageService();
// Export class for testing or multiple instances
export { StorageService };
// Legacy compatibility functions
export const getStorageItem = <T>(key: string): T | null => storageService.getItem<T>(key);
export const setStorageItem = <T>(key: string, value: T, options?: StorageOptions): boolean =>
storageService.setItem(key, value, options);
export const removeStorageItem = (key: string): boolean => storageService.removeItem(key);