diff --git a/.env.example b/.env.example
index dfc3839..5a76e47 100644
--- a/.env.example
+++ b/.env.example
@@ -52,7 +52,11 @@ META_APP_SECRET=
META_EMBEDDED_SIGNUP_CONFIG_ID=
# Optional Facebook Login for Business config for Pages and Ads. Do not put the WhatsApp Embedded Signup config here.
META_LOGIN_CONFIG_ID=
-# Must match a Valid OAuth Redirect URI on the Meta app. Defaults to {PUBLIC_API_BASE_URL}/api/v1/meta/oauth/callback.
+# Must match a Valid OAuth Redirect URI on the Meta app exactly.
+# Production (same-origin /api on urmiai.com):
+# META_OAUTH_REDIRECT_URI=https://urmiai.com/api/v1/meta/oauth/callback
+# Local dev:
+# META_OAUTH_REDIRECT_URI=http://localhost:8000/api/v1/meta/oauth/callback
META_OAUTH_REDIRECT_URI=
WHATSAPP_BUSINESS_PHONE=919411257916
WHATSAPP_API_VERSION=v21.0
diff --git a/backend/app/meta_service.py b/backend/app/meta_service.py
index cb6a935..4a2a003 100644
--- a/backend/app/meta_service.py
+++ b/backend/app/meta_service.py
@@ -7,6 +7,7 @@ needs to call a follow-up action.
from __future__ import annotations
+import json
import re
import uuid
from datetime import datetime, timedelta, timezone
@@ -120,6 +121,72 @@ def act_id(value: str) -> str:
return raw if raw.startswith("act_") else f"act_{raw}"
+def _equal_filter(field: str, value: str) -> str:
+ return json.dumps([{"field": field, "operator": "EQUAL", "value": value}])
+
+
+def _in_filter(field: str, values: list[str]) -> str:
+ return json.dumps([{"field": field, "operator": "IN", "value": values}])
+
+
+def _is_ads_api_path(path: str) -> bool:
+ """ads_read / ads_management live on Marketing API paths, not Page/Business Graph."""
+ cleaned = path.lstrip("/")
+ if cleaned == "me/adaccounts" or cleaned.startswith("me/adaccounts?"):
+ return True
+ if cleaned.startswith("act_"):
+ return True
+ return bool(re.fullmatch(r"\d+(?:/[A-Za-z_]+)?", cleaned))
+
+
+async def ads_api(
+ db: Session,
+ conn: MetaConnection,
+ method: str,
+ path: str,
+ params: dict[str, Any] | None = None,
+ body: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ """Marketing API for ads_read and ads_management (campaigns, ad sets, ads, insights)."""
+ if not _is_ads_api_path(path):
+ raise MetaApiError(500, "graph_error", "Ads operations must use the Marketing API on an ad account.")
+ return await graph_as_user(db, conn, method, path.lstrip("/"), params, body)
+
+
+async def marketing_get(
+ db: Session,
+ conn: MetaConnection,
+ account_id: str,
+ edge: str,
+ params: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ """GET /act_{id}/{edge} — Ads Insights / Ads Management read."""
+ return await ads_api(db, conn, "GET", f"{act_id(account_id)}/{edge.lstrip('/')}", params)
+
+
+async def marketing_post(
+ db: Session,
+ conn: MetaConnection,
+ account_id: str,
+ edge: str,
+ body: dict[str, Any],
+) -> dict[str, Any]:
+ """POST /act_{id}/{edge} — Ads Management write."""
+ return await ads_api(db, conn, "POST", f"{act_id(account_id)}/{edge.lstrip('/')}", body=body)
+
+
+async def ads_object_post(
+ db: Session,
+ conn: MetaConnection,
+ object_id: str,
+ body: dict[str, Any],
+) -> dict[str, Any]:
+ """POST /{campaign|adset|ad id} status and field updates (Marketing API object)."""
+ if not re.fullmatch(r"\d+", str(object_id)):
+ raise MetaApiError(422, "validation", "That Ads object is not valid for this ad account.")
+ return await ads_api(db, conn, "POST", str(object_id), body=body)
+
+
def currency_offset(currency: str) -> int:
return 1 if (currency or "").upper() in ZERO_DECIMAL else 100
@@ -178,7 +245,6 @@ def ensure_configured() -> None:
def require_connection(db: Session, user: UserAccount) -> MetaConnection:
- ensure_configured()
conn = load_connection(db, user)
if not conn or conn.status == "disconnected" or not conn.access_token:
raise MetaApiError(409, "not_connected", "Connect Meta before using this screen.")
@@ -235,6 +301,7 @@ def audit(
def public_connection(conn: MetaConnection | None) -> dict[str, Any]:
+ redirect_uri = settings.meta_oauth_redirect_uri_resolved
if not conn or conn.status == "disconnected":
return {
"configured": settings.meta_oauth_configured,
@@ -248,6 +315,7 @@ def public_connection(conn: MetaConnection | None) -> dict[str, Any]:
"selected_page_name": "",
"selected_ad_account_name": "",
"last_error": "",
+ "oauth_redirect_uri": redirect_uri,
}
return {
"configured": settings.meta_oauth_configured,
@@ -265,6 +333,7 @@ def public_connection(conn: MetaConnection | None) -> dict[str, Any]:
"selected_page_name": conn.selected_page_name,
"selected_ad_account_name": conn.selected_ad_account_name,
"last_error": conn.last_error,
+ "oauth_redirect_uri": redirect_uri,
}
@@ -323,6 +392,52 @@ async def finish_oauth(db: Session, code: str, state: str) -> None:
audit(db, user, "connection.authorized", asset_type="connection", asset_label=conn.meta_user_name, detail="Meta Login completed")
+async def connect_with_user_token(db: Session, user: UserAccount, access_token: str) -> dict[str, Any]:
+ """Store a customer-supplied user token without the Facebook login redirect."""
+ token = "".join(str(access_token or "").split())
+ if token.lower().startswith("bearer"):
+ token = token[6:]
+ if len(token) < 20:
+ raise MetaApiError(400, "validation", "Paste the access token from Meta.")
+ profile = await graph("GET", "me", token, {"fields": "id,name"})
+ permission_rows = await graph("GET", "me/permissions", token)
+ granted = [
+ row.get("permission")
+ for row in permission_rows.get("data") or []
+ if isinstance(row, dict) and row.get("status") == "granted" and row.get("permission")
+ ]
+ scopes = [scope for scope in META_SCOPES if scope in set(granted)]
+ if not any(scope != "public_profile" for scope in scopes):
+ raise MetaApiError(
+ 403,
+ "permission_denied",
+ "This token cannot see Pages or ad accounts. Use a token that includes those permissions.",
+ )
+ stored = token
+ expires_at = None
+ if settings.meta_app_id and settings.meta_graph_app_secret:
+ long_token = await _exchange_long_lived(token)
+ if long_token.get("access_token"):
+ stored = str(long_token["access_token"])
+ expires_in = long_token.get("expires_in")
+ if expires_in:
+ expires_at = datetime.now(timezone.utc) + timedelta(seconds=int(expires_in))
+ conn = load_connection(db, user) or MetaConnection(user_id=user.id)
+ conn.meta_user_id = str(profile.get("id") or "")
+ conn.meta_user_name = str(profile.get("name") or "")
+ conn.granted_scopes = ",".join(scopes)
+ conn.access_token = stored
+ conn.status = "connected"
+ conn.last_error = ""
+ conn.last_graph_code = None
+ conn.connected_at = datetime.now(timezone.utc)
+ conn.token_expires_at = expires_at
+ db.add(conn)
+ db.commit()
+ audit(db, user, "connection.authorized", asset_type="connection", asset_label=conn.meta_user_name, detail="Access token saved")
+ return public_connection(conn)
+
+
async def _exchange_code(code: str) -> dict[str, Any]:
return await graph(
"GET",
@@ -557,48 +672,27 @@ async def list_businesses(db: Session, user: UserAccount) -> dict[str, Any]:
async def list_ad_accounts(db: Session, user: UserAccount) -> dict[str, Any]:
conn = require_connection(db, user)
- if not (has_scope(conn, "ads_read") or has_scope(conn, "ads_management") or has_scope(conn, "business_management")):
+ if not (has_scope(conn, "ads_read") or has_scope(conn, "ads_management")):
raise MetaApiError(
403,
"permission_denied",
- "Meta has not granted Ads reporting or Business portfolio for this connection.",
+ "Meta has not granted Ads reporting or Ads management for this connection.",
permission="ads_read",
)
+ data = await ads_api(
+ db,
+ conn,
+ "GET",
+ "me/adaccounts",
+ {"fields": "id,name,account_status,currency,amount_spent,business{name}", "limit": "50"},
+ )
rows: dict[str, dict[str, Any]] = {}
- if has_scope(conn, "ads_read"):
- data = await graph_as_user(
- db,
- conn,
- "GET",
- "me/adaccounts",
- {"fields": "id,name,account_status,currency,amount_spent", "limit": "50"},
- )
- for row in data.get("data") or []:
- if isinstance(row, dict) and row.get("id"):
- rows[act_id(str(row["id"]))] = row
- if has_scope(conn, "business_management"):
- businesses = await graph_as_user(db, conn, "GET", "me/businesses", {"fields": "id,name", "limit": "25"})
- for business in businesses.get("data") or []:
- if not isinstance(business, dict) or not business.get("id"):
- continue
- business_name = str(business.get("name") or "")
- for edge in ("owned_ad_accounts", "client_ad_accounts"):
- try:
- owned = await graph_as_user(
- db,
- conn,
- "GET",
- f"{business['id']}/{edge}",
- {"fields": "id,name,account_status,currency,amount_spent", "limit": "50"},
- )
- except MetaApiError as exc:
- if exc.code == "permission_denied":
- continue
- raise
- for row in owned.get("data") or []:
- if isinstance(row, dict) and row.get("id"):
- current = rows.setdefault(act_id(str(row["id"])), row)
- current["business_name"] = business_name
+ for row in data.get("data") or []:
+ if not isinstance(row, dict) or not row.get("id"):
+ continue
+ business = row.get("business") if isinstance(row.get("business"), dict) else {}
+ row["business_name"] = business.get("name") or ""
+ rows[act_id(str(row["id"]))] = row
accounts = [
_account_summary(row, selected=act_id(str(row.get("id"))) == act_id(conn.selected_ad_account_id))
for row in rows.values()
@@ -625,7 +719,7 @@ def _account_summary(row: dict[str, Any], selected: bool) -> dict[str, Any]:
async def select_ad_account(db: Session, user: UserAccount, account_id: str) -> dict[str, Any]:
conn = require_connection(db, user)
- if not (has_scope(conn, "ads_read") or has_scope(conn, "ads_management") or has_scope(conn, "business_management")):
+ if not (has_scope(conn, "ads_read") or has_scope(conn, "ads_management")):
raise MetaApiError(403, "permission_denied", "Meta has not granted Ads reporting for this connection.", permission="ads_read")
accounts = await list_ad_accounts(db, user)
match = next((row for row in accounts["accounts"] if row["id"] == act_id(account_id)), None)
@@ -646,20 +740,142 @@ async def _selected_account(db: Session, user: UserAccount, manage: bool) -> tup
require_scope(conn, "ads_read")
if not conn.selected_ad_account_id:
raise MetaApiError(409, "no_ad_account", "Select an ad account before opening campaigns.")
- accounts = await list_ad_accounts(db, user)
- match = next((row for row in accounts["accounts"] if row["id"] == act_id(conn.selected_ad_account_id)), None)
- if not match:
- raise MetaApiError(409, "no_ad_account", "The selected ad account is no longer available to this connection.")
- return conn, match["id"]
+ return conn, act_id(conn.selected_ad_account_id)
+
+
+async def _account_object(
+ db: Session,
+ conn: MetaConnection,
+ account_id: str,
+ edge: str,
+ object_id: str,
+ fields: str,
+) -> dict[str, Any]:
+ data = await marketing_get(
+ db,
+ conn,
+ account_id,
+ edge,
+ {"fields": fields, "filtering": _equal_filter("id", object_id), "limit": "1"},
+ )
+ rows = [row for row in data.get("data") or [] if isinstance(row, dict) and str(row.get("id")) == object_id]
+ if not rows:
+ raise MetaApiError(404, "not_found", "That object is not in the selected ad account.")
+ return rows[0]
+
+
+def _adset_summary(row: dict[str, Any], currency: str) -> dict[str, Any]:
+ campaign = row.get("campaign") if isinstance(row.get("campaign"), dict) else {}
+ daily = minor_to_major(row.get("daily_budget"), currency)
+ lifetime = minor_to_major(row.get("lifetime_budget"), currency)
+ budget = money(daily, currency) if daily is not None else money(lifetime, currency) if lifetime is not None else None
+ goal = str(row.get("optimization_goal") or "").replace("_", " ").title()
+ return {
+ "id": str(row.get("id")),
+ "name": row.get("name") or "Ad set",
+ "campaign_id": str(row.get("campaign_id") or campaign.get("id") or ""),
+ "campaign_name": campaign.get("name") or "",
+ "status": row.get("effective_status") or row.get("status") or "",
+ "optimization_goal": goal or "—",
+ "billing_event": str(row.get("billing_event") or "").replace("_", " ").title() or "—",
+ "budget": budget,
+ "start_time": row.get("start_time"),
+ "end_time": row.get("end_time"),
+ }
+
+
+async def list_adsets(db: Session, user: UserAccount, campaign_id: str | None = None) -> dict[str, Any]:
+ conn, account_id = await _selected_account(db, user, manage=False)
+ currency = await _account_currency(db, conn, account_id)
+ params: dict[str, Any] = {
+ "fields": "id,name,status,effective_status,daily_budget,lifetime_budget,optimization_goal,billing_event,campaign_id,start_time,end_time,campaign{name}",
+ "limit": "50",
+ }
+ if campaign_id:
+ params["filtering"] = _equal_filter("campaign.id", campaign_id)
+ data = await marketing_get(db, conn, account_id, "adsets", params)
+ adsets = [_adset_summary(row, currency) for row in data.get("data") or [] if isinstance(row, dict) and row.get("id")]
+ return {
+ "account_name": conn.selected_ad_account_name,
+ "currency": currency,
+ "adsets": adsets,
+ }
+
+
+async def campaign_detail(db: Session, user: UserAccount, campaign_id: str) -> dict[str, Any]:
+ conn, account_id = await _selected_account(db, user, manage=False)
+ current = await _account_object(
+ db,
+ conn,
+ account_id,
+ "campaigns",
+ campaign_id,
+ "id,name,status,effective_status,objective,created_time",
+ )
+ currency = await _account_currency(db, conn, account_id)
+ adsets_payload = await marketing_get(
+ db,
+ conn,
+ account_id,
+ "adsets",
+ {
+ "fields": "id,name,status,effective_status,daily_budget,lifetime_budget,optimization_goal,billing_event,campaign_id,start_time,end_time",
+ "filtering": _equal_filter("campaign.id", campaign_id),
+ "limit": "50",
+ },
+ )
+ adsets = [_adset_summary(row, currency) for row in adsets_payload.get("data") or [] if isinstance(row, dict) and row.get("id")]
+ insights_error = None
+ metrics: dict[str, Any] = {}
+ try:
+ report = await marketing_get(
+ db,
+ conn,
+ account_id,
+ "insights",
+ {
+ "fields": "campaign_id,spend,impressions,clicks,reach,ctr",
+ "date_preset": "last_30d",
+ "level": "campaign",
+ "filtering": _in_filter("campaign.id", [campaign_id]),
+ },
+ )
+ row = (report.get("data") or [{}])[0] if isinstance(report.get("data"), list) else {}
+ if isinstance(row, dict):
+ spend = _float(row.get("spend"))
+ metrics = {
+ "spend": money(spend, currency) if row else None,
+ "impressions": _int(row.get("impressions")),
+ "clicks": _int(row.get("clicks")),
+ "reach": _int(row.get("reach")),
+ "ctr": _float(row.get("ctr")),
+ }
+ except MetaApiError as exc:
+ insights_error = {"code": exc.code, "message": exc.message}
+ return {
+ "account_name": conn.selected_ad_account_name,
+ "currency": currency,
+ "can_manage": has_scope(conn, "ads_management"),
+ "campaign": {
+ "id": str(current.get("id")),
+ "name": current.get("name") or "Campaign",
+ "status": current.get("effective_status") or current.get("status") or "",
+ "objective": OBJECTIVE_LABELS.get(str(current.get("objective")), str(current.get("objective") or "").replace("OUTCOME_", "").title()),
+ "created_time": current.get("created_time"),
+ },
+ "adsets": adsets,
+ "metrics": metrics,
+ "insights_error": insights_error,
+ }
async def list_campaigns(db: Session, user: UserAccount) -> dict[str, Any]:
conn, account_id = await _selected_account(db, user, manage=False)
- data = await graph_as_user(
+ data = await marketing_get(
db,
conn,
- "GET",
- f"{account_id}/campaigns",
+ account_id,
+ "campaigns",
{
"fields": "id,name,status,effective_status,objective,created_time",
"limit": "50",
@@ -668,11 +884,11 @@ async def list_campaigns(db: Session, user: UserAccount) -> dict[str, Any]:
insights: dict[str, dict[str, Any]] = {}
insights_error = None
try:
- report = await graph_as_user(
+ report = await marketing_get(
db,
conn,
- "GET",
- f"{account_id}/insights",
+ account_id,
+ "insights",
{
"level": "campaign",
"fields": "campaign_id,spend,impressions,clicks,ctr,reach",
@@ -717,7 +933,7 @@ async def list_campaigns(db: Session, user: UserAccount) -> dict[str, Any]:
async def _account_currency(db: Session, conn: MetaConnection, account_id: str) -> str:
try:
- data = await graph_as_user(db, conn, "GET", account_id, {"fields": "currency"})
+ data = await ads_api(db, conn, "GET", account_id, {"fields": "currency,account_status,name"})
return str(data.get("currency") or "")
except MetaApiError:
return ""
@@ -741,13 +957,11 @@ async def set_campaign_status(db: Session, user: UserAccount, campaign_id: str,
if status not in {"ACTIVE", "PAUSED"}:
raise MetaApiError(422, "validation", "Choose pause or resume.")
conn, account_id = await _selected_account(db, user, manage=True)
- current = await graph_as_user(db, conn, "GET", campaign_id, {"fields": "id,name,account_id"})
- if not _same_account(current.get("account_id"), account_id):
- raise MetaApiError(404, "not_found", "That campaign is not in the selected ad account.")
+ current = await _account_object(db, conn, account_id, "campaigns", campaign_id, "id,name")
name = str(current.get("name") or "Campaign")
action = "campaign.resume" if status == "ACTIVE" else "campaign.pause"
try:
- await graph_as_user(db, conn, "POST", campaign_id, body={"status": status})
+ await ads_object_post(db, conn, campaign_id, {"status": status})
except MetaApiError as exc:
audit(db, user, action, asset_type="campaign", asset_label=name, asset_id=campaign_id, status="failed", error_message=exc.message, graph_code=exc.graph_code)
raise
@@ -757,23 +971,27 @@ async def set_campaign_status(db: Session, user: UserAccount, campaign_id: str,
async def update_campaign(db: Session, user: UserAccount, campaign_id: str, name: str | None, daily_budget: float | None) -> dict[str, Any]:
conn, account_id = await _selected_account(db, user, manage=True)
- current = await graph_as_user(db, conn, "GET", campaign_id, {"fields": "id,name,account_id"})
- if not _same_account(current.get("account_id"), account_id):
- raise MetaApiError(404, "not_found", "That campaign is not in the selected ad account.")
+ current = await _account_object(db, conn, account_id, "campaigns", campaign_id, "id,name")
if not name and daily_budget is None:
raise MetaApiError(422, "validation", "Add a name or daily budget to update.")
label = name or str(current.get("name") or "Campaign")
try:
if name:
- await graph_as_user(db, conn, "POST", campaign_id, body={"name": name[:120]})
+ await ads_object_post(db, conn, campaign_id, {"name": name[:120]})
if daily_budget is not None:
currency = await _account_currency(db, conn, account_id)
- adsets = await graph_as_user(db, conn, "GET", f"{campaign_id}/adsets", {"fields": "id,name", "limit": "5"})
+ adsets = await marketing_get(
+ db,
+ conn,
+ account_id,
+ "adsets",
+ {"fields": "id,name", "filtering": _equal_filter("campaign.id", campaign_id), "limit": "5"},
+ )
rows = [row for row in adsets.get("data") or [] if isinstance(row, dict) and row.get("id")]
if len(rows) != 1:
raise MetaApiError(422, "feature_unavailable", "Budget can be edited here only when the campaign has one ad set.")
minor = str(int(round(daily_budget * currency_offset(currency))))
- await graph_as_user(db, conn, "POST", str(rows[0]["id"]), body={"daily_budget": minor})
+ await ads_object_post(db, conn, str(rows[0]["id"]), {"daily_budget": minor})
except MetaApiError as exc:
audit(db, user, "campaign.edit", asset_type="campaign", asset_label=label, asset_id=campaign_id, status="failed", error_message=exc.message, graph_code=exc.graph_code)
raise
@@ -820,12 +1038,12 @@ async def publish_campaign(db: Session, user: UserAccount, payload: dict[str, An
status = "ACTIVE" if activate else "PAUSED"
created_campaign = ""
try:
- campaign = await graph_as_user(
+ campaign = await marketing_post(
db,
conn,
- "POST",
- f"{account_id}/campaigns",
- body={
+ account_id,
+ "campaigns",
+ {
"name": name,
"objective": objective,
"status": "PAUSED",
@@ -855,21 +1073,21 @@ async def publish_campaign(db: Session, user: UserAccount, payload: dict[str, An
adset_body["destination_type"] = "WHATSAPP"
elif destination == "website":
adset_body["destination_type"] = "WEBSITE"
- adset = await graph_as_user(db, conn, "POST", f"{account_id}/adsets", body=adset_body)
+ adset = await marketing_post(db, conn, account_id, "adsets", adset_body)
story = _story_spec(page_id, destination, message, headline, link, cta, page_link)
- creative = await graph_as_user(
+ creative = await marketing_post(
db,
conn,
- "POST",
- f"{account_id}/adcreatives",
- body={"name": f"{name} creative", "object_story_spec": story},
+ account_id,
+ "adcreatives",
+ {"name": f"{name} creative", "object_story_spec": story},
)
- await graph_as_user(
+ await marketing_post(
db,
conn,
- "POST",
- f"{account_id}/ads",
- body={
+ account_id,
+ "ads",
+ {
"name": f"{name} ad",
"adset_id": adset.get("id"),
"creative": {"creative_id": creative.get("id")},
@@ -877,8 +1095,8 @@ async def publish_campaign(db: Session, user: UserAccount, payload: dict[str, An
},
)
if activate and created_campaign:
- await graph_as_user(db, conn, "POST", created_campaign, body={"status": "ACTIVE"})
- await graph_as_user(db, conn, "POST", str(adset.get("id")), body={"status": "ACTIVE"})
+ await ads_object_post(db, conn, created_campaign, {"status": "ACTIVE"})
+ await ads_object_post(db, conn, str(adset.get("id")), {"status": "ACTIVE"})
except MetaApiError as exc:
detail = exc.message
if created_campaign:
@@ -928,13 +1146,13 @@ def _story_spec(page_id: str, destination: str, message: str, headline: str, lin
async def list_ads(db: Session, user: UserAccount) -> dict[str, Any]:
conn, account_id = await _selected_account(db, user, manage=False)
- data = await graph_as_user(
+ data = await marketing_get(
db,
conn,
- "GET",
- f"{account_id}/ads",
+ account_id,
+ "ads",
{
- "fields": "id,name,status,effective_status,creative{title,body,image_url,thumbnail_url,call_to_action_type,object_story_spec},campaign{name}",
+ "fields": "id,name,status,effective_status,creative{title,body,image_url,thumbnail_url,call_to_action_type,object_story_spec},campaign{name},adset{name}",
"limit": "50",
},
)
@@ -952,10 +1170,12 @@ def _ad_summary(row: dict[str, Any]) -> dict[str, Any]:
video = story.get("video_data") if isinstance(story.get("video_data"), dict) else {}
cta = link_data.get("call_to_action") if isinstance(link_data.get("call_to_action"), dict) else {}
campaign = row.get("campaign") if isinstance(row.get("campaign"), dict) else {}
+ adset = row.get("adset") if isinstance(row.get("adset"), dict) else {}
return {
"id": str(row.get("id")),
"name": row.get("name") or "Ad",
"campaign_name": campaign.get("name") or "",
+ "adset_name": adset.get("name") or "",
"status": row.get("effective_status") or row.get("status") or "",
"headline": link_data.get("name") or video.get("title") or creative.get("title") or "",
"message": link_data.get("message") or video.get("message") or creative.get("body") or "",
@@ -968,19 +1188,18 @@ def _ad_summary(row: dict[str, Any]) -> dict[str, Any]:
async def update_ad(db: Session, user: UserAccount, ad_id: str, payload: dict[str, Any]) -> dict[str, Any]:
conn, account_id = await _selected_account(db, user, manage=True)
- current = await graph_as_user(
+ current = await _account_object(
db,
conn,
- "GET",
+ account_id,
+ "ads",
ad_id,
- {"fields": "id,name,account_id,creative{object_story_spec}"},
+ "id,name,creative{object_story_spec}",
)
- if not _same_account(current.get("account_id"), account_id):
- raise MetaApiError(404, "not_found", "That ad is not in the selected ad account.")
label = str(payload.get("name") or current.get("name") or "Ad")
try:
if payload.get("name"):
- await graph_as_user(db, conn, "POST", ad_id, body={"name": str(payload["name"])[:120]})
+ await ads_object_post(db, conn, ad_id, {"name": str(payload["name"])[:120]})
creative_fields = ("message", "headline", "call_to_action", "link")
if any(payload.get(field) for field in creative_fields):
creative = current.get("creative") if isinstance(current.get("creative"), dict) else {}
@@ -995,11 +1214,11 @@ async def update_ad(db: Session, user: UserAccount, ad_id: str, payload: dict[st
link = str(payload.get("link") or "")
if link and not link.startswith(("http://", "https://")):
raise MetaApiError(422, "validation", "Creative links need to start with http or https.")
- replacement = await graph_as_user(
+ replacement = await marketing_post(
db,
conn,
- "POST",
- f"{account_id}/adcreatives",
+ account_id,
+ "adcreatives",
body={
"name": f"{label} creative",
"object_story_spec": _story_spec(
@@ -1013,7 +1232,7 @@ async def update_ad(db: Session, user: UserAccount, ad_id: str, payload: dict[st
),
},
)
- await graph_as_user(db, conn, "POST", ad_id, body={"creative": {"creative_id": replacement.get("id")}})
+ await ads_object_post(db, conn, ad_id, {"creative": {"creative_id": replacement.get("id")}})
except MetaApiError as exc:
audit(db, user, "ad.edit", asset_type="ad", asset_label=label, asset_id=ad_id, status="failed", error_message=exc.message, graph_code=exc.graph_code)
raise
@@ -1025,13 +1244,11 @@ async def set_ad_status(db: Session, user: UserAccount, ad_id: str, status: str)
if status not in {"ACTIVE", "PAUSED"}:
raise MetaApiError(422, "validation", "Choose pause or resume.")
conn, account_id = await _selected_account(db, user, manage=True)
- current = await graph_as_user(db, conn, "GET", ad_id, {"fields": "id,name,account_id"})
- if not _same_account(current.get("account_id"), account_id):
- raise MetaApiError(404, "not_found", "That ad is not in the selected ad account.")
+ current = await _account_object(db, conn, account_id, "ads", ad_id, "id,name")
name = str(current.get("name") or "Ad")
action = "ad.resume" if status == "ACTIVE" else "ad.pause"
try:
- await graph_as_user(db, conn, "POST", ad_id, body={"status": status})
+ await ads_object_post(db, conn, ad_id, {"status": status})
except MetaApiError as exc:
audit(db, user, action, asset_type="ad", asset_label=name, asset_id=ad_id, status="failed", error_message=exc.message, graph_code=exc.graph_code)
raise
@@ -1039,23 +1256,23 @@ async def set_ad_status(db: Session, user: UserAccount, ad_id: str, status: str)
return {"name": name, "status": status}
-async def analytics(db: Session, user: UserAccount, date_preset: str) -> dict[str, Any]:
+async def analytics(db: Session, user: UserAccount, date_preset: str, breakdowns: bool = True) -> dict[str, Any]:
if date_preset not in {"last_7d", "last_14d", "last_30d", "last_90d", "maximum"}:
raise MetaApiError(422, "validation", "Choose a supported date range.")
conn, account_id = await _selected_account(db, user, manage=False)
currency = await _account_currency(db, conn, account_id)
- summary = await graph_as_user(
+ summary = await marketing_get(
db,
conn,
- "GET",
- f"{account_id}/insights",
+ account_id,
+ "insights",
{"fields": "spend,impressions,reach,clicks,ctr,cpc,cpm", "date_preset": date_preset},
)
- daily = await graph_as_user(
+ daily = await marketing_get(
db,
conn,
- "GET",
- f"{account_id}/insights",
+ account_id,
+ "insights",
{
"fields": "spend,impressions,clicks,date_start",
"date_preset": date_preset,
@@ -1063,6 +1280,34 @@ async def analytics(db: Session, user: UserAccount, date_preset: str) -> dict[st
"limit": "100",
},
)
+ campaign_rows: list[dict[str, Any]] = []
+ adset_rows: list[dict[str, Any]] = []
+ ad_rows: list[dict[str, Any]] = []
+ if breakdowns:
+ campaign_rows = await _insights_rows(
+ db,
+ conn,
+ account_id,
+ date_preset,
+ "campaign",
+ "campaign_id,campaign_name,spend,impressions,clicks,ctr,reach",
+ )
+ adset_rows = await _insights_rows(
+ db,
+ conn,
+ account_id,
+ date_preset,
+ "adset",
+ "adset_id,adset_name,spend,impressions,clicks,ctr",
+ )
+ ad_rows = await _insights_rows(
+ db,
+ conn,
+ account_id,
+ date_preset,
+ "ad",
+ "ad_id,ad_name,spend,impressions,clicks,ctr",
+ )
row = (summary.get("data") or [{}])[0] if isinstance(summary.get("data"), list) else {}
if not isinstance(row, dict):
row = {}
@@ -1094,6 +1339,43 @@ async def analytics(db: Session, user: UserAccount, date_preset: str) -> dict[st
"cpm": money(_float(row.get("cpm")), currency) if row.get("cpm") is not None else None,
},
"series": series,
+ "campaigns": [_insight_line(item, "campaign", currency) for item in campaign_rows],
+ "adsets": [_insight_line(item, "adset", currency) for item in adset_rows],
+ "ads": [_insight_line(item, "ad", currency) for item in ad_rows],
+ }
+
+
+async def _insights_rows(
+ db: Session,
+ conn: MetaConnection,
+ account_id: str,
+ date_preset: str,
+ level: str,
+ fields: str,
+) -> list[dict[str, Any]]:
+ try:
+ payload = await marketing_get(
+ db,
+ conn,
+ account_id,
+ "insights",
+ {"fields": fields, "date_preset": date_preset, "level": level, "limit": "50"},
+ )
+ except MetaApiError:
+ return []
+ return [row for row in payload.get("data") or [] if isinstance(row, dict)]
+
+
+def _insight_line(row: dict[str, Any], level: str, currency: str) -> dict[str, Any]:
+ name = row.get(f"{level}_name") or row.get("campaign_name") or "Untitled"
+ spend = _float(row.get("spend"))
+ return {
+ "name": name,
+ "spend": money(spend, currency),
+ "impressions": _int(row.get("impressions")),
+ "clicks": _int(row.get("clicks")),
+ "ctr": _float(row.get("ctr")),
+ "reach": _int(row.get("reach")),
}
@@ -1134,7 +1416,7 @@ async def overview(db: Session, user: UserAccount) -> dict[str, Any]:
return result
result["businesses"] = await _section(lambda: list_businesses(db, user), "businesses")
result["pages"] = await _section(lambda: list_pages(db, user), "pages")
- if has_scope(conn, "ads_read") or has_scope(conn, "business_management"):
+ if has_scope(conn, "ads_read") or has_scope(conn, "ads_management"):
result["ad_accounts"] = await _section(lambda: list_ad_accounts(db, user), "accounts")
else:
result["ad_accounts"] = {
@@ -1145,7 +1427,7 @@ async def overview(db: Session, user: UserAccount) -> dict[str, Any]:
}
if has_scope(conn, "ads_read") and conn.selected_ad_account_id:
try:
- report = await analytics(db, user, "last_30d")
+ report = await analytics(db, user, "last_30d", breakdowns=False)
result["performance"] = {"state": "ok", "message": "", "report": report}
except MetaApiError as exc:
result["performance"] = {"state": exc.code, "message": exc.message, "permission": exc.permission, "report": None}
@@ -1211,6 +1493,7 @@ def settings_view(db: Session, user: UserAccount) -> dict[str, Any]:
debug = {
"app_configured": settings.meta_oauth_configured,
"redirect_configured": bool(settings.meta_oauth_redirect_uri_resolved),
+ "oauth_redirect_uri": settings.meta_oauth_redirect_uri_resolved,
"login_config_set": bool(settings.meta_login_config_id),
"granted_scopes": sorted(scopes_of(conn)) if conn else [],
"last_error": conn.last_error if conn else "",
diff --git a/backend/app/routes_meta.py b/backend/app/routes_meta.py
index 06d76c8..5a7e476 100644
--- a/backend/app/routes_meta.py
+++ b/backend/app/routes_meta.py
@@ -13,11 +13,14 @@ from .meta_service import (
analytics,
authorization_url,
disconnect,
+ connect_with_user_token,
finish_oauth,
http_meta,
list_ad_accounts,
list_ads,
+ list_adsets,
list_businesses,
+ campaign_detail,
list_campaigns,
list_pages,
overview,
@@ -43,6 +46,10 @@ class Confirm(BaseModel):
confirm: bool = False
+class ManualToken(BaseModel):
+ access_token: str = Field(min_length=1, max_length=4096)
+
+
class IdBody(Confirm):
id: str = Field(min_length=1, max_length=64)
@@ -118,6 +125,14 @@ async def oauth_callback(code: str = "", state: str = "", error: str = "", db: S
return _redirect("connected=1")
+@router.post("/token")
+async def save_token(body: ManualToken, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
+ try:
+ return await connect_with_user_token(db, user, body.access_token)
+ except MetaApiError as exc:
+ raise http_meta(exc) from exc
+
+
@router.post("/disconnect")
def disconnect_meta(body: Confirm, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
_confirmed(body)
@@ -191,6 +206,26 @@ async def campaigns(user: UserAccount = Depends(current_user), db: Session = Dep
raise http_meta(exc) from exc
+@router.get("/campaigns/{campaign_id}")
+async def campaign(campaign_id: str, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
+ try:
+ return await campaign_detail(db, user, campaign_id)
+ except MetaApiError as exc:
+ raise http_meta(exc) from exc
+
+
+@router.get("/adsets")
+async def adsets(
+ campaign_id: str | None = None,
+ user: UserAccount = Depends(current_user),
+ db: Session = Depends(get_db),
+) -> dict:
+ try:
+ return await list_adsets(db, user, campaign_id or None)
+ except MetaApiError as exc:
+ raise http_meta(exc) from exc
+
+
@router.post("/campaigns")
async def create_campaign(body: CampaignPublish, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
_confirmed(body)
diff --git a/backend/app/routes_whatsapp.py b/backend/app/routes_whatsapp.py
index f6ccb58..181935b 100644
--- a/backend/app/routes_whatsapp.py
+++ b/backend/app/routes_whatsapp.py
@@ -17,6 +17,7 @@ from .whatsapp_onboarding import (
account_detail,
account_logs,
complete_signup,
+ connect_with_token,
customer_token_for_phone,
developer_config,
disconnect_account,
@@ -174,6 +175,12 @@ class RegisterPhoneRequest(BaseModel):
pin: str | None = Field(default=None, max_length=6)
+class ManualTokenRequest(BaseModel):
+ access_token: str = Field(min_length=1, max_length=4096)
+ waba_id: str = Field(min_length=1, max_length=64)
+ phone_number_id: str | None = Field(default=None, max_length=64)
+
+
class SignupCompleteRequest(BaseModel):
event: str = Field(min_length=1, max_length=80)
code: str | None = Field(default=None, max_length=2048)
@@ -208,6 +215,21 @@ async def whatsapp_signup_complete(
return await complete_signup(db, user, payload.model_dump())
+@router.post("/connections/token")
+async def whatsapp_connect_token(
+ payload: ManualTokenRequest,
+ db: Session = Depends(get_db),
+ user: UserAccount = Depends(current_user),
+) -> dict:
+ return await connect_with_token(
+ db,
+ user,
+ payload.access_token,
+ payload.waba_id,
+ payload.phone_number_id,
+ )
+
+
@router.get("/accounts/{account_id}")
def whatsapp_account_detail(
account_id: str,
diff --git a/backend/app/whatsapp_onboarding.py b/backend/app/whatsapp_onboarding.py
index 5ab8a2e..cf52a0d 100644
--- a/backend/app/whatsapp_onboarding.py
+++ b/backend/app/whatsapp_onboarding.py
@@ -8,6 +8,7 @@ addressed by another tenant.
from __future__ import annotations
import logging
+import re
import secrets
import uuid
from datetime import datetime, timezone
@@ -101,11 +102,13 @@ def _quality_label(value: str | None) -> str | None:
def _graph_error_message(data: Any, fallback: str) -> str:
+ message = fallback
if isinstance(data, dict):
error = data.get("error")
if isinstance(error, dict) and error.get("message"):
- return str(error["message"])[:500]
- return fallback
+ message = str(error["message"])
+ message = re.sub(r"(access_token|token|secret)=([^&\s]+)", r"\1=redacted", message, flags=re.I)
+ return message[:500]
async def _graph(
@@ -607,6 +610,78 @@ async def complete_signup(db: Session, user: UserAccount, payload: dict[str, Any
return {"status": overall, "message": message, "accounts": [serialize_account(account) for account in accounts]}
+_META_ID = re.compile(r"^\d{6,32}$")
+
+
+def _clean_token(value: str) -> str:
+ token = "".join(str(value or "").split())
+ if token.lower().startswith("bearer"):
+ token = token[6:]
+ return token
+
+
+async def connect_with_token(
+ db: Session,
+ user: UserAccount,
+ access_token: str,
+ waba_id: str,
+ phone_number_id: str | None = None,
+) -> dict[str, Any]:
+ """Store a customer-supplied WhatsApp token and provision that account.
+
+ This is the non-embedded alternative to Embedded Signup. The token is
+ kept on the connection row and is not returned to the browser.
+ """
+ token = _clean_token(access_token)
+ account_id = "".join(str(waba_id or "").split())
+ phone_id = "".join(str(phone_number_id or "").split()) or None
+ if len(token) < 20:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Paste the access token from Meta.")
+ if not _META_ID.match(account_id):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Enter the WhatsApp Business account ID from Meta. It is a number.",
+ )
+ if phone_id and not _META_ID.match(phone_id):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Phone number ID should be the number Meta shows for that phone.",
+ )
+
+ connection = WhatsAppMetaConnection(
+ user_id=user.id,
+ status="pending",
+ granted_scopes="manual",
+ access_token=token,
+ )
+ db.add(connection)
+ db.flush()
+ _audit(
+ db,
+ user,
+ "token_saved",
+ "Customer saved a WhatsApp access token",
+ connection_id=connection.id,
+ waba_id=account_id,
+ )
+ account = await _provision_waba(db, user, connection, token, account_id, phone_id)
+ db.commit()
+ db.refresh(account)
+ if account.status == "connected":
+ overall = "connected"
+ message = "WhatsApp Business is connected."
+ elif account.status == "incomplete":
+ overall = "incomplete"
+ message = account.last_error or "The account was saved, but the phone number still needs verification."
+ elif account.status == "expired":
+ overall = "expired"
+ message = account.last_error or "Meta did not accept this access token."
+ else:
+ overall = "failed"
+ message = account.last_error or "Urmi could not finish WhatsApp setup."
+ return {"status": overall, "message": message, "accounts": [serialize_account(account)]}
+
+
async def refresh_account(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]:
account = _owned_account(db, user, account_id)
connection = account.connection
diff --git a/docs/META_APP_REVIEW_PERMISSIONS.md b/docs/META_APP_REVIEW_PERMISSIONS.md
index b68ef1a..f594be8 100644
--- a/docs/META_APP_REVIEW_PERMISSIONS.md
+++ b/docs/META_APP_REVIEW_PERMISSIONS.md
@@ -357,7 +357,7 @@ Login → grant `ads_management` → **Campaigns → Create Campaign** (or pause
| **Official purpose** | Higher Marketing API rate limits, unlimited ad accounts, higher system-user limits, Business Manager API access. |
| **Minimum** | `ads_read` **or** `ads_management` |
| **Allowed usage** | Scale marketing ops; read reports (`ads_read`); read and manage ads (`ads_management`); pull reports for some clients and manage ads for others (both permissions). Aggregated de-identified analytics to improve the app is also listed. |
-| **Tiers** | Adding Marketing API product → **Limited** (dev). **Full** needs App Review plus, to **maintain/upgrade**: ~**500 successful Marketing API calls in 15 days** and **<15% errors** on last 500 calls. |
+| **Tiers** | Adding Marketing API product → **Limited** (dev). **Full** needs App Review plus, to **maintain/upgrade**: ~**500 successful Marketing API calls in 15 days** and **<15% errors** on last 500 calls. Only live **`/act_{ad-account-id}/…`** calls count (insights, campaigns, ad sets, ads). Standard Graph (`/me`, `/me/accounts`, `/me/businesses`, Page posts) does **not** count. |
| **Test call** | Any successful Marketing API call (`/insights`, `/campaigns`, etc.). For Full tier, volume matters after approval. |
**Relation to “Ads Management Standard Access”**
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index f3c5581..cf3a9bd 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -24,6 +24,7 @@ import { MetaConnectionsPage } from './features/meta/ConnectionsPage'
import { MetaPagesPage } from './features/meta/PagesPage'
import { MetaAdAccountsPage } from './features/meta/AdAccountsPage'
import { MetaCampaignsPage } from './features/meta/CampaignsPage'
+import { MetaCampaignDetailPage } from './features/meta/CampaignDetailPage'
import { MetaCampaignBuilderPage } from './features/meta/CampaignBuilderPage'
import { MetaAdsPage } from './features/meta/AdsPage'
import { MetaAnalyticsPage } from './features/meta/AnalyticsPage'
@@ -43,9 +44,9 @@ import { PrivacyPolicyPage } from './features/legal/PrivacyPolicyPage'
function Placeholder() {
return (
This product area is ready for the next iteration. We’ll open it here when it’s ready. The rest of your workspace is unchanged.Module workspace
- This area is on the way
+