""" 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 - matching frontend UnifiedOnboardingWizard step IDs ONBOARDING_STEPS = [ # Phase 0: System Steps "user_registered", # Auto-completed: User account created # Phase 1: Discovery "bakery-type-selection", # Choose bakery type: production/retail/mixed # Phase 2: Core Setup "setup", # Basic bakery setup and tenant creation # NOTE: POI detection now happens automatically in background during tenant registration # Phase 2a: AI-Assisted Inventory Setup (REFACTORED - split into 3 focused steps) "upload-sales-data", # File upload, validation, and AI classification "inventory-review", # Review and confirm AI-detected products with type selection "initial-stock-entry", # Capture initial stock levels # Phase 2b: Product Categorization (optional advanced categorization) "product-categorization", # Advanced categorization (may be deprecated) # Phase 2c: Suppliers (shared by all paths) "suppliers-setup", # Suppliers configuration # Phase 3: Advanced Configuration (all optional) "recipes-setup", # Production recipes (conditional: production/mixed bakery) "production-processes", # Finishing processes (conditional: retail/mixed bakery) "quality-setup", # Quality standards and templates "team-setup", # Team members and permissions # Phase 4: ML & Finalization "ml-training", # AI model training # "setup-review" removed - not useful for user, completion step is final "completion" # Onboarding completed ] # Step dependencies - defines which steps must be completed before others # Steps not listed here have no dependencies (can be completed anytime after user_registered) STEP_DEPENDENCIES = { # Discovery phase "bakery-type-selection": ["user_registered"], # Core setup - no longer depends on data-source-choice (removed) "setup": ["user_registered", "bakery-type-selection"], # NOTE: POI detection removed from steps - now happens automatically in background # AI-Assisted Inventory Setup - REFACTORED into 3 sequential steps "upload-sales-data": ["user_registered", "setup"], "inventory-review": ["user_registered", "setup", "upload-sales-data"], "initial-stock-entry": ["user_registered", "setup", "upload-sales-data", "inventory-review"], # Advanced product categorization (optional, may be deprecated) "product-categorization": ["user_registered", "setup", "upload-sales-data"], # Suppliers (after inventory review) "suppliers-setup": ["user_registered", "setup", "inventory-review"], # Advanced configuration (optional, minimal dependencies) "recipes-setup": ["user_registered", "setup"], "production-processes": ["user_registered", "setup"], "quality-setup": ["user_registered", "setup"], "team-setup": ["user_registered", "setup"], # ML Training - requires AI path completion AND POI detection for location features "ml-training": ["user_registered", "setup", "poi-detection", "upload-sales-data", "inventory-review"], # Review and completion "setup-review": ["user_registered", "setup"], "completion": ["user_registered", "setup"] # Minimal requirements for completion } 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 {} } ) # Try to update summary and handle partial failures gracefully try: # Update the user's onboarding summary await self._update_user_summary(user_id) except HTTPException as he: # If it's a 207 Multi-Status (partial success), log warning but continue if he.status_code == status.HTTP_207_MULTI_STATUS: logger.warning(f"Summary update failed for user {user_id}, step {step_name}: {he.detail}") # Continue execution - the step update was successful else: # Re-raise other HTTP exceptions raise # 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 # SPECIAL VALIDATION FOR ML TRAINING STEP if step_name == "ml-training": # ML training requires AI-assisted path completion # Check if upload-sales-data and inventory-review are completed upload_complete = user_progress_data.get("upload-sales-data", {}).get("completed", False) inventory_complete = user_progress_data.get("inventory-review", {}).get("completed", False) if upload_complete and inventory_complete: # Validate sales data was imported upload_data = user_progress_data.get("upload-sales-data", {}).get("data", {}) inventory_data = user_progress_data.get("inventory-review", {}).get("data", {}) # Check if sales data was processed has_sales_data = ( upload_data.get("validationResult", {}).get("is_valid", False) or upload_data.get("aiSuggestions", []) or inventory_data.get("inventoryItemsCreated", 0) > 0 ) if has_sales_data: logger.info(f"ML training allowed for user {user_id}: AI path with sales data") return True # AI path not complete or no sales data logger.warning(f"ML training blocked for user {user_id}: No inventory data from AI path") 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 ) 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 ) logger.debug(f"Successfully updated onboarding summary for user {user_id}") except Exception as e: logger.error(f"Error updating onboarding summary for user {user_id}: {e}", extra={"user_id": user_id, "error_type": type(e).__name__}) # Raise a warning-level HTTPException to inform frontend without breaking the flow # This allows the step update to succeed while alerting about summary issues raise HTTPException( status_code=status.HTTP_207_MULTI_STATUS, detail=f"Step updated successfully, but summary update failed: {str(e)}" ) # API Routes @router.get("/api/v1/auth/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.get("/api/v1/auth/users/{user_id}/onboarding/progress", response_model=UserProgress) async def get_user_progress_by_id( user_id: str, current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """ Get onboarding progress for a specific user Available for service-to-service calls and admin users """ # Allow service tokens or admin users user_type = current_user.get("type", "user") user_role = current_user.get("role", "user") if user_type != "service" and user_role not in ["admin", "super_admin"]: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions to access other users' onboarding progress" ) try: onboarding_service = OnboardingService(db) progress = await onboarding_service.get_user_progress(user_id) return progress except Exception as e: logger.error(f"Get onboarding progress error for user {user_id}: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get onboarding progress" ) @router.put("/api/v1/auth/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("/api/v1/auth/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("/api/v1/auth/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("/api/v1/auth/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" )