{{ - isWhatsAppChannel + isAnyWhatsAppChannel ? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE') : $t('INBOX_MGMT.CSAT.NOTE') }} diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 94b90fa2a..0a26462ad 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -158,6 +158,10 @@ class Inbox < ApplicationRecord channel_type == 'Channel::Whatsapp' end + def twilio_whatsapp? + channel_type == 'Channel::TwilioSms' && channel.medium == 'whatsapp' + end + def assignable_agents (account.users.where(id: members.select(:user_id)) + account.administrators).uniq end diff --git a/app/services/csat_survey_service.rb b/app/services/csat_survey_service.rb index 9afddc6d3..6c8a288b8 100644 --- a/app/services/csat_survey_service.rb +++ b/app/services/csat_survey_service.rb @@ -6,6 +6,8 @@ class CsatSurveyService if whatsapp_channel? && template_available_and_approved? send_whatsapp_template_survey + elsif inbox.twilio_whatsapp? && twilio_template_available_and_approved? + send_twilio_whatsapp_template_survey elsif within_messaging_window? ::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform else @@ -45,7 +47,7 @@ class CsatSurveyService template_config = inbox.csat_config&.dig('template') return false unless template_config - template_name = template_config['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(inbox.id) + template_name = template_config['name'] || CsatTemplateNameService.csat_template_name(inbox.id) status_result = inbox.channel.provider_service.get_template_status(template_name) @@ -55,9 +57,25 @@ class CsatSurveyService false end + def twilio_template_available_and_approved? + template_config = inbox.csat_config&.dig('template') + return false unless template_config + + content_sid = template_config['content_sid'] + return false unless content_sid + + template_service = Twilio::CsatTemplateService.new(inbox.channel) + status_result = template_service.get_template_status(content_sid) + + status_result[:success] && status_result[:template][:status] == 'approved' + rescue StandardError => e + Rails.logger.error "Error checking Twilio CSAT template status: #{e.message}" + false + end + def send_whatsapp_template_survey template_config = inbox.csat_config&.dig('template') - template_name = template_config['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(inbox.id) + template_name = template_config['name'] || CsatTemplateNameService.csat_template_name(inbox.id) phone_number = conversation.contact_inbox.source_id template_info = build_template_info(template_name, template_config) @@ -95,6 +113,26 @@ class CsatSurveyService ) end + def send_twilio_whatsapp_template_survey + template_config = inbox.csat_config&.dig('template') + content_sid = template_config['content_sid'] + + phone_number = conversation.contact_inbox.source_id + content_variables = { '1' => conversation.uuid } + message = build_csat_message + + send_service = Twilio::SendOnTwilioService.new(message: message) + result = send_service.send_csat_template_message( + phone_number: phone_number, + content_sid: content_sid, + content_variables: content_variables + ) + + message.update!(source_id: result[:message_id]) if result[:success] && result[:message_id].present? + rescue StandardError => e + Rails.logger.error "Error sending Twilio WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}" + end + def create_csat_not_sent_activity_message content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window') activity_message_params = { diff --git a/app/services/csat_template_management_service.rb b/app/services/csat_template_management_service.rb new file mode 100644 index 000000000..064804082 --- /dev/null +++ b/app/services/csat_template_management_service.rb @@ -0,0 +1,196 @@ +class CsatTemplateManagementService + DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze + DEFAULT_LANGUAGE = 'en'.freeze + + def initialize(inbox) + @inbox = inbox + end + + def template_status + template = @inbox.csat_config&.dig('template') + return { template_exists: false } unless template + + if @inbox.twilio_whatsapp? + get_twilio_template_status(template) + else + get_whatsapp_template_status(template) + end + rescue StandardError => e + Rails.logger.error "Error fetching CSAT template status: #{e.message}" + { service_error: e.message } + end + + def create_template(template_params) + validate_template_params!(template_params) + + delete_existing_template_if_needed + + result = create_template_via_provider(template_params) + update_inbox_csat_config(result) if result[:success] + + result + rescue StandardError => e + Rails.logger.error "Error creating CSAT template: #{e.message}" + { success: false, service_error: 'Template creation failed' } + end + + private + + def validate_template_params!(template_params) + raise ActionController::ParameterMissing, 'message' if template_params[:message].blank? + end + + def create_template_via_provider(template_params) + if @inbox.twilio_whatsapp? + create_twilio_template(template_params) + else + create_whatsapp_template(template_params) + end + end + + def create_twilio_template(template_params) + template_config = build_template_config(template_params) + template_service = Twilio::CsatTemplateService.new(@inbox.channel) + template_service.create_template(template_config) + end + + def create_whatsapp_template(template_params) + template_config = build_template_config(template_params) + Whatsapp::CsatTemplateService.new(@inbox.channel).create_template(template_config) + end + + def build_template_config(template_params) + { + message: template_params[:message], + button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT, + base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'), + language: template_params[:language] || DEFAULT_LANGUAGE, + template_name: CsatTemplateNameService.csat_template_name(@inbox.id) + } + end + + def update_inbox_csat_config(result) + current_config = @inbox.csat_config || {} + template_data = build_template_data_from_result(result) + updated_config = current_config.merge('template' => template_data) + @inbox.update!(csat_config: updated_config) + end + + def build_template_data_from_result(result) + if @inbox.twilio_whatsapp? + build_twilio_template_data(result) + else + build_whatsapp_cloud_template_data(result) + end + end + + def build_twilio_template_data(result) + { + 'friendly_name' => result[:friendly_name], + 'content_sid' => result[:content_sid], + 'approval_sid' => result[:approval_sid], + 'language' => result[:language], + 'status' => result[:whatsapp_status] || result[:status], + 'created_at' => Time.current.iso8601 + }.compact + end + + def build_whatsapp_cloud_template_data(result) + { + 'name' => result[:template_name], + 'template_id' => result[:template_id], + 'language' => result[:language], + 'created_at' => Time.current.iso8601 + } + end + + def get_twilio_template_status(template) + content_sid = template['content_sid'] + return { template_exists: false } unless content_sid + + template_service = Twilio::CsatTemplateService.new(@inbox.channel) + status_result = template_service.get_template_status(content_sid) + + if status_result[:success] + { + template_exists: true, + friendly_name: template['friendly_name'], + content_sid: template['content_sid'], + status: status_result[:template][:status], + language: template['language'] + } + else + { + template_exists: false, + error: 'Template not found' + } + end + end + + def get_whatsapp_template_status(template) + template_name = template['name'] || CsatTemplateNameService.csat_template_name(@inbox.id) + status_result = Whatsapp::CsatTemplateService.new(@inbox.channel).get_template_status(template_name) + + if status_result[:success] + { + template_exists: true, + template_name: template_name, + status: status_result[:template][:status], + template_id: status_result[:template][:id] + } + else + { + template_exists: false, + error: 'Template not found' + } + end + end + + def delete_existing_template_if_needed + template = @inbox.csat_config&.dig('template') + return true if template.blank? + + if @inbox.twilio_whatsapp? + delete_existing_twilio_template(template) + else + delete_existing_whatsapp_template(template) + end + rescue StandardError => e + Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}" + false + end + + def delete_existing_twilio_template(template) + content_sid = template['content_sid'] + return true if content_sid.blank? + + template_service = Twilio::CsatTemplateService.new(@inbox.channel) + deletion_result = template_service.delete_template(nil, content_sid) + + if deletion_result[:success] + Rails.logger.info "Deleted existing Twilio CSAT template '#{content_sid}' for inbox #{@inbox.id}" + true + else + Rails.logger.warn "Failed to delete existing Twilio CSAT template '#{content_sid}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}" + false + end + end + + def delete_existing_whatsapp_template(template) + template_name = template['name'] + return true if template_name.blank? + + csat_template_service = Whatsapp::CsatTemplateService.new(@inbox.channel) + template_status = csat_template_service.get_template_status(template_name) + return true unless template_status[:success] + + deletion_result = csat_template_service.delete_template(template_name) + if deletion_result[:success] + Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}" + true + else + Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}" + false + end + end +end diff --git a/app/services/whatsapp/csat_template_name_service.rb b/app/services/csat_template_name_service.rb similarity index 97% rename from app/services/whatsapp/csat_template_name_service.rb rename to app/services/csat_template_name_service.rb index 223efda04..ac2500abc 100644 --- a/app/services/whatsapp/csat_template_name_service.rb +++ b/app/services/csat_template_name_service.rb @@ -1,4 +1,4 @@ -class Whatsapp::CsatTemplateNameService +class CsatTemplateNameService CSAT_BASE_NAME = 'customer_satisfaction_survey'.freeze # Generates template names like: customer_satisfaction_survey_{inbox_id}_{version_number} diff --git a/app/services/twilio/csat_template_api_client.rb b/app/services/twilio/csat_template_api_client.rb new file mode 100644 index 000000000..e5e9b1b75 --- /dev/null +++ b/app/services/twilio/csat_template_api_client.rb @@ -0,0 +1,68 @@ +class Twilio::CsatTemplateApiClient + def initialize(twilio_channel) + @twilio_channel = twilio_channel + end + + def create_template(request_body) + HTTParty.post( + "#{api_base_path}/v1/Content", + headers: api_headers, + body: request_body.to_json + ) + end + + def submit_for_approval(approval_url, template_name, category) + request_body = { + name: template_name, + category: category + } + + HTTParty.post( + approval_url, + headers: api_headers, + body: request_body.to_json + ) + end + + def delete_template(content_sid) + HTTParty.delete( + "#{api_base_path}/v1/Content/#{content_sid}", + headers: api_headers + ) + end + + def fetch_template(content_sid) + HTTParty.get( + "#{api_base_path}/v1/Content/#{content_sid}", + headers: api_headers + ) + end + + def fetch_approval_status(content_sid) + HTTParty.get( + "#{api_base_path}/v1/Content/#{content_sid}/ApprovalRequests", + headers: api_headers + ) + end + + private + + def api_headers + { + 'Authorization' => "Basic #{encoded_credentials}", + 'Content-Type' => 'application/json' + } + end + + def encoded_credentials + if @twilio_channel.api_key_sid.present? + Base64.strict_encode64("#{@twilio_channel.api_key_sid}:#{@twilio_channel.auth_token}") + else + Base64.strict_encode64("#{@twilio_channel.account_sid}:#{@twilio_channel.auth_token}") + end + end + + def api_base_path + 'https://content.twilio.com' + end +end diff --git a/app/services/twilio/csat_template_service.rb b/app/services/twilio/csat_template_service.rb new file mode 100644 index 000000000..70be01a69 --- /dev/null +++ b/app/services/twilio/csat_template_service.rb @@ -0,0 +1,204 @@ +class Twilio::CsatTemplateService + DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze + DEFAULT_LANGUAGE = 'en'.freeze + TEMPLATE_CATEGORY = 'UTILITY'.freeze + TEMPLATE_STATUS_PENDING = 'PENDING'.freeze + TEMPLATE_CONTENT_TYPE = 'twilio/call-to-action'.freeze + + def initialize(twilio_channel) + @twilio_channel = twilio_channel + @api_client = Twilio::CsatTemplateApiClient.new(twilio_channel) + end + + def create_template(template_config) + base_name = template_config[:template_name] + template_name = generate_template_name(base_name) + template_config_with_name = template_config.merge(template_name: template_name) + + request_body = build_template_request_body(template_config_with_name) + + # Step 1: Create template + response = @api_client.create_template(request_body) + + return process_template_creation_response(response, template_config_with_name) unless response.success? && response['sid'] + + # Step 2: Submit for WhatsApp approval using the approval_create URL + approval_url = response.dig('links', 'approval_create') + + if approval_url.present? + approval_response = submit_for_whatsapp_approval(approval_url, template_config_with_name[:template_name]) + process_approval_response(approval_response, response, template_config_with_name) + else + Rails.logger.warn 'No approval_create URL provided in template creation response' + # Fallback if no approval URL provided + process_template_creation_response(response, template_config_with_name) + end + end + + def delete_template(_template_name = nil, content_sid = nil) + content_sid ||= current_template_sid_from_config + return { success: false, error: 'No template to delete' } unless content_sid + + response = @api_client.delete_template(content_sid) + { success: response.success?, response_body: response.body } + end + + def get_template_status(content_sid) + return { success: false, error: 'No content SID provided' } unless content_sid + + template_response = fetch_template_details(content_sid) + return template_response unless template_response[:success] + + approval_response = fetch_approval_status(content_sid) + build_template_status_response(content_sid, template_response[:data], approval_response) + rescue StandardError => e + Rails.logger.error "Error fetching Twilio template status: #{e.message}" + { success: false, error: e.message } + end + + private + + def fetch_template_details(content_sid) + response = @api_client.fetch_template(content_sid) + + if response.success? + { success: true, data: response } + else + Rails.logger.error "Failed to get template details: #{response.code} - #{response.body}" + { success: false, error: 'Template not found' } + end + end + + def fetch_approval_status(content_sid) + @api_client.fetch_approval_status(content_sid) + end + + def build_template_status_response(content_sid, template_response, approval_response) + if approval_response.success? && approval_response['whatsapp'] + build_approved_template_response(content_sid, template_response, approval_response['whatsapp']) + else + build_pending_template_response(content_sid, template_response) + end + end + + def build_approved_template_response(content_sid, template_response, whatsapp_data) + { + success: true, + template: { + content_sid: content_sid, + friendly_name: whatsapp_data['name'] || template_response['friendly_name'], + status: whatsapp_data['status'] || 'pending', + language: template_response['language'] || 'en' + } + } + end + + def build_pending_template_response(content_sid, template_response) + { + success: true, + template: { + content_sid: content_sid, + friendly_name: template_response['friendly_name'], + status: 'pending', + language: template_response['language'] || 'en' + } + } + end + + def generate_template_name(base_name) + current_template_name = current_template_name_from_config + CsatTemplateNameService.generate_next_template_name(base_name, @twilio_channel.inbox.id, current_template_name) + end + + def current_template_name_from_config + @twilio_channel.inbox.csat_config&.dig('template', 'friendly_name') + end + + def current_template_sid_from_config + @twilio_channel.inbox.csat_config&.dig('template', 'content_sid') + end + + def template_exists_in_config? + content_sid = current_template_sid_from_config + friendly_name = current_template_name_from_config + + content_sid.present? && friendly_name.present? + end + + def build_template_request_body(template_config) + { + friendly_name: template_config[:template_name], + language: template_config[:language] || DEFAULT_LANGUAGE, + variables: { + '1' => '12345' # Example conversation UUID + }, + types: { + TEMPLATE_CONTENT_TYPE => { + body: template_config[:message], + actions: [ + { + type: 'URL', + title: template_config[:button_text] || DEFAULT_BUTTON_TEXT, + url: "#{template_config[:base_url]}/survey/responses/{{1}}" + } + ] + } + } + } + end + + def submit_for_whatsapp_approval(approval_url, template_name) + @api_client.submit_for_approval(approval_url, template_name, TEMPLATE_CATEGORY) + end + + def process_template_creation_response(response, template_config = {}) + if response.success? && response['sid'] + { + success: true, + content_sid: response['sid'], + friendly_name: template_config[:template_name], + language: template_config[:language] || DEFAULT_LANGUAGE, + status: TEMPLATE_STATUS_PENDING + } + else + Rails.logger.error "Twilio template creation failed: #{response.code} - #{response.body}" + { + success: false, + error: 'Template creation failed', + response_body: response.body + } + end + end + + def process_approval_response(approval_response, creation_response, template_config) + if approval_response.success? + build_successful_approval_response(approval_response, creation_response, template_config) + else + build_failed_approval_response(approval_response, creation_response, template_config) + end + end + + def build_successful_approval_response(approval_response, creation_response, template_config) + approval_data = approval_response.parsed_response + { + success: true, + content_sid: creation_response['sid'], + friendly_name: template_config[:template_name], + language: template_config[:language] || DEFAULT_LANGUAGE, + status: TEMPLATE_STATUS_PENDING, + approval_sid: approval_data['sid'], + whatsapp_status: approval_data.dig('whatsapp', 'status') || TEMPLATE_STATUS_PENDING + } + end + + def build_failed_approval_response(approval_response, creation_response, template_config) + Rails.logger.error "Twilio template approval submission failed: #{approval_response.code} - #{approval_response.body}" + { + success: true, + content_sid: creation_response['sid'], + friendly_name: template_config[:template_name], + language: template_config[:language] || DEFAULT_LANGUAGE, + status: 'created' + } + end +end diff --git a/app/services/twilio/send_on_twilio_service.rb b/app/services/twilio/send_on_twilio_service.rb index d2fe5719b..71e67aabf 100644 --- a/app/services/twilio/send_on_twilio_service.rb +++ b/app/services/twilio/send_on_twilio_service.rb @@ -1,4 +1,24 @@ class Twilio::SendOnTwilioService < Base::SendOnChannelService + def send_csat_template_message(phone_number:, content_sid:, content_variables: {}) + send_params = { + to: phone_number, + content_sid: content_sid + } + + send_params[:content_variables] = content_variables.to_json if content_variables.present? + send_params[:status_callback] = channel.send(:twilio_delivery_status_index_url) if channel.respond_to?(:twilio_delivery_status_index_url, true) + + # Add messaging service or from number + send_params = send_params.merge(channel.send(:send_message_from)) + + twilio_message = channel.send(:client).messages.create(**send_params) + + { success: true, message_id: twilio_message.sid } + rescue Twilio::REST::TwilioError, Twilio::REST::RestError => e + Rails.logger.error "Failed to send Twilio template message: #{e.message}" + { success: false, error: e.message } + end + private def channel_class diff --git a/app/services/whatsapp/csat_template_service.rb b/app/services/whatsapp/csat_template_service.rb index 43009b484..9bdbef8ca 100644 --- a/app/services/whatsapp/csat_template_service.rb +++ b/app/services/whatsapp/csat_template_service.rb @@ -19,7 +19,7 @@ class Whatsapp::CsatTemplateService end def delete_template(template_name = nil) - template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(@whatsapp_channel.inbox.id) + template_name ||= CsatTemplateNameService.csat_template_name(@whatsapp_channel.inbox.id) response = HTTParty.delete( "#{business_account_path}/message_templates?name=#{template_name}", headers: api_headers @@ -51,7 +51,7 @@ class Whatsapp::CsatTemplateService def generate_template_name(base_name) current_template_name = current_template_name_from_config - Whatsapp::CsatTemplateNameService.generate_next_template_name(base_name, @whatsapp_channel.inbox.id, current_template_name) + CsatTemplateNameService.generate_next_template_name(base_name, @whatsapp_channel.inbox.id, current_template_name) end def current_template_name_from_config diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index e13ee8979..6f2ead579 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -67,7 +67,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi end def delete_csat_template(template_name = nil) - template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(whatsapp_channel.inbox.id) + template_name ||= CsatTemplateNameService.csat_template_name(whatsapp_channel.inbox.id) csat_template_service.delete_template(template_name) end diff --git a/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb b/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb index 7ca9083c0..d577c1b5d 100644 --- a/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb @@ -9,11 +9,11 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d end let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account) } let(:web_widget_inbox) { create(:inbox, account: account) } - let(:mock_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) } + let(:mock_service) { instance_double(Whatsapp::CsatTemplateService) } before do create(:inbox_member, user: agent, inbox: whatsapp_inbox) - allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(mock_service) + allow(Whatsapp::CsatTemplateService).to receive(:new).and_return(mock_service) end describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template' do @@ -32,7 +32,7 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d as: :json expect(response).to have_http_status(:bad_request) - expect(response.parsed_body['error']).to eq('CSAT template operations only available for WhatsApp channels') + expect(response.parsed_body['error']).to eq('CSAT template operations only available for WhatsApp and Twilio WhatsApp channels') end end @@ -161,7 +161,7 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d as: :json expect(response).to have_http_status(:bad_request) - expect(response.parsed_body['error']).to eq('CSAT template operations only available for WhatsApp channels') + expect(response.parsed_body['error']).to eq('CSAT template operations only available for WhatsApp and Twilio WhatsApp channels') end end @@ -195,11 +195,11 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d it 'creates template successfully' do allow(mock_service).to receive(:get_template_status).and_return({ success: false }) - allow(mock_service).to receive(:create_csat_template).and_return({ - success: true, - template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}", - template_id: '987654321' - }) + allow(mock_service).to receive(:create_template).and_return({ + success: true, + template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}", + template_id: '987654321' + }) post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", headers: admin.create_new_auth_token, @@ -222,7 +222,7 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d } allow(mock_service).to receive(:get_template_status).and_return({ success: false }) - expect(mock_service).to receive(:create_csat_template) do |config| + expect(mock_service).to receive(:create_template) do |config| expect(config[:button_text]).to eq('Please rate us') expect(config[:language]).to eq('en') expect(config[:template_name]).to eq("customer_satisfaction_survey_#{whatsapp_inbox.id}") @@ -249,11 +249,11 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d } allow(mock_service).to receive(:get_template_status).and_return({ success: false }) - allow(mock_service).to receive(:create_csat_template).and_return({ - success: false, - error: 'Template creation failed', - response_body: whatsapp_error_response.to_json - }) + allow(mock_service).to receive(:create_template).and_return({ + success: false, + error: 'Template creation failed', + response_body: whatsapp_error_response.to_json + }) post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", headers: admin.create_new_auth_token, @@ -272,11 +272,11 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d it 'handles generic API errors' do allow(mock_service).to receive(:get_template_status).and_return({ success: false }) - allow(mock_service).to receive(:create_csat_template).and_return({ - success: false, - error: 'Network timeout', - response_body: nil - }) + allow(mock_service).to receive(:create_template).and_return({ + success: false, + error: 'Network timeout', + response_body: nil + }) post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", headers: admin.create_new_auth_token, @@ -289,7 +289,7 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d it 'handles unexpected service errors' do allow(mock_service).to receive(:get_template_status).and_return({ success: false }) - allow(mock_service).to receive(:create_csat_template) + allow(mock_service).to receive(:create_template) .and_raise(StandardError, 'Unexpected error') post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", @@ -312,10 +312,10 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d allow(mock_service).to receive(:get_template_status) .with('existing_template') .and_return({ success: true, template: { id: '111111111' } }) - expect(mock_service).to receive(:delete_csat_template) + expect(mock_service).to receive(:delete_template) .with('existing_template') .and_return({ success: true }) - expect(mock_service).to receive(:create_csat_template) + expect(mock_service).to receive(:create_template) .and_return({ success: true, template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}", @@ -336,13 +336,13 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d }) allow(mock_service).to receive(:get_template_status).and_return({ success: true }) - allow(mock_service).to receive(:delete_csat_template) + allow(mock_service).to receive(:delete_template) .and_return({ success: false, response_body: 'Delete failed' }) - allow(mock_service).to receive(:create_csat_template).and_return({ - success: true, - template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}", - template_id: '333333333' - }) + allow(mock_service).to receive(:create_template).and_return({ + success: true, + template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}", + template_id: '333333333' + }) post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", headers: admin.create_new_auth_token, @@ -365,11 +365,11 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d it 'allows access when agent is assigned to inbox' do allow(mock_service).to receive(:get_template_status).and_return({ success: false }) - allow(mock_service).to receive(:create_csat_template).and_return({ - success: true, - template_name: 'customer_satisfaction_survey', - template_id: '444444444' - }) + allow(mock_service).to receive(:create_template).and_return({ + success: true, + template_name: 'customer_satisfaction_survey', + template_id: '444444444' + }) post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", headers: agent.create_new_auth_token, diff --git a/spec/services/twilio/send_on_twilio_service_spec.rb b/spec/services/twilio/send_on_twilio_service_spec.rb index beae54b57..53dee3816 100644 --- a/spec/services/twilio/send_on_twilio_service_spec.rb +++ b/spec/services/twilio/send_on_twilio_service_spec.rb @@ -8,11 +8,8 @@ describe Twilio::SendOnTwilioService do let(:message_record_double) { double } let!(:account) { create(:account) } - let!(:widget_inbox) { create(:inbox, account: account) } let!(:twilio_sms) { create(:channel_twilio_sms, account: account) } - let!(:twilio_whatsapp) { create(:channel_twilio_sms, medium: :whatsapp, account: account) } let!(:twilio_inbox) { create(:inbox, channel: twilio_sms, account: account) } - let!(:twilio_whatsapp_inbox) { create(:inbox, channel: twilio_whatsapp, account: account) } let!(:contact) { create(:contact, account: account) } let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: twilio_inbox) } let(:conversation) { create(:conversation, contact: contact, inbox: twilio_inbox, contact_inbox: contact_inbox) } @@ -23,6 +20,10 @@ describe Twilio::SendOnTwilioService do end describe '#perform' do + let!(:widget_inbox) { create(:inbox, account: account) } + let!(:twilio_whatsapp) { create(:channel_twilio_sms, medium: :whatsapp, account: account) } + let!(:twilio_whatsapp_inbox) { create(:inbox, channel: twilio_whatsapp, account: account) } + context 'without reply' do it 'if message is private' do message = create(:message, message_type: 'outgoing', private: true, inbox: twilio_inbox, account: account) @@ -107,4 +108,146 @@ describe Twilio::SendOnTwilioService do expect(outgoing_message.reload.status).to eq('failed') end end + + describe '#send_csat_template_message' do + let(:test_message) { create(:message, message_type: 'outgoing', inbox: twilio_inbox, account: account, conversation: conversation) } + let(:service) { described_class.new(message: test_message) } + let(:mock_twilio_message) { instance_double(Twilio::REST::Api::V2010::AccountContext::MessageInstance, sid: 'SM123456789') } + + # Test parameters defined using let statements + let(:test_params) do + { + phone_number: '+1234567890', + content_sid: 'HX123456789', + content_variables: { '1' => 'conversation-uuid-123' } + } + end + + before do + allow(twilio_sms).to receive(:send_message_from).and_return({ from: '+0987654321' }) + allow(twilio_sms).to receive(:respond_to?).and_return(true) + allow(twilio_sms).to receive(:twilio_delivery_status_index_url).and_return('http://localhost:3000/twilio/delivery_status') + end + + context 'when template message is sent successfully' do + before do + allow(messages_double).to receive(:create).and_return(mock_twilio_message) + end + + it 'sends template message with correct parameters' do + expected_params = { + to: test_params[:phone_number], + content_sid: test_params[:content_sid], + content_variables: test_params[:content_variables].to_json, + status_callback: 'http://localhost:3000/twilio/delivery_status', + from: '+0987654321' + } + + result = service.send_csat_template_message(**test_params) + + expect(messages_double).to have_received(:create).with(expected_params) + expect(result).to eq({ success: true, message_id: 'SM123456789' }) + end + + it 'sends template message without content variables when empty' do + expected_params = { + to: test_params[:phone_number], + content_sid: test_params[:content_sid], + status_callback: 'http://localhost:3000/twilio/delivery_status', + from: '+0987654321' + } + + result = service.send_csat_template_message( + phone_number: test_params[:phone_number], + content_sid: test_params[:content_sid] + ) + + expect(messages_double).to have_received(:create).with(expected_params) + expect(result).to eq({ success: true, message_id: 'SM123456789' }) + end + + it 'includes custom status callback when channel supports it' do + allow(twilio_sms).to receive(:respond_to?).and_return(true) + allow(twilio_sms).to receive(:twilio_delivery_status_index_url).and_return('https://example.com/webhook') + + expected_params = { + to: test_params[:phone_number], + content_sid: test_params[:content_sid], + content_variables: test_params[:content_variables].to_json, + status_callback: 'https://example.com/webhook', + from: '+0987654321' + } + + service.send_csat_template_message(**test_params) + + expect(messages_double).to have_received(:create).with(expected_params) + end + end + + context 'when Twilio API returns an error' do + before do + allow(Rails.logger).to receive(:error) + end + + it 'handles Twilio::REST::TwilioError' do + allow(messages_double).to receive(:create).and_raise(Twilio::REST::TwilioError, 'Invalid phone number') + + result = service.send_csat_template_message(**test_params) + + expect(result).to eq({ success: false, error: 'Invalid phone number' }) + expect(Rails.logger).to have_received(:error).with('Failed to send Twilio template message: Invalid phone number') + end + + it 'handles Twilio API errors' do + allow(messages_double).to receive(:create).and_raise(Twilio::REST::TwilioError, 'Content template not found') + + result = service.send_csat_template_message(**test_params) + + expect(result).to eq({ success: false, error: 'Content template not found' }) + expect(Rails.logger).to have_received(:error).with('Failed to send Twilio template message: Content template not found') + end + end + + context 'with parameter handling' do + before do + allow(messages_double).to receive(:create).and_return(mock_twilio_message) + end + + it 'handles empty content_variables hash' do + expected_params = { + to: test_params[:phone_number], + content_sid: test_params[:content_sid], + status_callback: 'http://localhost:3000/twilio/delivery_status', + from: '+0987654321' + } + + service.send_csat_template_message( + phone_number: test_params[:phone_number], + content_sid: test_params[:content_sid], + content_variables: {} + ) + + expect(messages_double).to have_received(:create).with(expected_params) + end + + it 'converts content_variables to JSON when present' do + variables = { '1' => 'test-uuid', '2' => 'another-value' } + expected_params = { + to: test_params[:phone_number], + content_sid: test_params[:content_sid], + content_variables: variables.to_json, + status_callback: 'http://localhost:3000/twilio/delivery_status', + from: '+0987654321' + } + + service.send_csat_template_message( + phone_number: test_params[:phone_number], + content_sid: test_params[:content_sid], + content_variables: variables + ) + + expect(messages_double).to have_received(:create).with(expected_params) + end + end + end end