diff --git a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb index d17fe35fb..bb5dab680 100644 --- a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb +++ b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb @@ -1,38 +1,27 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController - DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze - DEFAULT_LANGUAGE = 'en'.freeze - before_action :fetch_inbox before_action :validate_whatsapp_channel def show - template = @inbox.csat_config&.dig('template') - return render json: { template_exists: false } unless template + service = CsatTemplateManagementService.new(@inbox) + result = service.template_status - template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id) - status_result = @inbox.channel.provider_service.get_template_status(template_name) - - render_template_status_response(status_result, template_name) - rescue StandardError => e - Rails.logger.error "Error fetching CSAT template status: #{e.message}" - render json: { error: e.message }, status: :internal_server_error + if result[:service_error] + render json: { error: result[:service_error] }, status: :internal_server_error + else + render json: result + end end def create template_params = extract_template_params return render_missing_message_error if template_params[:message].blank? - # Delete existing template even though we are using a new one. - # We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails. - delete_existing_template_if_needed - - result = create_template_via_provider(template_params) + service = CsatTemplateManagementService.new(@inbox) + result = service.create_template(template_params) render_template_creation_result(result) rescue ActionController::ParameterMissing render json: { error: 'Template parameters are required' }, status: :unprocessable_entity - rescue StandardError => e - Rails.logger.error "Error creating CSAT template: #{e.message}" - render json: { error: 'Template creation failed' }, status: :internal_server_error end private @@ -43,9 +32,9 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC end def validate_whatsapp_channel - return if @inbox.whatsapp? + return if @inbox.whatsapp? || @inbox.twilio_whatsapp? - render json: { error: 'CSAT template operations only available for WhatsApp channels' }, + render json: { error: 'CSAT template operations only available for WhatsApp and Twilio WhatsApp channels' }, status: :bad_request end @@ -57,35 +46,36 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC render json: { error: 'Message is required' }, status: :unprocessable_entity end - def create_template_via_provider(template_params) - template_config = { - 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: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id) - } - - @inbox.channel.provider_service.create_csat_template(template_config) - end - def render_template_creation_result(result) if result[:success] render_successful_template_creation(result) + elsif result[:service_error] + render json: { error: result[:service_error] }, status: :internal_server_error else render_failed_template_creation(result) end end def render_successful_template_creation(result) - render json: { - template: { - name: result[:template_name], - template_id: result[:template_id], - status: 'PENDING', - language: result[:language] || DEFAULT_LANGUAGE - } - }, status: :created + if @inbox.twilio_whatsapp? + render json: { + template: { + friendly_name: result[:friendly_name], + content_sid: result[:content_sid], + status: result[:status] || 'pending', + language: result[:language] || 'en' + } + }, status: :created + else + render json: { + template: { + name: result[:template_name], + template_id: result[:template_id], + status: 'PENDING', + language: result[:language] || 'en' + } + }, status: :created + end end def render_failed_template_creation(result) @@ -98,45 +88,6 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC }, status: :unprocessable_entity end - def delete_existing_template_if_needed - template = @inbox.csat_config&.dig('template') - return true if template.blank? - - template_name = template['name'] - return true if template_name.blank? - - template_status = @inbox.channel.provider_service.get_template_status(template_name) - return true unless template_status[:success] - - deletion_result = @inbox.channel.provider_service.delete_csat_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 - rescue StandardError => e - Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}" - false - end - - def render_template_status_response(status_result, template_name) - if status_result[:success] - render json: { - template_exists: true, - template_name: template_name, - status: status_result[:template][:status], - template_id: status_result[:template][:id] - } - else - render json: { - template_exists: false, - error: 'Template not found' - } - end - end - def parse_whatsapp_error(response_body) return { user_message: nil, technical_details: nil } if response_body.blank? diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 1c8845c04..322c7c7fe 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -176,7 +176,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name, { csat_config: [:display_type, :message, :button_text, :language, { survey_rules: [:operator, { values: [] }], - template: [:name, :template_id, :created_at, :language] }] }] + template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }] end def permitted_params(channel_attributes = []) diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue index 9cf9c688f..9e91f9642 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue @@ -28,10 +28,15 @@ const { t } = useI18n(); const store = useStore(); const labels = useMapGetter('labels/getLabels'); -const { isAWhatsAppCloudChannel: isWhatsAppChannel } = useInbox( +const { isAWhatsAppChannel, isATwilioWhatsAppChannel } = useInbox( props.inbox?.id ); +// Computed to check if it's any type of WhatsApp channel (Cloud or Twilio) +const isAnyWhatsAppChannel = computed( + () => isAWhatsAppChannel.value || isATwilioWhatsAppChannel.value +); + const isUpdating = ref(false); const selectedLabelValues = ref([]); const currentLabel = ref(''); @@ -116,7 +121,9 @@ const templateApprovalStatus = computed(() => { // Handle existing template with status if (templateStatus.value?.template_exists && templateStatus.value.status) { - return statusMap[templateStatus.value.status] || statusMap.PENDING; + // Convert status to uppercase for consistency with statusMap keys + const normalizedStatus = templateStatus.value.status.toUpperCase(); + return statusMap[normalizedStatus] || statusMap.PENDING; } // Default case - no template exists @@ -155,7 +162,7 @@ const initializeState = () => { : []; // Store original template values for change detection - if (isWhatsAppChannel.value) { + if (isAnyWhatsAppChannel.value) { originalTemplateValues.value = { message: state.message, templateButtonText: state.templateButtonText, @@ -165,7 +172,7 @@ const initializeState = () => { }; const checkTemplateStatus = async () => { - if (!isWhatsAppChannel.value) return; + if (!isAnyWhatsAppChannel.value) return; try { templateLoading.value = true; @@ -195,7 +202,7 @@ const checkTemplateStatus = async () => { onMounted(() => { initializeState(); if (!labels.value?.length) store.dispatch('labels/get'); - if (isWhatsAppChannel.value) checkTemplateStatus(); + if (isAnyWhatsAppChannel.value) checkTemplateStatus(); }); watch(() => props.inbox, initializeState, { immediate: true }); @@ -225,7 +232,7 @@ const removeLabel = label => { // Check if template-related fields have changed const hasTemplateChanges = () => { - if (!isWhatsAppChannel.value) return false; + if (!isAnyWhatsAppChannel.value) return false; const original = originalTemplateValues.value; return ( @@ -254,10 +261,28 @@ const shouldCreateTemplate = () => { // Build template config for saving const buildTemplateConfig = () => { - if (!hasExistingTemplate()) return null; + if (!hasExistingTemplate()) { + return null; + } const { template_name, template_id, template, status } = templateStatus.value || {}; + + if (isATwilioWhatsAppChannel.value) { + // Twilio WhatsApp format - get from existing template config + const existingTemplate = props.inbox?.csat_config?.template; + + return existingTemplate + ? { + friendly_name: existingTemplate.friendly_name, + content_sid: existingTemplate.content_sid, + language: existingTemplate.language || state.templateLanguage, + status: existingTemplate.status || status, + } + : null; + } + + // WhatsApp Cloud format return { name: template_name, template_id, @@ -273,11 +298,11 @@ const updateInbox = async attributes => { ...attributes, }; - return store.dispatch('inboxes/updateInbox', payload); + await store.dispatch('inboxes/updateInbox', payload); }; const createTemplate = async () => { - if (!isWhatsAppChannel.value) return null; + if (!isAnyWhatsAppChannel.value) return null; const response = await store.dispatch('inboxes/createCSATTemplate', { inboxId: props.inbox.id, @@ -298,7 +323,7 @@ const performSave = async () => { // For WhatsApp channels, create template first if needed if ( - isWhatsAppChannel.value && + isAnyWhatsAppChannel.value && state.csatSurveyEnabled && shouldCreateTemplate() ) { @@ -326,13 +351,25 @@ const performSave = async () => { // Use new template data if created, otherwise preserve existing template information if (newTemplateData) { - csatConfig.template = { - name: newTemplateData.name, - template_id: newTemplateData.template_id, - language: newTemplateData.language, - status: newTemplateData.status, - created_at: new Date().toISOString(), - }; + if (isATwilioWhatsAppChannel.value) { + // Twilio WhatsApp template format + csatConfig.template = { + friendly_name: newTemplateData.friendly_name, + content_sid: newTemplateData.content_sid, + language: newTemplateData.language, + status: newTemplateData.status, + created_at: new Date().toISOString(), + }; + } else { + // WhatsApp Cloud template format + csatConfig.template = { + name: newTemplateData.name, + template_id: newTemplateData.template_id, + language: newTemplateData.language, + status: newTemplateData.status, + created_at: new Date().toISOString(), + }; + } } else { const templateConfig = buildTemplateConfig(); if (templateConfig) { @@ -356,8 +393,9 @@ const performSave = async () => { const saveSettings = async () => { // Check if we need to show confirmation dialog for WhatsApp template changes + // This applies to both WhatsApp Cloud and Twilio WhatsApp channels if ( - isWhatsAppChannel.value && + isAnyWhatsAppChannel.value && state.csatSurveyEnabled && hasExistingTemplate() && hasTemplateChanges() @@ -390,7 +428,7 @@ const handleConfirmTemplateUpdate = async () => {
{{ - 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