diff --git a/backend/api/onboarding_utils/endpoints_core.py b/backend/api/onboarding_utils/endpoints_core.py index de3afa3e..e640eb7f 100644 --- a/backend/api/onboarding_utils/endpoints_core.py +++ b/backend/api/onboarding_utils/endpoints_core.py @@ -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: diff --git a/backend/api/onboarding_utils/onboarding_completion_service.py b/backend/api/onboarding_utils/onboarding_completion_service.py index 4f1ee427..19ac807f 100644 --- a/backend/api/onboarding_utils/onboarding_completion_service.py +++ b/backend/api/onboarding_utils/onboarding_completion_service.py @@ -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." diff --git a/backend/api/onboarding_utils/step_management_service.py b/backend/api/onboarding_utils/step_management_service.py index 808accad..485fbff1 100644 --- a/backend/api/onboarding_utils/step_management_service.py +++ b/backend/api/onboarding_utils/step_management_service.py @@ -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 diff --git a/backend/services/comprehensive_user_data_cache_service.py b/backend/services/comprehensive_user_data_cache_service.py index 4a160a12..381e2ea8 100644 --- a/backend/services/comprehensive_user_data_cache_service.py +++ b/backend/services/comprehensive_user_data_cache_service.py @@ -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)} diff --git a/backend/services/onboarding_progress_service.py b/backend/services/onboarding_progress_service.py new file mode 100644 index 00000000..6afecfb0 --- /dev/null +++ b/backend/services/onboarding_progress_service.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 45db7751..f31761b8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( { > - {subscriptionLoading ? 'Checking subscription...' : 'Checking onboarding status...'} + {subscriptionLoading ? 'Checking subscription...' : 'Preparing your workspace...'} );