feat: Add WhatsApp health monitoring and self-service registration completion (#12556)

Fixes
https://linear.app/chatwoot/issue/CW-5692/whatsapp-es-numbers-stuck-in-pending-due-to-premature-registration


###  Problem  
Multiple customers reported that their WhatsApp numbers remain stuck in
**Pending** in WhatsApp Manager even after successful onboarding.

- Our system triggers a **registration call**
(`/<PHONE_NUMBER_ID>/register`) as soon as the number is OTP verified.
- In many cases, Meta hasn’t finished **display name
review/provisioning**, so the call fails with:

  ```
  code: 100, error_subcode: 2388001
  error_user_title: "Cannot Create Certificate"
error_user_msg: "Your display name could not be processed. Please edit
your display name and try again."
  ```

- This leaves the number stuck in Pending, no messaging can start until
we manually retry registration.
- Some customers have reported being stuck in this state for **7+
days**.

###  Root cause  
- We only check `code_verification_status = VERIFIED` before attempting
registration.
- We **don’t wait** for display name provisioning (`name_status` /
`platform_type`) to be complete.
- As a result, registration fails prematurely and the number never
transitions out of Pending.

### Solution  

#### 1. Health Status Monitoring  
- Build a backend service to fetch **real-time health data** from Graph
API:
  - `code_verification_status`  
  - `name_status` / `display_name_status`  
  - `platform_type`  
  - `throughput.level`  
  - `messaging_limit_tier`  
  - `quality_rating`  
- Expose health data via API
(`/api/v1/accounts/:account_id/inboxes/:id/health`).
- Display this in the UI as an **Account Health tab** with clear badges
and direct links to WhatsApp Manager.

#### 2. Smarter Registration Logic  
- Update `WebhookSetupService` to include a **dual-condition check**:  
  - Register if:  
    1. Phone is **not verified**, OR  
2. Phone is **verified but provisioning incomplete** (`platform_type =
NOT_APPLICABLE`, `throughput.level = NOT_APPLICABLE`).
- Skip registration if number is already provisioned.  
- Retry registration automatically when stuck.  
- Provide a UI banner with complete registration button so customers can
retry without manual support.

### Screenshot
<img width="2292" height="1344" alt="CleanShot 2025-09-30 at 16 01
03@2x"
src="https://github.com/user-attachments/assets/1c417d2a-b11c-475e-b092-3c5671ee59a7"
/>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Muhsin Keloth
2025-10-02 11:25:48 +05:30
committed by GitHub
parent 109aaa2341
commit 66cfef9298
15 changed files with 914 additions and 40 deletions

View File

@@ -4,7 +4,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
before_action :fetch_agent_bot, only: [:set_agent_bot] before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create] before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox # we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show] before_action :check_authorization, except: [:show, :health]
before_action :validate_whatsapp_cloud_channel, only: [:health]
def index def index
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] })) @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
@@ -78,6 +79,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
render status: :internal_server_error, json: { error: e.message } render status: :internal_server_error, json: { error: e.message }
end end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private private
def fetch_inbox def fetch_inbox
@@ -89,6 +98,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end end
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def create_channel def create_channel
return unless allowed_channel_types.include?(permitted_params[:channel][:type]) return unless allowed_channel_types.include?(permitted_params[:channel][:type])

View File

