ALwrity onboarding final step

This commit is contained in:
ajaysi
2025-10-10 23:19:28 +05:30
parent e3daebec16
commit b1ebe1034e
38 changed files with 4867 additions and 770 deletions

6
.gitignore vendored
View File

@@ -201,4 +201,8 @@ yarn.lock
.pytest_cache
# Documentation cache
docs/__pycache__/
docs/__pycache__/
# Onboarding JSON files (CRITICAL: Should use database instead)
.onboarding_progress.json
*_onboarding_progress.json
backend/.onboarding_progress*.json

View File

@@ -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))

View File

@@ -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

View File

@@ -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:

View File

@@ -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")

View File

@@ -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():

View File

@@ -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';

View File

@@ -22,3 +22,6 @@ WORDPRESS_REDIRECT_URI=
# Development Settings
DISABLE_AUTH=false
# local development
DEPLOY_ENV=local

View File

@@ -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)

View File

@@ -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"<OnboardingSession(id={self.id}, user_id={self.user_id}, step={self.current_step}, progress={self.progress})>"
@@ -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
}
}
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"<PersonaData(id={self.id}, session_id={self.session_id})>"
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
}

View File

@@ -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)}")

View File

@@ -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)

View File

@@ -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 <user_id>")
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)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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')

View File

@@ -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 <tester_a_clerk_token>
├─> 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 <tester_b_clerk_token>
├─> 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! 🚀

View File

@@ -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 <user_a_token>
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! 🎉

View File

@@ -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

View File

@@ -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! 🎉

View File

@@ -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.**

View File

@@ -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

View File

