Add onboardin steps improvements
This commit is contained in:
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
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,
|
useForecast,
|
||||||
useNotification,
|
useNotification,
|
||||||
useApiHooks,
|
useApiHooks,
|
||||||
|
useOnboarding,
|
||||||
} from './hooks';
|
} from './hooks';
|
||||||
|
|
||||||
// Export WebSocket functionality
|
// Export WebSocket functionality
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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 { 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
|
||||||
|
|||||||
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.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")
|
||||||
|
|||||||
@@ -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 .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"
|
||||||
]
|
]
|
||||||
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