Add onboardin steps improvements
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
// Components
|
||||
import LoadingSpinner from './components/ui/LoadingSpinner';
|
||||
@@ -19,6 +20,9 @@ import Layout from './components/layout/Layout';
|
||||
import { store } from './store';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
// Onboarding utilities
|
||||
import { OnboardingRouter, type NextAction, type RoutingDecision } from './utils/onboardingRouter';
|
||||
|
||||
// i18n
|
||||
import './i18n';
|
||||
|
||||
@@ -33,6 +37,7 @@ interface User {
|
||||
fullName: string;
|
||||
role: string;
|
||||
isOnboardingComplete: boolean;
|
||||
tenant_id?: string;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
@@ -40,6 +45,7 @@ interface AppState {
|
||||
isLoading: boolean;
|
||||
user: User | null;
|
||||
currentPage: CurrentPage;
|
||||
routingDecision: RoutingDecision | null;
|
||||
}
|
||||
|
||||
const LoadingFallback = () => (
|
||||
@@ -56,9 +62,25 @@ const App: React.FC = () => {
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
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
|
||||
useEffect(() => {
|
||||
const initializeApp = async () => {
|
||||
@@ -69,17 +91,43 @@ const App: React.FC = () => {
|
||||
|
||||
if (token && 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({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
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 {
|
||||
// Unauthenticated user
|
||||
const routingDecision = OnboardingRouter.getNextActionForGuest();
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
currentPage: 'landing' // 👈 Show landing page for non-authenticated users
|
||||
currentPage: 'landing',
|
||||
routingDecision
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -87,7 +135,8 @@ const App: React.FC = () => {
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
currentPage: 'landing' // 👈 Fallback to landing page
|
||||
currentPage: 'landing',
|
||||
routingDecision: null
|
||||
}));
|
||||
}
|
||||
};
|
||||
@@ -95,16 +144,45 @@ const App: React.FC = () => {
|
||||
initializeApp();
|
||||
}, []);
|
||||
|
||||
const handleLogin = (user: User, token: string) => {
|
||||
const handleLogin = async (user: User, token: string) => {
|
||||
localStorage.setItem('auth_token', token);
|
||||
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({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
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 = () => {
|
||||
@@ -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 };
|
||||
localStorage.setItem('user_data', JSON.stringify(updatedUser));
|
||||
|
||||
@@ -128,6 +232,7 @@ const App: React.FC = () => {
|
||||
user: updatedUser,
|
||||
currentPage: 'dashboard'
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const navigateTo = (page: CurrentPage) => {
|
||||
|
||||
@@ -9,6 +9,16 @@ export { useData } from './useData';
|
||||
export { useTraining } from './useTraining';
|
||||
export { useForecast } from './useForecast';
|
||||
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
|
||||
export const useApiHooks = () => {
|
||||
@@ -18,6 +28,7 @@ export const useApiHooks = () => {
|
||||
const training = useTraining();
|
||||
const forecast = useForecast();
|
||||
const notification = useNotification();
|
||||
const onboarding = useOnboarding();
|
||||
|
||||
return {
|
||||
auth,
|
||||
@@ -26,5 +37,6 @@ export const useApiHooks = () => {
|
||||
training,
|
||||
forecast,
|
||||
notification,
|
||||
onboarding,
|
||||
};
|
||||
};
|
||||
194
frontend/src/api/hooks/useOnboarding.ts
Normal file
194
frontend/src/api/hooks/useOnboarding.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -28,6 +28,7 @@ export {
|
||||
useForecast,
|
||||
useNotification,
|
||||
useApiHooks,
|
||||
useOnboarding,
|
||||
} from './hooks';
|
||||
|
||||
// Export WebSocket functionality
|
||||
|
||||
@@ -11,6 +11,7 @@ import { DataService } from './data.service';
|
||||
import { TrainingService } from './training.service';
|
||||
import { ForecastingService } from './forecasting.service';
|
||||
import { NotificationService } from './notification.service';
|
||||
import { OnboardingService } from './onboarding.service';
|
||||
|
||||
// Create service instances
|
||||
export const authService = new AuthService();
|
||||
@@ -19,9 +20,10 @@ export const dataService = new DataService();
|
||||
export const trainingService = new TrainingService();
|
||||
export const forecastingService = new ForecastingService();
|
||||
export const notificationService = new NotificationService();
|
||||
export const onboardingService = new OnboardingService();
|
||||
|
||||
// Export the classes as well
|
||||
export { AuthService, TenantService, DataService, TrainingService, ForecastingService, NotificationService };
|
||||
export { AuthService, TenantService, DataService, TrainingService, ForecastingService, NotificationService, OnboardingService };
|
||||
|
||||
// Import base client
|
||||
export { apiClient } from '../client';
|
||||
@@ -37,6 +39,7 @@ export const api = {
|
||||
training: trainingService,
|
||||
forecasting: forecastingService,
|
||||
notification: notificationService,
|
||||
onboarding: onboardingService,
|
||||
} as const;
|
||||
|
||||
// Service status checking
|
||||
|
||||
92
frontend/src/api/services/onboarding.service.ts
Normal file
92
frontend/src/api/services/onboarding.service.ts
Normal 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();
|
||||
@@ -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 toast from 'react-hot-toast';
|
||||
|
||||
@@ -9,10 +9,13 @@ import {
|
||||
useTraining,
|
||||
useData,
|
||||
useTrainingWebSocket,
|
||||
useOnboarding,
|
||||
TenantCreate,
|
||||
TrainingJobRequest
|
||||
} from '../../api';
|
||||
|
||||
import { OnboardingRouter } from '../../utils/onboardingRouter';
|
||||
|
||||
interface OnboardingPageProps {
|
||||
user: any;
|
||||
onComplete: () => void;
|
||||
@@ -47,6 +50,15 @@ const MADRID_PRODUCTS = [
|
||||
const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) => {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
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>({
|
||||
name: '',
|
||||
address: '',
|
||||
@@ -56,9 +68,12 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
});
|
||||
|
||||
// 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 { createTenant, isLoading: tenantLoading } = useTenant();
|
||||
const { createTenant, getUserTenants, isLoading: tenantLoading } = useTenant();
|
||||
const { startTrainingJob } = useTraining({ disablePolling: true });
|
||||
const { uploadSalesHistory, validateSalesData } = useData();
|
||||
|
||||
@@ -78,6 +93,60 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
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
|
||||
const {
|
||||
@@ -119,8 +188,18 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
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
|
||||
setTimeout(() => {
|
||||
manualNavigation.current = true;
|
||||
setCurrentStep(4);
|
||||
}, 2000);
|
||||
|
||||
@@ -203,32 +282,238 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (validateCurrentStep()) {
|
||||
if (currentStep === 2) {
|
||||
// Always proceed to training step after CSV upload
|
||||
startTraining();
|
||||
} else {
|
||||
setCurrentStep(prev => Math.min(prev + 1, steps.length));
|
||||
const handleNext = async () => {
|
||||
console.log(`🚀 HandleNext called for step ${currentStep}`);
|
||||
|
||||
if (!validateCurrentStep()) {
|
||||
console.log('❌ Validation failed for current step');
|
||||
return;
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
manualNavigation.current = true;
|
||||
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 => {
|
||||
console.log(`🔍 Validating step ${currentStep}`);
|
||||
console.log('Bakery data:', { name: bakeryData.name, address: bakeryData.address });
|
||||
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
if (!bakeryData.name.trim()) {
|
||||
console.log('❌ Bakery name is empty');
|
||||
toast.error('El nombre de la panadería es obligatorio');
|
||||
return false;
|
||||
}
|
||||
if (!bakeryData.address.trim()) {
|
||||
console.log('❌ Bakery address is empty');
|
||||
toast.error('La dirección es obligatoria');
|
||||
return false;
|
||||
}
|
||||
console.log('✅ Step 1 validation passed');
|
||||
return true;
|
||||
case 2:
|
||||
if (!bakeryData.csvFile) {
|
||||
@@ -253,6 +538,12 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
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;
|
||||
default:
|
||||
return true;
|
||||
@@ -260,6 +551,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
};
|
||||
|
||||
const startTraining = async () => {
|
||||
manualNavigation.current = true;
|
||||
setCurrentStep(3);
|
||||
setIsLoading(true);
|
||||
|
||||
@@ -270,50 +562,11 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
return;
|
||||
}
|
||||
|
||||
// Create tenant first
|
||||
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 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'
|
||||
}));
|
||||
if (!tenantId) {
|
||||
toast.error('Error: No se ha registrado la panadería correctamente.');
|
||||
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
|
||||
const trainingRequest: TrainingJobRequest = {
|
||||
include_weather: true,
|
||||
@@ -323,7 +576,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
};
|
||||
|
||||
// Start training job using the proper API
|
||||
const trainingJob = await startTrainingJob(tenant.id, trainingRequest);
|
||||
const trainingJob = await startTrainingJob(tenantId, trainingRequest);
|
||||
setTrainingJobId(trainingJob.job_id);
|
||||
|
||||
setTrainingProgress({
|
||||
@@ -357,9 +610,24 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
// Start training process
|
||||
await startTraining();
|
||||
} 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
|
||||
toast.success('¡Configuración completada exitosamente!');
|
||||
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: 'ℹ️',
|
||||
duration: 4000
|
||||
});
|
||||
manualNavigation.current = true;
|
||||
setCurrentStep(4);
|
||||
};
|
||||
|
||||
@@ -573,6 +842,8 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
csvFile: file,
|
||||
hasHistoricalData: true
|
||||
}));
|
||||
// Reset validation status when new file is selected
|
||||
setValidationStatus({ status: 'idle' });
|
||||
toast.success(`Archivo ${file.name} seleccionado correctamente`);
|
||||
}
|
||||
}}
|
||||
@@ -582,27 +853,132 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 mr-2" />
|
||||
<CheckCircle className="h-5 w-5 text-gray-500 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-700">
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
{bakeryData.csvFile.name}
|
||||
</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'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
Quitar
|
||||
</button>
|
||||
</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="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 (
|
||||
<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">
|
||||
@@ -759,6 +1147,20 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
<p className="text-gray-600">
|
||||
Configuremos tu panadería para obtener predicciones precisas de demanda
|
||||
</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>
|
||||
|
||||
{/* 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="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>
|
||||
@@ -857,7 +1261,10 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
</>
|
||||
) : trainingProgress.status === 'completed' ? (
|
||||
<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"
|
||||
>
|
||||
Continuar
|
||||
|
||||
39
frontend/src/utils/__tests__/onboardingRouter.test.md
Normal file
39
frontend/src/utils/__tests__/onboardingRouter.test.md
Normal 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
|
||||
215
frontend/src/utils/onboardingRouter.ts
Normal file
215
frontend/src/utils/onboardingRouter.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
418
services/auth/app/api/onboarding.py
Normal file
418
services/auth/app/api/onboarding.py
Normal 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"
|
||||
)
|
||||
@@ -10,7 +10,7 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from app.core.config import settings
|
||||
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 shared.monitoring import setup_logging, HealthChecker
|
||||
from shared.monitoring.metrics import setup_metrics_early
|
||||
@@ -149,6 +149,7 @@ app.add_middleware(
|
||||
# Include routers
|
||||
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(onboarding.router, prefix="/api/v1/users", tags=["onboarding"])
|
||||
|
||||
# Health check endpoint with comprehensive checks
|
||||
@app.get("/health")
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
91
services/auth/app/models/onboarding.py
Normal file
91
services/auth/app/models/onboarding.py
Normal 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
|
||||
}
|
||||
@@ -6,9 +6,11 @@ Repository implementations for authentication service
|
||||
from .base import AuthBaseRepository
|
||||
from .user_repository import UserRepository
|
||||
from .token_repository import TokenRepository
|
||||
from .onboarding_repository import OnboardingRepository
|
||||
|
||||
__all__ = [
|
||||
"AuthBaseRepository",
|
||||
"UserRepository",
|
||||
"TokenRepository"
|
||||
"TokenRepository",
|
||||
"OnboardingRepository"
|
||||
]
|
||||
211
services/auth/app/repositories/onboarding_repository.py
Normal file
211
services/auth/app/repositories/onboarding_repository.py
Normal 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
|
||||
}
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user