@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from './ApiClient';
class InboxHealthAPI extends ApiClient {
constructor() {
super('inboxes', { accountScoped: true });
}
getHealthStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/health`);
}
}
export default new InboxHealthAPI();

View File

@@ -5,6 +5,8 @@
"LEARN_MORE": "Learn more about inboxes", "LEARN_MORE": "Learn more about inboxes",
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.", "RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
"CLICK_TO_RECONNECT": "Click here to reconnect.", "CLICK_TO_RECONNECT": "Click here to reconnect.",
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isnt complete. Please check your display name status in Meta Business Manager before reconnecting.",
"COMPLETE_REGISTRATION": "Complete Registration",
"LIST": { "LIST": {
"404": "There are no inboxes attached to this account." "404": "There are no inboxes attached to this account."
}, },
@@ -605,8 +607,62 @@
"BUSINESS_HOURS": "Business Hours", "BUSINESS_HOURS": "Business Hours",
"WIDGET_BUILDER": "Widget Builder", "WIDGET_BUILDER": "Widget Builder",
"BOT_CONFIGURATION": "Bot Configuration", "BOT_CONFIGURATION": "Bot Configuration",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT" "CSAT": "CSAT"
}, },
"ACCOUNT_HEALTH": {
"TITLE": "Manage your WhatsApp account",
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
"GO_TO_SETTINGS": "Go to Meta Business Manager",
"NO_DATA": "Health data is not available",
"FIELDS": {
"DISPLAY_PHONE_NUMBER": {
"LABEL": "Display phone number",
"TOOLTIP": "Phone number displayed to customers"
},
"VERIFIED_NAME": {
"LABEL": "Business name",
"TOOLTIP": "Business name verified by WhatsApp"
},
"DISPLAY_NAME_STATUS": {
"LABEL": "Display name status",
"TOOLTIP": "Status of your business name verification"
},
"QUALITY_RATING": {
"LABEL": "Quality rating",
"TOOLTIP": "WhatsApp quality rating for your account"
},
"MESSAGING_LIMIT_TIER": {
"LABEL": "Messaging limit tier",
"TOOLTIP": "Daily messaging limit for your account"
},
"ACCOUNT_MODE": {
"LABEL": "Account mode",
"TOOLTIP": "Current operating mode of your WhatsApp account"
}
},
"VALUES": {
"TIERS": {
"TIER_250": "250 customers per 24h",
"TIER_1000": "1K customers per 24h",
"TIER_1K": "1K customers per 24h",
"TIER_10K": "10K customers per 24h",
"TIER_100K": "100K customers per 24h",
"TIER_UNLIMITED": "Unlimited customers per 24h"
},
"STATUSES": {
"APPROVED": "Approved",
"PENDING_REVIEW": "Pending Review",
"AVAILABLE_WITHOUT_REVIEW": "Available Without Review",
"REJECTED": "Rejected",
"DECLINED": "Declined"
},
"MODES": {
"SANDBOX": "Sandbox",
"LIVE": "Live"
}
}
},
"SETTINGS": "Settings", "SETTINGS": "Settings",
"FEATURES": { "FEATURES": {
"LABEL": "Features", "LABEL": "Features",

View File

@@ -13,6 +13,7 @@ import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue'
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue'; import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
import GoogleReauthorize from './channels/google/Reauthorize.vue'; import GoogleReauthorize from './channels/google/Reauthorize.vue';
import WhatsappReauthorize from './channels/whatsapp/Reauthorize.vue'; import WhatsappReauthorize from './channels/whatsapp/Reauthorize.vue';
import InboxHealthAPI from 'dashboard/api/inboxHealth';
import PreChatFormSettings from './PreChatForm/Settings.vue'; import PreChatFormSettings from './PreChatForm/Settings.vue';
import WeeklyAvailability from './components/WeeklyAvailability.vue'; import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue'; import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
@@ -21,6 +22,7 @@ import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vu
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue'; import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import WidgetBuilder from './WidgetBuilder.vue'; import WidgetBuilder from './WidgetBuilder.vue';
import BotConfiguration from './components/BotConfiguration.vue'; import BotConfiguration from './components/BotConfiguration.vue';
import AccountHealth from './components/AccountHealth.vue';
import { FEATURE_FLAGS } from '../../../../featureFlags'; import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue'; import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -51,6 +53,7 @@ export default {
DuplicateInboxBanner, DuplicateInboxBanner,
Editor, Editor,
Avatar, Avatar,
AccountHealth,
}, },
mixins: [inboxMixin], mixins: [inboxMixin],
setup() { setup() {
@@ -79,6 +82,9 @@ export default {
selectedPortalSlug: '', selectedPortalSlug: '',
showBusinessNameInput: false, showBusinessNameInput: false,
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS, welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
healthData: null,
isLoadingHealth: false,
healthError: null,
}; };
}, },
computed: { computed: {
@@ -175,6 +181,16 @@ export default {
}, },
]; ];
} }
if (this.shouldShowWhatsAppConfiguration) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'whatsappHealth',
name: this.$t('INBOX_MGMT.TABS.ACCOUNT_HEALTH'),
},
];
}
return visibleToAllChannelTabs; return visibleToAllChannelTabs;
}, },
currentInboxId() { currentInboxId() {
@@ -260,14 +276,30 @@ export default {
this.inbox.reauthorization_required this.inbox.reauthorization_required
); );
}, },
isEmbeddedSignupWhatsApp() {
return this.inbox.provider_config?.source === 'embedded_signup';
},
whatsappUnauthorized() { whatsappUnauthorized() {
return ( return (
this.isAWhatsAppChannel && this.isAWhatsAppCloudChannel &&
this.inbox.provider === 'whatsapp_cloud' && this.isEmbeddedSignupWhatsApp &&
this.inbox.provider_config?.source === 'embedded_signup' &&
this.inbox.reauthorization_required this.inbox.reauthorization_required
); );
}, },
whatsappRegistrationIncomplete() {
if (
!this.healthData ||
!this.isAWhatsAppCloudChannel ||
!this.isEmbeddedSignupWhatsApp
) {
return false;
}
return (
this.healthData.platform_type === 'NOT_APPLICABLE' ||
this.healthData.throughput?.level === 'NOT_APPLICABLE'
);
},
}, },
watch: { watch: {
$route(to) { $route(to) {
@@ -275,15 +307,40 @@ export default {
this.fetchInboxSettings(); this.fetchInboxSettings();
} }
}, },
inbox: {
handler() {
this.fetchHealthData();
},
immediate: false,
},
}, },
mounted() { mounted() {
this.fetchInboxSettings(); this.fetchInboxSettings();
this.fetchPortals(); this.fetchPortals();
this.fetchHealthData();
}, },
methods: { methods: {
fetchPortals() { fetchPortals() {
this.$store.dispatch('portals/index'); this.$store.dispatch('portals/index');
}, },
async fetchHealthData() {
if (!this.inbox) return;
if (!this.isAWhatsAppCloudChannel) {
return;
}
try {
this.isLoadingHealth = true;
this.healthError = null;
const response = await InboxHealthAPI.getHealthStatus(this.inbox.id);
this.healthData = response.data;
} catch (error) {
this.healthError = error.message || 'Failed to fetch health data';
} finally {
this.isLoadingHealth = false;
}
},
handleFeatureFlag(e) { handleFeatureFlag(e) {
this.selectedFeatureFlags = this.toggleInput( this.selectedFeatureFlags = this.toggleInput(
this.selectedFeatureFlags, this.selectedFeatureFlags,
@@ -446,7 +503,11 @@ export default {
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" /> <FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" /> <GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
<InstagramReauthorize v-if="instagramUnauthorized" :inbox="inbox" /> <InstagramReauthorize v-if="instagramUnauthorized" :inbox="inbox" />
<WhatsappReauthorize v-if="whatsappUnauthorized" :inbox="inbox" /> <WhatsappReauthorize
v-if="whatsappUnauthorized"
:whatsapp-registration-incomplete="whatsappRegistrationIncomplete"
:inbox="inbox"
/>
<DuplicateInboxBanner <DuplicateInboxBanner
v-if="hasDuplicateInstagramInbox" v-if="hasDuplicateInstagramInbox"
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.DUPLICATE_INBOX_BANNER')" :content="$t('INBOX_MGMT.ADD.INSTAGRAM.DUPLICATE_INBOX_BANNER')"
@@ -458,7 +519,7 @@ export default {
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_SUB_TEXT')" :sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_SUB_TEXT')"
:show-border="false" :show-border="false"
> >
<div class="flex flex-col mb-4 items-start gap-1"> <div class="flex flex-col gap-1 items-start mb-4">
<label class="mb-0.5 text-sm font-medium text-n-slate-12"> <label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_AVATAR.LABEL') }} {{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_AVATAR.LABEL') }}
</label> </label>
@@ -856,6 +917,9 @@ export default {
<div v-if="selectedTabKey === 'botConfiguration'"> <div v-if="selectedTabKey === 'botConfiguration'">
<BotConfiguration :inbox="inbox" /> <BotConfiguration :inbox="inbox" />
</div> </div>
<div v-if="selectedTabKey === 'whatsappHealth'">
<AccountHealth :health-data="healthData" />
</div>
</section> </section>
</div> </div>
</template> </template>

View File

@@ -16,6 +16,10 @@ const props = defineProps({
type: Object, type: Object,
required: true, required: true,
}, },
whatsappRegistrationIncomplete: {
type: Boolean,
default: false,
},
}); });
const { t } = useI18n(); const { t } = useI18n();
@@ -28,6 +32,20 @@ const whatsappConfigurationId = computed(
() => window.chatwootConfig.whatsappConfigurationId () => window.chatwootConfig.whatsappConfigurationId
); );
const actionLabel = computed(() => {
if (props.whatsappRegistrationIncomplete) {
return t('INBOX_MGMT.COMPLETE_REGISTRATION');
}
return '';
});
const description = computed(() => {
if (props.whatsappRegistrationIncomplete) {
return t('INBOX_MGMT.WHATSAPP_REGISTRATION_INCOMPLETE');
}
return '';
});
const reauthorizeWhatsApp = async params => { const reauthorizeWhatsApp = async params => {
isRequestingAuthorization.value = true; isRequestingAuthorization.value = true;
@@ -185,6 +203,8 @@ defineExpose({
<InboxReconnectionRequired <InboxReconnectionRequired
class="mx-8 mt-5" class="mx-8 mt-5"
:is-loading="isRequestingAuthorization" :is-loading="isRequestingAuthorization"
:action-label="actionLabel"
:description="description"
@reauthorize="requestAuthorization" @reauthorize="requestAuthorization"
/> />
</template> </template>

View File

@@ -0,0 +1,228 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import ButtonV4 from 'next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
healthData: {
type: Object,
default: null,
},
});
const { t } = useI18n();
const QUALITY_COLORS = {
GREEN: 'text-n-teal-11',
YELLOW: 'text-n-amber-11',
RED: 'text-n-ruby-11',
UNKNOWN: 'text-n-slate-12',
};
const STATUS_COLORS = {
APPROVED: 'text-n-teal-11',
PENDING_REVIEW: 'text-n-amber-11',
AVAILABLE_WITHOUT_REVIEW: 'text-n-teal-11',
REJECTED: 'text-n-ruby-9',
DECLINED: 'text-n-ruby-9',
};
const MODE_COLORS = {
LIVE: 'text-n-teal-11',
SANDBOX: 'text-n-slate-11',
};
const healthItems = computed(() => {
if (!props.healthData) {
return [];
}
const {
display_phone_number: displayPhoneNumber,
verified_name: verifiedName,
name_status: nameStatus,
quality_rating: qualityRating,
messaging_limit_tier: messagingLimitTier,
account_mode: accountMode,
} = props.healthData;
return [
{
key: 'displayPhoneNumber',
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.DISPLAY_PHONE_NUMBER.LABEL'),
value: displayPhoneNumber || 'N/A',
tooltip: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.DISPLAY_PHONE_NUMBER.TOOLTIP'
),
show: true,
},
{
key: 'verifiedName',
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.VERIFIED_NAME.LABEL'),
value: verifiedName || 'N/A',
tooltip: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.VERIFIED_NAME.TOOLTIP'),
show: true,
},
{
key: 'displayNameStatus',
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.DISPLAY_NAME_STATUS.LABEL'),
value: nameStatus || 'UNKNOWN',
tooltip: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.DISPLAY_NAME_STATUS.TOOLTIP'
),
show: true,
type: 'status',
},
{
key: 'qualityRating',
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.QUALITY_RATING.LABEL'),
value: qualityRating || 'UNKNOWN',
tooltip: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.QUALITY_RATING.TOOLTIP'),
show: true,
type: 'quality',
},
{
key: 'messagingLimitTier',
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.MESSAGING_LIMIT_TIER.LABEL'),
value: messagingLimitTier || 'UNKNOWN',
tooltip: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.MESSAGING_LIMIT_TIER.TOOLTIP'
),
show: true,
type: 'tier',
},
{
key: 'accountMode',
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.ACCOUNT_MODE.LABEL'),
value: accountMode || 'UNKNOWN',
tooltip: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.ACCOUNT_MODE.TOOLTIP'),
show: true,
type: 'mode',
},
];
});
const handleGoToSettings = () => {
const { business_id: businessId } = props.healthData || {};
if (businessId) {
// WhatsApp Business Manager URL with specific business ID and phone numbers tab
const whatsappBusinessUrl = `https://business.facebook.com/latest/whatsapp_manager/phone_numbers/?business_id=${businessId}&tab=phone-numbers`;
window.open(whatsappBusinessUrl, '_blank');
} else {
// Fallback to general WhatsApp Business Manager if business_id is not available
const fallbackUrl = 'https://business.facebook.com/';
window.open(fallbackUrl, '_blank');
}
};
const getQualityRatingTextColor = rating =>
QUALITY_COLORS[rating] || QUALITY_COLORS.UNKNOWN;
const formatTierDisplay = tier =>
t(`INBOX_MGMT.ACCOUNT_HEALTH.VALUES.TIERS.${tier}`) || tier;
const formatStatusDisplay = status =>
t(`INBOX_MGMT.ACCOUNT_HEALTH.VALUES.STATUSES.${status}`) || status;
const formatModeDisplay = mode =>
t(`INBOX_MGMT.ACCOUNT_HEALTH.VALUES.MODES.${mode}`) || mode;
const getModeStatusTextColor = mode => MODE_COLORS[mode] || 'text-n-slate-12';
const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
</script>
<template>
<div class="gap-4 pt-8 mx-8">
<div
class="px-5 py-5 space-y-6 rounded-xl border shadow-sm border-n-weak bg-n-solid-2"
>
<div
class="flex flex-col gap-5 justify-between items-start w-full md:flex-row"
>
<div>
<span class="text-base font-medium text-n-slate-12">
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.TITLE') }}
</span>
<p class="mt-1 text-sm text-n-slate-11">
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.DESCRIPTION') }}
</p>
</div>
<ButtonV4
sm
solid
blue
class="flex-shrink-0"
@click="handleGoToSettings"
>
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.GO_TO_SETTINGS') }}
</ButtonV4>
</div>
<div v-if="healthData" class="grid grid-cols-1 gap-4 xs:grid-cols-2">
<div
v-for="item in healthItems"
:key="item.key"
class="flex flex-col gap-2 p-4 rounded-lg border border-n-weak bg-n-solid-1"
>
<div class="flex gap-2 items-center">
<span class="text-sm font-medium text-n-slate-11">
{{ item.label }}
</span>
<Icon
v-tooltip.top="item.tooltip"
icon="i-lucide-info"
class="flex-shrink-0 w-4 h-4 cursor-help text-n-slate-9"
/>
</div>
<div class="flex items-center">
<span
v-if="item.type === 'quality'"
class="inline-flex items-center px-2 py-0.5 min-h-6 text-xs font-medium rounded-md bg-n-alpha-2"
:class="getQualityRatingTextColor(item.value)"
>
{{ item.value }}
</span>
<span
v-else-if="item.type === 'status'"
class="inline-flex items-center px-2 py-0.5 min-h-6 text-xs font-medium rounded-md bg-n-alpha-2"
:class="getStatusTextColor(item.value)"
>
{{ formatStatusDisplay(item.value) }}
</span>
<span
v-else-if="item.type === 'mode'"
class="inline-flex items-center px-2 py-0.5 min-h-6 text-xs font-medium rounded-md bg-n-alpha-2"
:class="getModeStatusTextColor(item.value)"
>
{{ formatModeDisplay(item.value) }}
</span>
<span
v-else-if="item.type === 'tier'"
class="text-sm font-medium text-n-slate-12"
>
{{ formatTierDisplay(item.value) }}
</span>
<span v-else class="text-sm font-medium text-n-slate-12">{{
item.value
}}</span>
</div>
</div>
</div>
<div v-else class="pt-8">
<div
class="flex justify-center items-center p-8 text-center text-n-slate-11"
>
<div>
<Icon icon="i-lucide-activity" class="mb-2 w-8 h-8" />
<p class="text-sm">{{ t('INBOX_MGMT.ACCOUNT_HEALTH.NO_DATA') }}</p>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,15 +1,26 @@
<script setup> <script setup>
import Banner from 'dashboard/components-next/banner/Banner.vue'; import Banner from 'dashboard/components-next/banner/Banner.vue';
defineProps({
actionLabel: {
type: String,
default: null,
},
description: {
type: String,
default: null,
},
});
const emit = defineEmits(['reauthorize']); const emit = defineEmits(['reauthorize']);
</script> </script>
<template> <template>
<Banner <Banner
color="ruby" color="ruby"
:action-label="$t('INBOX_MGMT.CLICK_TO_RECONNECT')" :action-label="actionLabel || $t('INBOX_MGMT.CLICK_TO_RECONNECT')"
@action="emit('reauthorize')" @action="emit('reauthorize')"
> >
{{ $t('INBOX_MGMT.RECONNECTION_REQUIRED') }} {{ description || $t('INBOX_MGMT.RECONNECTION_REQUIRED') }}
</Banner> </Banner>
</template> </template>