@@ -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`

View File

@@ -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<ApiKeyStepProps> = ({ onContinue, updateHeaderContent }) => {
const ApiKeyStep: React.FC<ApiKeyStepProps> = ({ onContinue, updateHeaderContent, onValidationChange }) => {
const [focusedProvider, setFocusedProvider] = useState<any>(null);
const {
@@ -63,11 +64,20 @@ const ApiKeyStep: React.FC<ApiKeyStepProps> = ({ 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 (
<>
<Fade in={true} timeout={500}>
<Container maxWidth="lg" sx={{ py: 2 }}>
<form onSubmit={(e) => { e.preventDefault(); handleContinue(); }}>
{/* Main Content Layout */}
<Grid container spacing={4} sx={{ mb: 4 }}>
{/* Carousel Section */}
@@ -140,43 +150,6 @@ const ApiKeyStep: React.FC<ApiKeyStepProps> = ({ onContinue, updateHeaderContent
)}
</Box>
{/* Continue Button */}
<Box sx={{ mt: 6, display: 'flex', justifyContent: 'center' }}>
<OnboardingButton
variant="primary"
type="submit"
loading={loading}
disabled={!isValid || loading}
size="large"
sx={{
px: 6,
py: 2.5,
fontSize: '1.1rem',
fontWeight: 700,
borderRadius: 4,
background: isValid
? 'linear-gradient(135deg, #10B981 0%, #059669 100%)'
: 'linear-gradient(135deg, #94A3B8 0%, #64748B 100%)',
boxShadow: isValid
? '0 12px 32px rgba(16, 185, 129, 0.3), 0 6px 12px rgba(16, 185, 129, 0.2)'
: '0 8px 16px rgba(148, 163, 184, 0.2)',
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
transform: isValid ? 'translateY(-3px) scale(1.02)' : 'none',
boxShadow: isValid
? '0 16px 40px rgba(16, 185, 129, 0.4), 0 8px 16px rgba(16, 185, 129, 0.3)'
: '0 8px 16px rgba(148, 163, 184, 0.2)',
},
'&:disabled': {
'&:hover': {
transform: 'none',
}
}
}}
>
{isValid ? 'Continue' : 'Complete All Required API Keys'}
</OnboardingButton>
</Box>
{/* Security Notice */}
<Box sx={{
@@ -201,7 +174,6 @@ const ApiKeyStep: React.FC<ApiKeyStepProps> = ({ onContinue, updateHeaderContent
Your API keys are encrypted and stored securely on your device
</Typography>
</Box>
</form>
</Container>
</Fade>

View File

@@ -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(() => {

View File

@@ -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<string, string>;
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<FinalStepProps> = ({ onContinue, updateHeaderContent }) => {
const [loading, setLoading] = useState(false);
const [dataLoading, setDataLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [onboardingData, setOnboardingData] = useState<OnboardingData>({
apiKeys: {}
});
const [showApiKeys, setShowApiKeys] = useState(false);
const [expandedSection, setExpandedSection] = useState<string | null>('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: <ContentCopy />,
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: <Psychology />,
unlocked: !!onboardingData.websiteUrl,
required: ['Website URL']
},
{
id: 'research-tools',
title: 'AI Research Tools',
description: 'Automated research and fact-checking capabilities',
icon: <TrendingUp />,
unlocked: !!onboardingData.researchPreferences,
required: ['Research Configuration']
},
{
id: 'personalization',
title: 'Content Personalization',
description: 'Tailored content based on your brand voice and preferences',
icon: <Settings />,
unlocked: !!onboardingData.personalizationSettings,
required: ['Personalization Settings']
},
{
id: 'integrations',
title: 'Third-party Integrations',
description: 'Connect with external tools and platforms',
icon: <Business />,
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 (
<Fade in={true} timeout={500}>
<Container maxWidth="lg" sx={{ py: 2 }}>
{/* Loading State */}
{dataLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 8 }}>
<Box sx={{ textAlign: 'center' }}>
<CircularProgress size={60} sx={{ mb: 2 }} />
<Typography variant="h6" sx={{ mb: 1 }}>
Loading your configuration...
</Typography>
<Typography variant="body2" color="text.secondary">
Retrieving your onboarding data and settings
</Typography>
</Box>
</Box>
)}
{/* Content - Only show when data is loaded */}
{!dataLoading && (
<React.Fragment>
{/* Summary Section */}
<Zoom in={true} timeout={800}>
<Paper elevation={0} sx={{
p: 4,
mb: 4,
background: 'linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%)',
border: '1px solid rgba(16, 185, 129, 0.2)',
borderRadius: 3
}}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<CheckCircle sx={{ color: 'success.main', fontSize: 32 }} />
<Typography variant="h4" color="success.main" sx={{ fontWeight: 600 }}>
Setup Summary
</Typography>
</Box>
<Chip
label={`${unlockedCapabilities.length}/${capabilities.length} Capabilities Unlocked`}
color="success"
variant="filled"
icon={<LockOpen />}
/>
</Box>
<Grid container spacing={3}>
{/* Configured Providers */}
<Grid item xs={12} md={6}>
<Card elevation={0} sx={{ background: 'rgba(255, 255, 255, 0.7)', borderRadius: 2 }}>
<CardContent>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Security sx={{ color: 'primary.main' }} />
AI Providers
</Typography>
<List dense>
{getConfiguredProviders().map((provider, index) => (
<ListItem key={index} sx={{ px: 0 }}>
<ListItemIcon sx={{ minWidth: 36 }}>
<CheckCircle sx={{ color: 'success.main', fontSize: 20 }} />
</ListItemIcon>
<ListItemText
primary={provider.name}
secondary="API key configured"
/>
</ListItem>
))}
</List>
</CardContent>
</Card>
</Grid>
{/* Quick Stats */}
<Grid item xs={12} md={6}>
<Card elevation={0} sx={{ background: 'rgba(255, 255, 255, 0.7)', borderRadius: 2 }}>
<CardContent>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<TrendingUp sx={{ color: 'primary.main' }} />
Quick Stats
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2">AI Providers:</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{Object.keys(onboardingData.apiKeys).length} configured
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2">Capabilities:</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{unlockedCapabilities.length} unlocked
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2">Missing:</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, color: missingRequirements.length > 0 ? 'warning.main' : 'success.main' }}>
{missingRequirements.length} requirements
</Typography>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
</Grid>
</Paper>
</Zoom>
{/* Detailed Configuration Review */}
<Zoom in={true} timeout={1000}>
<Paper elevation={0} sx={{
p: 4,
mb: 4,
background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)',
border: '1px solid rgba(59, 130, 246, 0.2)',
borderRadius: 3
}}>
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<Settings sx={{ color: 'primary.main' }} />
Configuration Details
</Typography>
<Grid container spacing={3}>
{/* API Keys Section */}
<Grid item xs={12} md={6}>
<Accordion
expanded={expandedSection === 'api-keys'}
onChange={() => setExpandedSection(expandedSection === 'api-keys' ? null : 'api-keys')}
sx={{ background: 'rgba(255, 255, 255, 0.8)', borderRadius: 2 }}
>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
<Security sx={{ color: 'primary.main' }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
API Keys ({Object.keys(onboardingData.apiKeys).length} configured)
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{Object.entries(onboardingData.apiKeys).map(([provider, key]) => (
<Box key={provider} sx={{
p: 2,
border: '1px solid rgba(0,0,0,0.1)',
borderRadius: 1,
background: 'rgba(255,255,255,0.5)'
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, textTransform: 'capitalize' }}>
{provider}
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Tooltip title={showApiKeys ? 'Hide key' : 'Show key'}>
<IconButton
size="small"
onClick={() => setShowApiKeys(!showApiKeys)}
>
{showApiKeys ? <VisibilityOff /> : <Visibility />}
</IconButton>
</Tooltip>
</Box>
</Box>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
{showApiKeys ? key : '••••••••••••••••••••••••••••••••'}
</Typography>
</Box>
))}
</Box>
</AccordionDetails>
</Accordion>
</Grid>
{/* Website Configuration */}
<Grid item xs={12} md={6}>
<Accordion
expanded={expandedSection === 'website'}
onChange={() => setExpandedSection(expandedSection === 'website' ? null : 'website')}
sx={{ background: 'rgba(255, 255, 255, 0.8)', borderRadius: 2 }}
>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
<Web sx={{ color: 'primary.main' }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Website Analysis
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
{onboardingData.websiteUrl ? (
<Box>
<Typography variant="body2" sx={{ mb: 2 }}>
<strong>URL:</strong> {onboardingData.websiteUrl}
</Typography>
{onboardingData.styleAnalysis && (
<Typography variant="body2" color="success.main">
Style analysis completed
</Typography>
)}
</Box>
) : (
<Typography variant="body2" color="warning.main">
No website URL configured
</Typography>
)}
</AccordionDetails>
</Accordion>
</Grid>
{/* Research Preferences */}
<Grid item xs={12} md={6}>
<Accordion
expanded={expandedSection === 'research'}
onChange={() => setExpandedSection(expandedSection === 'research' ? null : 'research')}
sx={{ background: 'rgba(255, 255, 255, 0.8)', borderRadius: 2 }}
>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
<TrendingUp sx={{ color: 'primary.main' }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Research Configuration
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
{onboardingData.researchPreferences ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant="body2">
<strong>Depth:</strong> {onboardingData.researchPreferences.research_depth}
</Typography>
<Typography variant="body2">
<strong>Content Types:</strong> {onboardingData.researchPreferences.content_types?.join(', ')}
</Typography>
<Typography variant="body2">
<strong>Auto Research:</strong> {onboardingData.researchPreferences.auto_research ? 'Enabled' : 'Disabled'}
</Typography>
</Box>
) : (
<Typography variant="body2" color="warning.main">
Research preferences not configured
</Typography>
)}
</AccordionDetails>
</Accordion>
</Grid>
{/* Personalization Settings */}
<Grid item xs={12} md={6}>
<Accordion
expanded={expandedSection === 'personalization'}
onChange={() => setExpandedSection(expandedSection === 'personalization' ? null : 'personalization')}
sx={{ background: 'rgba(255, 255, 255, 0.8)', borderRadius: 2 }}
>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
<Psychology sx={{ color: 'primary.main' }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Personalization
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
{onboardingData.personalizationSettings ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant="body2">
<strong>Style:</strong> {onboardingData.personalizationSettings.writing_style}
</Typography>
<Typography variant="body2">
<strong>Tone:</strong> {onboardingData.personalizationSettings.tone}
</Typography>
<Typography variant="body2">
<strong>Brand Voice:</strong> {onboardingData.personalizationSettings.brand_voice}
</Typography>
</Box>
) : (
<Typography variant="body2" color="warning.main">
Personalization not configured
</Typography>
)}
</AccordionDetails>
</Accordion>
</Grid>
</Grid>
</Paper>
</Zoom>
{/* Capabilities Overview */}
<Zoom in={true} timeout={1200}>
<Paper elevation={0} sx={{
p: 4,
mb: 4,
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
border: '1px solid rgba(245, 158, 11, 0.2)',
borderRadius: 3
}}>
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<Star sx={{ color: 'warning.main' }} />
Capabilities Overview
</Typography>
<Grid container spacing={2}>
{capabilities.map((capability) => (
<Grid item xs={12} sm={6} md={4} key={capability.id}>
<Card elevation={0} sx={{
background: capability.unlocked ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.05)',
border: `1px solid ${capability.unlocked ? 'rgba(16, 185, 129, 0.3)' : 'rgba(0, 0, 0, 0.1)'}`,
borderRadius: 2,
opacity: capability.unlocked ? 1 : 0.6
}}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Box sx={{
width: 40,
height: 40,
borderRadius: '50%',
background: capability.unlocked
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
: 'linear-gradient(135deg, #6b7280 0%, #4b5563 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
{React.cloneElement(capability.icon, {
sx: { color: 'white', fontSize: 20 }
})}
</Box>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
{capability.title}
{capability.unlocked ? (
<CheckCircle sx={{ color: 'success.main', fontSize: 16 }} />
) : (
<Lock sx={{ color: 'text.secondary', fontSize: 16 }} />
)}
</Typography>
</Box>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{capability.description}
</Typography>
{!capability.unlocked && capability.required && (
<Box>
<Typography variant="caption" color="text.secondary">
Requires: {capability.required.join(', ')}
</Typography>
</Box>
)}
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Paper>
</Zoom>
{/* Missing Requirements Warning */}
{missingRequirements.length > 0 && (
<Zoom in={true} timeout={1400}>
<Alert
severity="warning"
sx={{ mb: 4, borderRadius: 2 }}
action={
<Button color="inherit" size="small">
Configure Now
</Button>
}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Missing Requirements
</Typography>
<Typography variant="body2">
The following items are recommended for optimal experience: {missingRequirements.join(', ')}
</Typography>
</Alert>
</Zoom>
)}
{/* Alerts */}
<Box sx={{ mt: 3 }}>
{error && (
<Fade in={true}>
<Alert
severity="error"
sx={{ mb: 2, borderRadius: 2 }}
action={
<Button
color="inherit"
size="small"
onClick={() => setError(null)}
>
Dismiss
</Button>
}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Setup Incomplete
</Typography>
<Typography variant="body2">
{error}
</Typography>
</Alert>
</Fade>
)}
</Box>
{/* Action Button */}
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<OnboardingButton
variant="primary"
onClick={handleLaunch}
loading={loading}
size="large"
icon={<Rocket />}
disabled={Object.keys(onboardingData.apiKeys).length === 0}
>
Launch Alwrity & Complete Setup
</OnboardingButton>
</Box>
{/* Help Text */}
<Box sx={{ mt: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
This will complete your onboarding and launch Alwrity with your configured settings.
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
<Star sx={{ fontSize: 16 }} />
Ready to create amazing content with AI-powered assistance
</Typography>
</Box>
</React.Fragment>
)}
</Container>
</Fade>
);
};
export default FinalStep;

View File

@@ -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<FinalStepProps> = ({ onContinue, updateHeaderContent }) => {
const [loading, setLoading] = useState(false);
const [dataLoading, setDataLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [onboardingData, setOnboardingData] = useState<OnboardingData>({
apiKeys: {}
});
const [expandedSection, setExpandedSection] = useState<string | null>('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: <CheckCircle />,
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: <CheckCircle />,
unlocked: !!onboardingData.websiteUrl,
required: ['Website URL']
},
{
id: 'research-tools',
title: 'AI Research Tools',
description: 'Automated research and fact-checking capabilities',
icon: <CheckCircle />,
unlocked: !!onboardingData.researchPreferences,
required: ['Research Configuration']
},
{
id: 'personalization',
title: 'Content Personalization',
description: 'Tailored content based on your brand voice and preferences',
icon: <CheckCircle />,
unlocked: !!onboardingData.personalizationSettings,
required: ['Personalization Settings']
},
{
id: 'integrations',
title: 'Third-party Integrations',
description: 'Connect with external tools and platforms',
icon: <CheckCircle />,
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 (
<Fade in={true} timeout={500}>
<Container maxWidth="lg" sx={{ py: 2 }}>
{/* Loading State */}
{dataLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 8 }}>
<Box sx={{ textAlign: 'center' }}>
<CircularProgress size={60} sx={{ mb: 2 }} />
<Typography variant="h6" sx={{ mb: 1 }}>
Loading your configuration...
</Typography>
<Typography variant="body2" color="text.secondary">
Retrieving your onboarding data and settings
</Typography>
</Box>
</Box>
)}
{/* Content - Only show when data is loaded */}
{!dataLoading && (
<React.Fragment>
{/* Setup Summary */}
<SetupSummary
onboardingData={onboardingData}
capabilities={capabilities}
expandedSection={expandedSection}
setExpandedSection={setExpandedSection}
/>
{/* Capabilities Overview */}
<CapabilitiesOverview capabilities={capabilities} />
{/* Missing Requirements Warning */}
{missingRequirements.length > 0 && (
<Zoom in={true} timeout={1400}>
<Alert
severity="warning"
sx={{ mb: 4, borderRadius: 2 }}
action={
<Button color="inherit" size="small">
Configure Now
</Button>
}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Missing Requirements
</Typography>
<Typography variant="body2">
The following items are recommended for optimal experience: {missingRequirements.join(', ')}
</Typography>
</Alert>
</Zoom>
)}
{/* Alerts */}
<Box sx={{ mt: 3 }}>
{error && (
<Fade in={true}>
<Alert
severity="error"
sx={{ mb: 2, borderRadius: 2 }}
action={
<Button
color="inherit"
size="small"
onClick={() => setError(null)}
>
Dismiss
</Button>
}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Setup Incomplete
</Typography>
<Typography variant="body2">
{error}
</Typography>
</Alert>
</Fade>
)}
</Box>
{/* Action Button */}
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<OnboardingButton
variant="primary"
onClick={handleLaunch}
loading={loading}
size="large"
icon={<Rocket />}
disabled={Object.keys(onboardingData.apiKeys).length === 0}
>
Launch Alwrity & Complete Setup
</OnboardingButton>
</Box>
{/* Help Text */}
<Box sx={{ mt: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
This will complete your onboarding and launch Alwrity with your configured settings.
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
<Star sx={{ fontSize: 16 }} />
Ready to create amazing content with AI-powered assistance
</Typography>
</Box>
</React.Fragment>
)}
</Container>
</Fade>
);
};
export default FinalStep;

View File

@@ -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<string, string>;
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';
<FinalStep
onContinue={handleContinue}
updateHeaderContent={updateHeaderContent}
/>
```
## 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

