From 3e5b2979eb4d2916a0b4e67b17d88c6ded3e8514 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 6 Jan 2026 11:46:00 +0400 Subject: [PATCH] feat: Add support for sending CSAT surveys via templates (Whatsapp Cloud) (#12787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR enables sending CSAT surveys on WhatsApp using approved WhatsApp message templates, ensuring survey delivery even after the 24-hour session window. The system now automatically creates, updates, and monitors WhatsApp CSAT templates without manual intervention. approved #### Why this change Previously, WhatsApp CSAT messages failed outside the 24-hour customer window. With this update: - CSAT surveys are delivered reliably using WhatsApp templates - Template creation happens automatically in the background - Users can modify survey content and recreate templates easily - Clear UI states show template approval status #### Screens & States
Default — No template configured yet default
Pending — Template submitted, awaiting Meta approval pending
Approved — Survey will be sent when conversation resolves approved
Rejected — Template rejected by Meta rejected
Not Found — Template missing in Meta Platform not-exist
Edit Template — Delete & recreate template on change edit-survey
#### Test Cases **1. First-time CSAT setup on WhatsApp inbox** - Enable CSAT - Enter message + button text - Save - Expected: Template created automatically, UI shows pending state **2. CSAT toggle without changing text** - Existing approved template - Toggle CSAT OFF → ON (no text change) - Expected: No confirmation alert, no template recreation **3. Editing only survey rules** - Modify labels or rule conditions only - Expected: No confirmation alert, template remains unchanged **4. Template text change** - Change survey message or button text - Save - Expected: - Confirmation dialog shown - On confirm → previous template deleted, new one created - On cancel → revert to previous values **5. Language change** - Change template language (e.g., en → es) - Expected: Confirmation dialog + new template on confirm **6. Sending survey** - Template approved → always send template - Template pending → send free-form within 24 hours only - Template rejected/missing → fallback to free-form (if within window) - Outside 24 hours & no approved template → activity log only **7. Non-WhatsApp inbox** - Enable CSAT for email/web inbox - Expected: No template logic triggered Fixes https://linear.app/chatwoot/issue/CW-6188/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 --- app/javascript/dashboard/api/inboxes.js | 10 + .../message/bubbles/Template/CSAT.vue | 28 ++ .../components/widgets/WootWriter/Editor.vue | 1 + app/javascript/dashboard/constants/editor.js | 5 + .../dashboard/i18n/locale/en/inboxMgmt.json | 30 ++ .../settingsPage/CustomerSatisfactionPage.vue | 350 +++++++++++++++++- .../ConfirmTemplateUpdateDialog.vue | 34 ++ .../dashboard/store/modules/inboxes.js | 16 + app/services/csat_survey_service.rb | 62 +++- spec/services/csat_survey_service_spec.rb | 268 +++++++++++++- 10 files changed, 791 insertions(+), 13 deletions(-) create mode 100644 app/javascript/dashboard/components-next/message/bubbles/Template/CSAT.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/components/ConfirmTemplateUpdateDialog.vue diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index 361b9472f..83ba3e9ba 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -32,6 +32,16 @@ class Inboxes extends CacheEnabledApiClient { syncTemplates(inboxId) { return axios.post(`${this.url}/${inboxId}/sync_templates`); } + + createCSATTemplate(inboxId, template) { + return axios.post(`${this.url}/${inboxId}/csat_template`, { + template, + }); + } + + getCSATTemplateStatus(inboxId) { + return axios.get(`${this.url}/${inboxId}/csat_template`); + } } export default new Inboxes(); diff --git a/app/javascript/dashboard/components-next/message/bubbles/Template/CSAT.vue b/app/javascript/dashboard/components-next/message/bubbles/Template/CSAT.vue new file mode 100644 index 000000000..d1c9ebb37 --- /dev/null +++ b/app/javascript/dashboard/components-next/message/bubbles/Template/CSAT.vue @@ -0,0 +1,28 @@ + + + diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 4b83556de..d26192bcd 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -859,6 +859,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor); max-height: none !important; min-height: 0 !important; padding: 0 !important; + display: none !important; } > .ProseMirror { diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index a1e4da28d..95ac94b69 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -140,6 +140,11 @@ export const FORMATTING = { nodes: [], menu: ['strong', 'em', 'link', 'undo', 'redo'], }, + 'Context::Plain': { + marks: [], + nodes: [], + menu: [], + }, }; // Editor menu options for Full Editor diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index fa15f5f4d..51cae8679 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -808,6 +808,35 @@ "LABEL": "Message", "PLACEHOLDER": "Please enter a message to show users with the form" }, + "BUTTON_TEXT": { + "LABEL": "Button text", + "PLACEHOLDER": "Please rate us" + }, + "LANGUAGE": { + "LABEL": "Language", + "PLACEHOLDER": "Select template language" + }, + "MESSAGE_PREVIEW": { + "LABEL": "Message preview", + "TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform." + }, + "TEMPLATE_STATUS": { + "APPROVED": "Approved by WhatsApp", + "PENDING": "Pending WhatsApp approval", + "REJECTED": "Meta rejected the template", + "DEFAULT": "Needs WhatsApp approval", + "NOT_FOUND": "The template does not exist in the Meta platform." + }, + "TEMPLATE_CREATION": { + "SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval", + "ERROR_MESSAGE": "Failed to create WhatsApp template" + }, + "TEMPLATE_UPDATE_DIALOG": { + "TITLE": "Edit survey details", + "DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval", + "CONFIRM": "Create new template", + "CANCEL": "Go back" + }, "SURVEY_RULE": { "LABEL": "Survey rule", "DESCRIPTION_PREFIX": "Send the survey if the conversation", @@ -819,6 +848,7 @@ "SELECT_PLACEHOLDER": "select labels" }, "NOTE": "Note: CSAT surveys are sent only once per conversation", + "WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.", "API": { "SUCCESS_MESSAGE": "CSAT settings updated successfully", "ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later." diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue index 807f1ad0f..9cf9c688f 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue @@ -3,15 +3,22 @@ import { reactive, onMounted, ref, defineProps, watch, computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { useAlert } from 'dashboard/composables'; import { useStore, useMapGetter } from 'dashboard/composables/store'; +import { useInbox } from 'dashboard/composables/useInbox'; import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages'; +import Icon from 'dashboard/components-next/icon/Icon.vue'; import WithLabel from 'v3/components/Form/WithLabel.vue'; import SectionLayout from 'dashboard/routes/dashboard/settings/account/components/SectionLayout.vue'; import CSATDisplayTypeSelector from './components/CSATDisplayTypeSelector.vue'; +import CSATTemplate from 'dashboard/components-next/message/bubbles/Template/CSAT.vue'; import Editor from 'dashboard/components-next/Editor/Editor.vue'; import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue'; import NextButton from 'dashboard/components-next/button/Button.vue'; import Switch from 'next/switch/Switch.vue'; +import Input from 'dashboard/components-next/input/Input.vue'; +import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue'; +import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js'; +import ConfirmTemplateUpdateDialog from './components/ConfirmTemplateUpdateDialog.vue'; const props = defineProps({ inbox: { type: Object, required: true }, @@ -21,6 +28,10 @@ const { t } = useI18n(); const store = useStore(); const labels = useMapGetter('labels/getLabels'); +const { isAWhatsAppCloudChannel: isWhatsAppChannel } = useInbox( + props.inbox?.id +); + const isUpdating = ref(false); const selectedLabelValues = ref([]); const currentLabel = ref(''); @@ -29,7 +40,19 @@ const state = reactive({ csatSurveyEnabled: false, displayType: 'emoji', message: '', + templateButtonText: 'Please rate us', surveyRuleOperator: 'contains', + templateLanguage: '', +}); + +const templateStatus = ref(null); +const templateLoading = ref(false); +const confirmDialog = ref(null); + +const originalTemplateValues = ref({ + message: '', + templateButtonText: '', + templateLanguage: '', }); const filterTypes = [ @@ -51,6 +74,59 @@ const labelOptions = computed(() => : [] ); +const languageOptions = computed(() => + languages.map(({ name, id }) => ({ label: `${name} (${id})`, value: id })) +); + +const messagePreviewData = computed(() => ({ + content: state.message || t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER'), +})); + +const shouldShowTemplateStatus = computed( + () => templateStatus.value && !templateLoading.value +); + +const templateApprovalStatus = computed(() => { + const statusMap = { + APPROVED: { + text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.APPROVED'), + icon: 'i-lucide-circle-check', + color: 'text-n-teal-11', + }, + PENDING: { + text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.PENDING'), + icon: 'i-lucide-clock', + color: 'text-n-amber-11', + }, + REJECTED: { + text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.REJECTED'), + icon: 'i-lucide-circle-x', + color: 'text-n-ruby-10', + }, + }; + + // Handle template not found case + if (templateStatus.value?.error === 'TEMPLATE_NOT_FOUND') { + return { + text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.NOT_FOUND'), + icon: 'i-lucide-alert-triangle', + color: 'text-n-ruby-10', + }; + } + + // Handle existing template with status + if (templateStatus.value?.template_exists && templateStatus.value.status) { + return statusMap[templateStatus.value.status] || statusMap.PENDING; + } + + // Default case - no template exists + return { + text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.DEFAULT'), + icon: 'i-lucide-stamp', + color: 'text-n-slate-11', + }; +}); + const initializeState = () => { if (!props.inbox) return; @@ -63,21 +139,63 @@ const initializeState = () => { const { display_type: displayType = CSAT_DISPLAY_TYPES.EMOJI, message = '', + button_text: buttonText = 'Please rate us', + language = 'en', survey_rules: surveyRules = {}, } = csat_config; state.displayType = displayType; state.message = message; + state.templateButtonText = buttonText; + state.templateLanguage = language; state.surveyRuleOperator = surveyRules.operator || 'contains'; selectedLabelValues.value = Array.isArray(surveyRules.values) ? [...surveyRules.values] : []; + + // Store original template values for change detection + if (isWhatsAppChannel.value) { + originalTemplateValues.value = { + message: state.message, + templateButtonText: state.templateButtonText, + templateLanguage: state.templateLanguage, + }; + } +}; + +const checkTemplateStatus = async () => { + if (!isWhatsAppChannel.value) return; + + try { + templateLoading.value = true; + const response = await store.dispatch('inboxes/getCSATTemplateStatus', { + inboxId: props.inbox.id, + }); + + // Handle case where template doesn't exist + if (!response.template_exists && response.error === 'Template not found') { + templateStatus.value = { + template_exists: false, + error: 'TEMPLATE_NOT_FOUND', + }; + } else { + templateStatus.value = response; + } + } catch (error) { + templateStatus.value = { + template_exists: false, + error: 'API_ERROR', + }; + } finally { + templateLoading.value = false; + } }; onMounted(() => { initializeState(); if (!labels.value?.length) store.dispatch('labels/get'); + if (isWhatsAppChannel.value) checkTemplateStatus(); }); watch(() => props.inbox, initializeState, { immediate: true }); @@ -105,6 +223,49 @@ const removeLabel = label => { } }; +// Check if template-related fields have changed +const hasTemplateChanges = () => { + if (!isWhatsAppChannel.value) return false; + + const original = originalTemplateValues.value; + return ( + original.message !== state.message || + original.templateButtonText !== state.templateButtonText || + original.templateLanguage !== state.templateLanguage + ); +}; + +// Check if there's an existing template +const hasExistingTemplate = () => { + const { template_exists, error } = templateStatus.value || {}; + return template_exists && !error; +}; + +// Check if we should create a template +const shouldCreateTemplate = () => { + // Create template if no existing template + if (!hasExistingTemplate()) { + return true; + } + + // Create template if there are changes to template fields + return hasTemplateChanges(); +}; + +// Build template config for saving +const buildTemplateConfig = () => { + if (!hasExistingTemplate()) return null; + + const { template_name, template_id, template, status } = + templateStatus.value || {}; + return { + name: template_name, + template_id, + language: template?.language || state.templateLanguage, + status, + }; +}; + const updateInbox = async attributes => { const payload = { id: props.inbox.id, @@ -115,31 +276,103 @@ const updateInbox = async attributes => { return store.dispatch('inboxes/updateInbox', payload); }; -const saveSettings = async () => { +const createTemplate = async () => { + if (!isWhatsAppChannel.value) return null; + + const response = await store.dispatch('inboxes/createCSATTemplate', { + inboxId: props.inbox.id, + template: { + message: state.message, + button_text: state.templateButtonText, + language: state.templateLanguage, + }, + }); + useAlert(t('INBOX_MGMT.CSAT.TEMPLATE_CREATION.SUCCESS_MESSAGE')); + return response.template; +}; + +const performSave = async () => { try { isUpdating.value = true; + let newTemplateData = null; + + // For WhatsApp channels, create template first if needed + if ( + isWhatsAppChannel.value && + state.csatSurveyEnabled && + shouldCreateTemplate() + ) { + try { + newTemplateData = await createTemplate(); + } catch (error) { + const errorMessage = + error.response?.data?.error || + t('INBOX_MGMT.CSAT.TEMPLATE_CREATION.ERROR_MESSAGE'); + useAlert(errorMessage); + return; + } + } const csatConfig = { display_type: state.displayType, message: state.message, + button_text: state.templateButtonText, + language: state.templateLanguage, survey_rules: { operator: state.surveyRuleOperator, values: selectedLabelValues.value, }, }; + // Use new template data if created, otherwise preserve existing template information + if (newTemplateData) { + csatConfig.template = { + name: newTemplateData.name, + template_id: newTemplateData.template_id, + language: newTemplateData.language, + status: newTemplateData.status, + created_at: new Date().toISOString(), + }; + } else { + const templateConfig = buildTemplateConfig(); + if (templateConfig) { + csatConfig.template = templateConfig; + } + } + await updateInbox({ csat_survey_enabled: state.csatSurveyEnabled, csat_config: csatConfig, }); useAlert(t('INBOX_MGMT.CSAT.API.SUCCESS_MESSAGE')); + checkTemplateStatus(); } catch (error) { useAlert(t('INBOX_MGMT.CSAT.API.ERROR_MESSAGE')); } finally { isUpdating.value = false; } }; + +const saveSettings = async () => { + // Check if we need to show confirmation dialog for WhatsApp template changes + if ( + isWhatsAppChannel.value && + state.csatSurveyEnabled && + hasExistingTemplate() && + hasTemplateChanges() + ) { + confirmDialog.value?.open(); + return; + } + + await performSave(); +}; + +const handleConfirmTemplateUpdate = async () => { + // We will delete the template before creating the template + await performSave(); +};
+ @@ -165,14 +400,97 @@ const saveSettings = async () => { /> - - - + + + + { >
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_PREFIX') }} {

- {{ $t('INBOX_MGMT.CSAT.NOTE') }} + {{ + isWhatsAppChannel + ? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE') + : $t('INBOX_MGMT.CSAT.NOTE') + }}

{
+ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/components/ConfirmTemplateUpdateDialog.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/components/ConfirmTemplateUpdateDialog.vue new file mode 100644 index 000000000..6df5c2918 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/components/ConfirmTemplateUpdateDialog.vue @@ -0,0 +1,34 @@ + + + diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js index ac7d1483e..c24a90599 100644 --- a/app/javascript/dashboard/store/modules/inboxes.js +++ b/app/javascript/dashboard/store/modules/inboxes.js @@ -83,6 +83,14 @@ export const getters = { return false; } + // Filter out CSAT templates (customer_satisfaction_survey and its versions) + if ( + template.name && + template.name.startsWith('customer_satisfaction_survey') + ) { + return false; + } + // Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates const hasUnsupportedComponents = template.components.some( component => @@ -344,6 +352,14 @@ export const actions = { throw new Error(error); } }, + createCSATTemplate: async (_, { inboxId, template }) => { + const response = await InboxesAPI.createCSATTemplate(inboxId, template); + return response.data; + }, + getCSATTemplateStatus: async (_, { inboxId }) => { + const response = await InboxesAPI.getCSATTemplateStatus(inboxId); + return response.data; + }, }; export const mutations = { diff --git a/app/services/csat_survey_service.rb b/app/services/csat_survey_service.rb index 15d63722c..9afddc6d3 100644 --- a/app/services/csat_survey_service.rb +++ b/app/services/csat_survey_service.rb @@ -4,7 +4,9 @@ class CsatSurveyService def perform return unless should_send_csat_survey? - if within_messaging_window? + if whatsapp_channel? && template_available_and_approved? + send_whatsapp_template_survey + elsif within_messaging_window? ::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform else create_csat_not_sent_activity_message @@ -35,6 +37,64 @@ class CsatSurveyService conversation.can_reply? end + def whatsapp_channel? + inbox.channel_type == 'Channel::Whatsapp' + end + + def template_available_and_approved? + template_config = inbox.csat_config&.dig('template') + return false unless template_config + + template_name = template_config['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(inbox.id) + + status_result = inbox.channel.provider_service.get_template_status(template_name) + + status_result[:success] && status_result[:template][:status] == 'APPROVED' + rescue StandardError => e + Rails.logger.error "Error checking 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) + + phone_number = conversation.contact_inbox.source_id + template_info = build_template_info(template_name, template_config) + message = build_csat_message + + message_id = inbox.channel.provider_service.send_template(phone_number, template_info, message) + + message.update!(source_id: message_id) if message_id.present? + rescue StandardError => e + Rails.logger.error "Error sending WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}" + end + + def build_template_info(template_name, template_config) + { + name: template_name, + lang_code: template_config['language'] || 'en', + parameters: [ + { + type: 'button', + sub_type: 'url', + index: '0', + parameters: [{ type: 'text', text: conversation.uuid }] + } + ] + } + end + + def build_csat_message + conversation.messages.build( + account: conversation.account, + inbox: inbox, + message_type: :outgoing, + content: inbox.csat_config&.dig('message') || 'Please rate this conversation', + content_type: :input_csat + ) + 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/spec/services/csat_survey_service_spec.rb b/spec/services/csat_survey_service_spec.rb index ecb5a75c6..6359fbda1 100644 --- a/spec/services/csat_survey_service_spec.rb +++ b/spec/services/csat_survey_service_spec.rb @@ -3,7 +3,9 @@ require 'rails_helper' describe CsatSurveyService do let(:account) { create(:account) } let(:inbox) { create(:inbox, account: account, csat_survey_enabled: true) } - let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :resolved) } + let(:contact) { create(:contact, account: account) } + let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: '+1234567890') } + let(:conversation) { create(:conversation, contact_inbox: contact_inbox, inbox: inbox, account: account, status: :resolved) } let(:service) { described_class.new(conversation: conversation) } describe '#perform' do @@ -87,5 +89,269 @@ describe CsatSurveyService do expect(Conversations::ActivityMessageJob).not_to have_received(:perform_later) end end + + context 'when it is a WhatsApp channel' do + let(:whatsapp_channel) do + create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', + sync_templates: false, validate_provider_config: false) + end + let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account, csat_survey_enabled: true) } + let(:whatsapp_contact) { create(:contact, account: account) } + let(:whatsapp_contact_inbox) { create(:contact_inbox, contact: whatsapp_contact, inbox: whatsapp_inbox, source_id: '1234567890') } + let(:whatsapp_conversation) do + create(:conversation, contact_inbox: whatsapp_contact_inbox, inbox: whatsapp_inbox, account: account, status: :resolved) + end + let(:whatsapp_service) { described_class.new(conversation: whatsapp_conversation) } + let(:mock_provider_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) } + + before do + allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(mock_provider_service) + allow(whatsapp_conversation).to receive(:can_reply?).and_return(true) + end + + context 'when template is available and approved' do + before do + setup_approved_template('customer_survey_template') + end + + it 'sends WhatsApp template survey instead of regular survey' do + mock_successful_template_send('template_message_id_123') + + whatsapp_service.perform + + expect(mock_provider_service).to have_received(:send_template).with( + '1234567890', + hash_including( + name: 'customer_survey_template', + lang_code: 'en', + parameters: array_including( + hash_including( + type: 'button', + sub_type: 'url', + index: '0', + parameters: array_including( + hash_including(type: 'text', text: whatsapp_conversation.uuid) + ) + ) + ) + ), + instance_of(Message) + ) + expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new) + end + + it 'updates message with returned message ID' do + mock_successful_template_send('template_message_id_123') + + whatsapp_service.perform + + csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last + expect(csat_message).to be_present + expect(csat_message.source_id).to eq('template_message_id_123') + end + + it 'builds correct template info with default template name' do + expected_template_name = "customer_satisfaction_survey_#{whatsapp_inbox.id}" + whatsapp_inbox.update(csat_config: { 'template' => {}, 'message' => 'Rate us' }) + allow(mock_provider_service).to receive(:get_template_status) + .with(expected_template_name) + .and_return({ success: true, template: { status: 'APPROVED' } }) + allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message| + message.save! + 'msg_id' + end + + whatsapp_service.perform + + expect(mock_provider_service).to have_received(:send_template).with( + '1234567890', + hash_including( + name: expected_template_name, + lang_code: 'en' + ), + anything + ) + end + + it 'builds CSAT message with correct attributes' do + allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message| + message.save! + 'msg_id' + end + + whatsapp_service.perform + + csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last + expect(csat_message.account).to eq(account) + expect(csat_message.inbox).to eq(whatsapp_inbox) + expect(csat_message.message_type).to eq('outgoing') + expect(csat_message.content).to eq('Please rate your experience') + expect(csat_message.content_type).to eq('input_csat') + end + + it 'uses default message when not configured' do + setup_approved_template('test', { 'template' => { 'name' => 'test' } }) + mock_successful_template_send('msg_id') + + whatsapp_service.perform + + csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last + expect(csat_message.content).to eq('Please rate this conversation') + end + end + + context 'when template is not available or not approved' do + it 'falls back to regular survey when template is pending' do + setup_template_with_status('pending_template', 'PENDING') + + whatsapp_service.perform + + expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation) + expect(csat_template).to have_received(:perform) + end + + it 'falls back to regular survey when template is rejected' do + setup_template_with_status('pending_template', 'REJECTED') + + whatsapp_service.perform + + expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation) + expect(csat_template).to have_received(:perform) + end + + it 'falls back to regular survey when template API call fails' do + allow(mock_provider_service).to receive(:get_template_status) + .with('pending_template') + .and_return({ success: false, error: 'Template not found' }) + + whatsapp_service.perform + + expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation) + expect(csat_template).to have_received(:perform) + end + + it 'falls back to regular survey when template status check raises error' do + allow(mock_provider_service).to receive(:get_template_status) + .and_raise(StandardError, 'API connection failed') + + whatsapp_service.perform + + expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation) + expect(csat_template).to have_received(:perform) + end + end + + context 'when no template is configured' do + it 'falls back to regular survey' do + whatsapp_service.perform + + expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation) + expect(csat_template).to have_received(:perform) + end + end + + context 'when template sending fails' do + before do + setup_approved_template('working_template', { + 'template' => { 'name' => 'working_template' }, + 'message' => 'Rate us' + }) + end + + it 'handles template sending errors gracefully' do + mock_template_send_failure('Template send failed') + + expect { whatsapp_service.perform }.not_to raise_error + + # Should still create the CSAT message even if sending fails + csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last + expect(csat_message).to be_present + expect(csat_message.source_id).to be_nil + end + + it 'does not update message when send_template returns nil' do + mock_template_send_with_no_id + + whatsapp_service.perform + + csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last + expect(csat_message).to be_present + expect(csat_message.source_id).to be_nil + end + end + + context 'when outside messaging window' do + before do + allow(whatsapp_conversation).to receive(:can_reply?).and_return(false) + end + + it 'sends template survey even when outside messaging window if template is approved' do + setup_approved_template('approved_template', { 'template' => { 'name' => 'approved_template' } }) + mock_successful_template_send('msg_id') + + whatsapp_service.perform + + expect(mock_provider_service).to have_received(:send_template) + expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new) + # No activity message should be created when template is successfully sent + end + + it 'creates activity message when template is not available and outside window' do + whatsapp_service.perform + + expect(Conversations::ActivityMessageJob).to have_received(:perform_later).with( + whatsapp_conversation, + hash_including(content: I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')) + ) + expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new) + end + end + end + end + + private + + def setup_approved_template(template_name, config = nil) + template_config = config || { + 'template' => { + 'name' => template_name, + 'language' => 'en' + }, + 'message' => 'Please rate your experience' + } + whatsapp_inbox.update(csat_config: template_config) + allow(mock_provider_service).to receive(:get_template_status) + .with(template_name) + .and_return({ success: true, template: { status: 'APPROVED' } }) + end + + def setup_template_with_status(template_name, status) + whatsapp_inbox.update(csat_config: { + 'template' => { 'name' => template_name } + }) + allow(mock_provider_service).to receive(:get_template_status) + .with(template_name) + .and_return({ success: true, template: { status: status } }) + end + + def mock_successful_template_send(message_id) + allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message| + message.save! + message_id + end + end + + def mock_template_send_failure(error_message = 'Template send failed') + allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message| + message.save! + raise StandardError, error_message + end + end + + def mock_template_send_with_no_id + allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message| + message.save! + nil + end end end