ALwrity onboarding final step
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -202,3 +202,7 @@ yarn.lock
|
|||||||
|
|
||||||
# Documentation 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
|
||||||
|
|||||||
@@ -165,10 +165,10 @@ class OnboardingManager:
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@self.app.post("/api/onboarding/api-keys")
|
@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."""
|
"""Save an API key for a provider."""
|
||||||
try:
|
try:
|
||||||
return await save_api_key(request)
|
return await save_api_key(request, current_user)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in api_key_save: {e}")
|
logger.error(f"Error in api_key_save: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ class RouterManager:
|
|||||||
from routers.error_logging import router as error_logging_router
|
from routers.error_logging import router as error_logging_router
|
||||||
self.include_router_safely(error_logging_router, "error_logging")
|
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")
|
logger.info("✅ Core routers included successfully")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,20 @@ class APIKeyManagementService:
|
|||||||
"""Service for handling API key management operations."""
|
"""Service for handling API key management operations."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
# Initialize APIKeyManager with database support
|
||||||
self.api_key_manager = APIKeyManager()
|
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
|
# Simple cache for API keys
|
||||||
self._api_keys_cache = None
|
self._api_keys_cache = None
|
||||||
self._cache_timestamp = 0
|
self._cache_timestamp = 0
|
||||||
@@ -75,9 +88,16 @@ class APIKeyManagementService:
|
|||||||
logger.error(f"Error getting API keys for onboarding: {str(e)}")
|
logger.error(f"Error getting API keys for onboarding: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
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."""
|
"""Save an API key for a provider."""
|
||||||
try:
|
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)
|
success = self.api_key_manager.save_api_key(provider, api_key)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ async def get_api_keys_for_onboarding():
|
|||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
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:
|
try:
|
||||||
from api.onboarding_utils.api_key_management_service import APIKeyManagementService
|
from api.onboarding_utils.api_key_management_service import APIKeyManagementService
|
||||||
api_service = 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:
|
except Exception as e:
|
||||||
logger.error(f"Error saving API key: {str(e)}")
|
logger.error(f"Error saving API key: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|||||||
@@ -110,6 +110,16 @@ async def rate_limit_middleware(request: Request, call_next):
|
|||||||
"""Rate limiting middleware using modular utilities."""
|
"""Rate limiting middleware using modular utilities."""
|
||||||
return await rate_limiter.rate_limit_middleware(request, call_next)
|
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
|
# Health check endpoints using modular utilities
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
|
|||||||
26
backend/database/migrations/add_persona_data_table.sql
Normal file
26
backend/database/migrations/add_persona_data_table.sql
Normal 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';
|
||||||
@@ -22,3 +22,6 @@ WORDPRESS_REDIRECT_URI=
|
|||||||
|
|
||||||
# Development Settings
|
# Development Settings
|
||||||
DISABLE_AUTH=false
|
DISABLE_AUTH=false
|
||||||
|
|
||||||
|
# local development
|
||||||
|
DEPLOY_ENV=local
|
||||||
|
|||||||
114
backend/middleware/api_key_injection_middleware.py
Normal file
114
backend/middleware/api_key_injection_middleware.py
Normal 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)
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ class OnboardingSession(Base):
|
|||||||
api_keys = relationship('APIKey', back_populates='session', cascade="all, delete-orphan")
|
api_keys = relationship('APIKey', back_populates='session', cascade="all, delete-orphan")
|
||||||
website_analyses = relationship('WebsiteAnalysis', 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)
|
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):
|
def __repr__(self):
|
||||||
return f"<OnboardingSession(id={self.id}, user_id={self.user_id}, step={self.current_step}, progress={self.progress})>"
|
return f"<OnboardingSession(id={self.id}, user_id={self.user_id}, step={self.current_step}, progress={self.progress})>"
|
||||||
@@ -144,3 +145,39 @@ class ResearchPreferences(Base):
|
|||||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
'updated_at': self.updated_at.isoformat() if self.updated_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
|
||||||
|
}
|
||||||
110
backend/routers/frontend_env_manager.py
Normal file
110
backend/routers/frontend_env_manager.py
Normal 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)}")
|
||||||
124
backend/scripts/create_persona_data_table.py
Normal file
124
backend/scripts/create_persona_data_table.py
Normal 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)
|
||||||
|
|
||||||
338
backend/scripts/verify_onboarding_data.py
Normal file
338
backend/scripts/verify_onboarding_data.py
Normal 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)
|
||||||
|
|
||||||
@@ -35,14 +35,31 @@ class StepData:
|
|||||||
class OnboardingProgress:
|
class OnboardingProgress:
|
||||||
"""Manages onboarding progress with persistence and validation."""
|
"""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.steps = self._initialize_steps()
|
||||||
self.current_step = 1
|
self.current_step = 1
|
||||||
self.started_at = datetime.now().isoformat()
|
self.started_at = datetime.now().isoformat()
|
||||||
self.last_updated = datetime.now().isoformat()
|
self.last_updated = datetime.now().isoformat()
|
||||||
self.is_completed = False
|
self.is_completed = False
|
||||||
self.completed_at = None
|
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
|
# Load existing progress if available
|
||||||
self.load_progress()
|
self.load_progress()
|
||||||
@@ -192,8 +209,9 @@ class OnboardingProgress:
|
|||||||
logger.info("Onboarding completed successfully")
|
logger.info("Onboarding completed successfully")
|
||||||
|
|
||||||
def save_progress(self):
|
def save_progress(self):
|
||||||
"""Save progress to file."""
|
"""Save progress to both file and database (dual persistence)."""
|
||||||
try:
|
try:
|
||||||
|
# Save to JSON file (backward compatibility)
|
||||||
progress_data = {
|
progress_data = {
|
||||||
"steps": [{
|
"steps": [{
|
||||||
"step_number": step.step_number,
|
"step_number": step.step_number,
|
||||||
@@ -215,6 +233,65 @@ class OnboardingProgress:
|
|||||||
json.dump(progress_data, f, indent=2)
|
json.dump(progress_data, f, indent=2)
|
||||||
|
|
||||||
logger.debug(f"Progress saved to {self.progress_file}")
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error saving progress: {str(e)}")
|
logger.error(f"Error saving progress: {str(e)}")
|
||||||
|
|
||||||
@@ -423,8 +500,34 @@ class APIKeyManager:
|
|||||||
try:
|
try:
|
||||||
if provider in self.api_keys:
|
if provider in self.api_keys:
|
||||||
self.api_keys[provider] = api_key
|
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
|
return True
|
||||||
else:
|
else:
|
||||||
logger.error(f"Unknown provider: {provider}")
|
logger.error(f"Unknown provider: {provider}")
|
||||||
@@ -490,8 +593,50 @@ class APIKeyManager:
|
|||||||
"total_providers": len(self.api_keys)
|
"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):
|
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:
|
try:
|
||||||
env_mapping = {
|
env_mapping = {
|
||||||
"openai": "OPENAI_API_KEY",
|
"openai": "OPENAI_API_KEY",
|
||||||
@@ -513,11 +658,10 @@ class APIKeyManager:
|
|||||||
os.environ[env_var] = api_key
|
os.environ[env_var] = api_key
|
||||||
|
|
||||||
# Update .env file - use backend directory path
|
# Update .env file - use backend directory path
|
||||||
import os
|
|
||||||
backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
env_path = os.path.join(backend_dir, ".env")
|
env_path = os.path.join(backend_dir, ".env")
|
||||||
if os.path.exists(env_path):
|
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()
|
lines = f.readlines()
|
||||||
else:
|
else:
|
||||||
lines = []
|
lines = []
|
||||||
@@ -532,13 +676,23 @@ class APIKeyManager:
|
|||||||
updated_lines.append(line)
|
updated_lines.append(line)
|
||||||
|
|
||||||
if not key_found:
|
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")
|
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)
|
f.writelines(updated_lines)
|
||||||
|
|
||||||
# Reload environment variables
|
# Reload environment variables into current process
|
||||||
load_dotenv(override=True)
|
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}")
|
logger.debug(f"API key saved to .env file for {provider}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -555,13 +709,17 @@ def get_onboarding_progress() -> OnboardingProgress:
|
|||||||
return get_onboarding_progress._instance
|
return get_onboarding_progress._instance
|
||||||
|
|
||||||
def get_onboarding_progress_for_user(user_id: str) -> OnboardingProgress:
|
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
|
global _user_onboarding_progress_cache
|
||||||
safe_user_id = ''.join([c if c.isalnum() or c in ('-', '_') else '_' for c in str(user_id)])
|
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:
|
if safe_user_id in _user_onboarding_progress_cache:
|
||||||
return _user_onboarding_progress_cache[safe_user_id]
|
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"
|
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
|
_user_onboarding_progress_cache[safe_user_id] = instance
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|||||||
418
backend/services/onboarding_database_service.py
Normal file
418
backend/services/onboarding_database_service.py
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
150
backend/services/user_api_key_context.py
Normal file
150
backend/services/user_api_key_context.py
Normal 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')
|
||||||
|
|
||||||
370
docs/API_KEY_FLOW_DIAGRAM.md
Normal file
370
docs/API_KEY_FLOW_DIAGRAM.md
Normal 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! 🚀
|
||||||
|
|
||||||
326
docs/API_KEY_INJECTION_EXPLAINED.md
Normal file
326
docs/API_KEY_INJECTION_EXPLAINED.md
Normal 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! 🎉
|
||||||
|
|
||||||
349
docs/API_KEY_MANAGEMENT_ARCHITECTURE.md
Normal file
349
docs/API_KEY_MANAGEMENT_ARCHITECTURE.md
Normal 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
|
||||||
|
|
||||||
299
docs/API_KEY_QUICK_REFERENCE.md
Normal file
299
docs/API_KEY_QUICK_REFERENCE.md
Normal 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! 🎉
|
||||||
|
|
||||||
264
docs/CRITICAL_ONBOARDING_DATABASE_MIGRATION.md
Normal file
264
docs/CRITICAL_ONBOARDING_DATABASE_MIGRATION.md
Normal 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.**
|
||||||
|
|
||||||
489
docs/EXAMPLES_USER_API_KEYS.md
Normal file
489
docs/EXAMPLES_USER_API_KEYS.md
Normal 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
|
||||||
|
|
||||||
215
docs/PERSONA_DATA_MIGRATION_GUIDE.md
Normal file
215
docs/PERSONA_DATA_MIGRATION_GUIDE.md
Normal 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`
|
||||||
|
|
||||||
@@ -21,9 +21,10 @@ import ApiKeySidebar from './ApiKeyStep/utils/ApiKeySidebar';
|
|||||||
interface ApiKeyStepProps {
|
interface ApiKeyStepProps {
|
||||||
onContinue: (stepData?: any) => void;
|
onContinue: (stepData?: any) => void;
|
||||||
updateHeaderContent: (content: { title: string; description: string }) => 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 [focusedProvider, setFocusedProvider] = useState<any>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -63,11 +64,20 @@ const ApiKeyStep: React.FC<ApiKeyStepProps> = ({ onContinue, updateHeaderContent
|
|||||||
}
|
}
|
||||||
}, [updateHeaderContent, providers, currentProviderIndex]);
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Fade in={true} timeout={500}>
|
<Fade in={true} timeout={500}>
|
||||||
<Container maxWidth="lg" sx={{ py: 2 }}>
|
<Container maxWidth="lg" sx={{ py: 2 }}>
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleContinue(); }}>
|
|
||||||
{/* Main Content Layout */}
|
{/* Main Content Layout */}
|
||||||
<Grid container spacing={4} sx={{ mb: 4 }}>
|
<Grid container spacing={4} sx={{ mb: 4 }}>
|
||||||
{/* Carousel Section */}
|
{/* Carousel Section */}
|
||||||
@@ -140,43 +150,6 @@ const ApiKeyStep: React.FC<ApiKeyStepProps> = ({ onContinue, updateHeaderContent
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</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 */}
|
{/* Security Notice */}
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
@@ -201,7 +174,6 @@ const ApiKeyStep: React.FC<ApiKeyStepProps> = ({ onContinue, updateHeaderContent
|
|||||||
Your API keys are encrypted and stored securely on your device
|
Your API keys are encrypted and stored securely on your device
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</form>
|
|
||||||
</Container>
|
</Container>
|
||||||
</Fade>
|
</Fade>
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,19 @@ export const useApiKeyStep = (onContinue: (stepData?: any) => void) => {
|
|||||||
// Store CopilotKit key in localStorage for frontend use
|
// Store CopilotKit key in localStorage for frontend use
|
||||||
localStorage.setItem('copilotkit_api_key', copilotkitKey.trim());
|
localStorage.setItem('copilotkit_api_key', copilotkitKey.trim());
|
||||||
console.log('ApiKeyStep: CopilotKit key saved to localStorage for frontend CopilotKit provider');
|
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 {
|
try {
|
||||||
@@ -215,7 +228,49 @@ export const useApiKeyStep = (onContinue: (stepData?: any) => void) => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// All three keys are required
|
// 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
|
// Auto-scroll to next provider when current one is valid
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
284
frontend/src/components/OnboardingWizard/FinalStep/FinalStep.tsx
Normal file
284
frontend/src/components/OnboardingWizard/FinalStep/FinalStep.tsx
Normal 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;
|
||||||
96
frontend/src/components/OnboardingWizard/FinalStep/README.md
Normal file
96
frontend/src/components/OnboardingWizard/FinalStep/README.md
Normal 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
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as SetupSummary } from './SetupSummary';
|
||||||
|
export { default as CapabilitiesOverview } from './CapabilitiesOverview';
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default } from './FinalStep';
|
||||||
|
export { default as SetupSummary } from './components/SetupSummary';
|
||||||
|
export { default as CapabilitiesOverview } from './components/CapabilitiesOverview';
|
||||||
22
frontend/src/components/OnboardingWizard/FinalStep/types.ts
Normal file
22
frontend/src/components/OnboardingWizard/FinalStep/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { ComingSoonSection } from './PersonaStep/ComingSoonSection';
|
|||||||
interface PersonaStepProps {
|
interface PersonaStepProps {
|
||||||
onContinue: (personaData: PersonaData) => void;
|
onContinue: (personaData: PersonaData) => void;
|
||||||
updateHeaderContent: (content: StepHeaderContent) => void;
|
updateHeaderContent: (content: StepHeaderContent) => void;
|
||||||
|
onValidationChange?: (isValid: boolean) => void;
|
||||||
onboardingData?: {
|
onboardingData?: {
|
||||||
websiteAnalysis?: any;
|
websiteAnalysis?: any;
|
||||||
competitorResearch?: any;
|
competitorResearch?: any;
|
||||||
@@ -61,6 +62,7 @@ interface QualityMetrics {
|
|||||||
const PersonaStep: React.FC<PersonaStepProps> = ({
|
const PersonaStep: React.FC<PersonaStepProps> = ({
|
||||||
onContinue,
|
onContinue,
|
||||||
updateHeaderContent,
|
updateHeaderContent,
|
||||||
|
onValidationChange,
|
||||||
onboardingData = {},
|
onboardingData = {},
|
||||||
stepData
|
stepData
|
||||||
}) => {
|
}) => {
|
||||||
@@ -325,6 +327,23 @@ const PersonaStep: React.FC<PersonaStepProps> = ({
|
|||||||
}
|
}
|
||||||
}, [corePersona, platformPersonas, qualityMetrics, selectedPlatforms, onContinue]);
|
}, [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
|
// Auto-call onContinue when persona data is ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('PersonaStep: Checking persona data readiness:', {
|
console.log('PersonaStep: Checking persona data readiness:', {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
interface WebsiteStepProps {
|
interface WebsiteStepProps {
|
||||||
onContinue: (stepData?: any) => void;
|
onContinue: (stepData?: any) => void;
|
||||||
updateHeaderContent: (content: { title: string; description: string }) => void;
|
updateHeaderContent: (content: { title: string; description: string }) => void;
|
||||||
|
onValidationChange?: (isValid: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StyleAnalysis {
|
interface StyleAnalysis {
|
||||||
@@ -148,7 +149,7 @@ interface ExistingAnalysis {
|
|||||||
// MAIN COMPONENT
|
// MAIN COMPONENT
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderContent }) => {
|
const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderContent, onValidationChange }) => {
|
||||||
const [website, setWebsite] = useState('');
|
const [website, setWebsite] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -178,6 +179,16 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
|
|||||||
});
|
});
|
||||||
}, [updateHeaderContent]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
// Prefill from last session analysis on mount
|
// Prefill from last session analysis on mount
|
||||||
const loadLastAnalysis = async () => {
|
const loadLastAnalysis = async () => {
|
||||||
@@ -517,31 +528,6 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
|
|||||||
useAnalysisForGenAI={useAnalysisForGenAI}
|
useAnalysisForGenAI={useAnalysisForGenAI}
|
||||||
onUseAnalysisChange={setUseAnalysisForGenAI}
|
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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -101,18 +101,18 @@ const TargetAudienceAnalysisSection: React.FC<TargetAudienceAnalysisSectionProps
|
|||||||
<PsychologyIcon />
|
<PsychologyIcon />
|
||||||
</Box>
|
</Box>
|
||||||
<Box flex={1}>
|
<Box flex={1}>
|
||||||
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
|
<Typography variant="subtitle2" sx={{ color: '#1a202c !important', fontWeight: 600 }} gutterBottom>
|
||||||
Psychographic Profile
|
Psychographic Profile
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box component="ul" sx={styles.analysisList}>
|
<Box component="ul" sx={styles.analysisList}>
|
||||||
{Array.isArray(targetAudience.psychographic_profile)
|
{Array.isArray(targetAudience.psychographic_profile)
|
||||||
? targetAudience.psychographic_profile.map((item: string, index: number) => (
|
? 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}
|
{item}
|
||||||
</Typography>
|
</Typography>
|
||||||
))
|
))
|
||||||
: (
|
: (
|
||||||
<Typography component="li" variant="body2" sx={styles.analysisListItem}>
|
<Typography component="li" variant="body2" sx={{ ...styles.analysisListItem, color: '#1a202c !important' }}>
|
||||||
{targetAudience.psychographic_profile}
|
{targetAudience.psychographic_profile}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
@@ -131,12 +131,12 @@ const TargetAudienceAnalysisSection: React.FC<TargetAudienceAnalysisSectionProps
|
|||||||
<WarningIcon />
|
<WarningIcon />
|
||||||
</Box>
|
</Box>
|
||||||
<Box flex={1}>
|
<Box flex={1}>
|
||||||
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
|
<Typography variant="subtitle2" sx={{ color: '#1a202c !important', fontWeight: 600 }} gutterBottom>
|
||||||
Pain Points
|
Pain Points
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box component="ul" sx={styles.analysisList}>
|
<Box component="ul" sx={styles.analysisList}>
|
||||||
{targetAudience.pain_points.map((painPoint: string, index: number) => (
|
{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}
|
{painPoint}
|
||||||
</Typography>
|
</Typography>
|
||||||
))}
|
))}
|
||||||
@@ -155,12 +155,12 @@ const TargetAudienceAnalysisSection: React.FC<TargetAudienceAnalysisSectionProps
|
|||||||
<TrendingUpIcon />
|
<TrendingUpIcon />
|
||||||
</Box>
|
</Box>
|
||||||
<Box flex={1}>
|
<Box flex={1}>
|
||||||
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
|
<Typography variant="subtitle2" sx={{ color: '#1a202c !important', fontWeight: 600 }} gutterBottom>
|
||||||
Motivations
|
Motivations
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box component="ul" sx={styles.analysisList}>
|
<Box component="ul" sx={styles.analysisList}>
|
||||||
{targetAudience.motivations.map((motivation: string, index: number) => (
|
{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}
|
{motivation}
|
||||||
</Typography>
|
</Typography>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const KeyInsightCard: React.FC<KeyInsightProps> = ({
|
|||||||
mb: 0,
|
mb: 0,
|
||||||
borderRadius: 2.5,
|
borderRadius: 2.5,
|
||||||
// Force high-contrast base color so nested text never inherits a light color
|
// 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
|
background: isDark
|
||||||
? `linear-gradient(135deg, ${alpha(paletteColor.main, 0.08)} 0%, ${alpha(paletteColor.main, 0.04)} 100%)`
|
? `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%)`,
|
: `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),
|
: alpha(paletteColor.main, 0.15),
|
||||||
borderLeftWidth: '5px',
|
borderLeftWidth: '5px',
|
||||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
// Ensure all child elements inherit proper text color
|
||||||
|
'& *': {
|
||||||
|
color: 'inherit !important'
|
||||||
|
},
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
background: isDark
|
background: isDark
|
||||||
? `linear-gradient(135deg, ${alpha(paletteColor.main, 0.12)} 0%, ${alpha(paletteColor.main, 0.08)} 100%)`
|
? `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',
|
fontSize: '0.78rem',
|
||||||
letterSpacing: '0.6px',
|
letterSpacing: '0.6px',
|
||||||
textTransform: 'uppercase',
|
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)',
|
textShadow: isDark ? 'none' : '0 1px 0 rgba(255,255,255,0.6)',
|
||||||
mb: 0.5,
|
mb: 0.5,
|
||||||
display: 'block'
|
display: 'block',
|
||||||
|
// Force high contrast for readability
|
||||||
|
WebkitTextFillColor: isDark ? '#ffffff' : '#1a202c',
|
||||||
|
WebkitTextStroke: '0px transparent'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -127,8 +134,11 @@ const KeyInsightCard: React.FC<KeyInsightProps> = ({
|
|||||||
sx={{
|
sx={{
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fontSize: '1.1rem',
|
fontSize: '1.1rem',
|
||||||
color: isDark ? '#ffffff !important' : '#0b1220 !important',
|
color: isDark ? '#ffffff !important' : '#1a202c !important',
|
||||||
lineHeight: 1.35
|
lineHeight: 1.35,
|
||||||
|
// Force high contrast for readability
|
||||||
|
WebkitTextFillColor: isDark ? '#ffffff' : '#1a202c',
|
||||||
|
WebkitTextStroke: '0px transparent'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Array.isArray(value) ? value.join(', ') : value}
|
{Array.isArray(value) ? value.join(', ') : value}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
|||||||
const [stepData, setStepData] = useState<any>(null);
|
const [stepData, setStepData] = useState<any>(null);
|
||||||
const [competitorDataCollector, setCompetitorDataCollector] = useState<(() => any) | null>(null);
|
const [competitorDataCollector, setCompetitorDataCollector] = useState<(() => any) | null>(null);
|
||||||
const [isCurrentStepValid, setIsCurrentStepValid] = useState<boolean>(false);
|
const [isCurrentStepValid, setIsCurrentStepValid] = useState<boolean>(false);
|
||||||
|
const [stepValidationStates, setStepValidationStates] = useState<Record<number, boolean>>({});
|
||||||
const [stepHeaderContent, setStepHeaderContent] = useState<StepHeaderContent>({
|
const [stepHeaderContent, setStepHeaderContent] = useState<StepHeaderContent>({
|
||||||
title: steps[0].label,
|
title: steps[0].label,
|
||||||
description: steps[0].description
|
description: steps[0].description
|
||||||
@@ -61,18 +62,24 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
|||||||
switch (step) {
|
switch (step) {
|
||||||
case 0: // API Keys
|
case 0: // API Keys
|
||||||
const hasApiKeys = data && data.api_keys && Object.keys(data.api_keys).length > 0;
|
const hasApiKeys = data && data.api_keys && Object.keys(data.api_keys).length > 0;
|
||||||
console.log(`Wizard: Step 0 (API Keys) validation:`, hasApiKeys);
|
console.log(`Wizard: Step 0 (API Keys) validation:`, !!hasApiKeys);
|
||||||
return hasApiKeys;
|
return !!hasApiKeys;
|
||||||
|
|
||||||
case 1: // Website Analysis
|
case 1: // Website Analysis
|
||||||
const hasWebsite = data && (data.website || data.website_url);
|
const hasWebsite = data && (data.website || data.website_url);
|
||||||
console.log(`Wizard: Step 1 (Website) validation:`, hasWebsite);
|
console.log(`Wizard: Step 1 (Website) validation:`, !!hasWebsite);
|
||||||
return hasWebsite;
|
return !!hasWebsite;
|
||||||
|
|
||||||
case 2: // Competitor Analysis
|
case 2: // Competitor Analysis
|
||||||
const hasCompetitorData = data && (data.competitors || data.researchSummary || data.sitemapAnalysis);
|
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');
|
console.log(`Wizard: Step 2 (Competitor Analysis) validation:`, {
|
||||||
return hasCompetitorData;
|
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
|
case 3: // Persona Generation
|
||||||
const hasValidPersonaData = data &&
|
const hasValidPersonaData = data &&
|
||||||
@@ -81,14 +88,14 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
|||||||
Object.keys(data.platformPersonas).length > 0 &&
|
Object.keys(data.platformPersonas).length > 0 &&
|
||||||
data.qualityMetrics;
|
data.qualityMetrics;
|
||||||
console.log(`Wizard: Step 3 (Persona Generation) validation:`, {
|
console.log(`Wizard: Step 3 (Persona Generation) validation:`, {
|
||||||
hasValidPersonaData,
|
hasValidPersonaData: !!hasValidPersonaData,
|
||||||
hasCorePersona: !!(data && data.corePersona),
|
hasCorePersona: !!(data && data.corePersona),
|
||||||
hasPlatformPersonas: !!(data && data.platformPersonas),
|
hasPlatformPersonas: !!(data && data.platformPersonas),
|
||||||
platformPersonasCount: data && data.platformPersonas ? Object.keys(data.platformPersonas).length : 0,
|
platformPersonasCount: data && data.platformPersonas ? Object.keys(data.platformPersonas).length : 0,
|
||||||
hasQualityMetrics: !!(data && data.qualityMetrics),
|
hasQualityMetrics: !!(data && data.qualityMetrics),
|
||||||
dataKeys: data ? Object.keys(data) : 'no data'
|
dataKeys: data ? Object.keys(data) : 'no data'
|
||||||
});
|
});
|
||||||
return hasValidPersonaData;
|
return !!hasValidPersonaData;
|
||||||
|
|
||||||
case 4: // Integrations
|
case 4: // Integrations
|
||||||
console.log(`Wizard: Step 4 (Integrations) validation: always true (optional)`);
|
console.log(`Wizard: Step 4 (Integrations) validation: always true (optional)`);
|
||||||
@@ -126,7 +133,16 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(`Wizard: Validation effect triggered - activeStep: ${activeStep}, stepData:`, stepData);
|
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: 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
|
// For CompetitorAnalysisStep, also check the competitorDataCollector data
|
||||||
let dataToValidate = stepData;
|
let dataToValidate = stepData;
|
||||||
if (activeStep === 2 && competitorDataCollector) {
|
if (activeStep === 2 && competitorDataCollector) {
|
||||||
@@ -138,13 +154,38 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
|||||||
console.log(`Wizard: Validation result for step ${activeStep}:`, isValid);
|
console.log(`Wizard: Validation result for step ${activeStep}:`, isValid);
|
||||||
console.log(`Wizard: Setting isCurrentStepValid to:`, isValid);
|
console.log(`Wizard: Setting isCurrentStepValid to:`, isValid);
|
||||||
setIsCurrentStepValid(isValid);
|
setIsCurrentStepValid(isValid);
|
||||||
}, [activeStep, stepData, isStepDataValid, competitorDataCollector]);
|
}, [activeStep, stepData, isStepDataValid, competitorDataCollector, stepValidationStates]);
|
||||||
|
|
||||||
// Debug: log all state changes
|
// Debug: log all state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('Wizard: Render triggered - activeStep:', activeStep, 'direction:', direction);
|
console.log('Wizard: Render triggered - activeStep:', activeStep, 'direction:', direction);
|
||||||
}, [activeStep, 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
|
// Memoize the onDataReady callback to prevent infinite loops
|
||||||
const handleCompetitorDataReady = useCallback((dataCollector: (() => any) | undefined) => {
|
const handleCompetitorDataReady = useCallback((dataCollector: (() => any) | undefined) => {
|
||||||
console.log('Wizard: onDataReady called with:', dataCollector);
|
console.log('Wizard: onDataReady called with:', dataCollector);
|
||||||
@@ -516,8 +557,8 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
|||||||
|
|
||||||
const renderStepContent = (step: number) => {
|
const renderStepContent = (step: number) => {
|
||||||
const stepComponents = [
|
const stepComponents = [
|
||||||
<ApiKeyStep key="api-keys" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
|
<ApiKeyStep key="api-keys" onContinue={handleNext} updateHeaderContent={updateHeaderContent} onValidationChange={handleApiKeyValidationChange} />,
|
||||||
<WebsiteStep key="website" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
|
<WebsiteStep key="website" onContinue={handleNext} updateHeaderContent={updateHeaderContent} onValidationChange={(isValid) => handleStepValidationChange(1, isValid)} />,
|
||||||
<CompetitorAnalysisStep
|
<CompetitorAnalysisStep
|
||||||
key="research"
|
key="research"
|
||||||
onContinue={handleNext}
|
onContinue={handleNext}
|
||||||
@@ -530,6 +571,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
|||||||
key="personalization"
|
key="personalization"
|
||||||
onContinue={handleNext}
|
onContinue={handleNext}
|
||||||
updateHeaderContent={updateHeaderContent}
|
updateHeaderContent={updateHeaderContent}
|
||||||
|
onValidationChange={(isValid) => handleStepValidationChange(3, isValid)}
|
||||||
onboardingData={personaOnboardingData}
|
onboardingData={personaOnboardingData}
|
||||||
stepData={personaStepData}
|
stepData={personaStepData}
|
||||||
/>,
|
/>,
|
||||||
|
|||||||
Reference in New Issue
Block a user