View File

@@ -61,4 +61,8 @@ class InboxPolicy < ApplicationPolicy
def sync_templates? def sync_templates?
@account_user.administrator? @account_user.administrator?
end end
def health?
@account_user.administrator?
end
end end

View File

@@ -17,6 +17,7 @@ class Whatsapp::EmbeddedSignupService
channel = create_or_reauthorize_channel(access_token, phone_info) channel = create_or_reauthorize_channel(access_token, phone_info)
channel.setup_webhooks channel.setup_webhooks
check_channel_health_and_prompt_reauth(channel)
channel channel
rescue StandardError => e rescue StandardError => e
@@ -52,6 +53,24 @@ class Whatsapp::EmbeddedSignupService
end end
end end
def check_channel_health_and_prompt_reauth(channel)
health_data = Whatsapp::HealthService.new(channel).fetch_health_status
return unless health_data
if channel_in_pending_state?(health_data)
channel.prompt_reauthorization!
else
Rails.logger.info "[WHATSAPP] Channel #{channel.phone_number} health check passed"
end
rescue StandardError => e
Rails.logger.error "[WHATSAPP] Health check failed for channel #{channel.phone_number}: #{e.message}"
end
def channel_in_pending_state?(health_data)
health_data[:platform_type] == 'NOT_APPLICABLE' ||
health_data.dig(:throughput, 'level') == 'NOT_APPLICABLE'
end
def validate_parameters! def validate_parameters!
missing_params = [] missing_params = []
missing_params << 'code' if @code.blank? missing_params << 'code' if @code.blank?