View File

@@ -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<CapabilitiesOverviewProps> = ({ capabilities }) => {
return (
<Zoom in={true} timeout={1200}>
<Paper elevation={0} sx={{
p: 4,
mb: 4,
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
border: '1px solid rgba(245, 158, 11, 0.2)',
borderRadius: 3
}}>
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1, color: '#000000 !important' }}>
<Star sx={{ color: 'warning.main' }} />
Capabilities Overview
</Typography>
<Grid container spacing={2}>
{capabilities.map((capability) => (
<Grid item xs={12} sm={6} md={4} key={capability.id}>
<Card elevation={0} sx={{
background: capability.unlocked ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.05)',
border: `1px solid ${capability.unlocked ? 'rgba(16, 185, 129, 0.3)' : 'rgba(0, 0, 0, 0.1)'}`,
borderRadius: 2,
opacity: capability.unlocked ? 1 : 0.6
}}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Box sx={{
width: 40,
height: 40,
borderRadius: '50%',
background: capability.unlocked
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
: 'linear-gradient(135deg, #6b7280 0%, #4b5563 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
{React.cloneElement(capability.icon, {
sx: { color: 'white', fontSize: 20 }
})}
</Box>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#000000 !important' }}>
{capability.title}
{capability.unlocked ? (
<CheckCircle sx={{ color: 'success.main', fontSize: 16 }} />
) : (
<Lock sx={{ color: '#666666 !important', fontSize: 16 }} />
)}
</Typography>
</Box>
</Box>
<Typography variant="body2" sx={{ mb: 2, color: '#000000 !important' }}>
{capability.description}
</Typography>
{!capability.unlocked && capability.required && (
<Box>
<Typography variant="caption" sx={{ color: '#000000 !important' }}>
Requires: {capability.required.join(', ')}
</Typography>
</Box>
)}
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Paper>
</Zoom>
);
};
export default CapabilitiesOverview;

View File

@@ -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<SetupSummaryProps> = ({
onboardingData,
capabilities,
expandedSection,
setExpandedSection
}) => {
const [showApiKeys, setShowApiKeys] = useState(false);
const unlockedCapabilities = capabilities.filter(cap => cap.unlocked);
return (
<Zoom in={true} timeout={800}>
<Paper elevation={0} sx={{
p: 4,
mb: 4,
background: 'linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%)',
border: '1px solid rgba(16, 185, 129, 0.2)',
borderRadius: 3
}}>
{/* Header with Stats Chips */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 4, flexWrap: 'wrap', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<CheckCircle sx={{ color: 'success.main', fontSize: 32 }} />
<Typography variant="h4" color="success.main" sx={{ fontWeight: 600 }}>
Setup Summary
</Typography>
</Box>
{/* Stats Chips */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<Chip
label={`${Object.keys(onboardingData.apiKeys).length} AI Providers`}
color="primary"
variant="filled"
size="small"
icon={<Security />}
/>
<Chip
label={`${unlockedCapabilities.length}/${capabilities.length} Capabilities`}
color="success"
variant="filled"
size="small"
icon={<LockOpen />}
/>
<Chip
label="1 Missing"
color="warning"
variant="filled"
size="small"
/>
</Box>
</Box>
{/* Main Content Grid - Compact Single Card */}
<Grid container spacing={3}>
{/* Configuration Details Card */}
<Grid item xs={12}>
<Card elevation={0} sx={{ background: 'rgba(255, 255, 255, 0.9)', borderRadius: 2 }}>
<CardContent sx={{ p: 3 }}>
{/* Configuration Details Header - Updated for readability */}
<Typography variant="h6" sx={{
fontWeight: 600,
mb: 3,
display: 'flex',
alignItems: 'center',
gap: 1,
color: '#000000 !important'
}}>
<Settings sx={{ color: 'primary.main' }} />
Configuration Details
</Typography>
<Grid container spacing={2}>
{/* API Keys */}
<Grid item xs={6} sm={3}>
<Box
sx={{
p: 2,
border: '1px solid rgba(0,0,0,0.1)',
borderRadius: 1,
background: 'rgba(255,255,255,0.5)',
cursor: 'pointer',
'&:hover': { background: 'rgba(255,255,255,0.7)' }
}}
onClick={() => setExpandedSection(expandedSection === 'api-keys' ? null : 'api-keys')}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Security sx={{ color: 'primary.main', fontSize: 18 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#000000' }}>
API Keys
</Typography>
</Box>
<Typography variant="body2" sx={{ color: '#000000' }}>
{Object.keys(onboardingData.apiKeys).length} configured
</Typography>
</Box>
</Grid>
{/* Website Analysis */}
<Grid item xs={6} sm={3}>
<Box
sx={{
p: 2,
border: '1px solid rgba(0,0,0,0.1)',
borderRadius: 1,
background: 'rgba(255,255,255,0.5)',
cursor: 'pointer',
'&:hover': { background: 'rgba(255,255,255,0.7)' }
}}
onClick={() => setExpandedSection(expandedSection === 'website' ? null : 'website')}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Web sx={{ color: 'primary.main', fontSize: 18 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#000000' }}>
Website Analysis
</Typography>
</Box>
<Typography variant="body2" sx={{ color: '#000000' }}>
{onboardingData.websiteUrl ? 'Configured' : 'Not set'}
</Typography>
</Box>
</Grid>
{/* Research Configuration */}
<Grid item xs={6} sm={3}>
<Box
sx={{
p: 2,
border: '1px solid rgba(0,0,0,0.1)',
borderRadius: 1,
background: 'rgba(255,255,255,0.5)',
cursor: 'pointer',
'&:hover': { background: 'rgba(255,255,255,0.7)' }
}}
onClick={() => setExpandedSection(expandedSection === 'research' ? null : 'research')}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<TrendingUp sx={{ color: 'primary.main', fontSize: 18 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#000000' }}>
Research Config
</Typography>
</Box>
<Typography variant="body2" sx={{ color: '#000000' }}>
{onboardingData.researchPreferences ? 'Configured' : 'Not set'}
</Typography>
</Box>
</Grid>
{/* Personalization */}
<Grid item xs={6} sm={3}>
<Box
sx={{
p: 2,
border: '1px solid rgba(0,0,0,0.1)',
borderRadius: 1,
background: 'rgba(255,255,255,0.5)',
cursor: 'pointer',
'&:hover': { background: 'rgba(255,255,255,0.7)' }
}}
onClick={() => setExpandedSection(expandedSection === 'personalization' ? null : 'personalization')}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Psychology sx={{ color: 'primary.main', fontSize: 18 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#000000' }}>
Personalization
</Typography>
</Box>
<Typography variant="body2" sx={{ color: '#000000' }}>
{onboardingData.personalizationSettings ? 'Configured' : 'Not set'}
</Typography>
</Box>
</Grid>
</Grid>
{/* Expandable Details */}
{(expandedSection === 'api-keys' || expandedSection === 'website' || expandedSection === 'research' || expandedSection === 'personalization') && (
<Box sx={{ mt: 3 }}>
<Paper elevation={0} sx={{
background: 'rgba(255, 255, 255, 0.9)',
borderRadius: 2,
p: 3
}}>
{/* API Keys Details */}
{expandedSection === 'api-keys' && (
<Box>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1, color: '#000000' }}>
<Security sx={{ color: 'primary.main' }} />
API Keys ({Object.keys(onboardingData.apiKeys).length} configured)
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{Object.entries(onboardingData.apiKeys).map(([provider, key]) => (
<Box key={provider} sx={{
p: 2,
border: '1px solid rgba(0,0,0,0.1)',
borderRadius: 1,
background: 'rgba(255,255,255,0.5)'
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, textTransform: 'capitalize' }}>
{provider}
</Typography>
<Tooltip title={showApiKeys ? 'Hide key' : 'Show key'}>
<IconButton
size="small"
onClick={() => setShowApiKeys(!showApiKeys)}
>
{showApiKeys ? <VisibilityOff /> : <Visibility />}
</IconButton>
</Tooltip>
</Box>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
{showApiKeys ? key : '••••••••••••••••••••••••••••••••'}
</Typography>
</Box>
))}
</Box>
</Box>
)}
{/* Website Analysis Details */}
{expandedSection === 'website' && (
<Box>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1, color: '#000000' }}>
<Web sx={{ color: 'primary.main' }} />
Website Analysis
</Typography>
{onboardingData.websiteUrl ? (
<Box>
<Typography variant="body2" sx={{ mb: 2 }}>
<strong>URL:</strong> {onboardingData.websiteUrl}
</Typography>
{onboardingData.styleAnalysis && (
<Typography variant="body2" color="success.main">
Style analysis completed
</Typography>
)}
</Box>
) : (
<Typography variant="body2" color="warning.main">
No website URL configured
</Typography>
)}
</Box>
)}
{/* Research Configuration Details */}
{expandedSection === 'research' && (
<Box>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1, color: '#000000' }}>
<TrendingUp sx={{ color: 'primary.main' }} />
Research Configuration
</Typography>
{onboardingData.researchPreferences ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant="body2">
<strong>Depth:</strong> {onboardingData.researchPreferences.research_depth}
</Typography>
<Typography variant="body2">
<strong>Content Types:</strong> {onboardingData.researchPreferences.content_types?.join(', ')}
</Typography>
<Typography variant="body2">
<strong>Auto Research:</strong> {onboardingData.researchPreferences.auto_research ? 'Enabled' : 'Disabled'}
</Typography>
</Box>
) : (
<Typography variant="body2" color="warning.main">
Research preferences not configured
</Typography>
)}
</Box>
)}
{/* Personalization Details */}
{expandedSection === 'personalization' && (
<Box>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1, color: '#000000' }}>
<Psychology sx={{ color: 'primary.main' }} />
Personalization
</Typography>
{onboardingData.personalizationSettings ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant="body2">
<strong>Style:</strong> {onboardingData.personalizationSettings.writing_style}
</Typography>
<Typography variant="body2">
<strong>Tone:</strong> {onboardingData.personalizationSettings.tone}
</Typography>
<Typography variant="body2">
<strong>Brand Voice:</strong> {onboardingData.personalizationSettings.brand_voice}
</Typography>
</Box>
) : (
<Typography variant="body2" color="warning.main">
Personalization not configured
</Typography>
)}
</Box>
)}
</Paper>
</Box>
)}
</CardContent>
</Card>
</Grid>
</Grid>
</Paper>
</Zoom>
);
};
export default SetupSummary;

View File

@@ -0,0 +1,3 @@
export { default as SetupSummary } from './SetupSummary';
export { default as CapabilitiesOverview } from './CapabilitiesOverview';

View File

@@ -0,0 +1,3 @@
export { default } from './FinalStep';
export { default as SetupSummary } from './components/SetupSummary';
export { default as CapabilitiesOverview } from './components/CapabilitiesOverview';

View File

@@ -0,0 +1,22 @@
export interface OnboardingData {
apiKeys: Record<string, string>;
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;
}

View File

@@ -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<PersonaStepProps> = ({
onContinue,
updateHeaderContent,
onValidationChange,
onboardingData = {},
stepData
}) => {
@@ -325,6 +327,23 @@ const PersonaStep: React.FC<PersonaStepProps> = ({
}
}, [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:', {

View File

@@ -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<WebsiteStepProps> = ({ onContinue, updateHeaderContent }) => {
const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderContent, onValidationChange }) => {
const [website, setWebsite] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -178,6 +179,16 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ 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<WebsiteStepProps> = ({ onContinue, updateHeaderConte
useAnalysisForGenAI={useAnalysisForGenAI}
onUseAnalysisChange={setUseAnalysisForGenAI}
/>
{/* Continue Button */}
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
<Button
variant="contained"
size="large"
onClick={handleContinue}
disabled={loading}
sx={{
px: 4,
py: 1.5,
fontSize: '1.1rem',
fontWeight: 600,
borderRadius: 2,
boxShadow: '0 4px 14px rgba(25, 118, 210, 0.4)',
'&:hover': {
boxShadow: '0 6px 20px rgba(25, 118, 210, 0.6)',
transform: 'translateY(-2px)'
},
transition: 'all 0.2s ease-in-out'
}}
>
Continue to Next Step
</Button>
</Box>
</Box>
)}

View File

@@ -101,18 +101,18 @@ const TargetAudienceAnalysisSection: React.FC<TargetAudienceAnalysisSectionProps
<PsychologyIcon />
</Box>
<Box flex={1}>
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
<Typography variant="subtitle2" sx={{ color: '#1a202c !important', fontWeight: 600 }} gutterBottom>
Psychographic Profile
</Typography>
<Box component="ul" sx={styles.analysisList}>
{Array.isArray(targetAudience.psychographic_profile)
? targetAudience.psychographic_profile.map((item: string, index: number) => (
<Typography component="li" variant="body2" key={index} sx={styles.analysisListItem}>
<Typography component="li" variant="body2" key={index} sx={{ ...styles.analysisListItem, color: '#1a202c !important' }}>
{item}
</Typography>
))
: (
<Typography component="li" variant="body2" sx={styles.analysisListItem}>
<Typography component="li" variant="body2" sx={{ ...styles.analysisListItem, color: '#1a202c !important' }}>
{targetAudience.psychographic_profile}
</Typography>
)}
@@ -131,12 +131,12 @@ const TargetAudienceAnalysisSection: React.FC<TargetAudienceAnalysisSectionProps
<WarningIcon />
</Box>
<Box flex={1}>
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
<Typography variant="subtitle2" sx={{ color: '#1a202c !important', fontWeight: 600 }} gutterBottom>
Pain Points
</Typography>
<Box component="ul" sx={styles.analysisList}>
{targetAudience.pain_points.map((painPoint: string, index: number) => (
<Typography component="li" variant="body2" key={index} sx={styles.analysisListItem}>
<Typography component="li" variant="body2" key={index} sx={{ ...styles.analysisListItem, color: '#1a202c !important' }}>
{painPoint}
</Typography>
))}
@@ -155,12 +155,12 @@ const TargetAudienceAnalysisSection: React.FC<TargetAudienceAnalysisSectionProps
<TrendingUpIcon />
</Box>
<Box flex={1}>
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
<Typography variant="subtitle2" sx={{ color: '#1a202c !important', fontWeight: 600 }} gutterBottom>
Motivations
</Typography>
<Box component="ul" sx={styles.analysisList}>
{targetAudience.motivations.map((motivation: string, index: number) => (
<Typography component="li" variant="body2" key={index} sx={styles.analysisListItem}>
<Typography component="li" variant="body2" key={index} sx={{ ...styles.analysisListItem, color: '#1a202c !important' }}>
{motivation}
</Typography>
))}

View File

@@ -66,7 +66,7 @@ const KeyInsightCard: React.FC<KeyInsightProps> = ({
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<KeyInsightProps> = ({
: 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<KeyInsightProps> = ({
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<KeyInsightProps> = ({
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}

View File

@@ -49,6 +49,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
const [stepData, setStepData] = useState<any>(null);
const [competitorDataCollector, setCompetitorDataCollector] = useState<(() => any) | null>(null);
const [isCurrentStepValid, setIsCurrentStepValid] = useState<boolean>(false);
const [stepValidationStates, setStepValidationStates] = useState<Record<number, boolean>>({});
const [stepHeaderContent, setStepHeaderContent] = useState<StepHeaderContent>({
title: steps[0].label,
description: steps[0].description
@@ -61,18 +62,24 @@ const Wizard: React.FC<WizardProps> = ({ 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<WizardProps> = ({ 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<WizardProps> = ({ 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<WizardProps> = ({ 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<WizardProps> = ({ onComplete }) => {
const renderStepContent = (step: number) => {
const stepComponents = [
<ApiKeyStep key="api-keys" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
<WebsiteStep key="website" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
<ApiKeyStep key="api-keys" onContinue={handleNext} updateHeaderContent={updateHeaderContent} onValidationChange={handleApiKeyValidationChange} />,
<WebsiteStep key="website" onContinue={handleNext} updateHeaderContent={updateHeaderContent} onValidationChange={(isValid) => handleStepValidationChange(1, isValid)} />,
<CompetitorAnalysisStep
key="research"
onContinue={handleNext}
@@ -530,6 +571,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
key="personalization"
onContinue={handleNext}
updateHeaderContent={updateHeaderContent}
onValidationChange={(isValid) => handleStepValidationChange(3, isValid)}
onboardingData={personaOnboardingData}
stepData={personaStepData}
/>,