From b1ebe1034e25b1e4da904b33b73a0776fa21fe66 Mon Sep 17 00:00:00 2001 From: ajaysi Date: Fri, 10 Oct 2025 23:19:28 +0530 Subject: [PATCH] ALwrity onboarding final step --- .gitignore | 6 +- backend/alwrity_utils/onboarding_manager.py | 4 +- backend/alwrity_utils/router_manager.py | 4 + .../api_key_management_service.py | 22 +- .../onboarding_utils/endpoints_config_data.py | 4 +- backend/app.py | 10 + .../migrations/add_persona_data_table.sql | 26 + backend/env_template.txt | 3 + .../api_key_injection_middleware.py | 114 +++ backend/models/onboarding.py | 39 +- backend/routers/frontend_env_manager.py | 110 +++ backend/scripts/create_persona_data_table.py | 124 ++++ backend/scripts/verify_onboarding_data.py | 338 +++++++++ backend/services/api_key_manager.py | 184 ++++- .../services/onboarding_database_service.py | 418 +++++++++++ backend/services/user_api_key_context.py | 150 ++++ docs/API_KEY_FLOW_DIAGRAM.md | 370 ++++++++++ docs/API_KEY_INJECTION_EXPLAINED.md | 326 +++++++++ docs/API_KEY_MANAGEMENT_ARCHITECTURE.md | 349 +++++++++ docs/API_KEY_QUICK_REFERENCE.md | 299 ++++++++ .../CRITICAL_ONBOARDING_DATABASE_MIGRATION.md | 264 +++++++ docs/EXAMPLES_USER_API_KEYS.md | 489 +++++++++++++ docs/PERSONA_DATA_MIGRATION_GUIDE.md | 215 ++++++ .../OnboardingWizard/ApiKeyStep.tsx | 52 +- .../ApiKeyStep/utils/useApiKeyStep.ts | 57 +- .../components/OnboardingWizard/FinalStep.tsx | 660 ------------------ .../OnboardingWizard/FinalStep/FinalStep.tsx | 284 ++++++++ .../OnboardingWizard/FinalStep/README.md | 96 +++ .../components/CapabilitiesOverview.tsx | 94 +++ .../FinalStep/components/SetupSummary.tsx | 343 +++++++++ .../FinalStep/components/index.ts | 3 + .../OnboardingWizard/FinalStep/index.ts | 3 + .../OnboardingWizard/FinalStep/types.ts | 22 + .../OnboardingWizard/PersonaStep.tsx | 19 + .../OnboardingWizard/WebsiteStep.tsx | 38 +- .../TargetAudienceAnalysisSection.tsx | 14 +- .../WebsiteStep/utils/renderUtils.tsx | 20 +- .../components/OnboardingWizard/Wizard.tsx | 64 +- 38 files changed, 4867 insertions(+), 770 deletions(-) create mode 100644 backend/database/migrations/add_persona_data_table.sql create mode 100644 backend/middleware/api_key_injection_middleware.py create mode 100644 backend/routers/frontend_env_manager.py create mode 100644 backend/scripts/create_persona_data_table.py create mode 100644 backend/scripts/verify_onboarding_data.py create mode 100644 backend/services/onboarding_database_service.py create mode 100644 backend/services/user_api_key_context.py create mode 100644 docs/API_KEY_FLOW_DIAGRAM.md create mode 100644 docs/API_KEY_INJECTION_EXPLAINED.md create mode 100644 docs/API_KEY_MANAGEMENT_ARCHITECTURE.md create mode 100644 docs/API_KEY_QUICK_REFERENCE.md create mode 100644 docs/CRITICAL_ONBOARDING_DATABASE_MIGRATION.md create mode 100644 docs/EXAMPLES_USER_API_KEYS.md create mode 100644 docs/PERSONA_DATA_MIGRATION_GUIDE.md delete mode 100644 frontend/src/components/OnboardingWizard/FinalStep.tsx create mode 100644 frontend/src/components/OnboardingWizard/FinalStep/FinalStep.tsx create mode 100644 frontend/src/components/OnboardingWizard/FinalStep/README.md create mode 100644 frontend/src/components/OnboardingWizard/FinalStep/components/CapabilitiesOverview.tsx create mode 100644 frontend/src/components/OnboardingWizard/FinalStep/components/SetupSummary.tsx create mode 100644 frontend/src/components/OnboardingWizard/FinalStep/components/index.ts create mode 100644 frontend/src/components/OnboardingWizard/FinalStep/index.ts create mode 100644 frontend/src/components/OnboardingWizard/FinalStep/types.ts diff --git a/.gitignore b/.gitignore index 0925ec12..62f171f2 100644 --- a/.gitignore +++ b/.gitignore @@ -201,4 +201,8 @@ yarn.lock .pytest_cache # Documentation cache -docs/__pycache__/ \ No newline at end of file +docs/__pycache__/ +# Onboarding JSON files (CRITICAL: Should use database instead) +.onboarding_progress.json +*_onboarding_progress.json +backend/.onboarding_progress*.json diff --git a/backend/alwrity_utils/onboarding_manager.py b/backend/alwrity_utils/onboarding_manager.py index c800a334..2778c44a 100644 --- a/backend/alwrity_utils/onboarding_manager.py +++ b/backend/alwrity_utils/onboarding_manager.py @@ -165,10 +165,10 @@ class OnboardingManager: raise HTTPException(status_code=500, detail=str(e)) @self.app.post("/api/onboarding/api-keys") - async def api_key_save(request: APIKeyRequest): + async def api_key_save(request: APIKeyRequest, current_user: dict = Depends(get_current_user)): """Save an API key for a provider.""" try: - return await save_api_key(request) + return await save_api_key(request, current_user) except Exception as e: logger.error(f"Error in api_key_save: {e}") raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/alwrity_utils/router_manager.py b/backend/alwrity_utils/router_manager.py index 769e6443..7d632c75 100644 --- a/backend/alwrity_utils/router_manager.py +++ b/backend/alwrity_utils/router_manager.py @@ -95,6 +95,10 @@ class RouterManager: from routers.error_logging import router as error_logging_router self.include_router_safely(error_logging_router, "error_logging") + # Frontend environment manager router + from routers.frontend_env_manager import router as frontend_env_router + self.include_router_safely(frontend_env_router, "frontend_env_manager") + logger.info("✅ Core routers included successfully") return True diff --git a/backend/api/onboarding_utils/api_key_management_service.py b/backend/api/onboarding_utils/api_key_management_service.py index 1b57a04a..44f46111 100644 --- a/backend/api/onboarding_utils/api_key_management_service.py +++ b/backend/api/onboarding_utils/api_key_management_service.py @@ -15,7 +15,20 @@ class APIKeyManagementService: """Service for handling API key management operations.""" def __init__(self): + # Initialize APIKeyManager with database support self.api_key_manager = APIKeyManager() + # Ensure database service is available + if not hasattr(self.api_key_manager, 'use_database'): + self.api_key_manager.use_database = True + try: + from services.onboarding_database_service import OnboardingDatabaseService + self.api_key_manager.db_service = OnboardingDatabaseService() + logger.info("Database service initialized for APIKeyManager") + except Exception as e: + logger.warning(f"Database service not available: {e}") + self.api_key_manager.use_database = False + self.api_key_manager.db_service = None + # Simple cache for API keys self._api_keys_cache = None self._cache_timestamp = 0 @@ -75,9 +88,16 @@ class APIKeyManagementService: logger.error(f"Error getting API keys for onboarding: {str(e)}") raise HTTPException(status_code=500, detail="Internal server error") - async def save_api_key(self, provider: str, api_key: str, description: str = None) -> Dict[str, Any]: + async def save_api_key(self, provider: str, api_key: str, description: str = None, current_user: dict = None) -> Dict[str, Any]: """Save an API key for a provider.""" try: + logger.info(f"📝 save_api_key called for provider: {provider}") + + # Set user_id on the API key manager if available + if current_user and current_user.get('id'): + self.api_key_manager.user_id = current_user['id'] + logger.info(f"Set user_id on APIKeyManager: {current_user['id']}") + success = self.api_key_manager.save_api_key(provider, api_key) if success: diff --git a/backend/api/onboarding_utils/endpoints_config_data.py b/backend/api/onboarding_utils/endpoints_config_data.py index 7eebf442..736641a1 100644 --- a/backend/api/onboarding_utils/endpoints_config_data.py +++ b/backend/api/onboarding_utils/endpoints_config_data.py @@ -35,11 +35,11 @@ async def get_api_keys_for_onboarding(): raise HTTPException(status_code=500, detail="Internal server error") -async def save_api_key(request: APIKeyRequest): +async def save_api_key(request: APIKeyRequest, current_user: dict = None): try: from api.onboarding_utils.api_key_management_service import APIKeyManagementService api_service = APIKeyManagementService() - return await api_service.save_api_key(request.provider, request.api_key, request.description) + return await api_service.save_api_key(request.provider, request.api_key, request.description, current_user) except Exception as e: logger.error(f"Error saving API key: {str(e)}") raise HTTPException(status_code=500, detail="Internal server error") diff --git a/backend/app.py b/backend/app.py index 511a721d..f429f880 100644 --- a/backend/app.py +++ b/backend/app.py @@ -110,6 +110,16 @@ async def rate_limit_middleware(request: Request, call_next): """Rate limiting middleware using modular utilities.""" return await rate_limiter.rate_limit_middleware(request, call_next) +# API key injection middleware for production (user-specific keys) +@app.middleware("http") +async def inject_user_api_keys(request: Request, call_next): + """ + Inject user-specific API keys into environment for the request duration. + This allows existing code using os.getenv() to work in production. + """ + from middleware.api_key_injection_middleware import api_key_injection_middleware + return await api_key_injection_middleware(request, call_next) + # Health check endpoints using modular utilities @app.get("/health") async def health(): diff --git a/backend/database/migrations/add_persona_data_table.sql b/backend/database/migrations/add_persona_data_table.sql new file mode 100644 index 00000000..45b02bfd --- /dev/null +++ b/backend/database/migrations/add_persona_data_table.sql @@ -0,0 +1,26 @@ +-- Migration: Add persona_data table for onboarding step 4 +-- Created: 2025-10-10 +-- Description: Adds table to store persona generation data from onboarding step 4 + +CREATE TABLE IF NOT EXISTS persona_data ( + id SERIAL PRIMARY KEY, + session_id INTEGER NOT NULL, + core_persona JSONB, + platform_personas JSONB, + quality_metrics JSONB, + selected_platforms JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES onboarding_sessions(id) ON DELETE CASCADE +); + +-- Add index for better query performance +CREATE INDEX IF NOT EXISTS idx_persona_data_session_id ON persona_data(session_id); +CREATE INDEX IF NOT EXISTS idx_persona_data_created_at ON persona_data(created_at); + +-- Add comment to table +COMMENT ON TABLE persona_data IS 'Stores persona generation data from onboarding step 4'; +COMMENT ON COLUMN persona_data.core_persona IS 'Core persona data (demographics, psychographics, etc.)'; +COMMENT ON COLUMN persona_data.platform_personas IS 'Platform-specific personas (LinkedIn, Twitter, etc.)'; +COMMENT ON COLUMN persona_data.quality_metrics IS 'Quality assessment metrics'; +COMMENT ON COLUMN persona_data.selected_platforms IS 'Array of selected platforms'; diff --git a/backend/env_template.txt b/backend/env_template.txt index c938f6e8..59d2e795 100644 --- a/backend/env_template.txt +++ b/backend/env_template.txt @@ -22,3 +22,6 @@ WORDPRESS_REDIRECT_URI= # Development Settings DISABLE_AUTH=false + +# local development +DEPLOY_ENV=local diff --git a/backend/middleware/api_key_injection_middleware.py b/backend/middleware/api_key_injection_middleware.py new file mode 100644 index 00000000..5d5974ab --- /dev/null +++ b/backend/middleware/api_key_injection_middleware.py @@ -0,0 +1,114 @@ +""" +API Key Injection Middleware + +Temporarily injects user-specific API keys into os.environ for the duration of the request. +This allows existing code that uses os.getenv('GEMINI_API_KEY') to work without modification. + +IMPORTANT: This is a compatibility layer. For new code, use UserAPIKeyContext directly. +""" + +import os +from fastapi import Request +from loguru import logger +from typing import Callable +from services.user_api_key_context import user_api_keys + + +class APIKeyInjectionMiddleware: + """ + Middleware that injects user-specific API keys into environment variables + for the duration of each request. + """ + + def __init__(self): + self.original_keys = {} + + async def __call__(self, request: Request, call_next: Callable): + """ + Inject user-specific API keys before processing request, + restore original values after request completes. + """ + + # Try to extract user_id from Authorization header + user_id = None + auth_header = request.headers.get('Authorization') + + if auth_header and auth_header.startswith('Bearer '): + try: + from middleware.auth_middleware import clerk_auth + token = auth_header.replace('Bearer ', '') + user = await clerk_auth.verify_token(token) + if user: + # Try different possible keys for user_id + user_id = user.get('user_id') or user.get('clerk_user_id') or user.get('id') + logger.debug(f"[API Key Injection] Extracted user_id: {user_id}") + except Exception as e: + logger.debug(f"[API Key Injection] Could not extract user from token: {e}") + + if not user_id: + # No authenticated user, proceed without injection + return await call_next(request) + + # Check if we're in production mode + is_production = os.getenv('DEPLOY_ENV', 'local') == 'production' + + if not is_production: + # Local mode - keys already in .env, no injection needed + return await call_next(request) + + # Get user-specific API keys from database + with user_api_keys(user_id) as user_keys: + if not user_keys: + logger.warning(f"No API keys found for user {user_id}") + return await call_next(request) + + # Save original environment values + original_keys = {} + keys_to_inject = { + 'gemini': 'GEMINI_API_KEY', + 'exa': 'EXA_API_KEY', + 'copilotkit': 'COPILOTKIT_API_KEY', + 'openai': 'OPENAI_API_KEY', + 'anthropic': 'ANTHROPIC_API_KEY', + 'tavily': 'TAVILY_API_KEY', + 'serper': 'SERPER_API_KEY', + 'firecrawl': 'FIRECRAWL_API_KEY', + } + + # Inject user-specific keys into environment + for provider, env_var in keys_to_inject.items(): + if provider in user_keys and user_keys[provider]: + # Save original value (if any) + original_keys[env_var] = os.environ.get(env_var) + # Inject user-specific key + os.environ[env_var] = user_keys[provider] + logger.debug(f"[PRODUCTION] Injected {env_var} for user {user_id}") + + try: + # Process request with user-specific keys in environment + response = await call_next(request) + return response + + finally: + # CRITICAL: Restore original environment values + for env_var, original_value in original_keys.items(): + if original_value is None: + # Key didn't exist before, remove it + os.environ.pop(env_var, None) + else: + # Restore original value + os.environ[env_var] = original_value + + logger.debug(f"[PRODUCTION] Cleaned up environment for user {user_id}") + + +async def api_key_injection_middleware(request: Request, call_next: Callable): + """ + Middleware function that injects user-specific API keys into environment. + + Usage in app.py: + app.middleware("http")(api_key_injection_middleware) + """ + middleware = APIKeyInjectionMiddleware() + return await middleware(request, call_next) + diff --git a/backend/models/onboarding.py b/backend/models/onboarding.py index b15a97f1..bcaba151 100644 --- a/backend/models/onboarding.py +++ b/backend/models/onboarding.py @@ -16,6 +16,7 @@ class OnboardingSession(Base): api_keys = relationship('APIKey', back_populates='session', cascade="all, delete-orphan") website_analyses = relationship('WebsiteAnalysis', back_populates='session', cascade="all, delete-orphan") research_preferences = relationship('ResearchPreferences', back_populates='session', cascade="all, delete-orphan", uselist=False) + persona_data = relationship('PersonaData', back_populates='session', cascade="all, delete-orphan", uselist=False) def __repr__(self): return f"" @@ -143,4 +144,40 @@ class ResearchPreferences(Base): 'recommended_settings': self.recommended_settings, 'created_at': self.created_at.isoformat() if self.created_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None - } \ No newline at end of file + } + +class PersonaData(Base): + """Stores persona generation data from onboarding step 4.""" + __tablename__ = 'persona_data' + + id = Column(Integer, primary_key=True, autoincrement=True) + session_id = Column(Integer, ForeignKey('onboarding_sessions.id', ondelete='CASCADE'), nullable=False) + + # Persona generation results + core_persona = Column(JSON) # Core persona data (demographics, psychographics, etc.) + platform_personas = Column(JSON) # Platform-specific personas (LinkedIn, Twitter, etc.) + quality_metrics = Column(JSON) # Quality assessment metrics + selected_platforms = Column(JSON) # Array of selected platforms + + # Metadata + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + # Relationships + session = relationship('OnboardingSession', back_populates='persona_data') + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary for API responses.""" + return { + 'id': self.id, + 'session_id': self.session_id, + 'core_persona': self.core_persona, + 'platform_personas': self.platform_personas, + 'quality_metrics': self.quality_metrics, + 'selected_platforms': self.selected_platforms, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } \ No newline at end of file diff --git a/backend/routers/frontend_env_manager.py b/backend/routers/frontend_env_manager.py new file mode 100644 index 00000000..bdc8c939 --- /dev/null +++ b/backend/routers/frontend_env_manager.py @@ -0,0 +1,110 @@ +""" +Frontend Environment Manager +Handles updating frontend environment variables (for development purposes). +""" + +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel +from typing import Dict, Any, Optional +from loguru import logger +import os +from pathlib import Path + +router = APIRouter( + prefix="/api/frontend-env", + tags=["Frontend Environment"], +) + +class FrontendEnvUpdateRequest(BaseModel): + key: str + value: str + description: Optional[str] = None + +@router.post("/update") +async def update_frontend_env(request: FrontendEnvUpdateRequest): + """ + Update frontend environment variable (for development purposes). + This writes to the frontend/.env file. + """ + try: + # Get the frontend directory path + backend_dir = Path(__file__).parent.parent + frontend_dir = backend_dir.parent / "frontend" + env_path = frontend_dir / ".env" + + # Ensure the frontend directory exists + if not frontend_dir.exists(): + raise HTTPException(status_code=404, detail="Frontend directory not found") + + # Read existing .env file + env_lines = [] + if env_path.exists(): + with open(env_path, 'r') as f: + env_lines = f.readlines() + + # Update or add the environment variable + key_found = False + updated_lines = [] + for line in env_lines: + if line.startswith(f"{request.key}="): + updated_lines.append(f"{request.key}={request.value}\n") + key_found = True + else: + updated_lines.append(line) + + if not key_found: + # Add comment if description provided + if request.description: + updated_lines.append(f"# {request.description}\n") + updated_lines.append(f"{request.key}={request.value}\n") + + # Write back to .env file + with open(env_path, 'w') as f: + f.writelines(updated_lines) + + logger.info(f"Updated frontend environment variable: {request.key}") + + return { + "success": True, + "message": f"Environment variable {request.key} updated successfully", + "key": request.key, + "value": request.value + } + + except Exception as e: + logger.error(f"Error updating frontend environment: {e}") + raise HTTPException(status_code=500, detail=f"Failed to update environment variable: {str(e)}") + +@router.get("/status") +async def get_frontend_env_status(): + """ + Get status of frontend environment file. + """ + try: + # Get the frontend directory path + backend_dir = Path(__file__).parent.parent + frontend_dir = backend_dir.parent / "frontend" + env_path = frontend_dir / ".env" + + if not env_path.exists(): + return { + "exists": False, + "path": str(env_path), + "message": "Frontend .env file does not exist" + } + + # Read and return basic info about the .env file + with open(env_path, 'r') as f: + content = f.read() + + return { + "exists": True, + "path": str(env_path), + "size": len(content), + "lines": len(content.splitlines()), + "message": "Frontend .env file exists" + } + + except Exception as e: + logger.error(f"Error checking frontend environment status: {e}") + raise HTTPException(status_code=500, detail=f"Failed to check environment status: {str(e)}") diff --git a/backend/scripts/create_persona_data_table.py b/backend/scripts/create_persona_data_table.py new file mode 100644 index 00000000..c1222b63 --- /dev/null +++ b/backend/scripts/create_persona_data_table.py @@ -0,0 +1,124 @@ +""" +Script to create the persona_data table for onboarding step 4. +This migration adds support for storing persona generation data. + +Usage: + python backend/scripts/create_persona_data_table.py +""" + +import sys +import os +from pathlib import Path + +# Add backend directory to path +backend_dir = Path(__file__).parent.parent +sys.path.insert(0, str(backend_dir)) + +from loguru import logger +from sqlalchemy import inspect + +def create_persona_data_table(): + """Create the persona_data table.""" + try: + # Import after path is set + from services.database import engine + from models.onboarding import Base as OnboardingBase, PersonaData + + logger.info("🔍 Checking if persona_data table exists...") + + # Check if table already exists + inspector = inspect(engine) + existing_tables = inspector.get_table_names() + + if 'persona_data' in existing_tables: + logger.info("✅ persona_data table already exists") + return True + + logger.info("📊 Creating persona_data table...") + + # Create only the persona_data table + PersonaData.__table__.create(bind=engine, checkfirst=True) + + logger.info("✅ persona_data table created successfully") + + # Verify creation + inspector = inspect(engine) + existing_tables = inspector.get_table_names() + + if 'persona_data' in existing_tables: + logger.info("✅ Verification successful - persona_data table exists") + + # Show table structure + columns = inspector.get_columns('persona_data') + logger.info(f"📋 Table structure ({len(columns)} columns):") + for col in columns: + logger.info(f" - {col['name']}: {col['type']}") + + return True + else: + logger.error("❌ Table creation verification failed") + return False + + except Exception as e: + logger.error(f"❌ Error creating persona_data table: {e}") + import traceback + logger.error(traceback.format_exc()) + return False + +def check_onboarding_tables(): + """Check all onboarding-related tables.""" + try: + from services.database import engine + from sqlalchemy import inspect + + inspector = inspect(engine) + existing_tables = inspector.get_table_names() + + onboarding_tables = [ + 'onboarding_sessions', + 'api_keys', + 'website_analyses', + 'research_preferences', + 'persona_data' + ] + + logger.info("📋 Onboarding Tables Status:") + for table in onboarding_tables: + status = "✅" if table in existing_tables else "❌" + logger.info(f" {status} {table}") + + return True + + except Exception as e: + logger.error(f"Error checking tables: {e}") + return False + +if __name__ == "__main__": + logger.info("=" * 60) + logger.info("Persona Data Table Migration") + logger.info("=" * 60) + + # Check existing tables + check_onboarding_tables() + + logger.info("") + + # Create persona_data table + if create_persona_data_table(): + logger.info("") + logger.info("=" * 60) + logger.info("✅ Migration completed successfully!") + logger.info("=" * 60) + + # Check again to confirm + logger.info("") + check_onboarding_tables() + + sys.exit(0) + else: + logger.error("") + logger.error("=" * 60) + logger.error("❌ Migration failed!") + logger.error("=" * 60) + sys.exit(1) + diff --git a/backend/scripts/verify_onboarding_data.py b/backend/scripts/verify_onboarding_data.py new file mode 100644 index 00000000..e6fe86e9 --- /dev/null +++ b/backend/scripts/verify_onboarding_data.py @@ -0,0 +1,338 @@ +""" +Database Verification Script for Onboarding Data +Verifies that all onboarding steps data is properly saved to the database. + +Usage: + python backend/scripts/verify_onboarding_data.py [user_id] + +Example: + python backend/scripts/verify_onboarding_data.py user_33Gz1FPI86VDXhRY8QN4ragRFGN +""" + +import sys +import os +from pathlib import Path + +# Add backend directory to path +backend_dir = Path(__file__).parent.parent +sys.path.insert(0, str(backend_dir)) + +from loguru import logger +from sqlalchemy import inspect, text +from typing import Optional +import json + +def get_user_id_from_args() -> Optional[str]: + """Get user_id from command line arguments.""" + if len(sys.argv) > 1: + return sys.argv[1] + return None + +def verify_table_exists(table_name: str, inspector) -> bool: + """Check if a table exists in the database.""" + tables = inspector.get_table_names() + exists = table_name in tables + + if exists: + logger.info(f"✅ Table '{table_name}' exists") + # Show column count + columns = inspector.get_columns(table_name) + logger.info(f" Columns: {len(columns)}") + else: + logger.error(f"❌ Table '{table_name}' does NOT exist") + + return exists + +def verify_onboarding_session(user_id: str, db): + """Verify onboarding session data.""" + try: + from models.onboarding import OnboardingSession + + session = db.query(OnboardingSession).filter( + OnboardingSession.user_id == user_id + ).first() + + if session: + logger.info(f"✅ Onboarding Session found for user: {user_id}") + logger.info(f" Session ID: {session.id}") + logger.info(f" Current Step: {session.current_step}") + logger.info(f" Progress: {session.progress}%") + logger.info(f" Started At: {session.started_at}") + logger.info(f" Updated At: {session.updated_at}") + return session.id + else: + logger.error(f"❌ No onboarding session found for user: {user_id}") + return None + + except Exception as e: + logger.error(f"Error verifying onboarding session: {e}") + return None + +def verify_api_keys(session_id: int, user_id: str, db): + """Verify API keys data (Step 1).""" + try: + from models.onboarding import APIKey + + api_keys = db.query(APIKey).filter( + APIKey.session_id == session_id + ).all() + + if api_keys: + logger.info(f"✅ Step 1 (API Keys): Found {len(api_keys)} API key(s)") + for key in api_keys: + # Mask the key for security + masked_key = f"{key.key[:8]}...{key.key[-4:]}" if len(key.key) > 12 else "***" + logger.info(f" - Provider: {key.provider}") + logger.info(f" Key: {masked_key}") + logger.info(f" Created: {key.created_at}") + else: + logger.warning(f"⚠️ Step 1 (API Keys): No API keys found") + + except Exception as e: + logger.error(f"Error verifying API keys: {e}") + +def verify_website_analysis(session_id: int, user_id: str, db): + """Verify website analysis data (Step 2).""" + try: + from models.onboarding import WebsiteAnalysis + + analysis = db.query(WebsiteAnalysis).filter( + WebsiteAnalysis.session_id == session_id + ).first() + + if analysis: + logger.info(f"✅ Step 2 (Website Analysis): Data found") + logger.info(f" Website URL: {analysis.website_url}") + logger.info(f" Analysis Date: {analysis.analysis_date}") + logger.info(f" Status: {analysis.status}") + + if analysis.writing_style: + logger.info(f" Writing Style: {len(analysis.writing_style)} attributes") + if analysis.content_characteristics: + logger.info(f" Content Characteristics: {len(analysis.content_characteristics)} attributes") + if analysis.target_audience: + logger.info(f" Target Audience: {len(analysis.target_audience)} attributes") + else: + logger.warning(f"⚠️ Step 2 (Website Analysis): No data found") + + except Exception as e: + logger.error(f"Error verifying website analysis: {e}") + +def verify_research_preferences(session_id: int, user_id: str, db): + """Verify research preferences data (Step 3).""" + try: + from models.onboarding import ResearchPreferences + + prefs = db.query(ResearchPreferences).filter( + ResearchPreferences.session_id == session_id + ).first() + + if prefs: + logger.info(f"✅ Step 3 (Research Preferences): Data found") + logger.info(f" Research Depth: {prefs.research_depth}") + logger.info(f" Content Types: {prefs.content_types}") + logger.info(f" Auto Research: {prefs.auto_research}") + logger.info(f" Factual Content: {prefs.factual_content}") + else: + logger.warning(f"⚠️ Step 3 (Research Preferences): No data found") + + except Exception as e: + logger.error(f"Error verifying research preferences: {e}") + +def verify_persona_data(session_id: int, user_id: str, db): + """Verify persona data (Step 4) - THE NEW FIX!""" + try: + from models.onboarding import PersonaData + + persona = db.query(PersonaData).filter( + PersonaData.session_id == session_id + ).first() + + if persona: + logger.info(f"✅ Step 4 (Persona Generation): Data found ⭐") + + if persona.core_persona: + logger.info(f" Core Persona: Present") + if isinstance(persona.core_persona, dict): + logger.info(f" Attributes: {len(persona.core_persona)} fields") + + if persona.platform_personas: + logger.info(f" Platform Personas: Present") + if isinstance(persona.platform_personas, dict): + platforms = list(persona.platform_personas.keys()) + logger.info(f" Platforms: {', '.join(platforms)}") + + if persona.quality_metrics: + logger.info(f" Quality Metrics: Present") + if isinstance(persona.quality_metrics, dict): + logger.info(f" Metrics: {len(persona.quality_metrics)} fields") + + if persona.selected_platforms: + logger.info(f" Selected Platforms: {persona.selected_platforms}") + + logger.info(f" Created At: {persona.created_at}") + logger.info(f" Updated At: {persona.updated_at}") + else: + logger.error(f"❌ Step 4 (Persona Generation): No data found - THIS IS THE BUG WE FIXED!") + + except Exception as e: + logger.error(f"Error verifying persona data: {e}") + import traceback + logger.error(traceback.format_exc()) + +def show_raw_sql_query_example(user_id: str): + """Show example SQL queries for manual verification.""" + logger.info("") + logger.info("=" * 60) + logger.info("📋 Raw SQL Queries for Manual Verification:") + logger.info("=" * 60) + + queries = [ + ("Onboarding Session", + f"SELECT * FROM onboarding_sessions WHERE user_id = '{user_id}';"), + + ("API Keys", + f"""SELECT ak.* FROM api_keys ak + JOIN onboarding_sessions os ON ak.session_id = os.id + WHERE os.user_id = '{user_id}';"""), + + ("Website Analysis", + f"""SELECT wa.website_url, wa.analysis_date, wa.status + FROM website_analyses wa + JOIN onboarding_sessions os ON wa.session_id = os.id + WHERE os.user_id = '{user_id}';"""), + + ("Research Preferences", + f"""SELECT rp.research_depth, rp.content_types, rp.auto_research + FROM research_preferences rp + JOIN onboarding_sessions os ON rp.session_id = os.id + WHERE os.user_id = '{user_id}';"""), + + ("Persona Data (NEW!)", + f"""SELECT pd.* FROM persona_data pd + JOIN onboarding_sessions os ON pd.session_id = os.id + WHERE os.user_id = '{user_id}';"""), + ] + + for title, query in queries: + logger.info(f"\n{title}:") + logger.info(f" {query}") + +def count_all_records(db): + """Count records in all onboarding tables.""" + logger.info("") + logger.info("=" * 60) + logger.info("📊 Overall Database Statistics:") + logger.info("=" * 60) + + try: + from models.onboarding import ( + OnboardingSession, APIKey, WebsiteAnalysis, + ResearchPreferences, PersonaData + ) + + counts = { + "Onboarding Sessions": db.query(OnboardingSession).count(), + "API Keys": db.query(APIKey).count(), + "Website Analyses": db.query(WebsiteAnalysis).count(), + "Research Preferences": db.query(ResearchPreferences).count(), + "Persona Data": db.query(PersonaData).count(), + } + + for table, count in counts.items(): + logger.info(f" {table}: {count} record(s)") + + except Exception as e: + logger.error(f"Error counting records: {e}") + +def main(): + """Main verification function.""" + logger.info("=" * 60) + logger.info("🔍 Onboarding Database Verification") + logger.info("=" * 60) + + # Get user_id + user_id = get_user_id_from_args() + + if not user_id: + logger.warning("⚠️ No user_id provided. Will show overall statistics only.") + logger.info("Usage: python backend/scripts/verify_onboarding_data.py ") + + try: + from services.database import SessionLocal, engine + from sqlalchemy import inspect + + # Check tables exist + logger.info("") + logger.info("=" * 60) + logger.info("1️⃣ Verifying Database Tables:") + logger.info("=" * 60) + + inspector = inspect(engine) + tables = [ + 'onboarding_sessions', + 'api_keys', + 'website_analyses', + 'research_preferences', + 'persona_data' + ] + + all_exist = True + for table in tables: + if not verify_table_exists(table, inspector): + all_exist = False + + if not all_exist: + logger.error("") + logger.error("❌ Some tables are missing! Run migrations first.") + return False + + # Count all records + db = SessionLocal() + try: + count_all_records(db) + + # If user_id provided, show detailed data + if user_id: + logger.info("") + logger.info("=" * 60) + logger.info(f"2️⃣ Verifying Data for User: {user_id}") + logger.info("=" * 60) + + # Verify session + session_id = verify_onboarding_session(user_id, db) + + if session_id: + logger.info("") + # Verify each step's data + verify_api_keys(session_id, user_id, db) + logger.info("") + verify_website_analysis(session_id, user_id, db) + logger.info("") + verify_research_preferences(session_id, user_id, db) + logger.info("") + verify_persona_data(session_id, user_id, db) + + # Show SQL examples + show_raw_sql_query_example(user_id) + + logger.info("") + logger.info("=" * 60) + logger.info("✅ Verification Complete!") + logger.info("=" * 60) + + return True + + finally: + db.close() + + except Exception as e: + logger.error(f"❌ Verification failed: {e}") + import traceback + logger.error(traceback.format_exc()) + return False + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) + diff --git a/backend/services/api_key_manager.py b/backend/services/api_key_manager.py index 901868b8..908fa676 100644 --- a/backend/services/api_key_manager.py +++ b/backend/services/api_key_manager.py @@ -35,14 +35,31 @@ class StepData: class OnboardingProgress: """Manages onboarding progress with persistence and validation.""" - def __init__(self, progress_file: Optional[str] = None): + def __init__(self, progress_file: Optional[str] = None, user_id: Optional[str] = None): self.steps = self._initialize_steps() self.current_step = 1 self.started_at = datetime.now().isoformat() self.last_updated = datetime.now().isoformat() self.is_completed = False self.completed_at = None - self.progress_file = progress_file or ".onboarding_progress.json" + self.user_id = user_id # Add user_id for database isolation + + # Use user-specific file for backward compatibility + if user_id: + self.progress_file = progress_file or f".onboarding_progress_{user_id}.json" + else: + self.progress_file = progress_file or ".onboarding_progress.json" + + # Initialize database service for dual persistence + try: + from services.onboarding_database_service import OnboardingDatabaseService + self.db_service = OnboardingDatabaseService() + self.use_database = True + logger.info(f"Database service initialized for user {user_id}") + except Exception as e: + logger.warning(f"Database service not available, using file only: {e}") + self.db_service = None + self.use_database = False # Load existing progress if available self.load_progress() @@ -192,8 +209,9 @@ class OnboardingProgress: logger.info("Onboarding completed successfully") def save_progress(self): - """Save progress to file.""" + """Save progress to both file and database (dual persistence).""" try: + # Save to JSON file (backward compatibility) progress_data = { "steps": [{ "step_number": step.step_number, @@ -215,6 +233,65 @@ class OnboardingProgress: json.dump(progress_data, f, indent=2) logger.debug(f"Progress saved to {self.progress_file}") + + # Also save to database if available and user_id is set + if self.use_database and self.db_service and self.user_id: + try: + from services.database import SessionLocal + db = SessionLocal() + try: + # Update session progress + self.db_service.update_step(self.user_id, self.current_step, db) + + # Calculate progress percentage + completed_count = sum(1 for s in self.steps if s.status == StepStatus.COMPLETED) + progress_pct = (completed_count / len(self.steps)) * 100 + self.db_service.update_progress(self.user_id, progress_pct, db) + + # Save step-specific data to appropriate tables + for step in self.steps: + if step.status == StepStatus.COMPLETED and step.data: + if step.step_number == 1: # API Keys + api_keys = step.data.get('api_keys', {}) + for provider, key in api_keys.items(): + if key: + # Save to database (for user isolation in production) + self.db_service.save_api_key(self.user_id, provider, key, db) + + # Also save to .env file ONLY in local development + # This allows local developers to have keys in .env for convenience + # In production, keys are fetched from database per user + is_local = os.getenv('DEPLOY_ENV', 'local') == 'local' + if is_local: + try: + from services.api_key_manager import APIKeyManager + api_key_manager = APIKeyManager() + api_key_manager.save_api_key(provider, key) + logger.info(f"[LOCAL] API key for {provider} saved to .env file") + except Exception as env_error: + logger.warning(f"[LOCAL] Failed to save {provider} API key to .env file: {env_error}") + else: + logger.info(f"[PRODUCTION] API key for {provider} saved to database only (user: {self.user_id})") + + # Log database save confirmation + logger.info(f"✅ DATABASE: API key for {provider} saved to database for user {self.user_id}") + elif step.step_number == 2: # Website Analysis + self.db_service.save_website_analysis(self.user_id, step.data, db) + logger.info(f"✅ DATABASE: Website analysis saved to database for user {self.user_id}") + elif step.step_number == 3: # Research Preferences + self.db_service.save_research_preferences(self.user_id, step.data, db) + logger.info(f"✅ DATABASE: Research preferences saved to database for user {self.user_id}") + elif step.step_number == 4: # Persona Generation + self.db_service.save_persona_data(self.user_id, step.data, db) + logger.info(f"✅ DATABASE: Persona data saved to database for user {self.user_id}") + + logger.info(f"Progress also saved to database for user {self.user_id}") + finally: + db.close() + except Exception as db_error: + logger.warning(f"Failed to save to database, JSON file still saved: {db_error}") + # Don't fail if database save fails - JSON is still working + except Exception as e: logger.error(f"Error saving progress: {str(e)}") @@ -423,8 +500,34 @@ class APIKeyManager: try: if provider in self.api_keys: self.api_keys[provider] = api_key - self._save_to_env_file(provider, api_key) - logger.info(f"API key saved for {provider}") + + # Save to database if available and user_id is set + if hasattr(self, 'use_database') and self.use_database and hasattr(self, 'db_service') and self.db_service and hasattr(self, 'user_id') and self.user_id: + try: + from services.database import SessionLocal + db = SessionLocal() + try: + self.db_service.save_api_key(self.user_id, provider, api_key, db) + logger.info(f"✅ DATABASE: API key for {provider} saved to database for user {self.user_id}") + finally: + db.close() + except Exception as db_error: + logger.warning(f"Failed to save {provider} API key to database: {db_error}") + + # Also save to .env file in local mode + is_local = os.getenv('DEPLOY_ENV', 'local') == 'local' + if is_local: + # Special handling for CopilotKit - save to frontend/.env + if provider == 'copilotkit': + self._save_to_frontend_env(api_key) + logger.info(f"[LOCAL] CopilotKit API key saved to frontend/.env file") + else: + # Save other keys to backend/.env + self._save_to_env_file(provider, api_key) + logger.info(f"[LOCAL] API key for {provider} saved to backend/.env file") + else: + logger.info(f"[PRODUCTION] API key for {provider} saved to memory only (database handles persistence)") + return True else: logger.error(f"Unknown provider: {provider}") @@ -490,8 +593,50 @@ class APIKeyManager: "total_providers": len(self.api_keys) } + def _save_to_frontend_env(self, api_key: str): + """Save CopilotKit API key to frontend/.env file.""" + try: + # Get the frontend directory path + backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + frontend_dir = os.path.join(os.path.dirname(backend_dir), "frontend") + env_path = os.path.join(frontend_dir, ".env") + + # Read existing .env file + if os.path.exists(env_path): + with open(env_path, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + else: + lines = [] + + # Update or add REACT_APP_COPILOTKIT_API_KEY + key_found = False + updated_lines = [] + env_var = "REACT_APP_COPILOTKIT_API_KEY" + + for line in lines: + if line.startswith(f"{env_var}="): + updated_lines.append(f"{env_var}={api_key}\n") + key_found = True + else: + updated_lines.append(line) + + if not key_found: + # Ensure the file ends with a newline before adding new key + if updated_lines and not updated_lines[-1].endswith('\n'): + updated_lines[-1] += '\n' + updated_lines.append(f"{env_var}={api_key}\n") + + # Write back to frontend .env file + with open(env_path, 'w', encoding='utf-8') as f: + f.writelines(updated_lines) + + logger.debug(f"CopilotKit API key saved to frontend .env file") + + except Exception as e: + logger.error(f"Error saving to frontend .env file: {str(e)}") + def _save_to_env_file(self, provider: str, api_key: str): - """Save API key to .env file.""" + """Save API key to backend .env file.""" try: env_mapping = { "openai": "OPENAI_API_KEY", @@ -513,11 +658,10 @@ class APIKeyManager: os.environ[env_var] = api_key # Update .env file - use backend directory path - import os backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) env_path = os.path.join(backend_dir, ".env") if os.path.exists(env_path): - with open(env_path, 'r') as f: + with open(env_path, 'r', encoding='utf-8', errors='ignore') as f: lines = f.readlines() else: lines = [] @@ -532,13 +676,23 @@ class APIKeyManager: updated_lines.append(line) if not key_found: + # Ensure the file ends with a newline before adding new key + if updated_lines and not updated_lines[-1].endswith('\n'): + updated_lines[-1] += '\n' updated_lines.append(f"{env_var}={api_key}\n") - with open(env_path, 'w') as f: + with open(env_path, 'w', encoding='utf-8') as f: f.writelines(updated_lines) - # Reload environment variables - load_dotenv(override=True) + # Reload environment variables into current process + load_dotenv(env_path, override=True) + + # Verify the key is now in environment + loaded_key = os.environ.get(env_var) + if loaded_key == api_key: + logger.info(f"✅ {env_var} loaded into environment (available for immediate use)") + else: + logger.warning(f"⚠️ {env_var} written to .env but not in environment yet") logger.debug(f"API key saved to .env file for {provider}") except Exception as e: @@ -555,13 +709,17 @@ def get_onboarding_progress() -> OnboardingProgress: return get_onboarding_progress._instance def get_onboarding_progress_for_user(user_id: str) -> OnboardingProgress: - """Get or create a per-user onboarding progress instance persisted to a user-specific file.""" + """Get or create a per-user onboarding progress instance with database persistence.""" global _user_onboarding_progress_cache safe_user_id = ''.join([c if c.isalnum() or c in ('-', '_') else '_' for c in str(user_id)]) if safe_user_id in _user_onboarding_progress_cache: return _user_onboarding_progress_cache[safe_user_id] + + # Create user-specific progress file for backward compatibility progress_file = f".onboarding_progress_{safe_user_id}.json" - instance = OnboardingProgress(progress_file=progress_file) + + # Pass user_id to enable database persistence + instance = OnboardingProgress(progress_file=progress_file, user_id=user_id) _user_onboarding_progress_cache[safe_user_id] = instance return instance diff --git a/backend/services/onboarding_database_service.py b/backend/services/onboarding_database_service.py new file mode 100644 index 00000000..fcf4f991 --- /dev/null +++ b/backend/services/onboarding_database_service.py @@ -0,0 +1,418 @@ +""" +Onboarding Database Service +Provides database-backed storage for onboarding progress with user isolation. +This replaces the JSON file-based storage with proper database persistence. +""" + +from typing import Dict, Any, Optional, List +from datetime import datetime +from loguru import logger +from sqlalchemy.orm import Session +from sqlalchemy.exc import SQLAlchemyError + +from models.onboarding import OnboardingSession, APIKey, WebsiteAnalysis, ResearchPreferences, PersonaData +from services.database import get_db + + +class OnboardingDatabaseService: + """Database service for onboarding with user isolation.""" + + def __init__(self, db: Session = None): + """Initialize with optional database session.""" + self.db = db + + def get_or_create_session(self, user_id: str, db: Session = None) -> OnboardingSession: + """Get existing onboarding session or create new one for user.""" + session_db = db or self.db + if not session_db: + raise ValueError("Database session required") + + try: + # Try to get existing session for this user + session = session_db.query(OnboardingSession).filter( + OnboardingSession.user_id == user_id + ).first() + + if session: + logger.info(f"Found existing onboarding session for user {user_id}") + return session + + # Create new session + session = OnboardingSession( + user_id=user_id, + current_step=1, + progress=0.0, + started_at=datetime.now() + ) + session_db.add(session) + session_db.commit() + session_db.refresh(session) + + logger.info(f"Created new onboarding session for user {user_id}") + return session + + except SQLAlchemyError as e: + logger.error(f"Database error in get_or_create_session: {e}") + session_db.rollback() + raise + + def get_session_by_user(self, user_id: str, db: Session = None) -> Optional[OnboardingSession]: + """Get onboarding session for user.""" + session_db = db or self.db + if not session_db: + raise ValueError("Database session required") + + try: + return session_db.query(OnboardingSession).filter( + OnboardingSession.user_id == user_id + ).first() + except SQLAlchemyError as e: + logger.error(f"Error getting session: {e}") + return None + + def update_step(self, user_id: str, step_number: int, db: Session = None) -> bool: + """Update current step for user.""" + session_db = db or self.db + if not session_db: + raise ValueError("Database session required") + + try: + session = self.get_or_create_session(user_id, session_db) + session.current_step = step_number + session.updated_at = datetime.now() + session_db.commit() + + logger.info(f"Updated user {user_id} to step {step_number}") + return True + + except SQLAlchemyError as e: + logger.error(f"Error updating step: {e}") + session_db.rollback() + return False + + def update_progress(self, user_id: str, progress: float, db: Session = None) -> bool: + """Update progress percentage for user.""" + session_db = db or self.db + if not session_db: + raise ValueError("Database session required") + + try: + session = self.get_or_create_session(user_id, session_db) + session.progress = progress + session.updated_at = datetime.now() + session_db.commit() + + logger.info(f"Updated user {user_id} progress to {progress}%") + return True + + except SQLAlchemyError as e: + logger.error(f"Error updating progress: {e}") + session_db.rollback() + return False + + def save_api_key(self, user_id: str, provider: str, api_key: str, db: Session = None) -> bool: + """Save API key for user with isolation.""" + session_db = db or self.db + if not session_db: + raise ValueError("Database session required") + + try: + # Get user's onboarding session + session = self.get_or_create_session(user_id, session_db) + + # Check if key already exists for this provider and session + existing_key = session_db.query(APIKey).filter( + APIKey.session_id == session.id, + APIKey.provider == provider + ).first() + + if existing_key: + # Update existing key + existing_key.key = api_key + existing_key.updated_at = datetime.now() + logger.info(f"Updated {provider} API key for user {user_id}") + else: + # Create new key + new_key = APIKey( + session_id=session.id, + provider=provider, + key=api_key + ) + session_db.add(new_key) + logger.info(f"Created new {provider} API key for user {user_id}") + + session_db.commit() + return True + + except SQLAlchemyError as e: + logger.error(f"Error saving API key: {e}") + session_db.rollback() + return False + + def get_api_keys(self, user_id: str, db: Session = None) -> Dict[str, str]: + """Get all API keys for user.""" + session_db = db or self.db + if not session_db: + raise ValueError("Database session required") + + try: + session = self.get_session_by_user(user_id, session_db) + if not session: + return {} + + keys = session_db.query(APIKey).filter( + APIKey.session_id == session.id + ).all() + + return {key.provider: key.key for key in keys} + + except SQLAlchemyError as e: + logger.error(f"Error getting API keys: {e}") + return {} + + def save_website_analysis(self, user_id: str, analysis_data: Dict[str, Any], db: Session = None) -> bool: + """Save website analysis for user.""" + session_db = db or self.db + if not session_db: + raise ValueError("Database session required") + + try: + session = self.get_or_create_session(user_id, session_db) + + # Check if analysis already exists + existing = session_db.query(WebsiteAnalysis).filter( + WebsiteAnalysis.session_id == session.id + ).first() + + if existing: + # Update existing + existing.website_url = analysis_data.get('website_url', existing.website_url) + existing.writing_style = analysis_data.get('writing_style') + existing.content_characteristics = analysis_data.get('content_characteristics') + existing.target_audience = analysis_data.get('target_audience') + existing.content_type = analysis_data.get('content_type') + existing.recommended_settings = analysis_data.get('recommended_settings') + existing.crawl_result = analysis_data.get('crawl_result') + existing.style_patterns = analysis_data.get('style_patterns') + existing.style_guidelines = analysis_data.get('style_guidelines') + existing.status = analysis_data.get('status', 'completed') + existing.updated_at = datetime.now() + logger.info(f"Updated website analysis for user {user_id}") + else: + # Create new + analysis = WebsiteAnalysis( + session_id=session.id, + website_url=analysis_data.get('website_url', ''), + writing_style=analysis_data.get('writing_style'), + content_characteristics=analysis_data.get('content_characteristics'), + target_audience=analysis_data.get('target_audience'), + content_type=analysis_data.get('content_type'), + recommended_settings=analysis_data.get('recommended_settings'), + crawl_result=analysis_data.get('crawl_result'), + style_patterns=analysis_data.get('style_patterns'), + style_guidelines=analysis_data.get('style_guidelines'), + status=analysis_data.get('status', 'completed') + ) + session_db.add(analysis) + logger.info(f"Created website analysis for user {user_id}") + + session_db.commit() + return True + + except SQLAlchemyError as e: + logger.error(f"Error saving website analysis: {e}") + session_db.rollback() + return False + + def get_website_analysis(self, user_id: str, db: Session = None) -> Optional[Dict[str, Any]]: + """Get website analysis for user.""" + session_db = db or self.db + if not session_db: + raise ValueError("Database session required") + + try: + session = self.get_session_by_user(user_id, session_db) + if not session: + return None + + analysis = session_db.query(WebsiteAnalysis).filter( + WebsiteAnalysis.session_id == session.id + ).first() + + return analysis.to_dict() if analysis else None + + except SQLAlchemyError as e: + logger.error(f"Error getting website analysis: {e}") + return None + + def save_research_preferences(self, user_id: str, preferences: Dict[str, Any], db: Session = None) -> bool: + """Save research preferences for user.""" + session_db = db or self.db + if not session_db: + raise ValueError("Database session required") + + try: + session = self.get_or_create_session(user_id, session_db) + + # Check if preferences already exist + existing = session_db.query(ResearchPreferences).filter( + ResearchPreferences.session_id == session.id + ).first() + + if existing: + # Update existing + existing.research_depth = preferences.get('research_depth', existing.research_depth) + existing.content_types = preferences.get('content_types', existing.content_types) + existing.auto_research = preferences.get('auto_research', existing.auto_research) + existing.factual_content = preferences.get('factual_content', existing.factual_content) + existing.writing_style = preferences.get('writing_style') + existing.content_characteristics = preferences.get('content_characteristics') + existing.target_audience = preferences.get('target_audience') + existing.recommended_settings = preferences.get('recommended_settings') + existing.updated_at = datetime.now() + logger.info(f"Updated research preferences for user {user_id}") + else: + # Create new + prefs = ResearchPreferences( + session_id=session.id, + research_depth=preferences.get('research_depth', 'standard'), + content_types=preferences.get('content_types', []), + auto_research=preferences.get('auto_research', True), + factual_content=preferences.get('factual_content', True), + writing_style=preferences.get('writing_style'), + content_characteristics=preferences.get('content_characteristics'), + target_audience=preferences.get('target_audience'), + recommended_settings=preferences.get('recommended_settings') + ) + session_db.add(prefs) + logger.info(f"Created research preferences for user {user_id}") + + session_db.commit() + return True + + except SQLAlchemyError as e: + logger.error(f"Error saving research preferences: {e}") + session_db.rollback() + return False + + def save_persona_data(self, user_id: str, persona_data: Dict[str, Any], db: Session = None) -> bool: + """Save persona data for user.""" + session_db = db or self.db + if not session_db: + raise ValueError("Database session required") + + try: + session = self.get_or_create_session(user_id, session_db) + + # Check if persona data already exists for this user + existing = session_db.query(PersonaData).filter( + PersonaData.session_id == session.id + ).first() + + if existing: + # Update existing persona data + existing.core_persona = persona_data.get('corePersona') + existing.platform_personas = persona_data.get('platformPersonas') + existing.quality_metrics = persona_data.get('qualityMetrics') + existing.selected_platforms = persona_data.get('selectedPlatforms', []) + existing.updated_at = datetime.utcnow() + logger.info(f"Updated persona data for user {user_id}") + else: + # Create new persona data record + persona = PersonaData( + session_id=session.id, + core_persona=persona_data.get('corePersona'), + platform_personas=persona_data.get('platformPersonas'), + quality_metrics=persona_data.get('qualityMetrics'), + selected_platforms=persona_data.get('selectedPlatforms', []) + ) + session_db.add(persona) + logger.info(f"Created persona data for user {user_id}") + + session_db.commit() + return True + + except SQLAlchemyError as e: + logger.error(f"Error saving persona data: {e}") + session_db.rollback() + return False + + def get_research_preferences(self, user_id: str, db: Session = None) -> Optional[Dict[str, Any]]: + """Get research preferences for user.""" + session_db = db or self.db + if not session_db: + raise ValueError("Database session required") + + try: + session = self.get_session_by_user(user_id, session_db) + if not session: + return None + + prefs = session_db.query(ResearchPreferences).filter( + ResearchPreferences.session_id == session.id + ).first() + + return prefs.to_dict() if prefs else None + + except SQLAlchemyError as e: + logger.error(f"Error getting research preferences: {e}") + return None + + def mark_onboarding_complete(self, user_id: str, db: Session = None) -> bool: + """Mark onboarding as complete for user.""" + session_db = db or self.db + if not session_db: + raise ValueError("Database session required") + + try: + session = self.get_or_create_session(user_id, session_db) + session.current_step = 6 # Final step + session.progress = 100.0 + session.updated_at = datetime.now() + session_db.commit() + + logger.info(f"Marked onboarding complete for user {user_id}") + return True + + except SQLAlchemyError as e: + logger.error(f"Error marking onboarding complete: {e}") + session_db.rollback() + return False + + def get_onboarding_status(self, user_id: str, db: Session = None) -> Dict[str, Any]: + """Get comprehensive onboarding status for user.""" + session_db = db or self.db + if not session_db: + raise ValueError("Database session required") + + try: + session = self.get_session_by_user(user_id, session_db) + + if not session: + # User hasn't started onboarding yet + return { + "is_completed": False, + "current_step": 1, + "progress": 0.0, + "started_at": None, + "updated_at": None + } + + return { + "is_completed": session.current_step >= 6 and session.progress >= 100.0, + "current_step": session.current_step, + "progress": session.progress, + "started_at": session.started_at.isoformat() if session.started_at else None, + "updated_at": session.updated_at.isoformat() if session.updated_at else None + } + + except SQLAlchemyError as e: + logger.error(f"Error getting onboarding status: {e}") + return { + "is_completed": False, + "current_step": 1, + "progress": 0.0, + "started_at": None, + "updated_at": None + } + diff --git a/backend/services/user_api_key_context.py b/backend/services/user_api_key_context.py new file mode 100644 index 00000000..238d5e19 --- /dev/null +++ b/backend/services/user_api_key_context.py @@ -0,0 +1,150 @@ +""" +User API Key Context Manager +Provides user-specific API keys to backend services. + +In development: Uses .env file +In production: Fetches from database per user +""" + +import os +from typing import Optional, Dict +from loguru import logger +from contextlib import contextmanager + +class UserAPIKeyContext: + """ + Context manager for user-specific API keys. + + Usage: + with UserAPIKeyContext(user_id) as api_keys: + gemini_key = api_keys.get('gemini') + exa_key = api_keys.get('exa') + # Use keys for this specific user + """ + + def __init__(self, user_id: Optional[str] = None): + """ + Initialize with optional user_id. + + Args: + user_id: User ID to fetch keys for. If None, uses .env keys (local mode) + """ + self.user_id = user_id + self.keys: Dict[str, str] = {} + self._is_local = os.getenv('DEPLOY_ENV', 'local') == 'local' + + def __enter__(self): + """Load API keys when entering context.""" + if self._is_local: + # Local mode: Use .env file + self.keys = self._load_from_env() + logger.debug(f"[LOCAL] Loaded API keys from .env file") + elif self.user_id: + # Production mode: Fetch from database + self.keys = self._load_from_database(self.user_id) + logger.debug(f"[PRODUCTION] Loaded API keys from database for user {self.user_id}") + else: + logger.warning("No user_id provided in production mode - using empty keys") + self.keys = {} + + return self.keys + + def __exit__(self, exc_type, exc_val, exc_tb): + """Clean up when exiting context.""" + self.keys.clear() + return False # Don't suppress exceptions + + def _load_from_env(self) -> Dict[str, str]: + """Load API keys from environment variables (.env file).""" + return { + 'gemini': os.getenv('GEMINI_API_KEY', ''), + 'exa': os.getenv('EXA_API_KEY', ''), + 'copilotkit': os.getenv('COPILOTKIT_API_KEY', ''), + 'openai': os.getenv('OPENAI_API_KEY', ''), + 'anthropic': os.getenv('ANTHROPIC_API_KEY', ''), + 'tavily': os.getenv('TAVILY_API_KEY', ''), + 'serper': os.getenv('SERPER_API_KEY', ''), + 'firecrawl': os.getenv('FIRECRAWL_API_KEY', ''), + } + + def _load_from_database(self, user_id: str) -> Dict[str, str]: + """Load API keys from database for specific user.""" + try: + from services.onboarding_database_service import OnboardingDatabaseService + from services.database import SessionLocal + + db_service = OnboardingDatabaseService() + db = SessionLocal() + try: + keys = db_service.get_api_keys(user_id, db) + logger.info(f"Loaded {len(keys)} API keys from database for user {user_id}") + return keys + finally: + db.close() + except Exception as e: + logger.error(f"Failed to load API keys from database for user {user_id}: {e}") + return {} + + @staticmethod + def get_user_key(user_id: Optional[str], provider: str) -> Optional[str]: + """ + Convenience method to get a single API key for a user. + + Args: + user_id: User ID (None for development mode) + provider: Provider name (e.g., 'gemini', 'exa') + + Returns: + API key string or None + """ + with UserAPIKeyContext(user_id) as keys: + return keys.get(provider) + + +@contextmanager +def user_api_keys(user_id: Optional[str] = None): + """ + Context manager function for easier usage. + + Usage: + from services.user_api_key_context import user_api_keys + + with user_api_keys(user_id) as keys: + gemini_key = keys.get('gemini') + """ + context = UserAPIKeyContext(user_id) + try: + yield context.__enter__() + finally: + context.__exit__(None, None, None) + + +# Convenience function for FastAPI dependency injection +def get_user_api_keys(user_id: str) -> Dict[str, str]: + """ + Get user-specific API keys for use in FastAPI endpoints. + + Args: + user_id: User ID from current_user + + Returns: + Dictionary of API keys for this user + """ + with UserAPIKeyContext(user_id) as keys: + return keys + + +def get_gemini_key(user_id: Optional[str] = None) -> Optional[str]: + """Get Gemini API key for user.""" + return UserAPIKeyContext.get_user_key(user_id, 'gemini') + + +def get_exa_key(user_id: Optional[str] = None) -> Optional[str]: + """Get Exa API key for user.""" + return UserAPIKeyContext.get_user_key(user_id, 'exa') + + +def get_copilotkit_key(user_id: Optional[str] = None) -> Optional[str]: + """Get CopilotKit API key for user.""" + return UserAPIKeyContext.get_user_key(user_id, 'copilotkit') + diff --git a/docs/API_KEY_FLOW_DIAGRAM.md b/docs/API_KEY_FLOW_DIAGRAM.md new file mode 100644 index 00000000..d2acc73d --- /dev/null +++ b/docs/API_KEY_FLOW_DIAGRAM.md @@ -0,0 +1,370 @@ +# API Key Management Flow Diagrams + +## 🏠 Local Development Mode + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ LOCAL DEVELOPMENT │ +│ (DEBUG=true) │ +└─────────────────────────────────────────────────────────────────────┘ + +Developer completes onboarding + │ + ├─> Frontend: Save API keys + │ └─> POST /api/onboarding/api-keys (gemini, exa, copilotkit) + │ + ├─> Backend: Process API keys + │ │ + │ ├─> Save to PostgreSQL database + │ │ └─> onboarding_sessions (user_id) + │ │ └─> api_keys (provider, key) + │ │ + │ └─> Save to backend/.env file [DEV MODE ONLY] + │ ├─> GEMINI_API_KEY=xxx + │ ├─> EXA_API_KEY=xxx + │ └─> COPILOTKIT_API_KEY=xxx + │ + └─> Frontend: Save CopilotKit to frontend/.env + └─> REACT_APP_COPILOTKIT_API_KEY=xxx + + +Developer generates content + │ + ├─> Service calls user_api_keys(user_id=None) + │ │ + │ └─> Detects DEV mode (DEBUG=true) + │ └─> Reads from backend/.env file + │ └─> Returns all keys + │ + └─> Content generated using developer's keys + └─> All costs → Developer's API account + + +✅ Advantages: + • Quick setup (keys persist in .env) + • No database required for basic dev + • Single developer = single set of keys + • Keys survive server restarts +``` + +--- + +## 🌐 Production Mode (Multi-User) + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ PRODUCTION (VERCEL + RENDER) │ +│ (DEBUG=false, DEPLOY_ENV=render) │ +└─────────────────────────────────────────────────────────────────────┘ + +Alpha Tester A visits https://alwrity-ai.vercel.app + │ + ├─> Completes onboarding + │ └─> Enters API keys: + │ ├─> GEMINI_API_KEY=tester_a_key + │ ├─> EXA_API_KEY=tester_a_exa + │ └─> COPILOTKIT_API_KEY=tester_a_copilot + │ + ├─> Frontend: Save API keys + │ ├─> POST /api/onboarding/api-keys (gemini, exa, copilotkit) + │ └─> Save to localStorage (CopilotKit) + │ + └─> Backend: Process API keys + ├─> Save to PostgreSQL database ONLY [PROD MODE] + │ └─> onboarding_sessions + │ ├─> user_id = "user_clerk_tester_a" + │ └─> api_keys + │ ├─> (session_id, "gemini", "tester_a_key") + │ ├─> (session_id, "exa", "tester_a_exa") + │ └─> (session_id, "copilotkit", "tester_a_copilot") + │ + └─> [SKIP] ❌ Do NOT save to .env file (multi-user conflict!) + + +Alpha Tester A generates blog content + │ + ├─> Request to /api/blog/generate + │ └─> Headers: Authorization: Bearer + │ + ├─> Auth Middleware extracts user_id = "user_clerk_tester_a" + │ + ├─> BlogService calls user_api_keys("user_clerk_tester_a") + │ │ + │ ├─> Detects PROD mode (DEPLOY_ENV=render) + │ │ + │ └─> Query database: + │ SELECT key FROM api_keys + │ WHERE session_id = ( + │ SELECT id FROM onboarding_sessions + │ WHERE user_id = 'user_clerk_tester_a' + │ ) + │ └─> Returns: {"gemini": "tester_a_key", "exa": "tester_a_exa"} + │ + └─> Content generated using Tester A's Gemini key + └─> All costs → Tester A's Gemini account + + +──────────────────────────────────────────────────────────────────────── + +SIMULTANEOUSLY... + +Alpha Tester B visits https://alwrity-ai.vercel.app + │ + ├─> Completes onboarding + │ └─> Enters API keys: + │ ├─> GEMINI_API_KEY=tester_b_key + │ ├─> EXA_API_KEY=tester_b_exa + │ └─> COPILOTKIT_API_KEY=tester_b_copilot + │ + └─> Backend: Save to database + └─> onboarding_sessions + ├─> user_id = "user_clerk_tester_b" + └─> api_keys + ├─> (session_id, "gemini", "tester_b_key") [SEPARATE!] + ├─> (session_id, "exa", "tester_b_exa") + └─> (session_id, "copilotkit", "tester_b_copilot") + + +Alpha Tester B generates blog content + │ + ├─> Request to /api/blog/generate + │ └─> Headers: Authorization: Bearer + │ + ├─> Auth Middleware extracts user_id = "user_clerk_tester_b" + │ + ├─> BlogService calls user_api_keys("user_clerk_tester_b") + │ │ + │ └─> Query database: + │ WHERE user_id = 'user_clerk_tester_b' [DIFFERENT!] + │ └─> Returns: {"gemini": "tester_b_key", "exa": "tester_b_exa"} + │ + └─> Content generated using Tester B's Gemini key + └─> All costs → Tester B's Gemini account + + +✅ User Isolation: + • Tester A's keys ≠ Tester B's keys + • Tester A's costs ≠ Tester B's costs + • Completely isolated in database + • You (owner) pay nothing! 🎉 +``` + +--- + +## 🔄 Environment Detection Logic + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ ENVIRONMENT DETECTION │ +└─────────────────────────────────────────────────────────────────────┘ + +When user_api_keys(user_id) is called: + + ┌──────────────────────────────────┐ + │ Check environment variables │ + └──────────────────────────────────┘ + │ + ├─> DEBUG=true OR DEPLOY_ENV=None + │ │ + │ ├─> DEVELOPMENT MODE + │ │ └─> Read from backend/.env file + │ │ └─> os.getenv('GEMINI_API_KEY') + │ │ + │ └─> Log: "[DEV MODE] Using .env file" + │ + └─> DEBUG=false AND DEPLOY_ENV=render + │ + ├─> PRODUCTION MODE + │ └─> Read from database + │ └─> SELECT key FROM api_keys WHERE user_id=? + │ + └─> Log: "[PROD MODE] Using database for user {user_id}" + + +Example configurations: + + Local Development: + ┌─────────────────────────────┐ + │ backend/.env │ + ├─────────────────────────────┤ + │ DEBUG=true │ + │ GEMINI_API_KEY=dev_key │ + │ EXA_API_KEY=dev_exa │ + └─────────────────────────────┘ + + Render Production: + ┌─────────────────────────────┐ + │ Environment Variables │ + ├─────────────────────────────┤ + │ DEBUG=false │ + │ DEPLOY_ENV=render │ + │ DATABASE_URL=postgresql:// │ + └─────────────────────────────┘ +``` + +--- + +## 📊 Database Schema Visualization + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ DATABASE SCHEMA │ +└─────────────────────────────────────────────────────────────────────┘ + +onboarding_sessions +┌────────────┬──────────────────────────┬─────────────┬──────────┐ +│ id (PK) │ user_id (UNIQUE) │ current_step│ progress │ +├────────────┼──────────────────────────┼─────────────┼──────────┤ +│ 1 │ user_clerk_tester_a │ 6 │ 100.0 │ +│ 2 │ user_clerk_tester_b │ 6 │ 100.0 │ +│ 3 │ user_clerk_tester_c │ 3 │ 50.0 │ +└────────────┴──────────────────────────┴─────────────┴──────────┘ + +api_keys +┌────────────┬────────────┬──────────────┬────────────────────────┐ +│ id (PK) │ session_id │ provider │ key │ +│ │ (FK) │ │ │ +├────────────┼────────────┼──────────────┼────────────────────────┤ +│ 1 │ 1 │ gemini │ tester_a_gemini_key │ ← Tester A +│ 2 │ 1 │ exa │ tester_a_exa_key │ ← Tester A +│ 3 │ 1 │ copilotkit │ tester_a_copilot_key │ ← Tester A +├────────────┼────────────┼──────────────┼────────────────────────┤ +│ 4 │ 2 │ gemini │ tester_b_gemini_key │ ← Tester B +│ 5 │ 2 │ exa │ tester_b_exa_key │ ← Tester B +│ 6 │ 2 │ copilotkit │ tester_b_copilot_key │ ← Tester B +├────────────┼────────────┼──────────────┼────────────────────────┤ +│ 7 │ 3 │ gemini │ tester_c_gemini_key │ ← Tester C +│ 8 │ 3 │ exa │ tester_c_exa_key │ ← Tester C +└────────────┴────────────┴──────────────┴────────────────────────┘ + +Query to get Tester A's Gemini key: + + SELECT k.key + FROM api_keys k + JOIN onboarding_sessions s ON k.session_id = s.id + WHERE s.user_id = 'user_clerk_tester_a' + AND k.provider = 'gemini' + + Result: 'tester_a_gemini_key' + + +Query to get Tester B's Gemini key: + + SELECT k.key + FROM api_keys k + JOIN onboarding_sessions s ON k.session_id = s.id + WHERE s.user_id = 'user_clerk_tester_b' + AND k.provider = 'gemini' + + Result: 'tester_b_gemini_key' [DIFFERENT!] +``` + +--- + +## 🔐 Security & Isolation + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ USER ISOLATION GUARANTEE │ +└─────────────────────────────────────────────────────────────────────┘ + +Scenario: Both Tester A and Tester B generate content simultaneously + + Tester A's Request Thread: + ┌────────────────────────────────────────────┐ + │ 1. Auth: user_id = "user_clerk_tester_a" │ + │ 2. Fetch keys: WHERE user_id = tester_a │ + │ 3. Get: gemini_key = "tester_a_key" │ + │ 4. Generate with tester_a_key │ + │ 5. Response to Tester A │ + └────────────────────────────────────────────┘ + ↓ + [Database] + ↑ + ┌────────────────────────────────────────────┐ + │ 1. Auth: user_id = "user_clerk_tester_b" │ + │ 2. Fetch keys: WHERE user_id = tester_b │ + │ 3. Get: gemini_key = "tester_b_key" │ + │ 4. Generate with tester_b_key │ + │ 5. Response to Tester B │ + └────────────────────────────────────────────┘ + Tester B's Request Thread: + + +✅ Guarantees: + • Tester A NEVER sees Tester B's keys + • Tester B NEVER sees Tester A's keys + • Tester A's costs charged to Tester A + • Tester B's costs charged to Tester B + • Database enforces isolation via user_id + • Clerk auth ensures correct user_id +``` + +--- + +## 💰 Cost Distribution + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ WHO PAYS FOR WHAT? │ +└─────────────────────────────────────────────────────────────────────┘ + +Local Development (You): + Your API Keys → Your Costs + ┌─────────────────────────────────────────────┐ + │ Developer generates 100 blog posts │ + │ Uses: GEMINI_API_KEY from .env │ + │ Cost: $5.00 → Charged to developer's │ + │ Google Cloud account │ + └─────────────────────────────────────────────┘ + + +Production (Alpha Testers): + Their API Keys → Their Costs + ┌─────────────────────────────────────────────┐ + │ Tester A generates 50 blog posts │ + │ Uses: tester_a_gemini_key from database │ + │ Cost: $2.50 → Charged to Tester A's │ + │ Google Cloud account │ + └─────────────────────────────────────────────┘ + + ┌─────────────────────────────────────────────┐ + │ Tester B generates 200 blog posts │ + │ Uses: tester_b_gemini_key from database │ + │ Cost: $10.00 → Charged to Tester B's │ + │ Google Cloud account │ + └─────────────────────────────────────────────┘ + + ┌─────────────────────────────────────────────┐ + │ You (owner) host infrastructure │ + │ Render: Free tier / $7/month │ + │ Vercel: Free tier │ + │ Database: Render free tier │ + │ Cost: $0 - $7/month (infrastructure only) │ + └─────────────────────────────────────────────┘ + + +Total monthly cost for you with 100 alpha testers: + Infrastructure: $0 - $7 + API usage: $0 (testers pay their own!) + ──────────────────────────── + Total: $0 - $7/month 🎉 +``` + +--- + +## 🎯 Summary + +| Aspect | Local Dev | Production | +|--------|-----------|------------| +| **Environment** | `DEBUG=true` | `DEPLOY_ENV=render` | +| **Key Storage** | `.env` file + DB | Database only | +| **Key Retrieval** | `os.getenv()` | Database query | +| **User Isolation** | Not needed | Full isolation | +| **Cost Bearer** | You (developer) | Each tester | +| **Scalability** | 1 developer | Unlimited users | +| **Setup Effort** | Low (persist .env) | Low (onboard once) | + +**Architecture Principle:** +> Development convenience with `.env` files, production isolation with database. Best of both worlds! 🚀 + diff --git a/docs/API_KEY_INJECTION_EXPLAINED.md b/docs/API_KEY_INJECTION_EXPLAINED.md new file mode 100644 index 00000000..560679d4 --- /dev/null +++ b/docs/API_KEY_INJECTION_EXPLAINED.md @@ -0,0 +1,326 @@ +# API Key Injection - How It Works in Production + +## 🎯 The Problem You Identified + +**Question:** "For production, when we read APIs from database, how will they be exported to the environment?" + +**Answer:** They are **temporarily injected** into `os.environ` for each request, then immediately cleaned up. + +--- + +## 🔍 The Challenge + +### **Existing Code Pattern:** + +Most of your codebase uses this pattern: + +```python +import os +import google.generativeai as genai + +def generate_content(prompt: str): + # Expects GEMINI_API_KEY in environment + gemini_key = os.getenv('GEMINI_API_KEY') + genai.configure(api_key=gemini_key) + # ... +``` + +### **Production Problem:** + +``` +User A's request: + ↓ + os.getenv('GEMINI_API_KEY') → ??? (User A's key in database, not in os.environ) + +User B's request (simultaneous): + ↓ + os.getenv('GEMINI_API_KEY') → ??? (User B's key in database, not in os.environ) +``` + +**Issue:** `os.environ` is global, but we need user-specific keys! + +--- + +## ✅ The Solution: Request-Scoped Injection + +### **How It Works:** + +``` +1. Request arrives with Authorization: Bearer + ↓ +2. API Key Injection Middleware extracts user_id from token + ↓ +3. Fetch User A's keys from database + ↓ +4. Temporarily inject into os.environ: + - GEMINI_API_KEY = user_a_gemini_key + - EXA_API_KEY = user_a_exa_key + ↓ +5. Process request (all os.getenv() calls get User A's keys) + ↓ +6. Request completes + ↓ +7. IMMEDIATELY clean up os.environ (remove User A's keys) +``` + +### **Key Insight:** + +**The injection is request-scoped, not global:** +- User A's keys exist in `os.environ` ONLY during User A's request +- Immediately removed after response sent +- User B's request gets User B's keys injected +- No overlap, no conflict! + +--- + +## 🏗️ Architecture + +### **Middleware Flow:** + +``` +FastAPI Request Pipeline: + +┌─────────────────────────────────────────────────────────────┐ +│ 1. Rate Limit Middleware │ +│ └─> Check rate limits │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 2. API Key Injection Middleware (NEW!) │ +│ ├─> Extract user_id from Authorization header │ +│ ├─> Fetch user's API keys from database │ +│ ├─> Inject into os.environ (temporarily) │ +│ │ ├─> GEMINI_API_KEY = user_specific_key │ +│ │ ├─> EXA_API_KEY = user_specific_key │ +│ │ └─> COPILOTKIT_API_KEY = user_specific_key │ +│ └─> [Request processed with user-specific keys] │ +│ ↓ │ +│ ├─> [Response generated] │ +│ └─> CLEANUP: Remove injected keys from os.environ │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 3. Your Endpoint (e.g., /api/blog/generate) │ +│ └─> Calls service that uses os.getenv('GEMINI_API_KEY') │ +│ └─> Gets user-specific key! ✅ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 💻 Code Example + +### **The Middleware:** + +```python +async def __call__(self, request: Request, call_next): + # 1. Extract user_id from token + user_id = extract_user_from_token(request) + + if not user_id or DEPLOY_ENV == 'local': + return await call_next(request) # Skip in local mode + + # 2. Get user-specific keys from database + with user_api_keys(user_id) as user_keys: + # 3. Save original environment (if any) + original_gemini = os.environ.get('GEMINI_API_KEY') + original_exa = os.environ.get('EXA_API_KEY') + + # 4. Inject user-specific keys + os.environ['GEMINI_API_KEY'] = user_keys['gemini'] + os.environ['EXA_API_KEY'] = user_keys['exa'] + + try: + # 5. Process request with user-specific keys + response = await call_next(request) + return response + finally: + # 6. CRITICAL: Restore original environment + if original_gemini is None: + del os.environ['GEMINI_API_KEY'] + else: + os.environ['GEMINI_API_KEY'] = original_gemini + + if original_exa is None: + del os.environ['EXA_API_KEY'] + else: + os.environ['EXA_API_KEY'] = original_exa +``` + +--- + +## 📊 Concurrent Requests Example + +### **Scenario: Two Users Generate Content Simultaneously** + +``` +TIME: 00:00:000 +User A request arrives +├─> Extract user_id = "user_a" +├─> Fetch keys from DB: gemini_key = "key_a_123" +├─> os.environ['GEMINI_API_KEY'] = "key_a_123" +│ +├─> TIME: 00:00:050 (50ms later) +│ User B request arrives +│ ├─> Extract user_id = "user_b" +│ ├─> Fetch keys from DB: gemini_key = "key_b_456" +│ ├─> os.environ['GEMINI_API_KEY'] = "key_b_456" ← Overwrites! +│ │ +│ ├─> User B's request processes +│ │ os.getenv('GEMINI_API_KEY') → "key_b_456" ✅ +│ │ +│ └─> TIME: 00:00:100 +│ User B response sent +│ os.environ['GEMINI_API_KEY'] restored +│ +└─> TIME: 00:00:120 + User A's request processes + os.getenv('GEMINI_API_KEY') → ??? (Could be wrong!) +``` + +**⚠️ PROBLEM: Race condition!** + +--- + +## 🔒 Thread Safety Solution + +Python's asyncio in FastAPI handles this correctly: + +```python +# FastAPI uses asyncio, which is single-threaded +# Each request is processed in sequence (no parallel execution) +# So the injection is safe! + +User A request: + ├─> Inject A's keys + ├─> await generate_content() ← Async, but single-threaded + └─> Cleanup A's keys + +User B request (after A): + ├─> Inject B's keys + ├─> await generate_content() + └─> Cleanup B's keys +``` + +**BUT:** If your code uses threading or multiprocessing, this approach WON'T work safely. + +--- + +## 🎛️ Modes Compared + +### **Local Mode (DEPLOY_ENV=local):** + +``` +Request arrives + ↓ +Middleware detects DEPLOY_ENV=local + ↓ +SKIP injection (keys already in .env) + ↓ +os.getenv('GEMINI_API_KEY') → reads from .env file + ↓ +Works! ✅ +``` + +### **Production Mode (DEPLOY_ENV=production):** + +``` +Request arrives with user_id=user_123 + ↓ +Middleware detects DEPLOY_ENV=production + ↓ +Fetch user_123's keys from database + ↓ +Inject into os.environ (temporarily) + ↓ +os.getenv('GEMINI_API_KEY') → gets user_123's key + ↓ +Process request + ↓ +Clean up os.environ + ↓ +Works! ✅ +``` + +--- + +## 🚨 Important Caveats + +### **1. Async-Only Safety** + +This approach is safe ONLY because FastAPI uses asyncio (single-threaded event loop). + +**If you use:** +- `concurrent.futures.ThreadPoolExecutor` +- `multiprocessing.Pool` +- `threading.Thread` + +Then environment injection is **NOT SAFE** and will cause race conditions! + +### **2. Better Long-Term Approach** + +For critical services, refactor to pass `user_id` explicitly: + +```python +# Instead of: +def generate(prompt: str): + key = os.getenv('GEMINI_API_KEY') # Fragile! + +# Do this: +def generate(user_id: str, prompt: str): + with user_api_keys(user_id) as keys: + key = keys['gemini'] # Explicit and safe! +``` + +--- + +## 📝 Summary + +### **The Magic:** + +1. **Request arrives** → Middleware extracts `user_id` +2. **Fetch from DB** → Get user's keys +3. **Inject temporarily** → `os.environ['GEMINI_API_KEY'] = user_key` +4. **Process request** → All `os.getenv()` calls get user's key +5. **Cleanup** → Remove from `os.environ` +6. **Next request** → Different user, different keys + +### **Why It Works:** + +- ✅ FastAPI is async + single-threaded +- ✅ Injection is request-scoped +- ✅ Cleanup is guaranteed (finally block) +- ✅ Existing code works without changes +- ✅ Each user gets their own keys + +### **Limitations:** + +- ⚠️ Not safe with threading/multiprocessing +- ⚠️ Slightly slower (DB query per request) +- ⚠️ Better to refactor critical services + +### **Bottom Line:** + +> **It works!** Your existing code that uses `os.getenv()` will get user-specific keys in production, with zero code changes. The middleware handles everything automatically. + +--- + +## 🔄 Migration Path + +### **Phase 1: Now (Compatibility Layer)** +- ✅ Middleware injects keys for ALL services +- ✅ No code changes needed +- ✅ Works immediately + +### **Phase 2: Later (Gradual Refactor)** +- Refactor critical services to use `UserAPIKeyContext` directly +- Remove dependency on `os.getenv()` +- More explicit, safer + +### **Phase 3: Future (Full Migration)** +- All services use `user_api_keys(user_id)` +- Remove injection middleware +- Clean, explicit architecture + +**For now:** Middleware lets you deploy immediately without touching 100+ files! 🎉 + diff --git a/docs/API_KEY_MANAGEMENT_ARCHITECTURE.md b/docs/API_KEY_MANAGEMENT_ARCHITECTURE.md new file mode 100644 index 00000000..da371f36 --- /dev/null +++ b/docs/API_KEY_MANAGEMENT_ARCHITECTURE.md @@ -0,0 +1,349 @@ +# API Key Management Architecture + +## Overview + +ALwrity supports two deployment modes with different API key management strategies: + +1. **Local Development**: API keys stored in `.env` files for convenience +2. **Production (Vercel + Render)**: User-specific API keys stored in database with full user isolation + +## Architecture + +### 🏠 **Local Development Mode** + +**Detection:** +- `DEBUG=true` in environment variables, OR +- `DEPLOY_ENV` is not set + +**API Key Storage:** +- **Backend**: `backend/.env` file +- **Frontend**: `frontend/.env` file +- **Database**: Also saved for consistency + +**Flow:** +``` +User completes onboarding + ↓ +API keys saved to database (user-isolated) + ↓ +API keys ALSO saved to .env files (for convenience) + ↓ +Backend services read from .env file + ↓ +Single developer, single set of keys +``` + +**Advantages:** +- ✅ Quick setup for developers +- ✅ No need to configure environment for every user +- ✅ Keys persist across server restarts + +--- + +### 🌐 **Production Mode (Vercel + Render)** + +**Detection:** +- `DEBUG=false` or not set, AND +- `DEPLOY_ENV` is set (e.g., `DEPLOY_ENV=render`) + +**API Key Storage:** +- **Backend**: PostgreSQL database (user-isolated) +- **Frontend**: `localStorage` (runtime only) +- **NOT in .env files** + +**Flow:** +``` +Alpha Tester A completes onboarding + ↓ +API keys saved to database with user_id_A + ↓ +Backend services fetch keys from database when user_id_A makes requests + ↓ +Multiple users, each with their own keys + ↓ +Alpha Tester B completes onboarding + ↓ +API keys saved to database with user_id_B + ↓ +Backend services fetch keys from database when user_id_B makes requests +``` + +**Advantages:** +- ✅ **Complete user isolation** - User A's keys never conflict with User B's keys +- ✅ **Zero cost for you** - Each alpha tester uses their own API keys +- ✅ **Secure** - Keys stored encrypted in database +- ✅ **Scalable** - Unlimited alpha testers, each with their own keys + +--- + +## Implementation + +### **1. Backend: User API Key Context** + +The `UserAPIKeyContext` class provides user-specific API keys to backend services: + +```python +from services.user_api_key_context import user_api_keys + +# In your backend service +async def generate_content(user_id: str, prompt: str): + # Get user-specific API keys + with user_api_keys(user_id) as keys: + gemini_key = keys.get('gemini') + exa_key = keys.get('exa') + + # Use keys for this specific user + response = await call_gemini_api(gemini_key, prompt) + return response +``` + +**How it works:** +- **Development**: Reads from `backend/.env` +- **Production**: Fetches from database for the specific `user_id` + +### **2. Frontend: CopilotKit Key Management** + +```typescript +// Frontend automatically handles this: +// 1. Saves to localStorage (for runtime use) +// 2. In dev: Also saves to frontend/.env +// 3. In prod: Only uses localStorage + +const copilotApiKey = localStorage.getItem('copilotkit_api_key'); +``` + +### **3. Environment Variable Detection** + +**Backend (`backend/.env`):** +```bash +# Development +DEBUG=true + +# Production +DEBUG=false +DEPLOY_ENV=render # or 'railway', 'heroku', etc. +``` + +**Render Dashboard:** +``` +DEBUG=false +DEPLOY_ENV=render +``` + +**Vercel Dashboard:** +``` +REACT_APP_API_URL=https://alwrity.onrender.com +REACT_APP_BACKEND_URL=https://alwrity.onrender.com +``` + +--- + +## Use Cases + +### **Use Case 1: You (Developer) - Local Development** + +**Setup:** +```bash +# backend/.env +DEBUG=true +GEMINI_API_KEY=your_personal_key +EXA_API_KEY=your_personal_key +COPILOTKIT_API_KEY=your_personal_key +``` + +**Behavior:** +- You complete onboarding once +- Keys saved to both database AND `.env` files +- All your local testing uses these keys +- No need to re-enter keys + +--- + +### **Use Case 2: Alpha Tester A - Production** + +**Setup:** +- Alpha Tester A visits `https://alwrity-ai.vercel.app` +- Goes through onboarding +- Enters their own API keys: + - `GEMINI_API_KEY=tester_a_gemini_key` + - `EXA_API_KEY=tester_a_exa_key` + - `COPILOTKIT_API_KEY=tester_a_copilot_key` + +**Behavior:** +- Keys saved to database with `user_id=tester_a_clerk_id` +- When Tester A generates content: + - Backend fetches `tester_a_gemini_key` from database + - Uses Tester A's Gemini quota + - All costs charged to Tester A's Gemini account + +--- + +### **Use Case 3: Alpha Tester B - Production (Same Time)** + +**Setup:** +- Alpha Tester B visits `https://alwrity-ai.vercel.app` +- Goes through onboarding +- Enters their own API keys: + - `GEMINI_API_KEY=tester_b_gemini_key` + - `EXA_API_KEY=tester_b_exa_key` + - `COPILOTKIT_API_KEY=tester_b_copilot_key` + +**Behavior:** +- Keys saved to database with `user_id=tester_b_clerk_id` +- When Tester B generates content: + - Backend fetches `tester_b_gemini_key` from database + - Uses Tester B's Gemini quota + - All costs charged to Tester B's Gemini account +- **Tester A and Tester B completely isolated** ✅ + +--- + +## Database Schema + +```sql +-- OnboardingSession: One per user +CREATE TABLE onboarding_sessions ( + id SERIAL PRIMARY KEY, + user_id VARCHAR(255) UNIQUE NOT NULL, -- Clerk user ID + current_step INTEGER DEFAULT 1, + progress FLOAT DEFAULT 0.0, + started_at TIMESTAMP DEFAULT NOW(), + completed_at TIMESTAMP +); + +-- APIKey: Multiple per user (one per provider) +CREATE TABLE api_keys ( + id SERIAL PRIMARY KEY, + session_id INTEGER REFERENCES onboarding_sessions(id), + provider VARCHAR(50) NOT NULL, -- 'gemini', 'exa', 'copilotkit' + key TEXT NOT NULL, -- Encrypted in production + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(session_id, provider) -- One key per provider per user +); +``` + +**Isolation:** +- Each user has their own `onboarding_session` +- Each session has its own set of `api_keys` +- Query: `SELECT key FROM api_keys WHERE session_id = (SELECT id FROM onboarding_sessions WHERE user_id = ?)` + +--- + +## Migration Path + +### **Current State:** +- ❌ All users' keys overwrite the same `.env` file +- ❌ Last user's keys are used for all users + +### **New State:** +- ✅ Development: `.env` file for convenience +- ✅ Production: Database per user +- ✅ Complete user isolation + +### **Code Changes Required:** + +**Before (BAD - uses global .env):** +```python +import os + +def generate_content(prompt: str): + gemini_key = os.getenv('GEMINI_API_KEY') # Same for all users! + response = call_gemini_api(gemini_key, prompt) + return response +``` + +**After (GOOD - uses user-specific keys):** +```python +from services.user_api_key_context import user_api_keys + +def generate_content(user_id: str, prompt: str): + with user_api_keys(user_id) as keys: + gemini_key = keys.get('gemini') # User-specific key! + response = call_gemini_api(gemini_key, prompt) + return response +``` + +--- + +## Testing + +### **Test Local Development:** +1. Set `DEBUG=true` in `backend/.env` +2. Complete onboarding with test keys +3. Check `backend/.env` - should contain keys ✅ +4. Generate content - should use keys from `.env` ✅ + +### **Test Production:** +1. Set `DEBUG=false` and `DEPLOY_ENV=render` on Render +2. User A completes onboarding with keys A +3. User B completes onboarding with keys B +4. User A generates content - uses keys A ✅ +5. User B generates content - uses keys B ✅ +6. Check database: + ```sql + SELECT user_id, provider, key FROM api_keys + JOIN onboarding_sessions ON api_keys.session_id = onboarding_sessions.id; + ``` + Should show separate keys for User A and User B ✅ + +--- + +## Security Considerations + +### **Production Enhancements (Future):** +1. **Encrypt API keys** in database using application secret +2. **Rate limiting** per user to prevent abuse +3. **Key validation** before saving +4. **Audit logging** of API key usage +5. **Key rotation** support + +### **Current Implementation:** +- ✅ Keys stored in database (not in code) +- ✅ User isolation via `user_id` +- ✅ HTTPS encryption in transit +- ⚠️ Keys not encrypted at rest (TODO) + +--- + +## Troubleshooting + +### **Issue: "No API key found"** +- **Development**: Check `backend/.env` file exists and has keys +- **Production**: Check database has keys for this user: + ```sql + SELECT * FROM api_keys + WHERE session_id = (SELECT id FROM onboarding_sessions WHERE user_id = 'user_xxx'); + ``` + +### **Issue: "Wrong user's keys being used"** +- **Cause**: Service not using `UserAPIKeyContext` +- **Fix**: Update service to use `user_api_keys(user_id)` context manager + +### **Issue: "Keys not saving to .env in development"** +- **Cause**: `DEBUG` not set to `true` +- **Fix**: Set `DEBUG=true` in `backend/.env` + +--- + +## Summary + +| Feature | Local Development | Production | +|---------|------------------|------------| +| **Key Storage** | `.env` files + Database | Database only | +| **User Isolation** | Not needed (single user) | Full isolation | +| **Cost** | Your API keys | Each user's API keys | +| **Convenience** | High (keys persist) | Medium (enter once) | +| **Scalability** | 1 developer | Unlimited users | +| **Detection** | `DEBUG=true` | `DEPLOY_ENV` set | + +**Bottom Line:** +- 🏠 **Local**: Quick setup, your keys, `.env` convenience +- 🌐 **Production**: User isolation, their keys, zero cost for you + +This architecture ensures: +1. ✅ You can develop locally with convenience +2. ✅ Alpha testers use their own keys (no cost to you) +3. ✅ Complete user isolation in production +4. ✅ Seamless transition between environments + diff --git a/docs/API_KEY_QUICK_REFERENCE.md b/docs/API_KEY_QUICK_REFERENCE.md new file mode 100644 index 00000000..7b88b9c7 --- /dev/null +++ b/docs/API_KEY_QUICK_REFERENCE.md @@ -0,0 +1,299 @@ +# API Key Management - Quick Reference + +## 🎯 The Big Picture + +**Problem:** You want to develop locally with convenience, but alpha testers should use their own API keys (so you don't pay for their usage). + +**Solution:** +- **Local Dev**: API keys saved to `.env` files (convenient) +- **Production**: API keys saved to database per user (isolated, zero cost to you) + +--- + +## 🚀 How It Works + +### **1. Local Development (You)** + +```bash +# backend/.env +DEBUG=true +GEMINI_API_KEY=your_key_here +EXA_API_KEY=your_exa_key +COPILOTKIT_API_KEY=your_copilot_key +``` + +**Behavior:** +- ✅ Complete onboarding once +- ✅ Keys saved to `.env` AND database +- ✅ All services use keys from `.env` +- ✅ Convenient, keys persist + +**You pay for:** Your own API usage + +--- + +### **2. Production (Alpha Testers)** + +```bash +# Render environment variables +DEBUG=false +DEPLOY_ENV=render +DATABASE_URL=postgresql://... +``` + +**Behavior:** +- ✅ Each tester completes onboarding with their keys +- ✅ Keys saved to database (user-specific rows) +- ✅ Services fetch keys from database per user +- ✅ Complete user isolation + +**You pay for:** $0-$7/month (infrastructure only) +**Testers pay for:** Their own API usage + +--- + +## 📝 Code Examples + +### **Using User API Keys in Services** + +```python +from services.user_api_key_context import user_api_keys +import google.generativeai as genai + +def generate_blog(user_id: str, topic: str): + # Get user-specific API keys + with user_api_keys(user_id) as keys: + gemini_key = keys.get('gemini') + + # Configure Gemini with THIS user's key + genai.configure(api_key=gemini_key) + model = genai.GenerativeModel('gemini-pro') + + # Generate content (charges THIS user's Gemini account) + response = model.generate_content(f"Write a blog about {topic}") + return response.text +``` + +**What this does:** +- **Dev mode** (`user_id=None` or `DEBUG=true`): Uses `.env` file +- **Prod mode** (`DEPLOY_ENV=render`): Fetches from database for this `user_id` + +--- + +## 🔄 Migration Checklist + +### **Step 1: Update Environment Variables** + +**Local (backend/.env):** +```bash +DEBUG=true +# Your development API keys (stay as-is) +GEMINI_API_KEY=... +EXA_API_KEY=... +``` + +**Render Dashboard:** +```bash +DEBUG=false +DEPLOY_ENV=render +DATABASE_URL=postgresql://... +# Remove GEMINI_API_KEY, EXA_API_KEY from here! +# Users will provide their own via onboarding +``` + +### **Step 2: Update Services to Use user_api_keys** + +**Before:** +```python +import os +gemini_key = os.getenv('GEMINI_API_KEY') # ❌ Same for all users! +``` + +**After:** +```python +from services.user_api_key_context import user_api_keys +with user_api_keys(user_id) as keys: + gemini_key = keys.get('gemini') # ✅ User-specific! +``` + +### **Step 3: Update FastAPI Endpoints** + +**Add user_id parameter:** +```python +@router.post("/api/generate") +async def generate( + prompt: str, + current_user: dict = Depends(get_current_user) # Get authenticated user +): + user_id = current_user.get('user_id') # Extract user_id + + # Pass user_id to service + result = await my_service.generate(user_id, prompt) + return result +``` + +### **Step 4: Test** + +**Local:** +1. Complete onboarding +2. Check `backend/.env` has your keys ✅ +3. Generate content - should work ✅ + +**Production:** +1. Deploy to Render with `DEPLOY_ENV=render` +2. User A: Complete onboarding with keys A +3. User B: Complete onboarding with keys B +4. User A generates content → Uses keys A ✅ +5. User B generates content → Uses keys B ✅ + +--- + +## 🔍 Troubleshooting + +### **"No API key found" error** + +**In development:** +```bash +# Check backend/.env exists and has: +DEBUG=true +GEMINI_API_KEY=your_key_here +``` + +**In production:** +```sql +-- Check database has keys for this user: +SELECT s.user_id, k.provider, k.key +FROM api_keys k +JOIN onboarding_sessions s ON k.session_id = s.id +WHERE s.user_id = 'user_xxx'; +``` + +### **Wrong user's keys being used** + +**Cause:** Service not using `user_api_keys(user_id)` + +**Fix:** +```python +# OLD (wrong): +gemini_key = os.getenv('GEMINI_API_KEY') + +# NEW (correct): +with user_api_keys(user_id) as keys: + gemini_key = keys.get('gemini') +``` + +### **Keys not saving to .env in development** + +**Cause:** `DEBUG` not set to `true` + +**Fix:** +```bash +# backend/.env +DEBUG=true # Must be explicitly true +``` + +--- + +## 📊 Cost Breakdown + +### **Your Monthly Costs** + +| Item | Dev | Production | +|------|-----|------------| +| **Infrastructure** | $0 | $0-7/month | +| **Database** | Free | Free (Render) | +| **API Usage (Gemini, Exa, etc.)** | Your usage | $0 (users pay!) | +| **Total** | Your API usage | $0-7/month | + +### **Alpha Tester Costs** + +| Item | Cost | +|------|------| +| **ALwrity Subscription** | Free (alpha) | +| **Their Gemini API** | Their usage | +| **Their Exa API** | Their usage | +| **Total** | Their API usage | + +--- + +## 🎓 Key Concepts + +### **Environment Detection** + +```python +is_development = ( + os.getenv('DEBUG', 'false').lower() == 'true' or + os.getenv('DEPLOY_ENV') is None +) + +if is_development: + # Use .env file (convenience) + keys = load_from_env() +else: + # Use database (user isolation) + keys = load_from_database(user_id) +``` + +### **User Isolation** + +``` +Database guarantees: +┌──────────────────┬─────────────┬──────────────────┐ +│ user_id │ provider │ key │ +├──────────────────┼─────────────┼──────────────────┤ +│ user_tester_a │ gemini │ tester_a_key │ ← Isolated +│ user_tester_b │ gemini │ tester_b_key │ ← Isolated +└──────────────────┴─────────────┴──────────────────┘ + +Query for Tester A: WHERE user_id = 'user_tester_a' +Query for Tester B: WHERE user_id = 'user_tester_b' + +No overlap, no conflicts! +``` + +--- + +## 🚀 Quick Start + +### **For Local Development:** + +1. Clone repo +2. Set `DEBUG=true` in `backend/.env` +3. Add your API keys to `backend/.env` +4. Run backend: `python start_alwrity_backend.py --dev` +5. Complete onboarding (keys auto-save to `.env`) +6. Done! ✅ + +### **For Production Deployment:** + +1. Deploy backend to Render +2. Set environment variables: + - `DEBUG=false` + - `DEPLOY_ENV=render` + - `DATABASE_URL=postgresql://...` +3. Deploy frontend to Vercel +4. Alpha testers complete onboarding with their keys +5. Done! Each tester uses their own keys ✅ + +--- + +## 📚 Further Reading + +- [Complete Architecture Guide](./API_KEY_MANAGEMENT_ARCHITECTURE.md) +- [Usage Examples](./EXAMPLES_USER_API_KEYS.md) +- [Flow Diagrams](./API_KEY_FLOW_DIAGRAM.md) + +--- + +## ✅ Summary + +**The magic:** +- Same codebase works in both dev and prod +- Dev: Convenience of `.env` files +- Prod: Isolation via database +- Zero cost: Testers use their own API keys +- Automatic: Just set `DEBUG` and `DEPLOY_ENV` + +**Bottom line:** +> Write code once, works everywhere. Development is convenient, production is isolated. You focus on building, testers pay for their usage. Win-win! 🎉 + diff --git a/docs/CRITICAL_ONBOARDING_DATABASE_MIGRATION.md b/docs/CRITICAL_ONBOARDING_DATABASE_MIGRATION.md new file mode 100644 index 00000000..d41bacf1 --- /dev/null +++ b/docs/CRITICAL_ONBOARDING_DATABASE_MIGRATION.md @@ -0,0 +1,264 @@ +# 🚨 CRITICAL: Onboarding Data Must Use Database + +## Issue Summary + +**Severity:** 🔴 CRITICAL +**Impact:** User isolation, data persistence, security +**Status:** ⚠️ NEEDS IMMEDIATE FIX AFTER DEPLOYMENT STABILIZES + +## Problem Description + +The onboarding system currently saves all user data to a JSON file (`.onboarding_progress.json`) instead of using the database. This causes multiple critical issues: + +### 1. **No User Isolation** 🔴 +- All users share the same JSON file +- User data can be overwritten by other users +- Privacy violation - users can see each other's data +- **Line:** `backend/services/api_key_manager.py:45` +- **Code:** `self.progress_file = progress_file or ".onboarding_progress.json"` + +### 2. **Data Loss on Deployment** 🔴 +- Render uses ephemeral filesystem +- File is deleted on every deployment/restart +- Users lose all onboarding progress +- Have to restart onboarding after each deployment + +### 3. **No Scalability** 🔴 +- Won't work with multiple backend instances +- File locking issues +- Race conditions +- Performance bottleneck + +### 4. **Security Risk** 🔴 +- API keys stored in plain text JSON file +- No encryption +- File accessible with filesystem access +- Should be in database with proper security + +## Current Architecture + +``` +User completes step → OnboardingProgress.mark_step_completed() + → save_progress() (line 214) + → json.dump(progress_data, ".onboarding_progress.json") +``` + +**File Location:** `backend/.onboarding_progress.json` +**Affected Code:** +- `backend/services/api_key_manager.py` (OnboardingProgress class) +- `backend/api/onboarding_utils/endpoints_core.py` +- `backend/api/onboarding_utils/step_management_service.py` + +## Database Models Available + +✅ **Good News:** Proper database models already exist! + +**File:** `backend/models/onboarding.py` + +```python +- OnboardingSession (user_id, current_step, progress, started_at, updated_at) +- APIKey (session_id, provider, key, created_at, updated_at) +- WebsiteAnalysis (session_id, website_url, analysis_date, writing_style, etc.) +- ResearchPreferences (session_id, research_depth, content_types, etc.) +``` + +**Database Schema:** +- ✅ User isolation via `user_id` and `session_id` +- ✅ Proper relationships and foreign keys +- ✅ Timestamps for audit trail +- ✅ JSON fields for complex data +- ✅ Cascade deletes for cleanup + +## Required Changes + +### Phase 1: Database Layer (Priority 1) + +**File:** `backend/services/onboarding_database_service.py` (NEW) + +```python +class OnboardingDatabaseService: + """Database-backed onboarding service replacing JSON file storage.""" + + def get_or_create_session(self, user_id: str) -> OnboardingSession: + """Get existing session or create new one.""" + + def get_progress(self, user_id: str) -> OnboardingProgress: + """Load progress from database.""" + + def save_step_data(self, user_id: str, step_number: int, data: Dict): + """Save step data to database.""" + + def mark_step_completed(self, user_id: str, step_number: int): + """Mark step as completed in database.""" + + def get_step_data(self, user_id: str, step_number: int) -> Dict: + """Retrieve step data from database.""" +``` + +### Phase 2: Refactor API Key Manager (Priority 1) + +**File:** `backend/services/api_key_manager.py` + +**Changes:** +1. Remove JSON file operations (lines 214-242) +2. Add database dependency injection +3. Replace `save_progress()` with database calls +4. Replace `load_progress()` with database queries +5. Add user_id parameter to all methods + +**Before:** +```python +def mark_step_completed(self, step_number: int, data: Optional[Dict] = None): + # ... update in-memory state ... + self.save_progress() # Saves to JSON file +``` + +**After:** +```python +def mark_step_completed(self, user_id: str, step_number: int, data: Optional[Dict] = None): + # ... update database ... + db_service.save_step_data(user_id, step_number, data) + db_service.mark_step_completed(user_id, step_number) +``` + +### Phase 3: Update Endpoints (Priority 2) + +**Files to Update:** +- `backend/api/onboarding_utils/endpoints_core.py` +- `backend/api/onboarding_utils/step_management_service.py` +- `backend/api/onboarding_utils/step3_routes.py` +- `backend/api/onboarding_utils/step4_persona_routes.py` + +**Changes:** +1. Pass `user_id` from `get_current_user` to all service calls +2. Remove file-based caching +3. Use database queries for progress retrieval + +### Phase 4: Migration Script (Priority 3) + +**File:** `backend/scripts/migrate_onboarding_to_database.py` (NEW) + +```python +def migrate_json_to_database(): + """ + Migrate existing .onboarding_progress.json to database. + Only needed if production has existing data in JSON files. + """ + # Read JSON file + # Create database records + # Backup JSON file + # Delete JSON file +``` + +## Implementation Plan + +### Step 1: Create Database Service (1-2 hours) +- [ ] Create `onboarding_database_service.py` +- [ ] Implement CRUD operations +- [ ] Add user isolation checks +- [ ] Write unit tests + +### Step 2: Refactor API Key Manager (2-3 hours) +- [ ] Remove JSON file operations +- [ ] Add database calls +- [ ] Update method signatures with user_id +- [ ] Test with database + +### Step 3: Update Endpoints (1-2 hours) +- [ ] Pass user_id to service calls +- [ ] Remove file-based logic +- [ ] Test each endpoint + +### Step 4: Testing (2-3 hours) +- [ ] Test user isolation +- [ ] Test data persistence across deployments +- [ ] Test concurrent users +- [ ] Test error handling + +### Step 5: Deployment (1 hour) +- [ ] Deploy to staging +- [ ] Run migration script if needed +- [ ] Deploy to production +- [ ] Monitor for issues + +**Total Estimated Time:** 8-12 hours + +## Temporary Mitigation + +Until this is fixed, we must: + +1. ✅ Add `.onboarding_progress.json` to `.gitignore` +2. ✅ Document that onboarding data will be lost on deployment +3. ⚠️ Warn users that onboarding must be completed in one session +4. ⚠️ Consider using Render's persistent disk (expensive workaround) + +## Testing Checklist + +After migration: + +- [ ] User A completes onboarding +- [ ] User B completes onboarding +- [ ] Verify User A and User B data are separate +- [ ] Redeploy backend +- [ ] Verify both users' data persists +- [ ] User C starts onboarding +- [ ] Verify User C doesn't see User A or B data +- [ ] Test concurrent onboarding (multiple users at once) +- [ ] Verify API keys are stored securely +- [ ] Test onboarding restart (partial completion) + +## Security Considerations + +### Current (Insecure): +```json +{ + "steps": [ + { + "step_number": 1, + "data": { + "api_keys": { + "gemini": "ACTUAL_API_KEY_HERE", + "exa": "ACTUAL_API_KEY_HERE" + } + } + } + ] +} +``` + +### After Migration (Secure): +- API keys in database with user isolation +- Encrypted at rest (if database supports it) +- Access controlled by user_id +- Audit trail via timestamps + +## References + +- Database Models: `backend/models/onboarding.py` +- Current Implementation: `backend/services/api_key_manager.py` +- Endpoints: `backend/api/onboarding_utils/` +- Issue tracking: GitHub Issue #XXX (to be created) + +## Priority + +**This must be fixed before:** +- ❌ Going to production with real users +- ❌ Accepting paying customers +- ❌ Handling sensitive data +- ❌ Scaling to multiple instances + +**Acceptable to delay if:** +- ✅ Still in alpha/beta with limited users +- ✅ Users aware of data loss on deployment +- ✅ Not handling production workloads yet + +## Conclusion + +This is a critical architectural flaw that violates basic principles: +- User data isolation +- Data persistence +- Security best practices +- Scalability + +**Must be fixed immediately after current deployment stabilizes.** + diff --git a/docs/EXAMPLES_USER_API_KEYS.md b/docs/EXAMPLES_USER_API_KEYS.md new file mode 100644 index 00000000..acf81d96 --- /dev/null +++ b/docs/EXAMPLES_USER_API_KEYS.md @@ -0,0 +1,489 @@ +# User API Key Context - Usage Examples + +This document shows how to use the `UserAPIKeyContext` in your backend services to ensure user-specific API keys are used. + +## Quick Start + +### **1. Basic Usage in FastAPI Endpoint** + +```python +from fastapi import APIRouter, Depends +from middleware.auth_middleware import get_current_user +from services.user_api_key_context import user_api_keys +import google.generativeai as genai + +router = APIRouter() + +@router.post("/api/generate-content") +async def generate_content( + prompt: str, + current_user: dict = Depends(get_current_user) +): + user_id = current_user.get('user_id') + + # Get user-specific API keys + with user_api_keys(user_id) as keys: + gemini_key = keys.get('gemini') + + if not gemini_key: + raise HTTPException(status_code=400, detail="Gemini API key not configured") + + # Configure Gemini with user's key + genai.configure(api_key=gemini_key) + model = genai.GenerativeModel('gemini-pro') + + # Generate content using this user's quota + response = model.generate_content(prompt) + + return { + "content": response.text, + "user_id": user_id # For debugging + } +``` + +--- + +## Examples by Use Case + +### **Example 1: Blog Writer Service** + +**File: `backend/services/blog_writer_service.py`** + +```python +from services.user_api_key_context import user_api_keys, get_gemini_key +import google.generativeai as genai + +class BlogWriterService: + """ + Service for generating blog content using user-specific API keys. + """ + + def __init__(self, user_id: str): + self.user_id = user_id + + async def generate_blog_outline(self, topic: str) -> dict: + """Generate blog outline using user's Gemini API key.""" + + # Method 1: Using context manager (recommended) + with user_api_keys(self.user_id) as keys: + gemini_key = keys.get('gemini') + + if not gemini_key: + raise ValueError(f"No Gemini API key found for user {self.user_id}") + + # Configure Gemini with user's key + genai.configure(api_key=gemini_key) + model = genai.GenerativeModel('gemini-pro') + + prompt = f"Create a detailed blog outline for: {topic}" + response = model.generate_content(prompt) + + return { + "outline": response.text, + "topic": topic, + "user_id": self.user_id + } + + async def generate_blog_section(self, section_heading: str, context: str) -> str: + """Generate blog section using user's Gemini API key.""" + + # Method 2: Using convenience function + gemini_key = get_gemini_key(self.user_id) + + if not gemini_key: + raise ValueError(f"No Gemini API key found for user {self.user_id}") + + genai.configure(api_key=gemini_key) + model = genai.GenerativeModel('gemini-pro') + + prompt = f"Write a blog section for '{section_heading}'\n\nContext: {context}" + response = model.generate_content(prompt) + + return response.text +``` + +**Usage in FastAPI:** + +```python +from fastapi import APIRouter, Depends +from middleware.auth_middleware import get_current_user +from services.blog_writer_service import BlogWriterService + +router = APIRouter() + +@router.post("/api/blog/outline") +async def create_blog_outline( + topic: str, + current_user: dict = Depends(get_current_user) +): + user_id = current_user.get('user_id') + + # Create service instance with user_id + blog_service = BlogWriterService(user_id) + + # Service automatically uses this user's API keys + outline = await blog_service.generate_blog_outline(topic) + + return outline +``` + +--- + +### **Example 2: Research Service with Multiple APIs** + +**File: `backend/services/research_service.py`** + +```python +from services.user_api_key_context import user_api_keys +from exa_py import Exa +import google.generativeai as genai + +class ResearchService: + """ + Service for conducting research using user-specific API keys. + """ + + def __init__(self, user_id: str): + self.user_id = user_id + + async def conduct_research(self, query: str) -> dict: + """ + Conduct research using both Exa (search) and Gemini (analysis). + Uses user-specific API keys for both services. + """ + + with user_api_keys(self.user_id) as keys: + exa_key = keys.get('exa') + gemini_key = keys.get('gemini') + + if not exa_key or not gemini_key: + raise ValueError(f"Missing required API keys for user {self.user_id}") + + # 1. Search using user's Exa API key + exa = Exa(api_key=exa_key) + search_results = exa.search_and_contents( + query, + num_results=5, + text=True + ) + + # 2. Analyze results using user's Gemini API key + genai.configure(api_key=gemini_key) + model = genai.GenerativeModel('gemini-pro') + + # Prepare context from search results + context = "\n\n".join([ + f"Source: {r.url}\n{r.text[:500]}..." + for r in search_results.results + ]) + + prompt = f""" + Analyze the following research results for query: "{query}" + + {context} + + Provide a comprehensive summary and key insights. + """ + + analysis = model.generate_content(prompt) + + return { + "query": query, + "sources": [r.url for r in search_results.results], + "analysis": analysis.text, + "user_id": self.user_id # For debugging + } +``` + +--- + +### **Example 3: Persona Generation Service** + +**File: `backend/services/persona/core_persona_service.py`** + +```python +from services.user_api_key_context import user_api_keys, get_gemini_key +import google.generativeai as genai +from typing import Optional + +class CorePersonaService: + """ + Service for generating AI writing personas. + """ + + def generate_core_persona( + self, + onboarding_data: dict, + user_id: Optional[str] = None + ) -> dict: + """ + Generate core persona using user's Gemini API key. + + Args: + onboarding_data: User's onboarding information + user_id: User ID (optional - uses .env in dev mode if None) + """ + + # Get user-specific Gemini key + # In dev mode (user_id=None), this uses .env + # In prod mode, this fetches from database + gemini_key = get_gemini_key(user_id) + + if not gemini_key: + if user_id: + raise ValueError(f"No Gemini API key found for user {user_id}") + else: + raise ValueError("No Gemini API key found in .env file") + + # Configure Gemini + genai.configure(api_key=gemini_key) + model = genai.GenerativeModel('gemini-pro') + + # Extract user's business info + business_data = onboarding_data.get('businessData', {}) + website_analysis = onboarding_data.get('websiteAnalysis', {}) + + prompt = f""" + Generate an AI writing persona based on: + + Business: {business_data.get('name')} + Industry: {business_data.get('industry')} + Tone: {website_analysis.get('tone')} + + Create a detailed writing persona including voice, style, and personality. + """ + + response = model.generate_content(prompt) + + return { + "persona": response.text, + "user_id": user_id, + "source": "dev_env" if user_id is None else "user_database" + } +``` + +--- + +### **Example 4: Background Task with User Keys** + +**File: `backend/services/async_content_generator.py`** + +```python +from fastapi import BackgroundTasks +from services.user_api_key_context import user_api_keys +import google.generativeai as genai + +async def generate_content_background( + user_id: str, + task_id: str, + prompt: str, + callback_url: str = None +): + """ + Background task that generates content using user's API keys. + This runs asynchronously and doesn't block the API response. + """ + + try: + # Get user-specific API keys + with user_api_keys(user_id) as keys: + gemini_key = keys.get('gemini') + + if not gemini_key: + # Log error and notify user + logger.error(f"No Gemini API key for user {user_id} in task {task_id}") + return + + # Configure Gemini + genai.configure(api_key=gemini_key) + model = genai.GenerativeModel('gemini-pro') + + # Generate content (this may take a while) + response = model.generate_content(prompt) + + # Save to database or send callback + if callback_url: + # Notify user that content is ready + await send_callback(callback_url, { + "task_id": task_id, + "content": response.text, + "status": "completed" + }) + + logger.info(f"Task {task_id} completed for user {user_id}") + + except Exception as e: + logger.error(f"Task {task_id} failed for user {user_id}: {e}") + + +# Usage in FastAPI endpoint +@router.post("/api/generate-async") +async def generate_async( + prompt: str, + background_tasks: BackgroundTasks, + current_user: dict = Depends(get_current_user) +): + user_id = current_user.get('user_id') + task_id = str(uuid.uuid4()) + + # Queue background task + background_tasks.add_task( + generate_content_background, + user_id=user_id, + task_id=task_id, + prompt=prompt + ) + + return { + "task_id": task_id, + "status": "queued", + "message": "Content generation started" + } +``` + +--- + +### **Example 5: Migrating Existing Service** + +**Before (WRONG - uses global .env):** + +```python +import os +import google.generativeai as genai + +class OldBlogService: + def generate_content(self, prompt: str): + # BAD: Uses same API key for all users! + gemini_key = os.getenv('GEMINI_API_KEY') + genai.configure(api_key=gemini_key) + + model = genai.GenerativeModel('gemini-pro') + response = model.generate_content(prompt) + + return response.text +``` + +**After (CORRECT - uses user-specific keys):** + +```python +from services.user_api_key_context import user_api_keys +import google.generativeai as genai + +class NewBlogService: + def __init__(self, user_id: str): + self.user_id = user_id + + def generate_content(self, prompt: str): + # GOOD: Uses user-specific API key! + with user_api_keys(self.user_id) as keys: + gemini_key = keys.get('gemini') + + if not gemini_key: + raise ValueError(f"No Gemini API key for user {self.user_id}") + + genai.configure(api_key=gemini_key) + model = genai.GenerativeModel('gemini-pro') + response = model.generate_content(prompt) + + return response.text +``` + +--- + +## Best Practices + +### ✅ **DO:** + +1. **Always pass `user_id` to services:** + ```python + service = BlogWriterService(user_id=current_user.get('user_id')) + ``` + +2. **Use context manager for multiple keys:** + ```python + with user_api_keys(user_id) as keys: + gemini_key = keys.get('gemini') + exa_key = keys.get('exa') + ``` + +3. **Check for missing keys:** + ```python + if not gemini_key: + raise HTTPException(status_code=400, detail="Please configure your Gemini API key") + ``` + +4. **Log which user's keys are being used:** + ```python + logger.info(f"Generating content for user {user_id} with their API keys") + ``` + +### ❌ **DON'T:** + +1. **Don't use `os.getenv()` directly:** + ```python + # WRONG - same key for all users! + gemini_key = os.getenv('GEMINI_API_KEY') + ``` + +2. **Don't forget to pass `user_id`:** + ```python + # WRONG - will use .env even in production! + with user_api_keys() as keys: # Missing user_id! + ``` + +3. **Don't hardcode API keys:** + ```python + # WRONG - security risk! + genai.configure(api_key="AIzaSy...") + ``` + +--- + +## Testing + +### **Test in Development:** + +```python +# Set DEBUG=true in backend/.env +# Then test: + +def test_dev_mode(): + # user_id=None should use .env file + with user_api_keys(user_id=None) as keys: + assert keys.get('gemini') == os.getenv('GEMINI_API_KEY') +``` + +### **Test in Production:** + +```python +# Set DEBUG=false and DEPLOY_ENV=render +# Then test: + +def test_prod_mode(): + # Should fetch from database + user_id = "user_12345" + with user_api_keys(user_id) as keys: + # Keys should come from database, not .env + assert keys.get('gemini') != os.getenv('GEMINI_API_KEY') +``` + +--- + +## Summary + +| Method | Use Case | Example | +|--------|----------|---------| +| `user_api_keys(user_id)` | Multiple keys needed | Research service (Exa + Gemini) | +| `get_gemini_key(user_id)` | Single key needed | Blog writer (only Gemini) | +| `get_exa_key(user_id)` | Single key needed | Search service (only Exa) | +| `get_user_api_keys(user_id)` | FastAPI dependency | Endpoint that needs all keys | + +**Key Principle:** +> Always pass `user_id` to get user-specific API keys. In development (`user_id=None`), it uses `.env` for convenience. + +This ensures: +- ✅ **Local dev**: Your keys from `.env` +- ✅ **Production**: Each user's keys from database +- ✅ **Zero cost**: Alpha testers use their own API keys +- ✅ **User isolation**: No conflicts between users + diff --git a/docs/PERSONA_DATA_MIGRATION_GUIDE.md b/docs/PERSONA_DATA_MIGRATION_GUIDE.md new file mode 100644 index 00000000..178da796 --- /dev/null +++ b/docs/PERSONA_DATA_MIGRATION_GUIDE.md @@ -0,0 +1,215 @@ +# Persona Data Table Migration Guide + +## Overview +This guide explains how to create the `persona_data` table for storing Step 4 (Persona Generation) data from the onboarding flow. + +## Background +The `persona_data` table was missing from the database schema, causing Step 4 onboarding data to only be saved to JSON files instead of the database. This migration adds the required table with proper user isolation. + +## Migration Methods + +### Method 1: Automatic Migration (Recommended) +The easiest way is to restart your backend server. The table will be created automatically when the application starts. + +```bash +# Stop the backend if running (Ctrl+C) +# Then restart it: +python backend/start_alwrity_backend.py --dev +``` + +**How it works:** +- The `init_database()` function in `backend/services/database.py` (line 69) calls `OnboardingBase.metadata.create_all(bind=engine)` +- This automatically creates all missing tables defined in the `OnboardingBase` models +- Since we added the `PersonaData` model, it will be created on startup + +### Method 2: Manual Migration Script +If you prefer to run the migration manually without restarting the backend: + +```bash +# From the project root directory: +python backend/scripts/create_persona_data_table.py +``` + +**What this script does:** +1. Checks if the `persona_data` table already exists +2. Creates the table if it doesn't exist +3. Verifies the table was created successfully +4. Shows the table structure (columns and types) +5. Lists all onboarding-related tables and their status + +### Method 3: SQL Migration (Production/Manual) +For production environments or manual database management: + +```bash +# Connect to your PostgreSQL database and run: +psql -U your_username -d your_database -f backend/database/migrations/add_persona_data_table.sql +``` + +**Or using psql command:** +```sql +-- Connect to your database +\c your_database + +-- Run the migration +\i backend/database/migrations/add_persona_data_table.sql + +-- Verify the table was created +\dt persona_data +\d persona_data +``` + +## Table Structure + +The `persona_data` table includes: + +| Column | Type | Description | +|--------|------|-------------| +| `id` | SERIAL | Primary key | +| `session_id` | INTEGER | Foreign key to `onboarding_sessions.id` | +| `core_persona` | JSONB | Core persona data (demographics, psychographics, etc.) | +| `platform_personas` | JSONB | Platform-specific personas (LinkedIn, Twitter, etc.) | +| `quality_metrics` | JSONB | Quality assessment metrics | +| `selected_platforms` | JSONB | Array of selected platforms | +| `created_at` | TIMESTAMP | When the record was created | +| `updated_at` | TIMESTAMP | When the record was last updated | + +**Indexes:** +- `idx_persona_data_session_id` - For efficient session lookups +- `idx_persona_data_created_at` - For time-based queries + +**Constraints:** +- Foreign key to `onboarding_sessions.id` with `ON DELETE CASCADE` + +## Verification + +After running the migration, verify it was successful: + +### Using Python: +```python +from services.database import engine +from sqlalchemy import inspect + +inspector = inspect(engine) +tables = inspector.get_table_names() + +if 'persona_data' in tables: + print("✅ persona_data table exists") + columns = inspector.get_columns('persona_data') + for col in columns: + print(f" - {col['name']}: {col['type']}") +else: + print("❌ persona_data table not found") +``` + +### Using SQL: +```sql +-- Check if table exists +SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'persona_data' +); + +-- Show table structure +\d persona_data +``` + +### Using the Backend Logs: +After restarting the backend, look for this log message: +``` +Database initialized successfully with all models including subscription system and business info +``` + +Then, when a user completes Step 4, you should see: +``` +✅ DATABASE: Persona data saved to database for user user_xxxxx +``` + +## Expected Behavior After Migration + +Once the table is created and the backend is running with the updated code: + +1. **Step 4 Completion:** + - Persona data (corePersona, platformPersonas, qualityMetrics, selectedPlatforms) is saved to the database + - Database logs confirm: `✅ DATABASE: Persona data saved to database for user {user_id}` + +2. **User Isolation:** + - Each user's persona data is stored separately using their `user_id` + - Data is linked to the user's onboarding session + +3. **Data Persistence:** + - Persona data is no longer lost when JSON files are deleted + - Data survives backend restarts + - Data is accessible across different sessions + +## Troubleshooting + +### Table Already Exists Error +If you see "table already exists" errors: +- This is normal! It means the table was already created +- The migration scripts use `CREATE TABLE IF NOT EXISTS` to handle this +- No action needed + +### Permission Denied +If you get permission errors: +``` +ERROR: permission denied for schema public +``` +**Solution:** Ensure your database user has CREATE TABLE permissions: +```sql +GRANT CREATE ON SCHEMA public TO your_database_user; +``` + +### Foreign Key Constraint Fails +If the `onboarding_sessions` table doesn't exist: +1. Run the full database initialization first: + ```python + from services.database import init_database + init_database() + ``` +2. Then create the `persona_data` table + +### Missing Database Connection +If you see "database connection" errors: +1. Check your `DATABASE_URL` environment variable +2. Ensure PostgreSQL/SQLite is running +3. Verify database credentials + +## Rollback (If Needed) + +To remove the `persona_data` table: + +```sql +DROP TABLE IF EXISTS persona_data CASCADE; +``` + +**Warning:** This will delete all persona data. Use with caution! + +## Related Files + +- **Model:** `backend/models/onboarding.py` - `PersonaData` class (lines 149-183) +- **Service:** `backend/services/onboarding_database_service.py` - `save_persona_data()` method (lines 298-338) +- **Migration:** `backend/database/migrations/add_persona_data_table.sql` +- **Script:** `backend/scripts/create_persona_data_table.py` +- **Database Init:** `backend/services/database.py` - `init_database()` function (line 63-80) + +## Summary + +**Recommended approach for local development:** +```bash +# Just restart the backend - the table will be created automatically! +python backend/start_alwrity_backend.py --dev +``` + +**For production deployment:** +- The table will be created automatically on first deployment +- Or run the SQL migration manually before deployment +- No downtime required - the migration is additive only + +## Questions? + +If you encounter issues: +1. Check the backend logs for detailed error messages +2. Verify all onboarding tables exist using the verification script +3. Ensure your database user has proper permissions +4. Check that the `PersonaData` model is imported correctly in `backend/services/onboarding_database_service.py` + diff --git a/frontend/src/components/OnboardingWizard/ApiKeyStep.tsx b/frontend/src/components/OnboardingWizard/ApiKeyStep.tsx index 204f9ac7..9a709955 100644 --- a/frontend/src/components/OnboardingWizard/ApiKeyStep.tsx +++ b/frontend/src/components/OnboardingWizard/ApiKeyStep.tsx @@ -21,9 +21,10 @@ import ApiKeySidebar from './ApiKeyStep/utils/ApiKeySidebar'; interface ApiKeyStepProps { onContinue: (stepData?: any) => void; updateHeaderContent: (content: { title: string; description: string }) => void; + onValidationChange?: (isValid: boolean) => void; } -const ApiKeyStep: React.FC = ({ onContinue, updateHeaderContent }) => { +const ApiKeyStep: React.FC = ({ onContinue, updateHeaderContent, onValidationChange }) => { const [focusedProvider, setFocusedProvider] = useState(null); const { @@ -63,11 +64,20 @@ const ApiKeyStep: React.FC = ({ onContinue, updateHeaderContent } }, [updateHeaderContent, providers, currentProviderIndex]); + // Notify parent of validation changes + useEffect(() => { + console.log('ApiKeyStep: isValid changed to:', isValid); + console.log('ApiKeyStep: onValidationChange exists:', !!onValidationChange); + if (onValidationChange) { + console.log('ApiKeyStep: Calling onValidationChange with:', isValid); + onValidationChange(isValid); + } + }, [isValid, onValidationChange]); + return ( <> -
{ e.preventDefault(); handleContinue(); }}> {/* Main Content Layout */} {/* Carousel Section */} @@ -140,43 +150,6 @@ const ApiKeyStep: React.FC = ({ onContinue, updateHeaderContent )} - {/* Continue Button */} - - - {isValid ? 'Continue' : 'Complete All Required API Keys'} - - {/* Security Notice */} = ({ onContinue, updateHeaderContent Your API keys are encrypted and stored securely on your device -
diff --git a/frontend/src/components/OnboardingWizard/ApiKeyStep/utils/useApiKeyStep.ts b/frontend/src/components/OnboardingWizard/ApiKeyStep/utils/useApiKeyStep.ts index 9341fa59..ab53b911 100644 --- a/frontend/src/components/OnboardingWizard/ApiKeyStep/utils/useApiKeyStep.ts +++ b/frontend/src/components/OnboardingWizard/ApiKeyStep/utils/useApiKeyStep.ts @@ -103,6 +103,19 @@ export const useApiKeyStep = (onContinue: (stepData?: any) => void) => { // Store CopilotKit key in localStorage for frontend use localStorage.setItem('copilotkit_api_key', copilotkitKey.trim()); console.log('ApiKeyStep: CopilotKit key saved to localStorage for frontend CopilotKit provider'); + + // Also save to frontend .env file (for development) + try { + await apiClient.post('/api/frontend-env/update', { + key: 'REACT_APP_COPILOTKIT_API_KEY', + value: copilotkitKey.trim(), + description: 'CopilotKit API key for AI assistant functionality' + }); + console.log('ApiKeyStep: CopilotKit key saved to frontend .env file'); + } catch (envError) { + console.warn('ApiKeyStep: Failed to save CopilotKit key to frontend .env file:', envError); + // Don't fail the entire process if .env update fails + } } try { @@ -215,7 +228,49 @@ export const useApiKeyStep = (onContinue: (stepData?: any) => void) => { ]; // All three keys are required - const isValid = geminiKey.trim() && exaKey.trim() && copilotkitKey.trim(); + const isValid = !!(geminiKey.trim() && exaKey.trim() && copilotkitKey.trim()); + + // Debug logging + useEffect(() => { + console.log('useApiKeyStep: Validation check:', { + gemini: geminiKey.trim(), + exa: exaKey.trim(), + copilotkit: copilotkitKey.trim(), + isValid + }); + }, [geminiKey, exaKey, copilotkitKey, isValid]); + + // When keys change and all are valid, auto-save them + useEffect(() => { + if (isValid && (geminiKey || exaKey || copilotkitKey)) { + console.log('useApiKeyStep: All keys valid, auto-saving...'); + // Save keys immediately when all are provided + const saveKeys = async () => { + try { + const promises = []; + + if (geminiKey.trim()) { + promises.push(saveApiKey('gemini', geminiKey.trim())); + } + if (exaKey.trim()) { + promises.push(saveApiKey('exa', exaKey.trim())); + } + if (copilotkitKey.trim()) { + promises.push(saveApiKey('copilotkit', copilotkitKey.trim())); + // Store CopilotKit key in localStorage for frontend use + localStorage.setItem('copilotkit_api_key', copilotkitKey.trim()); + } + + await Promise.all(promises); + console.log('useApiKeyStep: All API keys auto-saved successfully (backend handles .env files)'); + } catch (error) { + console.error('useApiKeyStep: Auto-save failed:', error); + } + }; + + saveKeys(); + } + }, [geminiKey, exaKey, copilotkitKey, isValid]); // Auto-scroll to next provider when current one is valid useEffect(() => { diff --git a/frontend/src/components/OnboardingWizard/FinalStep.tsx b/frontend/src/components/OnboardingWizard/FinalStep.tsx deleted file mode 100644 index 345cc7f6..00000000 --- a/frontend/src/components/OnboardingWizard/FinalStep.tsx +++ /dev/null @@ -1,660 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - Box, - Button, - Typography, - Alert, - Paper, - Container, - Fade, - Zoom, - Grid, - Chip, - List, - ListItem, - ListItemIcon, - ListItemText, - Accordion, - AccordionSummary, - AccordionDetails, - Card, - CardContent, - IconButton, - Tooltip, - CircularProgress -} from '@mui/material'; -import { - CheckCircle, - Rocket, - Star, - TrendingUp, - Security, - ExpandMore, - Visibility, - VisibilityOff, - Lock, - LockOpen, - Settings, - Web, - Psychology, - Business, - ContentCopy -} from '@mui/icons-material'; -import OnboardingButton from './common/OnboardingButton'; -import { getApiKeys, completeOnboarding, getOnboardingSummary, getWebsiteAnalysisData, getResearchPreferencesData, setCurrentStep } from '../../api/onboarding'; - -interface FinalStepProps { - onContinue: () => void; - updateHeaderContent: (content: { title: string; description: string }) => void; -} - -interface OnboardingData { - apiKeys: Record; - websiteUrl?: string; - researchPreferences?: any; - personalizationSettings?: any; - integrations?: any; - styleAnalysis?: any; -} - -interface Capability { - id: string; - title: string; - description: string; - icon: React.ReactElement; - unlocked: boolean; - required?: string[]; -} - -const FinalStep: React.FC = ({ onContinue, updateHeaderContent }) => { - const [loading, setLoading] = useState(false); - const [dataLoading, setDataLoading] = useState(true); - const [error, setError] = useState(null); - const [onboardingData, setOnboardingData] = useState({ - apiKeys: {} - }); - const [showApiKeys, setShowApiKeys] = useState(false); - const [expandedSection, setExpandedSection] = useState('summary'); - - useEffect(() => { - updateHeaderContent({ - title: 'Review & Launch Alwrity 🚀', - description: 'Review your configuration and confirm all settings before launching your AI-powered content creation workspace.' - }); - loadOnboardingData(); - }, [updateHeaderContent]); - - const loadOnboardingData = async () => { - setDataLoading(true); - try { - // Load comprehensive onboarding summary - const summary = await getOnboardingSummary(); - - // Load individual data sources for detailed information - const websiteAnalysis = await getWebsiteAnalysisData(); - const researchPreferences = await getResearchPreferencesData(); - - setOnboardingData({ - apiKeys: summary.api_keys || {}, - websiteUrl: websiteAnalysis?.website_url || summary.website_url, - researchPreferences: researchPreferences || summary.research_preferences, - personalizationSettings: summary.personalization_settings, - integrations: summary.integrations || {}, - styleAnalysis: websiteAnalysis?.style_analysis || summary.style_analysis - }); - } catch (error) { - console.error('Error loading onboarding data:', error); - // Fallback to just API keys if other endpoints fail - try { - const apiKeys = await getApiKeys(); - setOnboardingData({ - apiKeys, - websiteUrl: undefined, - researchPreferences: undefined, - personalizationSettings: undefined, - integrations: undefined, - styleAnalysis: undefined - }); - } catch (fallbackError) { - console.error('Error loading API keys as fallback:', fallbackError); - } - } finally { - setDataLoading(false); - } - }; - - const handleLaunch = async () => { - setLoading(true); - setError(null); - try { - console.log('FinalStep: Starting onboarding completion...'); - - // First, complete step 6 (Final Step) to mark it as completed - console.log('FinalStep: Completing step 6...'); - await setCurrentStep(6); - console.log('FinalStep: Step 6 completed successfully'); - - // Then complete the entire onboarding process - console.log('FinalStep: Completing onboarding...'); - await completeOnboarding(); - console.log('FinalStep: Onboarding completed successfully'); - - // Navigate directly to dashboard without calling onContinue - // This bypasses the wizard flow and goes straight to the dashboard - console.log('FinalStep: Navigating to dashboard...'); - window.location.href = '/dashboard'; - } catch (e: any) { - console.error('FinalStep: Error completing onboarding:', e); - - // Provide more specific error messages - let errorMessage = 'Failed to complete onboarding. Please try again.'; - - if (e.response?.data?.detail) { - errorMessage = e.response.data.detail; - } else if (e.message) { - errorMessage = e.message; - } - - setError(errorMessage); - } - setLoading(false); - }; - - const capabilities: Capability[] = [ - { - id: 'ai-content', - title: 'AI Content Generation', - description: 'Generate high-quality, personalized content using advanced AI models', - icon: , - unlocked: Object.keys(onboardingData.apiKeys).length > 0, - required: ['API Keys'] - }, - { - id: 'style-analysis', - title: 'Style Analysis', - description: 'Analyze and match your brand\'s writing style and tone', - icon: , - unlocked: !!onboardingData.websiteUrl, - required: ['Website URL'] - }, - { - id: 'research-tools', - title: 'AI Research Tools', - description: 'Automated research and fact-checking capabilities', - icon: , - unlocked: !!onboardingData.researchPreferences, - required: ['Research Configuration'] - }, - { - id: 'personalization', - title: 'Content Personalization', - description: 'Tailored content based on your brand voice and preferences', - icon: , - unlocked: !!onboardingData.personalizationSettings, - required: ['Personalization Settings'] - }, - { - id: 'integrations', - title: 'Third-party Integrations', - description: 'Connect with external tools and platforms', - icon: , - unlocked: !!onboardingData.integrations, - required: ['Integration Setup'] - } - ]; - - const getConfiguredProviders = () => { - return Object.keys(onboardingData.apiKeys).map(provider => ({ - name: provider.charAt(0).toUpperCase() + provider.slice(1), - configured: true - })); - }; - - const getMissingRequirements = () => { - const missing = []; - if (Object.keys(onboardingData.apiKeys).length === 0) { - missing.push('At least one AI provider API key'); - } - if (!onboardingData.websiteUrl) { - missing.push('Website URL for style analysis'); - } - return missing; - }; - - const unlockedCapabilities = capabilities.filter(cap => cap.unlocked); - const missingRequirements = getMissingRequirements(); - - return ( - - - {/* Loading State */} - {dataLoading && ( - - - - - Loading your configuration... - - - Retrieving your onboarding data and settings - - - - )} - - {/* Content - Only show when data is loaded */} - {!dataLoading && ( - - {/* Summary Section */} - - - - - - - Setup Summary - - - } - /> - - - - {/* Configured Providers */} - - - - - - AI Providers - - - {getConfiguredProviders().map((provider, index) => ( - - - - - - - ))} - - - - - - {/* Quick Stats */} - - - - - - Quick Stats - - - - AI Providers: - - {Object.keys(onboardingData.apiKeys).length} configured - - - - Capabilities: - - {unlockedCapabilities.length} unlocked - - - - Missing: - 0 ? 'warning.main' : 'success.main' }}> - {missingRequirements.length} requirements - - - - - - - - - - - {/* Detailed Configuration Review */} - - - - - Configuration Details - - - - {/* API Keys Section */} - - setExpandedSection(expandedSection === 'api-keys' ? null : 'api-keys')} - sx={{ background: 'rgba(255, 255, 255, 0.8)', borderRadius: 2 }} - > - }> - - - - API Keys ({Object.keys(onboardingData.apiKeys).length} configured) - - - - - - {Object.entries(onboardingData.apiKeys).map(([provider, key]) => ( - - - - {provider} - - - - setShowApiKeys(!showApiKeys)} - > - {showApiKeys ? : } - - - - - - {showApiKeys ? key : '••••••••••••••••••••••••••••••••'} - - - ))} - - - - - - {/* Website Configuration */} - - setExpandedSection(expandedSection === 'website' ? null : 'website')} - sx={{ background: 'rgba(255, 255, 255, 0.8)', borderRadius: 2 }} - > - }> - - - - Website Analysis - - - - - {onboardingData.websiteUrl ? ( - - - URL: {onboardingData.websiteUrl} - - {onboardingData.styleAnalysis && ( - - ✓ Style analysis completed - - )} - - ) : ( - - ⚠️ No website URL configured - - )} - - - - - {/* Research Preferences */} - - setExpandedSection(expandedSection === 'research' ? null : 'research')} - sx={{ background: 'rgba(255, 255, 255, 0.8)', borderRadius: 2 }} - > - }> - - - - Research Configuration - - - - - {onboardingData.researchPreferences ? ( - - - Depth: {onboardingData.researchPreferences.research_depth} - - - Content Types: {onboardingData.researchPreferences.content_types?.join(', ')} - - - Auto Research: {onboardingData.researchPreferences.auto_research ? 'Enabled' : 'Disabled'} - - - ) : ( - - ⚠️ Research preferences not configured - - )} - - - - - {/* Personalization Settings */} - - setExpandedSection(expandedSection === 'personalization' ? null : 'personalization')} - sx={{ background: 'rgba(255, 255, 255, 0.8)', borderRadius: 2 }} - > - }> - - - - Personalization - - - - - {onboardingData.personalizationSettings ? ( - - - Style: {onboardingData.personalizationSettings.writing_style} - - - Tone: {onboardingData.personalizationSettings.tone} - - - Brand Voice: {onboardingData.personalizationSettings.brand_voice} - - - ) : ( - - ⚠️ Personalization not configured - - )} - - - - - - - - {/* Capabilities Overview */} - - - - - Capabilities Overview - - - - {capabilities.map((capability) => ( - - - - - - {React.cloneElement(capability.icon, { - sx: { color: 'white', fontSize: 20 } - })} - - - - {capability.title} - {capability.unlocked ? ( - - ) : ( - - )} - - - - - {capability.description} - - {!capability.unlocked && capability.required && ( - - - Requires: {capability.required.join(', ')} - - - )} - - - - ))} - - - - - {/* Missing Requirements Warning */} - {missingRequirements.length > 0 && ( - - - Configure Now - - } - > - - Missing Requirements - - - The following items are recommended for optimal experience: {missingRequirements.join(', ')} - - - - )} - - {/* Alerts */} - - {error && ( - - setError(null)} - > - Dismiss - - } - > - - Setup Incomplete - - - {error} - - - - )} - - - {/* Action Button */} - - } - disabled={Object.keys(onboardingData.apiKeys).length === 0} - > - Launch Alwrity & Complete Setup - - - - {/* Help Text */} - - - This will complete your onboarding and launch Alwrity with your configured settings. - - - - Ready to create amazing content with AI-powered assistance - - - - )} - - - ); -}; - -export default FinalStep; \ No newline at end of file diff --git a/frontend/src/components/OnboardingWizard/FinalStep/FinalStep.tsx b/frontend/src/components/OnboardingWizard/FinalStep/FinalStep.tsx new file mode 100644 index 00000000..0ceebeb0 --- /dev/null +++ b/frontend/src/components/OnboardingWizard/FinalStep/FinalStep.tsx @@ -0,0 +1,284 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Button, + Typography, + Alert, + Container, + Fade, + Zoom, + CircularProgress +} from '@mui/material'; +import { + Rocket, + Star, + CheckCircle +} from '@mui/icons-material'; +import OnboardingButton from '../common/OnboardingButton'; +import { getApiKeys, completeOnboarding, getOnboardingSummary, getWebsiteAnalysisData, getResearchPreferencesData, setCurrentStep } from '../../../api/onboarding'; +import { SetupSummary, CapabilitiesOverview } from './components'; +import { FinalStepProps, OnboardingData, Capability } from './types'; + +const FinalStep: React.FC = ({ onContinue, updateHeaderContent }) => { + const [loading, setLoading] = useState(false); + const [dataLoading, setDataLoading] = useState(true); + const [error, setError] = useState(null); + const [onboardingData, setOnboardingData] = useState({ + apiKeys: {} + }); + const [expandedSection, setExpandedSection] = useState('summary'); + + useEffect(() => { + updateHeaderContent({ + title: 'Review & Launch Alwrity 🚀', + description: 'Review your configuration and confirm all settings before launching your AI-powered content creation workspace.' + }); + loadOnboardingData(); + }, [updateHeaderContent]); + + const loadOnboardingData = async () => { + setDataLoading(true); + try { + // Load comprehensive onboarding summary + const summary = await getOnboardingSummary(); + + // Load individual data sources for detailed information + const websiteAnalysis = await getWebsiteAnalysisData(); + const researchPreferences = await getResearchPreferencesData(); + + setOnboardingData({ + apiKeys: summary.api_keys || {}, + websiteUrl: websiteAnalysis?.website_url || summary.website_url, + researchPreferences: researchPreferences || summary.research_preferences, + personalizationSettings: summary.personalization_settings, + integrations: summary.integrations || {}, + styleAnalysis: websiteAnalysis?.style_analysis || summary.style_analysis + }); + } catch (error) { + console.error('Error loading onboarding data:', error); + // Fallback to just API keys if other endpoints fail + try { + const apiKeys = await getApiKeys(); + setOnboardingData({ + apiKeys, + websiteUrl: undefined, + researchPreferences: undefined, + personalizationSettings: undefined, + integrations: undefined, + styleAnalysis: undefined + }); + } catch (fallbackError) { + console.error('Error loading API keys as fallback:', fallbackError); + } + } finally { + setDataLoading(false); + } + }; + + const handleLaunch = async () => { + setLoading(true); + setError(null); + try { + console.log('FinalStep: Starting onboarding completion...'); + + // First, complete step 6 (Final Step) to mark it as completed + console.log('FinalStep: Completing step 6...'); + await setCurrentStep(6); + console.log('FinalStep: Step 6 completed successfully'); + + // Then complete the entire onboarding process + console.log('FinalStep: Completing onboarding...'); + await completeOnboarding(); + console.log('FinalStep: Onboarding completed successfully'); + + // Navigate directly to dashboard without calling onContinue + // This bypasses the wizard flow and goes straight to the dashboard + console.log('FinalStep: Navigating to dashboard...'); + window.location.href = '/dashboard'; + } catch (e: any) { + console.error('FinalStep: Error completing onboarding:', e); + + // Provide more specific error messages + let errorMessage = 'Failed to complete onboarding. Please try again.'; + + if (e.response?.data?.detail) { + errorMessage = e.response.data.detail; + } else if (e.message) { + errorMessage = e.message; + } + + setError(errorMessage); + } + setLoading(false); + }; + + const capabilities: Capability[] = [ + { + id: 'ai-content', + title: 'AI Content Generation', + description: 'Generate high-quality, personalized content using advanced AI models', + icon: , + unlocked: Object.keys(onboardingData.apiKeys).length > 0, + required: ['API Keys'] + }, + { + id: 'style-analysis', + title: 'Style Analysis', + description: 'Analyze and match your brand\'s writing style and tone', + icon: , + unlocked: !!onboardingData.websiteUrl, + required: ['Website URL'] + }, + { + id: 'research-tools', + title: 'AI Research Tools', + description: 'Automated research and fact-checking capabilities', + icon: , + unlocked: !!onboardingData.researchPreferences, + required: ['Research Configuration'] + }, + { + id: 'personalization', + title: 'Content Personalization', + description: 'Tailored content based on your brand voice and preferences', + icon: , + unlocked: !!onboardingData.personalizationSettings, + required: ['Personalization Settings'] + }, + { + id: 'integrations', + title: 'Third-party Integrations', + description: 'Connect with external tools and platforms', + icon: , + unlocked: !!onboardingData.integrations, + required: ['Integration Setup'] + } + ]; + + const getMissingRequirements = () => { + const missing = []; + if (Object.keys(onboardingData.apiKeys).length === 0) { + missing.push('At least one AI provider API key'); + } + if (!onboardingData.websiteUrl) { + missing.push('Website URL for style analysis'); + } + return missing; + }; + + const missingRequirements = getMissingRequirements(); + + return ( + + + {/* Loading State */} + {dataLoading && ( + + + + + Loading your configuration... + + + Retrieving your onboarding data and settings + + + + )} + + {/* Content - Only show when data is loaded */} + {!dataLoading && ( + + {/* Setup Summary */} + + + {/* Capabilities Overview */} + + + {/* Missing Requirements Warning */} + {missingRequirements.length > 0 && ( + + + Configure Now + + } + > + + Missing Requirements + + + The following items are recommended for optimal experience: {missingRequirements.join(', ')} + + + + )} + + {/* Alerts */} + + {error && ( + + setError(null)} + > + Dismiss + + } + > + + Setup Incomplete + + + {error} + + + + )} + + + {/* Action Button */} + + } + disabled={Object.keys(onboardingData.apiKeys).length === 0} + > + Launch Alwrity & Complete Setup + + + + {/* Help Text */} + + + This will complete your onboarding and launch Alwrity with your configured settings. + + + + Ready to create amazing content with AI-powered assistance + + + + )} + + + ); +}; + +export default FinalStep; diff --git a/frontend/src/components/OnboardingWizard/FinalStep/README.md b/frontend/src/components/OnboardingWizard/FinalStep/README.md new file mode 100644 index 00000000..9aa4c2d4 --- /dev/null +++ b/frontend/src/components/OnboardingWizard/FinalStep/README.md @@ -0,0 +1,96 @@ +# FinalStep Component Structure + +This folder contains the refactored FinalStep component for the Onboarding Wizard, organized into smaller, reusable components. + +## File Structure + +``` +FinalStep/ +├── FinalStep.tsx # Main component container +├── components/ +│ ├── SetupSummary.tsx # Combined setup summary and configuration details +│ └── CapabilitiesOverview.tsx # Capabilities overview section +├── types.ts # Shared TypeScript interfaces +├── index.ts # Export barrel file +└── README.md # This documentation +``` + +## Components + +### FinalStep.tsx +- **Purpose**: Main container component for the final onboarding step +- **Responsibilities**: + - Data loading and state management + - Launch button handling + - Error handling + - Orchestrating child components + +### SetupSummary.tsx +- **Purpose**: Combined setup summary and configuration details +- **Features**: + - AI Providers list with checkmarks + - Quick Stats overview + - Compact configuration cards (API Keys, Website Analysis, Research Config, Personalization) + - Expandable details for each configuration section + - Clickable cards with hover effects + +### CapabilitiesOverview.tsx +- **Purpose**: Display unlocked capabilities and requirements +- **Features**: + - Visual capability cards with icons + - Locked/unlocked states + - Requirement information for locked capabilities + +## Types + +### OnboardingData +```typescript +interface OnboardingData { + apiKeys: Record; + websiteUrl?: string; + researchPreferences?: any; + personalizationSettings?: any; + integrations?: any; + styleAnalysis?: any; +} +``` + +### Capability +```typescript +interface Capability { + id: string; + title: string; + description: string; + icon: React.ReactElement; + unlocked: boolean; + required?: string[]; +} +``` + +## Usage + +```tsx +import FinalStep from './FinalStep'; + + +``` + +## Design Features + +1. **Combined Layout**: Setup Summary and Configuration Details are now in one cohesive section +2. **Compact Cards**: 4-column grid for configuration items (matches design requirements) +3. **Interactive Elements**: Clickable cards with expandable details +4. **Responsive Design**: Works on mobile and desktop +5. **Consistent Styling**: Maintains the green gradient theme with proper spacing + +## Recent Changes + +- ✅ Combined Configuration Details into Setup Summary +- ✅ Created compact, clickable configuration cards +- ✅ Maintained all existing functionality +- ✅ Improved readability and space efficiency +- ✅ Organized code into smaller, reusable components +- ✅ Added proper TypeScript interfaces diff --git a/frontend/src/components/OnboardingWizard/FinalStep/components/CapabilitiesOverview.tsx b/frontend/src/components/OnboardingWizard/FinalStep/components/CapabilitiesOverview.tsx new file mode 100644 index 00000000..fc54a770 --- /dev/null +++ b/frontend/src/components/OnboardingWizard/FinalStep/components/CapabilitiesOverview.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { + Box, + Paper, + Zoom, + Grid, + Typography, + Card, + CardContent +} from '@mui/material'; +import { + Star, + CheckCircle, + Lock +} from '@mui/icons-material'; +import { Capability } from '../types'; + +interface CapabilitiesOverviewProps { + capabilities: Capability[]; +} + +export const CapabilitiesOverview: React.FC = ({ capabilities }) => { + return ( + + + + + Capabilities Overview + + + + {capabilities.map((capability) => ( + + + + + + {React.cloneElement(capability.icon, { + sx: { color: 'white', fontSize: 20 } + })} + + + + {capability.title} + {capability.unlocked ? ( + + ) : ( + + )} + + + + + {capability.description} + + {!capability.unlocked && capability.required && ( + + + Requires: {capability.required.join(', ')} + + + )} + + + + ))} + + + + ); +}; + +export default CapabilitiesOverview; diff --git a/frontend/src/components/OnboardingWizard/FinalStep/components/SetupSummary.tsx b/frontend/src/components/OnboardingWizard/FinalStep/components/SetupSummary.tsx new file mode 100644 index 00000000..cd563b47 --- /dev/null +++ b/frontend/src/components/OnboardingWizard/FinalStep/components/SetupSummary.tsx @@ -0,0 +1,343 @@ +import React, { useState } from 'react'; +import { + Box, + Paper, + Zoom, + Grid, + Typography, + Card, + CardContent, + Chip, + Tooltip, + IconButton +} from '@mui/material'; +import { + CheckCircle, + Security, + TrendingUp, + Settings, + Web, + Psychology, + LockOpen, + Visibility, + VisibilityOff +} from '@mui/icons-material'; +import { OnboardingData, Capability } from '../types'; + +interface SetupSummaryProps { + onboardingData: OnboardingData; + capabilities: Capability[]; + expandedSection: string | null; + setExpandedSection: (section: string | null) => void; +} + +export const SetupSummary: React.FC = ({ + onboardingData, + capabilities, + expandedSection, + setExpandedSection +}) => { + const [showApiKeys, setShowApiKeys] = useState(false); + const unlockedCapabilities = capabilities.filter(cap => cap.unlocked); + + return ( + + + {/* Header with Stats Chips */} + + + + + Setup Summary + + + + {/* Stats Chips */} + + } + /> + } + /> + + + + + {/* Main Content Grid - Compact Single Card */} + + {/* Configuration Details Card */} + + + + {/* Configuration Details Header - Updated for readability */} + + + Configuration Details + + + + {/* API Keys */} + + setExpandedSection(expandedSection === 'api-keys' ? null : 'api-keys')} + > + + + + API Keys + + + + {Object.keys(onboardingData.apiKeys).length} configured + + + + + {/* Website Analysis */} + + setExpandedSection(expandedSection === 'website' ? null : 'website')} + > + + + + Website Analysis + + + + {onboardingData.websiteUrl ? 'Configured' : 'Not set'} + + + + + {/* Research Configuration */} + + setExpandedSection(expandedSection === 'research' ? null : 'research')} + > + + + + Research Config + + + + {onboardingData.researchPreferences ? 'Configured' : 'Not set'} + + + + + {/* Personalization */} + + setExpandedSection(expandedSection === 'personalization' ? null : 'personalization')} + > + + + + Personalization + + + + {onboardingData.personalizationSettings ? 'Configured' : 'Not set'} + + + + + + {/* Expandable Details */} + {(expandedSection === 'api-keys' || expandedSection === 'website' || expandedSection === 'research' || expandedSection === 'personalization') && ( + + + {/* API Keys Details */} + {expandedSection === 'api-keys' && ( + + + + API Keys ({Object.keys(onboardingData.apiKeys).length} configured) + + + {Object.entries(onboardingData.apiKeys).map(([provider, key]) => ( + + + + {provider} + + + setShowApiKeys(!showApiKeys)} + > + {showApiKeys ? : } + + + + + {showApiKeys ? key : '••••••••••••••••••••••••••••••••'} + + + ))} + + + )} + + {/* Website Analysis Details */} + {expandedSection === 'website' && ( + + + + Website Analysis + + {onboardingData.websiteUrl ? ( + + + URL: {onboardingData.websiteUrl} + + {onboardingData.styleAnalysis && ( + + ✓ Style analysis completed + + )} + + ) : ( + + ⚠️ No website URL configured + + )} + + )} + + {/* Research Configuration Details */} + {expandedSection === 'research' && ( + + + + Research Configuration + + {onboardingData.researchPreferences ? ( + + + Depth: {onboardingData.researchPreferences.research_depth} + + + Content Types: {onboardingData.researchPreferences.content_types?.join(', ')} + + + Auto Research: {onboardingData.researchPreferences.auto_research ? 'Enabled' : 'Disabled'} + + + ) : ( + + ⚠️ Research preferences not configured + + )} + + )} + + {/* Personalization Details */} + {expandedSection === 'personalization' && ( + + + + Personalization + + {onboardingData.personalizationSettings ? ( + + + Style: {onboardingData.personalizationSettings.writing_style} + + + Tone: {onboardingData.personalizationSettings.tone} + + + Brand Voice: {onboardingData.personalizationSettings.brand_voice} + + + ) : ( + + ⚠️ Personalization not configured + + )} + + )} + + + )} + + + + + + + ); +}; + +export default SetupSummary; \ No newline at end of file diff --git a/frontend/src/components/OnboardingWizard/FinalStep/components/index.ts b/frontend/src/components/OnboardingWizard/FinalStep/components/index.ts new file mode 100644 index 00000000..47d52624 --- /dev/null +++ b/frontend/src/components/OnboardingWizard/FinalStep/components/index.ts @@ -0,0 +1,3 @@ +export { default as SetupSummary } from './SetupSummary'; +export { default as CapabilitiesOverview } from './CapabilitiesOverview'; + diff --git a/frontend/src/components/OnboardingWizard/FinalStep/index.ts b/frontend/src/components/OnboardingWizard/FinalStep/index.ts new file mode 100644 index 00000000..1d2d2036 --- /dev/null +++ b/frontend/src/components/OnboardingWizard/FinalStep/index.ts @@ -0,0 +1,3 @@ +export { default } from './FinalStep'; +export { default as SetupSummary } from './components/SetupSummary'; +export { default as CapabilitiesOverview } from './components/CapabilitiesOverview'; diff --git a/frontend/src/components/OnboardingWizard/FinalStep/types.ts b/frontend/src/components/OnboardingWizard/FinalStep/types.ts new file mode 100644 index 00000000..de7ea2dd --- /dev/null +++ b/frontend/src/components/OnboardingWizard/FinalStep/types.ts @@ -0,0 +1,22 @@ +export interface OnboardingData { + apiKeys: Record; + websiteUrl?: string; + researchPreferences?: any; + personalizationSettings?: any; + integrations?: any; + styleAnalysis?: any; +} + +export interface Capability { + id: string; + title: string; + description: string; + icon: React.ReactElement; + unlocked: boolean; + required?: string[]; +} + +export interface FinalStepProps { + onContinue: () => void; + updateHeaderContent: (content: { title: string; description: string }) => void; +} diff --git a/frontend/src/components/OnboardingWizard/PersonaStep.tsx b/frontend/src/components/OnboardingWizard/PersonaStep.tsx index 53fbc649..63797ce9 100644 --- a/frontend/src/components/OnboardingWizard/PersonaStep.tsx +++ b/frontend/src/components/OnboardingWizard/PersonaStep.tsx @@ -21,6 +21,7 @@ import { ComingSoonSection } from './PersonaStep/ComingSoonSection'; interface PersonaStepProps { onContinue: (personaData: PersonaData) => void; updateHeaderContent: (content: StepHeaderContent) => void; + onValidationChange?: (isValid: boolean) => void; onboardingData?: { websiteAnalysis?: any; competitorResearch?: any; @@ -61,6 +62,7 @@ interface QualityMetrics { const PersonaStep: React.FC = ({ onContinue, updateHeaderContent, + onValidationChange, onboardingData = {}, stepData }) => { @@ -325,6 +327,23 @@ const PersonaStep: React.FC = ({ } }, [corePersona, platformPersonas, qualityMetrics, selectedPlatforms, onContinue]); + // Validation effect - notify wizard when persona data is ready + useEffect(() => { + const isValid = !!(corePersona && platformPersonas && Object.keys(platformPersonas).length > 0 && qualityMetrics); + console.log('PersonaStep: Validation check:', { + corePersona: !!corePersona, + platformPersonas: !!platformPersonas, + platformPersonasCount: platformPersonas ? Object.keys(platformPersonas).length : 0, + qualityMetrics: !!qualityMetrics, + isValid + }); + + if (onValidationChange) { + console.log('PersonaStep: Calling onValidationChange with:', isValid); + onValidationChange(isValid); + } + }, [corePersona, platformPersonas, qualityMetrics, onValidationChange]); + // Auto-call onContinue when persona data is ready useEffect(() => { console.log('PersonaStep: Checking persona data readiness:', { diff --git a/frontend/src/components/OnboardingWizard/WebsiteStep.tsx b/frontend/src/components/OnboardingWizard/WebsiteStep.tsx index cb6ab815..bea0f0ee 100644 --- a/frontend/src/components/OnboardingWizard/WebsiteStep.tsx +++ b/frontend/src/components/OnboardingWizard/WebsiteStep.tsx @@ -36,6 +36,7 @@ import { interface WebsiteStepProps { onContinue: (stepData?: any) => void; updateHeaderContent: (content: { title: string; description: string }) => void; + onValidationChange?: (isValid: boolean) => void; } interface StyleAnalysis { @@ -148,7 +149,7 @@ interface ExistingAnalysis { // MAIN COMPONENT // ============================================================================= -const WebsiteStep: React.FC = ({ onContinue, updateHeaderContent }) => { +const WebsiteStep: React.FC = ({ onContinue, updateHeaderContent, onValidationChange }) => { const [website, setWebsite] = useState(''); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -178,6 +179,16 @@ const WebsiteStep: React.FC = ({ onContinue, updateHeaderConte }); }, [updateHeaderContent]); + // Notify parent when validation state changes + useEffect(() => { + const isValid = !!(website.trim() && analysis); + console.log('WebsiteStep: Validation check:', { website: website.trim(), analysis: !!analysis, isValid }); + if (onValidationChange) { + console.log('WebsiteStep: Calling onValidationChange with:', isValid); + onValidationChange(isValid); + } + }, [website, analysis, onValidationChange]); + useEffect(() => { // Prefill from last session analysis on mount const loadLastAnalysis = async () => { @@ -517,31 +528,6 @@ const WebsiteStep: React.FC = ({ onContinue, updateHeaderConte useAnalysisForGenAI={useAnalysisForGenAI} onUseAnalysisChange={setUseAnalysisForGenAI} /> - - {/* Continue Button */} - - - )} diff --git a/frontend/src/components/OnboardingWizard/WebsiteStep/components/TargetAudienceAnalysisSection.tsx b/frontend/src/components/OnboardingWizard/WebsiteStep/components/TargetAudienceAnalysisSection.tsx index 652ea6d2..14472b8f 100644 --- a/frontend/src/components/OnboardingWizard/WebsiteStep/components/TargetAudienceAnalysisSection.tsx +++ b/frontend/src/components/OnboardingWizard/WebsiteStep/components/TargetAudienceAnalysisSection.tsx @@ -101,18 +101,18 @@ const TargetAudienceAnalysisSection: React.FC - + Psychographic Profile {Array.isArray(targetAudience.psychographic_profile) ? targetAudience.psychographic_profile.map((item: string, index: number) => ( - + {item} )) : ( - + {targetAudience.psychographic_profile} )} @@ -131,12 +131,12 @@ const TargetAudienceAnalysisSection: React.FC - + Pain Points {targetAudience.pain_points.map((painPoint: string, index: number) => ( - + {painPoint} ))} @@ -155,12 +155,12 @@ const TargetAudienceAnalysisSection: React.FC - + Motivations {targetAudience.motivations.map((motivation: string, index: number) => ( - + {motivation} ))} diff --git a/frontend/src/components/OnboardingWizard/WebsiteStep/utils/renderUtils.tsx b/frontend/src/components/OnboardingWizard/WebsiteStep/utils/renderUtils.tsx index e1a717f1..9c0ea381 100644 --- a/frontend/src/components/OnboardingWizard/WebsiteStep/utils/renderUtils.tsx +++ b/frontend/src/components/OnboardingWizard/WebsiteStep/utils/renderUtils.tsx @@ -66,7 +66,7 @@ const KeyInsightCard: React.FC = ({ mb: 0, borderRadius: 2.5, // Force high-contrast base color so nested text never inherits a light color - color: isDark ? '#ffffff' : '#1a202c', + color: isDark ? '#ffffff !important' : '#1a202c !important', background: isDark ? `linear-gradient(135deg, ${alpha(paletteColor.main, 0.08)} 0%, ${alpha(paletteColor.main, 0.04)} 100%)` : `linear-gradient(135deg, ${alpha(paletteColor.main, 0.06)} 0%, ${alpha(paletteColor.light, 0.08)} 100%)`, @@ -76,6 +76,10 @@ const KeyInsightCard: React.FC = ({ : alpha(paletteColor.main, 0.15), borderLeftWidth: '5px', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', + // Ensure all child elements inherit proper text color + '& *': { + color: 'inherit !important' + }, '&:hover': { background: isDark ? `linear-gradient(135deg, ${alpha(paletteColor.main, 0.12)} 0%, ${alpha(paletteColor.main, 0.08)} 100%)` @@ -114,10 +118,13 @@ const KeyInsightCard: React.FC = ({ fontSize: '0.78rem', letterSpacing: '0.6px', textTransform: 'uppercase', - color: isDark ? '#ffffff !important' : '#0f172a !important', + color: isDark ? '#ffffff !important' : '#1a202c !important', textShadow: isDark ? 'none' : '0 1px 0 rgba(255,255,255,0.6)', mb: 0.5, - display: 'block' + display: 'block', + // Force high contrast for readability + WebkitTextFillColor: isDark ? '#ffffff' : '#1a202c', + WebkitTextStroke: '0px transparent' }} > {title} @@ -127,8 +134,11 @@ const KeyInsightCard: React.FC = ({ sx={{ fontWeight: 700, fontSize: '1.1rem', - color: isDark ? '#ffffff !important' : '#0b1220 !important', - lineHeight: 1.35 + color: isDark ? '#ffffff !important' : '#1a202c !important', + lineHeight: 1.35, + // Force high contrast for readability + WebkitTextFillColor: isDark ? '#ffffff' : '#1a202c', + WebkitTextStroke: '0px transparent' }} > {Array.isArray(value) ? value.join(', ') : value} diff --git a/frontend/src/components/OnboardingWizard/Wizard.tsx b/frontend/src/components/OnboardingWizard/Wizard.tsx index 62d659b0..a38af266 100644 --- a/frontend/src/components/OnboardingWizard/Wizard.tsx +++ b/frontend/src/components/OnboardingWizard/Wizard.tsx @@ -49,6 +49,7 @@ const Wizard: React.FC = ({ onComplete }) => { const [stepData, setStepData] = useState(null); const [competitorDataCollector, setCompetitorDataCollector] = useState<(() => any) | null>(null); const [isCurrentStepValid, setIsCurrentStepValid] = useState(false); + const [stepValidationStates, setStepValidationStates] = useState>({}); const [stepHeaderContent, setStepHeaderContent] = useState({ title: steps[0].label, description: steps[0].description @@ -61,18 +62,24 @@ const Wizard: React.FC = ({ onComplete }) => { switch (step) { case 0: // API Keys const hasApiKeys = data && data.api_keys && Object.keys(data.api_keys).length > 0; - console.log(`Wizard: Step 0 (API Keys) validation:`, hasApiKeys); - return hasApiKeys; + console.log(`Wizard: Step 0 (API Keys) validation:`, !!hasApiKeys); + return !!hasApiKeys; case 1: // Website Analysis const hasWebsite = data && (data.website || data.website_url); - console.log(`Wizard: Step 1 (Website) validation:`, hasWebsite); - return hasWebsite; + console.log(`Wizard: Step 1 (Website) validation:`, !!hasWebsite); + return !!hasWebsite; case 2: // Competitor Analysis const hasCompetitorData = data && (data.competitors || data.researchSummary || data.sitemapAnalysis); - console.log(`Wizard: Step 2 (Competitor Analysis) validation:`, hasCompetitorData, 'Data keys:', data ? Object.keys(data) : 'no data'); - return hasCompetitorData; + console.log(`Wizard: Step 2 (Competitor Analysis) validation:`, { + hasCompetitorData: !!hasCompetitorData, + hasCompetitors: !!(data && data.competitors), + hasResearchSummary: !!(data && data.researchSummary), + hasSitemapAnalysis: !!(data && data.sitemapAnalysis), + dataKeys: data ? Object.keys(data) : 'no data' + }); + return !!hasCompetitorData; case 3: // Persona Generation const hasValidPersonaData = data && @@ -81,14 +88,14 @@ const Wizard: React.FC = ({ onComplete }) => { Object.keys(data.platformPersonas).length > 0 && data.qualityMetrics; console.log(`Wizard: Step 3 (Persona Generation) validation:`, { - hasValidPersonaData, + hasValidPersonaData: !!hasValidPersonaData, hasCorePersona: !!(data && data.corePersona), hasPlatformPersonas: !!(data && data.platformPersonas), platformPersonasCount: data && data.platformPersonas ? Object.keys(data.platformPersonas).length : 0, hasQualityMetrics: !!(data && data.qualityMetrics), dataKeys: data ? Object.keys(data) : 'no data' }); - return hasValidPersonaData; + return !!hasValidPersonaData; case 4: // Integrations console.log(`Wizard: Step 4 (Integrations) validation: always true (optional)`); @@ -126,7 +133,16 @@ const Wizard: React.FC = ({ onComplete }) => { useEffect(() => { console.log(`Wizard: Validation effect triggered - activeStep: ${activeStep}, stepData:`, stepData); console.log(`Wizard: stepData type:`, typeof stepData, 'keys:', stepData ? Object.keys(stepData) : 'no data'); + console.log(`Wizard: stepValidationStates:`, stepValidationStates); + // For step 0 (API Keys), step 1 (Website), and step 3 (Persona), use the step validation state if available + if ((activeStep === 0 || activeStep === 1 || activeStep === 3) && stepValidationStates[activeStep] !== undefined) { + console.log(`Wizard: Using step validation state for step ${activeStep}:`, stepValidationStates[activeStep]); + setIsCurrentStepValid(stepValidationStates[activeStep]); + return; + } + + // For other steps, use the existing validation logic // For CompetitorAnalysisStep, also check the competitorDataCollector data let dataToValidate = stepData; if (activeStep === 2 && competitorDataCollector) { @@ -138,12 +154,37 @@ const Wizard: React.FC = ({ onComplete }) => { console.log(`Wizard: Validation result for step ${activeStep}:`, isValid); console.log(`Wizard: Setting isCurrentStepValid to:`, isValid); setIsCurrentStepValid(isValid); - }, [activeStep, stepData, isStepDataValid, competitorDataCollector]); + }, [activeStep, stepData, isStepDataValid, competitorDataCollector, stepValidationStates]); // Debug: log all state changes useEffect(() => { console.log('Wizard: Render triggered - activeStep:', activeStep, 'direction:', direction); }, [activeStep, direction]); + + // Debug: log Continue button state + useEffect(() => { + console.log(`Wizard: isCurrentStepValid changed to: ${isCurrentStepValid} (Continue button ${isCurrentStepValid ? 'ENABLED' : 'DISABLED'})`); + }, [isCurrentStepValid]); + + // Handle validation changes from individual steps + const handleStepValidationChange = useCallback((step: number, isValid: boolean) => { + console.log(`Wizard: handleStepValidationChange called - step: ${step}, isValid: ${isValid}`); + setStepValidationStates(prev => { + // Only update if the value actually changed + if (prev[step] === isValid) { + console.log(`Wizard: Validation state unchanged for step ${step}, skipping update`); + return prev; // Return same reference to prevent re-render + } + const newState = { ...prev, [step]: isValid }; + console.log(`Wizard: Updated stepValidationStates:`, newState); + return newState; + }); + }, []); + + // Memoized callback specifically for ApiKeyStep to prevent infinite loops + const handleApiKeyValidationChange = useCallback((isValid: boolean) => { + handleStepValidationChange(0, isValid); + }, [handleStepValidationChange]); // Memoize the onDataReady callback to prevent infinite loops const handleCompetitorDataReady = useCallback((dataCollector: (() => any) | undefined) => { @@ -516,8 +557,8 @@ const Wizard: React.FC = ({ onComplete }) => { const renderStepContent = (step: number) => { const stepComponents = [ - , - , + , + handleStepValidationChange(1, isValid)} />, = ({ onComplete }) => { key="personalization" onContinue={handleNext} updateHeaderContent={updateHeaderContent} + onValidationChange={(isValid) => handleStepValidationChange(3, isValid)} onboardingData={personaOnboardingData} stepData={personaStepData} />,