View File

@@ -0,0 +1,84 @@
class Whatsapp::HealthService
BASE_URI = 'https://graph.facebook.com'.freeze
def initialize(channel)
@channel = channel
@access_token = channel.provider_config['api_key']
@api_version = GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0')
end
def fetch_health_status
validate_channel!
fetch_phone_health_data
end
private
def validate_channel!
raise ArgumentError, 'Channel is required' if @channel.blank?
raise ArgumentError, 'API key is missing' if @access_token.blank?
raise ArgumentError, 'Phone number ID is missing' if @channel.provider_config['phone_number_id'].blank?
end
def fetch_phone_health_data
phone_number_id = @channel.provider_config['phone_number_id']
response = HTTParty.get(
"#{BASE_URI}/#{@api_version}/#{phone_number_id}",
query: {
fields: health_fields,
access_token: @access_token
}
)
handle_response(response)
rescue StandardError => e
Rails.logger.error "[WHATSAPP HEALTH] Error fetching health data: #{e.message}"
raise e
end
def health_fields
%w[
quality_rating
messaging_limit_tier
code_verification_status
account_mode
id
display_phone_number
name_status
verified_name
webhook_configuration
throughput
last_onboarded_time
platform_type
certificate
].join(',')
end
def handle_response(response)
unless response.success?
error_message = "WhatsApp API request failed: #{response.code} - #{response.body}"
Rails.logger.error "[WHATSAPP HEALTH] #{error_message}"
raise error_message
end
data = response.parsed_response
format_health_response(data)
end
def format_health_response(response)
{
display_phone_number: response['display_phone_number'],
verified_name: response['verified_name'],
name_status: response['name_status'],
quality_rating: response['quality_rating'],
messaging_limit_tier: response['messaging_limit_tier'],
account_mode: response['account_mode'],
code_verification_status: response['code_verification_status'],
throughput: response['throughput'],
last_onboarded_time: response['last_onboarded_time'],
platform_type: response['platform_type'],
business_id: @channel.provider_config['business_account_id']
}
end
end

