feat: Add support for sending CSAT surveys via templates (Whatsapp Twilio) (#13143)
Fixes https://linear.app/chatwoot/issue/CW-6189/support-for-sending-csat-surveys-via-approved-whatsapp --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,38 +1,27 @@
|
|||||||
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
|
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 :fetch_inbox
|
||||||
before_action :validate_whatsapp_channel
|
before_action :validate_whatsapp_channel
|
||||||
|
|
||||||
def show
|
def show
|
||||||
template = @inbox.csat_config&.dig('template')
|
service = CsatTemplateManagementService.new(@inbox)
|
||||||
return render json: { template_exists: false } unless template
|
result = service.template_status
|
||||||
|
|
||||||
template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
|
if result[:service_error]
|
||||||
status_result = @inbox.channel.provider_service.get_template_status(template_name)
|
render json: { error: result[:service_error] }, status: :internal_server_error
|
||||||
|
else
|
||||||
render_template_status_response(status_result, template_name)
|
render json: result
|
||||||
rescue StandardError => e
|
end
|
||||||
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
|
|
||||||
render json: { error: e.message }, status: :internal_server_error
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
template_params = extract_template_params
|
template_params = extract_template_params
|
||||||
return render_missing_message_error if template_params[:message].blank?
|
return render_missing_message_error if template_params[:message].blank?
|
||||||
|
|
||||||
# Delete existing template even though we are using a new one.
|
service = CsatTemplateManagementService.new(@inbox)
|
||||||
# We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails.
|
result = service.create_template(template_params)
|
||||||
delete_existing_template_if_needed
|
|
||||||
|
|
||||||
result = create_template_via_provider(template_params)
|
|
||||||
render_template_creation_result(result)
|
render_template_creation_result(result)
|
||||||
rescue ActionController::ParameterMissing
|
rescue ActionController::ParameterMissing
|
||||||
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -43,9 +32,9 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
|||||||
end
|
end
|
||||||
|
|
||||||
def validate_whatsapp_channel
|
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
|
status: :bad_request
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -57,36 +46,37 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
|||||||
render json: { error: 'Message is required' }, status: :unprocessable_entity
|
render json: { error: 'Message is required' }, status: :unprocessable_entity
|
||||||
end
|
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)
|
def render_template_creation_result(result)
|
||||||
if result[:success]
|
if result[:success]
|
||||||
render_successful_template_creation(result)
|
render_successful_template_creation(result)
|
||||||
|
elsif result[:service_error]
|
||||||
|
render json: { error: result[:service_error] }, status: :internal_server_error
|
||||||
else
|
else
|
||||||
render_failed_template_creation(result)
|
render_failed_template_creation(result)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_successful_template_creation(result)
|
def render_successful_template_creation(result)
|
||||||
|
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: {
|
render json: {
|
||||||
template: {
|
template: {
|
||||||
name: result[:template_name],
|
name: result[:template_name],
|
||||||
template_id: result[:template_id],
|
template_id: result[:template_id],
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
language: result[:language] || DEFAULT_LANGUAGE
|
language: result[:language] || 'en'
|
||||||
}
|
}
|
||||||
}, status: :created
|
}, status: :created
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def render_failed_template_creation(result)
|
def render_failed_template_creation(result)
|
||||||
whatsapp_error = parse_whatsapp_error(result[:response_body])
|
whatsapp_error = parse_whatsapp_error(result[:response_body])
|
||||||
@@ -98,45 +88,6 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
|||||||
}, status: :unprocessable_entity
|
}, status: :unprocessable_entity
|
||||||
end
|
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)
|
def parse_whatsapp_error(response_body)
|
||||||
return { user_message: nil, technical_details: nil } if response_body.blank?
|
return { user_message: nil, technical_details: nil } if response_body.blank?
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
||||||
{ csat_config: [:display_type, :message, :button_text, :language,
|
{ csat_config: [:display_type, :message, :button_text, :language,
|
||||||
{ survey_rules: [:operator, { values: [] }],
|
{ 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
|
end
|
||||||
|
|
||||||
def permitted_params(channel_attributes = [])
|
def permitted_params(channel_attributes = [])
|
||||||
|
|||||||
@@ -28,10 +28,15 @@ const { t } = useI18n();
|
|||||||
const store = useStore();
|
const store = useStore();
|
||||||
const labels = useMapGetter('labels/getLabels');
|
const labels = useMapGetter('labels/getLabels');
|
||||||
|
|
||||||
const { isAWhatsAppCloudChannel: isWhatsAppChannel } = useInbox(
|
const { isAWhatsAppChannel, isATwilioWhatsAppChannel } = useInbox(
|
||||||
props.inbox?.id
|
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 isUpdating = ref(false);
|
||||||
const selectedLabelValues = ref([]);
|
const selectedLabelValues = ref([]);
|
||||||
const currentLabel = ref('');
|
const currentLabel = ref('');
|
||||||
@@ -116,7 +121,9 @@ const templateApprovalStatus = computed(() => {
|
|||||||
|
|
||||||
// Handle existing template with status
|
// Handle existing template with status
|
||||||
if (templateStatus.value?.template_exists && templateStatus.value.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
|
// Default case - no template exists
|
||||||
@@ -155,7 +162,7 @@ const initializeState = () => {
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Store original template values for change detection
|
// Store original template values for change detection
|
||||||
if (isWhatsAppChannel.value) {
|
if (isAnyWhatsAppChannel.value) {
|
||||||
originalTemplateValues.value = {
|
originalTemplateValues.value = {
|
||||||
message: state.message,
|
message: state.message,
|
||||||
templateButtonText: state.templateButtonText,
|
templateButtonText: state.templateButtonText,
|
||||||
@@ -165,7 +172,7 @@ const initializeState = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const checkTemplateStatus = async () => {
|
const checkTemplateStatus = async () => {
|
||||||
if (!isWhatsAppChannel.value) return;
|
if (!isAnyWhatsAppChannel.value) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
templateLoading.value = true;
|
templateLoading.value = true;
|
||||||
@@ -195,7 +202,7 @@ const checkTemplateStatus = async () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initializeState();
|
initializeState();
|
||||||
if (!labels.value?.length) store.dispatch('labels/get');
|
if (!labels.value?.length) store.dispatch('labels/get');
|
||||||
if (isWhatsAppChannel.value) checkTemplateStatus();
|
if (isAnyWhatsAppChannel.value) checkTemplateStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => props.inbox, initializeState, { immediate: true });
|
watch(() => props.inbox, initializeState, { immediate: true });
|
||||||
@@ -225,7 +232,7 @@ const removeLabel = label => {
|
|||||||
|
|
||||||
// Check if template-related fields have changed
|
// Check if template-related fields have changed
|
||||||
const hasTemplateChanges = () => {
|
const hasTemplateChanges = () => {
|
||||||
if (!isWhatsAppChannel.value) return false;
|
if (!isAnyWhatsAppChannel.value) return false;
|
||||||
|
|
||||||
const original = originalTemplateValues.value;
|
const original = originalTemplateValues.value;
|
||||||
return (
|
return (
|
||||||
@@ -254,10 +261,28 @@ const shouldCreateTemplate = () => {
|
|||||||
|
|
||||||
// Build template config for saving
|
// Build template config for saving
|
||||||
const buildTemplateConfig = () => {
|
const buildTemplateConfig = () => {
|
||||||
if (!hasExistingTemplate()) return null;
|
if (!hasExistingTemplate()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const { template_name, template_id, template, status } =
|
const { template_name, template_id, template, status } =
|
||||||
templateStatus.value || {};
|
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 {
|
return {
|
||||||
name: template_name,
|
name: template_name,
|
||||||
template_id,
|
template_id,
|
||||||
@@ -273,11 +298,11 @@ const updateInbox = async attributes => {
|
|||||||
...attributes,
|
...attributes,
|
||||||
};
|
};
|
||||||
|
|
||||||
return store.dispatch('inboxes/updateInbox', payload);
|
await store.dispatch('inboxes/updateInbox', payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createTemplate = async () => {
|
const createTemplate = async () => {
|
||||||
if (!isWhatsAppChannel.value) return null;
|
if (!isAnyWhatsAppChannel.value) return null;
|
||||||
|
|
||||||
const response = await store.dispatch('inboxes/createCSATTemplate', {
|
const response = await store.dispatch('inboxes/createCSATTemplate', {
|
||||||
inboxId: props.inbox.id,
|
inboxId: props.inbox.id,
|
||||||
@@ -298,7 +323,7 @@ const performSave = async () => {
|
|||||||
|
|
||||||
// For WhatsApp channels, create template first if needed
|
// For WhatsApp channels, create template first if needed
|
||||||
if (
|
if (
|
||||||
isWhatsAppChannel.value &&
|
isAnyWhatsAppChannel.value &&
|
||||||
state.csatSurveyEnabled &&
|
state.csatSurveyEnabled &&
|
||||||
shouldCreateTemplate()
|
shouldCreateTemplate()
|
||||||
) {
|
) {
|
||||||
@@ -326,6 +351,17 @@ const performSave = async () => {
|
|||||||
|
|
||||||
// Use new template data if created, otherwise preserve existing template information
|
// Use new template data if created, otherwise preserve existing template information
|
||||||
if (newTemplateData) {
|
if (newTemplateData) {
|
||||||
|
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 = {
|
csatConfig.template = {
|
||||||
name: newTemplateData.name,
|
name: newTemplateData.name,
|
||||||
template_id: newTemplateData.template_id,
|
template_id: newTemplateData.template_id,
|
||||||
@@ -333,6 +369,7 @@ const performSave = async () => {
|
|||||||
status: newTemplateData.status,
|
status: newTemplateData.status,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const templateConfig = buildTemplateConfig();
|
const templateConfig = buildTemplateConfig();
|
||||||
if (templateConfig) {
|
if (templateConfig) {
|
||||||
@@ -356,8 +393,9 @@ const performSave = async () => {
|
|||||||
|
|
||||||
const saveSettings = async () => {
|
const saveSettings = async () => {
|
||||||
// Check if we need to show confirmation dialog for WhatsApp template changes
|
// Check if we need to show confirmation dialog for WhatsApp template changes
|
||||||
|
// This applies to both WhatsApp Cloud and Twilio WhatsApp channels
|
||||||
if (
|
if (
|
||||||
isWhatsAppChannel.value &&
|
isAnyWhatsAppChannel.value &&
|
||||||
state.csatSurveyEnabled &&
|
state.csatSurveyEnabled &&
|
||||||
hasExistingTemplate() &&
|
hasExistingTemplate() &&
|
||||||
hasTemplateChanges()
|
hasTemplateChanges()
|
||||||
@@ -390,7 +428,7 @@ const handleConfirmTemplateUpdate = async () => {
|
|||||||
<div class="grid gap-5">
|
<div class="grid gap-5">
|
||||||
<!-- Show display type only for non-WhatsApp channels -->
|
<!-- Show display type only for non-WhatsApp channels -->
|
||||||
<WithLabel
|
<WithLabel
|
||||||
v-if="!isWhatsAppChannel"
|
v-if="!isAnyWhatsAppChannel"
|
||||||
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
|
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
|
||||||
name="display_type"
|
name="display_type"
|
||||||
>
|
>
|
||||||
@@ -400,7 +438,7 @@ const handleConfirmTemplateUpdate = async () => {
|
|||||||
/>
|
/>
|
||||||
</WithLabel>
|
</WithLabel>
|
||||||
|
|
||||||
<template v-if="isWhatsAppChannel">
|
<template v-if="isAnyWhatsAppChannel">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col gap-4 justify-between w-full lg:flex-row lg:gap-6"
|
class="flex flex-col gap-4 justify-between w-full lg:flex-row lg:gap-6"
|
||||||
>
|
>
|
||||||
@@ -536,7 +574,7 @@ const handleConfirmTemplateUpdate = async () => {
|
|||||||
</WithLabel>
|
</WithLabel>
|
||||||
<p class="text-sm italic text-n-slate-11">
|
<p class="text-sm italic text-n-slate-11">
|
||||||
{{
|
{{
|
||||||
isWhatsAppChannel
|
isAnyWhatsAppChannel
|
||||||
? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE')
|
? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE')
|
||||||
: $t('INBOX_MGMT.CSAT.NOTE')
|
: $t('INBOX_MGMT.CSAT.NOTE')
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -158,6 +158,10 @@ class Inbox < ApplicationRecord
|
|||||||
channel_type == 'Channel::Whatsapp'
|
channel_type == 'Channel::Whatsapp'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def twilio_whatsapp?
|
||||||
|
channel_type == 'Channel::TwilioSms' && channel.medium == 'whatsapp'
|
||||||
|
end
|
||||||
|
|
||||||
def assignable_agents
|
def assignable_agents
|
||||||
(account.users.where(id: members.select(:user_id)) + account.administrators).uniq
|
(account.users.where(id: members.select(:user_id)) + account.administrators).uniq
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ class CsatSurveyService
|
|||||||
|
|
||||||
if whatsapp_channel? && template_available_and_approved?
|
if whatsapp_channel? && template_available_and_approved?
|
||||||
send_whatsapp_template_survey
|
send_whatsapp_template_survey
|
||||||
|
elsif inbox.twilio_whatsapp? && twilio_template_available_and_approved?
|
||||||
|
send_twilio_whatsapp_template_survey
|
||||||
elsif within_messaging_window?
|
elsif within_messaging_window?
|
||||||
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
|
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
|
||||||
else
|
else
|
||||||
@@ -45,7 +47,7 @@ class CsatSurveyService
|
|||||||
template_config = inbox.csat_config&.dig('template')
|
template_config = inbox.csat_config&.dig('template')
|
||||||
return false unless template_config
|
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)
|
status_result = inbox.channel.provider_service.get_template_status(template_name)
|
||||||
|
|
||||||
@@ -55,9 +57,25 @@ class CsatSurveyService
|
|||||||
false
|
false
|
||||||
end
|
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
|
def send_whatsapp_template_survey
|
||||||
template_config = inbox.csat_config&.dig('template')
|
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
|
phone_number = conversation.contact_inbox.source_id
|
||||||
template_info = build_template_info(template_name, template_config)
|
template_info = build_template_info(template_name, template_config)
|
||||||
@@ -95,6 +113,26 @@ class CsatSurveyService
|
|||||||
)
|
)
|
||||||
end
|
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
|
def create_csat_not_sent_activity_message
|
||||||
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
|
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
|
||||||
activity_message_params = {
|
activity_message_params = {
|
||||||
|
|||||||
196
app/services/csat_template_management_service.rb
Normal file
196
app/services/csat_template_management_service.rb
Normal file
@@ -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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
class Whatsapp::CsatTemplateNameService
|
class CsatTemplateNameService
|
||||||
CSAT_BASE_NAME = 'customer_satisfaction_survey'.freeze
|
CSAT_BASE_NAME = 'customer_satisfaction_survey'.freeze
|
||||||
|
|
||||||
# Generates template names like: customer_satisfaction_survey_{inbox_id}_{version_number}
|
# Generates template names like: customer_satisfaction_survey_{inbox_id}_{version_number}
|
||||||
68
app/services/twilio/csat_template_api_client.rb
Normal file
68
app/services/twilio/csat_template_api_client.rb
Normal file
@@ -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
|
||||||
204
app/services/twilio/csat_template_service.rb
Normal file
204
app/services/twilio/csat_template_service.rb
Normal file
@@ -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
|
||||||
@@ -1,4 +1,24 @@
|
|||||||
class Twilio::SendOnTwilioService < Base::SendOnChannelService
|
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
|
private
|
||||||
|
|
||||||
def channel_class
|
def channel_class
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class Whatsapp::CsatTemplateService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def delete_template(template_name = nil)
|
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(
|
response = HTTParty.delete(
|
||||||
"#{business_account_path}/message_templates?name=#{template_name}",
|
"#{business_account_path}/message_templates?name=#{template_name}",
|
||||||
headers: api_headers
|
headers: api_headers
|
||||||
@@ -51,7 +51,7 @@ class Whatsapp::CsatTemplateService
|
|||||||
|
|
||||||
def generate_template_name(base_name)
|
def generate_template_name(base_name)
|
||||||
current_template_name = current_template_name_from_config
|
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
|
end
|
||||||
|
|
||||||
def current_template_name_from_config
|
def current_template_name_from_config
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
|||||||
end
|
end
|
||||||
|
|
||||||
def delete_csat_template(template_name = nil)
|
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)
|
csat_template_service.delete_template(template_name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
|||||||
end
|
end
|
||||||
let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account) }
|
let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account) }
|
||||||
let(:web_widget_inbox) { create(:inbox, 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
|
before do
|
||||||
create(:inbox_member, user: agent, inbox: whatsapp_inbox)
|
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
|
end
|
||||||
|
|
||||||
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template' do
|
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
|
as: :json
|
||||||
|
|
||||||
expect(response).to have_http_status(:bad_request)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
|||||||
as: :json
|
as: :json
|
||||||
|
|
||||||
expect(response).to have_http_status(:bad_request)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
|||||||
|
|
||||||
it 'creates template successfully' do
|
it 'creates template successfully' do
|
||||||
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
||||||
allow(mock_service).to receive(:create_csat_template).and_return({
|
allow(mock_service).to receive(:create_template).and_return({
|
||||||
success: true,
|
success: true,
|
||||||
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
|
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
|
||||||
template_id: '987654321'
|
template_id: '987654321'
|
||||||
@@ -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 })
|
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[:button_text]).to eq('Please rate us')
|
||||||
expect(config[:language]).to eq('en')
|
expect(config[:language]).to eq('en')
|
||||||
expect(config[:template_name]).to eq("customer_satisfaction_survey_#{whatsapp_inbox.id}")
|
expect(config[:template_name]).to eq("customer_satisfaction_survey_#{whatsapp_inbox.id}")
|
||||||
@@ -249,7 +249,7 @@ 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(:get_template_status).and_return({ success: false })
|
||||||
allow(mock_service).to receive(:create_csat_template).and_return({
|
allow(mock_service).to receive(:create_template).and_return({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Template creation failed',
|
error: 'Template creation failed',
|
||||||
response_body: whatsapp_error_response.to_json
|
response_body: whatsapp_error_response.to_json
|
||||||
@@ -272,7 +272,7 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
|||||||
|
|
||||||
it 'handles generic API errors' do
|
it 'handles generic API errors' do
|
||||||
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
||||||
allow(mock_service).to receive(:create_csat_template).and_return({
|
allow(mock_service).to receive(:create_template).and_return({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Network timeout',
|
error: 'Network timeout',
|
||||||
response_body: nil
|
response_body: nil
|
||||||
@@ -289,7 +289,7 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
|||||||
|
|
||||||
it 'handles unexpected service errors' do
|
it 'handles unexpected service errors' do
|
||||||
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
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')
|
.and_raise(StandardError, 'Unexpected error')
|
||||||
|
|
||||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
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)
|
allow(mock_service).to receive(:get_template_status)
|
||||||
.with('existing_template')
|
.with('existing_template')
|
||||||
.and_return({ success: true, template: { id: '111111111' } })
|
.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')
|
.with('existing_template')
|
||||||
.and_return({ success: true })
|
.and_return({ success: true })
|
||||||
expect(mock_service).to receive(:create_csat_template)
|
expect(mock_service).to receive(:create_template)
|
||||||
.and_return({
|
.and_return({
|
||||||
success: true,
|
success: true,
|
||||||
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
|
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
|
||||||
@@ -336,9 +336,9 @@ 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(: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' })
|
.and_return({ success: false, response_body: 'Delete failed' })
|
||||||
allow(mock_service).to receive(:create_csat_template).and_return({
|
allow(mock_service).to receive(:create_template).and_return({
|
||||||
success: true,
|
success: true,
|
||||||
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
|
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
|
||||||
template_id: '333333333'
|
template_id: '333333333'
|
||||||
@@ -365,7 +365,7 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
|||||||
|
|
||||||
it 'allows access when agent is assigned to inbox' do
|
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(:get_template_status).and_return({ success: false })
|
||||||
allow(mock_service).to receive(:create_csat_template).and_return({
|
allow(mock_service).to receive(:create_template).and_return({
|
||||||
success: true,
|
success: true,
|
||||||
template_name: 'customer_satisfaction_survey',
|
template_name: 'customer_satisfaction_survey',
|
||||||
template_id: '444444444'
|
template_id: '444444444'
|
||||||
|
|||||||
@@ -8,11 +8,8 @@ describe Twilio::SendOnTwilioService do
|
|||||||
let(:message_record_double) { double }
|
let(:message_record_double) { double }
|
||||||
|
|
||||||
let!(:account) { create(:account) }
|
let!(:account) { create(:account) }
|
||||||
let!(:widget_inbox) { create(:inbox, account: account) }
|
|
||||||
let!(:twilio_sms) { create(:channel_twilio_sms, 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_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) { create(:contact, account: account) }
|
||||||
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: twilio_inbox) }
|
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: twilio_inbox) }
|
||||||
let(:conversation) { create(:conversation, contact: contact, inbox: twilio_inbox, contact_inbox: contact_inbox) }
|
let(:conversation) { create(:conversation, contact: contact, inbox: twilio_inbox, contact_inbox: contact_inbox) }
|
||||||
@@ -23,6 +20,10 @@ describe Twilio::SendOnTwilioService do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe '#perform' do
|
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
|
context 'without reply' do
|
||||||
it 'if message is private' do
|
it 'if message is private' do
|
||||||
message = create(:message, message_type: 'outgoing', private: true, inbox: twilio_inbox, account: account)
|
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')
|
expect(outgoing_message.reload.status).to eq('failed')
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user