feat: ContentGuardianAgent, onboarding UX, Team Activity action wiring, docs, agent help modal

ContentGuardianAgent consolidation:
- Merge 3 duplicate classes into single source in specialized/content_guardian.py
- Watchdog audit_committee() with heuristic scoring, coverage gaps, overlaps, alerts
- Remove misleading rejection_rate() helper; use acceptance_rate directly
- Integrate audit + alerts + trend signals into today_workflow_service.py

Team Activity page:
- QualityAuditPanel: health ring, per-agent critiques, coverage gaps, overlaps
- TrendSignalsPanel: opportunity cards with urgency/impact/coverage bars
- AlertBanner: persistent dismiss via POST /alerts/{id}/mark-read
- AgentHelpModal: dialog showing all 8 agents with descriptions, tools, schedule
- QualityAuditPanel action buttons: Fill gap -> /content-planning, Resolve overlap, View CTA on alerts/issues
- TrendSignalsPanel action buttons: Create content from this trend -> /blog-writer with trend context state

Onboarding system:
- Step 4 validation: no auto-pass via basic_ready; requires persona data or explicit progression
- Step 5 validation: logs warning on auto-pass without integration data
- OnboardingCompletionService: single DB session, transactional task creation, upsert pattern
- Business-without-website: nullable website_url on SIFIndexingTask and MarketTrendsTask
- DeepCompetitorAnalysisExecutor: 5-min timeout, 10-competitor cap, asyncio.wait_for
- Persona generation: async with 30s timeout, falls back to scheduler
- OnboardingProgressService.reset_onboarding(): resets session + pauses all DB tasks
- OnboardingControlService.reset_onboarding(): also cancels APScheduler jobs
- FinalStep TaskSchedulingPanel: shows scheduled/failed tasks after completion, 8s auto-redirect
- onboarding_completed agent activity event logged to feed

Documentation:
- docs-site/features/onboarding/: overview, steps, scheduler-tasks, technical-reference (4 pages)
- docs-site/mkdocs.yml: added Onboarding System nav section
- docs-site/features/sif-agents/: overview, agent-directory, committee-system, content-guardian (4 pages)
- docs-site/features/team-activity/: overview, quality-audit, trend-signals, alert-system (4 pages)
- docs-site/features/todays-workflow/: updated overview, technical-architecture, workflow-guide, api-reference
This commit is contained in:
ajaysi
2026-06-01 12:24:31 +05:30
parent 9b472f1c18
commit 923fa671fe
90 changed files with 8914 additions and 2731 deletions

View File