View File

@@ -8,8 +8,12 @@ class Whatsapp::WebhookSetupService
def perform def perform
validate_parameters! validate_parameters!
# Since coexistence method does not need to register, we check it
register_phone_number unless phone_number_verified? # Register phone number if either condition is met:
# 1. Phone number is not verified (code_verification_status != 'VERIFIED')
# 2. Phone number needs registration (pending provisioning state)
register_phone_number if !phone_number_verified? || phone_number_needs_registration?
setup_webhook setup_webhook
end end
@@ -69,9 +73,44 @@ class Whatsapp::WebhookSetupService
def phone_number_verified? def phone_number_verified?
phone_number_id = @channel.provider_config['phone_number_id'] phone_number_id = @channel.provider_config['phone_number_id']
@api_client.phone_number_verified?(phone_number_id) # Check with WhatsApp API if the phone number code verification is complete
# This checks code_verification_status == 'VERIFIED'
verified = @api_client.phone_number_verified?(phone_number_id)
Rails.logger.info("[WHATSAPP] Phone number #{phone_number_id} code verification status: #{verified}")
verified
rescue StandardError => e rescue StandardError => e
Rails.logger.error("[WHATSAPP] Phone registration status check failed, but continuing: #{e.message}") # If verification check fails, assume not verified to be safe
Rails.logger.error("[WHATSAPP] Phone verification status check failed: #{e.message}")
false
end
def phone_number_needs_registration?
# Check if phone is in pending provisioning state based on health data
# This is a separate check from phone_number_verified? which only checks code verification
phone_number_in_pending_state?
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Phone registration check failed: #{e.message}")
# Conservative approach: don't register if we can't determine the state
false
end
def phone_number_in_pending_state?
health_service = Whatsapp::HealthService.new(@channel)
health_data = health_service.fetch_health_status
# Check if phone number is in "not provisioned" state based on health indicators
# These conditions indicate the number is pending and needs registration:
# - platform_type: "NOT_APPLICABLE" means not fully set up
# - throughput.level: "NOT_APPLICABLE" means no messaging capacity assigned
health_data[:platform_type] == 'NOT_APPLICABLE' ||
health_data.dig(:throughput, :level) == 'NOT_APPLICABLE'
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Health status check failed: #{e.message}")
# If health check fails, assume registration is not needed to avoid errors
false false
end end
end end

