551 lines
21 KiB
Python
551 lines
21 KiB
Python
"""
|
|
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
|
|
# NOTE: POI detection happens automatically in background, not required as dependency
|
|
"ml-training": ["user_registered", "setup", "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"
|
|
) |