meta options

This commit is contained in:
debu-2 2026-10-08 07:20:18 +05:30
parent 4697dab339
commit 8726c931fa
21 changed files with 3028 additions and 3 deletions

View file

@ -37,7 +37,11 @@ class Settings(BaseSettings):
whatsapp_phone_number_id: str = ""
whatsapp_business_account_id: str = ""
meta_app_id: str = ""
meta_app_secret: str = ""
meta_embedded_signup_config_id: str = ""
# Facebook Login for Business configuration. Do not reuse the WhatsApp Embedded Signup config.
meta_login_config_id: str = ""
meta_oauth_redirect_uri: str = ""
whatsapp_business_phone: str = "919411257916"
# Public HTTPS base for webhook callback (no trailing slash), e.g. https://urmiai.com
public_api_base_url: str = ""
@ -57,6 +61,26 @@ class Settings(BaseSettings):
return [self.frontend_origin]
@computed_field # type: ignore[prop-decorator]
@property
def meta_graph_app_secret(self) -> str:
return self.meta_app_secret or self.whatsapp_app_secret
@computed_field # type: ignore[prop-decorator]
@property
def meta_oauth_redirect_uri_resolved(self) -> str:
if self.meta_oauth_redirect_uri.strip():
return self.meta_oauth_redirect_uri.strip()
base = self.public_api_base_url.rstrip("/") if self.public_api_base_url else ""
if base:
return f"{base}/api/v1/meta/oauth/callback"
return "http://localhost:8000/api/v1/meta/oauth/callback"
@computed_field # type: ignore[prop-decorator]
@property
def meta_oauth_configured(self) -> bool:
return bool(self.meta_app_id and self.meta_graph_app_secret)
@computed_field # type: ignore[prop-decorator]
@property
def whatsapp_webhook_url(self) -> str:

View file

@ -26,6 +26,7 @@ from .routes_numbers import router as numbers_router
from .routes_urmiotp import router as urmiotp_router
from .routes_voice import router as voice_router
from .routes_whatsapp import router as whatsapp_router
from .routes_meta import router as meta_router
from .routes_agent_templates import router as agent_templates_router
from .routes_agent_messages import router as agent_messages_router
from .routes_urmiport import router as urmiport_router
@ -106,6 +107,7 @@ app.include_router(numbers_router)
app.include_router(integrations_router)
app.include_router(voice_router)
app.include_router(whatsapp_router)
app.include_router(meta_router)
app.include_router(agent_templates_router)
app.include_router(agent_messages_router)
app.include_router(urmiport_router)

