Improve onboarding flow
This commit is contained in:
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user