""" 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)