View File

@@ -193,6 +193,7 @@ Rails.application.routes.draw do
post :set_agent_bot, on: :member post :set_agent_bot, on: :member
delete :avatar, on: :member delete :avatar, on: :member
post :sync_templates, on: :member post :sync_templates, on: :member
get :health, on: :member
end end
resources :inbox_members, only: [:create, :show], param: :inbox_id do resources :inbox_members, only: [:create, :show], param: :inbox_id do
collection do collection do

View File

@@ -980,4 +980,153 @@ RSpec.describe 'Inboxes API', type: :request do
end end
end end
end end
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/health' do
let(:whatsapp_channel) do
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false)
end
let(:whatsapp_inbox) { create(:inbox, account: account, channel: whatsapp_channel) }
let(:non_whatsapp_inbox) { create(:inbox, account: account) }
let(:health_service) { instance_double(Whatsapp::HealthService) }
let(:health_data) do
{
display_phone_number: '+1234567890',
verified_name: 'Test Business',
name_status: 'APPROVED',
quality_rating: 'GREEN',
messaging_limit_tier: 'TIER_1000',
account_mode: 'LIVE',
business_id: 'business123'
}
end
before do
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
allow(health_service).to receive(:fetch_health_status).and_return(health_data)
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
context 'with WhatsApp inbox' do
it 'returns health data for administrator' do
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response).to include(
'display_phone_number' => '+1234567890',
'verified_name' => 'Test Business',
'name_status' => 'APPROVED',
'quality_rating' => 'GREEN',
'messaging_limit_tier' => 'TIER_1000',
'account_mode' => 'LIVE',
'business_id' => 'business123'
)
end
it 'returns health data for agent with inbox access' do
create(:inbox_member, user: agent, inbox: whatsapp_inbox)
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['display_phone_number']).to eq('+1234567890')
end
it 'returns unauthorized for agent without inbox access' do
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'calls the health service with correct channel' do
expect(Whatsapp::HealthService).to receive(:new).with(whatsapp_channel).and_return(health_service)
expect(health_service).to receive(:fetch_health_status)
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
it 'handles service errors gracefully' do
allow(health_service).to receive(:fetch_health_status).and_raise(StandardError, 'API Error')
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['error']).to include('API Error')
end
end
context 'with non-WhatsApp inbox' do
it 'returns bad request error for administrator' do
get "/api/v1/accounts/#{account.id}/inboxes/#{non_whatsapp_inbox.id}/health",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:bad_request)
json_response = response.parsed_body
expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API channels')
end
it 'returns bad request error for agent' do
create(:inbox_member, user: agent, inbox: non_whatsapp_inbox)
get "/api/v1/accounts/#{account.id}/inboxes/#{non_whatsapp_inbox.id}/health",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:bad_request)
json_response = response.parsed_body
expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API channels')
end
end
context 'with WhatsApp non-cloud inbox' do
let(:whatsapp_default_channel) do
create(:channel_whatsapp, account: account, provider: 'default', sync_templates: false, validate_provider_config: false)
end
let(:whatsapp_default_inbox) { create(:inbox, account: account, channel: whatsapp_default_channel) }
it 'returns bad request error for non-cloud provider' do
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_default_inbox.id}/health",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:bad_request)
json_response = response.parsed_body
expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API channels')
end
end
context 'with non-existent inbox' do
it 'returns not found error' do
get "/api/v1/accounts/#{account.id}/inboxes/999999/health",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
end
end
end end

