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:
@@ -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
|
||||
|
||||
@@ -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' } }
|
||||
|
||||
Reference in New Issue
Block a user