diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index b7bfd0da7..8d054a586 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -34,6 +34,7 @@ class Channel::Whatsapp < ApplicationRecord after_create :sync_templates after_create_commit :setup_webhooks + before_destroy :teardown_webhooks def name 'Whatsapp' @@ -105,4 +106,8 @@ class Channel::Whatsapp < ApplicationRecord # Don't raise the error to prevent channel creation from failing # Webhooks can be retried later end + + def teardown_webhooks + Whatsapp::WebhookTeardownService.new(self).perform + end end diff --git a/app/services/whatsapp/facebook_api_client.rb b/app/services/whatsapp/facebook_api_client.rb index 1aebdad2a..441e41b1a 100644 --- a/app/services/whatsapp/facebook_api_client.rb +++ b/app/services/whatsapp/facebook_api_client.rb @@ -63,6 +63,15 @@ class Whatsapp::FacebookApiClient handle_response(response, 'Webhook subscription failed') end + def unsubscribe_waba_webhook(waba_id) + response = HTTParty.delete( + "#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps", + headers: request_headers + ) + + handle_response(response, 'Webhook unsubscription failed') + end + private def request_headers diff --git a/app/services/whatsapp/webhook_teardown_service.rb b/app/services/whatsapp/webhook_teardown_service.rb new file mode 100644 index 000000000..c4a39a5eb --- /dev/null +++ b/app/services/whatsapp/webhook_teardown_service.rb @@ -0,0 +1,47 @@ +class Whatsapp::WebhookTeardownService + def initialize(channel) + @channel = channel + end + + def perform + return unless should_teardown_webhook? + + teardown_webhook + rescue StandardError => e + handle_webhook_teardown_error(e) + end + + private + + def should_teardown_webhook? + whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present? + end + + def whatsapp_cloud_provider? + @channel.provider == 'whatsapp_cloud' + end + + def embedded_signup_source? + @channel.provider_config['source'] == 'embedded_signup' + end + + def webhook_config_present? + @channel.provider_config['business_account_id'].present? && + @channel.provider_config['api_key'].present? + end + + def teardown_webhook + waba_id = @channel.provider_config['business_account_id'] + access_token = @channel.provider_config['api_key'] + api_client = Whatsapp::FacebookApiClient.new(access_token) + + api_client.unsubscribe_waba_webhook(waba_id) + Rails.logger.info "[WHATSAPP] Webhook unsubscribed successfully for channel #{@channel.id}" + end + + def handle_webhook_teardown_error(error) + Rails.logger.error "[WHATSAPP] Webhook teardown failed: #{error.message}" + # Don't raise the error to prevent channel deletion from failing + # Failed webhook teardown shouldn't block deletion + end +end diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb index 29a00fd96..b46c984d1 100644 --- a/spec/models/channel/whatsapp_spec.rb +++ b/spec/models/channel/whatsapp_spec.rb @@ -122,4 +122,60 @@ RSpec.describe Channel::Whatsapp do end end end + + describe '#teardown_webhooks' do + let(:account) { create(:account) } + + context 'when channel is whatsapp_cloud with embedded_signup' do + it 'calls WebhookTeardownService on destroy' do + # Mock the setup service to prevent HTTP calls during creation + setup_service = instance_double(Whatsapp::WebhookSetupService) + allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service) + allow(setup_service).to receive(:perform) + + channel = create(:channel_whatsapp, + account: account, + provider: 'whatsapp_cloud', + provider_config: { + 'source' => 'embedded_signup', + 'business_account_id' => 'test_waba_id', + 'api_key' => 'test_access_token', + 'phone_number_id' => '123456789' + }, + validate_provider_config: false, + sync_templates: false) + + teardown_service = instance_double(Whatsapp::WebhookTeardownService) + allow(Whatsapp::WebhookTeardownService).to receive(:new).with(channel).and_return(teardown_service) + allow(teardown_service).to receive(:perform) + + channel.destroy + + expect(Whatsapp::WebhookTeardownService).to have_received(:new).with(channel) + expect(teardown_service).to have_received(:perform) + end + end + + context 'when channel is not embedded_signup' do + it 'does not call WebhookTeardownService on destroy' do + channel = create(:channel_whatsapp, + account: account, + provider: 'whatsapp_cloud', + provider_config: { + 'source' => 'manual', + 'api_key' => 'test_access_token' + }, + validate_provider_config: false, + sync_templates: false) + + teardown_service = instance_double(Whatsapp::WebhookTeardownService) + allow(Whatsapp::WebhookTeardownService).to receive(:new).with(channel).and_return(teardown_service) + allow(teardown_service).to receive(:perform) + + channel.destroy + + expect(teardown_service).to have_received(:perform) + end + end + end end diff --git a/spec/services/whatsapp/facebook_api_client_spec.rb b/spec/services/whatsapp/facebook_api_client_spec.rb index 6c2af7716..308d61f62 100644 --- a/spec/services/whatsapp/facebook_api_client_spec.rb +++ b/spec/services/whatsapp/facebook_api_client_spec.rb @@ -194,4 +194,41 @@ describe Whatsapp::FacebookApiClient do end end end + + describe '#unsubscribe_waba_webhook' do + let(:waba_id) { 'test_waba_id' } + + context 'when successful' do + before do + stub_request(:delete, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") + .with( + headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' } + ) + .to_return( + status: 200, + body: { success: true }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns success response' do + result = api_client.unsubscribe_waba_webhook(waba_id) + expect(result['success']).to be(true) + end + end + + context 'when failed' do + before do + stub_request(:delete, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") + .with( + headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' } + ) + .to_return(status: 400, body: { error: 'Webhook unsubscription failed' }.to_json) + end + + it 'raises an error' do + expect { api_client.unsubscribe_waba_webhook(waba_id) }.to raise_error(/Webhook unsubscription failed/) + end + end + end end diff --git a/spec/services/whatsapp/webhook_teardown_service_spec.rb b/spec/services/whatsapp/webhook_teardown_service_spec.rb new file mode 100644 index 000000000..25d2ae374 --- /dev/null +++ b/spec/services/whatsapp/webhook_teardown_service_spec.rb @@ -0,0 +1,81 @@ +require 'rails_helper' + +RSpec.describe Whatsapp::WebhookTeardownService do + describe '#perform' do + let(:channel) { create(:channel_whatsapp, validate_provider_config: false, sync_templates: false) } + let(:service) { described_class.new(channel) } + + context 'when channel is whatsapp_cloud with embedded_signup' do + before do + channel.update!( + provider: 'whatsapp_cloud', + provider_config: { + 'source' => 'embedded_signup', + 'business_account_id' => 'test_waba_id', + 'api_key' => 'test_api_key' + } + ) + end + + it 'calls unsubscribe_waba_webhook on Facebook API client' do + api_client = instance_double(Whatsapp::FacebookApiClient) + allow(Whatsapp::FacebookApiClient).to receive(:new).with('test_api_key').and_return(api_client) + allow(api_client).to receive(:unsubscribe_waba_webhook).with('test_waba_id') + + service.perform + + expect(api_client).to have_received(:unsubscribe_waba_webhook).with('test_waba_id') + end + + it 'handles errors gracefully without raising' do + api_client = instance_double(Whatsapp::FacebookApiClient) + allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client) + allow(api_client).to receive(:unsubscribe_waba_webhook).and_raise(StandardError, 'API Error') + + expect { service.perform }.not_to raise_error + end + end + + context 'when channel is not whatsapp_cloud' do + before do + channel.update!(provider: 'default') + end + + it 'does not attempt to unsubscribe webhook' do + expect(Whatsapp::FacebookApiClient).not_to receive(:new) + + service.perform + end + end + + context 'when channel is whatsapp_cloud but not embedded_signup' do + before do + channel.update!( + provider: 'whatsapp_cloud', + provider_config: { 'source' => 'manual' } + ) + end + + it 'does not attempt to unsubscribe webhook' do + expect(Whatsapp::FacebookApiClient).not_to receive(:new) + + service.perform + end + end + + context 'when required config is missing' do + before do + channel.update!( + provider: 'whatsapp_cloud', + provider_config: { 'source' => 'embedded_signup' } + ) + end + + it 'does not attempt to unsubscribe webhook' do + expect(Whatsapp::FacebookApiClient).not_to receive(:new) + + service.perform + end + end + end +end