From dcae9609287d6a2146c476f01d955406647d5472 Mon Sep 17 00:00:00 2001 From: debu-2 Date: Wed, 14 Oct 2026 05:38:10 +0530 Subject: [PATCH] template --- backend/app/whatsapp_template_service.py | 157 +++++++++++++----- .../whatsapp/WhatsAppTemplateDetailPage.tsx | 3 +- .../whatsapp/WhatsAppTemplatesPage.tsx | 11 +- frontend/src/index.css | 4 +- frontend/src/lib/api.ts | 3 + 5 files changed, 129 insertions(+), 49 deletions(-) diff --git a/backend/app/whatsapp_template_service.py b/backend/app/whatsapp_template_service.py index d4eb161..c233fdc 100644 --- a/backend/app/whatsapp_template_service.py +++ b/backend/app/whatsapp_template_service.py @@ -11,7 +11,7 @@ from sqlalchemy import func, or_, select from sqlalchemy.orm import Session, selectinload from .config import settings -from .models import UserAccount, WhatsAppBusinessAccount, WhatsAppMessage, WhatsAppMessageTemplate +from .models import UserAccount, WhatsAppBusinessAccount, WhatsAppMessage, WhatsAppMessageTemplate, WhatsAppMetaConnection from .whatsapp_onboarding import _graph, _graph_error_message, _owned_account, _quality_label, _token_app_id _NAME = re.compile(r"^[a-z0-9_]{1,512}$") @@ -64,12 +64,59 @@ def _language_ok(code: str) -> bool: return any(item[0] == code for item in LANGUAGES) -def _token(account: WhatsAppBusinessAccount) -> str: - connection = account.connection - token = connection.access_token if connection else None - if connection is None or not token or connection.status == "disconnected": - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Save the WhatsApp access token before managing templates.") - return token +def _connection_method(connection: WhatsAppMetaConnection | None) -> str: + if connection is None: + return "WhatsApp" + if connection.granted_scopes == "manual": + return "Saved token" + if (connection.granted_scopes or "").strip(): + return "Embedded signup" + return "WhatsApp" + + +def _stored_token(connection: WhatsAppMetaConnection | None) -> str | None: + if connection is None or connection.status == "disconnected": + return None + token = (connection.access_token or "").strip() + return token or None + + +def _candidate_tokens(db: Session, account: WhatsAppBusinessAccount) -> list[str]: + """Embedded signup and a saved token are both stored on the connection.""" + tokens: list[str] = [] + own = _stored_token(account.connection) + if own: + tokens.append(own) + others = db.scalars( + select(WhatsAppMetaConnection).where( + WhatsAppMetaConnection.user_id == account.user_id, + WhatsAppMetaConnection.status != "disconnected", + ) + ).all() + for connection in others: + token = _stored_token(connection) + if token and token not in tokens: + tokens.append(token) + platform_waba = settings.whatsapp_business_account_id.strip() + platform_token = settings.whatsapp_access_token.strip() + if platform_token and account.waba_id == platform_waba and platform_token not in tokens: + tokens.append(platform_token) + return tokens + + +def _token(db: Session, account: WhatsAppBusinessAccount) -> str: + tokens = _candidate_tokens(db, account) + if not tokens: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Reconnect this WhatsApp account before managing templates.", + ) + return tokens[0] + + +def _retry_with_another_token(exc: HTTPException) -> bool: + detail = str(exc.detail).lower() + return any(part in detail for part in ("access token", "oauth", "(#190)", "permission", "unsupported get", "(#10)")) def _variables(text: str) -> list[str]: @@ -340,17 +387,26 @@ def _sent_counts(db: Session, user: UserAccount) -> dict[str, int]: def _serialize(row: WhatsAppMessageTemplate, account: WhatsAppBusinessAccount | None, sent: int) -> dict[str, Any]: phone = None + phones: list[str] = [] + connection = account.connection if account is not None else None if account is not None: phone = next((item for item in account.phones if item.phone_number_id == row.phone_number_id), None) if phone is None and account.phones: phone = account.phones[0] + for item in account.phones: + label = item.display_phone or item.verified_name or "" + if label and label not in phones: + phones.append(label) return { "id": str(row.id), "account_id": str(row.account_id), "account_name": account.name if account else "WhatsApp Business", "waba_name": account.name if account else "", + "connection_name": (connection.business_name if connection and connection.business_name else (account.name if account else "WhatsApp")), + "connection_method": _connection_method(connection), "phone_number_id": row.phone_number_id, "display_phone": phone.display_phone if phone else "", + "phones": phones, "meta_template_id": row.meta_template_id, "name": row.name, "language": row.language, @@ -433,41 +489,54 @@ async def _template_page(token: str, waba_id: str, params: dict[str, Any]) -> di return {} -async def sync_account_templates(db: Session, account: WhatsAppBusinessAccount) -> str | None: - token = account.connection.access_token if account.connection else None - if not token: - return "This account has no access token, so templates could not be refreshed." +async def _pull_templates(db: Session, account: WhatsAppBusinessAccount, token: str) -> set[tuple[str, str]]: seen: set[tuple[str, str]] = set() after = None - try: - for _ in range(5): - params = {"limit": "100"} - if after: - params["after"] = after - data = await _template_page(token, account.waba_id, params) - rows = data.get("data") if isinstance(data.get("data"), list) else [] - for remote in rows: - if isinstance(remote, dict) and remote.get("name"): - _upsert_remote(db, account, remote) - seen.add((str(remote.get("name")), str(remote.get("language") or ""))) - paging = data.get("paging") if isinstance(data.get("paging"), dict) else {} - cursors = paging.get("cursors") if isinstance(paging.get("cursors"), dict) else {} - after = cursors.get("after") if paging.get("next") else None - if not after: - break - except HTTPException as exc: - db.rollback() - return str(exc.detail) - stored = db.scalars( - select(WhatsAppMessageTemplate).where(WhatsAppMessageTemplate.waba_id == account.waba_id) - ).all() - for row in stored: - if row.meta_template_id and (row.name, row.language) not in seen: - row.status = "DISABLED" - account.template_count = len(seen) - account.templates_status = "active" - db.commit() - return None + for _ in range(5): + params: dict[str, Any] = {"limit": "100"} + if after: + params["after"] = after + data = await _template_page(token, account.waba_id, params) + rows = data.get("data") if isinstance(data.get("data"), list) else [] + for remote in rows: + if isinstance(remote, dict) and remote.get("name"): + _upsert_remote(db, account, remote) + seen.add((str(remote.get("name")), str(remote.get("language") or ""))) + paging = data.get("paging") if isinstance(data.get("paging"), dict) else {} + cursors = paging.get("cursors") if isinstance(paging.get("cursors"), dict) else {} + after = cursors.get("after") if paging.get("next") else None + if not after: + break + return seen + + +async def sync_account_templates(db: Session, account: WhatsAppBusinessAccount) -> str | None: + tokens = _candidate_tokens(db, account) + if not tokens: + if account.status == "disconnected": + return None + return "Reconnect this WhatsApp account to refresh its templates." + last_error: str | None = None + for index, token in enumerate(tokens): + try: + seen = await _pull_templates(db, account, token) + except HTTPException as exc: + db.rollback() + last_error = str(exc.detail) + if _retry_with_another_token(exc) and index < len(tokens) - 1: + continue + return last_error + stored = db.scalars( + select(WhatsAppMessageTemplate).where(WhatsAppMessageTemplate.waba_id == account.waba_id) + ).all() + for row in stored: + if row.meta_template_id and (row.name, row.language) not in seen: + row.status = "DISABLED" + account.template_count = len(seen) + account.templates_status = "active" + db.commit() + return None + return last_error async def list_managed_templates(db: Session, user: UserAccount, account_id: str | None) -> dict[str, Any]: @@ -556,7 +625,7 @@ def _validate_identity(payload: dict[str, Any]) -> tuple[str, str, str]: async def create_managed_template(db: Session, user: UserAccount, payload: dict[str, Any]) -> dict[str, Any]: account = _owned_account(db, user, payload.get("account_id")) - token = _token(account) + token = _token(db, account) name, language, category = _validate_identity(payload) existing = db.scalar( select(WhatsAppMessageTemplate).where( @@ -607,7 +676,7 @@ async def resubmit_managed_template(db: Session, user: UserAccount, template_id: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Meta has no id for this template yet.") _validate_identity({**payload, "name": row.name, "language": row.language, "category": payload.get("category") or row.category}) components = build_components({**payload, "name": row.name, "language": row.language}) - data = await _graph("POST", row.meta_template_id, token=_token(account), json_body={"components": components, "category": row.category}) + data = await _graph("POST", row.meta_template_id, token=_token(db, account), json_body={"components": components, "category": row.category}) row.components = components row.definition = payload row.status = str(data.get("status") or "PENDING").upper() @@ -623,7 +692,7 @@ async def delete_managed_template(db: Session, user: UserAccount, template_id: A params: dict[str, Any] = {"name": row.name} if row.meta_template_id: params["hsm_id"] = row.meta_template_id - await _graph("DELETE", f"{account.waba_id}/message_templates", token=_token(account), params=params) + await _graph("DELETE", f"{account.waba_id}/message_templates", token=_token(db, account), params=params) db.delete(row) db.commit() return {"status": "deleted"} @@ -631,7 +700,7 @@ async def delete_managed_template(db: Session, user: UserAccount, template_id: A async def upload_header_media(db: Session, user: UserAccount, account_id: Any, filename: str, content_type: str, data: bytes) -> dict[str, str]: account = _owned_account(db, user, account_id) - token = _token(account) + token = _token(db, account) kind = _MEDIA.get(content_type) if kind is None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Upload a JPEG, PNG, MP4, or PDF.") diff --git a/frontend/src/features/whatsapp/WhatsAppTemplateDetailPage.tsx b/frontend/src/features/whatsapp/WhatsAppTemplateDetailPage.tsx index d1b2a3a..e09454c 100644 --- a/frontend/src/features/whatsapp/WhatsAppTemplateDetailPage.tsx +++ b/frontend/src/features/whatsapp/WhatsAppTemplateDetailPage.tsx @@ -78,7 +78,8 @@ export function WhatsAppTemplateDetailPage() {
Meta template ID
{row.meta_template_id || 'Not assigned yet'}
Account
{row.account_name}
-
Phone
{row.display_phone || 'Shared on the account'}
+
Connection
{row.connection_name || row.account_name} · {row.connection_method || 'WhatsApp'}
+
Numbers
{(row.phones && row.phones.length ? row.phones : [row.display_phone]).filter(Boolean).join(', ') || 'Shared on the account'}
Category
{row.category.toLowerCase()}
Language
{row.language}
Quality
{row.quality || 'Not rated yet'}
diff --git a/frontend/src/features/whatsapp/WhatsAppTemplatesPage.tsx b/frontend/src/features/whatsapp/WhatsAppTemplatesPage.tsx index 0ad20fc..887d951 100644 --- a/frontend/src/features/whatsapp/WhatsAppTemplatesPage.tsx +++ b/frontend/src/features/whatsapp/WhatsAppTemplatesPage.tsx @@ -56,7 +56,7 @@ export function WhatsAppTemplatesPage() { }, []) const filtered = useMemo(() => rows.filter((row) => { - const haystack = `${row.name} ${row.meta_template_id || ''}`.toLowerCase() + const haystack = `${row.name} ${row.meta_template_id || ''} ${(row.phones || []).join(' ')} ${row.display_phone || ''} ${row.connection_name || ''} ${row.account_name || ''}`.toLowerCase() if (search && !haystack.includes(search.toLowerCase())) return false if (category && row.category !== category) return false if (language && row.language !== language) return false @@ -129,7 +129,10 @@ export function WhatsAppTemplatesPage() { setSearch(event.target.value)} placeholder="Search by name or ID" aria-label="Search templates" />