1228
backend/app/meta_service.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -743,6 +743,49 @@ class UrmiportReport(Base):
group: Mapped[UrmiportReportGroup] = relationship(back_populates="reports")
class MetaConnection(Base):
"""One Meta Login connection per customer. The user token never leaves the server."""
__tablename__ = "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"), unique=True, index=True)
meta_user_id: Mapped[str] = mapped_column(String(64), default="")
meta_user_name: Mapped[str] = mapped_column(String(255), default="")
granted_scopes: Mapped[str] = mapped_column(Text, default="")
access_token: Mapped[str] = mapped_column(Text, default="")
token_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
status: Mapped[str] = mapped_column(String(32), default="disconnected")
selected_page_id: Mapped[str] = mapped_column(String(64), default="")
selected_page_name: Mapped[str] = mapped_column(String(255), default="")
selected_ad_account_id: Mapped[str] = mapped_column(String(64), default="")
selected_ad_account_name: Mapped[str] = mapped_column(String(255), default="")
last_error: Mapped[str] = mapped_column(Text, default="")
last_graph_code: Mapped[int | None] = mapped_column(nullable=True)
connected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class MetaAuditEvent(Base):
"""Customer-visible record of Meta connect, publish, and status changes."""
__tablename__ = "meta_audit_events"
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"), index=True)
action: Mapped[str] = mapped_column(String(64))
asset_type: Mapped[str] = mapped_column(String(32), default="")
asset_label: Mapped[str] = mapped_column(String(255), default="")
asset_id: Mapped[str] = mapped_column(String(64), default="")
detail: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(String(16), default="success")
error_message: Mapped[str] = mapped_column(Text, default="")
graph_code: Mapped[int | None] = mapped_column(nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class PlatformSetting(Base):
"""Runtime overrides for platform credentials (admin-editable)."""

281
backend/app/routes_meta.py Normal file
View file

@ -0,0 +1,281 @@
from urllib.parse import quote
from fastapi import APIRouter, Depends
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from .auth import current_user
from .config import settings
from .database import get_db
from .meta_service import (
MetaApiError,
analytics,
authorization_url,
disconnect,
finish_oauth,
http_meta,
list_ad_accounts,
list_ads,
list_businesses,
list_campaigns,
list_pages,
overview,
page_detail,
public_connection,
publish_campaign,
load_connection,
recent_audit,
select_ad_account,
select_page,
set_ad_status,
set_campaign_status,
settings_view,
update_ad,
update_campaign,
)
from .models import UserAccount
router = APIRouter(prefix="/api/v1/meta", tags=["meta"])
class Confirm(BaseModel):
confirm: bool = False
class IdBody(Confirm):
id: str = Field(min_length=1, max_length=64)
class StatusBody(Confirm):
status: str
class CampaignEdit(Confirm):
name: str | None = None
daily_budget: float | None = Field(default=None, gt=0, le=10_000_000)
class CampaignPublish(Confirm):
name: str = Field(min_length=1, max_length=120)
objective: str
destination: str
page_id: str
daily_budget: float = Field(gt=0, le=10_000_000)
countries: list[str] = Field(min_length=1, max_length=5)
age_min: int = Field(default=18, ge=18, le=65)
age_max: int = Field(default=65, ge=18, le=65)
message: str = Field(min_length=1, max_length=2000)
headline: str = ""
link: str = ""
call_to_action: str = "LEARN_MORE"
activate: bool = False
class AdEdit(Confirm):
name: str | None = None
message: str | None = None
headline: str | None = None
call_to_action: str | None = None
link: str | None = None
def _confirmed(body: Confirm) -> None:
if not body.confirm:
raise http_meta(MetaApiError(422, "validation", "Confirm this action before Urmi sends it to Meta."))
def _redirect(query: str) -> RedirectResponse:
origin = settings.frontend_origin.rstrip("/")
return RedirectResponse(f"{origin}/console/meta/connections?{query}", status_code=302)
@router.get("/status")
def status(user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
conn = load_connection(db, user)
visible = conn if conn and conn.status != "disconnected" else None
return public_connection(visible)
@router.get("/oauth/start")
def oauth_start(user: UserAccount = Depends(current_user)) -> dict:
try:
return {"authorization_url": authorization_url(user)}
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.get("/oauth/callback")
async def oauth_callback(code: str = "", state: str = "", error: str = "", db: Session = Depends(get_db)):
if error or not code or not state:
return _redirect("error=Meta%20Login%20was%20cancelled")
try:
await finish_oauth(db, code, state)
except MetaApiError as exc:
return _redirect(f"error={quote(exc.message)}")
except Exception:
return _redirect("error=Meta%20Login%20could%20not%20be%20completed")
return _redirect("connected=1")
@router.post("/disconnect")
def disconnect_meta(body: Confirm, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
_confirmed(body)
try:
disconnect(db, user)
except MetaApiError as exc:
raise http_meta(exc) from exc
return {"status": "disconnected"}
@router.get("/overview")
async def meta_overview(user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
return await overview(db, user)
@router.get("/businesses")
async def businesses(user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
try:
return await list_businesses(db, user)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.get("/pages")
async def pages(user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
try:
return await list_pages(db, user)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.get("/pages/{page_id}")
async def page(page_id: str, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
try:
return await page_detail(db, user, page_id)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.post("/pages/select")
async def choose_page(body: IdBody, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
_confirmed(body)
try:
return await select_page(db, user, body.id)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.get("/ad-accounts")
async def ad_accounts(user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
try:
return await list_ad_accounts(db, user)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.post("/ad-accounts/select")
async def choose_ad_account(body: IdBody, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
_confirmed(body)
try:
return await select_ad_account(db, user, body.id)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.get("/campaigns")
async def campaigns(user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
try:
return await list_campaigns(db, user)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.post("/campaigns")
async def create_campaign(body: CampaignPublish, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
_confirmed(body)
try:
return await publish_campaign(db, user, body.model_dump())
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.post("/campaigns/{campaign_id}")
async def edit_campaign(
campaign_id: str,
body: CampaignEdit,
user: UserAccount = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
_confirmed(body)
try:
return await update_campaign(db, user, campaign_id, body.name, body.daily_budget)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.post("/campaigns/{campaign_id}/status")
async def campaign_status(
campaign_id: str,
body: StatusBody,
user: UserAccount = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
_confirmed(body)
try:
return await set_campaign_status(db, user, campaign_id, body.status)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.get("/ads")
async def ads(user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
try:
return await list_ads(db, user)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.post("/ads/{ad_id}")
async def edit_ad(ad_id: str, body: AdEdit, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
_confirmed(body)
try:
return await update_ad(db, user, ad_id, body.model_dump())
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.post("/ads/{ad_id}/status")
async def ad_status(
ad_id: str,
body: StatusBody,
user: UserAccount = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
_confirmed(body)
try:
return await set_ad_status(db, user, ad_id, body.status)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.get("/analytics")
async def meta_analytics(
date_preset: str = "last_30d",
user: UserAccount = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
try:
return await analytics(db, user, date_preset)
except MetaApiError as exc:
raise http_meta(exc) from exc
@router.get("/audit")
def audit(user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
return {"events": recent_audit(db, user)}
@router.get("/settings")
def meta_settings(user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
return settings_view(db, user)