feat: Brainstorm Topics with GSC + Issue #518 fixes + Blog Editor enhancements
Issue #518 - Subscription not updating after checkout: - Fix stale closure in SubscriptionContext checkout polling (use subscriptionRef) - Move checkout success polling from InitialRouteHandler into SubscriptionContext - Remove redundant polling code from InitialRouteHandler - Fix plan label: 'Free' instead of 'No Plan', proper capitalization - Add plan refresh button in UserBadge - Add 'View Costing Details' to UserBadge dropdown - Rename 'ALwrity Podcast Maker' to 'Podcast Creator' across UI - Clean subscription=success URL param after verification Blog Writer WYSIWYG Editor enhancements: - Per-section preview toggle (view/edit icons) - Enhanced hover-based toolbar - Circular SVG progress stats bar with detailed tooltip - Research tool chips in stats bar footer - Per-section TTS with useTextToSpeech hook (browser native) - Full blog preview modal with print/PDF support - PlayAllTTSButton: sequential playback with progress bar - OnThisPageNav: floating sidebar with scroll tracking - Section data attributes for scroll anchoring GSC Brainstorm Topics feature: - Backend: gsc_brainstorm_service.py (rule-based + LLM recommendations) - Backend: POST /gsc/brainstorm endpoint with 3-word minimum validation - Frontend: gscBrainstorm.ts API client - Frontend: useGSCBrainstormConnection hook (popup OAuth, no /onboarding redirect) - Frontend: useGSCBrainstorm hook (connect check + brainstorm call) - Frontend: GSCBrainstormModal (3-tab results: Opportunities, Gaps, AI Recs) - Frontend: BrainstormButton (visible at 3+ words, GSC connect overlay) - Wire BrainstormButton into ManualResearchForm and ResearchAction - Add blog_writer to gsc_auth router features for ALWRITY_ENABLED_FEATURES
This commit is contained in:
192
backend/api/charts.py
Normal file
192
backend/api/charts.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
Chart API — Shared chart generation endpoints for Blog Writer, Podcast Maker, etc.
|
||||
|
||||
Two modes:
|
||||
1. Explicit: POST /api/charts/generate with { chart_type, chart_data, title }
|
||||
2. AI-driven: POST /api/charts/generate with { text } → LLM infers chart_type + data
|
||||
|
||||
Both return { preview_url, chart_id, chart_type?, chart_data?, title? }
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from loguru import logger
|
||||
|
||||
from middleware.auth_middleware import get_current_user, get_current_user_with_query_token
|
||||
from api.story_writer.utils.auth import require_authenticated_user
|
||||
from services.chart_service import get_chart_service, VALID_CHART_TYPES
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/charts", tags=["Charts"])
|
||||
|
||||
|
||||
class ChartGenerateRequest(BaseModel):
|
||||
"""Request for chart generation.
|
||||
|
||||
Provide either:
|
||||
- chart_type + chart_data (explicit mode), OR
|
||||
- text (AI inference mode — LLM determines chart_type + data)
|
||||
"""
|
||||
chart_data: Optional[Dict[str, Any]] = Field(
|
||||
default=None,
|
||||
description="Chart data dict (labels, values, before/after, etc.)"
|
||||
)
|
||||
chart_type: Optional[str] = Field(
|
||||
default=None,
|
||||
description=f"Chart type: {', '.join(VALID_CHART_TYPES)}"
|
||||
)
|
||||
title: str = Field(default="", description="Chart title")
|
||||
subtitle: Optional[str] = Field(default="", description="Optional subtitle")
|
||||
text: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Text to infer chart from (AI mode). Mutually exclusive with chart_type+chart_data."
|
||||
)
|
||||
section_heading: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Blog section heading for context (AI mode with research)"
|
||||
)
|
||||
section_key_points: Optional[list] = Field(
|
||||
default=None,
|
||||
description="Key points from the section (AI mode with research)"
|
||||
)
|
||||
|
||||
|
||||
class ChartGenerateResponse(BaseModel):
|
||||
"""Response for chart generation."""
|
||||
preview_url: str = ""
|
||||
chart_id: str = ""
|
||||
chart_type: Optional[str] = None
|
||||
chart_data: Optional[Dict[str, Any]] = None
|
||||
title: Optional[str] = None
|
||||
warnings: list = Field(default_factory=list, description="Pipeline warnings (e.g. Exa search failures)")
|
||||
|
||||
|
||||
@router.post("/generate", response_model=ChartGenerateResponse)
|
||||
async def generate_chart(
|
||||
request: ChartGenerateRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Generate a chart PNG preview.
|
||||
|
||||
Two modes:
|
||||
1. Explicit: Provide chart_type + chart_data
|
||||
2. AI-driven: Provide text, and the LLM infers chart_type + chart_data
|
||||
"""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
try:
|
||||
chart_svc = get_chart_service(user_id=user_id)
|
||||
|
||||
if request.text and not request.chart_type:
|
||||
# AI inference mode
|
||||
logger.info(f"[Charts] AI inference mode for user {user_id}, text length={len(request.text)}")
|
||||
result = await chart_svc.generate_chart_from_text(
|
||||
text=request.text,
|
||||
user_id=user_id,
|
||||
section_heading=request.section_heading,
|
||||
section_key_points=request.section_key_points,
|
||||
)
|
||||
|
||||
if not result.get("path"):
|
||||
raise HTTPException(status_code=500, detail="Chart generation failed")
|
||||
|
||||
chart_id = result["chart_id"]
|
||||
filename = result.get("filename", f"chart_preview_{chart_id}.png")
|
||||
|
||||
return ChartGenerateResponse(
|
||||
preview_url=f"/api/charts/preview/{chart_id}/{filename}",
|
||||
chart_id=chart_id,
|
||||
chart_type=result.get("chart_type"),
|
||||
chart_data=result.get("chart_data"),
|
||||
title=result.get("title"),
|
||||
warnings=result.get("warnings", []),
|
||||
)
|
||||
|
||||
elif request.chart_type and request.chart_data:
|
||||
# Explicit mode
|
||||
chart_type = request.chart_type
|
||||
if chart_type not in VALID_CHART_TYPES:
|
||||
# Try normalizing aliases
|
||||
from services.chart_service import _normalize_chart_type
|
||||
chart_type = _normalize_chart_type(chart_type)
|
||||
if chart_type not in VALID_CHART_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid chart_type. Must be one of: {VALID_CHART_TYPES}"
|
||||
)
|
||||
|
||||
logger.info(f"[Charts] Explicit mode: type={chart_type}, user={user_id}")
|
||||
|
||||
chart_id = uuid.uuid4().hex[:8]
|
||||
result = chart_svc.generate_chart(
|
||||
chart_data=request.chart_data,
|
||||
chart_type=chart_type,
|
||||
title=request.title,
|
||||
subtitle=request.subtitle or "",
|
||||
chart_id=chart_id,
|
||||
)
|
||||
|
||||
if not result.get("path"):
|
||||
raise HTTPException(status_code=500, detail="Chart generation failed — check chart_data format")
|
||||
|
||||
filename = result.get("filename", f"chart_preview_{chart_id}.png")
|
||||
|
||||
return ChartGenerateResponse(
|
||||
preview_url=f"/api/charts/preview/{chart_id}/{filename}",
|
||||
chart_id=chart_id,
|
||||
chart_type=chart_type,
|
||||
chart_data=request.chart_data,
|
||||
title=request.title,
|
||||
)
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Provide either 'text' (AI mode) or 'chart_type' + 'chart_data' (explicit mode)"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Charts] Generation failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Chart generation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/preview/{chart_id}/{filename}")
|
||||
async def serve_chart_preview(
|
||||
chart_id: str,
|
||||
filename: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_with_query_token),
|
||||
):
|
||||
"""Serve chart preview PNG files. Auth via header or query token."""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
if ".." in filename or "/" in filename or "\\" in filename:
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
|
||||
chart_svc = get_chart_service(user_id=user_id)
|
||||
file_path = chart_svc.get_chart_preview_path(chart_id)
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Chart preview not found")
|
||||
|
||||
if not str(file_path.resolve()).startswith(str(chart_svc.output_dir.resolve())):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
media_type="image/png",
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def charts_health():
|
||||
"""Health check for Charts service."""
|
||||
return {"status": "ok", "service": "charts"}
|
||||
@@ -8,7 +8,7 @@ using Exa.ai integration, similar to the Exa.ai demo implementation.
|
||||
import time
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from models.hallucination_models import (
|
||||
@@ -24,6 +24,7 @@ from models.hallucination_models import (
|
||||
AssessmentType
|
||||
)
|
||||
from services.hallucination_detector import HallucinationDetector
|
||||
from middleware.auth_middleware import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,7 +35,7 @@ router = APIRouter(prefix="/api/hallucination-detector", tags=["Hallucination De
|
||||
detector = HallucinationDetector()
|
||||
|
||||
@router.post("/detect", response_model=HallucinationDetectionResponse)
|
||||
async def detect_hallucinations(request: HallucinationDetectionRequest) -> HallucinationDetectionResponse:
|
||||
async def detect_hallucinations(request: HallucinationDetectionRequest, current_user: Dict[str, Any] = Depends(get_current_user)) -> HallucinationDetectionResponse:
|
||||
"""
|
||||
Detect hallucinations in the provided text.
|
||||
|
||||
@@ -54,8 +55,10 @@ async def detect_hallucinations(request: HallucinationDetectionRequest) -> Hallu
|
||||
try:
|
||||
logger.info(f"Starting hallucination detection for text of length: {len(request.text)}")
|
||||
|
||||
user_id = current_user.get("id")
|
||||
|
||||
# Perform hallucination detection
|
||||
result = await detector.detect_hallucinations(request.text)
|
||||
result = await detector.detect_hallucinations(request.text, user_id=user_id)
|
||||
|
||||
# Convert to response format
|
||||
claims = []
|
||||
@@ -113,6 +116,8 @@ async def detect_hallucinations(request: HallucinationDetectionRequest) -> Hallu
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
if isinstance(e, HTTPException):
|
||||
raise e
|
||||
logger.error(f"Error in hallucination detection: {str(e)}")
|
||||
processing_time = int((time.time() - start_time) * 1000)
|
||||
|
||||
@@ -174,7 +179,7 @@ async def extract_claims(request: ClaimExtractionRequest) -> ClaimExtractionResp
|
||||
)
|
||||
|
||||
@router.post("/verify-claim", response_model=ClaimVerificationResponse)
|
||||
async def verify_claim(request: ClaimVerificationRequest) -> ClaimVerificationResponse:
|
||||
async def verify_claim(request: ClaimVerificationRequest, current_user: Dict[str, Any] = Depends(get_current_user)) -> ClaimVerificationResponse:
|
||||
"""
|
||||
Verify a single claim against available sources.
|
||||
|
||||
@@ -192,8 +197,10 @@ async def verify_claim(request: ClaimVerificationRequest) -> ClaimVerificationRe
|
||||
try:
|
||||
logger.info(f"Verifying claim: {request.claim[:100]}...")
|
||||
|
||||
user_id = current_user.get("id")
|
||||
|
||||
# Verify the claim
|
||||
claim_result = await detector._verify_claim(request.claim)
|
||||
claim_result = await detector._verify_claim(request.claim, user_id=user_id)
|
||||
|
||||
# Convert to response format
|
||||
supporting_sources = []
|
||||
@@ -246,6 +253,8 @@ async def verify_claim(request: ClaimVerificationRequest) -> ClaimVerificationRe
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
if isinstance(e, HTTPException):
|
||||
raise e
|
||||
logger.error(f"Error in claim verification: {str(e)}")
|
||||
processing_time = int((time.time() - start_time) * 1000)
|
||||
|
||||
@@ -273,17 +282,21 @@ async def health_check() -> HealthCheckResponse:
|
||||
HealthCheckResponse with service status and API availability
|
||||
"""
|
||||
try:
|
||||
# Check API availability
|
||||
exa_available = bool(detector.exa_api_key)
|
||||
openai_available = bool(detector.openai_api_key)
|
||||
from services.blog_writer.research.exa_provider import ExaResearchProvider
|
||||
try:
|
||||
exa_provider = ExaResearchProvider()
|
||||
exa_available = bool(exa_provider.api_key)
|
||||
except RuntimeError:
|
||||
exa_available = False
|
||||
llm_available = True # llm_text_gen handles provider selection via GPT_PROVIDER
|
||||
|
||||
status = "healthy" if (exa_available or openai_available) else "degraded"
|
||||
status = "healthy" if (exa_available and llm_available) else ("degraded" if exa_available or llm_available else "unhealthy")
|
||||
|
||||
response = HealthCheckResponse(
|
||||
status=status,
|
||||
version="1.0.0",
|
||||
exa_api_available=exa_available,
|
||||
openai_api_available=openai_available,
|
||||
openai_api_available=llm_available,
|
||||
timestamp=time.strftime('%Y-%m-%dT%H:%M:%S')
|
||||
)
|
||||
|
||||
|
||||
185
backend/api/links.py
Normal file
185
backend/api/links.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Link Search API — Internal & external link discovery and reword-with-links.
|
||||
|
||||
Endpoints:
|
||||
POST /api/links/search — Search for internal or external links via Exa
|
||||
POST /api/links/reword — Reword text to naturally incorporate selected links
|
||||
GET /api/links/health — Health check
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from loguru import logger
|
||||
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from api.story_writer.utils.auth import require_authenticated_user
|
||||
from services.link_search_service import get_link_search_service
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/links", tags=["Links"])
|
||||
|
||||
|
||||
class LinkSearchRequest(BaseModel):
|
||||
"""Request for link search (internal or external)."""
|
||||
query: str = Field(..., description="Search query (typically section heading or topic)")
|
||||
link_type: str = Field(
|
||||
...,
|
||||
description="Type of links: 'internal' or 'external'",
|
||||
)
|
||||
site_url: Optional[str] = Field(
|
||||
default=None,
|
||||
description="User's website URL (required for internal links, optional for external to exclude own domain)",
|
||||
)
|
||||
num_results: int = Field(default=5, description="Number of results to return", ge=1, le=15)
|
||||
|
||||
|
||||
class LinkSearchResult(BaseModel):
|
||||
"""A single link search result."""
|
||||
title: str = ""
|
||||
url: str = ""
|
||||
text: str = ""
|
||||
publishedDate: str = ""
|
||||
author: str = ""
|
||||
score: float = 0.5
|
||||
|
||||
|
||||
class LinkSearchResponse(BaseModel):
|
||||
"""Response for link search."""
|
||||
results: List[LinkSearchResult] = Field(default_factory=list)
|
||||
warnings: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RewordRequest(BaseModel):
|
||||
"""Request to reword text with selected links."""
|
||||
section_text: str = Field(..., description="Full section text")
|
||||
selected_text: Optional[str] = Field(
|
||||
default=None,
|
||||
description="If provided, only reword this portion of the text",
|
||||
)
|
||||
section_heading: Optional[str] = Field(default=None, description="Section heading for context")
|
||||
links: List[Dict[str, str]] = Field(
|
||||
...,
|
||||
description="List of {'url': str, 'title': str} dicts to incorporate",
|
||||
)
|
||||
|
||||
|
||||
class RewordResponse(BaseModel):
|
||||
"""Response for reword-with-links."""
|
||||
reworded_text: str = ""
|
||||
warnings: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@router.post("/search", response_model=LinkSearchResponse)
|
||||
async def search_links(
|
||||
request: LinkSearchRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Search for internal or external links using Exa."""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
if request.link_type not in ("internal", "external"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="link_type must be 'internal' or 'external'",
|
||||
)
|
||||
|
||||
if request.link_type == "internal" and not request.site_url:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="site_url is required for internal link search",
|
||||
)
|
||||
|
||||
if len(request.query) > 500:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Query must be 500 characters or less",
|
||||
)
|
||||
|
||||
service = get_link_search_service(user_id=user_id)
|
||||
|
||||
try:
|
||||
if request.link_type == "internal":
|
||||
logger.info(f"[Links] Internal search: query='{request.query[:50]}', site='{request.site_url}', user={user_id}")
|
||||
result = await service.search_internal(
|
||||
query=request.query,
|
||||
site_url=request.site_url,
|
||||
user_id=user_id,
|
||||
num_results=request.num_results,
|
||||
)
|
||||
else:
|
||||
logger.info(f"[Links] External search: query='{request.query[:50]}', user={user_id}")
|
||||
result = await service.search_external(
|
||||
query=request.query,
|
||||
site_url=request.site_url,
|
||||
user_id=user_id,
|
||||
num_results=request.num_results,
|
||||
)
|
||||
|
||||
return LinkSearchResponse(
|
||||
results=[LinkSearchResult(**r) for r in result.get("results", [])],
|
||||
warnings=result.get("warnings", []),
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Links] Search failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Link search failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/reword", response_model=RewordResponse)
|
||||
async def reword_with_links(
|
||||
request: RewordRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Reword text to naturally incorporate selected links."""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
if not request.links:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="At least one link must be provided",
|
||||
)
|
||||
|
||||
# Validate each link has a url
|
||||
for i, link in enumerate(request.links):
|
||||
if not link.get("url"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Link at index {i} is missing a 'url' field",
|
||||
)
|
||||
|
||||
if len(request.section_text) > 10000:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="section_text must be 10000 characters or less",
|
||||
)
|
||||
|
||||
service = get_link_search_service(user_id=user_id)
|
||||
|
||||
try:
|
||||
logger.info(f"[Links] Reword: heading='{request.section_heading}', links={len(request.links)}, user={user_id}")
|
||||
result = service.reword_with_links(
|
||||
section_text=request.section_text,
|
||||
links=request.links,
|
||||
section_heading=request.section_heading,
|
||||
selected_text=request.selected_text,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return RewordResponse(
|
||||
reworded_text=result.get("reworded_text", request.section_text),
|
||||
warnings=result.get("warnings", []),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Links] Reword failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Reword failed: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def links_health():
|
||||
"""Health check for Links service."""
|
||||
return {"status": "ok", "service": "links"}
|
||||
@@ -123,3 +123,187 @@ async def stripe_webhook(
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing webhook: {e}")
|
||||
raise HTTPException(status_code=500, detail="Webhook processing failed")
|
||||
|
||||
@router.get("/verify-checkout/{user_id}")
|
||||
async def verify_checkout_status(
|
||||
user_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
request: Request = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Directly query Stripe for user's current subscription status.
|
||||
Used during post-checkout polling to get fresh data without waiting for webhooks.
|
||||
|
||||
Rate limited: 5 requests per minute per user to prevent abuse.
|
||||
"""
|
||||
from ..dependencies import verify_user_access
|
||||
from models.subscription_models import UserSubscription, SubscriptionPlan, SubscriptionTier
|
||||
from services.subscription import PricingService
|
||||
from api.subscription.utils import format_plan_limits
|
||||
from datetime import datetime
|
||||
|
||||
verify_user_access(user_id, current_user)
|
||||
|
||||
# Rate limiting: 5 requests per minute per user
|
||||
now = time.time()
|
||||
window_start = now - 60 # 1 minute window
|
||||
if user_id not in _checkout_attempts_by_user:
|
||||
_checkout_attempts_by_user[user_id] = []
|
||||
attempts = _checkout_attempts_by_user[user_id]
|
||||
attempts[:] = [ts for ts in attempts if ts >= window_start]
|
||||
attempts.append(now)
|
||||
_checkout_attempts_by_user[user_id] = attempts
|
||||
|
||||
if len(attempts) > 5:
|
||||
client_ip = request.client.host if request and request.client else "unknown"
|
||||
logger.warning(f"Verify-checkout rate limit exceeded for user_id={user_id}, ip={client_ip}")
|
||||
raise HTTPException(status_code=429, detail="Too many verification requests. Please wait before trying again.")
|
||||
|
||||
stripe_service = StripeService(db)
|
||||
|
||||
try:
|
||||
# First, try to find user in local DB
|
||||
subscription = db.query(UserSubscription).filter(
|
||||
UserSubscription.user_id == user_id
|
||||
).first()
|
||||
|
||||
stripe_customer_id = subscription.stripe_customer_id if subscription else None
|
||||
|
||||
# If no stripe_customer_id in DB, try to find it by email
|
||||
if not stripe_customer_id:
|
||||
try:
|
||||
import stripe
|
||||
# Get user email from auth context
|
||||
user_email = current_user.get("email")
|
||||
if user_email:
|
||||
customers = stripe.Customer.list(email=user_email, limit=1)
|
||||
if customers and customers.data:
|
||||
stripe_customer_id = customers.data[0].id
|
||||
logger.info(f"Verify-checkout: Found Stripe customer by email for user {user_id}")
|
||||
|
||||
# Update DB with found customer ID
|
||||
if subscription:
|
||||
subscription.stripe_customer_id = stripe_customer_id
|
||||
db.commit()
|
||||
else:
|
||||
logger.info(f"Verify-checkout: No local subscription record for user {user_id}, will query Stripe directly")
|
||||
except Exception as email_err:
|
||||
logger.warning(f"Failed to find Stripe customer by email: {email_err}")
|
||||
|
||||
# If user has a Stripe customer ID, query Stripe directly
|
||||
if stripe_customer_id:
|
||||
try:
|
||||
import stripe
|
||||
stripe_subscriptions = stripe.Subscription.list(
|
||||
customer=stripe_customer_id,
|
||||
status="active",
|
||||
limit=1
|
||||
)
|
||||
|
||||
if stripe_subscriptions and stripe_subscriptions.data:
|
||||
stripe_sub = stripe_subscriptions.data[0]
|
||||
price_id = stripe_sub['items']['data'][0]['price']['id']
|
||||
|
||||
logger.info(f"Verify-checkout: Found active Stripe subscription for user {user_id}, plan from price {price_id}")
|
||||
|
||||
# Update local DB with fresh Stripe data
|
||||
stripe_service._update_user_subscription(
|
||||
user_id,
|
||||
stripe_customer_id=stripe_customer_id,
|
||||
stripe_subscription_id=stripe_sub.id,
|
||||
status="active",
|
||||
price_id=price_id
|
||||
)
|
||||
|
||||
# Clear caches
|
||||
try:
|
||||
PricingService.clear_user_cache(user_id)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from api.subscription.cache import clear_dashboard_cache
|
||||
clear_dashboard_cache(user_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db.expire_all()
|
||||
|
||||
# Re-query with fresh data
|
||||
subscription = db.query(UserSubscription).filter(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.is_active == True
|
||||
).first()
|
||||
|
||||
if subscription:
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"active": True,
|
||||
"plan": subscription.plan.tier.value,
|
||||
"tier": subscription.plan.tier.value,
|
||||
"can_use_api": True,
|
||||
"limits": format_plan_limits(subscription.plan),
|
||||
"source": "stripe_direct"
|
||||
}
|
||||
}
|
||||
except Exception as stripe_err:
|
||||
logger.warning(f"Failed to query Stripe directly for user {user_id}: {stripe_err}")
|
||||
|
||||
# Fallback to local DB status
|
||||
if subscription and subscription.is_active:
|
||||
from services.subscription.pricing_service import PricingService
|
||||
pricing = PricingService(db)
|
||||
try:
|
||||
pricing._ensure_subscription_current(subscription)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"active": True,
|
||||
"plan": subscription.plan.tier.value,
|
||||
"tier": subscription.plan.tier.value,
|
||||
"can_use_api": True,
|
||||
"limits": format_plan_limits(subscription.plan),
|
||||
"source": "local_db"
|
||||
}
|
||||
}
|
||||
|
||||
# No active subscription - return free tier
|
||||
free_plan = db.query(SubscriptionPlan).filter(
|
||||
SubscriptionPlan.tier == SubscriptionTier.FREE,
|
||||
SubscriptionPlan.is_active == True
|
||||
).first()
|
||||
|
||||
if free_plan:
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"active": True,
|
||||
"plan": "free",
|
||||
"tier": "free",
|
||||
"can_use_api": True,
|
||||
"limits": format_plan_limits(free_plan),
|
||||
"source": "free_tier"
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"active": False,
|
||||
"plan": "none",
|
||||
"tier": "none",
|
||||
"can_use_api": False,
|
||||
"reason": "No active subscription found",
|
||||
"source": "none"
|
||||
}
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error verifying checkout status for user {user_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to verify subscription: {str(e)}")
|
||||
|
||||
@@ -156,10 +156,13 @@ class WixPublishRequest(BaseModel):
|
||||
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
|
||||
# Optional access token for test-real publish flow
|
||||
access_token: Optional[str] = None
|
||||
member_id: Optional[str] = None
|
||||
seo_metadata: Optional[Dict[str, Any]] = None
|
||||
class WixCreateCategoryRequest(BaseModel):
|
||||
access_token: str
|
||||
label: str
|
||||
@@ -398,31 +401,29 @@ async def handle_oauth_callback_get(code: str, state: Optional[str] = None, requ
|
||||
|
||||
|
||||
@router.get("/connection/status")
|
||||
async def get_connection_status(current_user: dict = Depends(get_current_user)) -> WixConnectionStatus:
|
||||
async def get_connection_status(current_user: dict = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
"""
|
||||
Check Wix connection status and permissions
|
||||
|
||||
Args:
|
||||
current_user: Current authenticated user
|
||||
|
||||
Returns:
|
||||
Connection status and permissions
|
||||
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 WixConnectionStatus(
|
||||
connected=True,
|
||||
has_permissions=permissions.get("has_permissions", False),
|
||||
site_info=site_info,
|
||||
permissions=permissions
|
||||
)
|
||||
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}
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check connection status: {e}")
|
||||
mapped = _map_wix_error(e, "Failed to check Wix connection status")
|
||||
raise mapped
|
||||
return {"connected": False, "has_permissions": False}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
@@ -450,41 +451,81 @@ async def get_wix_status(current_user: dict = Depends(get_current_user)) -> Dict
|
||||
@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
|
||||
Publish blog post to Wix using server-stored OAuth tokens.
|
||||
|
||||
Args:
|
||||
request: Blog post data
|
||||
current_user: Current authenticated user
|
||||
|
||||
Returns:
|
||||
Published blog post information
|
||||
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:
|
||||
token_info = _resolve_valid_wix_token(current_user)
|
||||
access_token = token_info["access_token"]
|
||||
if request.access_token:
|
||||
from services.integrations.wix.utils import normalize_token_string
|
||||
access_token = normalize_token_string(request.access_token)
|
||||
else:
|
||||
try:
|
||||
token_info = _resolve_valid_wix_token(current_user)
|
||||
access_token = token_info["access_token"]
|
||||
except HTTPException:
|
||||
access_token = None
|
||||
|
||||
member_id = token_info.get("member_id") or wix_service.extract_member_id_from_access_token(access_token)
|
||||
if not access_token:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Wix account not connected. Connect your Wix account first.",
|
||||
}
|
||||
|
||||
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")
|
||||
if not member_id:
|
||||
raise HTTPException(status_code=401, detail="Unable to resolve Wix 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)
|
||||
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")
|
||||
|
||||
# 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=request.category_ids,
|
||||
tag_ids=request.tag_ids,
|
||||
category_ids=category_ids,
|
||||
tag_ids=tag_ids,
|
||||
publish=request.publish,
|
||||
member_id=member_id,
|
||||
seo_metadata=seo_metadata,
|
||||
)
|
||||
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": post.get("id"),
|
||||
"url": post.get("url"),
|
||||
"post_id": str(post.get("id", "")),
|
||||
"url": post_url,
|
||||
"publish_state": "PUBLISHED" if request.publish else "DRAFT"
|
||||
}
|
||||
except Exception as e:
|
||||
|
||||
@@ -55,6 +55,8 @@ async def suggest_endpoint(req: SuggestRequest, current_user: Dict[str, Any] = D
|
||||
for s in suggestions
|
||||
],
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Writing assistant error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
Reference in New Issue
Block a user