diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 4750e3b4a..ae1d4369a 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -4,7 +4,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController before_action :fetch_agent_bot, only: [:set_agent_bot] before_action :validate_limit, only: [:create] # we are already handling the authorization in fetch inbox - before_action :check_authorization, except: [:show] + before_action :check_authorization, except: [:show, :health] + before_action :validate_whatsapp_cloud_channel, only: [:health] def index @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] })) @@ -78,6 +79,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController render status: :internal_server_error, json: { error: e.message } end + def health + health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status + render json: health_data + rescue StandardError => e + Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}" + render json: { error: e.message }, status: :unprocessable_entity + end + private def fetch_inbox @@ -89,6 +98,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] end + def validate_whatsapp_cloud_channel + return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud' + + render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request + end + def create_channel return unless allowed_channel_types.include?(permitted_params[:channel][:type]) diff --git a/app/javascript/dashboard/api/inboxHealth.js b/app/javascript/dashboard/api/inboxHealth.js new file mode 100644 index 000000000..181b041ba --- /dev/null +++ b/app/javascript/dashboard/api/inboxHealth.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class InboxHealthAPI extends ApiClient { + constructor() { + super('inboxes', { accountScoped: true }); + } + + getHealthStatus(inboxId) { + return axios.get(`${this.url}/${inboxId}/health`); + } +} + +export default new InboxHealthAPI(); diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 1c54adcb2..60038253c 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -5,6 +5,8 @@ "LEARN_MORE": "Learn more about inboxes", "RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.", "CLICK_TO_RECONNECT": "Click here to reconnect.", + "WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isn’t complete. Please check your display name status in Meta Business Manager before reconnecting.", + "COMPLETE_REGISTRATION": "Complete Registration", "LIST": { "404": "There are no inboxes attached to this account." }, @@ -605,8 +607,62 @@ "BUSINESS_HOURS": "Business Hours", "WIDGET_BUILDER": "Widget Builder", "BOT_CONFIGURATION": "Bot Configuration", + "ACCOUNT_HEALTH": "Account Health", "CSAT": "CSAT" }, + "ACCOUNT_HEALTH": { + "TITLE": "Manage your WhatsApp account", + "DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed", + "GO_TO_SETTINGS": "Go to Meta Business Manager", + "NO_DATA": "Health data is not available", + "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" + }, + "DISPLAY_NAME_STATUS": { + "LABEL": "Display name status", + "TOOLTIP": "Status of your business name verification" + }, + "QUALITY_RATING": { + "LABEL": "Quality rating", + "TOOLTIP": "WhatsApp quality rating for your account" + }, + "MESSAGING_LIMIT_TIER": { + "LABEL": "Messaging limit tier", + "TOOLTIP": "Daily messaging limit for your account" + }, + "ACCOUNT_MODE": { + "LABEL": "Account mode", + "TOOLTIP": "Current operating mode of your WhatsApp account" + } + }, + "VALUES": { + "TIERS": { + "TIER_250": "250 customers per 24h", + "TIER_1000": "1K customers per 24h", + "TIER_1K": "1K customers per 24h", + "TIER_10K": "10K customers per 24h", + "TIER_100K": "100K customers per 24h", + "TIER_UNLIMITED": "Unlimited customers per 24h" + }, + "STATUSES": { + "APPROVED": "Approved", + "PENDING_REVIEW": "Pending Review", + "AVAILABLE_WITHOUT_REVIEW": "Available Without Review", + "REJECTED": "Rejected", + "DECLINED": "Declined" + }, + "MODES": { + "SANDBOX": "Sandbox", + "LIVE": "Live" + } + } + }, "SETTINGS": "Settings", "FEATURES": { "LABEL": "Features", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 5ae0cec2f..4849628e6 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -13,6 +13,7 @@ import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue' import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue'; import GoogleReauthorize from './channels/google/Reauthorize.vue'; import WhatsappReauthorize from './channels/whatsapp/Reauthorize.vue'; +import InboxHealthAPI from 'dashboard/api/inboxHealth'; import PreChatFormSettings from './PreChatForm/Settings.vue'; import WeeklyAvailability from './components/WeeklyAvailability.vue'; import GreetingsEditor from 'shared/components/GreetingsEditor.vue'; @@ -21,6 +22,7 @@ import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vu import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue'; import WidgetBuilder from './WidgetBuilder.vue'; import BotConfiguration from './components/BotConfiguration.vue'; +import AccountHealth from './components/AccountHealth.vue'; import { FEATURE_FLAGS } from '../../../../featureFlags'; import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue'; import NextButton from 'dashboard/components-next/button/Button.vue'; @@ -51,6 +53,7 @@ export default { DuplicateInboxBanner, Editor, Avatar, + AccountHealth, }, mixins: [inboxMixin], setup() { @@ -79,6 +82,9 @@ export default { selectedPortalSlug: '', showBusinessNameInput: false, welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS, + healthData: null, + isLoadingHealth: false, + healthError: null, }; }, computed: { @@ -175,6 +181,16 @@ export default { }, ]; } + if (this.shouldShowWhatsAppConfiguration) { + visibleToAllChannelTabs = [ + ...visibleToAllChannelTabs, + { + key: 'whatsappHealth', + name: this.$t('INBOX_MGMT.TABS.ACCOUNT_HEALTH'), + }, + ]; + } + return visibleToAllChannelTabs; }, currentInboxId() { @@ -260,14 +276,30 @@ export default { this.inbox.reauthorization_required ); }, + isEmbeddedSignupWhatsApp() { + return this.inbox.provider_config?.source === 'embedded_signup'; + }, whatsappUnauthorized() { return ( - this.isAWhatsAppChannel && - this.inbox.provider === 'whatsapp_cloud' && - this.inbox.provider_config?.source === 'embedded_signup' && + this.isAWhatsAppCloudChannel && + this.isEmbeddedSignupWhatsApp && this.inbox.reauthorization_required ); }, + whatsappRegistrationIncomplete() { + if ( + !this.healthData || + !this.isAWhatsAppCloudChannel || + !this.isEmbeddedSignupWhatsApp + ) { + return false; + } + + return ( + this.healthData.platform_type === 'NOT_APPLICABLE' || + this.healthData.throughput?.level === 'NOT_APPLICABLE' + ); + }, }, watch: { $route(to) { @@ -275,15 +307,40 @@ export default { this.fetchInboxSettings(); } }, + inbox: { + handler() { + this.fetchHealthData(); + }, + immediate: false, + }, }, mounted() { this.fetchInboxSettings(); this.fetchPortals(); + this.fetchHealthData(); }, methods: { fetchPortals() { this.$store.dispatch('portals/index'); }, + async fetchHealthData() { + if (!this.inbox) return; + + if (!this.isAWhatsAppCloudChannel) { + return; + } + + try { + this.isLoadingHealth = true; + this.healthError = null; + const response = await InboxHealthAPI.getHealthStatus(this.inbox.id); + this.healthData = response.data; + } catch (error) { + this.healthError = error.message || 'Failed to fetch health data'; + } finally { + this.isLoadingHealth = false; + } + }, handleFeatureFlag(e) { this.selectedFeatureFlags = this.toggleInput( this.selectedFeatureFlags, @@ -446,7 +503,11 @@ export default { - + -
+
@@ -856,6 +917,9 @@ export default {
+
+ +
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/whatsapp/Reauthorize.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/whatsapp/Reauthorize.vue index 08ddcaded..229b62f69 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/whatsapp/Reauthorize.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/whatsapp/Reauthorize.vue @@ -16,6 +16,10 @@ const props = defineProps({ type: Object, required: true, }, + whatsappRegistrationIncomplete: { + type: Boolean, + default: false, + }, }); const { t } = useI18n(); @@ -28,6 +32,20 @@ const whatsappConfigurationId = computed( () => window.chatwootConfig.whatsappConfigurationId ); +const actionLabel = computed(() => { + if (props.whatsappRegistrationIncomplete) { + return t('INBOX_MGMT.COMPLETE_REGISTRATION'); + } + return ''; +}); + +const description = computed(() => { + if (props.whatsappRegistrationIncomplete) { + return t('INBOX_MGMT.WHATSAPP_REGISTRATION_INCOMPLETE'); + } + return ''; +}); + const reauthorizeWhatsApp = async params => { isRequestingAuthorization.value = true; @@ -185,6 +203,8 @@ defineExpose({ diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/AccountHealth.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/AccountHealth.vue new file mode 100644 index 000000000..026c8ef69 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/AccountHealth.vue @@ -0,0 +1,228 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/InboxReconnectionRequired.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/InboxReconnectionRequired.vue index ac84065e9..109272973 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/InboxReconnectionRequired.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/InboxReconnectionRequired.vue @@ -1,15 +1,26 @@ diff --git a/app/policies/inbox_policy.rb b/app/policies/inbox_policy.rb index 8a6f81484..cace85e5e 100644 --- a/app/policies/inbox_policy.rb +++ b/app/policies/inbox_policy.rb @@ -61,4 +61,8 @@ class InboxPolicy < ApplicationPolicy def sync_templates? @account_user.administrator? end + + def health? + @account_user.administrator? + end end diff --git a/app/services/whatsapp/embedded_signup_service.rb b/app/services/whatsapp/embedded_signup_service.rb index 1b882b1f1..4379d0b74 100644 --- a/app/services/whatsapp/embedded_signup_service.rb +++ b/app/services/whatsapp/embedded_signup_service.rb @@ -17,6 +17,7 @@ class Whatsapp::EmbeddedSignupService channel = create_or_reauthorize_channel(access_token, phone_info) channel.setup_webhooks + check_channel_health_and_prompt_reauth(channel) channel rescue StandardError => e @@ -52,6 +53,24 @@ class Whatsapp::EmbeddedSignupService end end + def check_channel_health_and_prompt_reauth(channel) + health_data = Whatsapp::HealthService.new(channel).fetch_health_status + return unless health_data + + if channel_in_pending_state?(health_data) + channel.prompt_reauthorization! + else + Rails.logger.info "[WHATSAPP] Channel #{channel.phone_number} health check passed" + end + rescue StandardError => e + Rails.logger.error "[WHATSAPP] Health check failed for channel #{channel.phone_number}: #{e.message}" + end + + def channel_in_pending_state?(health_data) + health_data[:platform_type] == 'NOT_APPLICABLE' || + health_data.dig(:throughput, 'level') == 'NOT_APPLICABLE' + end + def validate_parameters! missing_params = [] missing_params << 'code' if @code.blank? diff --git a/app/services/whatsapp/health_service.rb b/app/services/whatsapp/health_service.rb new file mode 100644 index 000000000..94789ef79 --- /dev/null +++ b/app/services/whatsapp/health_service.rb @@ -0,0 +1,84 @@ +class Whatsapp::HealthService + BASE_URI = 'https://graph.facebook.com'.freeze + + def initialize(channel) + @channel = channel + @access_token = channel.provider_config['api_key'] + @api_version = GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0') + end + + def fetch_health_status + validate_channel! + fetch_phone_health_data + end + + private + + def validate_channel! + 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? + end + + def fetch_phone_health_data + phone_number_id = @channel.provider_config['phone_number_id'] + + response = HTTParty.get( + "#{BASE_URI}/#{@api_version}/#{phone_number_id}", + query: { + fields: health_fields, + access_token: @access_token + } + ) + + handle_response(response) + rescue StandardError => e + Rails.logger.error "[WHATSAPP HEALTH] Error fetching health data: #{e.message}" + raise e + end + + def health_fields + %w[ + quality_rating + messaging_limit_tier + code_verification_status + account_mode + id + display_phone_number + name_status + verified_name + webhook_configuration + throughput + last_onboarded_time + 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) + end + + def format_health_response(response) + { + 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'], + throughput: response['throughput'], + last_onboarded_time: response['last_onboarded_time'], + platform_type: response['platform_type'], + business_id: @channel.provider_config['business_account_id'] + } + end +end diff --git a/app/services/whatsapp/webhook_setup_service.rb b/app/services/whatsapp/webhook_setup_service.rb index a3faaaa56..63fed1e93 100644 --- a/app/services/whatsapp/webhook_setup_service.rb +++ b/app/services/whatsapp/webhook_setup_service.rb @@ -8,8 +8,12 @@ class Whatsapp::WebhookSetupService def perform validate_parameters! - # Since coexistence method does not need to register, we check it - register_phone_number unless phone_number_verified? + + # Register phone number if either condition is met: + # 1. Phone number is not verified (code_verification_status != 'VERIFIED') + # 2. Phone number needs registration (pending provisioning state) + register_phone_number if !phone_number_verified? || phone_number_needs_registration? + setup_webhook end @@ -69,9 +73,44 @@ class Whatsapp::WebhookSetupService def phone_number_verified? phone_number_id = @channel.provider_config['phone_number_id'] - @api_client.phone_number_verified?(phone_number_id) + # Check with WhatsApp API if the phone number code verification is complete + # This checks code_verification_status == 'VERIFIED' + verified = @api_client.phone_number_verified?(phone_number_id) + Rails.logger.info("[WHATSAPP] Phone number #{phone_number_id} code verification status: #{verified}") + + verified rescue StandardError => e - Rails.logger.error("[WHATSAPP] Phone registration status check failed, but continuing: #{e.message}") + # If verification check fails, assume not verified to be safe + Rails.logger.error("[WHATSAPP] Phone verification status check failed: #{e.message}") + false + end + + def phone_number_needs_registration? + # Check if phone is in pending provisioning state based on health data + # This is a separate check from phone_number_verified? which only checks code verification + + phone_number_in_pending_state? + + rescue StandardError => e + Rails.logger.error("[WHATSAPP] Phone registration check failed: #{e.message}") + # Conservative approach: don't register if we can't determine the state + false + end + + def phone_number_in_pending_state? + health_service = Whatsapp::HealthService.new(@channel) + health_data = health_service.fetch_health_status + + # Check if phone number is in "not provisioned" state based on health indicators + # These conditions indicate the number is pending and needs registration: + # - platform_type: "NOT_APPLICABLE" means not fully set up + # - throughput.level: "NOT_APPLICABLE" means no messaging capacity assigned + health_data[:platform_type] == 'NOT_APPLICABLE' || + health_data.dig(:throughput, :level) == 'NOT_APPLICABLE' + + rescue StandardError => e + Rails.logger.error("[WHATSAPP] Health status check failed: #{e.message}") + # If health check fails, assume registration is not needed to avoid errors false end end diff --git a/config/routes.rb b/config/routes.rb index 6a484b380..bf455949c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -193,6 +193,7 @@ Rails.application.routes.draw do post :set_agent_bot, on: :member delete :avatar, on: :member post :sync_templates, on: :member + get :health, on: :member end resources :inbox_members, only: [:create, :show], param: :inbox_id do collection do diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb index cc235cada..a44f60391 100644 --- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb @@ -980,4 +980,153 @@ RSpec.describe 'Inboxes API', type: :request do end end end + + describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/health' do + let(:whatsapp_channel) do + create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) + end + let(:whatsapp_inbox) { create(:inbox, account: account, channel: whatsapp_channel) } + let(:non_whatsapp_inbox) { create(:inbox, account: account) } + let(:health_service) { instance_double(Whatsapp::HealthService) } + let(:health_data) do + { + 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' + } + 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) + end + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + context 'with WhatsApp inbox' do + it 'returns health data for administrator' do + 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(:success) + json_response = response.parsed_body + expect(json_response).to include( + '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' + ) + end + + it 'returns health data for agent with inbox access' do + create(:inbox_member, user: agent, inbox: whatsapp_inbox) + + get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response['display_phone_number']).to eq('+1234567890') + end + + it 'returns unauthorized for agent without inbox access' do + get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + + 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) + + 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(:success) + end + + it 'handles service errors gracefully' do + allow(health_service).to receive(:fetch_health_status).and_raise(StandardError, 'API 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) + json_response = response.parsed_body + expect(json_response['error']).to include('API Error') + end + end + + context 'with non-WhatsApp inbox' do + it 'returns bad request error for administrator' do + get "/api/v1/accounts/#{account.id}/inboxes/#{non_whatsapp_inbox.id}/health", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:bad_request) + json_response = response.parsed_body + expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API channels') + end + + it 'returns bad request error for agent' do + create(:inbox_member, user: agent, inbox: non_whatsapp_inbox) + + get "/api/v1/accounts/#{account.id}/inboxes/#{non_whatsapp_inbox.id}/health", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:bad_request) + json_response = response.parsed_body + expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API channels') + end + end + + context 'with WhatsApp non-cloud inbox' do + let(:whatsapp_default_channel) do + create(:channel_whatsapp, account: account, provider: 'default', sync_templates: false, validate_provider_config: false) + end + let(:whatsapp_default_inbox) { create(:inbox, account: account, channel: whatsapp_default_channel) } + + it 'returns bad request error for non-cloud provider' do + get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_default_inbox.id}/health", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:bad_request) + json_response = response.parsed_body + expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API channels') + end + end + + context 'with non-existent inbox' do + it 'returns not found error' do + get "/api/v1/accounts/#{account.id}/inboxes/999999/health", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:not_found) + end + end + end + end end diff --git a/spec/services/whatsapp/embedded_signup_service_spec.rb b/spec/services/whatsapp/embedded_signup_service_spec.rb index 1db94928e..a20e36ac8 100644 --- a/spec/services/whatsapp/embedded_signup_service_spec.rb +++ b/spec/services/whatsapp/embedded_signup_service_spec.rb @@ -48,6 +48,15 @@ describe Whatsapp::EmbeddedSignupService do allow(channel_creation).to receive(:perform).and_return(channel) allow(channel).to receive(:setup_webhooks) + allow(channel).to receive(:phone_number).and_return('+1234567890') + + health_service = instance_double(Whatsapp::HealthService) + allow(Whatsapp::HealthService).to receive(:new).and_return(health_service) + allow(health_service).to receive(:fetch_health_status).and_return({ + platform_type: 'CLOUD_API', + throughput: { 'level' => 'STANDARD' }, + messaging_limit_tier: 'TIER_1000' + }) end it 'creates channel and sets up webhooks' do @@ -57,6 +66,49 @@ describe Whatsapp::EmbeddedSignupService do expect(result).to eq(channel) end + it 'checks health status after channel creation' do + health_service = instance_double(Whatsapp::HealthService) + allow(Whatsapp::HealthService).to receive(:new).and_return(health_service) + expect(health_service).to receive(:fetch_health_status) + + service.perform + end + + context 'when channel is in pending state' do + it 'prompts reauthorization for pending channel' do + health_service = instance_double(Whatsapp::HealthService) + allow(Whatsapp::HealthService).to receive(:new).and_return(health_service) + allow(health_service).to receive(:fetch_health_status).and_return({ + platform_type: 'NOT_APPLICABLE', + throughput: { 'level' => 'STANDARD' }, + messaging_limit_tier: 'TIER_1000' + }) + + expect(channel).to receive(:prompt_reauthorization!) + service.perform + end + + it 'prompts reauthorization when throughput level is NOT_APPLICABLE' do + health_service = instance_double(Whatsapp::HealthService) + allow(Whatsapp::HealthService).to receive(:new).and_return(health_service) + allow(health_service).to receive(:fetch_health_status).and_return({ + platform_type: 'CLOUD_API', + throughput: { 'level' => 'NOT_APPLICABLE' }, + messaging_limit_tier: 'TIER_1000' + }) + + expect(channel).to receive(:prompt_reauthorization!) + service.perform + end + end + + context 'when channel is healthy' do + it 'does not prompt reauthorization for healthy channel' do + expect(channel).not_to receive(:prompt_reauthorization!) + service.perform + end + end + context 'when parameters are invalid' do it 'raises ArgumentError for missing parameters' do invalid_service = described_class.new(account: account, params: { code: '', business_id: '', waba_id: '' }) @@ -114,6 +166,16 @@ describe Whatsapp::EmbeddedSignupService do business_id: params[:business_id] ).and_return(reauth_service) allow(reauth_service).to receive(:perform).with(access_token, phone_info).and_return(channel) + + allow(channel).to receive(:phone_number).and_return('+1234567890') + + health_service = instance_double(Whatsapp::HealthService) + allow(Whatsapp::HealthService).to receive(:new).and_return(health_service) + allow(health_service).to receive(:fetch_health_status).and_return({ + platform_type: 'CLOUD_API', + throughput: { 'level' => 'STANDARD' }, + messaging_limit_tier: 'TIER_1000' + }) end it 'uses ReauthorizationService and sets up webhooks' do @@ -124,36 +186,57 @@ describe Whatsapp::EmbeddedSignupService do expect(result).to eq(channel) end - it 'clears reauthorization flag' do - inbox = create(:inbox, account: account) - whatsapp_channel = create(:channel_whatsapp, account: account, phone_number: '+1234567890', - validate_provider_config: false, sync_templates: false) - inbox.update!(channel: whatsapp_channel) - whatsapp_channel.prompt_reauthorization! + context 'with real channel requiring reauthorization' do + let(:inbox) { create(:inbox, account: account) } + let(:whatsapp_channel) do + create(:channel_whatsapp, account: account, phone_number: '+1234567890', + validate_provider_config: false, sync_templates: false) + end + let(:service_with_real_inbox) { described_class.new(account: account, params: params, inbox_id: inbox.id) } - service_with_real_inbox = described_class.new(account: account, params: params, inbox_id: inbox.id) + before do + inbox.update!(channel: whatsapp_channel) + whatsapp_channel.prompt_reauthorization! - # Mock the ReauthorizationService to return our test channel - reauth_service = instance_double(Whatsapp::ReauthorizationService) - allow(Whatsapp::ReauthorizationService).to receive(:new).with( - account: account, - inbox_id: inbox.id, - phone_number_id: params[:phone_number_id], - business_id: params[:business_id] - ).and_return(reauth_service) - - # Perform the reauthorization and clear the flag - allow(reauth_service).to receive(:perform) do - whatsapp_channel.reauthorized! - whatsapp_channel + setup_reauthorization_mocks + setup_health_service_mock end - allow(whatsapp_channel).to receive(:setup_webhooks).and_return(true) + it 'clears reauthorization flag when reauthorization completes' do + expect(whatsapp_channel.reauthorization_required?).to be true + result = service_with_real_inbox.perform + expect(result).to eq(whatsapp_channel) + expect(whatsapp_channel.reauthorization_required?).to be false + end - expect(whatsapp_channel.reauthorization_required?).to be true - result = service_with_real_inbox.perform - expect(result).to eq(whatsapp_channel) - expect(whatsapp_channel.reauthorization_required?).to be false + private + + def setup_reauthorization_mocks + reauth_service = instance_double(Whatsapp::ReauthorizationService) + allow(Whatsapp::ReauthorizationService).to receive(:new).with( + account: account, + inbox_id: inbox.id, + phone_number_id: params[:phone_number_id], + business_id: params[:business_id] + ).and_return(reauth_service) + + allow(reauth_service).to receive(:perform) do + whatsapp_channel.reauthorized! + whatsapp_channel + end + + allow(whatsapp_channel).to receive(:setup_webhooks).and_return(true) + end + + def setup_health_service_mock + health_service = instance_double(Whatsapp::HealthService) + allow(Whatsapp::HealthService).to receive(:new).and_return(health_service) + allow(health_service).to receive(:fetch_health_status).and_return({ + platform_type: 'CLOUD_API', + throughput: { 'level' => 'STANDARD' }, + messaging_limit_tier: 'TIER_1000' + }) + end end end end diff --git a/spec/services/whatsapp/webhook_setup_service_spec.rb b/spec/services/whatsapp/webhook_setup_service_spec.rb index e6a246e5d..38856e252 100644 --- a/spec/services/whatsapp/webhook_setup_service_spec.rb +++ b/spec/services/whatsapp/webhook_setup_service_spec.rb @@ -16,6 +16,7 @@ describe Whatsapp::WebhookSetupService do let(:access_token) { 'test_access_token' } let(:service) { described_class.new(channel, waba_id, access_token) } let(:api_client) { instance_double(Whatsapp::FacebookApiClient) } + let(:health_service) { instance_double(Whatsapp::HealthService) } before do # Stub webhook teardown to prevent HTTP calls during cleanup @@ -24,8 +25,14 @@ describe Whatsapp::WebhookSetupService do # Clean up any existing channels to avoid phone number conflicts Channel::Whatsapp.destroy_all allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client) - # Default stub for phone_number_verified? with any argument + allow(Whatsapp::HealthService).to receive(:new).and_return(health_service) + + # Default stubs for phone_number_verified? and health service allow(api_client).to receive(:phone_number_verified?).and_return(false) + allow(health_service).to receive(:fetch_health_status).and_return({ + platform_type: 'APPLICABLE', + throughput: { level: 'APPLICABLE' } + }) end describe '#perform' do @@ -49,9 +56,13 @@ describe Whatsapp::WebhookSetupService do end end - context 'when phone number IS verified (should NOT register)' do + context 'when phone number IS verified AND fully provisioned (should NOT register)' do before do allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true) + allow(health_service).to receive(:fetch_health_status).and_return({ + platform_type: 'APPLICABLE', + throughput: { level: 'APPLICABLE' } + }) allow(api_client).to receive(:subscribe_waba_webhook) .with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true }) end @@ -66,16 +77,68 @@ describe Whatsapp::WebhookSetupService do end end + context 'when phone number IS verified BUT needs registration (pending provisioning)' do + before do + allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true) + allow(health_service).to receive(:fetch_health_status).and_return({ + platform_type: 'NOT_APPLICABLE', + throughput: { level: 'APPLICABLE' } + }) + allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) + allow(api_client).to receive(:register_phone_number).with('123456789', 223_456) + allow(api_client).to receive(:subscribe_waba_webhook) + .with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true }) + allow(channel).to receive(:save!) + end + + it 'registers the phone number due to pending provisioning state' do + with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do + expect(api_client).to receive(:register_phone_number).with('123456789', 223_456) + expect(api_client).to receive(:subscribe_waba_webhook) + .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token') + service.perform + end + end + end + + context 'when phone number needs registration due to throughput level' do + before do + allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true) + allow(health_service).to receive(:fetch_health_status).and_return({ + platform_type: 'APPLICABLE', + throughput: { level: 'NOT_APPLICABLE' } + }) + allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) + allow(api_client).to receive(:register_phone_number).with('123456789', 223_456) + allow(api_client).to receive(:subscribe_waba_webhook) + .with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true }) + allow(channel).to receive(:save!) + end + + it 'registers the phone number due to throughput not applicable' do + with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do + expect(api_client).to receive(:register_phone_number).with('123456789', 223_456) + expect(api_client).to receive(:subscribe_waba_webhook) + .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token') + service.perform + end + end + end + context 'when phone_number_verified? raises error' do before do allow(api_client).to receive(:phone_number_verified?).with('123456789').and_raise('API down') + allow(health_service).to receive(:fetch_health_status).and_return({ + platform_type: 'APPLICABLE', + throughput: { level: 'APPLICABLE' } + }) allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) allow(api_client).to receive(:register_phone_number) allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true }) allow(channel).to receive(:save!) end - it 'tries to register phone and proceeds with webhook setup' do + it 'tries to register phone (due to verification error) and proceeds with webhook setup' do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do expect(api_client).to receive(:register_phone_number) expect(api_client).to receive(:subscribe_waba_webhook) @@ -84,6 +147,22 @@ describe Whatsapp::WebhookSetupService do end end + context 'when health service raises error' do + before do + allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true) + allow(health_service).to receive(:fetch_health_status).and_raise('Health API down') + allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true }) + end + + it 'does not register phone (conservative approach) and proceeds with webhook setup' do + with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do + expect(api_client).not_to receive(:register_phone_number) + expect(api_client).to receive(:subscribe_waba_webhook) + expect { service.perform }.not_to raise_error + end + end + end + context 'when phone registration fails (not blocking)' do before do allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(false) @@ -193,6 +272,10 @@ describe Whatsapp::WebhookSetupService do before do allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true) + allow(health_service).to receive(:fetch_health_status).and_return({ + platform_type: 'APPLICABLE', + throughput: { level: 'APPLICABLE' } + }) allow(api_client).to receive(:subscribe_waba_webhook) .with(waba_id, anything, 'existing_verify_token').and_return({ 'success' => true }) end @@ -218,6 +301,10 @@ describe Whatsapp::WebhookSetupService do context 'when webhook setup is successful in creation flow' do before do allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true) + allow(health_service).to receive(:fetch_health_status).and_return({ + platform_type: 'APPLICABLE', + throughput: { level: 'APPLICABLE' } + }) allow(api_client).to receive(:subscribe_waba_webhook) .with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true }) end