@@ -536,7 +574,7 @@ const handleConfirmTemplateUpdate = async () => {
{{
- isWhatsAppChannel
+ isAnyWhatsAppChannel
? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE')
: $t('INBOX_MGMT.CSAT.NOTE')
}}
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue
index 0cade8635..bf45fe7b4 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue
@@ -76,14 +76,14 @@ export default {
businessHours,
};
},
- downloadAgentReports() {
+ downloadConversationReports() {
const { from, to } = this;
const fileName = generateFileName({
- type: 'agent',
+ type: 'conversation',
to,
businessHours: this.businessHours,
});
- this.$store.dispatch('downloadAgentReports', {
+ this.$store.dispatch('downloadConversationsSummaryReports', {
from,
to,
fileName,
@@ -109,10 +109,10 @@ export default {
diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js
index 96f4a0123..c7060bee3 100644
--- a/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js
+++ b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js
@@ -78,7 +78,6 @@ const getValueFromConversation = (conversation, attributeKey) => {
case 'team_id':
return conversation.meta?.team?.id;
case 'browser_language':
- case 'country_code':
case 'referer':
return conversation.additional_attributes?.[attributeKey];
default:
diff --git a/app/javascript/dashboard/store/modules/reports.js b/app/javascript/dashboard/store/modules/reports.js
index 99b2acf18..bb62a9b17 100644
--- a/app/javascript/dashboard/store/modules/reports.js
+++ b/app/javascript/dashboard/store/modules/reports.js
@@ -234,6 +234,19 @@ export const actions = {
console.error(error);
});
},
+ downloadConversationsSummaryReports(_, reportObj) {
+ return Report.getConversationsSummaryReports(reportObj)
+ .then(response => {
+ downloadCsvFile(reportObj.fileName, response.data);
+ AnalyticsHelper.track(REPORTS_EVENTS.DOWNLOAD_REPORT, {
+ reportType: 'conversations_summary',
+ businessHours: reportObj?.businessHours,
+ });
+ })
+ .catch(error => {
+ console.error(error);
+ });
+ },
downloadLabelReports(_, reportObj) {
return Report.getLabelReports(reportObj)
.then(response => {
diff --git a/app/javascript/dashboard/store/modules/specs/reports/actions.spec.js b/app/javascript/dashboard/store/modules/specs/reports/actions.spec.js
index 41e5a1d79..1a6cd99e8 100644
--- a/app/javascript/dashboard/store/modules/specs/reports/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/reports/actions.spec.js
@@ -201,4 +201,24 @@ describe('#actions', () => {
);
});
});
+
+ describe('#downloadConversationsSummaryReports', () => {
+ it('open CSV download prompt if API is success', async () => {
+ const data = `Conversations,Messages received,Messages sent,Avg first response time,Avg resolution time,Resolution count,Avg customer waiting time
+ 217,323,623,23 hours 22 minutes,179 days 18 hours,30,48 days 4 hours`;
+ axios.get.mockResolvedValue({ data });
+ const param = {
+ from: 1631039400,
+ to: 1635013800,
+ fileName: 'conversations-summary-report-24-10-2021.csv',
+ };
+ actions.downloadConversationsSummaryReports(1, param);
+ await flushPromises();
+
+ expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
+ param.fileName,
+ data
+ );
+ });
+ });
});
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/app/views/api/v2/accounts/reports/conversations_summary.csv.erb b/app/views/api/v2/accounts/reports/conversations_summary.csv.erb
new file mode 100644
index 000000000..5055739b6
--- /dev/null
+++ b/app/views/api/v2/accounts/reports/conversations_summary.csv.erb
@@ -0,0 +1,16 @@
+<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
+
+<% headers = [
+ I18n.t('reports.conversation_csv.conversations_count'),
+ I18n.t('reports.conversation_csv.incoming_messages_count'),
+ I18n.t('reports.conversation_csv.outgoing_messages_count'),
+ I18n.t('reports.conversation_csv.avg_first_response_time'),
+ I18n.t('reports.conversation_csv.avg_resolution_time'),
+ I18n.t('reports.conversation_csv.resolution_count'),
+ I18n.t('reports.conversation_csv.avg_customer_waiting_time')
+]
+%>
+<%= CSVSafe.generate_line headers -%>
+<% @report_data.each do |row| %>
+<%= CSVSafe.generate_line row -%>
+<% end %>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index f5cc97a0a..203ea7d81 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -172,6 +172,14 @@ en:
avg_resolution_time: Avg resolution time
resolution_count: Resolution Count
avg_customer_waiting_time: Avg customer waiting time
+ conversation_csv:
+ conversations_count: Conversations
+ incoming_messages_count: Messages received
+ outgoing_messages_count: Messages sent
+ avg_first_response_time: Avg first response time
+ avg_resolution_time: Avg resolution time
+ resolution_count: Resolution count
+ avg_customer_waiting_time: Avg customer waiting time
conversation_traffic_csv:
timezone: Timezone
sla_csv:
diff --git a/config/routes.rb b/config/routes.rb
index d9f0a40aa..801528607 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -437,6 +437,7 @@ Rails.application.routes.draw do
get :labels
get :teams
get :conversations
+ get :conversations_summary
get :conversation_traffic
get :bot_metrics
end
diff --git a/db/migrate/20260112092041_remove_country_code_from_conversation_filters.rb b/db/migrate/20260112092041_remove_country_code_from_conversation_filters.rb
new file mode 100644
index 000000000..b2d176451
--- /dev/null
+++ b/db/migrate/20260112092041_remove_country_code_from_conversation_filters.rb
@@ -0,0 +1,32 @@
+class RemoveCountryCodeFromConversationFilters < ActiveRecord::Migration[7.1]
+ def up
+ CustomFilter.conversation
+ .where('query::text LIKE ?', '%country_code%')
+ .find_each do |custom_filter|
+ query = custom_filter.query || {}
+ payload = query['payload']
+
+ next unless payload.is_a?(Array)
+
+ updated_payload = payload.reject do |filter|
+ filter.is_a?(Hash) && filter['attribute_key'] == 'country_code'
+ end
+
+ next if updated_payload == payload
+
+ if updated_payload.empty?
+ custom_filter.delete
+ next
+ end
+
+ # rubocop:disable Rails/SkipsModelValidations
+ # we will skip model validation, since we don't want any callbacks running
+ custom_filter.update_columns(query: query.merge('payload' => updated_payload))
+ # rubocop:enable Rails/SkipsModelValidations
+ end
+ end
+
+ def down
+ # no-op: removed filters cannot be restored reliably
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index ebdcffed0..bd4f0f968 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2025_12_29_081141) do
+ActiveRecord::Schema[7.1].define(version: 2026_01_12_092041) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
diff --git a/lib/filters/filter_keys.yml b/lib/filters/filter_keys.yml
index 626a0cbd5..bfaf39325 100644
--- a/lib/filters/filter_keys.yml
+++ b/lib/filters/filter_keys.yml
@@ -86,12 +86,6 @@ conversations:
filter_operators:
- "equal_to"
- "not_equal_to"
- country_code:
- attribute_type: "additional_attributes"
- data_type: "text"
- filter_operators:
- - "equal_to"
- - "not_equal_to"
referer:
attribute_type: "additional_attributes"
data_type: "link"
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/automation_rules/conditions_filter_service_spec.rb b/spec/services/automation_rules/conditions_filter_service_spec.rb
index 5efd26341..426cb533e 100644
--- a/spec/services/automation_rules/conditions_filter_service_spec.rb
+++ b/spec/services/automation_rules/conditions_filter_service_spec.rb
@@ -215,5 +215,25 @@ RSpec.describe AutomationRules::ConditionsFilterService do
end
end
end
+
+ context 'when conditions based on contact country_code' do
+ before do
+ conversation.update(additional_attributes: { country_code: 'US' })
+ conversation.contact.update(additional_attributes: { country_code: 'IN' })
+ rule.conditions = [
+ { 'values': ['IN'], 'attribute_key': 'country_code', 'query_operator': nil, 'filter_operator': 'equal_to' }
+ ]
+ rule.save
+ end
+
+ it 'matches against the contact additional_attributes' do
+ expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(true)
+ end
+
+ it 'returns false when the contact country_code does not match' do
+ conversation.contact.update(additional_attributes: { country_code: 'GB' })
+ expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(false)
+ end
+ end
end
end
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