meta embed flow

This commit is contained in:
debu-2 2026-10-07 23:25:19 +05:30
parent ae72b59878
commit 99ed8f2477
10 changed files with 2256 additions and 151 deletions

View file

@ -46,5 +46,7 @@ WHATSAPP_ACCESS_TOKEN=
WHATSAPP_PHONE_NUMBER_ID= WHATSAPP_PHONE_NUMBER_ID=
WHATSAPP_BUSINESS_ACCOUNT_ID= WHATSAPP_BUSINESS_ACCOUNT_ID=
META_APP_ID= META_APP_ID=
# Facebook Login for Business configuration ID (Embedded Signup). Not a secret.
META_EMBEDDED_SIGNUP_CONFIG_ID=
WHATSAPP_BUSINESS_PHONE=919411257916 WHATSAPP_BUSINESS_PHONE=919411257916
WHATSAPP_API_VERSION=v21.0 WHATSAPP_API_VERSION=v21.0

View file

@ -37,6 +37,7 @@ class Settings(BaseSettings):
whatsapp_phone_number_id: str = "" whatsapp_phone_number_id: str = ""
whatsapp_business_account_id: str = "" whatsapp_business_account_id: str = ""
meta_app_id: str = "" meta_app_id: str = ""
meta_embedded_signup_config_id: str = ""
whatsapp_business_phone: str = "919411257916" whatsapp_business_phone: str = "919411257916"
# Public HTTPS base for webhook callback (no trailing slash), e.g. https://urmiai.com # Public HTTPS base for webhook callback (no trailing slash), e.g. https://urmiai.com
public_api_base_url: str = "" public_api_base_url: str = ""

View file

