fix(whatsapp): preserve phone health when business enrichment fails (#15200)

WhatsApp phone-number health could be discarded when the separate WABA
business-information request failed due to missing business-management
permissions. This left the health snapshot empty even though Meta
successfully returned the phone’s status, quality, capacity, and other
operational fields.

This change treats WABA information as optional enrichment after a
successful phone-number request. Available phone health is persisted and
returned, while the enrichment failure remains recorded in
`phone_number_health_error`. Failures from the primary phone-number
request continue to follow the existing error path.

Related: https://github.com/chatwoot/chatwoot/pull/15100

### How to reproduce

1. Configure a WhatsApp Cloud API inbox whose token can read its
phone-number node but cannot access `owner_business_info` on the WABA.
2. Open Settings → Inbox → Account Health or run the scheduled health
sync.
3. Observe that the phone-number request succeeds while the WABA request
returns a permission error.

### How to test

1. Refresh Account Health for the affected inbox.
2. Confirm the available phone status, quality rating, messaging limit,
verification state, throughput, and coexistence information are
populated.
3. Confirm unavailable business-account and portfolio fields remain
absent.
4. Confirm the WABA permission failure is retained in the channel’s
health error field.
5. Confirm a failure from the phone-number endpoint still records the
error without replacing the last successful health snapshot.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
This commit is contained in:
Muhsin Keloth
2026-07-28 09:56:56 +04:00
committed by GitHub
parent ed5a099425
commit 3479b1026e
2 changed files with 101 additions and 18 deletions

View File

@@ -16,6 +16,7 @@ class Whatsapp::HealthService
BASE_URI = 'https://graph.facebook.com'.freeze
MINIMUM_HEALTH_API_VERSION = 24.0
BUSINESS_HEALTH_FIELDS = %i[business_account_id business_account_name business_portfolio_id business_portfolio_name].freeze
PERSISTED_FIELDS = %i[
id
display_phone_number
@@ -46,21 +47,15 @@ class Whatsapp::HealthService
@api_version = "v#{[configured_api_version, MINIMUM_HEALTH_API_VERSION].max}"
end
def fetch_health_status
validate_channel!
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 fetch_health_status = fetch_health_status_with_error.first
def sync_health_status!
attempted_at = Time.current
previous_health = @channel.phone_number_health
health_status = fetch_health_status
health_status, error = fetch_health_status_with_error
health_status = previous_health.symbolize_keys.slice(*BUSINESS_HEALTH_FIELDS).merge(health_status) if error
log_risky_transition(previous_health, health_status) if persist_health_status(health_status, attempted_at)
log_risky_transition(previous_health, health_status) if persist_health_status(health_status, attempted_at, error)
health_status.merge(health_checked_at: attempted_at)
rescue StandardError => e
@@ -70,6 +65,18 @@ class Whatsapp::HealthService
private
def fetch_health_status_with_error
validate_channel!
phone_health = format_phone_health_response(fetch_graph_data(@channel.provider_config['phone_number_id'], phone_health_fields))
business_health = format_business_account_response(fetch_graph_data(@channel.provider_config['business_account_id'], business_account_fields))
[phone_health.merge(business_health), nil]
rescue StandardError => e
return [phone_health, e] if phone_health
raise
end
def validate_channel!
raise ArgumentError, 'Channel is required' if @channel.blank?
raise ArgumentError, 'API key is missing' if @access_token.blank?
@@ -173,13 +180,13 @@ class Whatsapp::HealthService
"#{frontend_url}/webhooks/whatsapp/#{@channel.phone_number}"
end
def persist_health_status(health_status, attempted_at)
def persist_health_status(health_status, attempted_at, error)
# Health polling must bypass credential validation, timestamps, inbox touches, and audit callbacks.
# rubocop:disable Rails/SkipsModelValidations
updated_rows = health_attempt_scope(attempted_at).update_all(
phone_number_health: health_status.slice(*PERSISTED_FIELDS),
phone_number_health_checked_at: attempted_at,
phone_number_health_error: nil
phone_number_health_error: error&.message&.truncate(500)
)
# rubocop:enable Rails/SkipsModelValidations
@@ -215,11 +222,7 @@ class Whatsapp::HealthService
)
end
def risky_health?(health_status)
RISKY_QUALITY_RATINGS.include?(health_status[:quality_rating]) || RISKY_STATUSES.include?(health_status[:status])
end
def risky_health?(health_status) = RISKY_QUALITY_RATINGS.include?(health_status[:quality_rating]) || RISKY_STATUSES.include?(health_status[:status])
def risk_signature(health_status)
health_status.to_h.with_indifferent_access.values_at(:quality_rating, :status)
end
def risk_signature(health_status) = health_status.to_h.with_indifferent_access.values_at(:quality_rating, :status)
end

View File

@@ -179,6 +179,86 @@ RSpec.describe Whatsapp::HealthService do
end
end
context 'when business account enrichment fails' do
before do
stub_request(:get, %r{graph\.facebook\.com/v24\.0/test_waba_id})
.to_return(
status: 400,
body: {
error: {
message: '(#200) You do not have permission to access this field.',
type: 'OAuthException',
code: 200
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'persists and returns the phone health with the enrichment error' do
result = service.sync_health_status!
channel.reload
expect(result).to include(
quality_rating: 'GREEN',
messaging_limit_tier: 'TIER_250',
status: 'CONNECTED'
)
expect(result).not_to have_key(:business_account_id)
expect(channel.phone_number_health).to include(
'quality_rating' => 'GREEN',
'messaging_limit_tier' => 'TIER_250',
'status' => 'CONNECTED'
)
expect(channel.phone_number_health_error).to eq('(#200) You do not have permission to access this field.')
end
it 'preserves the phone health when the enrichment request times out' do
stub_request(:get, %r{graph\.facebook\.com/v24\.0/test_waba_id}).to_timeout
expect { service.sync_health_status! }.not_to raise_error
channel.reload
expect(channel.phone_number_health).to include(
'quality_rating' => 'GREEN',
'status' => 'CONNECTED'
)
expect(channel.phone_number_health_error).to be_present
end
it 'preserves previously fetched business account health' do
channel.update!(
phone_number_health: {
business_account_id: 'previous_waba_id',
business_account_name: 'Previous WABA',
business_portfolio_id: 'previous_portfolio_id',
business_portfolio_name: 'Previous Business Portfolio'
}
)
result = service.sync_health_status!
channel.reload
expect(result).to include(
business_account_id: 'previous_waba_id',
business_account_name: 'Previous WABA',
business_portfolio_id: 'previous_portfolio_id',
business_portfolio_name: 'Previous Business Portfolio',
quality_rating: 'GREEN',
status: 'CONNECTED'
)
expect(channel.phone_number_health).to include(
'business_account_id' => 'previous_waba_id',
'business_account_name' => 'Previous WABA',
'business_portfolio_id' => 'previous_portfolio_id',
'business_portfolio_name' => 'Previous Business Portfolio',
'quality_rating' => 'GREEN',
'status' => 'CONNECTED'
)
expect(channel.phone_number_health_error).to eq('(#200) You do not have permission to access this field.')
end
end
context 'when a newer health check finishes first' do
let(:previous_health) { { quality_rating: 'GREEN', status: 'CONNECTED' } }
let(:newer_health) { { quality_rating: 'GREEN', status: 'CONNECTED' } }