diff --git a/backend/app/whatsapp_onboarding.py b/backend/app/whatsapp_onboarding.py index d1cc396..93f6800 100644 --- a/backend/app/whatsapp_onboarding.py +++ b/backend/app/whatsapp_onboarding.py @@ -227,6 +227,11 @@ def _serialize_phone(phone: WhatsAppPhoneAsset) -> dict[str, Any]: } +def meta_calls_allowed(account: WhatsAppBusinessAccount) -> bool: + """A disconnected account keeps its saved records and is not sent to Meta.""" + return account.status != "disconnected" + + 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) @@ -906,6 +911,8 @@ async def connect_with_token( async def refresh_account(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]: account = _owned_account(db, user, account_id) + if not meta_calls_allowed(account): + return serialize_account(account) connection = account.connection token = connection.access_token if connection else None if connection is None or not token or connection.status == "disconnected": @@ -933,6 +940,8 @@ async def register_phone_number( pin: str | None, ) -> dict[str, Any]: account = _owned_account(db, user, account_id) + if not meta_calls_allowed(account): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before registering the number") connection = account.connection token = connection.access_token if connection else None if connection is None or not token or connection.status == "disconnected": @@ -970,6 +979,8 @@ async def register_phone_number( async def disconnect_account(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]: account = _owned_account(db, user, account_id) + if not meta_calls_allowed(account): + return serialize_account(account) connection = account.connection token = connection.access_token if connection else None if token: @@ -1012,7 +1023,7 @@ def account_detail(db: Session, user: UserAccount, account_id: Any) -> dict[str, 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: + if not meta_calls_allowed(account) or account.templates_status != "active" or account.connection is None or not account.connection.access_token: return {"available": False, "templates": []} data = await _graph( "GET", @@ -1054,6 +1065,8 @@ async def create_marketing_template( ) -> dict[str, Any]: """Submit a marketing template to Meta for the connected WhatsApp account.""" account = _owned_account(db, user, account_id) + if not meta_calls_allowed(account): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before creating a template.") connection = account.connection token = connection.access_token if connection else None if connection is None or not token or connection.status == "disconnected": @@ -1187,7 +1200,7 @@ def _messaging_phone(account: WhatsAppBusinessAccount) -> tuple[WhatsAppPhoneAss connection = account.connection token = connection.access_token if connection else None phone = next((item for item in account.phones if item.status == "connected"), None) - if phone is None or account.messaging_status != "active" or not token: + if not meta_calls_allowed(account) or phone is None or account.messaging_status != "active" or not token: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=account.last_error or "Messaging is unavailable until this number is connected on Cloud API", @@ -1198,6 +1211,8 @@ def _messaging_phone(account: WhatsAppBusinessAccount) -> tuple[WhatsAppPhoneAss async def enable_customer_delivery(db: Session, user: UserAccount, account_id: Any, app_secret: str) -> dict[str, Any]: """Point this account's replies and delivery updates at the workspace.""" account = _owned_account(db, user, account_id) + if not meta_calls_allowed(account): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before replies can arrive here.") connection = account.connection token = connection.access_token if connection else None if connection is None or not token or connection.status == "disconnected": @@ -1383,6 +1398,8 @@ async def send_account_broadcast( async def download_account_media(db: Session, user: UserAccount, account_id: Any, media_id: str) -> tuple[bytes, str]: account = _owned_account(db, user, account_id) + if not meta_calls_allowed(account): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before opening media") token = account.connection.access_token if account.connection else None if not token: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before opening media") diff --git a/backend/app/whatsapp_template_service.py b/backend/app/whatsapp_template_service.py index adef413..f079ca0 100644 --- a/backend/app/whatsapp_template_service.py +++ b/backend/app/whatsapp_template_service.py @@ -20,6 +20,7 @@ from .whatsapp_onboarding import ( _load_phones, _load_waba, _owned_account, + meta_calls_allowed, _quality_label, _token_app_id, _waba_ids_from_debug, @@ -613,6 +614,8 @@ async def _correct_waba_id(db: Session, account: WhatsAppBusinessAccount, token: async def sync_account_templates(db: Session, account: WhatsAppBusinessAccount) -> str | None: + if not meta_calls_allowed(account): + return None tokens = _candidate_tokens(db, account) if not tokens: if account.status == "disconnected": @@ -671,6 +674,7 @@ async def list_managed_templates(db: Session, user: UserAccount, account_id: str accounts = db.scalars(query).all() if account_id: accounts = [account for account in accounts if str(account.id) == account_id] + accounts = [account for account in accounts if meta_calls_allowed(account)] warnings = [] for account in accounts: warning = await sync_account_templates(db, account) @@ -746,8 +750,14 @@ def _validate_identity(payload: dict[str, Any]) -> tuple[str, str, str]: return name, language, category +def _require_connected(account: WhatsAppBusinessAccount) -> None: + if not meta_calls_allowed(account): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reconnect this WhatsApp account before managing templates.") + + 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")) + _require_connected(account) token = _token(db, account) name, language, category = _validate_identity(payload) existing = db.scalar( @@ -793,6 +803,7 @@ async def create_managed_template(db: Session, user: UserAccount, payload: dict[ async def resubmit_managed_template(db: Session, user: UserAccount, template_id: Any, payload: dict[str, Any]) -> dict[str, Any]: row, account = _owned_template(db, user, template_id) + _require_connected(account) if row.status not in {"REJECTED", "PAUSED", "APPROVED"}: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This template can be edited after Meta rejects it, pauses it, or approves it.") if not row.meta_template_id: @@ -812,6 +823,7 @@ async def resubmit_managed_template(db: Session, user: UserAccount, template_id: async def delete_managed_template(db: Session, user: UserAccount, template_id: Any) -> dict[str, str]: row, account = _owned_template(db, user, template_id) + _require_connected(account) params: dict[str, Any] = {"name": row.name} if row.meta_template_id: params["hsm_id"] = row.meta_template_id @@ -823,6 +835,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) + _require_connected(account) token = _token(db, account) kind = _MEDIA.get(content_type) if kind is None: diff --git a/frontend/src/features/whatsapp/CustomerWhatsAppInbox.tsx b/frontend/src/features/whatsapp/CustomerWhatsAppInbox.tsx index 5800197..2ea5458 100644 --- a/frontend/src/features/whatsapp/CustomerWhatsAppInbox.tsx +++ b/frontend/src/features/whatsapp/CustomerWhatsAppInbox.tsx @@ -137,7 +137,9 @@ export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAc useEffect(() => { Promise.all([ whatsappApi.messages(account.id), - whatsappApi.templates(account.id).catch(() => ({ available: false, templates: [] as WhatsAppTemplateRow[] })), + account.status === 'disconnected' + ? Promise.resolve({ available: false, templates: [] as WhatsAppTemplateRow[] }) + : whatsappApi.templates(account.id).catch(() => ({ available: false, templates: [] as WhatsAppTemplateRow[] })), ]) .then(([items, templatePayload]) => { setMessages(items) diff --git a/frontend/src/features/whatsapp/WhatsAppSetupPage.tsx b/frontend/src/features/whatsapp/WhatsAppSetupPage.tsx index 57e2350..60eaae0 100644 --- a/frontend/src/features/whatsapp/WhatsAppSetupPage.tsx +++ b/frontend/src/features/whatsapp/WhatsAppSetupPage.tsx @@ -193,6 +193,11 @@ export function WhatsAppSetupPage() { useEffect(() => { if (phase !== 'detail' || !selected) return if (tab === 'templates') { + if (selected.status === 'disconnected') { + setTemplates([]) + setTemplatesAvailable(false) + return + } setTemplates(null) whatsappApi.templates(selected.id) .then((payload) => { @@ -213,7 +218,7 @@ export function WhatsAppSetupPage() { .then(setLogs) .catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load logs')) } - }, [phase, tab, selected?.id]) + }, [phase, tab, selected?.id, selected?.status]) function replaceAccount(next: WhatsAppAccount) { setAccounts((current) => current.map((account) => (account.id === next.id ? next : account))) @@ -563,9 +568,11 @@ export function WhatsAppSetupPage() { Chat )} - + {selected.status !== 'disconnected' && ( + + )} @@ -620,7 +627,7 @@ export function WhatsAppSetupPage() { Quality: {phone.quality || '—'} - {phone.needs_registration && !phone.needs_otp && ( + {selected.status !== 'disconnected' && phone.needs_registration && !phone.needs_otp && (
accounts.flatMap((account) => ( + const numbers = useMemo(() => accounts.filter((account) => account.status !== 'disconnected').flatMap((account) => ( account.phones.length ? account.phones.map((phone) => ({ account, phone: phone as WhatsAppAccount['phones'][number] | null })) : [{ account, phone: null as WhatsAppAccount['phones'][number] | null }] diff --git a/frontend/src/features/whatsapp/WhatsAppTemplatesPage.tsx b/frontend/src/features/whatsapp/WhatsAppTemplatesPage.tsx index 887d951..968d685 100644 --- a/frontend/src/features/whatsapp/WhatsAppTemplatesPage.tsx +++ b/frontend/src/features/whatsapp/WhatsAppTemplatesPage.tsx @@ -129,7 +129,7 @@ export function WhatsAppTemplatesPage() { setSearch(event.target.value)} placeholder="Search by name or ID" aria-label="Search templates" />