Add onboardin steps improvements

This commit is contained in:
Urtzi Alfaro
2025-08-11 07:01:08 +02:00
parent c721575cd3
commit c4d4aeb449
16 changed files with 2015 additions and 103 deletions

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
import toast from 'react-hot-toast';
// Components // Components
import LoadingSpinner from './components/ui/LoadingSpinner'; import LoadingSpinner from './components/ui/LoadingSpinner';
@@ -19,6 +20,9 @@ import Layout from './components/layout/Layout';
import { store } from './store'; import { store } from './store';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
// Onboarding utilities
import { OnboardingRouter, type NextAction, type RoutingDecision } from './utils/onboardingRouter';
// i18n // i18n
import './i18n'; import './i18n';
@@ -33,6 +37,7 @@ interface User {
fullName: string; fullName: string;
role: string; role: string;
isOnboardingComplete: boolean; isOnboardingComplete: boolean;
tenant_id?: string;
} }
interface AppState { interface AppState {
@@ -40,6 +45,7 @@ interface AppState {
isLoading: boolean; isLoading: boolean;
user: User | null; user: User | null;
currentPage: CurrentPage; currentPage: CurrentPage;
routingDecision: RoutingDecision | null;
} }
const LoadingFallback = () => ( const LoadingFallback = () => (
@@ -56,9 +62,25 @@ const App: React.FC = () => {
isAuthenticated: false, isAuthenticated: false,
isLoading: true, isLoading: true,
user: null, user: null,
currentPage: 'landing' // 👈 Start with landing page currentPage: 'landing',
routingDecision: null
}); });
// Helper function to map NextAction to CurrentPage
const mapActionToPage = (action: NextAction): CurrentPage => {
const actionPageMap: Record<NextAction, CurrentPage> = {
'register': 'register',
'login': 'login',
'onboarding_bakery': 'onboarding',
'onboarding_data': 'onboarding',
'onboarding_training': 'onboarding',
'dashboard': 'dashboard',
'landing': 'landing'
};
return actionPageMap[action] || 'landing';
};
// Initialize app and check authentication // Initialize app and check authentication
useEffect(() => { useEffect(() => {
const initializeApp = async () => { const initializeApp = async () => {
@@ -69,17 +91,43 @@ const App: React.FC = () => {
if (token && userData) { if (token && userData) {
const user = JSON.parse(userData); const user = JSON.parse(userData);
try {
// Use enhanced onboarding router to determine next action
const routingDecision = await OnboardingRouter.getNextActionForUser();
const nextPage = mapActionToPage(routingDecision.nextAction);
setAppState({ setAppState({
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
user, user,
currentPage: user.isOnboardingComplete ? 'dashboard' : 'onboarding' currentPage: nextPage,
routingDecision
}); });
// Show welcome message with progress
if (routingDecision.message && routingDecision.completionPercentage > 0) {
toast.success(`Welcome back! ${routingDecision.message} (${Math.round(routingDecision.completionPercentage)}% complete)`);
}
} catch (onboardingError) {
// Fallback to legacy logic if onboarding API fails
console.warn('Onboarding API failed, using legacy logic:', onboardingError);
setAppState({
isAuthenticated: true,
isLoading: false,
user,
currentPage: user.isOnboardingComplete ? 'dashboard' : 'onboarding',
routingDecision: null
});
}
} else { } else {
// Unauthenticated user
const routingDecision = OnboardingRouter.getNextActionForGuest();
setAppState(prev => ({ setAppState(prev => ({
...prev, ...prev,
isLoading: false, isLoading: false,
currentPage: 'landing' // 👈 Show landing page for non-authenticated users currentPage: 'landing',
routingDecision
})); }));
} }
} catch (error) { } catch (error) {
@@ -87,7 +135,8 @@ const App: React.FC = () => {
setAppState(prev => ({ setAppState(prev => ({
...prev, ...prev,
isLoading: false, isLoading: false,
currentPage: 'landing' // 👈 Fallback to landing page currentPage: 'landing',
routingDecision: null
})); }));
} }
}; };
@@ -95,16 +144,45 @@ const App: React.FC = () => {
initializeApp(); initializeApp();
}, []); }, []);
const handleLogin = (user: User, token: string) => { const handleLogin = async (user: User, token: string) => {
localStorage.setItem('auth_token', token); localStorage.setItem('auth_token', token);
localStorage.setItem('user_data', JSON.stringify(user)); localStorage.setItem('user_data', JSON.stringify(user));
try {
// Mark user registration as complete
await OnboardingRouter.completeStep('user_registered', {
user_id: user.id,
email: user.email,
login_type: 'existing_user'
});
// Determine next action based on current progress
const routingDecision = await OnboardingRouter.getNextActionForUser();
const nextPage = mapActionToPage(routingDecision.nextAction);
setAppState({ setAppState({
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
user, user,
currentPage: user.isOnboardingComplete ? 'dashboard' : 'onboarding' currentPage: nextPage,
routingDecision
}); });
// Show progress message
if (routingDecision.message) {
toast.success(routingDecision.message);
}
} catch (error) {
console.warn('Enhanced login routing failed, using fallback:', error);
// Fallback to legacy logic
setAppState({
isAuthenticated: true,
isLoading: false,
user,
currentPage: user.isOnboardingComplete ? 'dashboard' : 'onboarding',
routingDecision: null
});
}
}; };
const handleLogout = () => { const handleLogout = () => {
@@ -119,7 +197,33 @@ const App: React.FC = () => {
}); });
}; };
const handleOnboardingComplete = () => { const handleOnboardingComplete = async () => {
try {
// Mark all onboarding steps as complete
await OnboardingRouter.completeStep('dashboard_accessible', {
completion_time: new Date().toISOString(),
user_id: appState.user?.id
});
const updatedUser = { ...appState.user!, isOnboardingComplete: true };
localStorage.setItem('user_data', JSON.stringify(updatedUser));
setAppState(prev => ({
...prev,
user: updatedUser,
currentPage: 'dashboard',
routingDecision: {
nextAction: 'dashboard',
currentStep: 'dashboard_accessible',
completionPercentage: 100,
message: 'Welcome to your PanIA dashboard!'
}
}));
toast.success('¡Configuración completada! Bienvenido a tu dashboard de PanIA 🎉');
} catch (error) {
console.warn('Enhanced onboarding completion failed, using fallback:', error);
// Fallback logic
const updatedUser = { ...appState.user!, isOnboardingComplete: true }; const updatedUser = { ...appState.user!, isOnboardingComplete: true };
localStorage.setItem('user_data', JSON.stringify(updatedUser)); localStorage.setItem('user_data', JSON.stringify(updatedUser));
@@ -128,6 +232,7 @@ const App: React.FC = () => {
user: updatedUser, user: updatedUser,
currentPage: 'dashboard' currentPage: 'dashboard'
})); }));
}
}; };
const navigateTo = (page: CurrentPage) => { const navigateTo = (page: CurrentPage) => {

View File

@@ -9,6 +9,16 @@ export { useData } from './useData';
export { useTraining } from './useTraining'; export { useTraining } from './useTraining';
export { useForecast } from './useForecast'; export { useForecast } from './useForecast';
export { useNotification } from './useNotification'; export { useNotification } from './useNotification';
export { useOnboarding, useOnboardingStep } from './useOnboarding';
// Import hooks for combined usage
import { useAuth } from './useAuth';
import { useTenant } from './useTenant';
import { useData } from './useData';
import { useTraining } from './useTraining';
import { useForecast } from './useForecast';
import { useNotification } from './useNotification';
import { useOnboarding } from './useOnboarding';
// Combined hook for common operations // Combined hook for common operations
export const useApiHooks = () => { export const useApiHooks = () => {
@@ -18,6 +28,7 @@ export const useApiHooks = () => {
const training = useTraining(); const training = useTraining();
const forecast = useForecast(); const forecast = useForecast();
const notification = useNotification(); const notification = useNotification();
const onboarding = useOnboarding();
return { return {
auth, auth,
@@ -26,5 +37,6 @@ export const useApiHooks = () => {
training, training,
forecast, forecast,
notification, notification,
onboarding,
}; };
}; };

View File

@@ -0,0 +1,194 @@
// frontend/src/api/hooks/useOnboarding.ts
/**
* Onboarding Hook
* React hook for managing user onboarding flow and progress
*/
import { useState, useEffect } from 'react';
import { onboardingService } from '../services/onboarding.service';
import type { UserProgress, UpdateStepRequest } from '../services/onboarding.service';
export interface UseOnboardingReturn {
progress: UserProgress | null;
isLoading: boolean;
error: string | null;
currentStep: string | null;
nextStep: string | null;
completionPercentage: number;
isFullyComplete: boolean;
// Actions
updateStep: (data: UpdateStepRequest) => Promise<void>;
completeStep: (stepName: string, data?: Record<string, any>) => Promise<void>;
resetStep: (stepName: string) => Promise<void>;
getNextStep: () => Promise<string>;
completeOnboarding: () => Promise<void>;
canAccessStep: (stepName: string) => Promise<boolean>;
refreshProgress: () => Promise<void>;
}
export const useOnboarding = (): UseOnboardingReturn => {
const [progress, setProgress] = useState<UserProgress | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Derived state
const currentStep = progress?.current_step || null;
const nextStep = progress?.next_step || null;
const completionPercentage = progress?.completion_percentage || 0;
const isFullyComplete = progress?.fully_completed || false;
// Load initial progress
const loadProgress = async () => {
setIsLoading(true);
setError(null);
try {
const userProgress = await onboardingService.getUserProgress();
setProgress(userProgress);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load onboarding progress';
setError(message);
console.error('Onboarding progress load error:', err);
} finally {
setIsLoading(false);
}
};
// Update step
const updateStep = async (data: UpdateStepRequest) => {
setIsLoading(true);
setError(null);
try {
const updatedProgress = await onboardingService.updateStep(data);
setProgress(updatedProgress);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update step';
setError(message);
throw err; // Re-throw so calling component can handle it
} finally {
setIsLoading(false);
}
};
// Complete step with data
const completeStep = async (stepName: string, data?: Record<string, any>) => {
await updateStep({
step_name: stepName,
completed: true,
data
});
};
// Reset step
const resetStep = async (stepName: string) => {
await updateStep({
step_name: stepName,
completed: false
});
};
// Get next step
const getNextStep = async (): Promise<string> => {
try {
const result = await onboardingService.getNextStep();
return result.step;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to get next step';
setError(message);
throw err;
}
};
// Complete entire onboarding
const completeOnboarding = async () => {
setIsLoading(true);
setError(null);
try {
await onboardingService.completeOnboarding();
await loadProgress(); // Refresh progress after completion
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to complete onboarding';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
// Check if user can access step
const canAccessStep = async (stepName: string): Promise<boolean> => {
try {
const result = await onboardingService.canAccessStep(stepName);
return result.can_access;
} catch (err) {
console.error('Can access step check failed:', err);
return false;
}
};
// Refresh progress
const refreshProgress = async () => {
await loadProgress();
};
// Load progress on mount
useEffect(() => {
loadProgress();
}, []);
return {
progress,
isLoading,
error,
currentStep,
nextStep,
completionPercentage,
isFullyComplete,
updateStep,
completeStep,
resetStep,
getNextStep,
completeOnboarding,
canAccessStep,
refreshProgress,
};
};
// Helper hook for specific steps
export const useOnboardingStep = (stepName: string) => {
const onboarding = useOnboarding();
const stepStatus = onboarding.progress?.steps.find(
step => step.step_name === stepName
);
const isCompleted = stepStatus?.completed || false;
const stepData = stepStatus?.data || {};
const completedAt = stepStatus?.completed_at;
const completeThisStep = async (data?: Record<string, any>) => {
await onboarding.completeStep(stepName, data);
};
const resetThisStep = async () => {
await onboarding.resetStep(stepName);
};
const canAccessThisStep = async (): Promise<boolean> => {
return await onboarding.canAccessStep(stepName);
};
return {
...onboarding,
stepName,
isCompleted,
stepData,
completedAt,
completeThisStep,
resetThisStep,
canAccessThisStep,
};
};

View File

@@ -28,6 +28,7 @@ export {
useForecast, useForecast,
useNotification, useNotification,
useApiHooks, useApiHooks,
useOnboarding,
} from './hooks'; } from './hooks';
// Export WebSocket functionality // Export WebSocket functionality

View File

@@ -11,6 +11,7 @@ import { DataService } from './data.service';
import { TrainingService } from './training.service'; import { TrainingService } from './training.service';
import { ForecastingService } from './forecasting.service'; import { ForecastingService } from './forecasting.service';
import { NotificationService } from './notification.service'; import { NotificationService } from './notification.service';
import { OnboardingService } from './onboarding.service';
// Create service instances // Create service instances
export const authService = new AuthService(); export const authService = new AuthService();
@@ -19,9 +20,10 @@ export const dataService = new DataService();
export const trainingService = new TrainingService(); export const trainingService = new TrainingService();
export const forecastingService = new ForecastingService(); export const forecastingService = new ForecastingService();
export const notificationService = new NotificationService(); export const notificationService = new NotificationService();
export const onboardingService = new OnboardingService();
// Export the classes as well // Export the classes as well
export { AuthService, TenantService, DataService, TrainingService, ForecastingService, NotificationService }; export { AuthService, TenantService, DataService, TrainingService, ForecastingService, NotificationService, OnboardingService };
// Import base client // Import base client
export { apiClient } from '../client'; export { apiClient } from '../client';
@@ -37,6 +39,7 @@ export const api = {
training: trainingService, training: trainingService,
forecasting: forecastingService, forecasting: forecastingService,
notification: notificationService, notification: notificationService,
onboarding: onboardingService,
} as const; } as const;
// Service status checking // Service status checking

View File

@@ -0,0 +1,92 @@
// frontend/src/api/services/onboarding.service.ts
/**
* Onboarding Service
* Handles user progress tracking and onboarding flow management
*/
import { apiClient } from '../client';
export interface OnboardingStepStatus {
step_name: string;
completed: boolean;
completed_at?: string;
data?: Record<string, any>;
}
export interface UserProgress {
user_id: string;
steps: OnboardingStepStatus[];
current_step: string;
next_step?: string;
completion_percentage: number;
fully_completed: boolean;
last_updated: string;
}
export interface UpdateStepRequest {
step_name: string;
completed: boolean;
data?: Record<string, any>;
}
export class OnboardingService {
private baseEndpoint = '/users/me/onboarding';
/**
* Get user's current onboarding progress
*/
async getUserProgress(): Promise<UserProgress> {
return apiClient.get(`${this.baseEndpoint}/progress`);
}
/**
* Update a specific onboarding step
*/
async updateStep(data: UpdateStepRequest): Promise<UserProgress> {
return apiClient.put(`${this.baseEndpoint}/step`, data);
}
/**
* Mark step as completed with optional data
*/
async completeStep(stepName: string, data?: Record<string, any>): Promise<UserProgress> {
return this.updateStep({
step_name: stepName,
completed: true,
data
});
}
/**
* Reset a step (mark as incomplete)
*/
async resetStep(stepName: string): Promise<UserProgress> {
return this.updateStep({
step_name: stepName,
completed: false
});
}
/**
* Get next required step for user
*/
async getNextStep(): Promise<{ step: string; data?: Record<string, any> }> {
return apiClient.get(`${this.baseEndpoint}/next-step`);
}
/**
* Complete entire onboarding process
*/
async completeOnboarding(): Promise<{ success: boolean; message: string }> {
return apiClient.post(`${this.baseEndpoint}/complete`);
}
/**
* Check if user can access a specific step
*/
async canAccessStep(stepName: string): Promise<{ can_access: boolean; reason?: string }> {
return apiClient.get(`${this.baseEndpoint}/can-access/${stepName}`);
}
}
export const onboardingService = new OnboardingService();

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { ChevronLeft, ChevronRight, Upload, MapPin, Store, Factory, Check, Brain, Clock, CheckCircle, AlertTriangle, Loader } from 'lucide-react'; import { ChevronLeft, ChevronRight, Upload, MapPin, Store, Factory, Check, Brain, Clock, CheckCircle, AlertTriangle, Loader } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -9,10 +9,13 @@ import {
useTraining, useTraining,
useData, useData,
useTrainingWebSocket, useTrainingWebSocket,
useOnboarding,
TenantCreate, TenantCreate,
TrainingJobRequest TrainingJobRequest
} from '../../api'; } from '../../api';
import { OnboardingRouter } from '../../utils/onboardingRouter';
interface OnboardingPageProps { interface OnboardingPageProps {
user: any; user: any;
onComplete: () => void; onComplete: () => void;
@@ -47,6 +50,15 @@ const MADRID_PRODUCTS = [
const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) => { const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) => {
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const manualNavigation = useRef(false);
// Enhanced onboarding with progress tracking
const {
progress,
isLoading: onboardingLoading,
completeStep,
refreshProgress
} = useOnboarding();
const [bakeryData, setBakeryData] = useState<BakeryData>({ const [bakeryData, setBakeryData] = useState<BakeryData>({
name: '', name: '',
address: '', address: '',
@@ -56,9 +68,12 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
}); });
// Training progress state // Training progress state
const [tenantId, setTenantId] = useState<string>(''); const [tenantId, setTenantId] = useState<string>(() => {
// Initialize from localStorage
return localStorage.getItem('current_tenant_id') || '';
});
const [trainingJobId, setTrainingJobId] = useState<string>(''); const [trainingJobId, setTrainingJobId] = useState<string>('');
const { createTenant, isLoading: tenantLoading } = useTenant(); const { createTenant, getUserTenants, isLoading: tenantLoading } = useTenant();
const { startTrainingJob } = useTraining({ disablePolling: true }); const { startTrainingJob } = useTraining({ disablePolling: true });
const { uploadSalesHistory, validateSalesData } = useData(); const { uploadSalesHistory, validateSalesData } = useData();
@@ -78,6 +93,60 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
estimatedTimeRemaining: 0 estimatedTimeRemaining: 0
}); });
// Sales data validation state
const [validationStatus, setValidationStatus] = useState<{
status: 'idle' | 'validating' | 'valid' | 'invalid';
message?: string;
records?: number;
}>({
status: 'idle'
});
// Initialize current step from onboarding progress (only when not manually navigating)
useEffect(() => {
const initializeStepFromProgress = async () => {
if (progress && !manualNavigation.current) {
// Only initialize from progress if we haven't manually navigated
try {
const { step } = await OnboardingRouter.getOnboardingStepFromProgress();
console.log('🎯 Initializing step from progress:', step);
setCurrentStep(step);
} catch (error) {
console.warn('Failed to get step from progress, using default:', error);
}
}
};
initializeStepFromProgress();
}, [progress]);
// Fetch tenant ID from backend if not available locally
useEffect(() => {
const fetchTenantIdFromBackend = async () => {
if (!tenantId && user) {
console.log('🏢 No tenant ID in localStorage, fetching from backend...');
try {
const userTenants = await getUserTenants();
console.log('📋 User tenants:', userTenants);
if (userTenants.length > 0) {
// Use the first (most recent) tenant for onboarding
const primaryTenant = userTenants[0];
console.log('✅ Found tenant from backend:', primaryTenant.id);
setTenantId(primaryTenant.id);
storeTenantId(primaryTenant.id);
} else {
console.log(' No tenants found for user - will be created in step 1');
}
} catch (error) {
console.warn('⚠️ Failed to fetch user tenants:', error);
// This is not a critical error - tenant will be created in step 1 if needed
}
}
};
fetchTenantIdFromBackend();
}, [tenantId, user, getUserTenants]);
// WebSocket connection for real-time training updates // WebSocket connection for real-time training updates
const { const {
@@ -119,8 +188,18 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
estimatedTimeRemaining: 0 estimatedTimeRemaining: 0
})); }));
// Mark training step as completed in onboarding API
completeStep('training_completed', {
training_completed_at: new Date().toISOString(),
user_id: user?.id,
tenant_id: tenantId
}).catch(error => {
console.warn('Failed to mark training as completed in API:', error);
});
// Auto-advance to final step after 2 seconds // Auto-advance to final step after 2 seconds
setTimeout(() => { setTimeout(() => {
manualNavigation.current = true;
setCurrentStep(4); setCurrentStep(4);
}, 2000); }, 2000);
@@ -203,32 +282,238 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
} }
}; };
const handleNext = () => { const handleNext = async () => {
if (validateCurrentStep()) { console.log(`🚀 HandleNext called for step ${currentStep}`);
if (currentStep === 2) {
// Always proceed to training step after CSV upload if (!validateCurrentStep()) {
startTraining(); console.log('❌ Validation failed for current step');
} else { return;
setCurrentStep(prev => Math.min(prev + 1, steps.length));
} }
setIsLoading(true);
try {
if (currentStep === 1) {
console.log('📝 Processing step 1: Register bakery');
await createBakeryAndTenant();
console.log('✅ Step 1 completed successfully');
} else if (currentStep === 2) {
console.log('📂 Processing step 2: Upload sales data');
await uploadAndValidateSalesData();
console.log('✅ Step 2 completed successfully');
}
} catch (error) {
console.error('❌ Error in step:', error);
toast.error('Error al procesar el paso. Por favor, inténtalo de nuevo.');
} finally {
setIsLoading(false);
console.log(`🔄 Loading state set to false, current step: ${currentStep}`);
} }
}; };
const handlePrevious = () => { const handlePrevious = () => {
manualNavigation.current = true;
setCurrentStep(prev => Math.max(prev - 1, 1)); setCurrentStep(prev => Math.max(prev - 1, 1));
}; };
// Create bakery and tenant for step 1
const createBakeryAndTenant = async () => {
try {
console.log('🏪 Creating bakery tenant...');
let currentTenantId = tenantId;
// Check if user already has a tenant
if (currentTenantId) {
console.log(' User already has tenant ID:', currentTenantId);
console.log('✅ Using existing tenant, skipping creation');
} else {
console.log('📝 Creating new tenant');
const tenantData: TenantCreate = {
name: bakeryData.name,
address: bakeryData.address,
business_type: "bakery",
postal_code: "28010",
phone: "+34655334455",
coordinates: bakeryData.coordinates,
products: bakeryData.products,
has_historical_data: bakeryData.hasHistoricalData,
};
const newTenant = await createTenant(tenantData);
console.log('✅ Tenant created:', newTenant.id);
currentTenantId = newTenant.id;
setTenantId(newTenant.id);
storeTenantId(newTenant.id);
}
// Mark step as completed in onboarding API (non-blocking)
try {
await completeStep('bakery_registered', {
bakery_name: bakeryData.name,
bakery_address: bakeryData.address,
business_type: bakeryData.businessType,
tenant_id: currentTenantId, // Use the correct tenant ID
user_id: user?.id
});
console.log('✅ Step marked as completed');
} catch (stepError) {
console.warn('⚠️ Failed to mark step as completed, but continuing:', stepError);
// Don't throw here - step completion is not critical for UI flow
}
toast.success('Panadería registrada correctamente');
console.log('🎯 Advancing to step 2');
manualNavigation.current = true;
setCurrentStep(2);
} catch (error) {
console.error('❌ Error creating tenant:', error);
throw new Error('Error al registrar la panadería');
}
};
// Validate sales data without importing
const validateSalesFile = async () => {
console.log('🔍 validateSalesFile called');
console.log('tenantId:', tenantId);
console.log('bakeryData.csvFile:', bakeryData.csvFile);
if (!tenantId || !bakeryData.csvFile) {
console.log('❌ Missing tenantId or csvFile');
toast.error('Falta información del tenant o archivo');
return;
}
console.log('⏳ Setting validation status to validating');
setValidationStatus({ status: 'validating' });
try {
console.log('📤 Calling validateSalesData API');
const validationResult = await validateSalesData(tenantId, bakeryData.csvFile);
console.log('📥 Validation result:', validationResult);
if (validationResult.is_valid) {
console.log('✅ Validation successful');
setValidationStatus({
status: 'valid',
message: validationResult.message,
records: validationResult.details?.total_records || 0
});
toast.success('¡Archivo validado correctamente!');
} else {
console.log('❌ Validation failed');
setValidationStatus({
status: 'invalid',
message: validationResult.message
});
toast.error(`Error en validación: ${validationResult.message}`);
}
} catch (error: any) {
console.error('❌ Error validating sales data:', error);
setValidationStatus({
status: 'invalid',
message: 'Error al validar el archivo'
});
toast.error('Error al validar el archivo');
}
};
// Upload and validate sales data for step 2
const uploadAndValidateSalesData = async () => {
if (!tenantId || !bakeryData.csvFile) {
throw new Error('Falta información del tenant o archivo');
}
try {
// If not already validated, validate first
if (validationStatus.status !== 'valid') {
const validationResult = await validateSalesData(tenantId, bakeryData.csvFile);
if (!validationResult.is_valid) {
toast.error(`Error en validación: ${validationResult.message}`);
return;
}
}
// If validation passes, upload the data
const uploadResult = await uploadSalesHistory(tenantId, bakeryData.csvFile);
// Mark step as completed
await completeStep('sales_data_uploaded', {
file_name: bakeryData.csvFile.name,
file_size: bakeryData.csvFile.size,
records_imported: uploadResult.records_imported || 0,
validation_status: 'success',
tenant_id: tenantId,
user_id: user?.id
});
toast.success(`Datos importados correctamente (${uploadResult.records_imported} registros)`);
// Automatically start training after successful upload
await startTraining();
} catch (error: any) {
console.error('Error uploading sales data:', error);
const message = error?.response?.data?.detail || error.message || 'Error al subir datos de ventas';
throw new Error(message);
}
};
// Helper function to mark current step as completed
const markStepCompleted = async () => {
const stepMap: Record<number, string> = {
1: 'bakery_registered',
2: 'sales_data_uploaded',
3: 'training_completed',
4: 'dashboard_accessible'
};
const stepName = stepMap[currentStep];
if (stepName) {
const stepData: Record<string, any> = {
completed_at: new Date().toISOString(),
user_id: user?.id
};
// Add step-specific data
if (currentStep === 1) {
stepData.bakery_name = bakeryData.name;
stepData.bakery_address = bakeryData.address;
stepData.business_type = bakeryData.businessType;
stepData.products_count = bakeryData.products.length;
} else if (currentStep === 2) {
stepData.file_name = bakeryData.csvFile?.name;
stepData.file_size = bakeryData.csvFile?.size;
stepData.file_type = bakeryData.csvFile?.type;
stepData.has_historical_data = bakeryData.hasHistoricalData;
}
await completeStep(stepName, stepData);
// Note: Not calling refreshProgress() here to avoid step reset
toast.success(`✅ Paso ${currentStep} completado`);
}
};
const validateCurrentStep = (): boolean => { const validateCurrentStep = (): boolean => {
console.log(`🔍 Validating step ${currentStep}`);
console.log('Bakery data:', { name: bakeryData.name, address: bakeryData.address });
switch (currentStep) { switch (currentStep) {
case 1: case 1:
if (!bakeryData.name.trim()) { if (!bakeryData.name.trim()) {
console.log('❌ Bakery name is empty');
toast.error('El nombre de la panadería es obligatorio'); toast.error('El nombre de la panadería es obligatorio');
return false; return false;
} }
if (!bakeryData.address.trim()) { if (!bakeryData.address.trim()) {
console.log('❌ Bakery address is empty');
toast.error('La dirección es obligatoria'); toast.error('La dirección es obligatoria');
return false; return false;
} }
console.log('✅ Step 1 validation passed');
return true; return true;
case 2: case 2:
if (!bakeryData.csvFile) { if (!bakeryData.csvFile) {
@@ -253,6 +538,12 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
return false; return false;
} }
// Check if validation was successful
if (validationStatus.status !== 'valid') {
toast.error('Por favor, valida el archivo antes de continuar');
return false;
}
return true; return true;
default: default:
return true; return true;
@@ -260,6 +551,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
}; };
const startTraining = async () => { const startTraining = async () => {
manualNavigation.current = true;
setCurrentStep(3); setCurrentStep(3);
setIsLoading(true); setIsLoading(true);
@@ -270,50 +562,11 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
return; return;
} }
// Create tenant first if (!tenantId) {
const tenantData: TenantCreate = { toast.error('Error: No se ha registrado la panadería correctamente.');
name: bakeryData.name,
address: bakeryData.address,
business_type: "bakery",
postal_code: "28010",
phone: "+34655334455",
coordinates: bakeryData.coordinates,
products: bakeryData.products,
has_historical_data: bakeryData.hasHistoricalData,
};
const tenant = await createTenant(tenantData);
setTenantId(tenant.id);
storeTenantId(tenant.id);
// Step 2: Validate and Upload CSV file if provided
if (bakeryData.csvFile) {
try {
const validationResult = await validateSalesData(tenant.id, bakeryData.csvFile);
if (!validationResult.is_valid) {
toast.error(`Error en los datos: ${validationResult.message}`);
setTrainingProgress(prev => ({
...prev,
status: 'failed',
error: 'Error en la validación de datos históricos'
}));
return; return;
} }
await uploadSalesHistory(tenant.id, bakeryData.csvFile);
toast.success('Datos históricos validados y subidos correctamente');
} catch (error) {
console.error('CSV validation/upload error:', error);
toast.error('Error al procesar los datos históricos');
setTrainingProgress(prev => ({
...prev,
status: 'failed',
error: 'Error al procesar los datos históricos'
}));
return;
}
}
// Prepare training job request - always use uploaded data since CSV is required // Prepare training job request - always use uploaded data since CSV is required
const trainingRequest: TrainingJobRequest = { const trainingRequest: TrainingJobRequest = {
include_weather: true, include_weather: true,
@@ -323,7 +576,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
}; };
// Start training job using the proper API // Start training job using the proper API
const trainingJob = await startTrainingJob(tenant.id, trainingRequest); const trainingJob = await startTrainingJob(tenantId, trainingRequest);
setTrainingJobId(trainingJob.job_id); setTrainingJobId(trainingJob.job_id);
setTrainingProgress({ setTrainingProgress({
@@ -357,9 +610,24 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
// Start training process // Start training process
await startTraining(); await startTraining();
} else { } else {
try {
// Mark final step as completed
await completeStep('dashboard_accessible', {
completion_time: new Date().toISOString(),
user_id: user?.id,
tenant_id: tenantId,
final_step: true
});
// Complete onboarding // Complete onboarding
toast.success('¡Configuración completada exitosamente!'); toast.success('¡Configuración completada exitosamente!');
onComplete(); onComplete();
} catch (error) {
console.warn('Failed to mark final step as completed:', error);
// Continue anyway for better UX
toast.success('¡Configuración completada exitosamente!');
onComplete();
}
} }
}; };
@@ -380,6 +648,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
icon: '', icon: '',
duration: 4000 duration: 4000
}); });
manualNavigation.current = true;
setCurrentStep(4); setCurrentStep(4);
}; };
@@ -573,6 +842,8 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
csvFile: file, csvFile: file,
hasHistoricalData: true hasHistoricalData: true
})); }));
// Reset validation status when new file is selected
setValidationStatus({ status: 'idle' });
toast.success(`Archivo ${file.name} seleccionado correctamente`); toast.success(`Archivo ${file.name} seleccionado correctamente`);
} }
}} }}
@@ -582,27 +853,132 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
</div> </div>
{bakeryData.csvFile ? ( {bakeryData.csvFile ? (
<div className="mt-4 p-4 bg-green-50 rounded-lg"> <div className="mt-4 space-y-3">
<div className="p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
<CheckCircle className="h-5 w-5 text-green-500 mr-2" /> <CheckCircle className="h-5 w-5 text-gray-500 mr-2" />
<div> <div>
<p className="text-sm font-medium text-green-700"> <p className="text-sm font-medium text-gray-700">
{bakeryData.csvFile.name} {bakeryData.csvFile.name}
</p> </p>
<p className="text-xs text-green-600"> <p className="text-xs text-gray-600">
{(bakeryData.csvFile.size / 1024).toFixed(1)} KB {bakeryData.csvFile.type || 'Archivo de datos'} {(bakeryData.csvFile.size / 1024).toFixed(1)} KB {bakeryData.csvFile.type || 'Archivo de datos'}
</p> </p>
</div> </div>
</div> </div>
<button <button
onClick={() => setBakeryData(prev => ({ ...prev, csvFile: undefined }))} onClick={() => {
setBakeryData(prev => ({ ...prev, csvFile: undefined }));
setValidationStatus({ status: 'idle' });
}}
className="text-red-600 hover:text-red-800 text-sm" className="text-red-600 hover:text-red-800 text-sm"
> >
Quitar Quitar
</button> </button>
</div> </div>
</div> </div>
{/* Validation Section */}
<div className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-900">
Validación de datos
</h4>
{validationStatus.status === 'validating' ? (
<Loader className="h-4 w-4 animate-spin text-blue-500" />
) : validationStatus.status === 'valid' ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : validationStatus.status === 'invalid' ? (
<AlertTriangle className="h-4 w-4 text-red-500" />
) : (
<Clock className="h-4 w-4 text-gray-400" />
)}
</div>
{validationStatus.status === 'idle' && tenantId ? (
<div className="space-y-2">
<p className="text-sm text-gray-600">
Valida tu archivo para verificar que tiene el formato correcto.
</p>
<button
onClick={validateSalesFile}
disabled={isLoading}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Validar archivo
</button>
</div>
) : (
<div className="space-y-2">
<p className="text-sm text-gray-500">
Debug: validationStatus = "{validationStatus.status}", tenantId = "{tenantId || 'not set'}"
</p>
{!tenantId ? (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
No se ha encontrado la panadería registrada.
</p>
<p className="text-xs text-yellow-600 mt-1">
Ve al paso anterior para registrar tu panadería o espera mientras se carga desde el servidor.
</p>
</div>
) : validationStatus.status !== 'idle' ? (
<button
onClick={() => setValidationStatus({ status: 'idle' })}
className="px-4 py-2 bg-gray-500 text-white text-sm rounded-lg hover:bg-gray-600"
>
Resetear validación
</button>
) : null}
</div>
)}
{validationStatus.status === 'validating' && (
<p className="text-sm text-blue-600">
Validando archivo... Por favor espera.
</p>
)}
{validationStatus.status === 'valid' && (
<div className="space-y-2">
<p className="text-sm text-green-700">
Archivo validado correctamente
</p>
{validationStatus.records && (
<p className="text-xs text-green-600">
{validationStatus.records} registros encontrados
</p>
)}
{validationStatus.message && (
<p className="text-xs text-green-600">
{validationStatus.message}
</p>
)}
</div>
)}
{validationStatus.status === 'invalid' && (
<div className="space-y-2">
<p className="text-sm text-red-700">
Error en validación
</p>
{validationStatus.message && (
<p className="text-xs text-red-600">
{validationStatus.message}
</p>
)}
<button
onClick={validateSalesFile}
disabled={isLoading}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Validar de nuevo
</button>
</div>
)}
</div>
</div>
) : ( ) : (
<div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg"> <div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center"> <div className="flex items-center">
@@ -748,6 +1124,18 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
} }
}; };
// Show loading while onboarding data is being fetched
if (onboardingLoading && !progress) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 via-white to-blue-50">
<div className="text-center">
<Loader className="h-8 w-8 animate-spin mx-auto mb-4 text-primary-500" />
<p className="text-gray-600">Cargando progreso de configuración...</p>
</div>
</div>
);
}
return ( return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-blue-50 py-8"> <div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-blue-50 py-8">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
@@ -759,6 +1147,20 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
<p className="text-gray-600"> <p className="text-gray-600">
Configuremos tu panadería para obtener predicciones precisas de demanda Configuremos tu panadería para obtener predicciones precisas de demanda
</p> </p>
{/* Progress Summary */}
{progress && (
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
<div className="flex items-center justify-center space-x-4">
<div className="text-sm text-blue-700">
<span className="font-semibold">Progreso:</span> {Math.round(progress.completion_percentage)}%
</div>
<div className="text-sm text-blue-600">
({progress.steps.filter((s: any) => s.completed).length}/{progress.steps.length} pasos completados)
</div>
</div>
</div>
)}
</div> </div>
{/* Progress Indicator */} {/* Progress Indicator */}
@@ -790,7 +1192,9 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
<div className="mt-4 bg-gray-200 rounded-full h-2"> <div className="mt-4 bg-gray-200 rounded-full h-2">
<div <div
className="bg-primary-500 h-2 rounded-full transition-all duration-500" className="bg-primary-500 h-2 rounded-full transition-all duration-500"
style={{ width: `${(currentStep / steps.length) * 100}%` }} style={{
width: `${progress ? progress.completion_percentage : (currentStep / steps.length) * 100}%`
}}
/> />
</div> </div>
</div> </div>
@@ -857,7 +1261,10 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
</> </>
) : trainingProgress.status === 'completed' ? ( ) : trainingProgress.status === 'completed' ? (
<button <button
onClick={() => setCurrentStep(4)} onClick={() => {
manualNavigation.current = true;
setCurrentStep(4);
}}
className="flex items-center px-6 py-2 bg-green-500 text-white rounded-xl hover:bg-green-600 transition-all hover:shadow-lg" className="flex items-center px-6 py-2 bg-green-500 text-white rounded-xl hover:bg-green-600 transition-all hover:shadow-lg"
> >
Continuar Continuar

View File

@@ -0,0 +1,39 @@
# Onboarding Router Test Cases
## Dashboard Access for Completed Users
### Test Case 1: Training Completed
**Given**: User has completed training (`current_step: "training_completed"`)
**When**: User logs in
**Expected**: `nextAction: "dashboard"`
**Message**: "Great! Your AI model is ready. Welcome to your dashboard!"
### Test Case 2: Dashboard Accessible
**Given**: User has reached dashboard accessible step (`current_step: "dashboard_accessible"`)
**When**: User logs in
**Expected**: `nextAction: "dashboard"`
**Message**: "Welcome back! You're ready to use your AI-powered dashboard."
### Test Case 3: High Completion Percentage
**Given**: User has `completion_percentage >= 80` but step may not be final
**When**: User logs in
**Expected**: `nextAction: "dashboard"`
### Test Case 4: Fully Completed
**Given**: User has `fully_completed: true`
**When**: User logs in
**Expected**: `nextAction: "dashboard"`
### Test Case 5: In Progress User
**Given**: User has `current_step: "sales_data_uploaded"`
**When**: User logs in
**Expected**: `nextAction: "onboarding_data"`
**Should**: Continue onboarding flow
## Manual Testing
1. Complete onboarding flow for a user
2. Log out
3. Log back in
4. Verify user goes directly to dashboard (not onboarding)
5. Check welcome message shows completion status

View File

@@ -0,0 +1,215 @@
// frontend/src/utils/onboardingRouter.ts
/**
* Onboarding Router Utility
* Determines user's next step based on their current progress
*/
import { onboardingService } from '../api/services/onboarding.service';
import type { UserProgress } from '../api/services/onboarding.service';
export type OnboardingStep =
| 'user_registered'
| 'bakery_registered'
| 'sales_data_uploaded'
| 'training_completed'
| 'dashboard_accessible';
export type NextAction =
| 'register'
| 'login'
| 'onboarding_bakery'
| 'onboarding_data'
| 'onboarding_training'
| 'dashboard'
| 'landing';
export interface RoutingDecision {
nextAction: NextAction;
currentStep: OnboardingStep | null;
message?: string;
canSkip?: boolean;
completionPercentage: number;
}
export class OnboardingRouter {
/**
* Determine next action for authenticated user
*/
static async getNextActionForUser(userProgress?: UserProgress): Promise<RoutingDecision> {
try {
const progress = userProgress || await onboardingService.getUserProgress();
// Check if user has fully completed onboarding or has high completion percentage
const isFullyCompleted = progress.fully_completed || progress.completion_percentage >= 80;
const currentStep = progress.current_step as OnboardingStep;
let nextAction: NextAction;
if (isFullyCompleted || currentStep === 'training_completed' || currentStep === 'dashboard_accessible') {
// User can access dashboard
nextAction = 'dashboard';
} else {
// Use step-based routing
nextAction = this.mapStepToAction(currentStep);
}
return {
nextAction,
currentStep,
completionPercentage: progress.completion_percentage,
message: this.getStepMessage(currentStep),
canSkip: this.canSkipStep(currentStep)
};
} catch (error) {
console.error('Error getting user progress:', error);
// Fallback logic when API fails
return {
nextAction: 'onboarding_bakery',
currentStep: 'bakery_registered',
completionPercentage: 0,
message: 'Let\'s set up your bakery information'
};
}
}
/**
* Determine next action for unauthenticated user
*/
static getNextActionForGuest(): RoutingDecision {
return {
nextAction: 'landing',
currentStep: null,
completionPercentage: 0,
message: 'Welcome to PanIA - AI for your bakery'
};
}
/**
* Check if user can access dashboard with current progress
*/
static async canAccessDashboard(): Promise<boolean> {
try {
const progress = await onboardingService.getUserProgress();
return progress.fully_completed || progress.completion_percentage >= 80;
} catch (error) {
console.error('Error checking dashboard access:', error);
return false;
}
}
/**
* Get dynamic onboarding step within the flow
*/
static async getOnboardingStepFromProgress(): Promise<{
step: number;
totalSteps: number;
canProceed: boolean;
}> {
try {
const progress = await onboardingService.getUserProgress();
const currentStep = progress.current_step as OnboardingStep;
const stepMap: Record<OnboardingStep, number> = {
'user_registered': 1,
'bakery_registered': 2,
'sales_data_uploaded': 3,
'training_completed': 4,
'dashboard_accessible': 5
};
const currentStepNumber = stepMap[currentStep] || 1;
return {
step: Math.max(currentStepNumber - 1, 1), // Convert to 1-based onboarding steps
totalSteps: 4, // We have 4 onboarding steps (excluding user registration)
canProceed: progress.completion_percentage > 0
};
} catch (error) {
console.error('Error getting onboarding step:', error);
return {
step: 1,
totalSteps: 4,
canProceed: true
};
}
}
/**
* Update progress when user completes an action
*/
static async completeStep(
step: OnboardingStep,
data?: Record<string, any>
): Promise<UserProgress> {
return await onboardingService.completeStep(step, data);
}
/**
* Check if user has completed a specific step
*/
static async hasCompletedStep(step: OnboardingStep): Promise<boolean> {
try {
const progress = await onboardingService.getUserProgress();
const stepStatus = progress.steps.find((s: any) => s.step_name === step);
return stepStatus?.completed || false;
} catch (error) {
console.error(`Error checking step completion for ${step}:`, error);
return false;
}
}
/**
* Get user-friendly message for current step
*/
private static getStepMessage(step: OnboardingStep): string {
const messages: Record<OnboardingStep, string> = {
'user_registered': 'Welcome! Your account has been created successfully.',
'bakery_registered': 'Let\'s set up your bakery information to get started.',
'sales_data_uploaded': 'Upload your historical sales data for better predictions.',
'training_completed': 'Great! Your AI model is ready. Welcome to your dashboard!',
'dashboard_accessible': 'Welcome back! You\'re ready to use your AI-powered dashboard.'
};
return messages[step] || 'Continue setting up your account';
}
/**
* Map step to corresponding frontend action
*/
private static mapStepToAction(step: OnboardingStep): NextAction {
const actionMap: Record<OnboardingStep, NextAction> = {
'user_registered': 'onboarding_bakery',
'bakery_registered': 'onboarding_bakery',
'sales_data_uploaded': 'onboarding_data',
'training_completed': 'dashboard', // ✅ Users can access dashboard when training is completed
'dashboard_accessible': 'dashboard'
};
return actionMap[step] || 'onboarding_bakery';
}
/**
* Check if user can skip a specific step
*/
private static canSkipStep(step: OnboardingStep): boolean {
// Define which steps can be skipped
const skippableSteps: OnboardingStep[] = [
'training_completed' // Users can access dashboard even if training is still in progress
];
return skippableSteps.includes(step);
}
}
// Helper functions for components
export const useOnboardingRouter = () => {
return {
getNextActionForUser: OnboardingRouter.getNextActionForUser,
getNextActionForGuest: OnboardingRouter.getNextActionForGuest,
canAccessDashboard: OnboardingRouter.canAccessDashboard,
getOnboardingStepFromProgress: OnboardingRouter.getOnboardingStepFromProgress,
completeStep: OnboardingRouter.completeStep,
hasCompletedStep: OnboardingRouter.hasCompletedStep,
};
};

View File

@@ -0,0 +1,418 @@
"""
User onboarding progress API routes
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Dict, Any, List, Optional
import structlog
from datetime import datetime, timezone
from pydantic import BaseModel
from app.core.database import get_db
from app.services.user_service import UserService
from app.repositories.onboarding_repository import OnboardingRepository
from shared.auth.decorators import get_current_user_dep
logger = structlog.get_logger()
router = APIRouter(tags=["onboarding"])
# Request/Response Models
class OnboardingStepStatus(BaseModel):
step_name: str
completed: bool
completed_at: Optional[datetime] = None
data: Optional[Dict[str, Any]] = None
class UserProgress(BaseModel):
user_id: str
steps: List[OnboardingStepStatus]
current_step: str
next_step: Optional[str] = None
completion_percentage: float
fully_completed: bool
last_updated: datetime
class UpdateStepRequest(BaseModel):
step_name: str
completed: bool
data: Optional[Dict[str, Any]] = None
# Define the onboarding steps and their order
ONBOARDING_STEPS = [
"user_registered", # Step 1: User account created
"bakery_registered", # Step 2: Bakery/tenant created
"sales_data_uploaded", # Step 3: Historical sales data uploaded
"training_completed", # Step 4: AI model training completed
"dashboard_accessible" # Step 5: Ready to use dashboard
]
STEP_DEPENDENCIES = {
"bakery_registered": ["user_registered"],
"sales_data_uploaded": ["user_registered", "bakery_registered"],
"training_completed": ["user_registered", "bakery_registered", "sales_data_uploaded"],
"dashboard_accessible": ["user_registered", "bakery_registered", "sales_data_uploaded", "training_completed"]
}
class OnboardingService:
"""Service for managing user onboarding progress"""
def __init__(self, db: AsyncSession):
self.db = db
self.user_service = UserService(db)
self.onboarding_repo = OnboardingRepository(db)
async def get_user_progress(self, user_id: str) -> UserProgress:
"""Get current onboarding progress for user"""
# Get user's onboarding data from user preferences or separate table
user_progress_data = await self._get_user_onboarding_data(user_id)
# Calculate current status for each step
steps = []
completed_steps = []
for step_name in ONBOARDING_STEPS:
step_data = user_progress_data.get(step_name, {})
is_completed = step_data.get("completed", False)
if is_completed:
completed_steps.append(step_name)
steps.append(OnboardingStepStatus(
step_name=step_name,
completed=is_completed,
completed_at=step_data.get("completed_at"),
data=step_data.get("data", {})
))
# Determine current and next step
current_step = self._get_current_step(completed_steps)
next_step = self._get_next_step(completed_steps)
# Calculate completion percentage
completion_percentage = (len(completed_steps) / len(ONBOARDING_STEPS)) * 100
# Check if fully completed
fully_completed = len(completed_steps) == len(ONBOARDING_STEPS)
return UserProgress(
user_id=user_id,
steps=steps,
current_step=current_step,
next_step=next_step,
completion_percentage=completion_percentage,
fully_completed=fully_completed,
last_updated=datetime.now(timezone.utc)
)
async def update_step(self, user_id: str, update_request: UpdateStepRequest) -> UserProgress:
"""Update a specific onboarding step"""
step_name = update_request.step_name
# Validate step name
if step_name not in ONBOARDING_STEPS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid step name: {step_name}"
)
# Check dependencies if marking as completed
if update_request.completed:
can_complete = await self._can_complete_step(user_id, step_name)
if not can_complete:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot complete step {step_name}: dependencies not met"
)
# Update the step
await self._update_user_onboarding_data(
user_id,
step_name,
{
"completed": update_request.completed,
"completed_at": datetime.now(timezone.utc).isoformat() if update_request.completed else None,
"data": update_request.data or {}
}
)
# Return updated progress
return await self.get_user_progress(user_id)
async def get_next_step(self, user_id: str) -> Dict[str, Any]:
"""Get the next required step for user"""
progress = await self.get_user_progress(user_id)
if progress.fully_completed:
return {"step": "dashboard_accessible", "completed": True}
return {"step": progress.next_step or progress.current_step}
async def can_access_step(self, user_id: str, step_name: str) -> Dict[str, Any]:
"""Check if user can access a specific step"""
if step_name not in ONBOARDING_STEPS:
return {"can_access": False, "reason": "Invalid step name"}
can_access = await self._can_complete_step(user_id, step_name)
return {"can_access": can_access}
async def complete_onboarding(self, user_id: str) -> Dict[str, Any]:
"""Mark entire onboarding as complete"""
# Ensure all steps are completed
progress = await self.get_user_progress(user_id)
if not progress.fully_completed:
incomplete_steps = [
step.step_name for step in progress.steps if not step.completed
]
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot complete onboarding: incomplete steps: {incomplete_steps}"
)
# Update user's isOnboardingComplete flag
await self.user_service.update_user_field(
user_id,
"is_onboarding_complete",
True
)
return {"success": True, "message": "Onboarding completed successfully"}
def _get_current_step(self, completed_steps: List[str]) -> str:
"""Determine current step based on completed steps"""
for step in ONBOARDING_STEPS:
if step not in completed_steps:
return step
return ONBOARDING_STEPS[-1] # All completed
def _get_next_step(self, completed_steps: List[str]) -> Optional[str]:
"""Determine next step based on completed steps"""
current_step = self._get_current_step(completed_steps)
current_index = ONBOARDING_STEPS.index(current_step)
if current_index < len(ONBOARDING_STEPS) - 1:
return ONBOARDING_STEPS[current_index + 1]
return None # No next step
async def _can_complete_step(self, user_id: str, step_name: str) -> bool:
"""Check if user can complete a specific step"""
# Get required dependencies for this step
required_steps = STEP_DEPENDENCIES.get(step_name, [])
if not required_steps:
return True # No dependencies
# Check if all required steps are completed
user_progress_data = await self._get_user_onboarding_data(user_id)
for required_step in required_steps:
if not user_progress_data.get(required_step, {}).get("completed", False):
return False
return True
async def _get_user_onboarding_data(self, user_id: str) -> Dict[str, Any]:
"""Get user's onboarding progress data from storage"""
try:
# Get all onboarding steps for the user from database
steps = await self.onboarding_repo.get_user_progress_steps(user_id)
# Convert to the expected dictionary format
progress_data = {}
for step in steps:
progress_data[step.step_name] = {
"completed": step.completed,
"completed_at": step.completed_at,
"data": step.step_data or {}
}
return progress_data
except Exception as e:
logger.error(f"Error getting onboarding data for user {user_id}: {e}")
return {}
async def _update_user_onboarding_data(
self,
user_id: str,
step_name: str,
step_data: Dict[str, Any]
):
"""Update user's onboarding step data"""
try:
# Extract the completion status and other data
completed = step_data.get("completed", False)
data_payload = step_data.get("data", {})
# Update the step in database
updated_step = await self.onboarding_repo.upsert_user_step(
user_id=user_id,
step_name=step_name,
completed=completed,
step_data=data_payload
)
# Update the user's onboarding summary
await self._update_user_summary(user_id)
logger.info(f"Successfully updated onboarding step for user {user_id}: {step_name} = {step_data}")
return updated_step
except Exception as e:
logger.error(f"Error updating onboarding data for user {user_id}, step {step_name}: {e}")
raise
async def _update_user_summary(self, user_id: str):
"""Update user's onboarding summary after step changes"""
try:
# Get updated progress
user_progress_data = await self._get_user_onboarding_data(user_id)
# Calculate current status
completed_steps = []
for step_name in ONBOARDING_STEPS:
if user_progress_data.get(step_name, {}).get("completed", False):
completed_steps.append(step_name)
# Determine current and next step
current_step = self._get_current_step(completed_steps)
next_step = self._get_next_step(completed_steps)
# Calculate completion percentage
completion_percentage = (len(completed_steps) / len(ONBOARDING_STEPS)) * 100
# Check if fully completed
fully_completed = len(completed_steps) == len(ONBOARDING_STEPS)
# Format steps count
steps_completed_count = f"{len(completed_steps)}/{len(ONBOARDING_STEPS)}"
# Update summary in database
await self.onboarding_repo.upsert_user_summary(
user_id=user_id,
current_step=current_step,
next_step=next_step,
completion_percentage=completion_percentage,
fully_completed=fully_completed,
steps_completed_count=steps_completed_count
)
except Exception as e:
logger.error(f"Error updating onboarding summary for user {user_id}: {e}")
# Don't raise here - summary update failure shouldn't break step updates
# API Routes
@router.get("/me/onboarding/progress", response_model=UserProgress)
async def get_user_progress(
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Get current user's onboarding progress"""
try:
onboarding_service = OnboardingService(db)
progress = await onboarding_service.get_user_progress(current_user["user_id"])
return progress
except Exception as e:
logger.error(f"Get onboarding progress error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get onboarding progress"
)
@router.put("/me/onboarding/step", response_model=UserProgress)
async def update_onboarding_step(
update_request: UpdateStepRequest,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Update a specific onboarding step"""
try:
onboarding_service = OnboardingService(db)
progress = await onboarding_service.update_step(
current_user["user_id"],
update_request
)
return progress
except HTTPException:
raise
except Exception as e:
logger.error(f"Update onboarding step error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update onboarding step"
)
@router.get("/me/onboarding/next-step")
async def get_next_step(
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Get next required step for user"""
try:
onboarding_service = OnboardingService(db)
result = await onboarding_service.get_next_step(current_user["user_id"])
return result
except Exception as e:
logger.error(f"Get next step error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get next step"
)
@router.get("/me/onboarding/can-access/{step_name}")
async def can_access_step(
step_name: str,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Check if user can access a specific step"""
try:
onboarding_service = OnboardingService(db)
result = await onboarding_service.can_access_step(
current_user["user_id"],
step_name
)
return result
except Exception as e:
logger.error(f"Can access step error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to check step access"
)
@router.post("/me/onboarding/complete")
async def complete_onboarding(
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Complete entire onboarding process"""
try:
onboarding_service = OnboardingService(db)
result = await onboarding_service.complete_onboarding(current_user["user_id"])
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Complete onboarding error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to complete onboarding"
)

View File

@@ -10,7 +10,7 @@ from contextlib import asynccontextmanager
from app.core.config import settings from app.core.config import settings
from app.core.database import engine, create_tables from app.core.database import engine, create_tables
from app.api import auth, users from app.api import auth, users, onboarding
from app.services.messaging import setup_messaging, cleanup_messaging from app.services.messaging import setup_messaging, cleanup_messaging
from shared.monitoring import setup_logging, HealthChecker from shared.monitoring import setup_logging, HealthChecker
from shared.monitoring.metrics import setup_metrics_early from shared.monitoring.metrics import setup_metrics_early
@@ -149,6 +149,7 @@ app.add_middleware(
# 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(users.router, prefix="/api/v1/users", tags=["users"]) app.include_router(users.router, prefix="/api/v1/users", tags=["users"])
app.include_router(onboarding.router, prefix="/api/v1/users", tags=["onboarding"])
# Health check endpoint with comprehensive checks # Health check endpoint with comprehensive checks
@app.get("/health") @app.get("/health")

View File

@@ -0,0 +1,14 @@
# services/auth/app/models/__init__.py
"""
Models export for auth service
"""
from .users import User, RefreshToken
from .onboarding import UserOnboardingProgress, UserOnboardingSummary
__all__ = [
'User',
'RefreshToken',
'UserOnboardingProgress',
'UserOnboardingSummary',
]

View File

@@ -0,0 +1,91 @@
# services/auth/app/models/onboarding.py
"""
User onboarding progress models
"""
from sqlalchemy import Column, String, Boolean, DateTime, Text, ForeignKey, JSON, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from datetime import datetime, timezone
import uuid
from shared.database.base import Base
class UserOnboardingProgress(Base):
"""User onboarding progress tracking model"""
__tablename__ = "user_onboarding_progress"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
# Step tracking
step_name = Column(String(50), nullable=False)
completed = Column(Boolean, default=False, nullable=False)
completed_at = Column(DateTime(timezone=True))
# Additional step data (JSON field for flexibility)
step_data = Column(JSON, default=dict)
# Timestamps
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# Unique constraint to prevent duplicate step entries per user
__table_args__ = (
UniqueConstraint('user_id', 'step_name', name='uq_user_step'),
{'extend_existing': True}
)
def __repr__(self):
return f"<UserOnboardingProgress(id={self.id}, user_id={self.user_id}, step={self.step_name}, completed={self.completed})>"
def to_dict(self):
"""Convert to dictionary"""
return {
"id": str(self.id),
"user_id": str(self.user_id),
"step_name": self.step_name,
"completed": self.completed,
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
"step_data": self.step_data or {},
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None
}
class UserOnboardingSummary(Base):
"""User onboarding summary for quick lookups"""
__tablename__ = "user_onboarding_summary"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
# Summary fields
current_step = Column(String(50), nullable=False, default="user_registered")
next_step = Column(String(50))
completion_percentage = Column(String(10), default="0.0") # Store as string for precision
fully_completed = Column(Boolean, default=False)
# Progress tracking
steps_completed_count = Column(String(10), default="0") # Store as string: "3/5"
# Timestamps
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
last_activity_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
def __repr__(self):
return f"<UserOnboardingSummary(user_id={self.user_id}, current_step={self.current_step}, completion={self.completion_percentage}%)>"
def to_dict(self):
"""Convert to dictionary"""
return {
"id": str(self.id),
"user_id": str(self.user_id),
"current_step": self.current_step,
"next_step": self.next_step,
"completion_percentage": float(self.completion_percentage) if self.completion_percentage else 0.0,
"fully_completed": self.fully_completed,
"steps_completed_count": self.steps_completed_count,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"last_activity_at": self.last_activity_at.isoformat() if self.last_activity_at else None
}

View File

@@ -6,9 +6,11 @@ Repository implementations for authentication service
from .base import AuthBaseRepository from .base import AuthBaseRepository
from .user_repository import UserRepository from .user_repository import UserRepository
from .token_repository import TokenRepository from .token_repository import TokenRepository
from .onboarding_repository import OnboardingRepository
__all__ = [ __all__ = [
"AuthBaseRepository", "AuthBaseRepository",
"UserRepository", "UserRepository",
"TokenRepository" "TokenRepository",
"OnboardingRepository"
] ]

View File

@@ -0,0 +1,211 @@
# services/auth/app/repositories/onboarding_repository.py
"""
Onboarding Repository for database operations
"""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete, and_
from sqlalchemy.dialects.postgresql import insert
from typing import List, Dict, Any, Optional
from datetime import datetime, timezone
import structlog
from app.models.onboarding import UserOnboardingProgress, UserOnboardingSummary
logger = structlog.get_logger()
class OnboardingRepository:
"""Repository for onboarding progress operations"""
def __init__(self, db: AsyncSession):
self.db = db
async def get_user_progress_steps(self, user_id: str) -> List[UserOnboardingProgress]:
"""Get all onboarding steps for a user"""
try:
result = await self.db.execute(
select(UserOnboardingProgress)
.where(UserOnboardingProgress.user_id == user_id)
.order_by(UserOnboardingProgress.created_at)
)
return result.scalars().all()
except Exception as e:
logger.error(f"Error getting user progress steps for {user_id}: {e}")
return []
async def get_user_step(self, user_id: str, step_name: str) -> Optional[UserOnboardingProgress]:
"""Get a specific step for a user"""
try:
result = await self.db.execute(
select(UserOnboardingProgress)
.where(
and_(
UserOnboardingProgress.user_id == user_id,
UserOnboardingProgress.step_name == step_name
)
)
)
return result.scalars().first()
except Exception as e:
logger.error(f"Error getting step {step_name} for user {user_id}: {e}")
return None
async def upsert_user_step(
self,
user_id: str,
step_name: str,
completed: bool,
step_data: Dict[str, Any] = None
) -> UserOnboardingProgress:
"""Insert or update a user's onboarding step"""
try:
completed_at = datetime.now(timezone.utc) if completed else None
step_data = step_data or {}
# Use PostgreSQL UPSERT (INSERT ... ON CONFLICT ... DO UPDATE)
stmt = insert(UserOnboardingProgress).values(
user_id=user_id,
step_name=step_name,
completed=completed,
completed_at=completed_at,
step_data=step_data,
updated_at=datetime.now(timezone.utc)
)
# On conflict, update the existing record
stmt = stmt.on_conflict_do_update(
index_elements=['user_id', 'step_name'],
set_=dict(
completed=stmt.excluded.completed,
completed_at=stmt.excluded.completed_at,
step_data=stmt.excluded.step_data,
updated_at=stmt.excluded.updated_at
)
)
# Return the updated record
stmt = stmt.returning(UserOnboardingProgress)
result = await self.db.execute(stmt)
await self.db.commit()
return result.scalars().first()
except Exception as e:
logger.error(f"Error upserting step {step_name} for user {user_id}: {e}")
await self.db.rollback()
raise
async def get_user_summary(self, user_id: str) -> Optional[UserOnboardingSummary]:
"""Get user's onboarding summary"""
try:
result = await self.db.execute(
select(UserOnboardingSummary)
.where(UserOnboardingSummary.user_id == user_id)
)
return result.scalars().first()
except Exception as e:
logger.error(f"Error getting onboarding summary for user {user_id}: {e}")
return None
async def upsert_user_summary(
self,
user_id: str,
current_step: str,
next_step: Optional[str],
completion_percentage: float,
fully_completed: bool,
steps_completed_count: str
) -> UserOnboardingSummary:
"""Insert or update user's onboarding summary"""
try:
# Use PostgreSQL UPSERT
stmt = insert(UserOnboardingSummary).values(
user_id=user_id,
current_step=current_step,
next_step=next_step,
completion_percentage=str(completion_percentage),
fully_completed=fully_completed,
steps_completed_count=steps_completed_count,
updated_at=datetime.now(timezone.utc),
last_activity_at=datetime.now(timezone.utc)
)
# On conflict, update the existing record
stmt = stmt.on_conflict_do_update(
index_elements=['user_id'],
set_=dict(
current_step=stmt.excluded.current_step,
next_step=stmt.excluded.next_step,
completion_percentage=stmt.excluded.completion_percentage,
fully_completed=stmt.excluded.fully_completed,
steps_completed_count=stmt.excluded.steps_completed_count,
updated_at=stmt.excluded.updated_at,
last_activity_at=stmt.excluded.last_activity_at
)
)
# Return the updated record
stmt = stmt.returning(UserOnboardingSummary)
result = await self.db.execute(stmt)
await self.db.commit()
return result.scalars().first()
except Exception as e:
logger.error(f"Error upserting summary for user {user_id}: {e}")
await self.db.rollback()
raise
async def delete_user_progress(self, user_id: str) -> bool:
"""Delete all onboarding progress for a user"""
try:
# Delete steps
await self.db.execute(
delete(UserOnboardingProgress)
.where(UserOnboardingProgress.user_id == user_id)
)
# Delete summary
await self.db.execute(
delete(UserOnboardingSummary)
.where(UserOnboardingSummary.user_id == user_id)
)
await self.db.commit()
return True
except Exception as e:
logger.error(f"Error deleting progress for user {user_id}: {e}")
await self.db.rollback()
return False
async def get_completion_stats(self) -> Dict[str, Any]:
"""Get completion statistics across all users"""
try:
# Get total users with onboarding data
total_result = await self.db.execute(
select(UserOnboardingSummary).count()
)
total_users = total_result.scalar()
# Get completed users
completed_result = await self.db.execute(
select(UserOnboardingSummary)
.where(UserOnboardingSummary.fully_completed == True)
.count()
)
completed_users = completed_result.scalar()
return {
"total_users_in_onboarding": total_users,
"fully_completed_users": completed_users,
"completion_rate": (completed_users / total_users * 100) if total_users > 0 else 0
}
except Exception as e:
logger.error(f"Error getting completion stats: {e}")
return {
"total_users_in_onboarding": 0,
"fully_completed_users": 0,
"completion_rate": 0
}

View File

@@ -0,0 +1,107 @@
"""Initial onboarding progress tables
Revision ID: 001_initial_onboarding_tables
Revises:
Create Date: 2024-12-20 15:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '001_initial_onboarding_tables'
down_revision = None # No previous migration
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create users table first (if it doesn't exist)
op.create_table(
'users',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, primary_key=True),
sa.Column('email', sa.String(length=255), nullable=False, unique=True, index=True),
sa.Column('hashed_password', sa.String(length=255), nullable=False),
sa.Column('full_name', sa.String(length=255), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
sa.Column('is_verified', sa.Boolean(), nullable=False, default=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('last_login', sa.DateTime(timezone=True), nullable=True),
sa.Column('phone', sa.String(length=20), nullable=True),
sa.Column('language', sa.String(length=10), nullable=True, default='es'),
sa.Column('timezone', sa.String(length=50), nullable=True, default='Europe/Madrid'),
sa.Column('role', sa.String(length=20), nullable=False),
)
# Create refresh_tokens table
op.create_table(
'refresh_tokens',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, primary_key=True),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('token', sa.String(length=500), nullable=False, unique=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('is_revoked', sa.Boolean(), nullable=False, default=False),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
# Foreign key to users table
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
)
# Create user_onboarding_progress table
op.create_table(
'user_onboarding_progress',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, primary_key=True),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False, index=True),
sa.Column('step_name', sa.String(length=50), nullable=False),
sa.Column('completed', sa.Boolean(), nullable=False, default=False),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('step_data', sa.JSON(), nullable=True, default=dict),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
# Foreign key to users table
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
# Unique constraint to prevent duplicate steps per user
sa.UniqueConstraint('user_id', 'step_name', name='uq_user_step'),
# Indexes for performance
sa.Index('ix_user_onboarding_progress_user_id', 'user_id'),
sa.Index('ix_user_onboarding_progress_step_name', 'step_name'),
sa.Index('ix_user_onboarding_progress_completed', 'completed'),
)
# Create user_onboarding_summary table
op.create_table(
'user_onboarding_summary',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, primary_key=True),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False, unique=True, index=True),
sa.Column('current_step', sa.String(length=50), nullable=False, default='user_registered'),
sa.Column('next_step', sa.String(length=50), nullable=True),
sa.Column('completion_percentage', sa.String(length=10), nullable=True, default='0.0'),
sa.Column('fully_completed', sa.Boolean(), nullable=False, default=False),
sa.Column('steps_completed_count', sa.String(length=10), nullable=True, default='0'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('last_activity_at', sa.DateTime(timezone=True), nullable=False),
# Foreign key to users table
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
# Indexes for performance
sa.Index('ix_user_onboarding_summary_user_id', 'user_id'),
sa.Index('ix_user_onboarding_summary_current_step', 'current_step'),
sa.Index('ix_user_onboarding_summary_fully_completed', 'fully_completed'),
)
def downgrade() -> None:
# Drop tables in reverse order
op.drop_table('user_onboarding_summary')
op.drop_table('user_onboarding_progress')
op.drop_table('refresh_tokens')
op.drop_table('users')