Compare commits

...
18 changed files with 1173 additions and 163 deletions
@@ -17,8 +17,19 @@ module Api::V1::Accounts::Concerns::WhatsappHealthManagement
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
health_data = Whatsapp::HealthService.new(@inbox.channel).sync_health_status!
render json: health_data
rescue Whatsapp::HealthService::ApiError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: {
error: {
type: e.authorization_error? ? 'authorization' : 'api',
message: e.message,
http_status: e.http_status,
code: e.code,
subcode: e.subcode
}.compact
}, status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
@@ -686,40 +686,98 @@
"WIDGET_FEATURES": "Widget features",
"ACCOUNT_HEALTH": {
"TITLE": "Manage your WhatsApp account",
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
"DESCRIPTION": "Review your WhatsApp phone number health, messaging capacity, and linked Meta business details.",
"GO_TO_SETTINGS": "Go to Meta Business Manager",
"NO_DATA": "Health data is not available",
"ERRORS": {
"AUTHORIZATION": {
"EMBEDDED_TITLE": "WhatsApp connection needs to be refreshed",
"EMBEDDED_DESCRIPTION": "Meta could not validate the connection for this inbox. Go to Configuration to reconfigure it using Embedded Signup.",
"MANUAL_TITLE": "WhatsApp access token needs attention",
"MANUAL_DESCRIPTION": "Meta could not validate the access token for this inbox. Go to Configuration to verify or replace it."
},
"GENERIC_TITLE": "WhatsApp health data is unavailable",
"GENERIC_DESCRIPTION": "Meta could not return the current health details. Review the error below and try again later.",
"GO_TO_CONFIGURATION": "Go to Configuration"
},
"SECTIONS": {
"PHONE_NUMBER": "Phone number",
"HEALTH_AND_CAPACITY": "Health and capacity",
"BUSINESS_ACCOUNT": "Business account"
},
"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"
"LABEL": "WhatsApp display name",
"TOOLTIP": "Display name associated with this phone number on WhatsApp"
},
"PHONE_NUMBER_ID": {
"LABEL": "Phone number ID",
"TOOLTIP": "Meta identifier for this WhatsApp phone number"
},
"COEXISTENCE": {
"LABEL": "Coexistence",
"TOOLTIP": "This number is connected to both the WhatsApp Business app and Cloud API"
},
"DISPLAY_NAME_STATUS": {
"LABEL": "Display name status",
"TOOLTIP": "Status of your business name verification"
"TOOLTIP": "Review status of the WhatsApp display name"
},
"LAST_ONBOARDED_TIME": {
"LABEL": "Last onboarded",
"TOOLTIP": "Most recent time Meta onboarded this phone number"
},
"PHONE_NUMBER_STATUS": {
"LABEL": "Phone number status",
"TOOLTIP": "Current connection status of this phone number on WhatsApp"
},
"QUALITY_RATING": {
"LABEL": "Quality rating",
"TOOLTIP": "WhatsApp quality rating for your account"
"TOOLTIP": "Quality rating Meta assigns to this phone number based on recent messaging signals"
},
"MESSAGING_LIMIT_TIER": {
"LABEL": "Messaging limit tier",
"TOOLTIP": "Daily messaging limit for your account"
"TOOLTIP": "Number of customers this phone number can initiate conversations with in 24 hours"
},
"ACCOUNT_MODE": {
"LABEL": "Account mode",
"TOOLTIP": "Current operating mode of your WhatsApp account"
"TOOLTIP": "Current operating mode of this WhatsApp phone number"
},
"CODE_VERIFICATION_STATUS": {
"LABEL": "Code verification",
"TOOLTIP": "Verification-code status reported by Meta for this phone number"
},
"THROUGHPUT_LEVEL": {
"LABEL": "Throughput",
"TOOLTIP": "Message throughput level assigned to this phone number"
},
"BUSINESS_ACCOUNT_NAME": {
"LABEL": "WhatsApp Business Account name",
"TOOLTIP": "Name of the WhatsApp Business Account linked to this phone number"
},
"BUSINESS_ACCOUNT_ID": {
"LABEL": "WhatsApp Business Account ID",
"TOOLTIP": "Meta identifier for the linked WhatsApp Business Account"
},
"BUSINESS_PORTFOLIO_NAME": {
"LABEL": "Business portfolio name",
"TOOLTIP": "Name of the Meta business portfolio that owns the WhatsApp Business Account"
},
"BUSINESS_PORTFOLIO_ID": {
"LABEL": "Business portfolio ID",
"TOOLTIP": "Meta identifier for the business portfolio that owns the WhatsApp Business Account"
}
},
"VALUES": {
"NOT_AVAILABLE": "Not available",
"TIERS": {
"TIER_250": "250 customers per 24h",
"TIER_1000": "1K customers per 24h",
"TIER_1K": "1K customers per 24h",
"TIER_2K": "2K customers per 24h",
"TIER_10K": "10K customers per 24h",
"TIER_100K": "100K customers per 24h",
"TIER_UNLIMITED": "Unlimited customers per 24h",
@@ -727,6 +785,16 @@
},
"STATUSES": {
"APPROVED": "Approved",
"CONNECTED": "Connected",
"VERIFIED": "Verified",
"STANDARD": "Standard",
"ACTIVE": "Active",
"EXPIRED": "Expired",
"FLAGGED": "Flagged",
"RESTRICTED": "Restricted",
"BANNED": "Banned",
"DISCONNECTED": "Disconnected",
"UNKNOWN": "Not available",
"PENDING_REVIEW": "Pending Review",
"AVAILABLE_WITHOUT_REVIEW": "Available Without Review",
"REJECTED": "Rejected",
@@ -746,7 +814,11 @@
"REGISTER_SUCCESS": "Webhook registered successfully",
"REGISTER_ERROR": "Failed to register webhook. Please try again.",
"CONFIGURED_SUCCESS": "Webhook configured successfully",
"URL_MISMATCH": "Webhook URL mismatch"
"URL_MISMATCH": "Webhook URL mismatch",
"CONFIGURED_URL": "Configured URL",
"EXPECTED_URL": "Expected URL",
"COPY_URL": "Copy webhook URL",
"COPY_SUCCESS": "Webhook URL copied to clipboard"
}
},
"SETTINGS": "Settings",
@@ -416,6 +416,7 @@ export default {
return (
this.isAWhatsAppCloudChannel &&
this.isEmbeddedSignupWhatsApp &&
this.healthError?.type !== 'authorization' &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.WHATSAPP_MANUAL_TRANSFER
@@ -585,11 +586,26 @@ export default {
const response = await InboxHealthAPI.getHealthStatus(this.inbox.id);
this.healthData = response.data;
} catch (error) {
this.healthError = error.message || 'Failed to fetch health data';
const apiError = error.response?.data?.error;
this.healthError =
typeof apiError === 'object'
? apiError
: {
type: 'generic',
message: apiError || error.message,
};
} finally {
this.isLoadingHealth = false;
}
},
goToWhatsAppConfiguration() {
const configurationTabIndex = this.tabs.findIndex(
tab => tab.key === 'configuration'
);
if (configurationTabIndex !== -1) {
this.onTabChange(configurationTabIndex);
}
},
async registerWebhook() {
if (!this.inbox) return;
@@ -1407,8 +1423,11 @@ export default {
<div v-if="selectedTabKey === 'whatsapp-health'">
<AccountHealth
:health-data="healthData"
:health-error="healthError"
:is-embedded-signup="isEmbeddedSignupWhatsApp"
:is-registering-webhook="isRegisteringWebhook"
@register-webhook="registerWebhook"
@go-to-configuration="goToWhatsAppConfiguration"
/>
</div>
<WhatsappManualMigrationDialog
@@ -1,24 +1,34 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import ButtonV4 from 'next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useAlert } from 'dashboard/composables';
const props = defineProps({
healthData: {
type: Object,
default: null,
},
healthError: {
type: Object,
default: null,
},
isEmbeddedSignup: {
type: Boolean,
default: false,
},
isRegisteringWebhook: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['registerWebhook']);
const emit = defineEmits(['registerWebhook', 'goToConfiguration']);
const { t } = useI18n();
const { t, te, locale } = useI18n();
const QUALITY_COLORS = {
GREEN: 'text-n-teal-11',
@@ -29,8 +39,17 @@ const QUALITY_COLORS = {
const STATUS_COLORS = {
APPROVED: 'text-n-teal-11',
CONNECTED: 'text-n-teal-11',
VERIFIED: 'text-n-teal-11',
STANDARD: 'text-n-teal-11',
ACTIVE: 'text-n-teal-11',
PENDING_REVIEW: 'text-n-amber-11',
AVAILABLE_WITHOUT_REVIEW: 'text-n-teal-11',
EXPIRED: 'text-n-amber-11',
FLAGGED: 'text-n-amber-11',
RESTRICTED: 'text-n-ruby-9',
BANNED: 'text-n-ruby-9',
DISCONNECTED: 'text-n-ruby-9',
REJECTED: 'text-n-ruby-9',
DECLINED: 'text-n-ruby-9',
};
@@ -40,85 +59,274 @@ const MODE_COLORS = {
SANDBOX: 'text-n-slate-11',
};
const healthItems = computed(() => {
const formatStatusDisplay = status => {
const translationKey = `INBOX_MGMT.ACCOUNT_HEALTH.VALUES.STATUSES.${status}`;
if (te(translationKey)) {
return t(translationKey);
}
return status
.toLowerCase()
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
const formatDateTime = value => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(locale.value, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(date);
};
const healthSections = computed(() => {
if (!props.healthData) {
return [];
}
const {
id: phoneNumberId,
display_phone_number: displayPhoneNumber,
verified_name: verifiedName,
name_status: nameStatus,
quality_rating: qualityRating,
messaging_limit_tier: messagingLimitTier,
account_mode: accountMode,
status: phoneNumberStatus,
code_verification_status: codeVerificationStatus,
throughput_level: throughputLevel,
last_onboarded_time: lastOnboardedTime,
is_on_biz_app: isOnBizApp,
platform_type: platformType,
business_account_id: businessAccountId,
business_account_name: businessAccountName,
business_portfolio_id: businessPortfolioId,
business_portfolio_name: businessPortfolioName,
} = props.healthData;
const notAvailable = t('INBOX_MGMT.ACCOUNT_HEALTH.VALUES.NOT_AVAILABLE');
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: 'phoneNumber',
title: t('INBOX_MGMT.ACCOUNT_HEALTH.SECTIONS.PHONE_NUMBER'),
items: [
{
key: 'displayPhoneNumber',
label: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.DISPLAY_PHONE_NUMBER.LABEL'
),
value: displayPhoneNumber || notAvailable,
tooltip: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.DISPLAY_PHONE_NUMBER.TOOLTIP'
),
},
{
key: 'verifiedName',
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.VERIFIED_NAME.LABEL'),
value: verifiedName || notAvailable,
tooltip: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.VERIFIED_NAME.TOOLTIP'),
},
{
key: 'phoneNumberId',
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.PHONE_NUMBER_ID.LABEL'),
value: phoneNumberId || notAvailable,
tooltip: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.PHONE_NUMBER_ID.TOOLTIP'
),
},
{
key: 'coexistence',
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.COEXISTENCE.LABEL'),
value: 'ACTIVE',
tooltip: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.COEXISTENCE.TOOLTIP'),
type: 'status',
show: isOnBizApp === true && platformType === 'CLOUD_API',
},
{
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'
),
type: 'status',
},
{
key: 'lastOnboardedTime',
label: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.LAST_ONBOARDED_TIME.LABEL'
),
value: lastOnboardedTime
? formatDateTime(lastOnboardedTime)
: notAvailable,
tooltip: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.LAST_ONBOARDED_TIME.TOOLTIP'
),
show: Boolean(lastOnboardedTime),
},
],
},
{
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: 'healthAndCapacity',
title: t('INBOX_MGMT.ACCOUNT_HEALTH.SECTIONS.HEALTH_AND_CAPACITY'),
items: [
{
key: 'phoneNumberStatus',
label: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.PHONE_NUMBER_STATUS.LABEL'
),
value: phoneNumberStatus || 'UNKNOWN',
tooltip: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.PHONE_NUMBER_STATUS.TOOLTIP'
),
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'),
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'
),
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'),
type: 'mode',
},
{
key: 'codeVerificationStatus',
label: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.CODE_VERIFICATION_STATUS.LABEL'
),
value: codeVerificationStatus || 'UNKNOWN',
tooltip: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.CODE_VERIFICATION_STATUS.TOOLTIP'
),
type: 'status',
},
{
key: 'throughputLevel',
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.THROUGHPUT_LEVEL.LABEL'),
value: throughputLevel || 'UNKNOWN',
tooltip: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.THROUGHPUT_LEVEL.TOOLTIP'
),
type: 'status',
},
],
},
{
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: 'businessAccount',
title: t('INBOX_MGMT.ACCOUNT_HEALTH.SECTIONS.BUSINESS_ACCOUNT'),
items: [
{
key: 'businessAccountName',
label: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.BUSINESS_ACCOUNT_NAME.LABEL'
),
value: businessAccountName || notAvailable,
tooltip: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.BUSINESS_ACCOUNT_NAME.TOOLTIP'
),
},
{
key: 'businessAccountId',
label: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.BUSINESS_ACCOUNT_ID.LABEL'
),
value: businessAccountId || notAvailable,
tooltip: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.BUSINESS_ACCOUNT_ID.TOOLTIP'
),
},
{
key: 'businessPortfolioName',
label: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.BUSINESS_PORTFOLIO_NAME.LABEL'
),
value: businessPortfolioName || notAvailable,
tooltip: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.BUSINESS_PORTFOLIO_NAME.TOOLTIP'
),
},
{
key: 'businessPortfolioId',
label: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.BUSINESS_PORTFOLIO_ID.LABEL'
),
value: businessPortfolioId || notAvailable,
tooltip: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.BUSINESS_PORTFOLIO_ID.TOOLTIP'
),
},
],
},
{
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',
},
];
].map(section => ({
...section,
items: section.items.filter(item => item.show !== false),
}));
});
const errorState = computed(() => {
if (!props.healthError) return null;
if (props.healthError.type === 'authorization') {
return {
title: props.isEmbeddedSignup
? t('INBOX_MGMT.ACCOUNT_HEALTH.ERRORS.AUTHORIZATION.EMBEDDED_TITLE')
: t('INBOX_MGMT.ACCOUNT_HEALTH.ERRORS.AUTHORIZATION.MANUAL_TITLE'),
description: props.isEmbeddedSignup
? t(
'INBOX_MGMT.ACCOUNT_HEALTH.ERRORS.AUTHORIZATION.EMBEDDED_DESCRIPTION'
)
: t(
'INBOX_MGMT.ACCOUNT_HEALTH.ERRORS.AUTHORIZATION.MANUAL_DESCRIPTION'
),
showConfigurationAction: true,
};
}
return {
title: t('INBOX_MGMT.ACCOUNT_HEALTH.ERRORS.GENERIC_TITLE'),
description: t('INBOX_MGMT.ACCOUNT_HEALTH.ERRORS.GENERIC_DESCRIPTION'),
showConfigurationAction: false,
};
});
const handleGoToSettings = () => {
const { business_id: businessId } = props.healthData || {};
const {
business_account_id: businessAccountId,
business_portfolio_id: businessPortfolioId,
} = 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');
if (businessPortfolioId && businessAccountId) {
const whatsappBusinessUrl = new URL(
'https://business.facebook.com/latest/whatsapp_manager/phone_numbers/'
);
whatsappBusinessUrl.searchParams.set('business_id', businessPortfolioId);
whatsappBusinessUrl.searchParams.set('asset_id', businessAccountId);
window.open(whatsappBusinessUrl.toString(), '_blank');
} else {
// Fallback to general WhatsApp Business Manager if business_id is not available
const fallbackUrl = 'https://business.facebook.com/';
window.open(fallbackUrl, '_blank');
}
@@ -127,14 +335,17 @@ const handleGoToSettings = () => {
const getQualityRatingTextColor = rating =>
QUALITY_COLORS[rating] || QUALITY_COLORS.UNKNOWN;
const formatTierDisplay = tier =>
t(`INBOX_MGMT.ACCOUNT_HEALTH.VALUES.TIERS.${tier}`) || tier;
const formatTierDisplay = tier => {
const translationKey = `INBOX_MGMT.ACCOUNT_HEALTH.VALUES.TIERS.${tier}`;
const formatStatusDisplay = status =>
t(`INBOX_MGMT.ACCOUNT_HEALTH.VALUES.STATUSES.${status}`) || status;
return te(translationKey) ? t(translationKey) : formatStatusDisplay(tier);
};
const formatModeDisplay = mode =>
t(`INBOX_MGMT.ACCOUNT_HEALTH.VALUES.MODES.${mode}`) || mode;
const formatModeDisplay = mode => {
const translationKey = `INBOX_MGMT.ACCOUNT_HEALTH.VALUES.MODES.${mode}`;
return te(translationKey) ? t(translationKey) : formatStatusDisplay(mode);
};
const getModeStatusTextColor = mode => MODE_COLORS[mode] || 'text-n-slate-12';
@@ -163,6 +374,15 @@ const webhookUrlMismatch = computed(
const handleRegisterWebhook = () => {
emit('registerWebhook');
};
const handleGoToConfiguration = () => {
emit('goToConfiguration');
};
const handleCopyWebhookUrl = async url => {
await copyTextToClipboard(url);
useAlert(t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.COPY_SUCCESS'));
};
</script>
<template>
@@ -192,55 +412,62 @@ const handleRegisterWebhook = () => {
</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-body-main 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 v-if="healthData && !errorState" class="space-y-6">
<section v-for="section in healthSections" :key="section.key">
<h3 class="mb-3 text-heading-4 text-n-slate-12">
{{ section.title }}
</h3>
<div class="grid grid-cols-1 gap-4 xs:grid-cols-2">
<div
v-for="item in section.items"
: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-body-main 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-label-small 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-label-small 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-label-small rounded-md bg-n-alpha-2"
:class="getModeStatusTextColor(item.value)"
>
{{ formatModeDisplay(item.value) }}
</span>
<span
v-else-if="item.type === 'tier'"
class="text-label text-n-slate-12"
>
{{ formatTierDisplay(item.value) }}
</span>
<span v-else class="text-label text-n-slate-12">
{{ item.value }}
</span>
</div>
</div>
</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-label-small 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-label-small 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-label-small rounded-md bg-n-alpha-2"
:class="getModeStatusTextColor(item.value)"
>
{{ formatModeDisplay(item.value) }}
</span>
<span
v-else-if="item.type === 'tier'"
class="text-label text-n-slate-12"
>
{{ formatTierDisplay(item.value) }}
</span>
<span v-else class="text-label text-n-slate-12">{{
item.value
}}</span>
</div>
</div>
</section>
<!-- Webhook configuration card -->
<div
@@ -289,6 +516,87 @@ const handleRegisterWebhook = () => {
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.REGISTER_BUTTON') }}
</ButtonV4>
</div>
<div v-if="webhookConfigured" class="pt-1 space-y-2">
<div class="flex items-center gap-3 min-w-0">
<span class="flex-shrink-0 text-label-small text-n-slate-10">
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.CONFIGURED_URL') }}
</span>
<span
v-tooltip.top="webhookUrl"
class="flex-1 min-w-0 font-mono text-label-small truncate text-n-slate-12"
>
{{ webhookUrl }}
</span>
<ButtonV4
v-tooltip.top="t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.COPY_URL')"
type="button"
ghost
sm
slate
icon="i-lucide-copy"
class="flex-shrink-0"
:aria-label="t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.COPY_URL')"
@click="handleCopyWebhookUrl(webhookUrl)"
/>
</div>
<div
v-if="webhookUrlMismatch"
class="flex items-center gap-3 min-w-0"
>
<span class="flex-shrink-0 text-label-small text-n-slate-10">
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.EXPECTED_URL') }}
</span>
<span
v-tooltip.top="healthData.expected_webhook_url"
class="flex-1 min-w-0 font-mono text-label-small truncate text-n-slate-12"
>
{{ healthData.expected_webhook_url }}
</span>
<ButtonV4
v-tooltip.top="t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.COPY_URL')"
type="button"
ghost
sm
slate
icon="i-lucide-copy"
class="flex-shrink-0"
:aria-label="t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.COPY_URL')"
@click="handleCopyWebhookUrl(healthData.expected_webhook_url)"
/>
</div>
</div>
</div>
</div>
<div v-else-if="errorState" class="pt-2">
<div
class="flex flex-col gap-4 p-5 rounded-lg border border-n-weak bg-n-solid-1"
>
<div class="flex gap-3 items-start">
<Icon
icon="i-lucide-alert-triangle"
class="flex-shrink-0 mt-0.5 w-5 h-5 text-n-amber-11"
/>
<div class="min-w-0">
<h3 class="text-heading-4 text-n-slate-12">
{{ errorState.title }}
</h3>
<p class="mt-1 text-body-main text-n-slate-11">
{{ errorState.description }}
</p>
</div>
</div>
<div
v-if="healthError.message"
class="p-3 rounded-lg bg-n-alpha-1 text-label-small text-n-slate-11"
>
<p class="break-words">{{ healthError.message }}</p>
</div>
<div v-if="errorState.showConfigurationAction">
<ButtonV4 sm solid blue @click="handleGoToConfiguration">
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.ERRORS.GO_TO_CONFIGURATION') }}
</ButtonV4>
</div>
</div>
</div>
@@ -0,0 +1,85 @@
import { shallowMount } from '@vue/test-utils';
import ButtonV4 from 'next/button/Button.vue';
import AccountHealth from '../AccountHealth.vue';
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: key => key,
te: () => false,
locale: { value: 'en' },
}),
}));
describe('AccountHealth', () => {
const mountComponent = (healthData, props = {}) =>
shallowMount(AccountHealth, {
props: { healthData, ...props },
});
beforeEach(() => {
vi.spyOn(window, 'open').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('opens the phone numbers page for the correct WhatsApp Business Account', async () => {
const wrapper = mountComponent({
business_account_id: 'waba-456',
business_portfolio_id: 'business-123',
});
await wrapper.findComponent(ButtonV4).trigger('click');
expect(window.open).toHaveBeenCalledWith(
'https://business.facebook.com/latest/whatsapp_manager/phone_numbers/?business_id=business-123&asset_id=waba-456',
'_blank'
);
});
it('opens Meta Business Manager when the WhatsApp Business Account ID is unavailable', async () => {
const wrapper = mountComponent({
business_portfolio_id: 'business-123',
});
await wrapper.findComponent(ButtonV4).trigger('click');
expect(window.open).toHaveBeenCalledWith(
'https://business.facebook.com/',
'_blank'
);
});
it('formats unknown messaging tiers and account modes without exposing translation keys', () => {
const wrapper = mountComponent({
messaging_limit_tier: 'TIER_CUSTOM',
account_mode: 'CUSTOM_MODE',
});
expect(wrapper.text()).toContain('Tier Custom');
expect(wrapper.text()).toContain('Custom Mode');
expect(wrapper.text()).not.toContain(
'INBOX_MGMT.ACCOUNT_HEALTH.VALUES.TIERS.TIER_CUSTOM'
);
expect(wrapper.text()).not.toContain(
'INBOX_MGMT.ACCOUNT_HEALTH.VALUES.MODES.CUSTOM_MODE'
);
});
it('shows the current error instead of stale health data', () => {
const wrapper = mountComponent(
{ verified_name: 'Stale Business Name' },
{
healthError: {
type: 'authorization',
message: 'The connection needs to be refreshed',
},
isEmbeddedSignup: true,
}
);
expect(wrapper.text()).toContain('The connection needs to be refreshed');
expect(wrapper.text()).not.toContain('Stale Business Name');
});
});
@@ -0,0 +1,9 @@
class Channels::Whatsapp::HealthSyncJob < ApplicationJob
queue_as :low
def perform(whatsapp_channel)
Whatsapp::HealthService.new(whatsapp_channel).sync_health_status!
rescue Whatsapp::HealthService::ApiError, ArgumentError
nil
end
end
@@ -0,0 +1,15 @@
class Channels::Whatsapp::HealthSyncSchedulerJob < ApplicationJob
queue_as :low
def perform
Channel::Whatsapp.joins(:account)
.merge(Account.active)
.where(provider: 'whatsapp_cloud')
.where('phone_number_health_checked_at <= ? OR phone_number_health_checked_at IS NULL', 6.hours.ago)
.order(Arel.sql('phone_number_health_checked_at IS NULL DESC, phone_number_health_checked_at ASC'))
.limit(Limits::BULK_EXTERNAL_HTTP_CALLS_LIMIT)
.each do |channel|
Channels::Whatsapp::HealthSyncJob.perform_later(channel)
end
end
end
+3
View File
@@ -19,6 +19,9 @@ class TriggerScheduledItemsJob < ApplicationJob
# Job to sync whatsapp templates
Channels::Whatsapp::TemplatesSyncSchedulerJob.perform_later
# Job to sync whatsapp phone number health
Channels::Whatsapp::HealthSyncSchedulerJob.perform_later
end
end
+5 -1
View File
@@ -6,6 +6,9 @@
# message_templates :jsonb
# message_templates_last_updated :datetime
# phone_number :string not null
# phone_number_health :jsonb not null
# phone_number_health_checked_at :datetime
# phone_number_health_error :string
# provider :string default("default")
# provider_config :jsonb
# created_at :datetime not null
@@ -14,7 +17,8 @@
#
# Indexes
#
# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
# index_channel_whatsapp_on_phone_number_health_checked_at (phone_number_health_checked_at)
#
class Channel::Whatsapp < ApplicationRecord
+154 -34
View File
@@ -1,5 +1,41 @@
class Whatsapp::HealthService
class ApiError < StandardError
attr_reader :http_status, :code, :subcode
def initialize(message:, http_status:, code: nil, subcode: nil)
super(message)
@http_status = http_status
@code = code
@subcode = subcode
end
def authorization_error?
code.to_i == 190
end
end
BASE_URI = 'https://graph.facebook.com'.freeze
PERSISTED_FIELDS = %i[
id
display_phone_number
verified_name
name_status
quality_rating
messaging_limit_tier
status
account_mode
code_verification_status
throughput_level
last_onboarded_time
is_on_biz_app
platform_type
business_account_id
business_account_name
business_portfolio_id
business_portfolio_name
].freeze
RISKY_QUALITY_RATINGS = %w[YELLOW RED].freeze
RISKY_STATUSES = %w[BANNED RESTRICTED RATE_LIMITED FLAGGED DISCONNECTED DELETED].freeze
def initialize(channel)
@channel = channel
@@ -9,7 +45,25 @@ class Whatsapp::HealthService
def fetch_health_status
validate_channel!
fetch_phone_health_data
phone_health_data = fetch_graph_data(@channel.provider_config['phone_number_id'], phone_health_fields)
business_account_data = fetch_graph_data(@channel.provider_config['business_account_id'], business_account_fields)
format_phone_health_response(phone_health_data).merge(format_business_account_response(business_account_data))
end
def sync_health_status!
health_status = fetch_health_status
checked_at = Time.current
previous_health = @channel.phone_number_health
persist_health_status(health_status, checked_at)
log_risky_transition(previous_health, health_status)
health_status.merge(health_checked_at: checked_at)
rescue StandardError => e
persist_health_error(e)
raise
end
private
@@ -18,15 +72,14 @@ class Whatsapp::HealthService
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?
raise ArgumentError, 'Business account ID is missing' if @channel.provider_config['business_account_id'].blank?
end
def fetch_phone_health_data
phone_number_id = @channel.provider_config['phone_number_id']
def fetch_graph_data(resource_id, fields)
response = HTTParty.get(
"#{BASE_URI}/#{@api_version}/#{phone_number_id}",
"#{BASE_URI}/#{@api_version}/#{resource_id}",
query: {
fields: health_fields,
fields: fields,
access_token: @access_token
}
)
@@ -34,14 +87,15 @@ class Whatsapp::HealthService
handle_response(response)
rescue StandardError => e
Rails.logger.error "[WHATSAPP HEALTH] Error fetching health data: #{e.message}"
raise e
raise
end
def health_fields
def phone_health_fields
%w[
id
quality_rating
messaging_limit_tier
whatsapp_business_manager_messaging_limit
status
code_verification_status
account_mode
display_phone_number
@@ -50,39 +104,63 @@ class Whatsapp::HealthService
webhook_configuration
throughput
last_onboarded_time
is_on_biz_app
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)
def business_account_fields
%w[id name owner_business_info].join(',')
end
def format_health_response(response)
def handle_response(response)
return response.parsed_response if response.success?
parsed_response = response.parsed_response
error_data = parsed_response.is_a?(Hash) ? parsed_response['error'].to_h : {}
error = ApiError.new(
message: error_data['message'].presence || 'WhatsApp API request failed',
http_status: response.code,
code: error_data['code'],
subcode: error_data['error_subcode']
)
Rails.logger.error(
"[WHATSAPP HEALTH] WhatsApp API request failed: http_status=#{error.http_status} " \
"code=#{error.code} subcode=#{error.subcode} message=#{error.message}"
)
raise error
end
def format_phone_health_response(phone_response)
{
id: response['id'],
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'],
webhook_configuration: response['webhook_configuration'],
id: phone_response['id'],
display_phone_number: phone_response['display_phone_number'],
verified_name: phone_response['verified_name'],
name_status: phone_response['name_status'],
quality_rating: phone_response['quality_rating'],
messaging_limit_tier: phone_response['whatsapp_business_manager_messaging_limit'],
status: phone_response['status'],
account_mode: phone_response['account_mode'],
code_verification_status: phone_response['code_verification_status'],
webhook_configuration: phone_response['webhook_configuration'],
expected_webhook_url: build_expected_webhook_url,
throughput: response['throughput'],
last_onboarded_time: response['last_onboarded_time'],
platform_type: response['platform_type'],
certificate: response['certificate'],
business_id: @channel.provider_config['business_account_id']
throughput: phone_response['throughput'],
throughput_level: phone_response.dig('throughput', 'level'),
last_onboarded_time: phone_response['last_onboarded_time'],
is_on_biz_app: phone_response['is_on_biz_app'],
platform_type: phone_response['platform_type']
}
end
def format_business_account_response(business_account_response)
owner_business_info = business_account_response['owner_business_info'] || {}
{
business_account_id: business_account_response['id'],
business_account_name: business_account_response['name'],
business_portfolio_id: owner_business_info['id'],
business_portfolio_name: owner_business_info['name']
}
end
@@ -92,4 +170,46 @@ class Whatsapp::HealthService
"#{frontend_url}/webhooks/whatsapp/#{@channel.phone_number}"
end
def persist_health_status(health_status, checked_at)
# Health polling should not touch the inbox or run channel credential validation.
# rubocop:disable Rails/SkipsModelValidations
@channel.update_columns(
phone_number_health: health_status.slice(*PERSISTED_FIELDS),
phone_number_health_checked_at: checked_at,
phone_number_health_error: nil
)
# rubocop:enable Rails/SkipsModelValidations
end
def persist_health_error(error)
return unless @channel&.persisted?
# rubocop:disable Rails/SkipsModelValidations
@channel.update_columns(
phone_number_health_checked_at: Time.current,
phone_number_health_error: error.message.truncate(500)
)
# rubocop:enable Rails/SkipsModelValidations
end
def log_risky_transition(previous_health, health_status)
return unless risky_health?(health_status)
return if risk_signature(previous_health) == risk_signature(health_status)
Rails.logger.warn(
'[WHATSAPP HEALTH] risky_phone_number ' \
"account_id=#{@channel.account_id} inbox_id=#{@channel.inbox&.id} channel_id=#{@channel.id} " \
"phone_number_id=#{health_status[:id]} quality_rating=#{health_status[:quality_rating]} " \
"status=#{health_status[:status]} messaging_limit_tier=#{health_status[:messaging_limit_tier]}"
)
end
def risky_health?(health_status)
RISKY_QUALITY_RATINGS.include?(health_status[:quality_rating]) || RISKY_STATUSES.include?(health_status[:status])
end
def risk_signature(health_status)
health_status.to_h.with_indifferent_access.values_at(:quality_rating, :status, :messaging_limit_tier)
end
end
@@ -0,0 +1,8 @@
class AddPhoneNumberHealthToChannelWhatsapp < ActiveRecord::Migration[7.1]
def change
add_column :channel_whatsapp, :phone_number_health, :jsonb, default: {}, null: false
add_column :channel_whatsapp, :phone_number_health_checked_at, :datetime
add_column :channel_whatsapp, :phone_number_health_error, :string, limit: 500
add_index :channel_whatsapp, :phone_number_health_checked_at
end
end
+5 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
ActiveRecord::Schema[7.1].define(version: 2026_07_18_000000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -683,6 +683,10 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
t.datetime "updated_at", null: false
t.jsonb "message_templates", default: {}
t.datetime "message_templates_last_updated", precision: nil
t.jsonb "phone_number_health", default: {}, null: false
t.datetime "phone_number_health_checked_at"
t.string "phone_number_health_error", limit: 500
t.index ["phone_number_health_checked_at"], name: "index_channel_whatsapp_on_phone_number_health_checked_at"
t.index ["phone_number"], name: "index_channel_whatsapp_on_phone_number", unique: true
end
@@ -1321,19 +1321,24 @@ RSpec.describe 'Inboxes API', type: :request do
let(:health_service) { instance_double(Whatsapp::HealthService) }
let(:health_data) do
{
id: 'phone123',
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'
status: 'CONNECTED',
business_account_id: 'waba123',
business_account_name: 'Test WABA',
business_portfolio_id: 'business123',
business_portfolio_name: 'Test Business Portfolio'
}
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)
allow(health_service).to receive(:sync_health_status!).and_return(health_data)
end
context 'when it is an unauthenticated user' do
@@ -1354,13 +1359,18 @@ RSpec.describe 'Inboxes API', type: :request do
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response).to include(
'id' => 'phone123',
'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'
'status' => 'CONNECTED',
'business_account_id' => 'waba123',
'business_account_name' => 'Test WABA',
'business_portfolio_id' => 'business123',
'business_portfolio_name' => 'Test Business Portfolio'
)
end
@@ -1386,7 +1396,7 @@ RSpec.describe 'Inboxes API', type: :request do
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)
expect(health_service).to receive(:sync_health_status!)
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health",
headers: admin.create_new_auth_token,
@@ -1396,7 +1406,7 @@ RSpec.describe 'Inboxes API', type: :request do
end
it 'handles service errors gracefully' do
allow(health_service).to receive(:fetch_health_status).and_raise(StandardError, 'API Error')
allow(health_service).to receive(:sync_health_status!).and_raise(StandardError, 'API Error')
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health",
headers: admin.create_new_auth_token,
@@ -1406,6 +1416,29 @@ RSpec.describe 'Inboxes API', type: :request do
json_response = response.parsed_body
expect(json_response['error']).to include('API Error')
end
it 'classifies Meta authorization failures for the recovery UI' do
error = Whatsapp::HealthService::ApiError.new(
message: 'The access token cannot authorize this request.',
http_status: 400,
code: 190,
subcode: 464
)
allow(health_service).to receive(:sync_health_status!).and_raise(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)
expect(response.parsed_body['error']).to eq(
'type' => 'authorization',
'message' => 'The access token cannot authorize this request.',
'http_status' => 400,
'code' => 190,
'subcode' => 464
)
end
end
context 'with non-WhatsApp inbox' do
@@ -0,0 +1,48 @@
require 'rails_helper'
RSpec.describe Channels::Whatsapp::HealthSyncJob do
let(:whatsapp_channel) do
create(
:channel_whatsapp,
provider: 'whatsapp_cloud',
sync_templates: false,
validate_provider_config: false
)
end
let(:health_service) { instance_double(Whatsapp::HealthService, sync_health_status!: true) }
it 'enqueues on the low priority queue' do
expect { described_class.perform_later(whatsapp_channel) }
.to have_enqueued_job(described_class).with(whatsapp_channel).on_queue('low')
end
it 'synchronizes the channel health status' do
allow(Whatsapp::HealthService).to receive(:new).with(whatsapp_channel).and_return(health_service)
described_class.perform_now(whatsapp_channel)
expect(health_service).to have_received(:sync_health_status!)
end
it 'does not retry recorded API failures' do
error = Whatsapp::HealthService::ApiError.new(message: 'Request failed', http_status: 400)
allow(Whatsapp::HealthService).to receive(:new).with(whatsapp_channel).and_return(health_service)
allow(health_service).to receive(:sync_health_status!).and_raise(error)
expect { described_class.perform_now(whatsapp_channel) }.not_to raise_error
end
it 'does not retry recorded configuration failures' do
allow(Whatsapp::HealthService).to receive(:new).with(whatsapp_channel).and_return(health_service)
allow(health_service).to receive(:sync_health_status!).and_raise(ArgumentError, 'API key is missing')
expect { described_class.perform_now(whatsapp_channel) }.not_to raise_error
end
it 'retries unexpected failures' do
allow(Whatsapp::HealthService).to receive(:new).with(whatsapp_channel).and_return(health_service)
allow(health_service).to receive(:sync_health_status!).and_raise(StandardError, 'Database unavailable')
expect { described_class.perform_now(whatsapp_channel) }.to raise_error(StandardError, 'Database unavailable')
end
end
@@ -0,0 +1,85 @@
require 'rails_helper'
RSpec.describe Channels::Whatsapp::HealthSyncSchedulerJob do
it 'enqueues on the low priority queue' do
expect { described_class.perform_later }.to have_enqueued_job(described_class).on_queue('low')
end
it 'schedules stale and unchecked active Cloud API channels' do
unchecked = create(
:channel_whatsapp,
provider: 'whatsapp_cloud',
phone_number_health_checked_at: nil,
sync_templates: false,
validate_provider_config: false
)
stale = create(
:channel_whatsapp,
provider: 'whatsapp_cloud',
phone_number_health_checked_at: 7.hours.ago,
sync_templates: false,
validate_provider_config: false
)
recent = create(
:channel_whatsapp,
provider: 'whatsapp_cloud',
phone_number_health_checked_at: 1.hour.ago,
sync_templates: false,
validate_provider_config: false
)
non_cloud = create(
:channel_whatsapp,
provider: 'default',
phone_number_health_checked_at: 7.hours.ago,
sync_templates: false,
validate_provider_config: false
)
suspended = create(
:channel_whatsapp,
account: create(:account, status: :suspended),
provider: 'whatsapp_cloud',
phone_number_health_checked_at: nil,
sync_templates: false,
validate_provider_config: false
)
described_class.perform_now
expect(Channels::Whatsapp::HealthSyncJob).to have_been_enqueued.with(unchecked).on_queue('low')
expect(Channels::Whatsapp::HealthSyncJob).to have_been_enqueued.with(stale).on_queue('low')
expect(Channels::Whatsapp::HealthSyncJob).not_to have_been_enqueued.with(recent).on_queue('low')
expect(Channels::Whatsapp::HealthSyncJob).not_to have_been_enqueued.with(non_cloud).on_queue('low')
expect(Channels::Whatsapp::HealthSyncJob).not_to have_been_enqueued.with(suspended).on_queue('low')
end
it 'schedules unchecked and oldest channels first when the batch is limited' do
stub_const('Limits::BULK_EXTERNAL_HTTP_CALLS_LIMIT', 2)
unchecked = create(
:channel_whatsapp,
provider: 'whatsapp_cloud',
phone_number_health_checked_at: nil,
sync_templates: false,
validate_provider_config: false
)
older = create(
:channel_whatsapp,
provider: 'whatsapp_cloud',
phone_number_health_checked_at: 9.hours.ago,
sync_templates: false,
validate_provider_config: false
)
newer = create(
:channel_whatsapp,
provider: 'whatsapp_cloud',
phone_number_health_checked_at: 7.hours.ago,
sync_templates: false,
validate_provider_config: false
)
described_class.perform_now
expect(Channels::Whatsapp::HealthSyncJob).to have_been_enqueued.with(unchecked).on_queue('low')
expect(Channels::Whatsapp::HealthSyncJob).to have_been_enqueued.with(older).on_queue('low')
expect(Channels::Whatsapp::HealthSyncJob).not_to have_been_enqueued.with(newer).on_queue('low')
end
end
@@ -30,6 +30,11 @@ RSpec.describe TriggerScheduledItemsJob do
described_class.perform_now
end
it 'triggers Channels::Whatsapp::HealthSyncSchedulerJob' do
expect(Channels::Whatsapp::HealthSyncSchedulerJob).to receive(:perform_later).once
described_class.perform_now
end
context 'when unexecuted Scheduled campaign jobs' do
let!(:twilio_sms) { create(:channel_twilio_sms, account: account) }
let!(:twilio_inbox) { create(:inbox, channel: twilio_sms, account: account) }
+2 -2
View File
@@ -92,11 +92,11 @@ RSpec.describe EmailTemplate do
create(:email_template, :layout, body: 'Global {{ content_for_layout }}')
account_template = create(:email_template, :layout, account: account, body: 'Account {{ content_for_layout }}')
expect(described_class.branded_layout_for(inbox: inbox, account: account, locale: :en)).to eq(account_template)
expect(described_class.branded_layout_for(inbox: inbox, account: account, locale: :en).id).to eq(account_template.id)
inbox_template = create(:email_template, :layout, account: account, inbox: inbox, body: 'Inbox {{ content_for_layout }}')
expect(described_class.branded_layout_for(inbox: inbox, account: account, locale: :en)).to eq(inbox_template)
expect(described_class.branded_layout_for(inbox: inbox, account: account, locale: :en).id).to eq(inbox_template.id)
end
end
@@ -0,0 +1,181 @@
require 'rails_helper'
RSpec.describe Whatsapp::HealthService do
subject(:service) { described_class.new(channel) }
let(:provider_config) do
{
'api_key' => 'test_access_token',
'phone_number_id' => 'test_phone_number_id',
'business_account_id' => 'test_waba_id',
'source' => 'embedded_signup'
}
end
let(:previous_health) { {} }
let(:channel) do
create(
:channel_whatsapp,
phone_number: '+15550001111',
provider: 'whatsapp_cloud',
sync_templates: false,
validate_provider_config: false
).tap do |record|
record.provider_config = provider_config
record.phone_number_health = previous_health
record.save!
end
end
let(:phone_health_response) do
{
'id' => 'test_phone_number_id',
'display_phone_number' => '+1 555-000-1111',
'verified_name' => 'Example Store',
'name_status' => 'APPROVED',
'quality_rating' => 'GREEN',
'whatsapp_business_manager_messaging_limit' => 'TIER_250',
'status' => 'CONNECTED',
'account_mode' => 'LIVE',
'code_verification_status' => 'VERIFIED',
'webhook_configuration' => {
'phone_number' => 'https://example.test/webhooks/whatsapp/+15550001111',
'application' => 'https://example.test/webhooks/whatsapp/+15550001111'
},
'throughput' => { 'level' => 'STANDARD' },
'last_onboarded_time' => '2026-07-01T08:30:00+0000',
'is_on_biz_app' => true,
'platform_type' => 'CLOUD_API'
}
end
let(:business_account_response) do
{
'id' => 'test_waba_id',
'name' => 'Example WABA',
'owner_business_info' => {
'id' => 'test_business_portfolio_id',
'name' => 'Example Business Portfolio'
}
}
end
before do
stub_request(:get, %r{graph\.facebook\.com/v22\.0/test_phone_number_id})
.to_return(status: 200, body: phone_health_response.to_json, headers: { 'Content-Type' => 'application/json' })
stub_request(:get, %r{graph\.facebook\.com/v22\.0/test_waba_id})
.to_return(status: 200, body: business_account_response.to_json, headers: { 'Content-Type' => 'application/json' })
end
describe '#fetch_health_status' do
it 'fetches and formats phone, WABA, portfolio, webhook, and coexistence health' do
result = nil
with_modified_env FRONTEND_URL: 'https://example.test' do
result = service.fetch_health_status
end
expect(result).to include(
id: 'test_phone_number_id',
display_phone_number: '+1 555-000-1111',
verified_name: 'Example Store',
quality_rating: 'GREEN',
messaging_limit_tier: 'TIER_250',
status: 'CONNECTED',
throughput: { 'level' => 'STANDARD' },
throughput_level: 'STANDARD',
is_on_biz_app: true,
platform_type: 'CLOUD_API',
business_account_id: 'test_waba_id',
business_account_name: 'Example WABA',
business_portfolio_id: 'test_business_portfolio_id',
business_portfolio_name: 'Example Business Portfolio',
expected_webhook_url: 'https://example.test/webhooks/whatsapp/+15550001111'
)
end
it 'requests the current messaging capacity field instead of the deprecated field' do
service.fetch_health_status
expect(
a_request(:get, %r{graph\.facebook\.com/v22\.0/test_phone_number_id}).with do |request|
fields = CGI.parse(request.uri.query)['fields']&.first.to_s.split(',')
fields.include?('whatsapp_business_manager_messaging_limit') && fields.exclude?('messaging_limit_tier')
end
).to have_been_made.once
end
end
describe '#sync_health_status!' do
it 'persists the latest successful snapshot and clears an earlier error' do
channel.phone_number_health_error = 'Previous failure'
channel.save!
travel_to(Time.zone.parse('2026-07-21 10:00:00')) do
result = service.sync_health_status!
channel.reload
expect(result[:health_checked_at]).to eq(Time.current)
expect(channel.phone_number_health).to include(
'quality_rating' => 'GREEN',
'messaging_limit_tier' => 'TIER_250',
'throughput_level' => 'STANDARD',
'is_on_biz_app' => true,
'platform_type' => 'CLOUD_API',
'business_account_id' => 'test_waba_id',
'business_portfolio_id' => 'test_business_portfolio_id'
)
expect(channel.phone_number_health).not_to have_key('webhook_configuration')
expect(channel.phone_number_health_checked_at).to eq(Time.current)
expect(channel.phone_number_health_error).to be_nil
end
end
context 'when Meta rejects the access token' do
let(:previous_health) { { quality_rating: 'GREEN', status: 'CONNECTED' } }
before do
stub_request(:get, %r{graph\.facebook\.com/v22\.0/test_phone_number_id})
.to_return(
status: 400,
body: {
error: {
message: 'The access token cannot authorize this request.',
type: 'OAuthException',
code: 190,
error_subcode: 464
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises a structured authorization error' do
expect { service.sync_health_status! }.to raise_error(described_class::ApiError) do |error|
expect(error.http_status).to eq(400)
expect(error.code).to eq(190)
expect(error.subcode).to eq(464)
expect(error).to be_authorization_error
end
end
it 'preserves the successful snapshot and records the failed attempt' do
travel_to(Time.zone.parse('2026-07-21 11:00:00')) do
expect { service.sync_health_status! }.to raise_error(described_class::ApiError)
channel.reload
expect(channel.phone_number_health).to eq('quality_rating' => 'GREEN', 'status' => 'CONNECTED')
expect(channel.phone_number_health_checked_at).to eq(Time.current)
expect(channel.phone_number_health_error).to eq('The access token cannot authorize this request.')
end
end
end
context 'when health first becomes risky' do
let(:previous_health) { { quality_rating: 'GREEN', status: 'CONNECTED', messaging_limit_tier: 'TIER_250' } }
let(:phone_health_response) { super().merge('quality_rating' => 'YELLOW') }
it 'logs the risky transition once it is persisted' do
expect(Rails.logger).to receive(:warn).with(/risky_phone_number.*quality_rating=YELLOW/)
service.sync_health_status!
end
end
end
end