View File

@@ -48,6 +48,15 @@ describe Whatsapp::EmbeddedSignupService do
allow(channel_creation).to receive(:perform).and_return(channel) allow(channel_creation).to receive(:perform).and_return(channel)
allow(channel).to receive(:setup_webhooks) allow(channel).to receive(:setup_webhooks)
allow(channel).to receive(:phone_number).and_return('+1234567890')
health_service = instance_double(Whatsapp::HealthService)
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'CLOUD_API',
throughput: { 'level' => 'STANDARD' },
messaging_limit_tier: 'TIER_1000'
})
end end
it 'creates channel and sets up webhooks' do it 'creates channel and sets up webhooks' do
@@ -57,6 +66,49 @@ describe Whatsapp::EmbeddedSignupService do
expect(result).to eq(channel) expect(result).to eq(channel)
end end
it 'checks health status after channel creation' do
health_service = instance_double(Whatsapp::HealthService)
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
expect(health_service).to receive(:fetch_health_status)
service.perform
end
context 'when channel is in pending state' do
it 'prompts reauthorization for pending channel' do
health_service = instance_double(Whatsapp::HealthService)
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'NOT_APPLICABLE',
throughput: { 'level' => 'STANDARD' },
messaging_limit_tier: 'TIER_1000'
})
expect(channel).to receive(:prompt_reauthorization!)
service.perform
end
it 'prompts reauthorization when throughput level is NOT_APPLICABLE' do
health_service = instance_double(Whatsapp::HealthService)
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'CLOUD_API',
throughput: { 'level' => 'NOT_APPLICABLE' },
messaging_limit_tier: 'TIER_1000'
})
expect(channel).to receive(:prompt_reauthorization!)
service.perform
end
end
context 'when channel is healthy' do
it 'does not prompt reauthorization for healthy channel' do
expect(channel).not_to receive(:prompt_reauthorization!)
service.perform
end
end
context 'when parameters are invalid' do context 'when parameters are invalid' do
it 'raises ArgumentError for missing parameters' do it 'raises ArgumentError for missing parameters' do
invalid_service = described_class.new(account: account, params: { code: '', business_id: '', waba_id: '' }) invalid_service = described_class.new(account: account, params: { code: '', business_id: '', waba_id: '' })
@@ -114,6 +166,16 @@ describe Whatsapp::EmbeddedSignupService do
business_id: params[:business_id] business_id: params[:business_id]
).and_return(reauth_service) ).and_return(reauth_service)
allow(reauth_service).to receive(:perform).with(access_token, phone_info).and_return(channel) allow(reauth_service).to receive(:perform).with(access_token, phone_info).and_return(channel)
allow(channel).to receive(:phone_number).and_return('+1234567890')
health_service = instance_double(Whatsapp::HealthService)
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'CLOUD_API',
throughput: { 'level' => 'STANDARD' },
messaging_limit_tier: 'TIER_1000'
})
end end
it 'uses ReauthorizationService and sets up webhooks' do it 'uses ReauthorizationService and sets up webhooks' do
@@ -124,16 +186,32 @@ describe Whatsapp::EmbeddedSignupService do
expect(result).to eq(channel) expect(result).to eq(channel)
end end
it 'clears reauthorization flag' do context 'with real channel requiring reauthorization' do
inbox = create(:inbox, account: account) let(:inbox) { create(:inbox, account: account) }
whatsapp_channel = create(:channel_whatsapp, account: account, phone_number: '+1234567890', let(:whatsapp_channel) do
create(:channel_whatsapp, account: account, phone_number: '+1234567890',
validate_provider_config: false, sync_templates: false) validate_provider_config: false, sync_templates: false)
end
let(:service_with_real_inbox) { described_class.new(account: account, params: params, inbox_id: inbox.id) }
before do
inbox.update!(channel: whatsapp_channel) inbox.update!(channel: whatsapp_channel)
whatsapp_channel.prompt_reauthorization! whatsapp_channel.prompt_reauthorization!
service_with_real_inbox = described_class.new(account: account, params: params, inbox_id: inbox.id) setup_reauthorization_mocks
setup_health_service_mock
end
# Mock the ReauthorizationService to return our test channel it 'clears reauthorization flag when reauthorization completes' do
expect(whatsapp_channel.reauthorization_required?).to be true
result = service_with_real_inbox.perform
expect(result).to eq(whatsapp_channel)
expect(whatsapp_channel.reauthorization_required?).to be false
end
private
def setup_reauthorization_mocks
reauth_service = instance_double(Whatsapp::ReauthorizationService) reauth_service = instance_double(Whatsapp::ReauthorizationService)
allow(Whatsapp::ReauthorizationService).to receive(:new).with( allow(Whatsapp::ReauthorizationService).to receive(:new).with(
account: account, account: account,
@@ -142,18 +220,23 @@ describe Whatsapp::EmbeddedSignupService do
business_id: params[:business_id] business_id: params[:business_id]
).and_return(reauth_service) ).and_return(reauth_service)
# Perform the reauthorization and clear the flag
allow(reauth_service).to receive(:perform) do allow(reauth_service).to receive(:perform) do
whatsapp_channel.reauthorized! whatsapp_channel.reauthorized!
whatsapp_channel whatsapp_channel
end end
allow(whatsapp_channel).to receive(:setup_webhooks).and_return(true) allow(whatsapp_channel).to receive(:setup_webhooks).and_return(true)
end
expect(whatsapp_channel.reauthorization_required?).to be true def setup_health_service_mock
result = service_with_real_inbox.perform health_service = instance_double(Whatsapp::HealthService)
expect(result).to eq(whatsapp_channel) allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
expect(whatsapp_channel.reauthorization_required?).to be false allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'CLOUD_API',
throughput: { 'level' => 'STANDARD' },
messaging_limit_tier: 'TIER_1000'
})
end
end end
end end
end end