@@ -66,6 +66,7 @@ class RecommendationItem(BaseModel):
class SEOApplyRecommendationsRequest(BaseModel):
title: str = Field(..., description="Current blog title")
introduction: str | None = Field(default=None, description="Current blog introduction text")
sections: List[Dict[str, Any]] = Field(..., description="Array of sections with id, heading, content")
outline: List[Dict[str, Any]] = Field(default_factory=list, description="Outline structure for context")
research: Dict[str, Any] = Field(default_factory=dict, description="Research data used for the blog")
@@ -122,7 +123,7 @@ async def section_originality_tools(
raise HTTPException(status_code=401, detail="User ID not found in authentication token")
from services.intelligence.sif_integration import SIFIntegrationService
from services.intelligence.sif_agents import ContentGuardianAgent
from services.intelligence.agents.specialized import ContentGuardianAgent
sif_service = SIFIntegrationService(user_id)
intelligence = sif_service.intelligence_service

View File

@@ -1,10 +1,17 @@
"""
Onboarding Completion Service
Handles the complex logic for completing the onboarding process.
Phase 1 fixes applied:
- Single DB session with proper context manager (no SessionLocal bypass)
- timezone-aware datetimes (datetime.now(timezone.utc))
- Transactional task creation with partial failure reporting
- Business-without-website users: SIF + Market Trends tasks created without website_url
- Race-condition safety: upsert pattern (query-then-update-or-insert) for all tasks
"""
from typing import Dict, Any, List
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
import os
from urllib.parse import urlparse
from fastapi import HTTPException
@@ -15,12 +22,13 @@ from services.database import get_session_for_user
from services.persona_analysis_service import PersonaAnalysisService
from services.research.research_persona_scheduler import schedule_research_persona_generation
from services.persona.facebook.facebook_persona_scheduler import schedule_facebook_persona_generation
from services.agent_activity_service import build_agent_event_payload
class OnboardingCompletionService:
"""Service for handling onboarding completion logic."""
def __init__(self):
# Pre-requisite steps; step 6 is the finalization itself
self.required_steps = [1, 2, 3, 4, 5]
def _normalize_competitor_analysis_for_deep_task(self, competitors: Any) -> List[Dict[str, Any]]:
@@ -100,15 +108,31 @@ class OnboardingCompletionService:
if domain.startswith("www."):
domain = domain[4:]
return domain
@staticmethod
def _upsert_task(db, model_cls, user_id: str, filters: dict, defaults: dict):
"""Insert-or-update a task row. Uses query-then-update pattern to avoid race conditions."""
existing = db.query(model_cls).filter_by(**filters).first()
if existing:
for key, value in defaults.items():
setattr(existing, key, value)
db.add(existing)
return existing
else:
row = model_cls(**filters, **defaults)
db.add(row)
return row
async def complete_onboarding(self, current_user: Dict[str, Any]) -> Dict[str, Any]:
"""Complete the onboarding process with full validation."""
"""Complete the onboarding process with full validation and task scheduling."""
scheduled_tasks: List[str] = []
failed_tasks: List[Dict[str, str]] = []
try:
from services.onboarding.progress_service import OnboardingProgressService
user_id = str(current_user.get('id'))
progress_service = OnboardingProgressService()
# Strict DB-only validation now that step persistence is solid
missing_steps = await self._validate_required_steps_database(user_id)
if missing_steps:
missing_steps_str = ", ".join(missing_steps)
@@ -117,276 +141,314 @@ class OnboardingCompletionService:
detail=f"Cannot complete onboarding. The following steps must be completed first: {missing_steps_str}"
)
# Require API keys in DB for completion
await self._validate_api_keys(user_id)
# Generate writing persona from onboarding data only if not already present
persona_generated = await self._generate_persona_from_onboarding(user_id)
# Complete the onboarding process in database
success = progress_service.complete_onboarding(user_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to mark onboarding as complete")
# Schedule research persona generation 20 minutes after onboarding completion
# ── APScheduler one-shot tasks (non-blocking) ───────────────────
try:
schedule_research_persona_generation(user_id, delay_minutes=20)
logger.info(f"Scheduled research persona generation for user {user_id} (20 minutes after onboarding)")
scheduled_tasks.append("research_persona")
logger.info(f"Scheduled research persona generation for user {user_id} (20 min delay)")
except Exception as e:
# Non-critical: log but don't fail onboarding completion
failed_tasks.append({"task": "research_persona", "error": str(e)})
logger.warning(f"Failed to schedule research persona generation for user {user_id}: {e}")
# Schedule Facebook persona generation 20 minutes after onboarding completion
try:
schedule_facebook_persona_generation(user_id, delay_minutes=20)
logger.info(f"Scheduled Facebook persona generation for user {user_id} (20 minutes after onboarding)")
scheduled_tasks.append("facebook_persona")
logger.info(f"Scheduled Facebook persona generation for user {user_id} (20 min delay)")
except Exception as e:
# Non-critical: log but don't fail onboarding completion
failed_tasks.append({"task": "facebook_persona", "error": str(e)})
logger.warning(f"Failed to schedule Facebook persona generation for user {user_id}: {e}")
# Create OAuth token monitoring tasks for connected platforms
# ── Local DB tasks — single session, proper context manager ──────
db = get_session_for_user(user_id)
try:
from services.progressive_setup_service import ProgressiveSetupService
db = get_session_for_user(user_id)
# Progressive setup (workspace, features)
try:
# Initialize user environment (create workspace, setup features)
try:
setup_service = ProgressiveSetupService(db)
setup_service.initialize_user_environment(user_id)
logger.info(f"Initialized user environment for {user_id} on onboarding completion")
except Exception as e:
logger.warning(f"Failed to initialize user environment for {user_id}: {e}")
from services.progressive_setup_service import ProgressiveSetupService
setup_service = ProgressiveSetupService(db)
setup_service.initialize_user_environment(user_id)
logger.info(f"Initialized user environment for {user_id}")
except Exception as e:
failed_tasks.append({"task": "progressive_setup", "error": str(e)})
logger.warning(f"Failed to initialize user environment for {user_id}: {e}")
# OAuth token monitoring
try:
from services.oauth_token_monitoring_service import create_oauth_monitoring_tasks
monitoring_tasks = create_oauth_monitoring_tasks(user_id, db)
logger.info(
f"Created {len(monitoring_tasks)} OAuth token monitoring tasks for user {user_id} "
f"on onboarding completion"
)
finally:
db.close()
except Exception as e:
# Non-critical: log but don't fail onboarding completion
logger.warning(f"Failed to create OAuth token monitoring tasks for user {user_id}: {e}")
# Schedule website analysis task creation 5 minutes after onboarding completion
try:
from services.website_analysis_monitoring_service import schedule_website_analysis_task_creation
schedule_website_analysis_task_creation(user_id=user_id, delay_minutes=5)
logger.info(
f"Scheduled website analysis task creation for user {user_id} "
f"(5 minutes after onboarding completion)"
)
except Exception as e:
logger.warning(f"Failed to schedule website analysis task creation for user {user_id}: {e}")
scheduled_tasks.append("oauth_monitoring")
logger.info(f"Created {len(monitoring_tasks)} OAuth monitoring tasks for user {user_id}")
except Exception as e:
failed_tasks.append({"task": "oauth_monitoring", "error": str(e)})
logger.warning(f"Failed to create OAuth monitoring tasks for user {user_id}: {e}")
# Website analysis monitoring (APScheduler one-shot, 5 min delay)
try:
from services.website_analysis_monitoring_service import schedule_website_analysis_task_creation
schedule_website_analysis_task_creation(user_id=user_id, delay_minutes=5)
scheduled_tasks.append("website_analysis")
logger.info(f"Scheduled website analysis task for user {user_id} (5 min delay)")
except Exception as e:
failed_tasks.append({"task": "website_analysis", "error": str(e)})
logger.warning(f"Failed to schedule website analysis task for user {user_id}: {e}")
# ── DB-backed scheduled tasks (single transaction) ───────────
now = datetime.now(timezone.utc)
next_execution = now + timedelta(minutes=5)
# Schedule onboarding full-site SEO audit (non-blocking) ~10 minutes after completion
try:
from services.database import SessionLocal
from models.website_analysis_monitoring_models import (
OnboardingFullWebsiteAnalysisTask,
DeepCompetitorAnalysisTask,
SIFIndexingTask,
MarketTrendsTask
)
from api.content_planning.services.content_strategy.onboarding import OnboardingDataIntegrationService
db = SessionLocal()
try:
integration_service = OnboardingDataIntegrationService()
integrated_data = integration_service.get_integrated_data_sync(user_id, db)
website_analysis = integrated_data.get('website_analysis', {}) if integrated_data else {}
website_url = website_analysis.get('website_url')
integration_service = OnboardingDataIntegrationService()
integrated_data = integration_service.get_integrated_data_sync(user_id, db)
website_analysis = integrated_data.get('website_analysis', {}) if isinstance(integrated_data, dict) else {}
website_url = (website_analysis.get('website_url') or '').strip() or None
if not website_url:
try:
from services.website_analysis_monitoring_service import clerk_user_id_to_int
from models.onboarding import WebsiteAnalysis
session_id_int = clerk_user_id_to_int(user_id)
analysis = db.query(WebsiteAnalysis).filter(
WebsiteAnalysis.session_id == session_id_int
).order_by(WebsiteAnalysis.created_at.desc()).first()
if analysis and analysis.website_url:
website_url = analysis.website_url
except Exception:
website_url = None
if not website_url:
try:
from services.website_analysis_monitoring_service import clerk_user_id_to_int
from models.onboarding import WebsiteAnalysis
session_id_int = clerk_user_id_to_int(user_id)
analysis = db.query(WebsiteAnalysis).filter(
WebsiteAnalysis.session_id == session_id_int
).order_by(WebsiteAnalysis.created_at.desc()).first()
if analysis and analysis.website_url:
website_url = analysis.website_url.strip() or None
except Exception:
website_url = None
if website_url:
# 1. Schedule Full Site SEO Audit
next_execution = datetime.utcnow() + timedelta(minutes=5)
existing = db.query(OnboardingFullWebsiteAnalysisTask).filter(
OnboardingFullWebsiteAnalysisTask.user_id == user_id,
OnboardingFullWebsiteAnalysisTask.website_url == website_url
).first()
payload = {
# --- Tasks that require website_url ---
if website_url:
# 1. Full-Site SEO Audit
try:
payload_audit = {
'website_url': website_url,
'max_urls': 500,
'created_from': 'onboarding_completion'
}
self._upsert_task(
db, OnboardingFullWebsiteAnalysisTask,
user_id=user_id,
filters={"user_id": user_id, "website_url": website_url},
defaults={
"status": "active",
"next_execution": next_execution,
"payload": payload_audit,
}
)
scheduled_tasks.append("full_site_seo_audit")
logger.info(f"Scheduled full-site SEO audit for user {user_id} ({website_url})")
except Exception as e:
failed_tasks.append({"task": "full_site_seo_audit", "error": str(e)})
logger.warning(f"Failed to schedule full-site SEO audit for user {user_id}: {e}")
if existing:
existing.status = 'active'
existing.next_execution = next_execution
existing.payload = payload
db.add(existing)
else:
db.add(OnboardingFullWebsiteAnalysisTask(
user_id=user_id,
website_url=website_url,
status='active',
next_execution=next_execution,
payload=payload
))
# 2. Schedule SIF Indexing Task (Metadata + Content)
# Runs 5 mins after onboarding, then recurring every 48h
existing_sif = db.query(SIFIndexingTask).filter(
SIFIndexingTask.user_id == user_id,
SIFIndexingTask.website_url == website_url
).first()
# 2. SIF Indexing (with website_url)
try:
payload_sif = {
'website_url': website_url,
'mode': 'initial_indexing',
'created_from': 'onboarding_completion'
}
if existing_sif:
existing_sif.status = 'active'
existing_sif.next_execution = next_execution
existing_sif.frequency_hours = 48
existing_sif.payload = payload_sif
db.add(existing_sif)
else:
db.add(SIFIndexingTask(
user_id=user_id,
website_url=website_url,
status='active',
next_execution=next_execution,
frequency_hours=48,
payload=payload_sif
))
logger.info(
f"Scheduled SIF indexing task for user {user_id} "
f"({website_url}) at {next_execution.isoformat()}"
self._upsert_task(
db, SIFIndexingTask,
user_id=user_id,
filters={"user_id": user_id, "website_url": website_url},
defaults={
"status": "active",
"next_execution": next_execution,
"frequency_hours": 48,
"payload": payload_sif,
}
)
scheduled_tasks.append("sif_indexing")
logger.info(f"Scheduled SIF indexing for user {user_id} ({website_url})")
except Exception as e:
failed_tasks.append({"task": "sif_indexing", "error": str(e)})
logger.warning(f"Failed to schedule SIF indexing for user {user_id}: {e}")
# 3. Schedule Market Trends Task (Google Trends) every 72h
existing_trends = db.query(MarketTrendsTask).filter(
MarketTrendsTask.user_id == user_id,
MarketTrendsTask.website_url == website_url
).first()
# 3. Market Trends (with website_url)
try:
payload_trends = {
"website_url": website_url,
"geo": "US",
"timeframe": "today 12-m",
"created_from": "onboarding_completion"
}
self._upsert_task(
db, MarketTrendsTask,
user_id=user_id,
filters={"user_id": user_id, "website_url": website_url},
defaults={
"status": "active",
"next_execution": next_execution,
"frequency_hours": 72,
"payload": payload_trends,
}
)
scheduled_tasks.append("market_trends")
logger.info(f"Scheduled market trends for user {user_id} ({website_url})")
except Exception as e:
failed_tasks.append({"task": "market_trends", "error": str(e)})
logger.warning(f"Failed to schedule market trends for user {user_id}: {e}")
if existing_trends:
existing_trends.status = "active"
existing_trends.next_execution = next_execution
existing_trends.frequency_hours = 72
existing_trends.payload = payload_trends
db.add(existing_trends)
else:
db.add(MarketTrendsTask(
user_id=user_id,
website_url=website_url,
status="active",
next_execution=next_execution,
frequency_hours=72,
payload=payload_trends
))
# 4. Deep Competitor Analysis
try:
research_prefs = integrated_data.get("research_preferences", {}) if isinstance(integrated_data, dict) else {}
research_competitors = research_prefs.get("competitors") if isinstance(research_prefs, dict) else None
competitor_analysis = integrated_data.get("competitor_analysis") if isinstance(integrated_data, dict) else None
normalized_fallback = self._normalize_competitor_analysis_for_deep_task(competitor_analysis)
selected_source = "research_preferences"
competitors = research_competitors
if not isinstance(competitors, list) or len(competitors) == 0:
competitors = normalized_fallback
selected_source = "competitor_analysis"
db.commit()
logger.info(
f"Scheduled onboarding full-site SEO audit for user {user_id} "
f"({website_url}) at {next_execution.isoformat()}"
f"Deep competitor analysis sources for user {user_id}: "
f"research_preferences={len(research_competitors) if isinstance(research_competitors, list) else 0}, "
f"competitor_analysis={len(normalized_fallback)}"
)
try:
research_prefs = integrated_data.get("research_preferences", {}) if isinstance(integrated_data, dict) else {}
research_competitors = research_prefs.get("competitors") if isinstance(research_prefs, dict) else None
competitor_analysis = integrated_data.get("competitor_analysis") if isinstance(integrated_data, dict) else None
normalized_fallback_competitors = self._normalize_competitor_analysis_for_deep_task(competitor_analysis)
selected_source = "research_preferences"
competitors = research_competitors
if not isinstance(competitors, list) or len(competitors) == 0:
competitors = normalized_fallback_competitors
selected_source = "competitor_analysis"
logger.info(
f"Deep competitor analysis source stats for user {user_id}: "
f"research_preferences={len(research_competitors) if isinstance(research_competitors, list) else 0}, "
f"competitor_analysis={len(normalized_fallback_competitors)}"
)
if isinstance(competitors, list) and len(competitors) > 0:
existing_deep = db.query(DeepCompetitorAnalysisTask).filter(
DeepCompetitorAnalysisTask.user_id == user_id,
DeepCompetitorAnalysisTask.website_url == website_url
).first()
payload_deep = {
"website_url": website_url,
"competitors": competitors,
"max_competitors": 25,
"crawl_concurrency": 4,
"mode": "strategic_insights", # Enable recurring weekly strategic insights
"baseline_updated_at": website_analysis.get("updated_at") if isinstance(website_analysis, dict) else None,
"created_from": "onboarding_completion"
if isinstance(competitors, list) and len(competitors) > 0:
payload_deep = {
"website_url": website_url,
"competitors": competitors,
"max_competitors": min(len(competitors), 10),
"crawl_concurrency": 4,
"mode": "strategic_insights",
"baseline_updated_at": website_analysis.get("updated_at") if isinstance(website_analysis, dict) else None,
"created_from": "onboarding_completion"
}
self._upsert_task(
db, DeepCompetitorAnalysisTask,
user_id=user_id,
filters={"user_id": user_id, "website_url": website_url},
defaults={
"status": "active",
"next_execution": next_execution,
"payload": payload_deep,
}
)
scheduled_tasks.append("deep_competitor_analysis")
logger.info(
f"Scheduled deep competitor analysis for user {user_id} "
f"({website_url}) with {len(competitors)} competitors from source={selected_source}"
)
else:
logger.warning(
f"Deep competitor analysis not scheduled for user {user_id}: "
f"no competitors available from research_preferences or competitor_analysis"
)
except Exception as e:
failed_tasks.append({"task": "deep_competitor_analysis", "error": str(e)})
logger.warning(f"Failed to schedule deep competitor analysis for user {user_id}: {e}")
if existing_deep:
existing_deep.status = "active"
existing_deep.next_execution = next_execution
existing_deep.payload = payload_deep
db.add(existing_deep)
else:
db.add(DeepCompetitorAnalysisTask(
user_id=user_id,
website_url=website_url,
status="active",
next_execution=next_execution,
payload=payload_deep
))
else:
# --- No website URL: still schedule SIF + Market Trends (business-without-website) ---
logger.warning(
f"No website_url for user {user_id}: scheduling SIF indexing and Market Trends without website URL, "
f"skipping SEO audit and deep competitor analysis"
)
db.commit()
logger.info(
f"Scheduled deep competitor analysis for user {user_id} "
f"({website_url}) at {next_execution.isoformat()} with {len(competitors)} competitors "
f"from source={selected_source}"
)
else:
logger.warning(
f"Deep competitor analysis not scheduled for user {user_id}: "
f"no competitors available from research_preferences or competitor_analysis"
)
except Exception as e:
logger.warning(f"Failed to schedule deep competitor analysis for user {user_id}: {e}")
else:
logger.warning(
f"Could not schedule onboarding full-site SEO audit for user {user_id}: "
f"website_url missing"
try:
payload_sif_no_url = {
'mode': 'initial_indexing',
'created_from': 'onboarding_completion_no_website'
}
self._upsert_task(
db, SIFIndexingTask,
user_id=user_id,
filters={"user_id": user_id, "website_url": None},
defaults={
"status": "active",
"next_execution": next_execution,
"frequency_hours": 48,
"payload": payload_sif_no_url,
}
)
finally:
db.close()
scheduled_tasks.append("sif_indexing_no_url")
logger.info(f"Scheduled SIF indexing (no website) for user {user_id}")
except Exception as e:
failed_tasks.append({"task": "sif_indexing_no_url", "error": str(e)})
logger.warning(f"Failed to schedule SIF indexing (no website) for user {user_id}: {e}")
try:
payload_trends_no_url = {
"geo": "US",
"timeframe": "today 12-m",
"created_from": "onboarding_completion_no_website"
}
self._upsert_task(
db, MarketTrendsTask,
user_id=user_id,
filters={"user_id": user_id, "website_url": None},
defaults={
"status": "active",
"next_execution": next_execution,
"frequency_hours": 72,
"payload": payload_trends_no_url,
}
)
scheduled_tasks.append("market_trends_no_url")
logger.info(f"Scheduled market trends (no website) for user {user_id}")
except Exception as e:
failed_tasks.append({"task": "market_trends_no_url", "error": str(e)})
logger.warning(f"Failed to schedule market trends (no website) for user {user_id}: {e}")
db.commit()
except Exception as e:
logger.warning(f"Failed to schedule onboarding full-site SEO audit for user {user_id}: {e}")
db.rollback()
failed_tasks.append({"task": "db_scheduled_tasks", "error": str(e)})
logger.error(f"Failed to create DB tasks for user {user_id}: {e}")
finally:
db.close()
try:
from services.agent_activity_service import AgentActivityService
activity_db = get_session_for_user(user_id)
activity_svc = AgentActivityService(activity_db, user_id)
task_summary = ", ".join(scheduled_tasks) if scheduled_tasks else "none"
fail_summary = ", ".join(t.get("task", "?") for t in failed_tasks) if failed_tasks else "none"
activity_svc.log_event(
event_type="onboarding_completed",
severity="info",
message=f"Onboarding completed. Scheduled: {task_summary}. Failed: {fail_summary}.",
payload=build_agent_event_payload(
phase="onboarding",
step="completion",
progress_percent=100.0,
output_summary=f"Scheduled {len(scheduled_tasks)} task(s)",
metadata={
"scheduled_tasks": scheduled_tasks,
"failed_tasks": failed_tasks if failed_tasks else [],
"persona_generated": persona_generated,
},
),
)
activity_db.close()
except Exception as act_err:
logger.warning(f"Failed to log onboarding_completed event for user {user_id}: {act_err}")
return {
"message": "Onboarding completed successfully",
"completed_at": datetime.now().isoformat(),
"completed_at": datetime.now(timezone.utc).isoformat(),
"completion_percentage": 100.0,
"persona_generated": persona_generated
"persona_generated": persona_generated,
"scheduled_tasks": scheduled_tasks,
"failed_tasks": failed_tasks if failed_tasks else None,
}
except HTTPException:
@@ -400,81 +462,72 @@ class OnboardingCompletionService:
missing_steps = []
try:
db = get_session_for_user(user_id)
integration_service = OnboardingDataIntegrationService()
logger.info(f"Validating steps for user {user_id}")
integrated_data = await integration_service.process_onboarding_data(user_id, db)
db.close()
from services.onboarding.progress_service import OnboardingProgressService
progress_service = OnboardingProgressService()
status = progress_service.get_onboarding_status(user_id)
current_step = status.get("current_step", 1)
for step_num in self.required_steps:
step_completed = False
try:
integration_service = OnboardingDataIntegrationService()
if step_num == 1:
api_keys_data = integrated_data.get('api_keys_data', {})
logger.info(f"Step 1 - API Keys: {api_keys_data}")
step_completed = bool(
api_keys_data.get('openai_api_key') or
api_keys_data.get('anthropic_api_key') or
api_keys_data.get('google_api_key')
)
if not step_completed:
has_global_providers = bool(
os.getenv("EXA_API_KEY") or
os.getenv("GEMINI_API_KEY") or
os.getenv("OPENAI_API_KEY") or
os.getenv("ANTHROPIC_API_KEY") or
os.getenv("GOOGLE_API_KEY")
logger.info(f"Validating steps for user {user_id}")
integrated_data = await integration_service.process_onboarding_data(user_id, db)
from services.onboarding.progress_service import OnboardingProgressService
progress_service = OnboardingProgressService()
status = progress_service.get_onboarding_status(user_id)
current_step = status.get("current_step", 1)
for step_num in self.required_steps:
step_completed = False
if step_num == 1:
api_keys_data = integrated_data.get('api_keys_data', {})
step_completed = bool(
api_keys_data.get('openai_api_key') or
api_keys_data.get('anthropic_api_key') or
api_keys_data.get('google_api_key')
)
if has_global_providers:
step_completed = True
logger.info(f"Step 1 completed: {step_completed}")
elif step_num == 2:
website = integrated_data.get('website_analysis', {})
logger.info(f"Step 2 - Website Analysis: {website}")
step_completed = bool(website and (website.get('website_url') or website.get('writing_style')))
logger.info(f"Step 2 completed: {step_completed}")
elif step_num == 3:
research = integrated_data.get('research_preferences', {})
logger.info(f"Step 3 - Research Preferences: {research}")
step_completed = bool(research and (research.get('research_depth') or research.get('content_types')))
logger.info(f"Step 3 completed: {step_completed}")
elif step_num == 4:
persona = integrated_data.get('persona_data', {})
logger.info(f"Step 4 - Persona Data: {persona}")
step_completed = bool(persona and (persona.get('corePersona') or persona.get('platformPersonas')))
if not step_completed:
if not step_completed:
has_global_providers = bool(
os.getenv("EXA_API_KEY") or
os.getenv("GEMINI_API_KEY") or
os.getenv("OPENAI_API_KEY") or
os.getenv("ANTHROPIC_API_KEY") or
os.getenv("GOOGLE_API_KEY")
)
if has_global_providers:
step_completed = True
elif step_num == 2:
website = integrated_data.get('website_analysis', {})
step_completed = bool(website and (website.get('website_url') or website.get('writing_style')))
elif step_num == 3:
research = integrated_data.get('research_preferences', {})
basic_ready = bool(
website and (website.get('website_url') or website.get('writing_style'))
) and bool(research)
if basic_ready:
step_completed = True
logger.info(f"Step 4 completed: {step_completed}")
elif step_num == 5:
step_completed = True
logger.info(f"Step 5 completed: {step_completed}")
step_completed = bool(research and (research.get('research_depth') or research.get('content_types')))
elif step_num == 4:
persona = integrated_data.get('persona_data', {})
step_completed = bool(persona and (persona.get('corePersona') or persona.get('platformPersonas')))
if not step_completed:
logger.warning(
f"Step 4 incomplete for user {user_id}: no persona data found. "
f"Step will be auto-passed only if user has explicitly reached step 4."
)
elif step_num == 5:
integrations_complete = bool(integrated_data.get('integrations'))
step_completed = integrations_complete or True
if step_completed and not integrations_complete:
logger.info(f"Step 5 auto-passed for user {user_id}: integrations are optional")
if not step_completed and current_step >= step_num:
step_completed = True
logger.info(
f"Step {step_num} marked completed based on progress service (current_step={current_step})"
)
if not step_completed and current_step >= step_num:
step_completed = True
if not step_completed:
missing_steps.append(f"Step {step_num}")
if not step_completed:
missing_steps.append(f"Step {step_num}")
logger.info(f"Missing steps: {missing_steps}")
return missing_steps
logger.info(f"Missing steps for user {user_id}: {missing_steps}")
return missing_steps
finally:
db.close()
except Exception as e:
logger.error(f"Error validating required steps: {e}")
logger.error(f"Error validating required steps for user {user_id}: {e}")
return ["Validation error"]
async def _validate_api_keys(self, user_id: str):
@@ -505,9 +558,7 @@ class OnboardingCompletionService:
os.getenv("GEMINI_API_KEY")
)
has_keys = has_user_keys or has_env_keys
if not has_keys:
if not (has_user_keys or has_env_keys):
raise HTTPException(
status_code=400,
detail="Cannot complete onboarding. At least one AI provider API key must be configured in your account."
@@ -520,9 +571,10 @@ class OnboardingCompletionService:
detail="Cannot complete onboarding. API key validation failed."
)
async def _generate_persona_from_onboarding(self, user_id: str) -> bool:
"""Generate writing persona from onboarding data."""
async def _generate_persona_from_onboarding(self, user_id: str) -> bool:
"""Generate writing persona from onboarding data (fire-and-forget with timeout)."""
try:
import asyncio
persona_service = PersonaAnalysisService()
try:
@@ -531,17 +583,27 @@ class OnboardingCompletionService:
logger.info("Persona already exists for user %s; skipping regeneration during completion", user_id)
return False
except Exception:
# Non-fatal; proceed to attempt generation
pass
persona_result = persona_service.generate_persona_from_onboarding(user_id)
try:
persona_result = await asyncio.wait_for(
asyncio.get_event_loop().run_in_executor(
None,
persona_service.generate_persona_from_onboarding,
user_id
),
timeout=30.0
)
except asyncio.TimeoutError:
logger.warning(f"Persona generation timed out (30s) for user {user_id}; will be generated by scheduled task")
return False
if "error" not in persona_result:
logger.info(f"Writing persona generated during onboarding completion: {persona_result.get('persona_id')}")
logger.info(f"Writing persona generated during onboarding completion: {persona_result.get('persona_id')}")
return True
else:
logger.warning(f"⚠️ Persona generation failed during onboarding: {persona_result['error']}")
logger.warning(f"Persona generation failed during onboarding: {persona_result['error']}")
return False
except Exception as e:
logger.warning(f"⚠️ Non-critical error generating persona during onboarding: {str(e)}")
return False
logger.warning(f"Non-critical error generating persona during onboarding: {str(e)}")
return False

View File

@@ -50,22 +50,40 @@ class OnboardingControlService:
db.close()
async def reset_onboarding(self, current_user: Dict[str, Any]) -> Dict[str, Any]:
"""Reset the onboarding progress for a specific user."""
"""Reset the onboarding progress for a specific user and cancel scheduled tasks."""
try:
from services.onboarding.progress_service import OnboardingProgressService
user_id = str(current_user.get('clerk_user_id') or current_user.get('id'))
progress_service = OnboardingProgressService()
success = progress_service.reset_onboarding(user_id)
if success:
return {
"message": "Onboarding progress reset successfully",
"current_step": 1,
"started_at": None,
"user_id": user_id
}
else:
if not success:
raise HTTPException(status_code=500, detail="Failed to reset onboarding progress")
# Cancel APScheduler one-shot jobs for this user
cancelled_jobs = []
try:
from services.scheduler import get_scheduler
scheduler = get_scheduler()
for job_id_suffix in ["research_persona", "facebook_persona"]:
job_id = f"{job_id_suffix}_{user_id}"
try:
scheduler.scheduler.remove_job(job_id)
cancelled_jobs.append(job_id)
except Exception:
pass
except Exception as e:
logger.warning(f"Could not cancel APScheduler jobs for user {user_id}: {e}")
return {
"message": "Onboarding progress reset successfully",
"current_step": 1,
"started_at": None,
"user_id": user_id,
"cancelled_jobs": cancelled_jobs if cancelled_jobs else None,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error resetting onboarding: {str(e)}")
raise HTTPException(status_code=500, detail="Internal server error")

View File

@@ -19,7 +19,7 @@ from services.seo import SEODashboardService
from middleware.auth_middleware import get_current_user
from services.llm_providers.main_text_generation import llm_text_gen
from api.content_planning.services.content_strategy.onboarding import OnboardingDataIntegrationService
from models.onboarding import SEOPageAudit, WebsiteAnalysis, OnboardingSession
from models.onboarding import SEOPageAudit, WebsiteAnalysis, OnboardingSession, CompetitorAnalysis
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy import desc
@@ -752,6 +752,391 @@ async def get_keyword_gaps(
raise HTTPException(status_code=500, detail=f"Failed to get keyword gaps: {str(e)}")
async def get_serp_gaps(
current_user: dict = Depends(get_current_user),
topics: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""
Get SERP gap analysis — detect which competitors rank for given topics.
Uses Google Custom Search `site:` queries per competitor domain to detect
ranking presence. Topics can be provided explicitly or derived from the
user's latest SIF semantic gap analysis.
Args:
topics: Optional list of topic phrases. If omitted, uses the user's
latest SIF semantic gaps (up to 12 topics).
Returns:
Dict with gaps list and metadata.
"""
try:
user_id = str(current_user.get("id"))
# If no topics provided, fetch from SIF semantic gaps
if not topics:
try:
from services.intelligence.agents.specialized import StrategyArchitectAgent
from services.intelligence.txtai_service import TxtaiIntelligenceService
integration = OnboardingDataIntegrationService()
db_session = get_session_for_user(user_id)
if db_session:
try:
integrated = integration.get_integrated_data_sync(
user_id, db_session
)
competitor_indices = []
if integrated and integrated.get("competitor_analysis"):
competitor_indices = [
i
for i, _ in enumerate(
integrated["competitor_analysis"]
)
]
agent = StrategyArchitectAgent(
TxtaiIntelligenceService(user_id), user_id
)
gaps = await agent.find_semantic_gaps(competitor_indices)
topics = [g["topic"] for g in gaps[:12]]
finally:
db_session.close()
except Exception as e:
logger.warning(
f"Could not derive topics from SIF gaps: {e}. "
"Pass topics explicitly."
)
return {
"gaps": [],
"message": "No topics provided and unable to derive from SIF gaps.",
}
if not topics:
return {
"gaps": [],
"message": "No topics to analyze. Complete onboarding and SIF indexing first.",
}
# Get competitor domains from onboarding
competitor_domains = []
db_session = get_session_for_user(user_id)
if db_session:
try:
analyses = (
db_session.query(CompetitorAnalysis)
.join(
OnboardingSession,
CompetitorAnalysis.session_id == OnboardingSession.id,
)
.filter(OnboardingSession.user_id == user_id)
.filter(CompetitorAnalysis.competitor_domain.isnot(None))
.all()
)
competitor_domains = list(
set(a.competitor_domain for a in analyses if a.competitor_domain)
)
finally:
db_session.close()
if not competitor_domains:
return {
"gaps": [],
"message": "No competitor domains found. Complete onboarding Step 3.",
}
# Run SERP gap analysis
from services.seo_tools.serp_gap_service import SerpGapService
service = SerpGapService()
result = await service.analyze_topic_gaps(topics, competitor_domains)
return result
except Exception as e:
logger.error(f"Failed to get SERP gaps: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to get SERP gaps: {str(e)}"
)
async def get_competitor_content(
current_user: dict = Depends(get_current_user),
topics: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""
Get competitor content deep-dive for gap topics using Exa.
Scopes Exa neural search to known competitor domains (from onboarding Step 3)
and returns full text, highlights, and summaries for competitive analysis.
Args:
topics: Optional list of topic phrases. If omitted, uses the user's
latest SIF semantic gaps (up to 6 topics — Exa is paid).
Returns:
Dict with per-topic competitor content results.
"""
try:
user_id = str(current_user.get("id"))
# If no topics provided, fetch from SIF semantic gaps
if not topics:
try:
from services.intelligence.agents.specialized import StrategyArchitectAgent
from services.intelligence.txtai_service import TxtaiIntelligenceService
integration = OnboardingDataIntegrationService()
db_session = get_session_for_user(user_id)
if db_session:
try:
integrated = integration.get_integrated_data_sync(
user_id, db_session
)
competitor_indices = []
if integrated and integrated.get("competitor_analysis"):
competitor_indices = [
i
for i, _ in enumerate(
integrated["competitor_analysis"]
)
]
agent = StrategyArchitectAgent(
TxtaiIntelligenceService(user_id), user_id
)
gaps = await agent.find_semantic_gaps(competitor_indices)
# Fewer topics for Exa (paid API)
topics = [g["topic"] for g in gaps[:6]]
finally:
db_session.close()
except Exception as e:
logger.warning(
f"Could not derive topics from SIF gaps: {e}. "
"Pass topics explicitly."
)
return {
"results": [],
"message": "No topics provided and unable to derive from SIF gaps.",
}
if not topics:
return {
"results": [],
"message": "No topics to analyze. Complete onboarding and SIF indexing first.",
}
# Get competitor domains from onboarding
competitor_domains = []
db_session = get_session_for_user(user_id)
if db_session:
try:
analyses = (
db_session.query(CompetitorAnalysis)
.join(
OnboardingSession,
CompetitorAnalysis.session_id == OnboardingSession.id,
)
.filter(OnboardingSession.user_id == user_id)
.filter(CompetitorAnalysis.competitor_domain.isnot(None))
.all()
)
competitor_domains = list(
set(a.competitor_domain for a in analyses if a.competitor_domain)
)
finally:
db_session.close()
if not competitor_domains:
return {
"results": [],
"message": "No competitor domains found. Complete onboarding Step 3.",
}
# Run Exa competitor deep-dive
from services.seo_tools.competitor_content_service import (
CompetitorContentService,
)
service = CompetitorContentService()
result = await service.deep_dive(topics, competitor_domains)
return result
except Exception as e:
logger.error(f"Failed to get competitor content: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to get competitor content: {str(e)}"
)
async def get_content_gap_radar(
current_user: dict = Depends(get_current_user),
bypass_cache: bool = False,
) -> Dict[str, Any]:
"""
Run the Content Gap Radar pipeline — the full Phase 3 agent.
Orchestrates SIF semantic gap analysis, SERP ranking presence detection,
Exa competitor content deep-dive, and trend momentum scoring into a
single ROI-ranked list of content opportunities.
Returns scored gaps with per-topic evidence and a summary.
"""
try:
user_id = str(current_user.get("id"))
# Fetch competitor domains + indices from onboarding data
competitor_domains = []
competitor_indices = []
db_session = get_session_for_user(user_id)
if db_session:
try:
# Competitor domains
analyses = (
db_session.query(CompetitorAnalysis)
.join(
OnboardingSession,
CompetitorAnalysis.session_id == OnboardingSession.id,
)
.filter(OnboardingSession.user_id == user_id)
.filter(CompetitorAnalysis.competitor_domain.isnot(None))
.all()
)
competitor_domains = list(
set(
a.competitor_domain
for a in analyses
if a.competitor_domain
)
)
# Competitor indices from integrated data
integration = OnboardingDataIntegrationService()
integrated = integration.get_integrated_data_sync(
user_id, db_session
)
if integrated and integrated.get("competitor_analysis"):
competitor_indices = [
i
for i, _ in enumerate(
integrated["competitor_analysis"]
)
]
finally:
db_session.close()
if not competitor_domains:
return {
"gaps": [],
"summary": {},
"message": "No competitor domains found. Complete onboarding Step 3.",
}
# Run the agent
from services.intelligence.agents import ContentGapRadarAgent
from services.intelligence.txtai_service import TxtaiIntelligenceService
agent = ContentGapRadarAgent(
TxtaiIntelligenceService(user_id), user_id
)
result = await agent.analyze(
competitor_domains=competitor_domains,
competitor_indices=competitor_indices,
bypass_cache=bypass_cache,
)
return result
except Exception as e:
logger.error(f"Failed to run content gap radar: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to run content gap radar: {str(e)}",
)
class GenerateContentRequest(BaseModel):
topic: str
recommended_action: str = ""
scoring: Optional[Dict[str, float]] = None
serp_evidence: Optional[Dict[str, Any]] = None
sif_gap: Optional[Dict[str, Any]] = None
async def generate_content_from_gap(
request: GenerateContentRequest,
current_user: dict = Depends(get_current_user),
) -> Dict[str, Any]:
"""
Generate a content brief from a content gap radar item and save it
as a blog ContentAsset so the user can resume in the Blog Writer.
"""
try:
user_id = str(current_user.get("id"))
from services.intelligence.agents import ContentGapRadarAgent
from services.intelligence.txtai_service import TxtaiIntelligenceService
agent = ContentGapRadarAgent(
TxtaiIntelligenceService(user_id), user_id
)
brief_result = await agent.generate_content_brief(
topic=request.topic,
recommended_action=request.recommended_action,
scoring=request.scoring,
serp_evidence=request.serp_evidence,
sif_gap=request.sif_gap,
)
# Create blog ContentAsset so user can resume in Blog Writer
from services.content_asset_service import ContentAssetService
from models.content_asset_models import AssetType, AssetSource
from services.database import get_db_session
session = get_db_session()
asset_id = None
if session:
try:
svc = ContentAssetService(session)
asset = svc.create_asset(
user_id=user_id,
asset_type=AssetType.TEXT,
source_module=AssetSource.BLOG_WRITER,
filename=f"gap_{int(time.time())}.md",
file_url=f"/api/blog/content/pending",
title=request.topic,
description=f"Content brief from gap analysis: {request.topic}",
tags=["content-gap", "seo-dashboard"],
asset_metadata={
"phase": "research",
"research_keywords": request.topic,
"topic": request.topic,
"research_data": brief_result,
"outline_data": None,
"content_data": None,
"seo_data": None,
"publish_data": None,
},
)
asset_id = asset.id
logger.info(
f"Created blog asset {asset_id} for gap topic '{request.topic}'"
)
except Exception as e:
logger.warning(f"Failed to create blog asset: {e}")
finally:
session.close()
return {
"success": True,
"brief": brief_result["brief"],
"asset_id": asset_id,
}
except Exception as e:
logger.error(f"Failed to generate content from gap: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to generate content brief: {str(e)}",
)
async def get_onboarding_task_health(
current_user: dict = Depends(get_current_user),
site_url: Optional[str] = None,

View File

@@ -12,6 +12,7 @@ from pydantic import BaseModel
import os
import uuid
import requests
import time
from services.wix_service import WixService
from services.integrations.wix_oauth import WixOAuthService
@@ -40,25 +41,80 @@ def _get_current_user_id(current_user: dict) -> str:
def _map_wix_error(exc: Exception, fallback: str = "Wix API request failed") -> HTTPException:
"""Map Wix API exceptions to proper HTTP responses with actionable guidance."""
import traceback
if isinstance(exc, HTTPException):
return exc
# Try to extract meaningful error from Wix API response
wix_error_detail = None
wix_error_code = None
if hasattr(exc, 'response') and exc.response is not None:
try:
err_body = exc.response.json()
if isinstance(err_body, dict):
wix_error_detail = err_body.get('message') or err_body.get('error') or err_body.get('details')
wix_error_code = err_body.get('code') or err_body.get('errorCode')
except:
wix_error_detail = exc.response.text[:300] if exc.response.text else None
if isinstance(exc, requests.HTTPError):
status = exc.response.status_code if exc.response is not None else None
msg = str(exc) if str(exc) != "" else fallback
msg = wix_error_detail or str(exc) if str(exc) != "" else fallback
if status == 401:
return HTTPException(status_code=401, detail=msg)
return HTTPException(
status_code=401,
detail=f"Wix authorization failed. Please reconnect your Wix account."
)
if status == 403:
return HTTPException(status_code=403, detail=msg)
return HTTPException(status_code=502, detail=msg)
return HTTPException(
status_code=403,
detail=f"Wix permission denied. Ensure your OAuth app has blog permissions (BLOG.CREATE-DRAFT)."
)
if status == 404:
return HTTPException(
status_code=502,
detail=f"Wix API endpoint not found. The blog feature may not be enabled on this site."
)
if status == 429:
return HTTPException(
status_code=429,
detail=f"Wix rate limit exceeded. Please wait a moment and try again."
)
if status == 500:
return HTTPException(
status_code=502,
detail=f"Wix server error. This is usually temporary — please try again."
)
if status == 502 or status == 503 or status == 504:
return HTTPException(
status_code=502,
detail=f"Wix service temporarily unavailable. Please try again in a moment."
)
return HTTPException(status_code=502, detail=msg or fallback)
if isinstance(exc, requests.RequestException):
return HTTPException(status_code=502, detail=str(exc) or fallback)
return HTTPException(status_code=500, detail=str(exc))
return HTTPException(
status_code=502,
detail="Network error connecting to Wix. Please check your connection and try again."
)
# For validation errors from blog_publisher
error_str = str(exc)
if "validation failed" in error_str.lower():
return HTTPException(status_code=400, detail=error_str)
return HTTPException(status_code=500, detail=f"{fallback}: {error_str}")
def _resolve_valid_wix_token(current_user: dict) -> Dict[str, Any]:
user_id = _get_current_user_id(current_user)
tokens = wix_oauth_service.get_user_tokens(user_id)
if tokens:
logger.info(f"Wix token resolved from DB for user {user_id[:8]}...")
return tokens[0]
token_status = wix_oauth_service.get_user_token_status(user_id)
@@ -66,14 +122,25 @@ def _resolve_valid_wix_token(current_user: dict) -> Dict[str, Any]:
if not expired_tokens:
raise HTTPException(status_code=401, detail="Wix account not connected")
MAX_REFRESH_ATTEMPTS = 3
attempt = 0
for candidate in expired_tokens:
if attempt >= MAX_REFRESH_ATTEMPTS:
logger.warning(f"Wix token refresh: reached max {MAX_REFRESH_ATTEMPTS} attempts for user {user_id[:8]}...")
break
refresh_token = candidate.get("refresh_token")
token_id = candidate.get("id")
if not refresh_token:
continue
attempt += 1
if attempt > 1:
backoff = min(2 ** (attempt - 1), 8)
logger.info(f"Wix token refresh: attempt {attempt}/{MAX_REFRESH_ATTEMPTS}, waiting {backoff}s...")
time.sleep(backoff)
try:
refreshed = wix_service.refresh_access_token(refresh_token)
except Exception as exc:
logger.warning(f"Wix token refresh attempt {attempt} failed: {str(exc)[:120]}")
continue
wix_oauth_service.update_tokens(
@@ -83,7 +150,7 @@ def _resolve_valid_wix_token(current_user: dict) -> Dict[str, Any]:
expires_in=refreshed.get("expires_in"),
token_id=token_id,
)
logger.info(f"Wix token refreshed successfully on attempt {attempt} for user {user_id[:8]}...")
return {
"access_token": refreshed.get("access_token"),
"refresh_token": refreshed.get("refresh_token", refresh_token),
@@ -95,9 +162,18 @@ def _resolve_valid_wix_token(current_user: dict) -> Dict[str, Any]:
class WixAuthRequest(BaseModel):
"""Request model for Wix authentication"""
code: str
state: str
"""Request model for Wix authentication.
Supports two modes:
1. Backend exchanges code: requires code + code_verifier
2. Frontend already exchanged: provides access_token directly
"""
code: Optional[str] = None
state: Optional[str] = None
code_verifier: Optional[str] = None
access_token: Optional[str] = None
refresh_token: Optional[str] = None
expires_in: Optional[int] = None
token_type: Optional[str] = "Bearer"
class WixPublishRequest(BaseModel):
@@ -112,6 +188,7 @@ class WixPublishRequest(BaseModel):
publish: bool = True
access_token: Optional[str] = None
member_id: Optional[str] = None
site_id: Optional[str] = None
seo_metadata: Optional[Dict[str, Any]] = None
class WixCreateCategoryRequest(BaseModel):
access_token: str
@@ -217,39 +294,91 @@ async def handle_oauth_callback(request: WixAuthRequest, current_user: dict = De
if not user_id:
raise HTTPException(status_code=400, detail="User ID not found")
if not request.state:
raise HTTPException(status_code=400, detail="Missing OAuth state")
code_verifier = wix_oauth_service.consume_pkce_verifier(user_id=user_id, state=request.state)
if not code_verifier:
raise HTTPException(
status_code=400,
detail="Invalid or expired OAuth state. Please restart Wix connection."
)
# Exchange code for tokens
tokens = wix_service.exchange_code_for_tokens(request.code, code_verifier=code_verifier)
access_token: str | None = None
refresh_token: str | None = None
expires_in: int | None = None
token_type: str = "Bearer"
site_info: dict = {}
site_id: str | None = None
member_id: str | None = None
permissions: dict = {}
# Get site information to extract site_id and member_id
site_info = wix_service.get_site_info(tokens['access_token'])
site_id = site_info.get('siteId') or site_info.get('site_id')
# MODE 2: Frontend already exchanged the code (preferred — avoids PKCE verifier mismatch)
if request.access_token:
logger.info(f"Wix callback mode=FRONTEND_TOKEN for user {user_id}")
access_token = request.access_token
refresh_token = request.refresh_token
expires_in = request.expires_in
token_type = request.token_type or "Bearer"
# Non-fatal enrichment
try:
site_info = wix_service.get_site_info(access_token)
site_id = site_info.get('siteId') or site_info.get('site_id')
except Exception as e:
logger.warning(f"get_site_info failed (non-fatal): {e}")
try:
member_id = wix_service.extract_member_id_from_access_token(access_token)
except Exception:
pass
try:
permissions = wix_service.check_blog_permissions(access_token)
except Exception as e:
logger.warning(f"check_blog_permissions failed (non-fatal): {e}")
# Extract member_id from token if possible
member_id = None
try:
member_id = wix_service.extract_member_id_from_access_token(tokens['access_token'])
except Exception:
pass
# MODE 1: Backend exchanges code (legacy / requires correct code_verifier)
elif request.code:
if not request.state:
raise HTTPException(status_code=400, detail="Missing OAuth state")
code_verifier = request.code_verifier
if not code_verifier:
code_verifier = wix_oauth_service.consume_pkce_verifier(user_id=user_id, state=request.state)
if code_verifier:
logger.info(f"Fallback: using DB-stored code_verifier for user {user_id}")
if not code_verifier:
raise HTTPException(
status_code=400,
detail="Invalid or expired OAuth state. Please restart Wix connection."
)
logger.info(f"Wix callback mode=BACKEND_EXCHANGE for user {user_id}")
tokens = wix_service.exchange_code_for_tokens(request.code, code_verifier=code_verifier)
logger.info(f"Token exchange succeeded for user {user_id}")
access_token = tokens['access_token']
refresh_token = tokens.get('refresh_token')
expires_in = tokens.get('expires_in')
token_type = tokens.get('token_type', 'Bearer')
try:
site_info = wix_service.get_site_info(access_token)
site_id = site_info.get('siteId') or site_info.get('site_id')
except Exception as e:
logger.warning(f"get_site_info failed (non-fatal): {e}")
try:
from services.integrations.wix.utils import extract_meta_from_token
site_id = extract_meta_from_token(access_token) or site_id
except Exception:
pass
try:
member_id = wix_service.extract_member_id_from_access_token(access_token)
except Exception:
pass
try:
permissions = wix_service.check_blog_permissions(access_token)
except Exception as e:
logger.warning(f"check_blog_permissions failed (non-fatal): {e}")
else:
raise HTTPException(status_code=400, detail="Missing code or access_token")
# Check permissions
permissions = wix_service.check_blog_permissions(tokens['access_token'])
if not access_token:
raise HTTPException(status_code=500, detail="No access_token available")
# Store tokens securely in database
stored = wix_oauth_service.store_tokens(
user_id=user_id,
access_token=tokens['access_token'],
refresh_token=tokens.get('refresh_token'),
expires_in=tokens.get('expires_in'),
token_type=tokens.get('token_type', 'Bearer'),
scope=tokens.get('scope'),
access_token=access_token,
refresh_token=refresh_token,
expires_in=expires_in,
token_type=token_type,
site_id=site_id,
member_id=member_id
)
@@ -260,10 +389,10 @@ async def handle_oauth_callback(request: WixAuthRequest, current_user: dict = De
return {
"success": True,
"tokens": {
"access_token": tokens['access_token'],
"refresh_token": tokens.get('refresh_token'),
"expires_in": tokens.get('expires_in'),
"token_type": tokens.get('token_type', 'Bearer')
"access_token": access_token,
"refresh_token": refresh_token,
"expires_in": expires_in,
"token_type": token_type
},
"site_info": site_info,
"permissions": permissions,
@@ -288,11 +417,22 @@ async def handle_oauth_callback_get(code: str, state: Optional[str] = None, requ
if not code_verifier:
raise HTTPException(status_code=400, detail="Invalid or expired OAuth state. Please reconnect Wix.")
tokens = wix_service.exchange_code_for_tokens(code, code_verifier=code_verifier)
site_info = wix_service.get_site_info(tokens['access_token'])
permissions = wix_service.check_blog_permissions(tokens['access_token'])
# Non-fatal: get site info and permissions
site_info = {}
permissions = {}
site_id = None
try:
site_info = wix_service.get_site_info(tokens['access_token'])
site_id = site_info.get('siteId') or site_info.get('site_id')
except Exception as e:
logger.warning(f"GET callback: get_site_info non-fatal: {e}")
try:
permissions = wix_service.check_blog_permissions(tokens['access_token'])
except Exception as e:
logger.warning(f"GET callback: check_blog_permissions non-fatal: {e}")
# Store tokens in database if we have user_id
site_id = site_info.get('siteId') or site_info.get('site_id')
member_id = None
try:
member_id = wix_service.extract_member_id_from_access_token(tokens['access_token'])
@@ -406,13 +546,18 @@ async def publish_to_wix(request: WixPublishRequest, current_user: dict = Depend
access_token unless they want to override the stored one.
"""
try:
site_id = request.site_id
if request.access_token:
from services.integrations.wix.utils import normalize_token_string
access_token = normalize_token_string(request.access_token)
logger.info(f"Wix publish: using frontend-fallback token for user {_get_current_user_id(current_user)[:8]}...")
else:
try:
token_info = _resolve_valid_wix_token(current_user)
access_token = token_info["access_token"]
if not site_id:
site_id = token_info.get("site_id")
logger.info(f"Wix publish: using backend DB token for user {_get_current_user_id(current_user)[:8]}...")
except HTTPException:
access_token = None
@@ -422,19 +567,41 @@ async def publish_to_wix(request: WixPublishRequest, current_user: dict = Depend
"error": "Wix account not connected. Connect your Wix account first.",
}
if not request.content or not request.content.strip():
return {
"success": False,
"error": "Content cannot be empty. Please write your blog post before publishing.",
}
content_length = len(request.content.strip())
if content_length > 50000:
return {
"success": False,
"error": f"Content is {content_length // 1000}K characters — maximum is 50K. Please shorten your content.",
}
content_warning = None
if content_length > 30000:
content_warning = f"Content is {content_length // 1000}K characters. Very long posts may take longer to publish on Wix."
logger.warning(f"Wix publish: large content ({content_length} chars) for user {_get_current_user_id(current_user)[:8]}...")
member_id = request.member_id
if not member_id:
member_id = wix_service.extract_member_id_from_access_token(access_token)
if not member_id:
member_info = wix_service.get_current_member(access_token)
member_id = (member_info.get("member") or {}).get("id") or member_info.get("id")
try:
member_info = wix_service.get_current_member(access_token)
if member_info and isinstance(member_info, dict):
member_id = (member_info.get("member") or {}).get("id") or member_info.get("id")
except Exception as e:
logger.warning(f"Wix: could not resolve member ID from token: {e}")
if not member_id:
return {
"success": False,
"error": "Unable to resolve Wix member ID. Please reconnect your Wix account.",
}
# Resolve categories: accept IDs or names (looked up/created)
# Resolve categories/tags: precedence is top-level params > seo_metadata fallback
category_ids = request.category_ids or request.category_names
tag_ids = request.tag_ids or request.tag_names
@@ -445,6 +612,9 @@ async def publish_to_wix(request: WixPublishRequest, current_user: dict = Depend
if not tag_ids and seo_metadata.get("blog_tags"):
tag_ids = seo_metadata.get("blog_tags")
if seo_metadata.get("url_slug"):
logger.info(f"Wix publish: using SEO url_slug for post slug: {seo_metadata.get('url_slug')[:50]}")
# Ensure category_ids and tag_ids are lists of strings (not ints)
if category_ids:
category_ids = [str(c) for c in category_ids if c is not None]
@@ -461,6 +631,7 @@ async def publish_to_wix(request: WixPublishRequest, current_user: dict = Depend
publish=request.publish,
member_id=member_id,
seo_metadata=seo_metadata,
site_id=site_id,
)
post = result.get("draftPost") or result.get("post") or result
raw_url = post.get("url")
@@ -474,7 +645,8 @@ async def publish_to_wix(request: WixPublishRequest, current_user: dict = Depend
"success": True,
"post_id": str(post.get("id", "")),
"url": post_url,
"publish_state": "PUBLISHED" if request.publish else "DRAFT"
"publish_state": "PUBLISHED" if request.publish else "DRAFT",
**({"warning": content_warning} if content_warning else {}),
}
except Exception as e:
logger.error(f"Failed to publish to Wix: {e}")