Onboarding Progress Service Implementation

This commit is contained in:
ajaysi
2025-10-24 17:22:06 +05:30
parent a3f25f23c9
commit caeb6e56a9
6 changed files with 509 additions and 228 deletions

View File

@@ -5,9 +5,7 @@ from fastapi import HTTPException, Depends
from middleware.auth_middleware import get_current_user from middleware.auth_middleware import get_current_user
from .endpoint_models import ( from services.onboarding_progress_service import get_onboarding_progress_service
get_onboarding_progress_for_user,
)
def health_check(): def health_check():
@@ -17,73 +15,78 @@ def health_check():
async def initialize_onboarding(current_user: Dict[str, Any] = Depends(get_current_user)): async def initialize_onboarding(current_user: Dict[str, Any] = Depends(get_current_user)):
try: try:
user_id = str(current_user.get('id')) 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 = [] steps_data = []
for step in progress.steps: for step_num in range(1, 7): # Steps 1-6
# Include step data for completed steps, especially persona data (step 4) and research data (step 3) step_completed = False
step_data = None step_data = None
if step.data:
if step.step_number == 4: # Personalization step with persona data # Check if step is completed based on database data
# Include persona data for step 4 to ensure it's available for step 5 if step_num == 1: # API Keys
step_data = step.data api_keys = completion_data.get('api_keys', {})
logger.info(f"Including persona data for step 4: {len(str(step_data))} chars") step_completed = any(v for v in api_keys.values() if v)
elif step.step_number == 3: # Research step with research preferences elif step_num == 2: # Website Analysis
# Include research preferences for step 3 to ensure it's available for step 4 website = completion_data.get('website_analysis', {})
step_data = step.data step_completed = bool(website.get('website_url') or website.get('writing_style'))
logger.info(f"Including research data for step 3: {len(str(step_data))} chars") 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({ steps_data.append({
"step_number": step.step_number, "step_number": step_num,
"title": step.title, "title": f"Step {step_num}",
"description": step.description, "description": f"Step {step_num} description",
"status": step.status.value, "status": "completed" if step_completed else "pending",
"completed_at": step.completed_at, "completed_at": datetime.now().isoformat() if step_completed else None,
"has_data": step.data is not None and len(step.data) > 0 if step.data else False, "has_data": step_data is not None,
"data": step_data, # Include actual data for critical steps "data": step_data
}) })
next_step = progress.get_next_incomplete_step() # Reconciliation: if not completed but all artifacts exist, mark complete once
# 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
try: try:
# Only derive if we're at the initial state if not status['is_completed']:
if (progress.current_step in (1, 0)) or not progress.is_completed: all_have = (
from services.onboarding_database_service import OnboardingDatabaseService any(v for v in completion_data.get('api_keys', {}).values() if v) and
from services.database import SessionLocal bool((completion_data.get('website_analysis') or {}).get('website_url') or (completion_data.get('website_analysis') or {}).get('writing_style')) and
db = SessionLocal() bool((completion_data.get('research_preferences') or {}).get('research_depth') or (completion_data.get('research_preferences') or {}).get('content_types')) and
try: bool((completion_data.get('persona_data') or {}).get('corePersona') or (completion_data.get('persona_data') or {}).get('platformPersonas'))
db_service = OnboardingDatabaseService() )
# If a DB session exists, prefer that state for completion if all_have:
session_row = db_service.get_session_by_user(user_id, db) svc = progress_service
if session_row: svc.complete_onboarding(user_id)
# Trust explicit completion state from DB if available # refresh status after reconciliation
if (getattr(session_row, 'current_step', 0) or 0) >= 6 or (getattr(session_row, 'progress', 0.0) or 0.0) >= 100.0: status = svc.get_onboarding_status(user_id)
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()
except Exception: except Exception:
# Non-fatal; keep original progress.current_step
pass 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 = { response_data = {
"user": { "user": {
"id": user_id, "id": user_id,
@@ -93,24 +96,25 @@ async def initialize_onboarding(current_user: Dict[str, Any] = Depends(get_curre
"clerk_user_id": user_id, "clerk_user_id": user_id,
}, },
"onboarding": { "onboarding": {
"is_completed": derived_is_completed, "is_completed": status['is_completed'],
"current_step": derived_current_step, "current_step": 6 if status['is_completed'] else status['current_step'],
"completion_percentage": progress.get_completion_percentage(), "completion_percentage": status['completion_percentage'],
"next_step": next_step, "next_step": next_step,
"started_at": progress.started_at, "started_at": status['started_at'],
"last_updated": progress.last_updated, "last_updated": status['last_updated'],
"completed_at": progress.completed_at, "completed_at": status['completed_at'],
"can_proceed_to_final": progress.can_complete_onboarding(), "can_proceed_to_final": True if status['is_completed'] else status['current_step'] >= 5,
"steps": steps_data, "steps": steps_data,
}, },
"session": { "session": {
"session_id": user_id, "session_id": user_id,
"initialized_at": datetime.now().isoformat(), "initialized_at": status['started_at'],
"last_activity": status['last_updated'],
}, },
} }
logger.info( 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 return response_data
except Exception as e: except Exception as e:

View File

@@ -4,10 +4,11 @@ Handles the complex logic for completing the onboarding process.
""" """
from typing import Dict, Any, List from typing import Dict, Any, List
from datetime import datetime
from fastapi import HTTPException from fastapi import HTTPException
from loguru import logger 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.onboarding_database_service import OnboardingDatabaseService
from services.database import get_db from services.database import get_db
from services.persona_analysis_service import PersonaAnalysisService from services.persona_analysis_service import PersonaAnalysisService
@@ -23,29 +24,31 @@ class OnboardingCompletionService:
"""Complete the onboarding process with full validation.""" """Complete the onboarding process with full validation."""
try: try:
user_id = str(current_user.get('id')) 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) # Strict DB-only validation now that step persistence is solid
missing_steps = self._validate_required_steps(user_id, progress) missing_steps = self._validate_required_steps_database(user_id)
if missing_steps: if missing_steps:
missing_steps_str = ", ".join(missing_steps) missing_steps_str = ", ".join(missing_steps)
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Cannot complete onboarding. The following steps must be completed first: {missing_steps_str}" 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) self._validate_api_keys(user_id)
# Generate writing persona from onboarding data only if not already present # Generate writing persona from onboarding data only if not already present
persona_generated = await self._generate_persona_from_onboarding(user_id) persona_generated = await self._generate_persona_from_onboarding(user_id)
# Complete the onboarding process # Complete the onboarding process in database
progress.complete_onboarding() success = progress_service.complete_onboarding(user_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to mark onboarding as complete")
return { return {
"message": "Onboarding completed successfully", "message": "Onboarding completed successfully",
"completed_at": progress.completed_at, "completed_at": datetime.now().isoformat(),
"completion_percentage": 100.0, "completion_percentage": 100.0,
"persona_generated": persona_generated "persona_generated": persona_generated
} }
@@ -56,6 +59,55 @@ class OnboardingCompletionService:
logger.error(f"Error completing onboarding: {str(e)}") logger.error(f"Error completing onboarding: {str(e)}")
raise HTTPException(status_code=500, detail="Internal server error") 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]: def _validate_required_steps(self, user_id: str, progress) -> List[str]:
"""Validate that all required steps are completed. """Validate that all required steps are completed.
@@ -169,48 +221,19 @@ class OnboardingCompletionService:
return missing_steps return missing_steps
def _validate_api_keys(self, user_id: str): def _validate_api_keys(self, user_id: str):
"""Validate that API keys are configured for the current user. """Validate that API keys are configured for the current user (DB-only)."""
Priority:
1) Check database for per-user keys (production, user isolation)
2) Fallback to in-memory/env keys via APIKeyManager (development/local)
"""
try: try:
# Prefer per-user DB keys in production db = next(get_db())
db = None db_service = OnboardingDatabaseService()
try: user_keys = db_service.get_api_keys(user_id, db)
db = next(get_db()) if not user_keys or not any(v for v in user_keys.values()):
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:
raise HTTPException( raise HTTPException(
status_code=400, 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: except HTTPException:
raise raise
except Exception: except Exception:
# On unexpected error, fail closed with clear message
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="Cannot complete onboarding. API key validation failed." detail="Cannot complete onboarding. API key validation failed."

View File

@@ -7,9 +7,9 @@ from typing import Dict, Any, List, Optional
from fastapi import HTTPException from fastapi import HTTPException
from loguru import logger from loguru import logger
from services.api_key_manager import get_onboarding_progress_for_user, StepStatus from services.onboarding_progress_service import get_onboarding_progress_service
from services.progressive_setup_service import ProgressiveSetupService from services.onboarding_database_service import OnboardingDatabaseService
from services.database import get_db_session from services.database import get_db
class StepManagementService: class StepManagementService:
"""Service for handling onboarding step management.""" """Service for handling onboarding step management."""
@@ -21,25 +21,15 @@ class StepManagementService:
"""Get the current onboarding status (per user).""" """Get the current onboarding status (per user)."""
try: try:
user_id = str(current_user.get('id')) user_id = str(current_user.get('id'))
progress = get_onboarding_progress_for_user(user_id) status = get_onboarding_progress_service().get_onboarding_status(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()
return { return {
"is_completed": progress.is_completed, "is_completed": status["is_completed"],
"current_step": progress.current_step, "current_step": status["current_step"],
"completion_percentage": progress.get_completion_percentage(), "completion_percentage": status["completion_percentage"],
"next_step": progress.get_next_incomplete_step(), "next_step": 6 if status["is_completed"] else max(1, status["current_step"]),
"started_at": progress.started_at, "started_at": status["started_at"],
"completed_at": progress.completed_at, "completed_at": status["completed_at"],
"can_proceed_to_final": progress.can_complete_onboarding() "can_proceed_to_final": True if status["is_completed"] else status["current_step"] >= 5,
} }
except Exception as e: except Exception as e:
logger.error(f"Error getting onboarding status: {str(e)}") logger.error(f"Error getting onboarding status: {str(e)}")
@@ -49,29 +39,83 @@ class StepManagementService:
"""Get the full onboarding progress data.""" """Get the full onboarding progress data."""
try: try:
user_id = str(current_user.get('id')) 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)
# Convert StepData objects to dictionaries data = progress_service.get_completion_data(user_id)
step_data = []
for step in progress.steps: def completed(b: bool) -> str:
step_data.append({ return 'completed' if b else 'pending'
"step_number": step.step_number,
"title": step.title, api_keys = data.get('api_keys') or {}
"description": step.description, website = data.get('website_analysis') or {}
"status": step.status.value, research = data.get('research_preferences') or {}
"completed_at": step.completed_at, persona = data.get('persona_data') or {}
"data": step.data,
"validation_errors": step.validation_errors 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 { return {
"steps": step_data, "steps": steps,
"current_step": progress.current_step, "current_step": 6 if status['is_completed'] else status['current_step'],
"started_at": progress.started_at, "started_at": status['started_at'],
"last_updated": progress.last_updated, "last_updated": status['last_updated'],
"is_completed": progress.is_completed, "is_completed": status['is_completed'],
"completed_at": progress.completed_at, "completed_at": status['completed_at'],
"completion_percentage": progress.get_completion_percentage() "completion_percentage": status['completion_percentage']
} }
except Exception as e: except Exception as e:
logger.error(f"Error getting onboarding progress: {str(e)}") logger.error(f"Error getting onboarding progress: {str(e)}")
@@ -81,20 +125,58 @@ class StepManagementService:
"""Get data for a specific step.""" """Get data for a specific step."""
try: try:
user_id = str(current_user.get('id')) user_id = str(current_user.get('id'))
progress = get_onboarding_progress_for_user(user_id) db = next(get_db())
step = progress.get_step_data(step_number) db_service = OnboardingDatabaseService()
if not step: if step_number == 2:
raise HTTPException(status_code=404, detail=f"Step {step_number} not found") 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 { return {
"step_number": step.step_number, "step_number": step_number,
"title": step.title, "title": title,
"description": step.description, "description": description,
"status": step.status.value, "status": 'completed' if done else 'pending',
"completed_at": step.completed_at, "completed_at": status['completed_at'] if step_number == 6 and done else None,
"data": step.data, "data": None,
"validation_errors": step.validation_errors or [] "validation_errors": []
} }
except HTTPException: except HTTPException:
raise raise
@@ -107,63 +189,41 @@ class StepManagementService:
try: try:
logger.info(f"[complete_step] Completing step {step_number}") logger.info(f"[complete_step] Completing step {step_number}")
user_id = str(current_user.get('id')) user_id = str(current_user.get('id'))
progress = get_onboarding_progress_for_user(user_id)
step = progress.get_step_data(step_number) # Optional validation
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
try: try:
db_session = get_db_session() from services.validation import validate_step_data
if db_session: logger.info(f"[complete_step] Validating step {step_number} with data: {request_data}")
setup_service = ProgressiveSetupService(db_session) validation_errors = validate_step_data(step_number, request_data)
if validation_errors:
# Initialize environment if first time, or upgrade if progressing logger.warning(f"[complete_step] Step {step_number} validation failed: {validation_errors}")
if step_number == 1: raise HTTPException(status_code=400, detail=f"Step validation failed: {'; '.join(validation_errors)}")
setup_service.initialize_user_environment(user_id) except ImportError:
else: pass
setup_service.upgrade_user_environment(user_id, step_number)
db = next(get_db())
db_session.close() db_service = OnboardingDatabaseService()
except Exception as env_error:
logger.warning(f"Could not set up user environment: {env_error}") # Step-specific side effects: save API keys to DB
# Don't fail the step completion for environment setup issues 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 { return {
"message": f"Step {step_number} completed successfully", "message": "Step completed successfully",
"step_number": step_number, "step_number": step_number,
"data": request_data "data": request_data or {}
} }
except HTTPException: except HTTPException:
raise raise

View File

@@ -6,6 +6,7 @@ Manages caching of expensive comprehensive user data operations.
from typing import Dict, Any, Optional, Tuple from typing import Dict, Any, Optional, Tuple
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.exc import OperationalError
from sqlalchemy import and_ from sqlalchemy import and_
from loguru import logger from loguru import logger
import json import json
@@ -19,6 +20,12 @@ class ComprehensiveUserDataCacheService:
def __init__(self, db_session: Session): def __init__(self, db_session: Session):
self.db = db_session self.db = db_session
self.data_processor = ComprehensiveUserDataProcessor() 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( async def get_cached_data(
self, self,
@@ -146,6 +153,10 @@ class ComprehensiveUserDataCacheService:
return None 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: except Exception as e:
logger.error(f"❌ Error getting from cache: {str(e)}") logger.error(f"❌ Error getting from cache: {str(e)}")
return None return None
@@ -185,6 +196,11 @@ class ComprehensiveUserDataCacheService:
f"Data Size: {len(str(data))} chars") f"Data Size: {len(str(data))} chars")
return True 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: except Exception as e:
logger.error(f"❌ Error storing in cache: {str(e)}") logger.error(f"❌ Error storing in cache: {str(e)}")
self.db.rollback() self.db.rollback()
@@ -225,6 +241,11 @@ class ComprehensiveUserDataCacheService:
return deleted_count 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: except Exception as e:
logger.error(f"❌ Error cleaning up cache: {str(e)}") logger.error(f"❌ Error cleaning up cache: {str(e)}")
self.db.rollback() 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: except Exception as e:
logger.error(f"❌ Error getting cache stats: {str(e)}") logger.error(f"❌ Error getting cache stats: {str(e)}")
return {"error": str(e)} return {"error": str(e)}

View 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

View File

@@ -47,7 +47,7 @@ const ConditionalCopilotKit: React.FC<{ children: React.ReactNode }> = ({ childr
// Component to handle initial routing based on subscription and onboarding status // Component to handle initial routing based on subscription and onboarding status
// Flow: Subscription → Onboarding → Dashboard // Flow: Subscription → Onboarding → Dashboard
const InitialRouteHandler: React.FC = () => { 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(); const { subscription, loading: subscriptionLoading, error: subscriptionError, checkSubscription } = useSubscription();
// Note: subscriptionError is available for future error handling // Note: subscriptionError is available for future error handling
const [connectionError, setConnectionError] = useState<{ const [connectionError, setConnectionError] = useState<{
@@ -125,8 +125,9 @@ const InitialRouteHandler: React.FC = () => {
); );
} }
// Loading state - checking both subscription and onboarding // Loading state - ensure we wait for onboarding init after subscription is confirmed
if (loading || subscriptionLoading) { const waitingForOnboardingInit = !!subscription && subscription.active && !subscriptionLoading && (loading || !data);
if (subscriptionLoading || loading || waitingForOnboardingInit) {
return ( return (
<Box <Box
display="flex" display="flex"
@@ -138,7 +139,7 @@ const InitialRouteHandler: React.FC = () => {
> >
<CircularProgress size={60} /> <CircularProgress size={60} />
<Typography variant="h6" color="textSecondary"> <Typography variant="h6" color="textSecondary">
{subscriptionLoading ? 'Checking subscription...' : 'Checking onboarding status...'} {subscriptionLoading ? 'Checking subscription...' : 'Preparing your workspace...'}
</Typography> </Typography>
</Box> </Box>
); );