View File

@@ -16,6 +16,7 @@ describe Whatsapp::WebhookSetupService do
let(:access_token) { 'test_access_token' } let(:access_token) { 'test_access_token' }
let(:service) { described_class.new(channel, waba_id, access_token) } let(:service) { described_class.new(channel, waba_id, access_token) }
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) } let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
let(:health_service) { instance_double(Whatsapp::HealthService) }
before do before do
# Stub webhook teardown to prevent HTTP calls during cleanup # Stub webhook teardown to prevent HTTP calls during cleanup
@@ -24,8 +25,14 @@ describe Whatsapp::WebhookSetupService do
# Clean up any existing channels to avoid phone number conflicts # Clean up any existing channels to avoid phone number conflicts
Channel::Whatsapp.destroy_all Channel::Whatsapp.destroy_all
allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client) allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
# Default stub for phone_number_verified? with any argument allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
# Default stubs for phone_number_verified? and health service
allow(api_client).to receive(:phone_number_verified?).and_return(false) allow(api_client).to receive(:phone_number_verified?).and_return(false)
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'APPLICABLE',
throughput: { level: 'APPLICABLE' }
})
end end
describe '#perform' do describe '#perform' do
@@ -49,9 +56,13 @@ describe Whatsapp::WebhookSetupService do
end end
end end
context 'when phone number IS verified (should NOT register)' do context 'when phone number IS verified AND fully provisioned (should NOT register)' do
before do before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true) allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'APPLICABLE',
throughput: { level: 'APPLICABLE' }
})
allow(api_client).to receive(:subscribe_waba_webhook) allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true }) .with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
end end
@@ -66,16 +77,68 @@ describe Whatsapp::WebhookSetupService do
end end
end end
context 'when phone number IS verified BUT needs registration (pending provisioning)' do
before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'NOT_APPLICABLE',
throughput: { level: 'APPLICABLE' }
})
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
allow(channel).to receive(:save!)
end
it 'registers the phone number due to pending provisioning state' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
expect(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
service.perform
end
end
end
context 'when phone number needs registration due to throughput level' do
before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'APPLICABLE',
throughput: { level: 'NOT_APPLICABLE' }
})
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
allow(channel).to receive(:save!)
end
it 'registers the phone number due to throughput not applicable' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
expect(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
service.perform
end
end
end
context 'when phone_number_verified? raises error' do context 'when phone_number_verified? raises error' do
before do before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_raise('API down') allow(api_client).to receive(:phone_number_verified?).with('123456789').and_raise('API down')
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'APPLICABLE',
throughput: { level: 'APPLICABLE' }
})
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
allow(api_client).to receive(:register_phone_number) allow(api_client).to receive(:register_phone_number)
allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true }) allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true })
allow(channel).to receive(:save!) allow(channel).to receive(:save!)
end end
it 'tries to register phone and proceeds with webhook setup' do it 'tries to register phone (due to verification error) and proceeds with webhook setup' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(api_client).to receive(:register_phone_number) expect(api_client).to receive(:register_phone_number)
expect(api_client).to receive(:subscribe_waba_webhook) expect(api_client).to receive(:subscribe_waba_webhook)
@@ -84,6 +147,22 @@ describe Whatsapp::WebhookSetupService do
end end
end end
context 'when health service raises error' do
before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
allow(health_service).to receive(:fetch_health_status).and_raise('Health API down')
allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true })
end
it 'does not register phone (conservative approach) and proceeds with webhook setup' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(api_client).not_to receive(:register_phone_number)
expect(api_client).to receive(:subscribe_waba_webhook)
expect { service.perform }.not_to raise_error
end
end
end
context 'when phone registration fails (not blocking)' do context 'when phone registration fails (not blocking)' do
before do before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(false) allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(false)
@@ -193,6 +272,10 @@ describe Whatsapp::WebhookSetupService do
before do before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true) allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'APPLICABLE',
throughput: { level: 'APPLICABLE' }
})
allow(api_client).to receive(:subscribe_waba_webhook) allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'existing_verify_token').and_return({ 'success' => true }) .with(waba_id, anything, 'existing_verify_token').and_return({ 'success' => true })
end end
@@ -218,6 +301,10 @@ describe Whatsapp::WebhookSetupService do
context 'when webhook setup is successful in creation flow' do context 'when webhook setup is successful in creation flow' do
before do before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true) allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'APPLICABLE',
throughput: { level: 'APPLICABLE' }
})
allow(api_client).to receive(:subscribe_waba_webhook) allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true }) .with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
end end