Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2498503b8 | ||
|
|
5f8444be70 | ||
|
|
ded86b9ed1 | ||
|
|
a2b7a11b6c | ||
|
|
f83bba4cd0 | ||
|
|
b12922b451 | ||
|
|
6780fda715 | ||
|
|
716a1c58fc | ||
|
|
9c7418a40a |
@@ -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
|
||||
|
||||
+418
-110
@@ -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>
|
||||
|
||||
|
||||
+85
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user