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
973 lines
38 KiB
Python
973 lines
38 KiB
Python
"""
|
|
Wix Integration API Routes
|
|
|
|
Handles Wix authentication, connection status, and blog publishing.
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException, Depends, Request
|
|
from fastapi.responses import HTMLResponse
|
|
from typing import Dict, Any, Optional
|
|
from loguru import logger
|
|
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
|
|
from services.integrations.oauth_callback_utils import (
|
|
build_oauth_callback_html,
|
|
sanitize_error,
|
|
)
|
|
from middleware.auth_middleware import get_current_user
|
|
|
|
router = APIRouter(prefix="/api/wix", tags=["Wix Integration"])
|
|
qa_router = APIRouter(prefix="/api/wix/test", tags=["Wix Integration QA"])
|
|
|
|
|
|
# Initialize Wix service
|
|
wix_service = WixService()
|
|
|
|
# Initialize Wix OAuth service for token storage
|
|
wix_oauth_service = WixOAuthService()
|
|
|
|
|
|
def _get_current_user_id(current_user: dict) -> str:
|
|
user_id = current_user.get("id") if current_user else None
|
|
if not user_id:
|
|
raise HTTPException(status_code=401, detail="Missing authenticated user context")
|
|
return user_id
|
|
|
|
|
|
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 = wix_error_detail or str(exc) if str(exc) != "" else fallback
|
|
|
|
if status == 401:
|
|
return HTTPException(
|
|
status_code=401,
|
|
detail=f"Wix authorization failed. Please reconnect your Wix account."
|
|
)
|
|
if status == 403:
|
|
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="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)
|
|
expired_tokens = token_status.get("expired_tokens", [])
|
|
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(
|
|
user_id=user_id,
|
|
access_token=refreshed.get("access_token"),
|
|
refresh_token=refreshed.get("refresh_token", refresh_token),
|
|
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),
|
|
"member_id": candidate.get("member_id"),
|
|
"site_id": candidate.get("site_id"),
|
|
}
|
|
|
|
raise HTTPException(status_code=401, detail="Wix token expired and cannot be refreshed")
|
|
|
|
|
|
class WixAuthRequest(BaseModel):
|
|
"""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):
|
|
"""Request model for publishing to Wix"""
|
|
title: str
|
|
content: str
|
|
cover_image_url: Optional[str] = None
|
|
category_ids: Optional[list] = None
|
|
category_names: Optional[list] = None
|
|
tag_ids: Optional[list] = None
|
|
tag_names: Optional[list] = None
|
|
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
|
|
label: str
|
|
description: Optional[str] = None
|
|
language: Optional[str] = None
|
|
|
|
|
|
class WixCreateTagRequest(BaseModel):
|
|
access_token: str
|
|
label: str
|
|
language: Optional[str] = None
|
|
|
|
|
|
class WixConnectionStatus(BaseModel):
|
|
"""Response model for Wix connection status"""
|
|
connected: bool
|
|
has_permissions: bool
|
|
site_info: Optional[Dict[str, Any]] = None
|
|
permissions: Optional[Dict[str, Any]] = None
|
|
error: Optional[str] = None
|
|
|
|
|
|
def _is_wix_test_mode_enabled() -> bool:
|
|
return os.getenv("WIX_TEST_ROUTES_ENABLED", "false").lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def _is_admin_user(current_user: Dict[str, Any]) -> bool:
|
|
email = (current_user.get("email") or "").lower()
|
|
role = current_user.get("role")
|
|
public_metadata = current_user.get("public_metadata")
|
|
if isinstance(public_metadata, dict):
|
|
role = public_metadata.get("role") or role
|
|
|
|
admin_emails = {
|
|
e.strip().lower()
|
|
for e in os.getenv("ADMIN_EMAILS", "").split(",")
|
|
if e.strip()
|
|
}
|
|
admin_domain = (os.getenv("ADMIN_EMAIL_DOMAIN") or "").lower().strip()
|
|
|
|
return bool(
|
|
role == "admin"
|
|
or (email and email in admin_emails)
|
|
or (email and admin_domain and email.endswith(f"@{admin_domain}"))
|
|
)
|
|
|
|
|
|
def _require_wix_test_access(current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
|
if not _is_wix_test_mode_enabled():
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
if not _is_admin_user(current_user):
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
return current_user
|
|
|
|
|
|
@router.get("/auth/url")
|
|
async def get_authorization_url(state: Optional[str] = None, current_user: dict = Depends(get_current_user)) -> Dict[str, str]:
|
|
"""
|
|
Get Wix OAuth authorization URL
|
|
|
|
Args:
|
|
state: Optional state parameter for security
|
|
|
|
Returns:
|
|
Authorization URL
|
|
"""
|
|
try:
|
|
user_id = current_user.get('id') if current_user else None
|
|
if not user_id:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
oauth_state = state or str(uuid.uuid4())
|
|
oauth_payload = wix_service.get_authorization_url(oauth_state)
|
|
saved = wix_oauth_service.store_pkce_verifier(
|
|
user_id=user_id,
|
|
state=oauth_state,
|
|
code_verifier=oauth_payload["code_verifier"],
|
|
ttl_seconds=600
|
|
)
|
|
if not saved:
|
|
raise HTTPException(status_code=500, detail="Failed to persist OAuth verifier state")
|
|
return {"authorization_url": oauth_payload["authorization_url"], "state": oauth_state}
|
|
except Exception as e:
|
|
logger.error(f"Failed to generate authorization URL: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/auth/callback")
|
|
async def handle_oauth_callback(request: WixAuthRequest, current_user: dict = Depends(get_current_user)) -> Dict[str, Any]:
|
|
"""
|
|
Handle OAuth callback and exchange code for tokens
|
|
|
|
Args:
|
|
request: OAuth callback request with code
|
|
current_user: Current authenticated user
|
|
|
|
Returns:
|
|
Token information and connection status
|
|
"""
|
|
try:
|
|
user_id = current_user.get('id')
|
|
if not user_id:
|
|
raise HTTPException(status_code=400, detail="User ID not found")
|
|
|
|
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 = {}
|
|
|
|
# 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}")
|
|
|
|
# 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")
|
|
|
|
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=access_token,
|
|
refresh_token=refresh_token,
|
|
expires_in=expires_in,
|
|
token_type=token_type,
|
|
site_id=site_id,
|
|
member_id=member_id
|
|
)
|
|
|
|
if not stored:
|
|
logger.warning(f"Failed to store Wix tokens for user {user_id}, but OAuth succeeded")
|
|
|
|
return {
|
|
"success": True,
|
|
"tokens": {
|
|
"access_token": access_token,
|
|
"refresh_token": refresh_token,
|
|
"expires_in": expires_in,
|
|
"token_type": token_type
|
|
},
|
|
"site_info": site_info,
|
|
"permissions": permissions,
|
|
"message": "Successfully connected to Wix"
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to handle OAuth callback: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/callback")
|
|
async def handle_oauth_callback_get(code: str, state: Optional[str] = None, request: Request = None, current_user: dict = Depends(get_current_user)):
|
|
"""HTML callback page for Wix OAuth that exchanges code and notifies opener via postMessage."""
|
|
try:
|
|
user_id = current_user.get('id') if current_user else None
|
|
if not user_id:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
if not state:
|
|
raise HTTPException(status_code=400, detail="Missing OAuth state")
|
|
code_verifier = wix_oauth_service.consume_pkce_verifier(user_id=user_id, state=state)
|
|
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)
|
|
|
|
# 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
|
|
member_id = None
|
|
try:
|
|
member_id = wix_service.extract_member_id_from_access_token(tokens['access_token'])
|
|
except Exception:
|
|
pass
|
|
|
|
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'),
|
|
site_id=site_id,
|
|
member_id=member_id
|
|
)
|
|
if not stored:
|
|
logger.warning(f"Failed to store Wix tokens for user {user_id} in GET callback")
|
|
|
|
# Build success payload for postMessage
|
|
payload = {
|
|
"type": "WIX_OAUTH_SUCCESS",
|
|
"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')
|
|
},
|
|
"site_info": site_info,
|
|
"permissions": permissions
|
|
}
|
|
|
|
html = build_oauth_callback_html(
|
|
payload=payload,
|
|
title="Wix Connected",
|
|
heading="Connection Successful",
|
|
message="Your Wix account was connected. You can close this window."
|
|
)
|
|
return HTMLResponse(content=html, headers={
|
|
"Cross-Origin-Opener-Policy": "unsafe-none",
|
|
"Cross-Origin-Embedder-Policy": "unsafe-none"
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"Wix OAuth GET callback failed: {e}")
|
|
html = build_oauth_callback_html(
|
|
payload={"type": "WIX_OAUTH_ERROR", "success": False, "error": sanitize_error(e)},
|
|
title="Wix Connection Failed",
|
|
heading="Connection Failed",
|
|
message="There was an issue connecting your Wix account. You can close this window and try again."
|
|
)
|
|
return HTMLResponse(content=html, headers={
|
|
"Cross-Origin-Opener-Policy": "unsafe-none",
|
|
"Cross-Origin-Embedder-Policy": "unsafe-none"
|
|
})
|
|
|
|
|
|
@router.get("/connection/status")
|
|
async def get_connection_status(current_user: dict = Depends(get_current_user)) -> Dict[str, Any]:
|
|
"""
|
|
Check Wix connection status and permissions.
|
|
Returns connected: false when no tokens are stored (instead of 401).
|
|
"""
|
|
try:
|
|
token_info = _resolve_valid_wix_token(current_user)
|
|
access_token = token_info["access_token"]
|
|
site_info = wix_service.get_site_info(access_token)
|
|
permissions = wix_service.check_blog_permissions(access_token)
|
|
return {
|
|
"connected": True,
|
|
"has_permissions": permissions.get("has_permissions", False),
|
|
"site_info": site_info,
|
|
"permissions": permissions
|
|
}
|
|
except HTTPException as e:
|
|
if e.status_code == 401:
|
|
return {"connected": False, "has_permissions": False, "error": "Wix account not connected"}
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to check connection status: {e}")
|
|
return {"connected": False, "has_permissions": False, "error": "Unable to check Wix connection"}
|
|
|
|
|
|
@router.get("/status")
|
|
async def get_wix_status(current_user: dict = Depends(get_current_user)) -> Dict[str, Any]:
|
|
"""
|
|
Get Wix connection status (similar to GSC/WordPress pattern)
|
|
"""
|
|
try:
|
|
token_info = _resolve_valid_wix_token(current_user)
|
|
site_info = wix_service.get_site_info(token_info["access_token"])
|
|
return {
|
|
"connected": True,
|
|
"sites": [site_info],
|
|
"total_sites": 1,
|
|
"site_info": site_info
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Failed to get Wix status: {e}")
|
|
mapped = _map_wix_error(e, "Failed to get Wix status")
|
|
raise mapped
|
|
|
|
|
|
@router.post("/publish")
|
|
async def publish_to_wix(request: WixPublishRequest, current_user: dict = Depends(get_current_user)) -> Dict[str, Any]:
|
|
"""
|
|
Publish blog post to Wix using server-stored OAuth tokens.
|
|
|
|
The backend resolves the access token from the database (via
|
|
_resolve_valid_wix_token), so callers do NOT need to pass
|
|
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
|
|
|
|
if not access_token:
|
|
return {
|
|
"success": False,
|
|
"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:
|
|
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/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
|
|
|
|
seo_metadata = request.seo_metadata
|
|
if seo_metadata:
|
|
if not category_ids and seo_metadata.get("blog_categories"):
|
|
category_ids = seo_metadata.get("blog_categories")
|
|
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]
|
|
if tag_ids:
|
|
tag_ids = [str(t) for t in tag_ids if t is not None]
|
|
|
|
result = wix_service.create_blog_post(
|
|
access_token=access_token,
|
|
title=request.title,
|
|
content=request.content,
|
|
cover_image_url=request.cover_image_url,
|
|
category_ids=category_ids,
|
|
tag_ids=tag_ids,
|
|
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")
|
|
if isinstance(raw_url, dict):
|
|
post_url = raw_url.get("base", "").rstrip("/") + "/" + raw_url.get("path", "").lstrip("/")
|
|
elif isinstance(raw_url, str):
|
|
post_url = raw_url
|
|
else:
|
|
post_url = None
|
|
return {
|
|
"success": True,
|
|
"post_id": str(post.get("id", "")),
|
|
"url": post_url,
|
|
"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}")
|
|
raise _map_wix_error(e, "Failed to publish to Wix")
|
|
|
|
|
|
@router.get("/categories")
|
|
async def get_blog_categories(current_user: dict = Depends(get_current_user)) -> Dict[str, Any]:
|
|
"""
|
|
Get available blog categories from Wix
|
|
|
|
Args:
|
|
current_user: Current authenticated user
|
|
|
|
Returns:
|
|
List of blog categories
|
|
"""
|
|
try:
|
|
token_info = _resolve_valid_wix_token(current_user)
|
|
categories = wix_service.get_blog_categories(token_info["access_token"])
|
|
return {
|
|
"success": True,
|
|
"categories": categories
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Failed to get blog categories: {e}")
|
|
raise _map_wix_error(e, "Failed to fetch Wix blog categories")
|
|
|
|
|
|
@router.get("/tags")
|
|
async def get_blog_tags(current_user: dict = Depends(get_current_user)) -> Dict[str, Any]:
|
|
"""
|
|
Get available blog tags from Wix
|
|
|
|
Args:
|
|
current_user: Current authenticated user
|
|
|
|
Returns:
|
|
List of blog tags
|
|
"""
|
|
try:
|
|
token_info = _resolve_valid_wix_token(current_user)
|
|
tags = wix_service.get_blog_tags(token_info["access_token"])
|
|
return {
|
|
"success": True,
|
|
"tags": tags
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Failed to get blog tags: {e}")
|
|
raise _map_wix_error(e, "Failed to fetch Wix blog tags")
|
|
|
|
|
|
@router.post("/disconnect")
|
|
async def disconnect_wix(current_user: dict = Depends(get_current_user)) -> Dict[str, Any]:
|
|
"""
|
|
Disconnect Wix account
|
|
|
|
Args:
|
|
current_user: Current authenticated user
|
|
|
|
Returns:
|
|
Disconnection status
|
|
"""
|
|
try:
|
|
user_id = _get_current_user_id(current_user)
|
|
token_status = wix_oauth_service.get_user_token_status(user_id)
|
|
all_tokens = token_status.get("active_tokens", []) + token_status.get("expired_tokens", [])
|
|
for token in all_tokens:
|
|
token_id = token.get("id")
|
|
if token_id:
|
|
wix_oauth_service.revoke_token(user_id, token_id)
|
|
return {
|
|
"success": True,
|
|
"connected": False,
|
|
"message": "Wix account disconnected successfully"
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to disconnect Wix: {e}")
|
|
raise _map_wix_error(e, "Failed to disconnect Wix account")
|
|
|
|
|
|
# =============================================================================
|
|
# TEST ENDPOINTS - No authentication required for testing
|
|
# =============================================================================
|
|
|
|
@qa_router.get("/connection/status")
|
|
async def get_test_connection_status(_: Dict[str, Any] = Depends(_require_wix_test_access)) -> WixConnectionStatus:
|
|
"""
|
|
TEST ENDPOINT: Check Wix connection status without authentication
|
|
|
|
Returns:
|
|
Connection status and permissions
|
|
"""
|
|
try:
|
|
logger.info("TEST: Checking Wix connection status (no auth required)")
|
|
|
|
return WixConnectionStatus(
|
|
connected=False,
|
|
has_permissions=False,
|
|
error="No stored tokens found. Please connect your Wix account first."
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"TEST: Failed to check connection status: {e}")
|
|
return WixConnectionStatus(
|
|
connected=False,
|
|
has_permissions=False,
|
|
error=str(e)
|
|
)
|
|
|
|
|
|
@qa_router.get("/auth/url")
|
|
async def get_test_authorization_url(state: Optional[str] = None, _: Dict[str, Any] = Depends(_require_wix_test_access)) -> Dict[str, str]:
|
|
"""
|
|
TEST ENDPOINT: Get Wix OAuth authorization URL without authentication
|
|
|
|
Args:
|
|
state: Optional state parameter for security
|
|
|
|
Returns:
|
|
Authorization URL for user to visit
|
|
"""
|
|
try:
|
|
logger.info("TEST: Generating Wix authorization URL (no auth required)")
|
|
|
|
# Check if Wix service is properly configured
|
|
if not wix_service.client_id:
|
|
logger.warning("TEST: Wix Client ID not configured, returning mock URL")
|
|
return {
|
|
"url": (
|
|
"https://www.wix.com/oauth/access?client_id=YOUR_CLIENT_ID"
|
|
"&redirect_uri=http://localhost:3000/wix/callback"
|
|
"&response_type=code&scope="
|
|
"BLOG.CREATE-DRAFT,BLOG.PUBLISH-POST,BLOG.READ-CATEGORY,"
|
|
"BLOG.CREATE-CATEGORY,BLOG.READ-TAG,BLOG.CREATE-TAG,"
|
|
"MEDIA.SITE_MEDIA_FILES_IMPORT"
|
|
"&code_challenge=test&code_challenge_method=S256"
|
|
),
|
|
"state": state or "test_state",
|
|
"message": "WIX_CLIENT_ID not configured. Please set it in your .env file to get a real authorization URL."
|
|
}
|
|
|
|
auth_payload = wix_service.get_authorization_url(state)
|
|
return {"url": auth_payload.get("authorization_url", ""), "state": state or "test_state"}
|
|
except Exception as e:
|
|
logger.error(f"TEST: Failed to generate authorization URL: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@qa_router.post("/publish")
|
|
async def test_publish_to_wix(request: WixPublishRequest, _: Dict[str, Any] = Depends(_require_wix_test_access)) -> Dict[str, Any]:
|
|
"""
|
|
TEST ENDPOINT: Simulate publishing a blog post to Wix without authentication.
|
|
|
|
Returns a fake success response so the frontend can validate the flow.
|
|
"""
|
|
try:
|
|
logger.info("TEST: Simulating publish to Wix (no auth required)")
|
|
return {
|
|
"success": True,
|
|
"post_id": "test_post_id",
|
|
"url": "https://example.com/blog/test-post",
|
|
"message": "Simulated blog post published successfully (test mode)"
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"TEST: Failed to simulate publish: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/refresh-token")
|
|
async def refresh_wix_token(current_user: dict = Depends(get_current_user)) -> Dict[str, Any]:
|
|
"""
|
|
Refresh Wix access token using stored refresh token.
|
|
|
|
Args:
|
|
current_user: Current authenticated user
|
|
|
|
Returns:
|
|
New token information with access_token, refresh_token, expires_in
|
|
"""
|
|
try:
|
|
user_id = _get_current_user_id(current_user)
|
|
token_status = wix_oauth_service.get_user_token_status(user_id)
|
|
all_tokens = token_status.get("active_tokens", []) + token_status.get("expired_tokens", [])
|
|
|
|
refresh_token = None
|
|
token_id = None
|
|
for t in all_tokens:
|
|
if t.get("refresh_token"):
|
|
refresh_token = t["refresh_token"]
|
|
token_id = t["id"]
|
|
break
|
|
|
|
if not refresh_token:
|
|
raise HTTPException(status_code=400, detail="No refresh token found. Please reconnect your Wix account.")
|
|
|
|
new_tokens = wix_service.refresh_access_token(refresh_token)
|
|
|
|
wix_oauth_service.update_tokens(
|
|
user_id=user_id,
|
|
access_token=new_tokens.get("access_token"),
|
|
refresh_token=new_tokens.get("refresh_token", refresh_token),
|
|
expires_in=new_tokens.get("expires_in"),
|
|
token_id=token_id,
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"expires_in": new_tokens.get("expires_in"),
|
|
"token_type": new_tokens.get("token_type", "Bearer")
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to refresh Wix token: {e}")
|
|
raise _map_wix_error(e, "Failed to refresh token")
|
|
|
|
|
|
@qa_router.post("/publish/real")
|
|
async def test_publish_real(payload: Dict[str, Any], _: Dict[str, Any] = Depends(_require_wix_test_access)) -> Dict[str, Any]:
|
|
"""
|
|
TEST ENDPOINT: Perform a real publish to Wix using a provided access token.
|
|
|
|
Notes:
|
|
- Expects request.access_token from the frontend's Wix SDK tokens
|
|
- Derives member_id server-side (required by Wix for third-party apps)
|
|
"""
|
|
try:
|
|
# Normalize access_token from payload (could be string, dict, or other format)
|
|
from services.integrations.wix.utils import normalize_token_string
|
|
raw_access_token = payload.get("access_token")
|
|
if not raw_access_token:
|
|
raise HTTPException(status_code=400, detail="Missing access_token")
|
|
|
|
# Normalize token to string (handles dict with accessToken.value, int, etc.)
|
|
access_token = normalize_token_string(raw_access_token)
|
|
if not access_token:
|
|
# Fallback: try to convert to string directly
|
|
access_token = str(raw_access_token).strip()
|
|
if not access_token or access_token == "None":
|
|
raise HTTPException(status_code=400, detail="Invalid access_token format")
|
|
|
|
# Derive current member id from token (try local decode first, then API fallback)
|
|
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")
|
|
)
|
|
if not member_id:
|
|
raise HTTPException(status_code=400, detail="Unable to resolve member_id from token")
|
|
|
|
# Extract SEO metadata if provided
|
|
seo_metadata = payload.get("seo_metadata")
|
|
|
|
# Extract category/tag IDs or names
|
|
# Can be either:
|
|
# - IDs: List of UUID strings
|
|
# - Names: List of name strings (will be looked up/created)
|
|
category_ids = payload.get("category_ids") or payload.get("category_names")
|
|
tag_ids = payload.get("tag_ids") or payload.get("tag_names")
|
|
|
|
# If SEO metadata has categories/tags but they weren't explicitly provided, use them
|
|
if seo_metadata:
|
|
if not category_ids and seo_metadata.get("blog_categories"):
|
|
category_ids = seo_metadata.get("blog_categories")
|
|
if not tag_ids and seo_metadata.get("blog_tags"):
|
|
tag_ids = seo_metadata.get("blog_tags")
|
|
|
|
result = wix_service.create_blog_post(
|
|
access_token=access_token,
|
|
title=payload.get("title") or "Untitled",
|
|
content=payload.get("content") or "",
|
|
cover_image_url=payload.get("cover_image_url"),
|
|
category_ids=category_ids,
|
|
tag_ids=tag_ids,
|
|
publish=bool(payload.get("publish", True)),
|
|
member_id=member_id,
|
|
seo_metadata=seo_metadata,
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"post_id": (result.get("draftPost") or result.get("post") or {}).get("id"),
|
|
"url": (result.get("draftPost") or result.get("post") or {}).get("url"),
|
|
"message": "Blog post published to Wix",
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"TEST: Real publish failed: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@qa_router.post("/category")
|
|
async def test_create_category(request: WixCreateCategoryRequest, _: Dict[str, Any] = Depends(_require_wix_test_access)) -> Dict[str, Any]:
|
|
try:
|
|
result = wix_service.create_category(
|
|
access_token=request.access_token,
|
|
label=request.label,
|
|
description=request.description,
|
|
language=request.language,
|
|
)
|
|
return {"success": True, "category": result.get("category", {}), "raw": result}
|
|
except Exception as e:
|
|
logger.error(f"TEST: Create category failed: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@qa_router.post("/tag")
|
|
async def test_create_tag(request: WixCreateTagRequest, _: Dict[str, Any] = Depends(_require_wix_test_access)) -> Dict[str, Any]:
|
|
try:
|
|
result = wix_service.create_tag(
|
|
access_token=request.access_token,
|
|
label=request.label,
|
|
language=request.language,
|
|
)
|
|
return {"success": True, "tag": result.get("tag", {}), "raw": result}
|
|
except Exception as e:
|
|
logger.error(f"TEST: Create tag failed: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|