From 79381a4c5ff3cd1a2470b927dc3429c1c87c88c5 Mon Sep 17 00:00:00 2001 From: Pranav Date: Tue, 30 Dec 2025 11:25:54 -0800 Subject: [PATCH 1/3] fix: Add code_block method to WhatsApp and Instagram markdown renderers (#13166) Problem: SystemStackError: stack level too deep occurred when rendering messages with indented content (4+ spaces) for WhatsApp, Instagram, and Facebook channels. Root Cause: CommonMarker::Renderer#code_block contains a self-recursive placeholder that must be overridden: ``` def code_block(node) code_block(node) # calls itself infinitely end ``` WhatsAppRenderer and InstagramRenderer were missing this override, causing infinite recursion when markdown with 4-space indentation (interpreted as code blocks) was rendered. Fix: Added code_block method to both renderers that outputs the node content as plain text: ``` def code_block(node) out(node.string_content) end ``` Fix https://linear.app/chatwoot/issue/CW-6217/systemstackerror-stack-level-too-deep-systemstackerror --- .../markdown_renderers/instagram_renderer.rb | 4 ++ .../markdown_renderers/whats_app_renderer.rb | 4 ++ .../markdown_renderer_service_spec.rb | 48 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/app/services/messages/markdown_renderers/instagram_renderer.rb b/app/services/messages/markdown_renderers/instagram_renderer.rb index 3b47577b2..4ceb6f38e 100644 --- a/app/services/messages/markdown_renderers/instagram_renderer.rb +++ b/app/services/messages/markdown_renderers/instagram_renderer.rb @@ -42,6 +42,10 @@ class Messages::MarkdownRenderers::InstagramRenderer < Messages::MarkdownRendere cr end + def code_block(node) + out(node.string_content) + end + def softbreak(_node) out("\n") end diff --git a/app/services/messages/markdown_renderers/whats_app_renderer.rb b/app/services/messages/markdown_renderers/whats_app_renderer.rb index 9f7dbab6e..8f98218cf 100644 --- a/app/services/messages/markdown_renderers/whats_app_renderer.rb +++ b/app/services/messages/markdown_renderers/whats_app_renderer.rb @@ -30,6 +30,10 @@ class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderer cr end + def code_block(node) + out(node.string_content) + end + def softbreak(_node) out("\n") end diff --git a/spec/services/messages/markdown_renderer_service_spec.rb b/spec/services/messages/markdown_renderer_service_spec.rb index 5b8e02e2f..e43b0d6d0 100644 --- a/spec/services/messages/markdown_renderer_service_spec.rb +++ b/spec/services/messages/markdown_renderer_service_spec.rb @@ -74,6 +74,24 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do expect(result.scan("\n").count).to eq(4) expect(result).to include("Para 1\n\n\n\nPara 2") end + + it 'renders code blocks as plain text' do + content = "```\ncode here\n```" + result = described_class.new(content, channel_type).render + expect(result.strip).to eq('code here') + end + + it 'renders indented code blocks as plain text preserving exact content' do + content = ' indented code line' + result = described_class.new(content, channel_type).render + expect(result).to eq('indented code line') + end + + it 'handles code blocks with emojis and special characters without stack overflow' do + content = " first line\n 🌐 second line\n" + result = described_class.new(content, channel_type).render + expect(result).to eq("first line\n🌐 second line") + end end context 'when channel is Channel::Instagram' do @@ -130,6 +148,24 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do expect(result.scan("\n").count).to eq(4) expect(result).to include("Para 1\n\n\n\nPara 2") end + + it 'renders code blocks as plain text' do + content = "```\ncode here\n```" + result = described_class.new(content, channel_type).render + expect(result.strip).to eq('code here') + end + + it 'renders indented code blocks as plain text preserving exact content' do + content = ' indented code line' + result = described_class.new(content, channel_type).render + expect(result).to eq('indented code line') + end + + it 'handles code blocks with emojis and special characters without stack overflow' do + content = " first line\n 🌐 second line\n" + result = described_class.new(content, channel_type).render + expect(result).to eq("first line\n🌐 second line") + end end context 'when channel is Channel::Line' do @@ -358,6 +394,18 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do expect(result).to include('1. first step') expect(result).to include('2. second step') end + + it 'renders code blocks as plain text' do + content = "```\ncode here\n```" + result = described_class.new(content, channel_type).render + expect(result.strip).to eq('code here') + end + + it 'handles code blocks with emojis and special characters without stack overflow' do + content = " first line\n 🌐 second line\n" + result = described_class.new(content, channel_type).render + expect(result).to eq("first line\n🌐 second line") + end end context 'when channel is Channel::TwilioSms' do From bd698cb12cf7f2681c3ba36a7a60008c5c0b38df Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 6 Jan 2026 10:38:36 +0400 Subject: [PATCH 2/3] feat: Add call-to-action template support for Twilio (#13179) Fixes https://linear.app/chatwoot/issue/CW-6228/add-call-to-action-template-support-for-twilio-whatsapp-templates Adds support for Twilio WhatsApp call-to-action templates, enabling customers to use URL button templates with variable inputs. CleanShot 2026-01-05 at 16 25
55@2x --- .../ContentTemplatesPicker.vue | 3 ++ .../i18n/locale/en/contentTemplates.json | 1 + app/javascript/shared/constants/messages.js | 1 + .../twilio/template_processor_service.rb | 4 +- app/services/twilio/template_sync_service.rb | 4 ++ .../twilio/template_sync_service_spec.rb | 52 ++++++++++++++++++- 6 files changed, 61 insertions(+), 4 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/conversation/ContentTemplates/ContentTemplatesPicker.vue b/app/javascript/dashboard/components/widgets/conversation/ContentTemplates/ContentTemplatesPicker.vue index 89d82fe0c..c36f38b73 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ContentTemplates/ContentTemplatesPicker.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ContentTemplates/ContentTemplatesPicker.vue @@ -41,6 +41,9 @@ const getTemplateType = template => { if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) { return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY'); } + if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.CALL_TO_ACTION) { + return t('CONTENT_TEMPLATES.PICKER.TYPES.CALL_TO_ACTION'); + } return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT'); }; diff --git a/app/javascript/dashboard/i18n/locale/en/contentTemplates.json b/app/javascript/dashboard/i18n/locale/en/contentTemplates.json index a9b1d54c4..79c2c8c64 100644 --- a/app/javascript/dashboard/i18n/locale/en/contentTemplates.json +++ b/app/javascript/dashboard/i18n/locale/en/contentTemplates.json @@ -28,6 +28,7 @@ "TYPES": { "MEDIA": "Media", "QUICK_REPLY": "Quick Reply", + "CALL_TO_ACTION": "Call to Action", "TEXT": "Text" } }, diff --git a/app/javascript/shared/constants/messages.js b/app/javascript/shared/constants/messages.js index 8e9d06beb..989aa12ca 100644 --- a/app/javascript/shared/constants/messages.js +++ b/app/javascript/shared/constants/messages.js @@ -164,4 +164,5 @@ export const TWILIO_CONTENT_TEMPLATE_TYPES = { TEXT: 'text', MEDIA: 'media', QUICK_REPLY: 'quick_reply', + CALL_TO_ACTION: 'call_to_action', }; diff --git a/app/services/twilio/template_processor_service.rb b/app/services/twilio/template_processor_service.rb index 0c0faf9b1..2801f743f 100644 --- a/app/services/twilio/template_processor_service.rb +++ b/app/services/twilio/template_processor_service.rb @@ -23,8 +23,8 @@ class Twilio::TemplateProcessorService def build_content_variables(template) case template['template_type'] - when 'text', 'quick_reply' - convert_text_template(template_params) # Text and quick reply templates use body variables + when 'text', 'quick_reply', 'call_to_action' + convert_text_template(template_params) # Text, quick reply and call-to-action templates use body variables when 'media' convert_media_template(template_params) else diff --git a/app/services/twilio/template_sync_service.rb b/app/services/twilio/template_sync_service.rb index d30a6cde9..747c50f43 100644 --- a/app/services/twilio/template_sync_service.rb +++ b/app/services/twilio/template_sync_service.rb @@ -63,6 +63,8 @@ class Twilio::TemplateSyncService 'media' elsif template_types.include?('twilio/quick-reply') 'quick_reply' + elsif template_types.include?('twilio/call-to-action') + 'call_to_action' elsif template_types.include?('twilio/catalog') 'catalog' else @@ -107,6 +109,8 @@ class Twilio::TemplateSyncService template_types['twilio/media']['body'] elsif template_types['twilio/quick-reply'] template_types['twilio/quick-reply']['body'] + elsif template_types['twilio/call-to-action'] + template_types['twilio/call-to-action']['body'] elsif template_types['twilio/catalog'] template_types['twilio/catalog']['body'] else diff --git a/spec/services/twilio/template_sync_service_spec.rb b/spec/services/twilio/template_sync_service_spec.rb index 26d2db0e2..472ae8821 100644 --- a/spec/services/twilio/template_sync_service_spec.rb +++ b/spec/services/twilio/template_sync_service_spec.rb @@ -81,7 +81,29 @@ RSpec.describe Twilio::TemplateSyncService do ) end - let(:templates) { [text_template, media_template, quick_reply_template, catalog_template] } + let(:call_to_action_template) do + instance_double( + Twilio::REST::Content::V1::ContentInstance, + sid: 'HX444555666', + friendly_name: 'payment_reminder', + language: 'en', + date_created: Time.current, + date_updated: Time.current, + variables: {}, + types: { + 'twilio/call-to-action' => { + 'body' => 'Hello, this is a gentle reminder regarding your RVA Astrology course fee.' \ + '\n\n• Vignana Course: ₹3,000\n• Panditha Course: ₹6,000' \ + '\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience', + 'actions' => [ + { 'id' => 'make_payment', 'title' => 'Make Payment', 'url' => 'https://example.com/payment' } + ] + } + } + ) + end + + let(:templates) { [text_template, media_template, quick_reply_template, catalog_template, call_to_action_template] } before do allow(twilio_channel).to receive(:send).and_call_original @@ -104,7 +126,7 @@ RSpec.describe Twilio::TemplateSyncService do twilio_channel.reload expect(twilio_channel.content_templates).to be_present expect(twilio_channel.content_templates['templates']).to be_an(Array) - expect(twilio_channel.content_templates['templates'].size).to eq(4) + expect(twilio_channel.content_templates['templates'].size).to eq(5) expect(twilio_channel.content_templates_last_updated).to be_within(1.second).of(Time.current) end end @@ -172,6 +194,32 @@ RSpec.describe Twilio::TemplateSyncService do ) end + it 'correctly formats call-to-action templates with variables' do + sync_service.call + + twilio_channel.reload + call_to_action_data = twilio_channel.content_templates['templates'].find do |t| + t['friendly_name'] == 'payment_reminder' + end + + expect(call_to_action_data).to include( + 'content_sid' => 'HX444555666', + 'friendly_name' => 'payment_reminder', + 'language' => 'en', + 'status' => 'approved', + 'template_type' => 'call_to_action', + 'media_type' => nil, + 'variables' => {}, + 'category' => 'utility' + ) + + expected_body = 'Hello, this is a gentle reminder regarding your RVA Astrology course fee.' \ + '\n\n• Vignana Course: ₹3,000\n• Panditha Course: ₹6,000' \ + '\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience' + expect(call_to_action_data['body']).to eq(expected_body) + expect(call_to_action_data['body']).to match(/{{date}}/) + end + it 'categorizes marketing templates correctly' do marketing_template = instance_double( Twilio::REST::Content::V1::ContentInstance, From 3e5b2979eb4d2916a0b4e67b17d88c6ded3e8514 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 6 Jan 2026 11:46:00 +0400 Subject: [PATCH 3/3] 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