meta options
This commit is contained in:
parent
4697dab339
commit
8726c931fa
21 changed files with 3028 additions and 3 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
1228
backend/app/meta_service.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
281
backend/app/routes_meta.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue