Improve onboarding flow

This commit is contained in:
Urtzi Alfaro
2026-01-04 21:37:44 +01:00
parent 47ccea4900
commit 429e724a2c
13 changed files with 1052 additions and 213 deletions

View File

@@ -24,6 +24,26 @@ class OnboardingStepStatus(BaseModel):
completed_at: Optional[datetime] = None
data: Optional[Dict[str, Any]] = None
class WizardContextState(BaseModel):
"""
Wizard context state extracted from completed step data.
This is used to restore the frontend WizardContext on session restore.
"""
bakery_type: Optional[str] = None
tenant_id: Optional[str] = None
subscription_tier: Optional[str] = None
ai_analysis_complete: bool = False
inventory_review_completed: bool = False
stock_entry_completed: bool = False
categorization_completed: bool = False
suppliers_completed: bool = False
inventory_setup_completed: bool = False
recipes_completed: bool = False
quality_completed: bool = False
team_completed: bool = False
child_tenants_completed: bool = False
ml_training_complete: bool = False
class UserProgress(BaseModel):
user_id: str
steps: List[OnboardingStepStatus]
@@ -32,12 +52,17 @@ class UserProgress(BaseModel):
completion_percentage: float
fully_completed: bool
last_updated: datetime
context_state: Optional[WizardContextState] = None
class UpdateStepRequest(BaseModel):
step_name: str
completed: bool
data: Optional[Dict[str, Any]] = None
class SaveStepDraftRequest(BaseModel):
step_name: str
draft_data: Dict[str, Any]
# Define the onboarding steps and their order - matching frontend UnifiedOnboardingWizard step IDs
ONBOARDING_STEPS = [
# Phase 0: System Steps
@@ -64,6 +89,9 @@ ONBOARDING_STEPS = [
# Phase 2c: Suppliers (shared by all paths)
"suppliers-setup", # Suppliers configuration
# Phase 2d: Manual Inventory Setup (alternative to AI-assisted path)
"inventory-setup", # Manual ingredient/inventory management
# Phase 3: Advanced Configuration (all optional)
"recipes-setup", # Production recipes (conditional: production/mixed bakery)
"quality-setup", # Quality standards and templates
@@ -100,6 +128,9 @@ STEP_DEPENDENCIES = {
# Suppliers (after inventory review)
"suppliers-setup": ["user_registered", "setup", "inventory-review"],
# Manual inventory setup (alternative to AI-assisted path)
"inventory-setup": ["user_registered", "setup"],
# Advanced configuration (optional, minimal dependencies)
"recipes-setup": ["user_registered", "setup"],
"quality-setup": ["user_registered", "setup"],
@@ -181,6 +212,9 @@ class OnboardingService:
fully_completed = required_completed
# Extract wizard context state for frontend restoration
context_state = self._extract_context_state(user_progress_data)
return UserProgress(
user_id=user_id,
steps=steps,
@@ -188,7 +222,8 @@ class OnboardingService:
next_step=next_step,
completion_percentage=completion_percentage,
fully_completed=fully_completed,
last_updated=datetime.now(timezone.utc)
last_updated=datetime.now(timezone.utc),
context_state=context_state
)
async def update_step(self, user_id: str, update_request: UpdateStepRequest) -> UserProgress:
@@ -342,12 +377,65 @@ class OnboardingService:
"""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
def _extract_context_state(self, user_progress_data: Dict[str, Any]) -> WizardContextState:
"""
Extract wizard context state from completed step data.
This allows the frontend to restore WizardContext state on session restore,
ensuring conditional steps remain visible based on previous progress.
"""
# Extract bakeryType from bakery-type-selection step
bakery_step = user_progress_data.get("bakery-type-selection", {})
bakery_type = None
if bakery_step.get("completed"):
bakery_type = bakery_step.get("data", {}).get("bakeryType")
# Extract tenantId from setup step
setup_step = user_progress_data.get("setup", {})
tenant_id = None
if setup_step.get("completed"):
setup_data = setup_step.get("data", {})
tenant_id = setup_data.get("tenantId") or setup_data.get("tenant", {}).get("id")
# Extract subscription tier from user_registered step
user_registered_step = user_progress_data.get("user_registered", {})
subscription_tier = user_registered_step.get("data", {}).get("subscription_tier")
# Derive completion flags from step completion status
upload_step = user_progress_data.get("upload-sales-data", {})
inventory_review_step = user_progress_data.get("inventory-review", {})
stock_entry_step = user_progress_data.get("initial-stock-entry", {})
categorization_step = user_progress_data.get("product-categorization", {})
suppliers_step = user_progress_data.get("suppliers-setup", {})
inventory_setup_step = user_progress_data.get("inventory-setup", {})
recipes_step = user_progress_data.get("recipes-setup", {})
quality_step = user_progress_data.get("quality-setup", {})
team_step = user_progress_data.get("team-setup", {})
child_tenants_step = user_progress_data.get("child-tenants-setup", {})
ml_training_step = user_progress_data.get("ml-training", {})
return WizardContextState(
bakery_type=bakery_type,
tenant_id=tenant_id,
subscription_tier=subscription_tier,
ai_analysis_complete=upload_step.get("completed", False),
inventory_review_completed=inventory_review_step.get("completed", False),
stock_entry_completed=stock_entry_step.get("completed", False),
categorization_completed=categorization_step.get("completed", False),
suppliers_completed=suppliers_step.get("completed", False),
inventory_setup_completed=inventory_setup_step.get("completed", False),
recipes_completed=recipes_step.get("completed", False),
quality_completed=quality_step.get("completed", False),
team_completed=team_step.get("completed", False),
child_tenants_completed=child_tenants_step.get("completed", False),
ml_training_complete=ml_training_step.get("completed", False),
)
async def _can_complete_step(self, user_id: str, step_name: str) -> bool:
"""Check if user can complete a specific step"""
@@ -381,8 +469,19 @@ class OnboardingService:
required_steps.remove("bakery-type-selection")
for required_step in required_steps:
if not user_progress_data.get(required_step, {}).get("completed", False):
logger.debug(f"Step {step_name} blocked for user {user_id}: missing dependency {required_step}")
is_completed = user_progress_data.get(required_step, {}).get("completed", False)
if not is_completed:
logger.warning(
f"Step {step_name} blocked for user {user_id}: missing dependency {required_step}",
extra={
"user_id": user_id,
"step_name": step_name,
"missing_dependency": required_step,
"all_required_steps": required_steps,
"user_progress_keys": list(user_progress_data.keys()),
"dependency_data": user_progress_data.get(required_step, {})
}
)
return False
# SPECIAL VALIDATION FOR ML TRAINING STEP
@@ -537,6 +636,23 @@ async def get_user_progress(
data={"auto_completed": True, "demo_mode": True}
))
# Create a fully completed context state for demo users
demo_context_state = WizardContextState(
bakery_type="mixed",
tenant_id="demo-tenant",
subscription_tier="professional",
ai_analysis_complete=True,
inventory_review_completed=True,
stock_entry_completed=True,
categorization_completed=True,
suppliers_completed=True,
recipes_completed=True,
quality_completed=True,
team_completed=True,
child_tenants_completed=False,
ml_training_complete=True,
)
return UserProgress(
user_id=user_id,
steps=demo_steps,
@@ -544,7 +660,8 @@ async def get_user_progress(
next_step=None,
completion_percentage=100.0,
fully_completed=True,
last_updated=datetime.now(timezone.utc)
last_updated=datetime.now(timezone.utc),
context_state=demo_context_state
)
# Regular user flow
@@ -595,6 +712,23 @@ async def get_user_progress_by_id(
data={"auto_completed": True, "demo_mode": True}
))
# Create a fully completed context state for demo users
demo_context_state = WizardContextState(
bakery_type="mixed",
tenant_id="demo-tenant",
subscription_tier="professional",
ai_analysis_complete=True,
inventory_review_completed=True,
stock_entry_completed=True,
categorization_completed=True,
suppliers_completed=True,
recipes_completed=True,
quality_completed=True,
team_completed=True,
child_tenants_completed=False,
ml_training_complete=True,
)
return UserProgress(
user_id=user_id,
steps=demo_steps,
@@ -602,7 +736,8 @@ async def get_user_progress_by_id(
next_step=None,
completion_percentage=100.0,
fully_completed=True,
last_updated=datetime.now(timezone.utc)
last_updated=datetime.now(timezone.utc),
context_state=demo_context_state
)
# Regular user flow
@@ -643,6 +778,23 @@ async def update_onboarding_step(
data={"auto_completed": True, "demo_mode": True}
))
# Create a fully completed context state for demo users
demo_context_state = WizardContextState(
bakery_type="mixed",
tenant_id="demo-tenant",
subscription_tier="professional",
ai_analysis_complete=True,
inventory_review_completed=True,
stock_entry_completed=True,
categorization_completed=True,
suppliers_completed=True,
recipes_completed=True,
quality_completed=True,
team_completed=True,
child_tenants_completed=False,
ml_training_complete=True,
)
return UserProgress(
user_id=user_id,
steps=demo_steps,
@@ -650,7 +802,8 @@ async def update_onboarding_step(
next_step=None,
completion_percentage=100.0,
fully_completed=True,
last_updated=datetime.now(timezone.utc)
last_updated=datetime.now(timezone.utc),
context_state=demo_context_state
)
# Regular user flow
@@ -745,4 +898,150 @@ async def complete_onboarding(
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to complete onboarding"
)
@router.put("/api/v1/auth/me/onboarding/step-draft")
async def save_step_draft(
request: SaveStepDraftRequest,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Save in-progress step data without marking the step as complete.
This allows users to save their work and resume later.
"""
try:
user_id = current_user["user_id"]
is_demo = current_user.get("is_demo", False)
# Demo users don't save drafts
if is_demo or user_id.startswith("demo-user-"):
logger.info(f"Demo user {user_id} attempting to save draft - returning success (no-op)")
return {"success": True, "demo_mode": True}
# Validate step name
if request.step_name not in ONBOARDING_STEPS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid step name: {request.step_name}"
)
# Save the draft data
onboarding_repo = OnboardingRepository(db)
await onboarding_repo.upsert_user_step(
user_id=user_id,
step_name=request.step_name,
completed=False,
step_data={**request.draft_data, "is_draft": True, "draft_saved_at": datetime.now(timezone.utc).isoformat()}
)
logger.info(f"Saved draft for step {request.step_name} for user {user_id}")
return {"success": True, "step_name": request.step_name}
except HTTPException:
raise
except Exception as e:
logger.error(f"Save step draft error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to save step draft"
)
@router.get("/api/v1/auth/me/onboarding/step-draft/{step_name}")
async def get_step_draft(
step_name: str,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get saved draft data for a specific step.
Returns null if no draft exists or the step is already completed.
"""
try:
user_id = current_user["user_id"]
is_demo = current_user.get("is_demo", False)
# Demo users don't have drafts
if is_demo or user_id.startswith("demo-user-"):
return {"step_name": step_name, "draft_data": None, "demo_mode": True}
# 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}"
)
# Get the step data
onboarding_repo = OnboardingRepository(db)
step = await onboarding_repo.get_user_step(user_id, step_name)
# Return draft data only if step exists, is not completed, and has draft flag
if step and not step.completed and step.step_data and step.step_data.get("is_draft"):
return {
"step_name": step_name,
"draft_data": step.step_data,
"draft_saved_at": step.step_data.get("draft_saved_at")
}
return {"step_name": step_name, "draft_data": None}
except HTTPException:
raise
except Exception as e:
logger.error(f"Get step draft error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get step draft"
)
@router.delete("/api/v1/auth/me/onboarding/step-draft/{step_name}")
async def delete_step_draft(
step_name: str,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Delete saved draft data for a specific step.
Called after step is completed to clean up draft data.
"""
try:
user_id = current_user["user_id"]
is_demo = current_user.get("is_demo", False)
# Demo users don't have drafts
if is_demo or user_id.startswith("demo-user-"):
return {"success": True, "demo_mode": True}
# 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}"
)
# Get the step data and remove draft flag
onboarding_repo = OnboardingRepository(db)
step = await onboarding_repo.get_user_step(user_id, step_name)
if step and step.step_data and step.step_data.get("is_draft"):
# Remove draft-related fields but keep other data
updated_data = {k: v for k, v in step.step_data.items() if k not in ["is_draft", "draft_saved_at"]}
await onboarding_repo.upsert_user_step(
user_id=user_id,
step_name=step_name,
completed=step.completed,
step_data=updated_data
)
logger.info(f"Deleted draft for step {step_name} for user {user_id}")
return {"success": True, "step_name": step_name}
except HTTPException:
raise
except Exception as e:
logger.error(f"Delete step draft error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete step draft"
)