forked from sagnik/Project_Velocity
#38 Ipad app production readiness, Colony orchestration, Social posting Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: sagnik/Project_Velocity#44
623 lines
25 KiB
Python
623 lines
25 KiB
Python
"""
|
||
routes_catalyst.py
|
||
Meta Marketing API wrappers for The Catalyst module.
|
||
|
||
Routes:
|
||
POST /api/catalyst/campaigns/create — Bulk campaign creation
|
||
POST /api/catalyst/creative/sync — Upload ComfyUI assets to Meta
|
||
GET /api/catalyst/insights/realtime — Poll Ads Insights API
|
||
POST /api/catalyst/audiences/lookalike — Push CRM leads → Meta Custom Audience
|
||
POST /api/catalyst/auth/meta — OAuth token acquisition
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import uuid
|
||
import hashlib
|
||
import logging
|
||
from typing import Any
|
||
from datetime import datetime
|
||
|
||
import httpx
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||
from pydantic import BaseModel, Field
|
||
|
||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||
from backend.services.ad_network_service import (
|
||
AdInsight,
|
||
BidStrategyUpdate,
|
||
BudgetUpdate,
|
||
Platform,
|
||
ad_network_service,
|
||
)
|
||
from backend.services.social_posting import (
|
||
PostRequest,
|
||
PostStatus,
|
||
SocialPlatform,
|
||
SocialPostingConfigurationError,
|
||
SocialPostingError,
|
||
get_post,
|
||
list_posts,
|
||
publish_content,
|
||
publish_due_scheduled,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
|
||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
def _get_sdk() -> tuple[Any, str]:
|
||
"""
|
||
Initialise the facebook-business SDK lazily.
|
||
Returns (FacebookAdsApi instance, ad_account_id).
|
||
Raises HTTPException 503 if credentials are missing or SDK init fails.
|
||
"""
|
||
try:
|
||
from facebook_business.api import FacebookAdsApi # type: ignore
|
||
access_token = os.getenv("META_ACCESS_TOKEN", "")
|
||
app_id = os.getenv("META_APP_ID", "")
|
||
app_secret = os.getenv("META_APP_SECRET", "")
|
||
account_id = os.getenv("META_AD_ACCOUNT_ID", "")
|
||
|
||
if not access_token or access_token.startswith("PLACEHOLDER"):
|
||
raise ValueError("META_ACCESS_TOKEN is not configured.")
|
||
if not account_id or account_id.startswith("PLACEHOLDER"):
|
||
raise ValueError("META_AD_ACCOUNT_ID is not configured.")
|
||
|
||
FacebookAdsApi.init(app_id, app_secret, access_token)
|
||
return FacebookAdsApi.get_default_api(), account_id
|
||
except ImportError:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail="facebook-business SDK not installed. Run: pip install facebook-business",
|
||
)
|
||
except ValueError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail=str(exc),
|
||
)
|
||
|
||
|
||
def _get_supabase():
|
||
"""Initialise the Supabase client lazily."""
|
||
try:
|
||
from supabase import create_client # type: ignore
|
||
url = os.getenv("SUPABASE_URL", "")
|
||
key = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "")
|
||
if not url or url.startswith("PLACEHOLDER"):
|
||
raise ValueError("SUPABASE_URL is not configured.")
|
||
return create_client(url, key)
|
||
except ImportError:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail="supabase SDK not installed. Run: pip install supabase",
|
||
)
|
||
except ValueError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail=str(exc),
|
||
)
|
||
|
||
|
||
def _ok(data: Any, meta: dict | None = None) -> dict:
|
||
return {"status": "ok", "data": data, "meta": meta or {}}
|
||
|
||
|
||
def _get_db_pool(request: Request) -> Any:
|
||
pool = getattr(request.app.state, "db_pool", None)
|
||
if pool is None:
|
||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||
return pool
|
||
|
||
|
||
def _sha256_hash(value: str) -> str:
|
||
"""SHA-256 hash an email for Meta's hashed audience upload."""
|
||
return hashlib.sha256(value.strip().lower().encode()).hexdigest()
|
||
|
||
|
||
# ── Request / Response Models ─────────────────────────────────────────────────
|
||
|
||
class CampaignCreateRequest(BaseModel):
|
||
name: str = Field(..., description="Campaign display name")
|
||
platform: Platform = Field(default=Platform.META, description="Target ad network platform")
|
||
objective: str = Field("OUTCOME_LEADS", description="Meta campaign objective enum")
|
||
budget_daily: int = Field(..., gt=0, description="Daily budget in cents (AED × 100)")
|
||
status: str = Field("PAUSED", description="Initial campaign status — start PAUSED for review")
|
||
special_ad_categories: list[str] = Field(default_factory=list)
|
||
|
||
|
||
class CampaignCreateResponse(BaseModel):
|
||
campaign_id: str
|
||
name: str
|
||
status: str
|
||
created_at: str
|
||
|
||
|
||
class CreativeSyncRequest(BaseModel):
|
||
asset_url: str = Field(..., description="Public URL of the ComfyUI-rendered image or video")
|
||
asset_name: str = Field(..., description="Human-readable asset name")
|
||
asset_type: str = Field(..., description="'image' or 'video'")
|
||
ad_account_id: str | None = Field(None, description="Override ad account ID (optional)")
|
||
|
||
|
||
class LookalikeAudienceRequest(BaseModel):
|
||
country: str = Field("AE", description="ISO 3166-1 alpha-2 country code for lookalike")
|
||
ratio: float = Field(0.01, ge=0.01, le=0.20, description="Lookalike ratio (1%–20%)")
|
||
crm_filter_status: str = Field("Closed/Won", description="Supabase lead status to filter on")
|
||
|
||
|
||
class MetaAuthRequest(BaseModel):
|
||
short_lived_token: str = Field(..., description="Short-lived user access token from Meta OAuth")
|
||
|
||
|
||
@router.get("/campaigns", summary="List unified campaign summaries for the Catalyst marketing tab")
|
||
async def list_campaigns(platform: Platform | None = Query(default=None)) -> dict:
|
||
campaigns = await ad_network_service.list_campaigns(platform=platform)
|
||
insights = await ad_network_service.get_insights(platform=platform, days=7)
|
||
rollup: dict[str, dict[str, float]] = {}
|
||
for insight in insights:
|
||
insight_campaign_id = insight.campaign_id if isinstance(insight, AdInsight) else insight.get("campaign_id")
|
||
if not insight_campaign_id:
|
||
continue
|
||
spent = insight.spend if isinstance(insight, AdInsight) else float(insight.get("spend", 0))
|
||
impressions = insight.impressions if isinstance(insight, AdInsight) else int(insight.get("impressions", 0))
|
||
clicks = insight.clicks if isinstance(insight, AdInsight) else int(insight.get("clicks", 0))
|
||
conversions = insight.conversions if isinstance(insight, AdInsight) else int(insight.get("conversions", 0))
|
||
slot = rollup.setdefault(
|
||
insight_campaign_id,
|
||
{
|
||
"spent": 0.0,
|
||
"impressions": 0.0,
|
||
"clicks": 0.0,
|
||
"conversions": 0.0,
|
||
},
|
||
)
|
||
slot["spent"] += spent
|
||
slot["impressions"] += impressions
|
||
slot["clicks"] += clicks
|
||
slot["conversions"] += conversions
|
||
data = [
|
||
{
|
||
"id": campaign.id,
|
||
"name": campaign.name,
|
||
"platform": campaign.platform.value,
|
||
"status": campaign.status.value,
|
||
"budget": campaign.daily_budget,
|
||
"spent": round(rollup.get(campaign.id, {}).get("spent", campaign.spent), 2),
|
||
"impressions": int(rollup.get(campaign.id, {}).get("impressions", 0)),
|
||
"clicks": int(rollup.get(campaign.id, {}).get("clicks", 0)),
|
||
"conversions": int(rollup.get(campaign.id, {}).get("conversions", 0)),
|
||
"objective": campaign.objective,
|
||
"bid_strategy": campaign.bid_strategy,
|
||
}
|
||
for campaign in campaigns
|
||
]
|
||
source = "ad_network_service_live" if platform else "ad_network_service_unified"
|
||
return _ok(data, meta={"count": len(data), "source": source})
|
||
|
||
|
||
# ── 1. POST /campaigns/create ─────────────────────────────────────────────────
|
||
|
||
@router.post("/campaigns/create", summary="Create Meta or Google marketing campaigns")
|
||
async def create_campaigns(
|
||
request: Request,
|
||
payload: CampaignCreateRequest,
|
||
) -> dict:
|
||
"""
|
||
Triggers `facebook_business.adobjects.campaign.Campaign` to create a campaign
|
||
under the configured Ad Account.
|
||
|
||
Requires: META_ACCESS_TOKEN, META_AD_ACCOUNT_ID
|
||
"""
|
||
if payload.platform == Platform.GOOGLE:
|
||
campaign_id = f"google-camp-{uuid.uuid4().hex[:8]}"
|
||
if hasattr(request.app.state, "broadcast_live_event"):
|
||
await request.app.state.broadcast_live_event(
|
||
"create",
|
||
f"Created Google Ads campaign '{payload.name}'.",
|
||
payload.name,
|
||
f"Budget: AED {payload.budget_daily / 100:.0f}/day",
|
||
)
|
||
return _ok(
|
||
CampaignCreateResponse(
|
||
campaign_id=campaign_id,
|
||
name=payload.name,
|
||
status=payload.status,
|
||
created_at=datetime.utcnow().isoformat(),
|
||
).model_dump(),
|
||
meta={"platform": "google", "mode": "simulated_or_provider_managed"},
|
||
)
|
||
|
||
_api, account_id = _get_sdk()
|
||
|
||
try:
|
||
from facebook_business.adobjects.adaccount import AdAccount # type: ignore
|
||
from facebook_business.adobjects.campaign import Campaign # type: ignore
|
||
|
||
account = AdAccount(account_id)
|
||
params = {
|
||
Campaign.Field.name: payload.name,
|
||
Campaign.Field.objective: payload.objective,
|
||
Campaign.Field.status: payload.status,
|
||
Campaign.Field.daily_budget: payload.budget_daily,
|
||
Campaign.Field.special_ad_categories: payload.special_ad_categories,
|
||
}
|
||
campaign = account.create_campaign(params=params)
|
||
|
||
# Broadcast live event via WebSocket
|
||
if hasattr(request.app.state, "broadcast_live_event"):
|
||
await request.app.state.broadcast_live_event(
|
||
"create",
|
||
f"Created campaign '{payload.name}' (objective: {payload.objective}).",
|
||
payload.name,
|
||
f"Budget: AED {payload.budget_daily / 100:.0f}/day",
|
||
)
|
||
|
||
return _ok(
|
||
CampaignCreateResponse(
|
||
campaign_id=campaign["id"],
|
||
name=payload.name,
|
||
status=payload.status,
|
||
created_at=datetime.utcnow().isoformat(),
|
||
).model_dump(),
|
||
meta={"account_id": account_id},
|
||
)
|
||
except Exception as exc:
|
||
logger.error("Campaign creation failed: %s", exc)
|
||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||
|
||
|
||
# ── 2. POST /creative/sync ────────────────────────────────────────────────────
|
||
|
||
@router.post("/creative/sync", summary="Upload ComfyUI asset to Meta Ad Library")
|
||
async def sync_creative(
|
||
request: Request,
|
||
payload: CreativeSyncRequest,
|
||
) -> dict:
|
||
"""
|
||
Uploads an image or video URL (from ComfyUI / Wan 2.2 / Qwen-Image 2512) to
|
||
the Meta Ad Library (Creative Hub) and returns the Meta Asset ID.
|
||
|
||
Requires: META_ACCESS_TOKEN, META_AD_ACCOUNT_ID
|
||
"""
|
||
_api, account_id = _get_sdk()
|
||
account_id = payload.ad_account_id or account_id
|
||
|
||
try:
|
||
from facebook_business.adobjects.adaccount import AdAccount # type: ignore
|
||
from facebook_business.adobjects.advideo import AdVideo # type: ignore
|
||
from facebook_business.adobjects.adimage import AdImage # type: ignore
|
||
|
||
account = AdAccount(account_id)
|
||
|
||
if payload.asset_type == "video":
|
||
# Video upload via file_url
|
||
result = account.create_ad_video(params={
|
||
AdVideo.Field.name: payload.asset_name,
|
||
AdVideo.Field.file_url: payload.asset_url,
|
||
})
|
||
meta_asset_id = result["id"]
|
||
else:
|
||
# Image upload via url
|
||
result = account.create_ad_image(params={
|
||
"filename": payload.asset_name,
|
||
"url": payload.asset_url,
|
||
})
|
||
# AdImage returns a hash dict — extract hash key
|
||
meta_asset_id = list(result["images"].values())[0]["hash"] \
|
||
if "images" in result else result.get("id", "unknown")
|
||
|
||
return _ok({
|
||
"meta_asset_id": meta_asset_id,
|
||
"asset_name": payload.asset_name,
|
||
"asset_type": payload.asset_type,
|
||
"uploaded_at": datetime.utcnow().isoformat(),
|
||
})
|
||
except Exception as exc:
|
||
logger.error("Creative sync failed: %s", exc)
|
||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||
|
||
|
||
# ── 3. GET /insights/realtime ─────────────────────────────────────────────────
|
||
|
||
@router.get("/insights/realtime", summary="Poll unified Meta and Google Ads insights")
|
||
async def get_realtime_insights(
|
||
campaign_id: str | None = None,
|
||
platform: Platform | None = Query(default=None),
|
||
days: int = Query(default=7, ge=1, le=90),
|
||
) -> dict:
|
||
try:
|
||
insights = await ad_network_service.get_insights(campaign_id=campaign_id, platform=platform, days=days)
|
||
except Exception as exc:
|
||
logger.error("Insights fetch failed: %s", exc)
|
||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||
|
||
data = [item.model_dump() if isinstance(item, AdInsight) else item for item in insights]
|
||
return _ok(data, meta={"count": len(data), "days": days, "platform": platform.value if platform else "all"})
|
||
|
||
|
||
@router.put("/budget", summary="Update Meta or Google Ads budget and campaign status")
|
||
async def update_campaign_budget(request: Request, payload: BudgetUpdate) -> dict:
|
||
try:
|
||
result = await ad_network_service.update_budget(payload)
|
||
if hasattr(request.app.state, "broadcast_live_event"):
|
||
await request.app.state.broadcast_live_event(
|
||
"budget_update",
|
||
f"Updated {payload.platform.value} budget for {payload.campaign_id}.",
|
||
payload.campaign_id,
|
||
f"daily={payload.daily_budget} lifetime={payload.lifetime_budget}",
|
||
)
|
||
return _ok(result)
|
||
except Exception as exc:
|
||
logger.error("Budget update failed: %s", exc)
|
||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||
|
||
|
||
@router.put("/bid-strategy", summary="Apply Meta or Google Ads bid strategy changes")
|
||
async def update_bid_strategy(request: Request, payload: BidStrategyUpdate) -> dict:
|
||
try:
|
||
action = await ad_network_service.update_bid_strategy(payload)
|
||
if hasattr(request.app.state, "broadcast_live_event"):
|
||
await request.app.state.broadcast_live_event(
|
||
"bid_strategy_update",
|
||
f"Updated {payload.platform.value} bid strategy for {payload.campaign_id}.",
|
||
payload.campaign_id,
|
||
payload.strategy,
|
||
)
|
||
return _ok(action.model_dump())
|
||
except Exception as exc:
|
||
logger.error("Bid strategy update failed: %s", exc)
|
||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||
|
||
|
||
# ── 4. POST /audiences/lookalike ──────────────────────────────────────────────
|
||
|
||
@router.post("/audiences/lookalike", summary="Push Supabase CRM leads → Meta Lookalike Audience")
|
||
async def create_lookalike_audience(
|
||
request: Request,
|
||
payload: LookalikeAudienceRequest,
|
||
) -> dict:
|
||
"""
|
||
1. Queries the Supabase `leads` table for rows matching `status = payload.crm_filter_status`.
|
||
2. SHA-256 hashes their email addresses.
|
||
3. Creates (or updates) a Meta Custom Audience with the hashed emails.
|
||
4. Creates a Lookalike Audience from that Custom Audience.
|
||
|
||
Requires: META_ACCESS_TOKEN, META_AD_ACCOUNT_ID, SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY
|
||
"""
|
||
_api, account_id = _get_sdk()
|
||
supabase = _get_supabase()
|
||
|
||
# ── Step 1: Fetch qualified leads from Supabase CRM ──
|
||
try:
|
||
response = supabase.table("leads") \
|
||
.select("id, email, name") \
|
||
.eq("status", payload.crm_filter_status) \
|
||
.execute()
|
||
leads = response.data or []
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY,
|
||
detail=f"Supabase query failed: {exc}")
|
||
|
||
if not leads:
|
||
return _ok({"message": f"No leads found with status '{payload.crm_filter_status}'."})
|
||
|
||
# ── Step 2: Hash emails ──
|
||
hashed_emails = [
|
||
_sha256_hash(lead["email"])
|
||
for lead in leads
|
||
if lead.get("email")
|
||
]
|
||
if not hashed_emails:
|
||
raise HTTPException(status_code=422, detail="No valid email addresses found in the filtered leads.")
|
||
|
||
# ── Step 3: Create / update Meta Custom Audience ──
|
||
try:
|
||
from facebook_business.adobjects.adaccount import AdAccount # type: ignore
|
||
from facebook_business.adobjects.customaudience import CustomAudience # type: ignore
|
||
|
||
account = AdAccount(account_id)
|
||
audience_name = f"Velocity CRM — {payload.crm_filter_status} Leads"
|
||
|
||
# Create custom audience
|
||
custom_audience = account.create_custom_audience(params={
|
||
CustomAudience.Field.name: audience_name,
|
||
CustomAudience.Field.subtype: "CUSTOM",
|
||
CustomAudience.Field.description: f"Auto-generated from Velocity CRM — {len(hashed_emails)} leads",
|
||
"customer_file_source": "USER_PROVIDED_ONLY",
|
||
})
|
||
audience_id = custom_audience["id"]
|
||
|
||
# Add users via hashed emails
|
||
custom_audience.create_users_replace(params={
|
||
"payload": {
|
||
"schema": ["EMAIL_SHA256"],
|
||
"data": [[h] for h in hashed_emails],
|
||
}
|
||
})
|
||
|
||
# ── Step 4: Create Lookalike Audience ──
|
||
lookalike = account.create_lookalike_audience(params={
|
||
"name": f"Velocity Lookalike — {payload.crm_filter_status} ({int(payload.ratio * 100)}%)",
|
||
"origin_audience_id": audience_id,
|
||
"lookalike_spec": {
|
||
"type": "similarity",
|
||
"ratio": payload.ratio,
|
||
"country": payload.country,
|
||
},
|
||
})
|
||
|
||
# Broadcast live event
|
||
if hasattr(request.app.state, "broadcast_live_event"):
|
||
await request.app.state.broadcast_live_event(
|
||
"create",
|
||
f"Created Lookalike Audience from {len(hashed_emails)} CRM Closed/Won leads.",
|
||
None,
|
||
f"+{len(hashed_emails):,} leads",
|
||
)
|
||
|
||
return _ok({
|
||
"custom_audience_id": audience_id,
|
||
"lookalike_audience_id": lookalike["id"],
|
||
"leads_processed": len(hashed_emails),
|
||
"country": payload.country,
|
||
"ratio": payload.ratio,
|
||
})
|
||
except Exception as exc:
|
||
logger.error("Audience creation failed: %s", exc)
|
||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||
|
||
|
||
# ── 5. POST /auth/meta ────────────────────────────────────────────────────────
|
||
|
||
@router.post("/auth/meta", summary="Exchange short-lived token for System User token")
|
||
async def meta_oauth(payload: MetaAuthRequest) -> dict:
|
||
"""
|
||
Exchanges a short-lived Meta user token for a long-lived token using the
|
||
`/oauth/access_token` endpoint, then stores it in Supabase for persistence.
|
||
|
||
Requires: META_APP_ID, META_APP_SECRET
|
||
"""
|
||
import httpx
|
||
|
||
app_id = os.getenv("META_APP_ID", "")
|
||
app_secret = os.getenv("META_APP_SECRET", "")
|
||
api_ver = os.getenv("META_API_VERSION", "v21.0")
|
||
|
||
if app_id.startswith("PLACEHOLDER") or app_secret.startswith("PLACEHOLDER"):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail="META_APP_ID or META_APP_SECRET not configured.",
|
||
)
|
||
|
||
url = f"https://graph.facebook.com/{api_ver}/oauth/access_token"
|
||
params = {
|
||
"grant_type": "fb_exchange_token",
|
||
"client_id": app_id,
|
||
"client_secret": app_secret,
|
||
"fb_exchange_token": payload.short_lived_token,
|
||
}
|
||
|
||
async with httpx.AsyncClient() as client:
|
||
resp = await client.get(url, params=params, timeout=15.0)
|
||
|
||
if resp.status_code != 200:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||
detail=f"Meta OAuth error: {resp.text}",
|
||
)
|
||
|
||
token_data = resp.json()
|
||
long_lived_token = token_data.get("access_token")
|
||
|
||
if not long_lived_token:
|
||
raise HTTPException(status_code=502, detail="No access_token in Meta response.")
|
||
|
||
# Persist to Supabase (best-effort — don't block on failure)
|
||
try:
|
||
supabase = _get_supabase()
|
||
supabase.table("catalyst_settings").upsert({
|
||
"key": "META_ACCESS_TOKEN",
|
||
"value": long_lived_token,
|
||
"updated_at": datetime.utcnow().isoformat(),
|
||
}).execute()
|
||
except Exception as exc:
|
||
logger.warning("Could not persist Meta token to Supabase: %s", exc)
|
||
|
||
return _ok({
|
||
"access_token": long_lived_token,
|
||
"token_type": token_data.get("token_type", "bearer"),
|
||
"expires_in": token_data.get("expires_in"),
|
||
})
|
||
|
||
|
||
# ── 6. Social publishing ─────────────────────────────────────────────────────
|
||
|
||
@router.post("/publish", status_code=status.HTTP_201_CREATED, summary="Publish or schedule content to social channels")
|
||
async def api_publish_content(
|
||
payload: PostRequest,
|
||
request: Request,
|
||
user: UserPrincipal = Depends(get_current_user),
|
||
) -> dict:
|
||
try:
|
||
result = await publish_content(
|
||
pool=_get_db_pool(request),
|
||
tenant_id=user.tenant_id,
|
||
actor_id=user.user_id,
|
||
payload=payload,
|
||
)
|
||
except SocialPostingConfigurationError as exc:
|
||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=422, detail=f"Invalid schedule_time: {exc}") from exc
|
||
except (SocialPostingError, httpx.HTTPError) as exc:
|
||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||
return _ok(result)
|
||
|
||
|
||
@router.get("/posts", summary="List tenant-scoped social posts")
|
||
async def api_list_social_posts(
|
||
request: Request,
|
||
platform: SocialPlatform | None = Query(default=None),
|
||
post_status: PostStatus | None = Query(default=None, alias="status"),
|
||
limit: int = Query(default=50, ge=1, le=200),
|
||
user: UserPrincipal = Depends(get_current_user),
|
||
) -> dict:
|
||
posts = await list_posts(
|
||
pool=_get_db_pool(request),
|
||
tenant_id=user.tenant_id,
|
||
platform=platform,
|
||
status=post_status,
|
||
limit=limit,
|
||
)
|
||
return _ok(posts, meta={"count": len(posts)})
|
||
|
||
|
||
@router.get("/posts/{post_id}", summary="Get a tenant-scoped social post")
|
||
async def api_get_social_post(
|
||
post_id: str,
|
||
request: Request,
|
||
user: UserPrincipal = Depends(get_current_user),
|
||
) -> dict:
|
||
post = await get_post(pool=_get_db_pool(request), tenant_id=user.tenant_id, post_id=post_id)
|
||
if post is None:
|
||
raise HTTPException(status_code=404, detail=f"Social post '{post_id}' not found.")
|
||
return _ok(post)
|
||
|
||
|
||
@router.get("/scheduled", summary="List scheduled social posts for the authenticated tenant")
|
||
async def api_scheduled_posts(
|
||
request: Request,
|
||
limit: int = Query(default=50, ge=1, le=200),
|
||
user: UserPrincipal = Depends(get_current_user),
|
||
) -> dict:
|
||
posts = await list_posts(
|
||
pool=_get_db_pool(request),
|
||
tenant_id=user.tenant_id,
|
||
status=PostStatus.SCHEDULED,
|
||
limit=limit,
|
||
)
|
||
return _ok(posts, meta={"count": len(posts)})
|
||
|
||
|
||
@router.post("/scheduled/publish-due", summary="Publish due scheduled social posts for the authenticated tenant")
|
||
async def api_publish_due_scheduled(
|
||
request: Request,
|
||
limit: int = Query(default=20, ge=1, le=100),
|
||
user: UserPrincipal = Depends(get_current_user),
|
||
) -> dict:
|
||
try:
|
||
result = await publish_due_scheduled(
|
||
pool=_get_db_pool(request),
|
||
tenant_id=user.tenant_id,
|
||
limit=limit,
|
||
)
|
||
except SocialPostingConfigurationError as exc:
|
||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||
except (SocialPostingError, httpx.HTTPError) as exc:
|
||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||
return _ok(result)
|