@ -539,6 +539,87 @@ class WhatsAppMessage(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
class WhatsAppMetaConnection(Base):
"""A customer's Meta authorization. Tokens stay server-side."""
__tablename__ = "whatsapp_meta_connections"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True)
status: Mapped[str] = mapped_column(String(24), default="pending", index=True)
meta_business_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
business_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
granted_scopes: Mapped[str | None] = mapped_column(String(500), nullable=True)
access_token: Mapped[str | None] = mapped_column(Text, nullable=True)
last_error: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
disconnected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
accounts: Mapped[list["WhatsAppBusinessAccount"]] = relationship(back_populates="connection")
class WhatsAppBusinessAccount(Base):
"""One WhatsApp Business Account (WABA) owned by a single Urmi user."""
__tablename__ = "whatsapp_business_accounts"
__table_args__ = (UniqueConstraint("waba_id", name="uq_whatsapp_waba_id"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True)
connection_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("whatsapp_meta_connections.id", ondelete="CASCADE"), index=True
)
waba_id: Mapped[str] = mapped_column(String(64), index=True)
name: Mapped[str] = mapped_column(String(200), default="WhatsApp Business")
status: Mapped[str] = mapped_column(String(24), default="provisioning", index=True)
messaging_status: Mapped[str] = mapped_column(String(24), default="unavailable")
management_status: Mapped[str] = mapped_column(String(24), default="unavailable")
templates_status: Mapped[str] = mapped_column(String(24), default="unavailable")
webhooks_status: Mapped[str] = mapped_column(String(24), default="unavailable")
template_count: Mapped[int] = mapped_column(default=0)
last_error: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
connection: Mapped[WhatsAppMetaConnection] = relationship(back_populates="accounts")
phones: Mapped[list["WhatsAppPhoneAsset"]] = relationship(
back_populates="account", cascade="all, delete-orphan"
)
class WhatsAppPhoneAsset(Base):
__tablename__ = "whatsapp_phone_assets"
__table_args__ = (UniqueConstraint("phone_number_id", name="uq_whatsapp_phone_number_id"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True)
account_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("whatsapp_business_accounts.id", ondelete="CASCADE"), index=True
)
phone_number_id: Mapped[str] = mapped_column(String(64), index=True)
display_phone: Mapped[str] = mapped_column(String(32), default="")
verified_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
quality: Mapped[str | None] = mapped_column(String(24), nullable=True)
status: Mapped[str] = mapped_column(String(32), default="pending")
code_verification_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
account: Mapped[WhatsAppBusinessAccount] = relationship(back_populates="phones")
class WhatsAppConnectionAudit(Base):
__tablename__ = "whatsapp_connection_audits"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True)
connection_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
waba_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
event: Mapped[str] = mapped_column(String(40), index=True)
detail: Mapped[str] = mapped_column(String(500), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
class AgentMessageTemplate(Base): class AgentMessageTemplate(Base):
"""Platform WhatsApp agent message templates available for user subscription.""" """Platform WhatsApp agent message templates available for user subscription."""

View file

@ -3,15 +3,29 @@ import logging
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import select from sqlalchemy import or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .auth import current_user from .auth import current_user
from .config import settings from .config import settings
from .database import get_db from .database import get_db
from .models import UserAccount, WhatsAppWebhookEvent from .models import UserAccount, WhatsAppBusinessAccount, WhatsAppPhoneAsset, WhatsAppWebhookEvent
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
from .whatsapp_config import resolved_whatsapp_app_secret from .whatsapp_config import resolved_whatsapp_app_secret
from .whatsapp_onboarding import (
RECOMMENDED_WEBHOOK_FIELDS,
account_detail,
account_logs,
complete_signup,
customer_token_for_phone,
developer_config,
disconnect_account,
list_accounts,
mask_identifier,
list_templates,
refresh_account,
signup_public_config,
)
from .whatsapp_security import verify_meta_signature from .whatsapp_security import verify_meta_signature
from .whatsapp_webhook import persist_webhook_payload from .whatsapp_webhook import persist_webhook_payload
from .whatsapp_messages import record_inbound_messages from .whatsapp_messages import record_inbound_messages
@ -20,22 +34,12 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/whatsapp", tags=["whatsapp"]) router = APIRouter(prefix="/api/v1/whatsapp", tags=["whatsapp"])
RECOMMENDED_WEBHOOK_FIELDS = [
"messages",
"message_template_status_update",
"message_template_quality_update",
"account_update",
"phone_number_name_update",
"phone_number_quality_update",
"business_capability_update",
]
def _serialize_event(event: WhatsAppWebhookEvent) -> dict: def _serialize_event(event: WhatsAppWebhookEvent) -> dict:
return { return {
"id": str(event.id), "id": str(event.id),
"waba_id": event.waba_id, "waba_id": mask_identifier(event.waba_id),
"phone_number_id": event.phone_number_id, "phone_number_id": mask_identifier(event.phone_number_id),
"field": event.field, "field": event.field,
"event_type": event.event_type, "event_type": event.event_type,
"from_wa_id": event.from_wa_id, "from_wa_id": event.from_wa_id,
@ -101,19 +105,14 @@ async def receive_webhook(
def whatsapp_setup( def whatsapp_setup(
_: UserAccount = Depends(current_user), _: UserAccount = Depends(current_user),
) -> dict: ) -> dict:
"""Console setup info — paste these values into Meta Developer Console.""" """Customer-safe webhook address. Secrets stay on the server."""
return { return {
"webhook_url": settings.whatsapp_webhook_url, "webhook_url": settings.whatsapp_webhook_url,
"verify_token": settings.whatsapp_verify_token or None,
"verify_token_configured": bool(settings.whatsapp_verify_token), "verify_token_configured": bool(settings.whatsapp_verify_token),
"app_secret_configured": bool(settings.whatsapp_app_secret), "verify_token_visible": False,
"access_token_configured": bool(settings.whatsapp_access_token),
"phone_number_id": settings.whatsapp_phone_number_id or None,
"business_account_id": settings.whatsapp_business_account_id or None,
"meta_app_id": settings.meta_app_id or None,
"api_version": settings.whatsapp_api_version, "api_version": settings.whatsapp_api_version,
"recommended_fields": RECOMMENDED_WEBHOOK_FIELDS, "recommended_fields": RECOMMENDED_WEBHOOK_FIELDS,
"docs_url": "https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview/", "docs_url": "https://developers.facebook.com/documentation/business-messaging/whatsapp/embedded-signup",
} }
@ -121,10 +120,24 @@ def whatsapp_setup(
def list_webhook_events( def list_webhook_events(
limit: int = Query(default=25, ge=1, le=100), limit: int = Query(default=25, ge=1, le=100),
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: UserAccount = Depends(current_user), user: UserAccount = Depends(current_user),
) -> list[dict]: ) -> list[dict]:
waba_ids = list(
db.scalars(select(WhatsAppBusinessAccount.waba_id).where(WhatsAppBusinessAccount.user_id == user.id)).all()
)
phone_ids = list(
db.scalars(select(WhatsAppPhoneAsset.phone_number_id).where(WhatsAppPhoneAsset.user_id == user.id)).all()
)
if not waba_ids and not phone_ids:
return []
filters = []
if waba_ids:
filters.append(WhatsAppWebhookEvent.waba_id.in_(waba_ids))
if phone_ids:
filters.append(WhatsAppWebhookEvent.phone_number_id.in_(phone_ids))
events = db.scalars( events = db.scalars(
select(WhatsAppWebhookEvent) select(WhatsAppWebhookEvent)
.where(or_(*filters))
.order_by(WhatsAppWebhookEvent.created_at.desc()) .order_by(WhatsAppWebhookEvent.created_at.desc())
.limit(limit) .limit(limit)
).all() ).all()
@ -134,15 +147,106 @@ def list_webhook_events(
class SendWhatsAppTextRequest(BaseModel): class SendWhatsAppTextRequest(BaseModel):
to: str = Field(min_length=8, max_length=20) to: str = Field(min_length=8, max_length=20)
body: str = Field(min_length=1, max_length=4096) body: str = Field(min_length=1, max_length=4096)
phone_number_id: str = Field(min_length=4, max_length=64)
class SignupCompleteRequest(BaseModel):
event: str = Field(min_length=1, max_length=80)
code: str | None = Field(default=None, max_length=2048)
waba_id: str | None = Field(default=None, max_length=64)
phone_number_id: str | None = Field(default=None, max_length=64)
business_id: str | None = Field(default=None, max_length=64)
error_message: str | None = Field(default=None, max_length=500)
@router.get("/signup/config")
def whatsapp_signup_config(
db: Session = Depends(get_db),
_: UserAccount = Depends(current_user),
) -> dict:
return signup_public_config(db)
@router.get("/accounts")
def whatsapp_accounts(
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> list[dict]:
return list_accounts(db, user)
@router.post("/signup/complete")
async def whatsapp_signup_complete(
payload: SignupCompleteRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await complete_signup(db, user, payload.model_dump())
@router.get("/accounts/{account_id}")
def whatsapp_account_detail(
account_id: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return account_detail(db, user, account_id)
@router.post("/accounts/{account_id}/refresh")
async def whatsapp_account_refresh(
account_id: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await refresh_account(db, user, account_id)
@router.post("/accounts/{account_id}/disconnect")
async def whatsapp_account_disconnect(
account_id: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await disconnect_account(db, user, account_id)
@router.get("/accounts/{account_id}/templates")
async def whatsapp_account_templates(
account_id: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return await list_templates(db, user, account_id)
@router.get("/accounts/{account_id}/logs")
def whatsapp_account_logs(
account_id: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return account_logs(db, user, account_id)
@router.get("/accounts/{account_id}/developer")
def whatsapp_account_developer(
account_id: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
return developer_config(db, user, account_id)
@router.post("/messages/send") @router.post("/messages/send")
async def send_whatsapp_text( async def send_whatsapp_text(
payload: SendWhatsAppTextRequest, payload: SendWhatsAppTextRequest,
_: UserAccount = Depends(current_user), db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict: ) -> dict:
"""Send a test text message via Graph API (requires WHATSAPP_ACCESS_TOKEN).""" """Send from a WhatsApp number this workspace has actually connected."""
client = WhatsAppGraphClient() token, phone_number_id = customer_token_for_phone(db, user, payload.phone_number_id.strip())
client = WhatsAppGraphClient(access_token=token, phone_number_id=phone_number_id)
try: try:
return await client.send_text(payload.to.strip(), payload.body.strip()) return await client.send_text(payload.to.strip(), payload.body.strip())
except WhatsAppGraphError as exc: except WhatsAppGraphError as exc:

View file

@ -0,0 +1,693 @@
"""Customer WhatsApp Embedded Signup provisioning.
Tokens and the platform verify token never leave this module's responses.
Each WABA and phone number is stored against one Urmi user and cannot be
addressed by another tenant.
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timezone
from typing import Any
import httpx
from fastapi import HTTPException, status
from sqlalchemy import or_, select
from sqlalchemy.orm import Session, selectinload
from .config import settings
from .models import (
UserAccount,
WhatsAppBusinessAccount,
WhatsAppConnectionAudit,
WhatsAppMetaConnection,
WhatsAppPhoneAsset,
WhatsAppWebhookEvent,
)
from .whatsapp_config import resolved_whatsapp_app_secret
logger = logging.getLogger(__name__)
RECOMMENDED_WEBHOOK_FIELDS = [
"messages",
"message_template_status_update",
"message_template_quality_update",
"account_update",
"phone_number_name_update",
"phone_number_quality_update",
"business_capability_update",
]
def signup_public_config(db: Session) -> dict[str, Any]:
app_id = settings.meta_app_id.strip()
config_id = settings.meta_embedded_signup_config_id.strip()
secret = resolved_whatsapp_app_secret(db)
return {
"app_id": app_id or None,
"config_id": config_id or None,
"api_version": settings.whatsapp_api_version,
"ready": bool(app_id and config_id and secret),
}
def _audit(
db: Session,
user: UserAccount,
event: str,
detail: str,
*,
connection_id: Any = None,
waba_id: str | None = None,
) -> None:
db.add(
WhatsAppConnectionAudit(
user_id=user.id,
connection_id=connection_id,
waba_id=waba_id,
event=event,
detail=detail[:500],
)
)
def mask_identifier(value: str | None) -> str | None:
if not value:
return None
tail = value[-4:]
return f"••••••••{tail}"
def _quality_label(value: str | None) -> str | None:
if not value:
return None
normalized = value.strip().upper()
return {
"GREEN": "High",
"HIGH": "High",
"YELLOW": "Medium",
"MEDIUM": "Medium",
"RED": "Low",
"LOW": "Low",
"UNKNOWN": "Unknown",
}.get(normalized, value.title())
def _graph_error_message(data: Any, fallback: str) -> str:
if isinstance(data, dict):
error = data.get("error")
if isinstance(error, dict) and error.get("message"):
return str(error["message"])[:500]
return fallback
async def _graph(
method: str,
path: str,
*,
token: str | None = None,
params: dict[str, Any] | None = None,
json_body: dict[str, Any] | None = None,
) -> dict[str, Any]:
url = f"https://graph.facebook.com/{settings.whatsapp_api_version}/{path.lstrip('/')}"
headers = {"Authorization": f"Bearer {token}"} if token else {}
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.request(method, url, params=params, json=json_body, headers=headers)
try:
data = response.json()
except ValueError:
data = {}
if response.status_code >= 400:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=_graph_error_message(data, "Meta could not complete WhatsApp setup"),
)
return data if isinstance(data, dict) else {}
async def _exchange_code(db: Session, code: str) -> str:
app_id = settings.meta_app_id.strip()
secret = resolved_whatsapp_app_secret(db)
if not app_id or not secret:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="WhatsApp Embedded Signup is not configured",
)
data = await _graph(
"GET",
"oauth/access_token",
params={"client_id": app_id, "client_secret": secret, "code": code},
)
token = str(data.get("access_token") or "")
if not token:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Meta did not return an authorization token",
)
return token
async def _debug_token(db: Session, token: str) -> dict[str, Any]:
app_id = settings.meta_app_id.strip()
secret = resolved_whatsapp_app_secret(db)
data = await _graph(
"GET",
"debug_token",
params={"input_token": token, "access_token": f"{app_id}|{secret}"},
)
payload = data.get("data")
return payload if isinstance(payload, dict) else {}
def _waba_ids_from_debug(debug: dict[str, Any]) -> list[str]:
found: list[str] = []
scopes = debug.get("granular_scopes")
if not isinstance(scopes, list):
return found
preferred = {"whatsapp_business_management", "whatsapp_business_messaging"}
for item in scopes:
if not isinstance(item, dict):
continue
if item.get("scope") not in preferred:
continue
for target in item.get("target_ids") or []:
value = str(target)
if value and value not in found:
found.append(value)
return found
def _as_uuid(account_id: Any) -> uuid.UUID:
try:
return account_id if isinstance(account_id, uuid.UUID) else uuid.UUID(str(account_id))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="WhatsApp account not found") from exc
def _owned_account(db: Session, user: UserAccount, account_id: Any) -> WhatsAppBusinessAccount:
account = db.scalar(
select(WhatsAppBusinessAccount)
.options(selectinload(WhatsAppBusinessAccount.phones), selectinload(WhatsAppBusinessAccount.connection))
.where(WhatsAppBusinessAccount.id == _as_uuid(account_id), WhatsAppBusinessAccount.user_id == user.id)
)
if account is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="WhatsApp account not found")
return account
def _serialize_phone(phone: WhatsAppPhoneAsset) -> dict[str, Any]:
return {
"id": str(phone.id),
"phone_number_id": phone.phone_number_id,
"display_phone": phone.display_phone,
"verified_name": phone.verified_name,
"quality": phone.quality,
"status": phone.status,
"code_verification_status": phone.code_verification_status,
}
def serialize_account(account: WhatsAppBusinessAccount) -> dict[str, Any]:
phones = [_serialize_phone(phone) for phone in account.phones]
primary = next((phone for phone in phones if phone["status"] == "connected"), phones[0] if phones else None)
connection = account.connection
usable = account.status == "connected"
return {
"id": str(account.id),
"waba_id_masked": mask_identifier(account.waba_id),
"name": account.name,
"business_name": connection.business_name if connection else None,
"business_id_masked": mask_identifier(connection.meta_business_id) if connection else None,
"status": account.status,
"usable": usable,
"messaging_status": account.messaging_status if usable else "unavailable",
"management_status": account.management_status if usable or account.status == "incomplete" else "unavailable",
"templates_status": account.templates_status if account.templates_status == "active" else "unavailable",
"webhooks_status": account.webhooks_status if account.webhooks_status == "active" else "unavailable",
"template_count": account.template_count,
"quality": primary["quality"] if primary else None,
"primary_phone": primary,
"phones": phones,
"last_error": account.last_error,
"updated_at": account.updated_at,
}
def list_accounts(db: Session, user: UserAccount) -> list[dict[str, Any]]:
rows = db.scalars(
select(WhatsAppBusinessAccount)
.options(selectinload(WhatsAppBusinessAccount.phones), selectinload(WhatsAppBusinessAccount.connection))
.where(WhatsAppBusinessAccount.user_id == user.id)
.order_by(WhatsAppBusinessAccount.created_at.desc())
).all()
return [serialize_account(row) for row in rows]
async def _load_waba(token: str, waba_id: str) -> dict[str, Any]:
return await _graph(
"GET",
waba_id,
token=token,
params={"fields": "id,name,account_review_status,owner_business_info"},
)
async def _load_phones(token: str, waba_id: str) -> list[dict[str, Any]]:
data = await _graph(
"GET",
f"{waba_id}/phone_numbers",
token=token,
params={
"fields": "id,display_phone_number,verified_name,quality_rating,code_verification_status,status",
"limit": "50",
},
)
rows = data.get("data")
return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else []
async def _subscribe(token: str, waba_id: str) -> bool:
try:
await _graph("POST", f"{waba_id}/subscribed_apps", token=token)
return True
except HTTPException:
logger.warning("WhatsApp subscribed_apps failed for waba %s", waba_id[-4:])
return False
async def _template_count(token: str, waba_id: str) -> tuple[int, bool]:
try:
data = await _graph(
"GET",
f"{waba_id}/message_templates",
token=token,
params={"limit": "1", "summary": "total_count"},
)
except HTTPException:
return 0, False
summary = data.get("summary")
if isinstance(summary, dict) and isinstance(summary.get("total_count"), int):
return summary["total_count"], True
rows = data.get("data")
return (len(rows) if isinstance(rows, list) else 0), True
def _claim_waba(db: Session, user: UserAccount, waba_id: str) -> WhatsAppBusinessAccount | None:
existing = db.scalar(select(WhatsAppBusinessAccount).where(WhatsAppBusinessAccount.waba_id == waba_id))
if existing is not None and existing.user_id != user.id:
return None
return existing
def _apply_phones(
db: Session,
user: UserAccount,
account: WhatsAppBusinessAccount,
phones: list[dict[str, Any]],
session_phone_id: str | None,
) -> None:
seen = {str(item.get("id")) for item in phones if item.get("id")}
if session_phone_id and session_phone_id not in seen:
phones.append({"id": session_phone_id})
current = {phone.phone_number_id: phone for phone in account.phones}
for item in phones:
phone_id = str(item.get("id") or "")
if not phone_id:
continue
other = db.scalar(select(WhatsAppPhoneAsset).where(WhatsAppPhoneAsset.phone_number_id == phone_id))
if other is not None and other.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This phone number is already connected to another Urmi workspace",
)
raw_status = str(item.get("status") or "PENDING").upper()
phone_status = "connected" if raw_status == "CONNECTED" else raw_status.lower()
row = current.get(phone_id) or other
if row is None:
row = WhatsAppPhoneAsset(
user_id=user.id,
account_id=account.id,
phone_number_id=phone_id,
)
db.add(row)
if row not in account.phones:
account.phones.append(row)
row.user_id = user.id
row.account_id = account.id
row.display_phone = str(item.get("display_phone_number") or row.display_phone or "")
row.verified_name = item.get("verified_name") or row.verified_name
row.quality = _quality_label(item.get("quality_rating"))
row.status = phone_status
row.code_verification_status = item.get("code_verification_status")
async def _provision_waba(
db: Session,
user: UserAccount,
connection: WhatsAppMetaConnection,
token: str,
waba_id: str,
session_phone_id: str | None,
) -> WhatsAppBusinessAccount:
claimed = _claim_waba(db, user, waba_id)
if claimed is None and db.scalar(select(WhatsAppBusinessAccount.id).where(WhatsAppBusinessAccount.waba_id == waba_id)):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This WhatsApp Business Account is already connected to another Urmi workspace",
)
try:
waba = await _load_waba(token, waba_id)
except HTTPException as exc:
account = claimed or WhatsAppBusinessAccount(
user_id=user.id,
connection_id=connection.id,
waba_id=waba_id,
name="WhatsApp Business",
)
if claimed is None:
db.add(account)
db.flush()
detail = str(exc.detail)[:500]
expired = "expired" in detail.lower() or "validating access token" in detail.lower()
account.status = "expired" if expired else "failed"
account.last_error = detail
account.messaging_status = "unavailable"
account.management_status = "unavailable"
connection.status = "failed"
connection.last_error = account.last_error
_audit(db, user, "provision_failed", account.last_error or "", connection_id=connection.id, waba_id=waba_id)
return account
owner = waba.get("owner_business_info") if isinstance(waba.get("owner_business_info"), dict) else {}
business_id = str(owner.get("id") or connection.meta_business_id or "")
business_name = str(owner.get("name") or connection.business_name or waba.get("name") or "WhatsApp Business")
connection.meta_business_id = business_id or connection.meta_business_id
connection.business_name = business_name
connection.status = "connected"
connection.last_error = None
connection.access_token = token
account = claimed or WhatsAppBusinessAccount(
user_id=user.id,
connection_id=connection.id,
waba_id=waba_id,
)
if claimed is None:
db.add(account)
db.flush()
previous_connection_id = account.connection_id if claimed is not None else None
account.user_id = user.id
account.connection_id = connection.id
if previous_connection_id and previous_connection_id != connection.id:
previous = db.get(WhatsAppMetaConnection, previous_connection_id)
if previous is not None and previous.user_id == user.id:
previous.access_token = None
account.name = str(waba.get("name") or business_name)
account.last_error = None
account.management_status = "active"
try:
phones = await _load_phones(token, waba_id)
except HTTPException as exc:
phones = []
account.last_error = str(exc.detail)[:500]
_apply_phones(db, user, account, phones, session_phone_id)
subscribed = await _subscribe(token, waba_id)
template_count, templates_ok = await _template_count(token, waba_id)
account.template_count = template_count
account.templates_status = "active" if templates_ok else "unavailable"
account.webhooks_status = "active" if subscribed else "unavailable"
has_connected_phone = any(phone.status == "connected" for phone in account.phones)
if has_connected_phone and account.management_status == "active":
account.status = "connected"
account.messaging_status = "active"
_audit(db, user, "connected", "WhatsApp Business connection verified", connection_id=connection.id, waba_id=waba_id)
else:
account.status = "incomplete"
account.messaging_status = "unavailable"
account.last_error = account.last_error or "Phone number verification is not complete"
_audit(
db,
user,
"provision_incomplete",
account.last_error,
connection_id=connection.id,
waba_id=waba_id,
)
return account
async def complete_signup(db: Session, user: UserAccount, payload: dict[str, Any]) -> dict[str, Any]:
event = str(payload.get("event") or "").upper()
error_message = str(payload.get("error_message") or "").strip()[:500]
if event == "CANCEL":
_audit(db, user, "signup_cancelled", "Customer closed Embedded Signup")
db.commit()
return {"status": "cancelled", "message": "WhatsApp setup was cancelled. Nothing was connected.", "accounts": []}
if event == "ERROR" or event == "DENIED":
_audit(db, user, "signup_denied", error_message or "Meta denied the requested permissions")
db.commit()
return {
"status": "denied",
"message": error_message or "Meta did not grant the permissions Urmi needs. You can try again.",
"accounts": [],
}
if not event.startswith("FINISH"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unrecognized Embedded Signup result")
code = str(payload.get("code") or "").strip()
if not code:
_audit(db, user, "provision_failed", "Embedded Signup finished without an authorization code")
db.commit()
return {
"status": "failed",
"message": "Meta finished without an authorization code. Start setup again.",
"accounts": [],
}
token = await _exchange_code(db, code)
debug = await _debug_token(db, token)
if debug.get("is_valid") is False:
_audit(db, user, "authorization_expired", "Meta authorization was not valid")
db.commit()
return {
"status": "expired",
"message": "The Meta authorization expired before Urmi could finish setup. Start setup again.",
"accounts": [],
}
session_waba = str(payload.get("waba_id") or "").strip()
waba_ids = [session_waba] if session_waba else _waba_ids_from_debug(debug)
if not waba_ids:
_audit(db, user, "provision_failed", "Embedded Signup did not return a WhatsApp Business Account")
db.commit()
return {
"status": "failed",
"message": "Meta did not return a WhatsApp Business Account. Finish business and number setup in the Meta window, then try again.",
"accounts": [],
}
scopes = debug.get("scopes")
granted = ",".join(str(item) for item in scopes) if isinstance(scopes, list) else None
connection = WhatsAppMetaConnection(
user_id=user.id,
status="pending",
meta_business_id=str(payload.get("business_id") or "") or None,
granted_scopes=granted,
access_token=token,
)
db.add(connection)
db.flush()
_audit(db, user, "authorization_received", "Embedded Signup returned an authorization code", connection_id=connection.id)
session_phone = str(payload.get("phone_number_id") or "").strip() or None
accounts: list[WhatsAppBusinessAccount] = []
for waba_id in waba_ids:
accounts.append(await _provision_waba(db, user, connection, token, waba_id, session_phone))
db.commit()
for account in accounts:
db.refresh(account)
statuses = {account.status for account in accounts}
if "connected" in statuses and statuses <= {"connected"}:
overall = "connected"
message = "WhatsApp Business is connected."
elif "connected" in statuses:
overall = "connected"
message = "Some WhatsApp Business accounts are connected. Others still need attention."
elif "incomplete" in statuses:
overall = "incomplete"
message = "Meta authorization succeeded, but phone verification is not complete. Finish verification in Meta, then reconnect."
else:
overall = "failed"
message = accounts[0].last_error or "Urmi could not finish WhatsApp setup."
return {"status": overall, "message": message, "accounts": [serialize_account(account) for account in accounts]}
async def refresh_account(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]:
account = _owned_account(db, user, account_id)
connection = account.connection
token = connection.access_token if connection else None
if connection is None or not token or connection.status == "disconnected":
account.status = "expired"
account.messaging_status = "unavailable"
account.last_error = "Authorization expired. Reconnect this WhatsApp account."
_audit(db, user, "authorization_expired", account.last_error, connection_id=connection.id if connection else None, waba_id=account.waba_id)
db.commit()
db.refresh(account)
return serialize_account(account)
await _provision_waba(db, user, connection, token, account.waba_id, None)
db.commit()
db.refresh(account)
return serialize_account(account)
async def disconnect_account(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]:
account = _owned_account(db, user, account_id)
connection = account.connection
token = connection.access_token if connection else None
if token:
try:
await _graph("DELETE", f"{account.waba_id}/subscribed_apps", token=token)
except HTTPException:
logger.info("Could not unsubscribe WABA %s during disconnect", account.waba_id[-4:])
account.status = "disconnected"
account.messaging_status = "unavailable"
account.management_status = "unavailable"
account.templates_status = "unavailable"
account.webhooks_status = "unavailable"
account.last_error = None
for phone in account.phones:
phone.status = "disconnected"
if connection is not None:
siblings_remain = (
db.scalar(
select(WhatsAppBusinessAccount.id).where(
WhatsAppBusinessAccount.connection_id == connection.id,
WhatsAppBusinessAccount.id != account.id,
WhatsAppBusinessAccount.status != "disconnected",
)
)
is not None
)
if not siblings_remain:
connection.status = "disconnected"
connection.access_token = None
connection.disconnected_at = datetime.now(timezone.utc)
_audit(db, user, "disconnected", "Customer disconnected this WhatsApp Business account", connection_id=connection.id if connection else None, waba_id=account.waba_id)
db.commit()
db.refresh(account)
return serialize_account(account)
def account_detail(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]:
return serialize_account(_owned_account(db, user, account_id))
async def list_templates(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]:
account = _owned_account(db, user, account_id)
if account.templates_status != "active" or account.connection is None or not account.connection.access_token:
return {"available": False, "templates": []}
data = await _graph(
"GET",
f"{account.waba_id}/message_templates",
token=account.connection.access_token,
params={"fields": "name,category,language,status,quality_score", "limit": "50"},
)
rows = data.get("data") if isinstance(data.get("data"), list) else []
templates = []
for row in rows:
if not isinstance(row, dict):
continue
quality = row.get("quality_score")
score = quality.get("score") if isinstance(quality, dict) else None
templates.append(
{
"name": row.get("name"),
"category": row.get("category"),
"language": row.get("language"),
"status": row.get("status"),
"quality": _quality_label(str(score) if score else None),
}
)
account.template_count = len(templates)
db.commit()
return {"available": True, "templates": templates}
def account_logs(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]:
account = _owned_account(db, user, account_id)
audits = db.scalars(
select(WhatsAppConnectionAudit)
.where(
WhatsAppConnectionAudit.user_id == user.id,
or_(
WhatsAppConnectionAudit.waba_id == account.waba_id,
WhatsAppConnectionAudit.connection_id == account.connection_id,
),
)
.order_by(WhatsAppConnectionAudit.created_at.desc())
.limit(50)
).all()
phone_ids = [phone.phone_number_id for phone in account.phones]
event_filter = [WhatsAppWebhookEvent.waba_id == account.waba_id]
if phone_ids:
event_filter.append(WhatsAppWebhookEvent.phone_number_id.in_(phone_ids))
events = db.scalars(
select(WhatsAppWebhookEvent)
.where(or_(*event_filter))
.order_by(WhatsAppWebhookEvent.created_at.desc())
.limit(25)
).all()
return {
"audits": [
{
"id": str(item.id),
"event": item.event,
"detail": item.detail,
"created_at": item.created_at,
}
for item in audits
],
"events": [
{
"id": str(item.id),
"field": item.field,
"event_type": item.event_type,
"from_wa_id": item.from_wa_id,
"status": item.status,
"created_at": item.created_at,
}
for item in events
],
}
def developer_config(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]:
account = _owned_account(db, user, account_id)
return {
"webhook_url": settings.whatsapp_webhook_url,
"verify_token_configured": bool(settings.whatsapp_verify_token),
"verify_token_visible": False,
"recommended_fields": RECOMMENDED_WEBHOOK_FIELDS,
"webhooks_status": account.webhooks_status,
"docs_url": "https://developers.facebook.com/documentation/business-messaging/whatsapp/embedded-signup",
}
def customer_token_for_phone(db: Session, user: UserAccount, phone_number_id: str) -> tuple[str, str]:
phone = db.scalar(
select(WhatsAppPhoneAsset)
.options(selectinload(WhatsAppPhoneAsset.account).selectinload(WhatsAppBusinessAccount.connection))
.where(WhatsAppPhoneAsset.phone_number_id == phone_number_id, WhatsAppPhoneAsset.user_id == user.id)
)
if phone is None or phone.account.status != "connected" or phone.status != "connected":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No connected WhatsApp number for this workspace")
token = phone.account.connection.access_token if phone.account.connection else None
if not token:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before sending")
return token, phone.phone_number_id

View file

@ -51,6 +51,7 @@ services:
WHATSAPP_PHONE_NUMBER_ID: ${WHATSAPP_PHONE_NUMBER_ID:-} WHATSAPP_PHONE_NUMBER_ID: ${WHATSAPP_PHONE_NUMBER_ID:-}
WHATSAPP_BUSINESS_ACCOUNT_ID: ${WHATSAPP_BUSINESS_ACCOUNT_ID:-} WHATSAPP_BUSINESS_ACCOUNT_ID: ${WHATSAPP_BUSINESS_ACCOUNT_ID:-}
META_APP_ID: ${META_APP_ID:-} META_APP_ID: ${META_APP_ID:-}
META_EMBEDDED_SIGNUP_CONFIG_ID: ${META_EMBEDDED_SIGNUP_CONFIG_ID:-}
WHATSAPP_BUSINESS_PHONE: ${WHATSAPP_BUSINESS_PHONE:-919411257916} WHATSAPP_BUSINESS_PHONE: ${WHATSAPP_BUSINESS_PHONE:-919411257916}
WHATSAPP_API_VERSION: ${WHATSAPP_API_VERSION:-v21.0} WHATSAPP_API_VERSION: ${WHATSAPP_API_VERSION:-v21.0}
UPLOAD_DIR: uploads UPLOAD_DIR: uploads

View file

@ -0,0 +1,501 @@
# Urmi × Meta App Review — permissions research
Research-only document. Nothing in this file is implemented in the product yet.
It covers the App Review items currently on the Meta dashboard for Urmi, maps each permission to allowed usage, Graph APIs, Urmi product screens, screencast and test-call requirements, and what **not** to request until the feature actually exists.
**Official sources (review these before submitting)**
- [Permissions Reference](https://developers.facebook.com/docs/permissions/)
- [Features Reference](https://developers.facebook.com/docs/features-reference/)
- [App Review submission guide](https://developers.facebook.com/documentation/resp-plat-initiatives/appreview/tutorial)
- [Screen recordings](https://developers.facebook.com/docs/app-review/submission-guide/screen-recordings/)
- [Tech Providers](https://developers.facebook.com/docs/development/release/tech-providers/)
- [Marketing API use cases](https://developers.facebook.com/documentation/development/create-an-app/marketing-api-use-cases)
- [Use-case permission mapping](https://developers.facebook.com/documentation/development/create-an-app/use-cases-permission-mapping)
---
## 1. What Urmi is today vs what this review is for
### Today (WhatsApp Cloud API)
Urmi already talks to Meta Graph for **WhatsApp Business**: send/receive messages, templates, media, read receipts, webhooks. That product uses:
- `whatsapp_business_messaging`
- `whatsapp_business_management`
Those permissions are **not** in the list at the top of this prompt. The existing **WhatsApp Business** area in admin/console should stay a messaging inbox. It does not justify Pages, Ads, or Marketing API access.
### What this App Review list is for
The permissions below belong to **Facebook Login for Business + Pages API + Marketing API**. They let a customer’s business connect their **Business Portfolio, Facebook Pages, and ad accounts** so Urmi can list assets, show Page engagement, read ads reports, and (later) create campaigns.
That is a **new Meta product section**, separate from the WhatsApp inbox.
### Tech Provider vs App Review
These are two different gates:
| Gate | What it unlocks |
|------|-----------------|
| **Business Verification + Tech Provider** | Other businesses (not just people with a role on URMI AI) can grant `pages_show_list`, `ads_read`, `ads_management`, `business_management`, etc. |
| **App Review Advanced Access** | Those permissions can be requested from any app user after the app is live |
Meta’s [Tech Provider](https://developers.facebook.com/docs/development/release/tech-providers/) list includes **every permission in this document**. Without Tech Provider verification, Graph calls from a customer who is **not** an app admin/developer/tester are rejected.
WhatsApp Tech Provider (Embedded Signup / Cloud API for clients) is related but **not the same submission**. You can be a WhatsApp Tech Provider and still fail Marketing API App Review if Pages/Ads screens do not exist.
---
## 2. Hard App Review rules (apply to every item)
1. **Only request permissions the live product uses today.** Meta rejects “we will use this later.” Audiences (Custom Audience upload) and Leads should **not** be in this submission unless those screens exist and call the APIs.
2. **Allowed usage is narrow.** If the written use case or screencast does not match the official allowed usage, that permission is denied.
3. **Screencast is required** for almost every item: full Facebook Login for Business, user grants **that** permission, then the Urmi UI consumes the data.
4. **At least one successful Graph call** using that permission in the last **30 days** before submit. The “Request advanced access” button stays disabled until Meta logs the call (can take up to 2 days).
5. **Dependencies must be in the same submission.** Example: `pages_read_engagement` requires `pages_show_list`; `ads_management` requires `pages_read_engagement`.
6. **Do not show tokens** in UI or recordings. Show granted-permission names, asset names, and metrics only.
7. Reviewers must be able to **reproduce** the flow with the test credentials you provide.
**Recommended Meta use case to add on the app**
For this permission set, customize:
- **Create & manage ads with Marketing API**, and/or
- **Measure ad performance data with Marketing API**, and/or
- **Manage everything on your Page** (for Pages listing + engagement)
Marketing API use cases **require** (cannot remove): `public_profile`, `ads_management`, `ads_read`, Marketing API Access Tier, `business_management`, `pages_read_engagement`, `pages_show_list`.
---
## 3. Permission dependency graph
```
public_profile
│
pages_show_list ─────────────────────────────────────────┐
│ │
pages_read_engagement ◄── required by ads_management │
│ │
business_management (also lists pages_show_list + │
pages_read_engagement as deps) │
│ │
ads_read (no Graph permission deps) │
│ │
ads_management ◄── requires pages_read_engagement │
│ (and therefore pages_show_list) │
Marketing API Access Tier ◄── requires ads_read │
OR ads_management │
```
Submit **all of these together** if Urmi will both list Pages and run/read ads. Do not submit `ads_management` without a working Pages + engagement surface.
---
## 4. Item-by-item research
Each section is written so it can be pasted into App Review after the product exists. Sentences in **Proposed App Review answer** are drafts; they must match the shipped UI exactly.
---
### 4.1 `pages_show_list`
| | |
|---|---|
| **Official purpose** | Access the list of Pages a person manages. |
| **Allowed usage** | (1) Show a person the list of Pages they manage. (2) Verify that a person manages a Page. |
| **Dependencies** | None |
| **Required by** | `pages_read_engagement`, and therefore `ads_management` |
| **Primary APIs** | `GET /me/accounts?fields=id,name,access_token,tasks,link,fan_count` |
| **Urmi screen** | **Connections** + **Facebook Pages** |
| **Test call** | Graph Explorer or app: `GET /me/accounts` with a user token that includes `pages_show_list` |
**What it does**
After Facebook Login for Business, Urmi can list Facebook Pages the logged-in admin can act on (name, Page ID, tasks such as `MANAGE` / `ADVERTISE` / `ANALYZE`). That is how Urmi knows which Page belongs to this workspace instead of asking the customer to paste a Page ID.
**What it does *not* do**
It does not read posts, insights, ads, or WhatsApp messages. Listing Pages is the entire allowed use.
**Compliance**
- Only Pages the user explicitly selected in the login dialog.
- Do not scrape or store Pages the user did not grant.
- Page access tokens are secrets (server-side only).
- Do not use this to browse public Pages the user does not manage.
**Urmi business use**
A business or agency may manage several brands. In **Facebook Pages**, they see authorized Pages, pick which Page this Urmi workspace uses, and see connection status. **Connections** shows “2 Pages connected.”
**Proposed App Review answer**
> Urmi is a communications and marketing platform for businesses. After Facebook Login for Business, we call `GET /me/accounts` with `pages_show_list` to show the customer only the Facebook Pages they manage, so they can connect the correct Page to their Urmi workspace. We use this solely to display that list and to verify that the user administers the selected Page before we read Page engagement or attach the Page to ads. We do not use it to access Pages the user does not manage.
**Screencast (must show)**
1. Logged-out Urmi → sign in.
2. **Meta → Connections → Connect Meta**.
3. Facebook Login for Business; user grants `pages_show_list` (and related scopes).
4. Urmi **Facebook Pages** lists the real Pages (name, @handle or ID, Connected).
5. User selects a Page. Stop. Do not skip login or use a pre-connected session only.
**Do not request if** Connections/Pages UI is still a mock with hardcoded Pages.
---
### 4.2 `business_management`
| | |
|---|---|
| **Official purpose** | Read/write Business Manager API. |
| **Allowed usage** | Manage business assets such as an ad account. Claim ad accounts. |
| **Dependencies** | `pages_read_engagement`, `pages_show_list` |
| **Primary APIs** | `GET /me/businesses`, `GET /{business-id}?fields=name,id`, `GET /{business-id}/owned_ad_accounts`, `GET /{business-id}/client_ad_accounts`, `GET /{business-id}/owned_pages`, system users / assigned users (write only if you actually assign assets) |
| **Urmi screen** | **Connections** (Business Portfolio) + **Ad Accounts** + **Meta Settings** |
| **Test call** | `GET /me/businesses` then `GET /{business-id}/owned_ad_accounts` |
**What it does**
Lets Urmi see which **Business Portfolio** the user belongs to and which **ad accounts and Pages** sit under that business—not only Pages from `/me/accounts`. For a Tech Provider this is how you onboard another company’s assets after they grant access.
**Allowed usage trap**
The permission reference also asks the screencast to show **ads performance data** (impressions, conversions, spend, clicks, reach) after grant. Reviewers treat `business_management` as “manage assets **and** prove the business can then see ads data in your app.” Pair this screencast with **Analytics** numbers that came from `ads_read`, while the **Connections** screen shows the Business Portfolio and ad accounts discovered via Business Manager API.
**If requested as a dependency**
If you mainly need it because Marketing API / Pages use cases require it, say so explicitly: *“Requested as a required permission for pages_show_list / Create & manage ads with Marketing API, and used to list the customer’s Business Portfolio and ad accounts.”*
**Compliance**
- Do not claim or reassign ad accounts unless the product has a real “claim / assign asset” flow. Listing owned/client accounts is enough for v1.
- Do not expose system-user tokens.
- Write endpoints (assign users, create ad accounts) are high-risk; skip until needed.
**Urmi business use**
**Connections** shows:
- Business Portfolio: genZenith / customer business — Connected
- Advertising: 1 Ad Account — Connected
**Meta Settings** lists the connected business, Pages, ad accounts, WABA (WABA still uses WhatsApp permissions, not this one).
**Proposed App Review answer**
> Urmi acts as a Tech Provider for other businesses. We use `business_management` to read the customer’s Business Portfolio and the ad accounts and Pages assigned to it (`GET /me/businesses`, `GET /{business-id}/owned_ad_accounts`, owned/client Pages). This lets the customer connect those assets to Urmi once, instead of pasting IDs. We do not claim or transfer assets in the current product. We request this both as allowed usage (manage/list business assets) and as a required permission for `pages_show_list` and Marketing API ads use cases. After connection, the same customer can open Meta Analytics in Urmi to see spend, impressions, clicks, and reach for those ad accounts.
**Screencast**
Login → grant `business_management` → Connections shows Business Portfolio + ad accounts → open **Analytics** and show impressions/spend/clicks (satisfies Meta’s ads-performance screencast note).
---
### 4.3 `ads_read`
| | |
|---|---|
| **Official purpose** | Ads Insights API reports for ad accounts you own or were granted; also Server-Side API (CAPI) for web events. |
| **Allowed usage** | Custom dashboards and data analytics. Send web events from your server to Facebook. |
| **Dependencies** | None (still pair with Login + an ad account) |
| **Primary APIs** | `GET /act_{ad-account-id}/insights?fields=spend,impressions,clicks,reach,cpc,ctr,actions&date_preset=last_7d` · `GET /act_{id}/campaigns` (read) · optional CAPI `POST /{pixel-id}/events` |
| **Urmi screen** | **Overview**, **Ad Accounts** (spend), **Analytics** |
| **Test call** | `GET /act_{id}/insights` with `ads_read` |
**What it does**
Read-only performance. Spend, impressions, clicks, CTR, CPC, reach, conversions. This is the cleanest justification for **Meta Analytics**.
**What it does *not* do**
Create, pause, or edit campaigns. That is `ads_management`.
**Compliance**
- Only ad accounts the user granted.
- Do not resell raw user-level ad data. Aggregated account/campaign metrics in the customer’s own dashboard is the allowed pattern.
- Server-Side API / CAPI is a **separate** allowed use. Do not mention CAPI in App Review unless Urmi actually sends purchase/lead events from the backend today.
- Custom Audience upload is **not** `ads_read`.
**Urmi business use**
**Analytics**: spend, impressions, clicks, CTR, CPC; filters Today / 7D / 30D; breakdown Campaigns / Ads. **Overview** tiles the same totals. **Ad Accounts** shows currency, status, spend.
**Proposed App Review answer**
> Urmi provides a Meta Analytics dashboard so a business can see advertising performance next to their customer-communication workspace. After the customer grants `ads_read`, we call the Ads Insights API (`GET /act_{ad-account-id}/insights`) and display spend, impressions, clicks, reach, CTR, and CPC in Urmi, filterable by date and campaign. We use this only for that customer’s authorized ad accounts, for in-product reporting. We do not create or edit ads with this permission.
**Screencast**
Login → grant `ads_read` → **Analytics** with real numbers (Impressions, Conversions/results, Spend, Clicks, Reach). Empty zeros fail review.
**Do not** justify `ads_read` with “we might send CAPI events later.”
---
### 4.4 `pages_read_engagement`
| | |
|---|---|
| **Official purpose** | Read Page-posted content (posts, photos, videos, events); follower name/PSID/picture; Page metadata and insights. |
| **Allowed usage** | Help a Page admin administer and manage a Page: get content posted by the Page; get names, PSIDs, pictures of Page followers; get metadata about the Page. |
| **Dependencies** | **Must include `pages_show_list` in the same submission** |
| **Primary APIs** | `GET /{page-id}?fields=name,username,fan_count,link,about` · `GET /{page-id}/posts?fields=id,message,created_time,permalink_url,likes.summary(true),comments.summary(true)` · Page insights require **`read_insights` as well** (`GET /{page-id}/insights`) — **not in the current request list** |
| **Urmi screen** | **Facebook Pages** → engagement, recent content, Page information, performance |
| **Test call** | `GET /{page-id}/posts` with a Page token from `/me/accounts` |
**What it does**
After the user picks a Page (`pages_show_list`), Urmi can show recent posts and basic Page profile so the admin can confirm it is the right brand and see recent content/engagement.
**Insights caveat**
`page_impressions` / `page_engaged_users` typically need **`read_insights`** plus ANALYZE on the Page. If the Pages screen shows “Performance” charts, either:
- request `read_insights` too, or
- limit v1 “Performance” to fields available without it (e.g. post like/comment summaries from `/posts`, `fan_count` on the Page node).
Do not promise Ads Manager–style Page analytics unless `read_insights` is approved.
**Follower PSIDs**
Allowed usage includes follower names/PSIDs. That is sensitive. For App Review, **showing Page posts and Page metadata is enough**. Do not build a follower-export CRM from this permission unless you have a clear admin-management use and privacy policy coverage.
**Compliance**
- Page admin administering **their** Page only.
- Do not publish, comment, or moderate (those are `pages_manage_*`).
- Do not use PSIDs to message people except through approved messaging products.
**Urmi business use**
**Facebook Pages** card: Page name, @handle, Connected, recent posts, engagement on those posts, Page about/link. Supports choosing which Page this workspace advertises or represents.
**Proposed App Review answer**
> After a customer connects a Facebook Page with `pages_show_list`, Urmi uses `pages_read_engagement` so the Page admin can administer that Page from Urmi: we load Page metadata (`GET /{page-id}`) and recent posts (`GET /{page-id}/posts`) and show engagement on that content (reactions/comments summaries). This helps them confirm the correct brand Page and monitor recent content without leaving Urmi. We do not post, comment, or message followers with this permission.
**Screencast (Meta is strict here)**
Login → grant permission → open **Facebook Pages** → open a Page → **visible post content** from that Page inside Urmi (text/image, timestamp). If you only show Page name and “Connected,” reviewers often reject `pages_read_engagement`.
---
### 4.5 `public_profile`
| | |
|---|---|
| **Official purpose** | Default public profile fields on the User node. Automatically granted to apps. |
| **Allowed usage** | Authenticate app users and provide a personalized in-app experience. |
| **Dependencies** | None |
| **Primary APIs** | `GET /me?fields=id,name,picture` |
| **Urmi screen** | **Connections** / **Meta Settings** — “Connected as {name}” |
| **Screencast** | Often not required the same way; still describe use. Login flow in other screencasts covers it. |
| **Test call** | Usually not required for Advanced Access the same way; still used on every Login. |
**What it does**
Identifies who connected Meta: name and profile photo in **Meta Settings → Connected user**. Used only to personalize the connection, not as a consumer social graph.
**Proposed App Review answer**
> Urmi uses `public_profile` during Facebook Login for Business to authenticate the Meta user and show their name (and profile picture) as the connected operator in Meta Settings, so the workspace knows who granted access. We do not use friends lists or any data beyond the default public profile fields.
---
### 4.6 `ads_management`
| | |
|---|---|
| **Official purpose** | Read **and** manage ad accounts the app owns or was granted. |
| **Allowed usage** | Programmatically create campaigns, manage ads, and fetch metrics. Build ad management tools that provide differentiated value for advertisers. |
| **Dependencies** | **`pages_read_engagement`** (and therefore **`pages_show_list`**) |
| **Primary APIs** | `POST /act_{id}/campaigns` · `POST /act_{id}/adsets` · `POST /act_{id}/adcreatives` · `POST /act_{id}/ads` · `POST /{campaign-id}` (status PAUSED/ACTIVE) · plus read insights |
| **Urmi screen** | **Campaigns**, **Ads & Creatives** |
| **Test call** | Create or update at least one campaign/ad in a **sandbox / real test ad account**, then list it in Urmi |
**What it does**
This is the write path: create campaign, set budget, attach Page + creative, publish, pause. For Urmi the differentiated value Meta wants to see is **messaging-oriented ads** (Click-to-WhatsApp / click-to-message) sitting next to the WhatsApp inbox—not a clone of Ads Manager with no extra value.
**Click-to-WhatsApp extra permissions**
Embedded Signup v4 / CTWA on Marketing API also lists **`pages_manage_ads`**. That is **not** in the current prompt. If Create Campaign publishes a WhatsApp destination ad, confirm whether `pages_manage_ads` is required for your objective; if Graph returns permission errors, add it in a **later** App Review rather than claiming CTWA without the permission.
**Compliance**
- Advertising Policies + Special Ad Categories (housing, credit, politics).
- Customer must be able to pause/stop from Urmi.
- Do not run ads on accounts the user did not select.
- Creative (image/video/text) stored only as needed to render/manage that ad.
- “Differentiated value”: communication + ads in one product (WhatsApp destination) is the honest story. A thin wrapper of Ads Manager is a common rejection.
**Urmi business use**
**Campaigns**: list status, budget, spend; **Create Campaign** wizard: Objective → Destination → Page → Budget → Audience (saved/core, not Custom Audience upload) → Creative → Review → Publish. **Ads & Creatives**: headline, CTA, destination, delivery state.
**Proposed App Review answer**
> Urmi is an ad management tool for businesses that already use Urmi to talk to customers on WhatsApp. With `ads_management` we create and manage Meta campaigns and ads on ad accounts the customer has authorized (`POST /act_{id}/campaigns`, ad sets, creatives, ads; pause/activate). Typical flow: choose objective and destination (including WhatsApp where supported), select their connected Facebook Page, set budget, attach creative and CTA, then publish. We also fetch delivery metrics for those campaigns. This keeps advertising and customer messaging in one product. We do not manage ads for accounts the user did not grant.
**Screencast**
Login → grant `ads_management` → **Campaigns → Create Campaign** (or pause/edit an existing one) → resulting ad/campaign visible in Urmi **and** ads performance (impressions, spend, clicks) on Analytics or the campaign row. Reviewers want **create/manage**, not only a metrics table (metrics alone is `ads_read`).
**Do not submit `ads_management` if Create Campaign is UI-only and never calls Graph.**
---
### 4.7 Marketing API Access Tier (feature, not a permission)
| | |
|---|---|
| **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 **&lt;15% errors** on last 500 calls. |
| **Test call** | Any successful Marketing API call (`/insights`, `/campaigns`, etc.). For Full tier, volume matters after approval. |
**Relation to “Ads Management Standard Access”**
Older docs used “Ads Management Standard Access.” Current dashboard language is **Marketing API Access Tier**. Same idea: rate limits + many ad accounts.
**Urmi business use**
Urmi as Tech Provider will operate **many customer ad accounts**. Limited tier (~300 write units/hour class limits) is not enough once several workspaces publish campaigns. Overview/Analytics polling insights for many accounts also needs Full tier.
**Proposed App Review answer**
> Urmi is a Tech Provider. Each customer workspace may connect one or more ad accounts. We use Marketing API Access Tier so those customers can scale: we read insights (`ads_read`) and create/manage campaigns (`ads_management`) across authorized accounts without hitting Limited-tier rate limits, and so we can use Business Manager APIs to list their business assets. We only operate on accounts the customer granted to Urmi.
**Screencast**
Often covered by ads_read + ads_management recordings. Still describe the feature in text. Show more than one ad account on **Ad Accounts** if you can.
---
## 5. Map to the proposed Urmi Meta sidebar
| Sidebar | Permissions that must be live | Ship for this App Review? |
|---------|-------------------------------|---------------------------|
| Overview | `business_management`, `pages_show_list`, `ads_read` | **Yes** |
| Connections | Login, `public_profile`, `pages_show_list`, `business_management` | **Yes** (onboarding + screencast backbone) |
| Facebook Pages | `pages_show_list`, `pages_read_engagement` | **Yes** |
| Ad Accounts | `business_management`, `ads_read` | **Yes** |
| Campaigns | `ads_management` (+ Page perms) | **Yes if you request ads_management** |
| Ads & Creatives | `ads_management` | **Yes if you request ads_management** |
| Analytics | `ads_read` | **Yes** |
| Meta Settings | all granted scopes (display only) | **Yes** |
| Audiences | Custom/lookalike APIs, often extra terms | **Design only — do not request extra perms** |
| Leads | `leads_retrieval`, `pages_manage_ads` | **Not this submission** |
Keep **WhatsApp** (inbox, templates, webhooks) as its own area. Meta Overview may *link* to WABA connection health; it should not replace the inbox.
---
## 6. Graph calls to log before submit
Use Graph API Explorer or the future Urmi backend. App-role user is enough for Standard Access testing. Log **success (HTTP 200)** within 30 days:
| Permission / feature | Minimum successful call |
|----------------------|-------------------------|
| `public_profile` | `GET /me?fields=id,name` |
| `pages_show_list` | `GET /me/accounts?fields=id,name,tasks` |
| `pages_read_engagement` | `GET /{page-id}/posts?fields=id,message,created_time` |
| `business_management` | `GET /me/businesses` |
| `ads_read` | `GET /act_{id}/insights?fields=impressions,spend,clicks,reach&date_preset=last_7d` |
| `ads_management` | `POST /act_{id}/campaigns` (or a status update `POST /{campaign-id}`) **and** a read of `/campaigns` |
| Marketing API Access Tier | Same Marketing API calls as ads_read/management |
Wait up to **2 days** for App Review UI to show the test-call check.
---
## 7. Screencast production notes
From Meta’s [screen recordings guide](https://developers.facebook.com/docs/app-review/submission-guide/screen-recordings/):
- 1080p or higher, entire screen, no jump-cuts through Login.
- Start **logged out** of Urmi and Meta when possible.
- Use **Facebook Login for Business** (not personal Login) for business assets.
- One recording per permission is safer than one giant video; each must still include Login + grant + that feature.
- Show **real Page posts** and **real ads metrics**, not lorem ipsum.
- Provide reviewer login to Urmi + a Meta test user / business they can use.
Suggested recording set:
1. **Connections + Pages list** → `pages_show_list`, `public_profile`, `business_management` (asset list).
2. **Page detail with posts** → `pages_read_engagement`.
3. **Analytics dashboard** → `ads_read` (+ business_management ads-data requirement).
4. **Create or pause campaign + creative** → `ads_management` (+ Marketing API Access Tier narrative).
---
## 8. What not to build or request in this round
| Topic | Why |
|-------|-----|
| **Custom Audiences / lookalikes from Urmi CRM** | Extra Custom Audience terms, hashing, consent, often `ads_management` plus policy review. Easy App Review rejection if you cannot demo a lawful upload. |
| **Meta Lead Ads inbox** | Needs `leads_retrieval` (and usually `pages_manage_ads`). Request when the Leads UI exists. |
| **Page posting / comments** | `pages_manage_posts`, `pages_manage_engagement` — not in the current list. |
| **Follower lists / PSID export** | Allowed under `pages_read_engagement` but privacy-hostile; skip for v1. |
| **CAPI / Server-Side API** | Allowed under `ads_read` only if you actually send events. |
| **Dummy Pages/campaigns in the customer UI** | Reviewers test live Graph. Mock data will fail. |
| **Exposing Page/ad tokens** | Security + brand guidelines. |
---
## 9. Privacy, data handling, and Tech Provider
Expect Data Use Checkup questions for Pages and ads data:
- **What you collect:** Meta user id/name; Page ids/names/posts metadata; ad account ids; campaign/ad objects; aggregated insights (spend, impressions, clicks).
- **Why:** Provide the customer’s own dashboard and ad tooling.
- **Sharing:** Not sold; not used to build unrelated profiles; subprocessors only as in Privacy Policy.
- **Retention:** Until the customer disconnects Meta or deletes the workspace; then delete tokens and cached insights.
- **User control:** Disconnect Meta in Settings; revoke also from facebook.com/settings.
Update [Privacy Policy](https://developers.facebook.com) URLs on the app dashboard so they mention Meta Login, Pages, and ads data—not only WhatsApp Cloud API.
---
## 10. Recommended build order (still not implementing)
Ship in this order so each App Review item has a real screen:
1. **Facebook Login for Business** + token storage (server-only) + **Meta Settings** (connected user, scopes, reconnect/disconnect).
2. **Connections** — Business, Pages count, Ad accounts count, WABA status (WABA may stay on existing WhatsApp token).
3. **Facebook Pages** — list from `/me/accounts`; detail with `/posts` (this unlocks `pages_show_list` + `pages_read_engagement`).
4. **Ad Accounts** + **Analytics** — insights (`ads_read`, `business_management` screencast).
5. **Campaigns** + **Ads & Creatives** — only then submit `ads_management` and Marketing API Access Tier.
6. **Overview** — compose the above (no new permissions).
Until step 3 exists, **do not submit** this App Review list. Until step 5 exists, **remove `ads_management` and Marketing API Access Tier** from the submission rather than describing a future wizard.
---
## 11. Checklist before clicking Submit
- [ ] Business Verification complete
- [ ] Tech Provider verification in progress or complete
- [ ] Privacy Policy updated for Pages + ads
- [ ] Facebook Login for Business works on production URL reviewers can reach
- [ ] Each permission has a successful Graph call &lt; 30 days old
- [ ] Screencasts: login + grant + in-app use, 1080p
- [ ] No dummy Pages/ads in the demo workspace
- [ ] `pages_show_list` included if requesting `pages_read_engagement`
- [ ] `pages_read_engagement` included if requesting `ads_management`
- [ ] Audiences/Leads extra permissions **not** requested
- [ ] WhatsApp inbox remains separate; Meta section is the review surface
---
## 12. Current Urmi codebase (gap)
As of this research, Urmi Graph usage is **WhatsApp-only** (`WhatsAppGraphClient`, admin WhatsApp inbox, webhooks). There is no Facebook Login, no `/me/accounts`, no Ads Insights, no campaign create. The permissions in this document **cannot be honestly submitted** until the Meta section is built and those APIs are called with customer (or test) tokens.
This document is the product/compliance spec for that work. Implementation is a follow-up.

View file

@ -1,144 +1,703 @@
import { Check, Copy, ExternalLink, MessageCircle } from 'lucide-react' import { Check, ChevronLeft, Copy, ExternalLink, Loader2, MessageCircle, MoreHorizontal, Plus, RefreshCw } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { whatsappApi, type WhatsAppSetup, type WhatsAppWebhookEvent } from '../../lib/api' import {
whatsappApi,
type WhatsAppAccount,
type WhatsAppAccountLogs,
type WhatsAppDeveloperConfig,
type WhatsAppSignupConfig,
type WhatsAppSignupResult,
type WhatsAppTemplateRow,
} from '../../lib/api'
function ConfigBadge({ ok, label }: { ok: boolean; label: string }) { type Phase = 'loading' | 'landing' | 'list' | 'provisioning' | 'result' | 'detail'
return ( type DetailTab = 'overview' | 'numbers' | 'templates' | 'configuration' | 'logs'
<span className={`whatsapp-config-badge ${ok ? 'ok' : 'missing'}`}>
<i /> {label}: {ok ? 'Configured' : 'Missing'} type SignupSession = {
</span> event: string
) wabaId?: string
phoneNumberId?: string
businessId?: string
errorMessage?: string
currentStep?: string
}
type FacebookSdk = {
init: (options: Record<string, unknown>) => void
login: (
callback: (response: { authResponse?: { code?: string } | null }) => void,
options: Record<string, unknown>,
) => void
}
const STEPS = [
'Connect or create a Meta business',
'Create or select a WhatsApp Business Account',
'Add and verify a business phone number',
'Grant Urmi access to manage messaging',
]
const WHY = [
['Send and receive messages', 'Use the number you verify with Meta for customer conversations.'],
['Manage templates', 'Review template status and quality for each WhatsApp Business Account.'],
['Multiple numbers', 'Connect the businesses, accounts, and numbers Meta makes available to you.'],
['Built for businesses of all sizes', 'Each connection stays inside this Urmi workspace.'],
]
function facebookSdk(): FacebookSdk | undefined {
return (window as Window & { FB?: FacebookSdk }).FB
}
function loadFacebookSdk(): Promise<void> {
if (facebookSdk()) return Promise.resolve()
return new Promise((resolve, reject) => {
const host = window as Window & { fbAsyncInit?: () => void }
host.fbAsyncInit = () => resolve()
if (document.getElementById('facebook-jssdk')) return
const script = document.createElement('script')
script.id = 'facebook-jssdk'
script.async = true
script.defer = true
script.crossOrigin = 'anonymous'
script.src = 'https://connect.facebook.net/en_US/sdk.js'
script.onerror = () => reject(new Error('Meta sign-in could not be loaded. Check that this browser can reach Facebook.'))
document.body.appendChild(script)
})
}
function statusLabel(status: string) {
return {
connected: 'Connected',
provisioning: 'Configuring',
incomplete: 'Needs verification',
failed: 'Failed',
disconnected: 'Disconnected',
expired: 'Authorization expired',
pending: 'Pending',
}[status] || status
}
function capabilityLabel(status: string) {
if (status === 'active') return 'Active'
if (status === 'unavailable') return 'Unavailable'
return statusLabel(status)
}
function formatWhen(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
}
function parseSignupMessage(raw: unknown): SignupSession | null {
let data = raw
if (typeof data === 'string') {
try {
data = JSON.parse(data)
} catch {
return null
}
}
if (!data || typeof data !== 'object') return null
const message = data as { type?: string; event?: string; data?: Record<string, unknown> }
if (message.type !== 'WA_EMBEDDED_SIGNUP' || !message.event) return null
const body = message.data || {}
return {
event: message.event,
wabaId: typeof body.waba_id === 'string' ? body.waba_id : undefined,
phoneNumberId: typeof body.phone_number_id === 'string' ? body.phone_number_id : undefined,
businessId: typeof body.business_id === 'string' ? body.business_id : undefined,
errorMessage: typeof body.error_message === 'string' ? body.error_message : undefined,
currentStep: typeof body.current_step === 'string' ? body.current_step : undefined,
}
} }
export function WhatsAppSetupPage() { export function WhatsAppSetupPage() {
const [setup, setSetup] = useState<WhatsAppSetup | null>(null) const [phase, setPhase] = useState<Phase>('loading')
const [events, setEvents] = useState<WhatsAppWebhookEvent[]>([]) const [config, setConfig] = useState<WhatsAppSignupConfig | null>(null)
const [loading, setLoading] = useState(true) const [accounts, setAccounts] = useState<WhatsAppAccount[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [tab, setTab] = useState<DetailTab>('overview')
const [result, setResult] = useState<WhatsAppSignupResult | null>(null)
const [error, setError] = useState('') const [error, setError] = useState('')
const [copied, setCopied] = useState<string | null>(null) const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [menuId, setMenuId] = useState<string | null>(null)
const [disconnecting, setDisconnecting] = useState<WhatsAppAccount | null>(null)
const [templates, setTemplates] = useState<WhatsAppTemplateRow[] | null>(null)
const [templatesAvailable, setTemplatesAvailable] = useState(false)
const [developer, setDeveloper] = useState<WhatsAppDeveloperConfig | null>(null)
const [logs, setLogs] = useState<WhatsAppAccountLogs | null>(null)
const [copied, setCopied] = useState(false)
const sessionRef = useRef<SignupSession | null>(null)
const codeRef = useRef<string | null>(null)
const finishingRef = useRef(false)
const accountsRef = useRef<WhatsAppAccount[]>([])
const reportRef = useRef<(session: SignupSession, code: string | null) => Promise<void>>(async () => {})
const finishRef = useRef<() => Promise<void>>(async () => {})
const selected = accounts.find((account) => account.id === selectedId) || null
accountsRef.current = accounts
useEffect(() => { useEffect(() => {
Promise.all([whatsappApi.setup(), whatsappApi.events()]) Promise.all([whatsappApi.signupConfig(), whatsappApi.accounts()])
.then(([setupPayload, eventPayload]) => { .then(([signupConfig, rows]) => {
setSetup(setupPayload) setConfig(signupConfig)
setEvents(eventPayload) setAccounts(rows)
setPhase(rows.length ? 'list' : 'landing')
})
.catch((reason) => {
setError(reason instanceof Error ? reason.message : 'Unable to load WhatsApp')
setPhase('landing')
}) })
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load WhatsApp setup'))
.finally(() => setLoading(false))
}, []) }, [])
async function copyValue(key: string, value: string) { useEffect(() => {
await navigator.clipboard.writeText(value) function onMessage(event: MessageEvent) {
setCopied(key) let host = ''
window.setTimeout(() => setCopied(null), 2000) try {
host = new URL(event.origin).hostname
} catch {
return
}
if (host !== 'facebook.com' && !host.endsWith('.facebook.com')) return
const session = parseSignupMessage(event.data)
if (!session) return
sessionRef.current = session
if (session.event === 'CANCEL' || session.event === 'ERROR') {
void reportRef.current(session, null)
return
}
void finishRef.current()
}
window.addEventListener('message', onMessage)
return () => window.removeEventListener('message', onMessage)
}, [])
useEffect(() => {
if (phase !== 'detail' || !selected) return
if (tab === 'templates') {
setTemplates(null)
whatsappApi.templates(selected.id)
.then((payload) => {
setTemplatesAvailable(payload.available)
setTemplates(payload.templates)
})
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load templates'))
}
if (tab === 'configuration') {
setDeveloper(null)
whatsappApi.developer(selected.id)
.then(setDeveloper)
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load configuration'))
}
if (tab === 'logs') {
setLogs(null)
whatsappApi.logs(selected.id)
.then(setLogs)
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load logs'))
}
}, [phase, tab, selected?.id])
function replaceAccount(next: WhatsAppAccount) {
setAccounts((current) => current.map((account) => (account.id === next.id ? next : account)))
} }
if (loading) return <p className="explorer-loading">Loading WhatsApp setup…</p> async function reportSession(session: SignupSession, code: string | null) {
if (finishingRef.current) return
finishingRef.current = true
const terminal = session.event === 'CANCEL' || session.event === 'ERROR'
if (!terminal) setPhase('provisioning')
try {
const payload = await whatsappApi.completeSignup({
event: session.event,
code,
waba_id: session.wabaId,
phone_number_id: session.phoneNumberId,
business_id: session.businessId,
error_message: session.errorMessage || session.currentStep,
})
setResult(payload)
if (payload.accounts.length) {
setAccounts((current) => {
const incoming = new Map(payload.accounts.map((account) => [account.id, account]))
const merged = current.map((account) => incoming.get(account.id) || account)
for (const account of payload.accounts) {
if (!merged.some((item) => item.id === account.id)) merged.unshift(account)
}
return merged
})
}
if (terminal) {
setNotice(payload.message)
setPhase(accountsRef.current.length || payload.accounts.length ? 'list' : 'landing')
} else {
setPhase('result')
}
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'WhatsApp setup could not be completed')
setPhase(accountsRef.current.length ? 'list' : 'landing')
} finally {
finishingRef.current = false
codeRef.current = null
sessionRef.current = null
}
}
reportRef.current = reportSession
finishRef.current = finishIfReady
async function finishIfReady() {
const session = sessionRef.current
const code = codeRef.current
if (!session || !code || finishingRef.current) return
if (!session.event.startsWith('FINISH')) return
await reportSession(session, code)
}
async function startEmbeddedSignup() {
setError('')
setNotice('')
setMenuId(null)
if (!config?.ready || !config.app_id || !config.config_id) {
setError('WhatsApp Embedded Signup is not configured yet. Add the Meta app id and Embedded Signup configuration id on the server.')
return
}
sessionRef.current = null
codeRef.current = null
finishingRef.current = false
setBusy(true)
try {
await loadFacebookSdk()
const sdk = facebookSdk()
if (!sdk) throw new Error('Meta sign-in did not initialize')
sdk.init({
appId: config.app_id,
cookie: true,
xfbml: false,
version: config.api_version,
})
sdk.login(
(response) => {
setBusy(false)
const code = response.authResponse?.code
if (!code) {
const session = sessionRef.current
if (session && (session.event === 'CANCEL' || session.event === 'ERROR')) return
setNotice('WhatsApp setup was closed before Meta returned an authorization. Nothing was connected.')
setPhase(accounts.length ? 'list' : 'landing')
return
}
codeRef.current = code
void finishIfReady()
window.setTimeout(() => {
if (codeRef.current !== code || finishingRef.current) return
if (!sessionRef.current) sessionRef.current = { event: 'FINISH' }
if (sessionRef.current.event.startsWith('FINISH')) void finishIfReady()
}, 1500)
},
{
config_id: config.config_id,
response_type: 'code',
override_default_response_type: true,
extras: {
setup: {},
sessionInfoVersion: '3',
},
},
)
} catch (reason) {
setBusy(false)
setError(reason instanceof Error ? reason.message : 'Unable to open Meta Embedded Signup')
}
}
async function refreshSelected() {
if (!selected) return
setBusy(true)
setError('')
try {
replaceAccount(await whatsappApi.refreshAccount(selected.id))
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to refresh this connection')
} finally {
setBusy(false)
}
}
async function confirmDisconnect() {
if (!disconnecting) return
setBusy(true)
setError('')
try {
const next = await whatsappApi.disconnectAccount(disconnecting.id)
replaceAccount(next)
setDisconnecting(null)
setNotice('This WhatsApp account was disconnected. Existing Urmi records were kept. Reconnect to authorize it again.')
if (selectedId === next.id) setPhase('list')
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to disconnect this account')
} finally {
setBusy(false)
}
}
async function copyWebhook(url: string) {
await navigator.clipboard.writeText(url)
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
}
const resultAccount = result?.accounts[0]
return ( return (
<div className="whatsapp-setup-page"> <div className="wa-biz">
{phase === 'loading' && <p className="explorer-loading">Loading WhatsApp Business…</p>}
{phase !== 'loading' && phase !== 'detail' && (
<div className="account-heading"> <div className="account-heading">
<div className="wa-biz-heading">
<div>
<span className="eyebrow">Communicate</span> <span className="eyebrow">Communicate</span>
<h1>WhatsApp</h1> <h1>WhatsApp Business</h1>
<p>Configure Meta webhooks so Urmi can receive messages and delivery updates on behalf of your customers.</p> <p>
{accounts.length
? 'Manage WhatsApp Business accounts and phone numbers connected to this Urmi workspace.'
: 'Connect and manage WhatsApp Business accounts with Urmi.'}
</p>
</div> </div>
{accounts.length > 0 && phase !== 'provisioning' && (
<button className="primary-button" type="button" onClick={() => void startEmbeddedSignup()} disabled={busy}>
<Plus size={16} /> Connect account
</button>
)}
</div>
</div>
)}
{error && <p className="auth-error">{error}</p>} {error && <p className="auth-error">{error}</p>}
{notice && <p className="wa-biz-notice">{notice}</p>}
{setup && ( {phase === 'landing' && (
<> <div className="wa-biz-hero">
<section className="panel whatsapp-panel"> <section className="wa-biz-card">
<div className="panel-heading"> <div className="wa-biz-mark"><MessageCircle size={22} /></div>
<div> <h2>Set up WhatsApp with Meta</h2>
<h2><MessageCircle size={18} /> Meta webhook configuration</h2> <p>Set up WhatsApp Business Platform through Meta&apos;s secure onboarding process.</p>
<p>Paste these into <strong>App Dashboard → WhatsApp → Configuration → Configure Webhooks</strong></p> <ul>
{STEPS.map((step) => (
<li key={step}><Check size={16} /> {step}</li>
))}
</ul>
<button className="primary-button wa-biz-cta" onClick={() => void startEmbeddedSignup()} disabled={busy || !config?.ready}>
{busy ? <Loader2 size={16} className="wa-spin" /> : null}
Set up WhatsApp with Meta
</button>
<small>
{config?.ready
? 'Setup usually takes a few minutes. Meta decides which businesses, accounts, and numbers you can use.'
: 'Embedded Signup is not available until the Meta configuration id is set for this environment.'}
</small>
</section>
<aside className="wa-biz-aside">
<h3>Why connect?</h3>
{WHY.map(([title, body]) => (
<div key={title}>
<strong>{title}</strong>
<p>{body}</p>
</div> </div>
<a className="ghost-button" href={setup.docs_url} target="_blank" rel="noreferrer"> ))}
</aside>
</div>
)}
{phase === 'provisioning' && (
<section className="wa-biz-card wa-biz-provision">
<div className="wa-biz-mark ok"><Check size={18} /></div>
<h2>Meta authorization completed</h2>
<p>We&apos;re configuring your WhatsApp Business connection. This screen updates from the setup result, not from a placeholder status.</p>
<div className="wa-biz-wait"><Loader2 size={18} className="wa-spin" /> Checking the business, account, and phone Meta returned</div>
</section>
)}
{phase === 'result' && result && (
<section className="wa-biz-card wa-biz-result">
<div className={`wa-biz-mark ${result.status === 'connected' ? 'ok' : 'warn'}`}>
{result.status === 'connected' ? <Check size={18} /> : <MessageCircle size={18} />}
</div>
<h2>{result.status === 'connected' ? 'WhatsApp Business' : 'Setup needs attention'}</h2>
<p>{result.message}</p>
{resultAccount && (
<dl className="wa-biz-facts">
<div><dt>Business</dt><dd>{resultAccount.business_name || '—'}</dd></div>
<div><dt>WhatsApp Business Account</dt><dd>{resultAccount.name}</dd></div>
<div><dt>Phone number</dt><dd>{resultAccount.primary_phone?.display_phone || 'Not returned yet'}</dd></div>
<div><dt>Display name</dt><dd>{resultAccount.primary_phone?.verified_name || '—'}</dd></div>
<div><dt>Status</dt><dd><StatusDot status={resultAccount.status} /></dd></div>
<div><dt>Messaging</dt><dd><StatusDot status={resultAccount.messaging_status} label={capabilityLabel(resultAccount.messaging_status)} /></dd></div>
<div><dt>Management</dt><dd><StatusDot status={resultAccount.management_status} label={capabilityLabel(resultAccount.management_status)} /></dd></div>
<div><dt>Webhooks</dt><dd><StatusDot status={resultAccount.webhooks_status} label={capabilityLabel(resultAccount.webhooks_status)} /></dd></div>
</dl>
)}
<div className="wa-biz-actions">
{resultAccount && (
<button className="primary-button" onClick={() => { setSelectedId(resultAccount.id); setTab('overview'); setPhase('detail') }}>
{resultAccount.usable ? 'Open WhatsApp' : 'View account'}
</button>
)}
<button className="secondary-button" onClick={() => setPhase(accounts.length ? 'list' : 'landing')}>Back to accounts</button>
</div>
</section>
)}
{phase === 'list' && (
<div className="wa-biz-list">
{accounts.map((account) => (
<article key={account.id} className="wa-biz-account">
<header>
<div>
<h2>{account.business_name || account.name}</h2>
<StatusDot status={account.status} />
</div>
<div className="wa-biz-menu">
<button className="secondary-button" onClick={() => { setSelectedId(account.id); setTab('overview'); setPhase('detail') }}>Open</button>
<button className="icon-button" aria-label="Account actions" onClick={() => setMenuId(menuId === account.id ? null : account.id)}>
<MoreHorizontal size={16} />
</button>
{menuId === account.id && (
<div className="wa-biz-popover">
<button onClick={() => { setMenuId(null); void startEmbeddedSignup() }}>Reconnect</button>
<button onClick={() => { setMenuId(null); setDisconnecting(account) }}>Disconnect</button>
</div>
)}
</div>
</header>
<div className="wa-biz-account-grid">
<div>
<span>Phone</span>
<strong>{account.primary_phone?.display_phone || 'No number yet'}</strong>
<small>{account.primary_phone?.verified_name || account.name}</small>
</div>
<div>
<span>WABA</span>
<strong>{account.waba_id_masked || '—'}</strong>
</div>
<div>
<span>Messaging</span>
<strong>{capabilityLabel(account.messaging_status)}</strong>
</div>
<div>
<span>Templates</span>
<strong>{account.templates_status === 'active' ? account.template_count : '—'}</strong>
</div>
<div>
<span>Quality</span>
<strong>{account.quality || '—'}</strong>
</div>
</div>
{account.last_error && account.status !== 'connected' && <p className="wa-biz-inline-error">{account.last_error}</p>}
</article>
))}
</div>
)}
{phase === 'detail' && selected && (
<div className="wa-biz-detail">
<button className="wa-biz-back" onClick={() => setPhase('list')}><ChevronLeft size={16} /> Accounts</button>
<div className="wa-biz-detail-head">
<div>
<h1>{selected.business_name || selected.name}</h1>
<StatusDot status={selected.status} />
</div>
<div className="wa-biz-actions">
<button className="secondary-button" onClick={() => void refreshSelected()} disabled={busy}>
<RefreshCw size={14} /> Refresh
</button>
<button className="secondary-button" onClick={() => void startEmbeddedSignup()} disabled={busy}>Reconnect</button>
<button className="secondary-button wa-biz-danger" onClick={() => setDisconnecting(selected)}>Disconnect</button>
</div>
</div>
<div className="wa-biz-tabs">
{(['overview', 'numbers', 'templates', 'configuration', 'logs'] as DetailTab[]).map((item) => (
<button key={item} className={tab === item ? 'active' : ''} onClick={() => setTab(item)}>
{item === 'numbers' ? 'Phone Numbers' : item === 'configuration' ? 'Configuration' : item[0].toUpperCase() + item.slice(1)}
</button>
))}
</div>
{tab === 'overview' && (
<div className="wa-biz-overview">
<section className="wa-biz-card">
<h2>Overview</h2>
<dl className="wa-biz-facts">
<div><dt>Business portfolio</dt><dd>{selected.business_name || '—'}</dd></div>
<div><dt>WhatsApp Business Account</dt><dd>{selected.name}</dd></div>
<div><dt>WABA</dt><dd>{selected.waba_id_masked}</dd></div>
<div><dt>Connection</dt><dd><StatusDot status={selected.status === 'connected' ? 'connected' : selected.status} label={selected.status === 'connected' ? 'Healthy' : statusLabel(selected.status)} /></dd></div>
</dl>
{selected.last_error && <p className="wa-biz-inline-error">{selected.last_error}</p>}
</section>
<section className="wa-biz-card">
<h2>Capabilities</h2>
<ul className="wa-biz-capabilities">
<li>Messaging <StatusDot status={selected.messaging_status} label={capabilityLabel(selected.messaging_status)} /></li>
<li>Business management <StatusDot status={selected.management_status} label={capabilityLabel(selected.management_status)} /></li>
<li>Templates <StatusDot status={selected.templates_status} label={capabilityLabel(selected.templates_status)} /></li>
<li>Webhooks <StatusDot status={selected.webhooks_status} label={capabilityLabel(selected.webhooks_status)} /></li>
</ul>
{!selected.usable && (
<p className="wa-biz-inline-error">Messaging stays unavailable until Urmi can verify a connected phone on this account.</p>
)}
</section>
</div>
)}
{tab === 'numbers' && (
<section className="wa-biz-card">
<h2>Phone numbers</h2>
{selected.phones.length === 0 && <p className="wa-biz-muted">Meta has not returned a phone number for this account yet.</p>}
<ul className="wa-biz-numbers">
{selected.phones.map((phone) => (
<li key={phone.id}>
<div>
<strong>{phone.display_phone || 'Number pending'}</strong>
<small>{phone.verified_name || 'Display name pending'}</small>
</div>
<StatusDot status={phone.status} />
<span>Quality: {phone.quality || '—'}</span>
</li>
))}
</ul>
</section>
)}
{tab === 'templates' && (
<section className="wa-biz-card">
<h2>Templates</h2>
{templates === null && <p className="wa-biz-muted">Loading templates…</p>}
{templates && !templatesAvailable && <p className="wa-biz-muted">Template access is unavailable until this account is authorized.</p>}
{templates && templatesAvailable && templates.length === 0 && <p className="wa-biz-muted">No templates were returned for this WhatsApp Business Account.</p>}
{templates && templates.length > 0 && (
<div className="wa-biz-table">
<div className="wa-biz-table-head"><span>Name</span><span>Category</span><span>Language</span><span>Status</span><span>Quality</span></div>
{templates.map((template) => (
<div key={`${template.name}-${template.language}`} className="wa-biz-table-row">
<span>{template.name}</span>
<span>{template.category}</span>
<span>{template.language}</span>
<span>{template.status}</span>
<span>{template.quality || '—'}</span>
</div>
))}
</div>
)}
</section>
)}
{tab === 'configuration' && (
<section className="wa-biz-card">
<div className="wa-biz-card-head">
<div>
<h2>Developer configuration</h2>
<p>Webhook delivery for this workspace. Verification credentials stay on Urmi&apos;s servers.</p>
</div>
{developer && (
<a className="ghost-button" href={developer.docs_url} target="_blank" rel="noreferrer">
Meta docs <ExternalLink size={14} /> Meta docs <ExternalLink size={14} />
</a> </a>
)}
</div> </div>
{!developer && <p className="wa-biz-muted">Loading configuration…</p>}
{developer && (
<>
<div className="whatsapp-config-row"> <div className="whatsapp-config-row">
<div> <div>
<span>Callback URL</span> <span>Callback URL</span>
<code>{setup.webhook_url}</code> <code>{developer.webhook_url}</code>
</div> </div>
<button className="secondary-button" onClick={() => copyValue('url', setup.webhook_url)}> <button className="secondary-button" onClick={() => void copyWebhook(developer.webhook_url)}>
<Copy size={14} /> {copied === 'url' ? 'Copied' : 'Copy'} <Copy size={14} /> {copied ? 'Copied' : 'Copy'}
</button> </button>
</div> </div>
<div className="whatsapp-config-row"> <div className="whatsapp-config-row">
<div> <div>
<span>Verify token</span> <span>Verify token</span>
<code>{setup.verify_token || 'Not configured'}</code> <code>{developer.verify_token_configured ? 'Hidden — managed by Urmi' : 'Not configured'}</code>
<small>Paste this exact value into Meta → Configure Webhooks → Verify token.</small> <small>The verify token is not shown in the customer console.</small>
</div>
{setup.verify_token && (
<button className="secondary-button" onClick={() => copyValue('token', setup.verify_token!)}>
<Copy size={14} /> {copied === 'token' ? 'Copied' : 'Copy'}
</button>
)}
</div>
<div className="whatsapp-badges">
<ConfigBadge ok={setup.verify_token_configured} label="Verify token" />
<ConfigBadge ok={setup.app_secret_configured} label="App secret" />
<ConfigBadge ok={setup.access_token_configured} label="Access token" />
<ConfigBadge ok={Boolean(setup.phone_number_id)} label="Phone number ID" />
<ConfigBadge ok={Boolean(setup.business_account_id)} label="WABA ID" />
</div>
</section>
<section className="panel whatsapp-panel">
<div className="panel-heading">
<div>
<h2>Subscribe to webhook fields</h2>
<p>In Meta, subscribe to these fields after verifying the callback URL.</p>
</div> </div>
</div> </div>
<p className="wa-biz-subhead">Webhook subscriptions</p>
<ul className="whatsapp-field-list"> <ul className="whatsapp-field-list">
{setup.recommended_fields.map((field) => ( {developer.recommended_fields.map((field) => (
<li key={field}><Check size={14} /> <code>{field}</code></li> <li key={field}><Check size={14} /> {field}</li>
))} ))}
</ul> </ul>
<p className="relay-note"> <p className="wa-biz-subhead">Webhook status <StatusDot status={developer.webhooks_status} label={capabilityLabel(developer.webhooks_status)} /></p>
Minimum for messaging: <code>messages</code>. For a tech provider / BSP, also enable template and account fields. </>
</p>
</section>
<section className="panel whatsapp-panel">
<div className="panel-heading">
<div>
<h2>Recent webhook events</h2>
<p>Incoming payloads from Meta (after verification succeeds).</p>
</div>
</div>
{events.length === 0 ? (
<p className="analytics-empty">No webhook events yet. Click <strong>Verify and save</strong> in Meta to send a test.</p>
) : (
<div className="logs-table">
<div className="logs-table-head logs-cols-5">
<span>Field</span>
<span>Type</span>
<span>From</span>
<span>Status</span>
<span>Time</span>
</div>
{events.map((event) => (
<div className="logs-table-row logs-cols-5" key={event.id}>
<span>{event.field}</span>
<span>{event.event_type}</span>
<span>{event.from_wa_id || '—'}</span>
<span>{event.status || '—'}</span>
<span>{new Date(event.created_at).toLocaleString()}</span>
</div>
))}
</div>
)} )}
</section> </section>
</> )}
{tab === 'logs' && (
<div className="wa-biz-overview">
<section className="wa-biz-card">
<h2>Connection history</h2>
{logs === null && <p className="wa-biz-muted">Loading logs…</p>}
{logs && logs.audits.length === 0 && <p className="wa-biz-muted">No connection events yet.</p>}
<ul className="wa-biz-log">
{logs?.audits.map((item) => (
<li key={item.id}>
<strong>{item.event.replace(/_/g, ' ')}</strong>
<span>{item.detail}</span>
<small>{formatWhen(item.created_at)}</small>
</li>
))}
</ul>
</section>
<section className="wa-biz-card">
<h2>Recent webhook events</h2>
{logs && logs.events.length === 0 && <p className="wa-biz-muted">No webhook events for this account.</p>}
<ul className="wa-biz-log">
{logs?.events.map((item) => (
<li key={item.id}>
<strong>{item.field}</strong>
<span>{item.event_type}{item.status ? ` · ${item.status}` : ''}</span>
<small>{formatWhen(item.created_at)}</small>
</li>
))}
</ul>
</section>
</div>
)}
</div>
)}
{disconnecting && (
<div className="modal-overlay" onClick={() => setDisconnecting(null)}>
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h2>Disconnect {disconnecting.business_name || disconnecting.name}?</h2>
</div>
<p className="wa-biz-modal-copy">
Urmi will stop using this WhatsApp Business Account and clear its stored authorization.
The account record stays in this workspace so you can reconnect without starting over.
</p>
<div className="modal-actions">
<button className="secondary-button" onClick={() => setDisconnecting(null)}>Cancel</button>
<button className="primary-button wa-biz-danger-fill" onClick={() => void confirmDisconnect()} disabled={busy}>Disconnect</button>
</div>
</div>
</div>
)} )}
</div> </div>
) )
} }
function StatusDot({ status, label }: { status: string; label?: string }) {
const tone = status === 'connected' || status === 'active' ? 'ok' : status === 'unavailable' || status === 'failed' || status === 'expired' || status === 'disconnected' ? 'bad' : 'wait'
return (
<span className={`wa-biz-status ${tone}`}>
<i /> {label || statusLabel(status)}
</span>
)
}

View file

@ -1819,3 +1819,83 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-list { max-height: 280px; } .wa-list { max-height: 280px; }
.wa-setup form { grid-template-columns: 1fr; } .wa-setup form { grid-template-columns: 1fr; }
} }
.wa-biz { max-width: 1080px; }
.wa-biz-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; }
.wa-biz-heading h1 { margin: 6px 0; }
.wa-biz-heading p { max-width: 640px; }
.wa-biz-notice { margin: 0 0 16px; padding: 12px 14px; border: 1px solid #d7e6ff; border-radius: 12px; background: #f4f8ff; color: #1d4f91; font-size: 13px; }
.wa-biz-hero { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(280px, .8fr); gap: 18px; }
.wa-biz-card { padding: 28px; border: 1px solid #e6ebf2; border-radius: 18px; background: #fff; box-shadow: 0 8px 28px rgba(16, 34, 63, .04); }
.wa-biz-card h2 { margin: 14px 0 8px; color: #14233b; font-size: 22px; letter-spacing: -.03em; }
.wa-biz-card > p, .wa-biz-muted { color: #6d7c90; font-size: 13px; line-height: 1.5; }
.wa-biz-mark { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 12px; background: #e8f8ef; color: #12805c; }
.wa-biz-mark.ok { background: #e8f8ef; color: #12805c; }
.wa-biz-mark.warn { background: #fff6e8; color: #a56d12; }
.wa-biz-card ul { margin: 18px 0; padding: 0; list-style: none; display: grid; gap: 10px; }
.wa-biz-card ul li { display: flex; align-items: center; gap: 8px; color: #24344c; font-size: 14px; }
.wa-biz-card ul li svg { color: #159768; }
.wa-biz-cta { width: 100%; margin-top: 8px; padding: 12px 16px; font-size: 14px; }
.wa-biz-card > small { display: block; margin-top: 12px; color: #8a95a7; font-size: 12px; }
.wa-biz-aside { padding: 24px; border-radius: 18px; background: #f4f7fb; }
.wa-biz-aside h3 { margin: 0 0 16px; color: #14233b; font-size: 16px; }
.wa-biz-aside div + div { margin-top: 16px; }
.wa-biz-aside strong { display: block; color: #1c2b42; font-size: 13px; }
.wa-biz-aside p { margin: 4px 0 0; color: #6d7c90; font-size: 12px; line-height: 1.45; }
.wa-biz-provision, .wa-biz-result { max-width: 720px; }
.wa-biz-wait { display: flex; align-items: center; gap: 8px; margin-top: 18px; color: #1d4f91; font-size: 13px; font-weight: 600; }
.wa-spin { animation: wa-spin .8s linear infinite; }
@keyframes wa-spin { to { transform: rotate(360deg); } }
.wa-biz-facts { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 24px; margin: 18px 0 0; }
.wa-biz-facts div { display: grid; gap: 4px; }
.wa-biz-facts dt { color: #8a95a7; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
.wa-biz-facts dd { margin: 0; color: #1c2b42; font-size: 14px; }
.wa-biz-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; }
.wa-biz-list { display: grid; gap: 14px; }
.wa-biz-account { padding: 20px 22px; border: 1px solid #e6ebf2; border-radius: 16px; background: #fff; }
.wa-biz-account header, .wa-biz-detail-head, .wa-biz-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.wa-biz-account h2, .wa-biz-detail h1 { margin: 0 0 6px; color: #14233b; font-size: 18px; letter-spacing: -.03em; }
.wa-biz-account-grid { display: grid; grid-template-columns: 1.4fr repeat(4, minmax(0, 1fr)); gap: 12px; margin-top: 16px; }
.wa-biz-account-grid span, .wa-biz-numbers span { color: #8a95a7; font-size: 11px; font-weight: 700; text-transform: uppercase; }
.wa-biz-account-grid strong, .wa-biz-numbers strong { display: block; margin-top: 4px; color: #1c2b42; font-size: 14px; }
.wa-biz-account-grid small, .wa-biz-numbers small { display: block; color: #6d7c90; font-size: 12px; }
.wa-biz-menu { position: relative; display: flex; gap: 8px; }
.wa-biz-popover { position: absolute; top: 40px; right: 0; z-index: 5; display: grid; min-width: 160px; padding: 6px; border: 1px solid #e6ebf2; border-radius: 12px; background: #fff; box-shadow: 0 12px 32px rgba(16, 34, 63, .12); }
.wa-biz-popover button { border: 0; background: transparent; padding: 8px 10px; border-radius: 8px; color: #24344c; font-size: 13px; text-align: left; }
.wa-biz-popover button:hover { background: #f4f7fb; }
.wa-biz-status { display: inline-flex; align-items: center; gap: 6px; color: #8a5b00; font-size: 12px; font-weight: 700; }
.wa-biz-status i { width: 8px; height: 8px; border-radius: 50%; background: #d78219; }
.wa-biz-status.ok { color: #0f7a49; }
.wa-biz-status.ok i { background: #12b76a; }
.wa-biz-status.bad { color: #b42318; }
.wa-biz-status.bad i { background: #f04438; }
.wa-biz-inline-error { margin: 12px 0 0; color: #b42318; font-size: 12px; }
.wa-biz-back { display: inline-flex; align-items: center; gap: 4px; margin-bottom: 12px; border: 0; background: transparent; color: #4b5a70; font-size: 13px; font-weight: 600; }
.wa-biz-tabs { display: flex; gap: 8px; margin: 16px 0; border-bottom: 1px solid #e6ebf2; }
.wa-biz-tabs button { border: 0; background: transparent; padding: 10px 12px; color: #7d8ba0; font-size: 13px; font-weight: 700; }
.wa-biz-tabs button.active { color: #1475ff; box-shadow: inset 0 -2px 0 #1475ff; }
.wa-biz-overview { display: grid; grid-template-columns: 1.2fr .8fr; gap: 16px; }
.wa-biz-capabilities { margin: 0; }
.wa-biz-capabilities li { justify-content: space-between; }
.wa-biz-numbers { margin: 8px 0 0; padding: 0; list-style: none; display: grid; gap: 10px; }
.wa-biz-numbers li { display: grid; grid-template-columns: 1fr auto auto; gap: 16px; align-items: center; padding: 12px 0; border-top: 1px solid #edf0f4; }
.wa-biz-table { margin-top: 12px; }
.wa-biz-table-head, .wa-biz-table-row { display: grid; grid-template-columns: 1.4fr .8fr .7fr .8fr .6fr; gap: 8px; padding: 10px 0; }
.wa-biz-table-head { color: #8a95a7; font-size: 11px; font-weight: 700; text-transform: uppercase; }
.wa-biz-table-row { border-top: 1px solid #edf0f4; color: #24344c; font-size: 13px; }
.wa-biz-subhead { display: flex; align-items: center; gap: 8px; margin: 16px 0 8px; color: #607089; font-size: 12px; font-weight: 700; text-transform: uppercase; }
.wa-biz-log { margin: 0; padding: 0; list-style: none; display: grid; gap: 12px; }
.wa-biz-log li { display: grid; gap: 2px; padding-top: 10px; border-top: 1px solid #edf0f4; }
.wa-biz-log strong { color: #1c2b42; font-size: 13px; text-transform: capitalize; }
.wa-biz-log span { color: #4b5a70; font-size: 12px; }
.wa-biz-log small { color: #8a95a7; font-size: 11px; }
.wa-biz-danger { color: #b42318; }
.wa-biz-danger-fill { background: #d92d20; border-color: #d92d20; }
.wa-biz-modal-copy { margin: 0; padding: 16px 20px 0; color: #4b5a70; font-size: 13px; line-height: 1.5; }
.wa-biz .ghost-button { display: inline-flex; align-items: center; gap: 6px; color: #1475ff; font-size: 12px; font-weight: 700; text-decoration: none; }
@media (max-width: 900px) {
.wa-biz-hero, .wa-biz-overview, .wa-biz-facts, .wa-biz-account-grid { grid-template-columns: 1fr; }
.wa-biz-heading { flex-direction: column; align-items: stretch; }
.wa-biz-table-head { display: none; }
.wa-biz-table-row, .wa-biz-numbers li { grid-template-columns: 1fr; }
}

View file

@ -1115,18 +1115,78 @@ export const analyticsApi = {
export type WhatsAppSetup = { export type WhatsAppSetup = {
webhook_url: string webhook_url: string
verify_token: string | null
verify_token_configured: boolean verify_token_configured: boolean
app_secret_configured: boolean verify_token_visible: boolean
access_token_configured: boolean
phone_number_id: string | null
business_account_id: string | null
meta_app_id: string | null
api_version: string api_version: string
recommended_fields: string[] recommended_fields: string[]
docs_url: string docs_url: string
} }
export type WhatsAppSignupConfig = {
app_id: string | null
config_id: string | null
api_version: string
ready: boolean
}
export type WhatsAppPhoneAsset = {
id: string
phone_number_id: string
display_phone: string
verified_name: string | null
quality: string | null
status: string
code_verification_status: string | null
}
export type WhatsAppAccount = {
id: string
waba_id_masked: string | null
name: string
business_name: string | null
business_id_masked: string | null
status: string
usable: boolean
messaging_status: string
management_status: string
templates_status: string
webhooks_status: string
template_count: number
quality: string | null
primary_phone: WhatsAppPhoneAsset | null
phones: WhatsAppPhoneAsset[]
last_error: string | null
updated_at: string
}
export type WhatsAppSignupResult = {
status: string
message: string
accounts: WhatsAppAccount[]
}
export type WhatsAppTemplateRow = {
name: string | null
category: string | null
language: string | null
status: string | null
quality: string | null
}
export type WhatsAppDeveloperConfig = {
webhook_url: string
verify_token_configured: boolean
verify_token_visible: boolean
recommended_fields: string[]
webhooks_status: string
docs_url: string
}
export type WhatsAppAccountLogs = {
audits: { id: string; event: string; detail: string; created_at: string }[]
events: { id: string; field: string; event_type: string; from_wa_id: string | null; status: string | null; created_at: string }[]
}
export type WhatsAppWebhookEvent = { export type WhatsAppWebhookEvent = {
id: string id: string
waba_id: string | null waba_id: string | null
@ -1142,7 +1202,30 @@ export type WhatsAppWebhookEvent = {
export const whatsappApi = { export const whatsappApi = {
setup: () => jsonRequest<WhatsAppSetup>('/api/v1/whatsapp/setup'), setup: () => jsonRequest<WhatsAppSetup>('/api/v1/whatsapp/setup'),
events: () => jsonRequest<WhatsAppWebhookEvent[]>('/api/v1/whatsapp/events'), events: () => jsonRequest<WhatsAppWebhookEvent[]>('/api/v1/whatsapp/events'),
sendText: (payload: { to: string; body: string }) => signupConfig: () => jsonRequest<WhatsAppSignupConfig>('/api/v1/whatsapp/signup/config'),
accounts: () => jsonRequest<WhatsAppAccount[]>('/api/v1/whatsapp/accounts'),
completeSignup: (payload: {
event: string
code?: string | null
waba_id?: string | null
phone_number_id?: string | null
business_id?: string | null
error_message?: string | null
}) =>
jsonRequest<WhatsAppSignupResult>('/api/v1/whatsapp/signup/complete', {
method: 'POST',
body: JSON.stringify(payload),
}),
account: (accountId: string) => jsonRequest<WhatsAppAccount>(`/api/v1/whatsapp/accounts/${accountId}`),
refreshAccount: (accountId: string) =>
jsonRequest<WhatsAppAccount>(`/api/v1/whatsapp/accounts/${accountId}/refresh`, { method: 'POST' }),
disconnectAccount: (accountId: string) =>
jsonRequest<WhatsAppAccount>(`/api/v1/whatsapp/accounts/${accountId}/disconnect`, { method: 'POST' }),
templates: (accountId: string) =>
jsonRequest<{ available: boolean; templates: WhatsAppTemplateRow[] }>(`/api/v1/whatsapp/accounts/${accountId}/templates`),
logs: (accountId: string) => jsonRequest<WhatsAppAccountLogs>(`/api/v1/whatsapp/accounts/${accountId}/logs`),
developer: (accountId: string) => jsonRequest<WhatsAppDeveloperConfig>(`/api/v1/whatsapp/accounts/${accountId}/developer`),
sendText: (payload: { to: string; body: string; phone_number_id: string }) =>
jsonRequest<Record<string, unknown>>('/api/v1/whatsapp/messages/send', { jsonRequest<Record<string, unknown>>('/api/v1/whatsapp/messages/send', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),