From 3479b1026eeaed5dbad8cba77af0d0ae2134ba59 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 28 Jul 2026 09:56:56 +0400 Subject: [PATCH] fix(whatsapp): preserve phone health when business enrichment fails (#15200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- app/services/whatsapp/health_service.rb | 39 ++++----- spec/services/whatsapp/health_service_spec.rb | 80 +++++++++++++++++++ 2 files changed, 101 insertions(+), 18 deletions(-) diff --git a/app/services/whatsapp/health_service.rb b/app/services/whatsapp/health_service.rb index 00a78a463..bd3ecd4d6 100644 --- a/app/services/whatsapp/health_service.rb +++ b/app/services/whatsapp/health_service.rb @@ -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 diff --git a/spec/services/whatsapp/health_service_spec.rb b/spec/services/whatsapp/health_service_spec.rb index 57739f409..05fb847fa 100644 --- a/spec/services/whatsapp/health_service_spec.rb +++ b/spec/services/whatsapp/health_service_spec.rb @@ -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' } }