Onboarding Progress Service Implementation
This commit is contained in:
@@ -5,9 +5,7 @@ from fastapi import HTTPException, Depends
|
||||
|
||||
from middleware.auth_middleware import get_current_user
|
||||
|
||||
from .endpoint_models import (
|
||||
get_onboarding_progress_for_user,
|
||||
)
|
||||
from services.onboarding_progress_service import get_onboarding_progress_service
|
||||
|
||||
|
||||
def health_check():
|
||||
@@ -17,73 +15,78 @@ def health_check():
|
||||
async def initialize_onboarding(current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
progress = get_onboarding_progress_for_user(user_id)
|
||||
progress_service = get_onboarding_progress_service()
|
||||
status = progress_service.get_onboarding_status(user_id)
|
||||
|
||||
# Get completion data for step validation
|
||||
completion_data = progress_service.get_completion_data(user_id)
|
||||
|
||||
# Build steps data based on database state
|
||||
steps_data = []
|
||||
for step in progress.steps:
|
||||
# Include step data for completed steps, especially persona data (step 4) and research data (step 3)
|
||||
for step_num in range(1, 7): # Steps 1-6
|
||||
step_completed = False
|
||||
step_data = None
|
||||
if step.data:
|
||||
if step.step_number == 4: # Personalization step with persona data
|
||||
# Include persona data for step 4 to ensure it's available for step 5
|
||||
step_data = step.data
|
||||
logger.info(f"Including persona data for step 4: {len(str(step_data))} chars")
|
||||
elif step.step_number == 3: # Research step with research preferences
|
||||
# Include research preferences for step 3 to ensure it's available for step 4
|
||||
step_data = step.data
|
||||
logger.info(f"Including research data for step 3: {len(str(step_data))} chars")
|
||||
|
||||
|
||||
# Check if step is completed based on database data
|
||||
if step_num == 1: # API Keys
|
||||
api_keys = completion_data.get('api_keys', {})
|
||||
step_completed = any(v for v in api_keys.values() if v)
|
||||
elif step_num == 2: # Website Analysis
|
||||
website = completion_data.get('website_analysis', {})
|
||||
step_completed = bool(website.get('website_url') or website.get('writing_style'))
|
||||
if step_completed:
|
||||
step_data = website
|
||||
elif step_num == 3: # Research Preferences
|
||||
research = completion_data.get('research_preferences', {})
|
||||
step_completed = bool(research.get('research_depth') or research.get('content_types'))
|
||||
if step_completed:
|
||||
step_data = research
|
||||
elif step_num == 4: # Persona Generation
|
||||
persona = completion_data.get('persona_data', {})
|
||||
step_completed = bool(persona.get('corePersona') or persona.get('platformPersonas'))
|
||||
if step_completed:
|
||||
step_data = persona
|
||||
elif step_num == 5: # Integrations (always completed if we reach this point)
|
||||
step_completed = status['current_step'] >= 5
|
||||
elif step_num == 6: # Final Step
|
||||
step_completed = status['is_completed']
|
||||
|
||||
steps_data.append({
|
||||
"step_number": step.step_number,
|
||||
"title": step.title,
|
||||
"description": step.description,
|
||||
"status": step.status.value,
|
||||
"completed_at": step.completed_at,
|
||||
"has_data": step.data is not None and len(step.data) > 0 if step.data else False,
|
||||
"data": step_data, # Include actual data for critical steps
|
||||
"step_number": step_num,
|
||||
"title": f"Step {step_num}",
|
||||
"description": f"Step {step_num} description",
|
||||
"status": "completed" if step_completed else "pending",
|
||||
"completed_at": datetime.now().isoformat() if step_completed else None,
|
||||
"has_data": step_data is not None,
|
||||
"data": step_data
|
||||
})
|
||||
|
||||
next_step = progress.get_next_incomplete_step()
|
||||
|
||||
# Derive a resilient current_step and is_completed from DB if file-based progress is absent/outdated
|
||||
derived_current_step = progress.current_step
|
||||
derived_is_completed = progress.is_completed
|
||||
# Reconciliation: if not completed but all artifacts exist, mark complete once
|
||||
try:
|
||||
# Only derive if we're at the initial state
|
||||
if (progress.current_step in (1, 0)) or not progress.is_completed:
|
||||
from services.onboarding_database_service import OnboardingDatabaseService
|
||||
from services.database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db_service = OnboardingDatabaseService()
|
||||
# If a DB session exists, prefer that state for completion
|
||||
session_row = db_service.get_session_by_user(user_id, db)
|
||||
if session_row:
|
||||
# Trust explicit completion state from DB if available
|
||||
if (getattr(session_row, 'current_step', 0) or 0) >= 6 or (getattr(session_row, 'progress', 0.0) or 0.0) >= 100.0:
|
||||
derived_current_step = max(derived_current_step, 6)
|
||||
derived_is_completed = True
|
||||
|
||||
# If website analysis exists -> at least step 2 completed
|
||||
website = db_service.get_website_analysis(user_id, db)
|
||||
if website and (website.get('website_url') or website.get('writing_style') or website.get('status') == 'completed'):
|
||||
derived_current_step = max(derived_current_step, 2)
|
||||
# If competitor research data exists, bump to step 3 (best-effort via preferences)
|
||||
prefs = db_service.get_research_preferences(user_id, db)
|
||||
if prefs and (prefs.get('research_depth') or prefs.get('content_types')):
|
||||
derived_current_step = max(derived_current_step, 3)
|
||||
# If persona data exists, bump to step 5 (personalization done)
|
||||
persona = db_service.get_persona_data(user_id, db)
|
||||
if persona and (persona.get('corePersona') or persona.get('platformPersonas')):
|
||||
derived_current_step = max(derived_current_step, 5)
|
||||
# If DB session did not explicitly mark completion but all major data exists,
|
||||
# do not auto-complete; leave final step to the user.
|
||||
finally:
|
||||
db.close()
|
||||
if not status['is_completed']:
|
||||
all_have = (
|
||||
any(v for v in completion_data.get('api_keys', {}).values() if v) and
|
||||
bool((completion_data.get('website_analysis') or {}).get('website_url') or (completion_data.get('website_analysis') or {}).get('writing_style')) and
|
||||
bool((completion_data.get('research_preferences') or {}).get('research_depth') or (completion_data.get('research_preferences') or {}).get('content_types')) and
|
||||
bool((completion_data.get('persona_data') or {}).get('corePersona') or (completion_data.get('persona_data') or {}).get('platformPersonas'))
|
||||
)
|
||||
if all_have:
|
||||
svc = progress_service
|
||||
svc.complete_onboarding(user_id)
|
||||
# refresh status after reconciliation
|
||||
status = svc.get_onboarding_status(user_id)
|
||||
except Exception:
|
||||
# Non-fatal; keep original progress.current_step
|
||||
pass
|
||||
|
||||
# Determine next step robustly
|
||||
next_step = 6 if status['is_completed'] else None
|
||||
if not status['is_completed']:
|
||||
for step in steps_data:
|
||||
if step['status'] != 'completed':
|
||||
next_step = step['step_number']
|
||||
break
|
||||
|
||||
|
||||
response_data = {
|
||||
"user": {
|
||||
"id": user_id,
|
||||
@@ -93,24 +96,25 @@ async def initialize_onboarding(current_user: Dict[str, Any] = Depends(get_curre
|
||||
"clerk_user_id": user_id,
|
||||
},
|
||||
"onboarding": {
|
||||
"is_completed": derived_is_completed,
|
||||
"current_step": derived_current_step,
|
||||
"completion_percentage": progress.get_completion_percentage(),
|
||||
"is_completed": status['is_completed'],
|
||||
"current_step": 6 if status['is_completed'] else status['current_step'],
|
||||
"completion_percentage": status['completion_percentage'],
|
||||
"next_step": next_step,
|
||||
"started_at": progress.started_at,
|
||||
"last_updated": progress.last_updated,
|
||||
"completed_at": progress.completed_at,
|
||||
"can_proceed_to_final": progress.can_complete_onboarding(),
|
||||
"started_at": status['started_at'],
|
||||
"last_updated": status['last_updated'],
|
||||
"completed_at": status['completed_at'],
|
||||
"can_proceed_to_final": True if status['is_completed'] else status['current_step'] >= 5,
|
||||
"steps": steps_data,
|
||||
},
|
||||
"session": {
|
||||
"session_id": user_id,
|
||||
"initialized_at": datetime.now().isoformat(),
|
||||
"initialized_at": status['started_at'],
|
||||
"last_activity": status['last_updated'],
|
||||
},
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Batch init successful for user {user_id}: step {progress.current_step}/{len(progress.steps)}"
|
||||
f"Batch init successful for user {user_id}: step {status['current_step']}/6"
|
||||
)
|
||||
return response_data
|
||||
except Exception as e:
|
||||
|
||||
@@ -4,10 +4,11 @@ Handles the complex logic for completing the onboarding process.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List
|
||||
from datetime import datetime
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from services.api_key_manager import get_onboarding_progress_for_user, get_api_key_manager, StepStatus
|
||||
from services.onboarding_progress_service import get_onboarding_progress_service
|
||||
from services.onboarding_database_service import OnboardingDatabaseService
|
||||
from services.database import get_db
|
||||
from services.persona_analysis_service import PersonaAnalysisService
|
||||
@@ -23,29 +24,31 @@ class OnboardingCompletionService:
|
||||
"""Complete the onboarding process with full validation."""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
progress = get_onboarding_progress_for_user(user_id)
|
||||
progress_service = get_onboarding_progress_service()
|
||||
|
||||
# Validate required steps are completed (with DB-aware fallbacks)
|
||||
missing_steps = self._validate_required_steps(user_id, progress)
|
||||
# Strict DB-only validation now that step persistence is solid
|
||||
missing_steps = self._validate_required_steps_database(user_id)
|
||||
if missing_steps:
|
||||
missing_steps_str = ", ".join(missing_steps)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
status_code=400,
|
||||
detail=f"Cannot complete onboarding. The following steps must be completed first: {missing_steps_str}"
|
||||
)
|
||||
|
||||
# Validate API keys are configured (DB-aware)
|
||||
|
||||
# Require API keys in DB for completion
|
||||
self._validate_api_keys(user_id)
|
||||
|
||||
# Generate writing persona from onboarding data only if not already present
|
||||
persona_generated = await self._generate_persona_from_onboarding(user_id)
|
||||
|
||||
# Complete the onboarding process
|
||||
progress.complete_onboarding()
|
||||
# Complete the onboarding process in database
|
||||
success = progress_service.complete_onboarding(user_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to mark onboarding as complete")
|
||||
|
||||
return {
|
||||
"message": "Onboarding completed successfully",
|
||||
"completed_at": progress.completed_at,
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
"completion_percentage": 100.0,
|
||||
"persona_generated": persona_generated
|
||||
}
|
||||
@@ -56,6 +59,55 @@ class OnboardingCompletionService:
|
||||
logger.error(f"Error completing onboarding: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
def _validate_required_steps_database(self, user_id: str) -> List[str]:
|
||||
"""Validate that all required steps are completed using database only."""
|
||||
missing_steps = []
|
||||
try:
|
||||
db = next(get_db())
|
||||
db_service = OnboardingDatabaseService()
|
||||
|
||||
# Debug logging
|
||||
logger.info(f"Validating steps for user {user_id}")
|
||||
|
||||
# Check each required step
|
||||
for step_num in self.required_steps:
|
||||
step_completed = False
|
||||
|
||||
if step_num == 1: # API Keys
|
||||
api_keys = db_service.get_api_keys(user_id, db)
|
||||
logger.info(f"Step 1 - API Keys: {api_keys}")
|
||||
step_completed = any(v for v in api_keys.values() if v)
|
||||
logger.info(f"Step 1 completed: {step_completed}")
|
||||
elif step_num == 2: # Website Analysis
|
||||
website = db_service.get_website_analysis(user_id, db)
|
||||
logger.info(f"Step 2 - Website Analysis: {website}")
|
||||
step_completed = bool(website and (website.get('website_url') or website.get('writing_style')))
|
||||
logger.info(f"Step 2 completed: {step_completed}")
|
||||
elif step_num == 3: # Research Preferences
|
||||
research = db_service.get_research_preferences(user_id, db)
|
||||
logger.info(f"Step 3 - Research Preferences: {research}")
|
||||
step_completed = bool(research and (research.get('research_depth') or research.get('content_types')))
|
||||
logger.info(f"Step 3 completed: {step_completed}")
|
||||
elif step_num == 4: # Persona Generation
|
||||
persona = db_service.get_persona_data(user_id, db)
|
||||
logger.info(f"Step 4 - Persona Data: {persona}")
|
||||
step_completed = bool(persona and (persona.get('corePersona') or persona.get('platformPersonas')))
|
||||
logger.info(f"Step 4 completed: {step_completed}")
|
||||
elif step_num == 5: # Integrations
|
||||
# For now, consider this always completed if we reach this point
|
||||
step_completed = True
|
||||
logger.info(f"Step 5 completed: {step_completed}")
|
||||
|
||||
if not step_completed:
|
||||
missing_steps.append(f"Step {step_num}")
|
||||
|
||||
logger.info(f"Missing steps: {missing_steps}")
|
||||
return missing_steps
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating required steps: {e}")
|
||||
return ["Validation error"]
|
||||
|
||||
def _validate_required_steps(self, user_id: str, progress) -> List[str]:
|
||||
"""Validate that all required steps are completed.
|
||||
|
||||
@@ -169,48 +221,19 @@ class OnboardingCompletionService:
|
||||
return missing_steps
|
||||
|
||||
def _validate_api_keys(self, user_id: str):
|
||||
"""Validate that API keys are configured for the current user.
|
||||
|
||||
Priority:
|
||||
1) Check database for per-user keys (production, user isolation)
|
||||
2) Fallback to in-memory/env keys via APIKeyManager (development/local)
|
||||
"""
|
||||
"""Validate that API keys are configured for the current user (DB-only)."""
|
||||
try:
|
||||
# Prefer per-user DB keys in production
|
||||
db = None
|
||||
try:
|
||||
db = next(get_db())
|
||||
db_service = OnboardingDatabaseService(db)
|
||||
user_keys = db_service.get_api_keys(user_id, db)
|
||||
if user_keys and any(v for v in user_keys.values()):
|
||||
return
|
||||
except Exception:
|
||||
# DB lookup failed - continue to env fallback
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
if db and hasattr(db, 'close'):
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to env/in-memory
|
||||
api_manager = get_api_key_manager()
|
||||
# Ensure latest env is loaded (middleware may have injected per-request keys)
|
||||
try:
|
||||
api_manager.load_api_keys()
|
||||
except Exception:
|
||||
pass
|
||||
api_keys = api_manager.get_all_keys()
|
||||
if not api_keys:
|
||||
db = next(get_db())
|
||||
db_service = OnboardingDatabaseService()
|
||||
user_keys = db_service.get_api_keys(user_id, db)
|
||||
if not user_keys or not any(v for v in user_keys.values()):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot complete onboarding. At least one AI provider API key must be configured."
|
||||
detail="Cannot complete onboarding. At least one AI provider API key must be configured in your account."
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
# On unexpected error, fail closed with clear message
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot complete onboarding. API key validation failed."
|
||||
|
||||
@@ -7,9 +7,9 @@ from typing import Dict, Any, List, Optional
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from services.api_key_manager import get_onboarding_progress_for_user, StepStatus
|
||||
from services.progressive_setup_service import ProgressiveSetupService
|
||||
from services.database import get_db_session
|
||||
from services.onboarding_progress_service import get_onboarding_progress_service
|
||||
from services.onboarding_database_service import OnboardingDatabaseService
|
||||
from services.database import get_db
|
||||
|
||||
class StepManagementService:
|
||||
"""Service for handling onboarding step management."""
|
||||
@@ -21,25 +21,15 @@ class StepManagementService:
|
||||
"""Get the current onboarding status (per user)."""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
progress = get_onboarding_progress_for_user(user_id)
|
||||
|
||||
# Safety check: if all steps are completed, ensure is_completed is True
|
||||
all_steps_completed = all(s.status in [StepStatus.COMPLETED, StepStatus.SKIPPED] for s in progress.steps)
|
||||
if all_steps_completed and not progress.is_completed:
|
||||
logger.info(f"[get_onboarding_status] All steps completed but is_completed was False, fixing...")
|
||||
progress.is_completed = True
|
||||
progress.completed_at = progress.started_at # Use started_at as fallback
|
||||
progress.current_step = len(progress.steps)
|
||||
progress.save_progress()
|
||||
|
||||
status = get_onboarding_progress_service().get_onboarding_status(user_id)
|
||||
return {
|
||||
"is_completed": progress.is_completed,
|
||||
"current_step": progress.current_step,
|
||||
"completion_percentage": progress.get_completion_percentage(),
|
||||
"next_step": progress.get_next_incomplete_step(),
|
||||
"started_at": progress.started_at,
|
||||
"completed_at": progress.completed_at,
|
||||
"can_proceed_to_final": progress.can_complete_onboarding()
|
||||
"is_completed": status["is_completed"],
|
||||
"current_step": status["current_step"],
|
||||
"completion_percentage": status["completion_percentage"],
|
||||
"next_step": 6 if status["is_completed"] else max(1, status["current_step"]),
|
||||
"started_at": status["started_at"],
|
||||
"completed_at": status["completed_at"],
|
||||
"can_proceed_to_final": True if status["is_completed"] else status["current_step"] >= 5,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting onboarding status: {str(e)}")
|
||||
@@ -49,29 +39,83 @@ class StepManagementService:
|
||||
"""Get the full onboarding progress data."""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
progress = get_onboarding_progress_for_user(user_id)
|
||||
|
||||
# Convert StepData objects to dictionaries
|
||||
step_data = []
|
||||
for step in progress.steps:
|
||||
step_data.append({
|
||||
"step_number": step.step_number,
|
||||
"title": step.title,
|
||||
"description": step.description,
|
||||
"status": step.status.value,
|
||||
"completed_at": step.completed_at,
|
||||
"data": step.data,
|
||||
"validation_errors": step.validation_errors or []
|
||||
})
|
||||
|
||||
progress_service = get_onboarding_progress_service()
|
||||
status = progress_service.get_onboarding_status(user_id)
|
||||
data = progress_service.get_completion_data(user_id)
|
||||
|
||||
def completed(b: bool) -> str:
|
||||
return 'completed' if b else 'pending'
|
||||
|
||||
api_keys = data.get('api_keys') or {}
|
||||
website = data.get('website_analysis') or {}
|
||||
research = data.get('research_preferences') or {}
|
||||
persona = data.get('persona_data') or {}
|
||||
|
||||
steps = [
|
||||
{
|
||||
"step_number": 1,
|
||||
"title": "API Keys",
|
||||
"description": "Connect your AI services",
|
||||
"status": completed(any(v for v in api_keys.values() if v)),
|
||||
"completed_at": None,
|
||||
"data": None,
|
||||
"validation_errors": []
|
||||
},
|
||||
{
|
||||
"step_number": 2,
|
||||
"title": "Website",
|
||||
"description": "Set up your website",
|
||||
"status": completed(bool(website.get('website_url') or website.get('writing_style'))),
|
||||
"completed_at": None,
|
||||
"data": website or None,
|
||||
"validation_errors": []
|
||||
},
|
||||
{
|
||||
"step_number": 3,
|
||||
"title": "Research",
|
||||
"description": "Discover competitors",
|
||||
"status": completed(bool(research.get('research_depth') or research.get('content_types'))),
|
||||
"completed_at": None,
|
||||
"data": research or None,
|
||||
"validation_errors": []
|
||||
},
|
||||
{
|
||||
"step_number": 4,
|
||||
"title": "Personalization",
|
||||
"description": "Customize your experience",
|
||||
"status": completed(bool(persona.get('corePersona') or persona.get('platformPersonas'))),
|
||||
"completed_at": None,
|
||||
"data": persona or None,
|
||||
"validation_errors": []
|
||||
},
|
||||
{
|
||||
"step_number": 5,
|
||||
"title": "Integrations",
|
||||
"description": "Connect additional services",
|
||||
"status": completed(status['current_step'] >= 5),
|
||||
"completed_at": None,
|
||||
"data": None,
|
||||
"validation_errors": []
|
||||
},
|
||||
{
|
||||
"step_number": 6,
|
||||
"title": "Finish",
|
||||
"description": "Complete setup",
|
||||
"status": completed(status['is_completed']),
|
||||
"completed_at": status['completed_at'],
|
||||
"data": None,
|
||||
"validation_errors": []
|
||||
}
|
||||
]
|
||||
|
||||
return {
|
||||
"steps": step_data,
|
||||
"current_step": progress.current_step,
|
||||
"started_at": progress.started_at,
|
||||
"last_updated": progress.last_updated,
|
||||
"is_completed": progress.is_completed,
|
||||
"completed_at": progress.completed_at,
|
||||
"completion_percentage": progress.get_completion_percentage()
|
||||
"steps": steps,
|
||||
"current_step": 6 if status['is_completed'] else status['current_step'],
|
||||
"started_at": status['started_at'],
|
||||
"last_updated": status['last_updated'],
|
||||
"is_completed": status['is_completed'],
|
||||
"completed_at": status['completed_at'],
|
||||
"completion_percentage": status['completion_percentage']
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting onboarding progress: {str(e)}")
|
||||
@@ -81,20 +125,58 @@ class StepManagementService:
|
||||
"""Get data for a specific step."""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
progress = get_onboarding_progress_for_user(user_id)
|
||||
step = progress.get_step_data(step_number)
|
||||
|
||||
if not step:
|
||||
raise HTTPException(status_code=404, detail=f"Step {step_number} not found")
|
||||
|
||||
db = next(get_db())
|
||||
db_service = OnboardingDatabaseService()
|
||||
|
||||
if step_number == 2:
|
||||
website = db_service.get_website_analysis(user_id, db) or {}
|
||||
return {
|
||||
"step_number": 2,
|
||||
"title": "Website",
|
||||
"description": "Set up your website",
|
||||
"status": 'completed' if (website.get('website_url') or website.get('writing_style')) else 'pending',
|
||||
"completed_at": None,
|
||||
"data": website,
|
||||
"validation_errors": []
|
||||
}
|
||||
if step_number == 3:
|
||||
research = db_service.get_research_preferences(user_id, db) or {}
|
||||
return {
|
||||
"step_number": 3,
|
||||
"title": "Research",
|
||||
"description": "Discover competitors",
|
||||
"status": 'completed' if (research.get('research_depth') or research.get('content_types')) else 'pending',
|
||||
"completed_at": None,
|
||||
"data": research,
|
||||
"validation_errors": []
|
||||
}
|
||||
if step_number == 4:
|
||||
persona = db_service.get_persona_data(user_id, db) or {}
|
||||
return {
|
||||
"step_number": 4,
|
||||
"title": "Personalization",
|
||||
"description": "Customize your experience",
|
||||
"status": 'completed' if (persona.get('corePersona') or persona.get('platformPersonas')) else 'pending',
|
||||
"completed_at": None,
|
||||
"data": persona,
|
||||
"validation_errors": []
|
||||
}
|
||||
|
||||
status = get_onboarding_progress_service().get_onboarding_status(user_id)
|
||||
mapping = {
|
||||
1: ('API Keys', 'Connect your AI services', status['current_step'] >= 1),
|
||||
5: ('Integrations', 'Connect additional services', status['current_step'] >= 5),
|
||||
6: ('Finish', 'Complete setup', status['is_completed'])
|
||||
}
|
||||
title, description, done = mapping.get(step_number, (f'Step {step_number}', 'Onboarding step', False))
|
||||
return {
|
||||
"step_number": step.step_number,
|
||||
"title": step.title,
|
||||
"description": step.description,
|
||||
"status": step.status.value,
|
||||
"completed_at": step.completed_at,
|
||||
"data": step.data,
|
||||
"validation_errors": step.validation_errors or []
|
||||
"step_number": step_number,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"status": 'completed' if done else 'pending',
|
||||
"completed_at": status['completed_at'] if step_number == 6 and done else None,
|
||||
"data": None,
|
||||
"validation_errors": []
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -107,63 +189,41 @@ class StepManagementService:
|
||||
try:
|
||||
logger.info(f"[complete_step] Completing step {step_number}")
|
||||
user_id = str(current_user.get('id'))
|
||||
progress = get_onboarding_progress_for_user(user_id)
|
||||
step = progress.get_step_data(step_number)
|
||||
|
||||
if not step:
|
||||
logger.error(f"[complete_step] Step {step_number} not found")
|
||||
raise HTTPException(status_code=404, detail=f"Step {step_number} not found")
|
||||
|
||||
# Validate step data before marking as completed
|
||||
from services.validation import validate_step_data
|
||||
logger.info(f"[complete_step] Validating step {step_number} with data: {request_data}")
|
||||
validation_errors = validate_step_data(step_number, request_data)
|
||||
|
||||
if validation_errors:
|
||||
logger.warning(f"[complete_step] Step {step_number} validation failed: {validation_errors}")
|
||||
raise HTTPException(status_code=400, detail=f"Step validation failed: {'; '.join(validation_errors)}")
|
||||
|
||||
# Mark step as completed
|
||||
progress.mark_step_completed(step_number, request_data)
|
||||
logger.info(f"[complete_step] Step {step_number} completed successfully")
|
||||
|
||||
# If this is step 1 (API keys), also save to global .env file
|
||||
if step_number == 1 and request_data and 'api_keys' in request_data:
|
||||
try:
|
||||
from services.api_key_manager import APIKeyManager
|
||||
api_manager = APIKeyManager()
|
||||
|
||||
# Save each API key to the global .env file
|
||||
api_keys = request_data['api_keys']
|
||||
for provider, api_key in api_keys.items():
|
||||
if api_key: # Only save non-empty keys
|
||||
api_manager.save_api_key(provider, api_key)
|
||||
logger.info(f"[complete_step] Saved {provider} API key to global .env file")
|
||||
except Exception as env_error:
|
||||
logger.warning(f"Could not save API keys to global .env file: {env_error}")
|
||||
# Don't fail the step completion for .env file issues
|
||||
|
||||
# Initialize/upgrade user environment based on new step
|
||||
|
||||
# Optional validation
|
||||
try:
|
||||
db_session = get_db_session()
|
||||
if db_session:
|
||||
setup_service = ProgressiveSetupService(db_session)
|
||||
|
||||
# Initialize environment if first time, or upgrade if progressing
|
||||
if step_number == 1:
|
||||
setup_service.initialize_user_environment(user_id)
|
||||
else:
|
||||
setup_service.upgrade_user_environment(user_id, step_number)
|
||||
|
||||
db_session.close()
|
||||
except Exception as env_error:
|
||||
logger.warning(f"Could not set up user environment: {env_error}")
|
||||
# Don't fail the step completion for environment setup issues
|
||||
|
||||
from services.validation import validate_step_data
|
||||
logger.info(f"[complete_step] Validating step {step_number} with data: {request_data}")
|
||||
validation_errors = validate_step_data(step_number, request_data)
|
||||
if validation_errors:
|
||||
logger.warning(f"[complete_step] Step {step_number} validation failed: {validation_errors}")
|
||||
raise HTTPException(status_code=400, detail=f"Step validation failed: {'; '.join(validation_errors)}")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
db = next(get_db())
|
||||
db_service = OnboardingDatabaseService()
|
||||
|
||||
# Step-specific side effects: save API keys to DB
|
||||
if step_number == 1 and request_data and 'api_keys' in request_data:
|
||||
api_keys = request_data['api_keys'] or {}
|
||||
for provider, key in api_keys.items():
|
||||
if key:
|
||||
db_service.save_api_key(user_id, provider, key, db)
|
||||
|
||||
# Persist current step and progress in DB
|
||||
db_service.update_step(user_id, step_number, db)
|
||||
try:
|
||||
progress_pct = min(100.0, round((step_number / 6) * 100))
|
||||
db_service.update_progress(user_id, float(progress_pct), db)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"[complete_step] Step {step_number} persisted to DB for user {user_id}")
|
||||
return {
|
||||
"message": f"Step {step_number} completed successfully",
|
||||
"message": "Step completed successfully",
|
||||
"step_number": step_number,
|
||||
"data": request_data
|
||||
"data": request_data or {}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -6,6 +6,7 @@ Manages caching of expensive comprehensive user data operations.
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy import and_
|
||||
from loguru import logger
|
||||
import json
|
||||
@@ -19,6 +20,12 @@ class ComprehensiveUserDataCacheService:
|
||||
def __init__(self, db_session: Session):
|
||||
self.db = db_session
|
||||
self.data_processor = ComprehensiveUserDataProcessor()
|
||||
# Ensure table exists in dev environments where migrations may not have run yet
|
||||
try:
|
||||
ComprehensiveUserDataCache.__table__.create(bind=self.db.bind, checkfirst=True)
|
||||
except Exception:
|
||||
# Non-fatal; subsequent operations handle absence defensively
|
||||
pass
|
||||
|
||||
async def get_cached_data(
|
||||
self,
|
||||
@@ -146,6 +153,10 @@ class ComprehensiveUserDataCacheService:
|
||||
|
||||
return None
|
||||
|
||||
except OperationalError as e:
|
||||
# Table might not exist yet; treat as cache miss
|
||||
logger.warning(f"❕ Cache table not found (get): {str(e)}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error getting from cache: {str(e)}")
|
||||
return None
|
||||
@@ -185,6 +196,11 @@ class ComprehensiveUserDataCacheService:
|
||||
f"Data Size: {len(str(data))} chars")
|
||||
return True
|
||||
|
||||
except OperationalError as e:
|
||||
# Table might not exist yet; skip storing
|
||||
logger.warning(f"❕ Cache table not found (store): {str(e)}")
|
||||
self.db.rollback()
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error storing in cache: {str(e)}")
|
||||
self.db.rollback()
|
||||
@@ -225,6 +241,11 @@ class ComprehensiveUserDataCacheService:
|
||||
|
||||
return deleted_count
|
||||
|
||||
except OperationalError as e:
|
||||
# Table might not exist yet; nothing to cleanup
|
||||
logger.warning(f"❕ Cache table not found (cleanup): {str(e)}")
|
||||
self.db.rollback()
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error cleaning up cache: {str(e)}")
|
||||
self.db.rollback()
|
||||
@@ -258,6 +279,15 @@ class ComprehensiveUserDataCacheService:
|
||||
]
|
||||
}
|
||||
|
||||
except OperationalError as e:
|
||||
# Table might not exist yet; return empty stats to avoid noisy errors
|
||||
logger.warning(f"❕ Cache table not found (stats): {str(e)}")
|
||||
return {
|
||||
"total_entries": 0,
|
||||
"expired_entries": 0,
|
||||
"valid_entries": 0,
|
||||
"most_accessed": []
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error getting cache stats: {str(e)}")
|
||||
return {"error": str(e)}
|
||||
|
||||
163
backend/services/onboarding_progress_service.py
Normal file
163
backend/services/onboarding_progress_service.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Database-only Onboarding Progress Service
|
||||
Replaces file-based progress tracking with database-only implementation.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime
|
||||
from loguru import logger
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from services.database import SessionLocal
|
||||
from services.onboarding_database_service import OnboardingDatabaseService
|
||||
|
||||
|
||||
class OnboardingProgressService:
|
||||
"""Database-only onboarding progress management."""
|
||||
|
||||
def __init__(self):
|
||||
self.db_service = OnboardingDatabaseService()
|
||||
|
||||
def get_onboarding_status(self, user_id: str) -> Dict[str, Any]:
|
||||
"""Get current onboarding status from database only."""
|
||||
try:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Get session data
|
||||
session = self.db_service.get_session_by_user(user_id, db)
|
||||
if not session:
|
||||
return {
|
||||
"is_completed": False,
|
||||
"current_step": 1,
|
||||
"completion_percentage": 0.0,
|
||||
"started_at": None,
|
||||
"last_updated": None,
|
||||
"completed_at": None
|
||||
}
|
||||
|
||||
# Check if onboarding is complete
|
||||
# Consider complete if either the final step is reached OR progress hit 100%
|
||||
# This guards against partial writes where one field persisted but the other didn't.
|
||||
is_completed = (session.current_step >= 6) or (session.progress >= 100.0)
|
||||
|
||||
return {
|
||||
"is_completed": is_completed,
|
||||
"current_step": session.current_step,
|
||||
"completion_percentage": session.progress,
|
||||
"started_at": session.started_at.isoformat() if session.started_at else None,
|
||||
"last_updated": session.updated_at.isoformat() if session.updated_at else None,
|
||||
"completed_at": session.updated_at.isoformat() if is_completed else None
|
||||
}
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting onboarding status: {e}")
|
||||
return {
|
||||
"is_completed": False,
|
||||
"current_step": 1,
|
||||
"completion_percentage": 0.0,
|
||||
"started_at": None,
|
||||
"last_updated": None,
|
||||
"completed_at": None
|
||||
}
|
||||
|
||||
def update_step(self, user_id: str, step_number: int) -> bool:
|
||||
"""Update current step in database."""
|
||||
try:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
success = self.db_service.update_step(user_id, step_number, db)
|
||||
if success:
|
||||
logger.info(f"Updated user {user_id} to step {step_number}")
|
||||
return success
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating step: {e}")
|
||||
return False
|
||||
|
||||
def update_progress(self, user_id: str, progress_percentage: float) -> bool:
|
||||
"""Update progress percentage in database."""
|
||||
try:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
success = self.db_service.update_progress(user_id, progress_percentage, db)
|
||||
if success:
|
||||
logger.info(f"Updated user {user_id} progress to {progress_percentage}%")
|
||||
return success
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating progress: {e}")
|
||||
return False
|
||||
|
||||
def complete_onboarding(self, user_id: str) -> bool:
|
||||
"""Mark onboarding as complete in database."""
|
||||
try:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
success = self.db_service.mark_onboarding_complete(user_id, db)
|
||||
if success:
|
||||
logger.info(f"Marked onboarding complete for user {user_id}")
|
||||
return success
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error completing onboarding: {e}")
|
||||
return False
|
||||
|
||||
def reset_onboarding(self, user_id: str) -> bool:
|
||||
"""Reset onboarding progress in database."""
|
||||
try:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Reset to step 1, 0% progress
|
||||
success = self.db_service.update_step(user_id, 1, db)
|
||||
if success:
|
||||
self.db_service.update_progress(user_id, 0.0, db)
|
||||
logger.info(f"Reset onboarding for user {user_id}")
|
||||
return success
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error resetting onboarding: {e}")
|
||||
return False
|
||||
|
||||
def get_completion_data(self, user_id: str) -> Dict[str, Any]:
|
||||
"""Get completion data for validation."""
|
||||
try:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Get all relevant data for completion validation
|
||||
session = self.db_service.get_session_by_user(user_id, db)
|
||||
api_keys = self.db_service.get_api_keys(user_id, db)
|
||||
website_analysis = self.db_service.get_website_analysis(user_id, db)
|
||||
research_preferences = self.db_service.get_research_preferences(user_id, db)
|
||||
persona_data = self.db_service.get_persona_data(user_id, db)
|
||||
|
||||
return {
|
||||
"session": session,
|
||||
"api_keys": api_keys,
|
||||
"website_analysis": website_analysis,
|
||||
"research_preferences": research_preferences,
|
||||
"persona_data": persona_data
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting completion data: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
# Global instance
|
||||
_onboarding_progress_service = None
|
||||
|
||||
def get_onboarding_progress_service() -> OnboardingProgressService:
|
||||
"""Get the global onboarding progress service instance."""
|
||||
global _onboarding_progress_service
|
||||
if _onboarding_progress_service is None:
|
||||
_onboarding_progress_service = OnboardingProgressService()
|
||||
return _onboarding_progress_service
|
||||
@@ -47,7 +47,7 @@ const ConditionalCopilotKit: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
// Component to handle initial routing based on subscription and onboarding status
|
||||
// Flow: Subscription → Onboarding → Dashboard
|
||||
const InitialRouteHandler: React.FC = () => {
|
||||
const { loading, error, isOnboardingComplete, initializeOnboarding } = useOnboarding();
|
||||
const { loading, error, isOnboardingComplete, initializeOnboarding, data } = useOnboarding();
|
||||
const { subscription, loading: subscriptionLoading, error: subscriptionError, checkSubscription } = useSubscription();
|
||||
// Note: subscriptionError is available for future error handling
|
||||
const [connectionError, setConnectionError] = useState<{
|
||||
@@ -125,8 +125,9 @@ const InitialRouteHandler: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state - checking both subscription and onboarding
|
||||
if (loading || subscriptionLoading) {
|
||||
// Loading state - ensure we wait for onboarding init after subscription is confirmed
|
||||
const waitingForOnboardingInit = !!subscription && subscription.active && !subscriptionLoading && (loading || !data);
|
||||
if (subscriptionLoading || loading || waitingForOnboardingInit) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
@@ -138,7 +139,7 @@ const InitialRouteHandler: React.FC = () => {
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
{subscriptionLoading ? 'Checking subscription...' : 'Checking onboarding status...'}
|
||||
{subscriptionLoading ? 'Checking subscription...' : 'Preparing your workspace...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user