From d57170712dcddf1656ce50601236d1008720a830 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Tue, 23 Dec 2025 13:59:20 +0700 Subject: [PATCH 01/43] fix: increase the alfred connection pool size to 10 (#13138) ## Description Increased the alfred pool size to 10, so that each worker has enough redis connection thread pool to work ## Type of change Please delete options that are not relevant. - [ ] Bug fix (non-breaking change which fixes an issue) ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- > [!NOTE] > Introduces configurability for the Redis pool used by `alfred`. > > - `$alfred` `ConnectionPool` size now reads from `ENV['REDIS_ALFRED_SIZE']` with a default of `5` > - Adds `REDIS_ALFRED_SIZE=10` to `.env.example` > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 96cdff8c0ea40f82a57d70be053780e87384ed47. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: tanmay --- .env.example | 2 ++ config/initializers/01_redis.rb | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index d5c7a76f9..55750b2f2 100644 --- a/.env.example +++ b/.env.example @@ -274,3 +274,5 @@ AZURE_APP_SECRET= # Set to true if you want to remove stale contact inboxes # contact_inboxes with no conversation older than 90 days will be removed # REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false + +# REDIS_ALFRED_SIZE=10 diff --git a/config/initializers/01_redis.rb b/config/initializers/01_redis.rb index 93c12fce7..84ae88d29 100644 --- a/config/initializers/01_redis.rb +++ b/config/initializers/01_redis.rb @@ -5,7 +5,8 @@ # Alfred # Add here as you use it for more features # Used for Round Robin, Conversation Emails & Online Presence -$alfred = ConnectionPool.new(size: 5, timeout: 1) do +alfred_size = ENV.fetch('REDIS_ALFRED_SIZE', 5) +$alfred = ConnectionPool.new(size: alfred_size, timeout: 1) do redis = Rails.env.test? ? MockRedis.new : Redis.new(Redis::Config.app) Redis::Namespace.new('alfred', redis: redis, warning: true) end From 79381a4c5ff3cd1a2470b927dc3429c1c87c88c5 Mon Sep 17 00:00:00 2001 From: Pranav Date: Tue, 30 Dec 2025 11:25:54 -0800 Subject: [PATCH 02/43] 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 03/43] 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 04/43] 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 From e58600d1b9f1606a50d44858b6f596cd77b62212 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:23:54 +0530 Subject: [PATCH 05/43] fix: the webhook url to be text (#13157) ## Description Change the url type from string to text, to support more than 255 characters Fixes # (issue) https://app.chatwoot.com/app/accounts/1/conversations/65240 ## Type of change Please delete options that are not relevant. - [ ] Bug fix (non-breaking change which fixes an issue) ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- app/models/webhook.rb | 2 +- db/migrate/20251229081141_change_webhook_url_to_text.rb | 9 +++++++++ db/schema.rb | 4 ++-- 3 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20251229081141_change_webhook_url_to_text.rb diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 3fafb4354..1d61c1614 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -5,7 +5,7 @@ # id :bigint not null, primary key # name :string # subscriptions :jsonb -# url :string +# url :text # webhook_type :integer default("account_type") # created_at :datetime not null # updated_at :datetime not null diff --git a/db/migrate/20251229081141_change_webhook_url_to_text.rb b/db/migrate/20251229081141_change_webhook_url_to_text.rb new file mode 100644 index 000000000..633fd5b45 --- /dev/null +++ b/db/migrate/20251229081141_change_webhook_url_to_text.rb @@ -0,0 +1,9 @@ +class ChangeWebhookUrlToText < ActiveRecord::Migration[7.1] + def up + change_column :webhooks, :url, :text + end + + def down + change_column :webhooks, :url, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 82dcd37f6..ebdcffed0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2025_11_19_161025) do +ActiveRecord::Schema[7.1].define(version: 2025_12_29_081141) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -1239,7 +1239,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_11_19_161025) do create_table "webhooks", force: :cascade do |t| t.integer "account_id" t.integer "inbox_id" - t.string "url" + t.text "url" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "webhook_type", default: 0 From 02ab856520e7246cbfbaede557601e0adb3e64a3 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 7 Jan 2026 12:45:54 +0530 Subject: [PATCH 06/43] feat(CW-6187): include headers from incoming emails (#13139) --- app/presenters/mail_presenter.rb | 11 +++++++++ spec/mailboxes/reply_mailbox_spec.rb | 6 ++--- spec/presenters/mail_presenter_spec.rb | 34 ++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/app/presenters/mail_presenter.rb b/app/presenters/mail_presenter.rb index 202c255a1..08e370cd8 100644 --- a/app/presenters/mail_presenter.rb +++ b/app/presenters/mail_presenter.rb @@ -95,6 +95,7 @@ class MailPresenter < SimpleDelegator content_type: content_type, date: date, from: from, + headers: headers_data, html_content: html_content, in_reply_to: in_reply_to, message_id: message_id, @@ -136,6 +137,16 @@ class MailPresenter < SimpleDelegator from_email_address(@mail[:reply_to].try(:value)) || @mail['X-Original-Sender'].try(:value) || from_email_address(from.first) end + def headers_data + headers = { + 'x-original-from' => @mail['X-Original-From']&.value, + 'x-original-sender' => @mail['X-Original-Sender']&.value, + 'x-forwarded-for' => @mail['X-Forwarded-For']&.value + }.compact + + headers.presence + end + def from_email_address(email) Mail::Address.new(email).address end diff --git a/spec/mailboxes/reply_mailbox_spec.rb b/spec/mailboxes/reply_mailbox_spec.rb index 2658731bc..d062c7d73 100644 --- a/spec/mailboxes/reply_mailbox_spec.rb +++ b/spec/mailboxes/reply_mailbox_spec.rb @@ -12,8 +12,8 @@ RSpec.describe ReplyMailbox do let(:conversation) { create(:conversation, assignee: agent, inbox: create(:inbox, account: account, greeting_enabled: false), account: account) } let(:described_subject) { described_class.receive reply_mail } let(:serialized_attributes) do - %w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject text_content to - auto_reply] + %w[bcc cc content_type date from headers html_content in_reply_to message_id multipart number_of_attachments references subject text_content + to auto_reply] end context 'with reply uuid present' do @@ -397,7 +397,7 @@ RSpec.describe ReplyMailbox do let(:support_in_reply_to_mail) { create_inbound_email_from_fixture('support_in_reply_to.eml') } let(:described_subject) { described_class.receive support_mail } let(:serialized_attributes) do - %w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject + %w[bcc cc content_type date from headers html_content in_reply_to message_id multipart number_of_attachments references subject text_content to auto_reply] end let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last } diff --git a/spec/presenters/mail_presenter_spec.rb b/spec/presenters/mail_presenter_spec.rb index b9b23c8ce..bbe51c52d 100644 --- a/spec/presenters/mail_presenter_spec.rb +++ b/spec/presenters/mail_presenter_spec.rb @@ -41,6 +41,7 @@ RSpec.describe MailPresenter do :content_type, :date, :from, + :headers, :html_content, :in_reply_to, :message_id, @@ -60,6 +61,39 @@ RSpec.describe MailPresenter do expect(data[:auto_reply]).to eq(decorated_mail.auto_reply?) end + it 'includes forwarded headers in serialized_data' do + mail_with_headers = Mail.new do + from 'Sender ' + to 'Inbox ' + subject :header + body 'Hi' + header['X-Original-From'] = 'Original ' + header['X-Original-Sender'] = 'original@example.com' + header['X-Forwarded-For'] = 'forwarder@example.com' + end + + data = described_class.new(mail_with_headers).serialized_data + + expect(data[:headers]).to eq( + 'x-original-from' => 'Original ', + 'x-original-sender' => 'original@example.com', + 'x-forwarded-for' => 'forwarder@example.com' + ) + end + + it 'returns nil headers when forwarding headers are missing' do + mail_without_headers = Mail.new do + from 'Sender ' + to 'Inbox ' + subject :header + body 'Hi' + end + + data = described_class.new(mail_without_headers).serialized_data + + expect(data[:headers]).to be_nil + end + it 'give email from in downcased format' do expect(decorated_mail.from.first.eql?(mail.from.first.downcase)).to be true end From 566de02385ce216ae25e7831411db105ad81ca8c Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 7 Jan 2026 13:57:43 +0530 Subject: [PATCH 07/43] feat: allow agent bot and captain responses to reset waiting since (#13181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When AgentBot responds to customer messages, the `waiting_since` timestamp is not reset, causing inflated reply time metrics when a human agent eventually responds. This results in inaccurate reporting that incorrectly includes periods when customers were satisfied with bot responses. ### Timeline from Production Data ``` Dec 12, 16:20:14 - Customer sends message (ID: 368451924) ↓ waiting_since = Dec 12, 16:20:14 Dec 12, 16:20:17 - AgentBot replies (ID: 368451960) ↓ waiting_since STILL = Dec 12, 16:20:14 ❌ ↓ (Bot response doesn't clear it) 14-day gap - Customer satisfied, no messages ↓ waiting_since STILL = Dec 12, 16:20:14 ❌ Dec 26, 22:25:45 - Customer sends new message (ID: 383522275) ↓ waiting_since STILL = Dec 12, 16:20:14 ❌ ↓ (New message doesn't reset it) Dec 26-27 - More AgentBot interactions ↓ waiting_since STILL = Dec 12, 16:20:14 ❌ Dec 27, 07:36:53 - Human agent finally replies (ID: 383799517) ↓ Reply time calculated: 1,268,404 seconds ↓ = 14.7 DAYS ❌ ``` ## Root Cause The core issues is in `app/models/message.rb`, where **AgentBot messages does not clear `waiting_since`** - The `human_response?` method only returns true for `User` senders, so bot replies never trigger the clearing logic. This means once `waiting_since` is set, it stays set even when customers send new messages after receiving bot responses. The solution is to simply reset `waiting_since` **after a bot has responded**. This ensures reply time metrics reflect actual human agent response times, not bot-handled periods. ### What triggers the rest This is an intentional "gotcha", that only `AgentBot` and `Captain::Assistant` messages trigger the waiting time reset. Automation and campaign messages maintain current behavior (no reset). This is because interactive bot assistants provide conversational help that might satisfy customers. Automation and campaigns are one-way communications and shouldn't affect waiting time calculations. ## Related Work Extends PR #11787 which fixed `waiting_since` clearing on conversation resolution. This PR addresses the bot interaction scenario which was not covered by that fix. Scripts to clean data: https://gist.github.com/scmmishra/bd133208e219d0ab52fbfdf03036c48a --- app/models/message.rb | 24 ++++++++-- spec/enterprise/models/message_spec.rb | 3 +- spec/models/conversation_spec.rb | 65 ++++++++++++++++++++++++++ spec/models/message_spec.rb | 57 ++++++++++++++++++++++ 4 files changed, 143 insertions(+), 6 deletions(-) diff --git a/app/models/message.rb b/app/models/message.rb index 0bf7a176b..3be04c54f 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -322,12 +322,21 @@ class Message < ApplicationRecord end def update_waiting_since - if human_response? && !private && conversation.waiting_since.present? - Rails.configuration.dispatcher.dispatch( - REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self - ) - conversation.update(waiting_since: nil) + waiting_present = conversation.waiting_since.present? + + if waiting_present && !private + if human_response? + Rails.configuration.dispatcher.dispatch( + REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self + ) + conversation.update(waiting_since: nil) + elsif bot_response? + # Bot responses also clear waiting_since (simpler than checking on next customer message) + conversation.update(waiting_since: nil) + end end + + # Set waiting_since when customer sends a message (if currently blank) conversation.update(waiting_since: created_at) if incoming? && conversation.waiting_since.blank? end @@ -341,6 +350,11 @@ class Message < ApplicationRecord sender.is_a?(User) end + def bot_response? + # Check if this is a response from AgentBot or Captain::Assistant + outgoing? && sender_type.in?(['AgentBot', 'Captain::Assistant']) + end + def dispatch_create_events Rails.configuration.dispatcher.dispatch(MESSAGE_CREATED, Time.zone.now, message: self, performed_by: Current.executed_by) diff --git a/spec/enterprise/models/message_spec.rb b/spec/enterprise/models/message_spec.rb index 5a9dc4e27..aa1537e65 100644 --- a/spec/enterprise/models/message_spec.rb +++ b/spec/enterprise/models/message_spec.rb @@ -14,8 +14,9 @@ RSpec.describe Message do create(:message, message_type: :outgoing, conversation: conversation, sender: captain_assistant) + # Captain::Assistant responses clear waiting_since (like AgentBot) expect(conversation.first_reply_created_at).to be_nil - expect(conversation.waiting_since).to be_within(0.000001.seconds).of(conversation.created_at) + expect(conversation.waiting_since).to be_nil create(:message, message_type: :outgoing, conversation: conversation) diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index 7a5398456..5a4acf329 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -978,5 +978,70 @@ RSpec.describe Conversation do reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id) expect(reply_events.count).to eq(0) end + + context 'when AgentBot responds between customer messages' do + let(:agent_bot) { create(:agent_bot, account: account) } + + def create_bot_message(conversation, created_at: Time.current) + message = nil + perform_enqueued_jobs do + message = create(:message, + message_type: 'outgoing', + account: conversation.account, + inbox: conversation.inbox, + conversation: conversation, + sender: agent_bot, + created_at: created_at) + end + message + end + + it 'calculates reply time from the most recent customer message after bot response' do + # Initial conversation: customer message -> agent first reply (to establish first_reply_created_at) + create_customer_message(conversation, created_at: 10.hours.ago) + create_agent_message(conversation, created_at: 9.hours.ago) + + # Customer message 1 + create_customer_message(conversation, created_at: 5.hours.ago) + + # Bot responds + create_bot_message(conversation, created_at: 4.hours.ago) + + # Customer message 2 (after bot response) - should reset waiting_since + create_customer_message(conversation, created_at: 2.hours.ago) + + # Human agent replies - should create reply_time event from customer message 2 + create_agent_message(conversation, created_at: 1.hour.ago) + + reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id) + expect(reply_events.count).to eq(1) # Only the second agent reply creates a reply_time event + # Reply time should be 1 hour (from customer message 2 to agent reply) + expect(reply_events.first.value).to be_within(60).of(3600) + end + + it 'handles multiple bot responses before customer messages again' do + # Initial conversation: customer message -> agent first reply + create_customer_message(conversation, created_at: 10.hours.ago) + create_agent_message(conversation, created_at: 9.hours.ago) + + # Customer message 1 + create_customer_message(conversation, created_at: 6.hours.ago) + + # Bot responds multiple times + create_bot_message(conversation, created_at: 5.hours.ago) + create_bot_message(conversation, created_at: 4.hours.ago) + + # Customer message 2 (after multiple bot responses) - should reset waiting_since + create_customer_message(conversation, created_at: 2.hours.ago) + + # Human agent replies + create_agent_message(conversation, created_at: 1.hour.ago) + + reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id) + expect(reply_events.count).to eq(1) # Only the second agent reply creates a reply_time event + # Reply time should be 1 hour (from customer message 2 to agent reply) + expect(reply_events.first.value).to be_within(60).of(3600) + end + end end end diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb index 8905f5103..d606c266d 100644 --- a/spec/models/message_spec.rb +++ b/spec/models/message_spec.rb @@ -300,6 +300,63 @@ RSpec.describe Message do expect(conversation.waiting_since).to eq old_waiting_since end + + context 'when bot has responded to the conversation' do + let(:agent_bot) { create(:agent_bot, account: conversation.account) } + + before do + # Create initial customer message + create(:message, conversation: conversation, message_type: :incoming, + created_at: 2.hours.ago) + conversation.update(waiting_since: 2.hours.ago) + + # Bot responds + create(:message, conversation: conversation, message_type: :outgoing, + sender: agent_bot, created_at: 1.hour.ago) + end + + it 'resets waiting_since when customer sends a new message after bot response' do + new_message = build(:message, conversation: conversation, message_type: :incoming) + new_message.save! + + conversation.reload + expect(conversation.waiting_since).to be_within(1.second).of(new_message.created_at) + end + + it 'does not reset waiting_since if last response was from human agent' do + # Human agent responds (clears waiting_since) + create(:message, conversation: conversation, message_type: :outgoing, + sender: agent) + conversation.reload + expect(conversation.waiting_since).to be_nil + + # Customer sends new message + new_message = build(:message, conversation: conversation, message_type: :incoming) + new_message.save! + + conversation.reload + expect(conversation.waiting_since).to be_within(1.second).of(new_message.created_at) + end + + it 'clears waiting_since when bot responds' do + # After the bot response in before block, waiting_since should already be cleared + conversation.reload + expect(conversation.waiting_since).to be_nil + + # Customer sends another message + create(:message, conversation: conversation, message_type: :incoming, + created_at: 30.minutes.ago) + conversation.reload + expect(conversation.waiting_since).to be_within(1.second).of(30.minutes.ago) + + # Another bot response should clear it again + create(:message, conversation: conversation, message_type: :outgoing, + sender: agent_bot, created_at: 15.minutes.ago) + + conversation.reload + expect(conversation.waiting_since).to be_nil + end + end end context 'with webhook_data' do From 59cbf57e2022808573455a2e536b5ec2d6d49de4 Mon Sep 17 00:00:00 2001 From: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com> Date: Wed, 7 Jan 2026 15:30:49 +0530 Subject: [PATCH 08/43] feat: Advanced Search Backend (#12917) ## Description Implements comprehensive search functionality with advanced filtering capabilities for Chatwoot (Linear: CW-5956). This PR adds: 1. **Time-based filtering** for contacts and conversations (SQL-based search) 2. **Advanced message search** with multiple filters (OpenSearch/Elasticsearch-based) - **`from` filter**: Filter messages by sender (format: `contact:42` or `agent:5`) - **`inbox_id` filter**: Filter messages by specific inbox - **Time range filters**: Filter messages using `since` and `until` parameters (Unix timestamps in seconds) - **90-day limit enforcement**: Automatically limits searches to the last 90 days to prevent performance issues The implementation extends the existing `Enterprise::SearchService` module for advanced features and adds time filtering to the base `SearchService` for SQL-based searches. ## API Documentation ### Base URL All search endpoints follow this pattern: ``` GET /api/v1/accounts/{account_id}/search/{resource} ``` ### Authentication All requests require authentication headers: ``` api_access_token: YOUR_ACCESS_TOKEN ``` --- ## 1. Search All Resources **Endpoint:** `GET /api/v1/accounts/{account_id}/search` Returns results from all searchable resources (contacts, conversations, messages, articles). ### Parameters | Parameter | Type | Description | Required | |-----------|------|-------------|----------| | `q` | string | Search query | Yes | | `page` | integer | Page number (15 items per page) | No | | `since` | integer | Unix timestamp (contacts/conversations only) | No | | `until` | integer | Unix timestamp (contacts/conversations only) | No | ### Example Request ```bash curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search?q=customer" \ -H "api_access_token: YOUR_ACCESS_TOKEN" ``` ### Example Response ```json { "payload": { "contacts": [...], "conversations": [...], "messages": [...], "articles": [...] } } ``` --- ## 2. Search Contacts **Endpoint:** `GET /api/v1/accounts/{account_id}/search/contacts` Search contacts by name, email, phone number, or identifier with optional time filtering. ### Parameters | Parameter | Type | Description | Required | |-----------|------|-------------|----------| | `q` | string | Search query | Yes | | `page` | integer | Page number (15 items per page) | No | | `since` | integer | Unix timestamp - filter by last_activity_at | No | | `until` | integer | Unix timestamp - filter by last_activity_at | No | ### Example Requests **Basic search:** ```bash curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/contacts?q=john" \ -H "api_access_token: YOUR_ACCESS_TOKEN" ``` **Search contacts active in the last 7 days:** ```bash SINCE=$(date -v-7d +%s) curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/contacts?q=john&since=${SINCE}" \ -H "api_access_token: YOUR_ACCESS_TOKEN" ``` **Search contacts active between 30 and 7 days ago:** ```bash SINCE=$(date -v-30d +%s) UNTIL=$(date -v-7d +%s) curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/contacts?q=john&since=${SINCE}&until=${UNTIL}" \ -H "api_access_token: YOUR_ACCESS_TOKEN" ``` ### Example Response ```json { "payload": { "contacts": [ { "id": 42, "email": "john@example.com", "name": "John Doe", "phone_number": "+1234567890", "identifier": "user_123", "additional_attributes": {}, "created_at": 1701234567 } ] } } ``` --- ## 3. Search Conversations **Endpoint:** `GET /api/v1/accounts/{account_id}/search/conversations` Search conversations by display ID, contact name, email, phone number, or identifier with optional time filtering. ### Parameters | Parameter | Type | Description | Required | |-----------|------|-------------|----------| | `q` | string | Search query | Yes | | `page` | integer | Page number (15 items per page) | No | | `since` | integer | Unix timestamp - filter by last_activity_at | No | | `until` | integer | Unix timestamp - filter by last_activity_at | No | ### Example Requests **Basic search:** ```bash curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/conversations?q=billing" \ -H "api_access_token: YOUR_ACCESS_TOKEN" ``` **Search conversations active in the last 24 hours:** ```bash SINCE=$(date -v-1d +%s) curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/conversations?q=billing&since=${SINCE}" \ -H "api_access_token: YOUR_ACCESS_TOKEN" ``` **Search conversations from last month:** ```bash SINCE=$(date -v-30d +%s) UNTIL=$(date +%s) curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/conversations?q=billing&since=${SINCE}&until=${UNTIL}" \ -H "api_access_token: YOUR_ACCESS_TOKEN" ``` ### Example Response ```json { "payload": { "conversations": [ { "id": 123, "display_id": 45, "inbox_id": 1, "status": "open", "messages": [...], "meta": {...} } ] } } ``` --- ## 4. Search Messages (Advanced) **Endpoint:** `GET /api/v1/accounts/{account_id}/search/messages` Advanced message search with multiple filters powered by OpenSearch/Elasticsearch. ### Prerequisites - OpenSearch/Elasticsearch must be running (`OPENSEARCH_URL` env var configured) - Account must have `advanced_search` feature flag enabled - Messages must be indexed in OpenSearch ### Parameters | Parameter | Type | Description | Required | |-----------|------|-------------|----------| | `q` | string | Search query | Yes | | `page` | integer | Page number (15 items per page) | No | | `from` | string | Filter by sender: `contact:{id}` or `agent:{id}` | No | | `inbox_id` | integer | Filter by specific inbox ID | No | | `since` | integer | Unix timestamp - searches from this time (max 90 days ago) | No | | `until` | integer | Unix timestamp - searches until this time | No | ### Important Notes - **90-Day Limit**: If `since` is not provided, searches default to the last 90 days - If `since` exceeds 90 days, returns `422` error: "Search is limited to the last 90 days" - All time filters use message `created_at` timestamp ### Example Requests **Basic message search:** ```bash curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund" \ -H "api_access_token: YOUR_ACCESS_TOKEN" ``` **Search messages from a specific contact:** ```bash curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&from=contact:42" \ -H "api_access_token: YOUR_ACCESS_TOKEN" ``` **Search messages from a specific agent:** ```bash curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&from=agent:5" \ -H "api_access_token: YOUR_ACCESS_TOKEN" ``` **Search messages in a specific inbox:** ```bash curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&inbox_id=3" \ -H "api_access_token: YOUR_ACCESS_TOKEN" ``` **Search messages from the last 7 days:** ```bash SINCE=$(date -v-7d +%s) curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&since=${SINCE}" \ -H "api_access_token: YOUR_ACCESS_TOKEN" ``` **Search messages between specific dates:** ```bash SINCE=$(date -v-30d +%s) UNTIL=$(date -v-7d +%s) curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&since=${SINCE}&until=${UNTIL}" \ -H "api_access_token: YOUR_ACCESS_TOKEN" ``` **Combine all filters:** ```bash SINCE=$(date -v-14d +%s) curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&from=contact:42&inbox_id=3&since=${SINCE}" \ -H "api_access_token: YOUR_ACCESS_TOKEN" ``` **Attempt to search beyond 90 days (returns error):** ```bash SINCE=$(date -v-120d +%s) curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&since=${SINCE}" \ -H "api_access_token: YOUR_ACCESS_TOKEN" ``` ### Example Response (Success) ```json { "payload": { "messages": [ { "id": 789, "content": "I need a refund for my purchase", "message_type": "incoming", "created_at": 1701234567, "conversation_id": 123, "inbox_id": 3, "sender": { "id": 42, "type": "contact" } } ] } } ``` ### Example Response (90-day limit exceeded) ```json { "error": "Search is limited to the last 90 days" } ``` **Status Code:** `422 Unprocessable Entity` --- ## 5. Search Articles **Endpoint:** `GET /api/v1/accounts/{account_id}/search/articles` Search help center articles by title or content. ### Parameters | Parameter | Type | Description | Required | |-----------|------|-------------|----------| | `q` | string | Search query | Yes | | `page` | integer | Page number (15 items per page) | No | ### Example Request ```bash curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/articles?q=installation" \ -H "api_access_token: YOUR_ACCESS_TOKEN" ``` ### Example Response ```json { "payload": { "articles": [ { "id": 456, "title": "Installation Guide", "slug": "installation-guide", "portal_slug": "help", "account_id": 1, "category_name": "Getting Started", "status": "published", "updated_at": 1701234567 } ] } } ``` --- ## Technical Implementation ### SQL-Based Search (Contacts, Conversations, Articles) - Uses PostgreSQL `ILIKE` queries by default - Optional GIN index support via `search_with_gin` feature flag for better performance - Time filtering uses `last_activity_at` for contacts/conversations - Returns paginated results (15 per page) ### Advanced Search (Messages) - Powered by OpenSearch/Elasticsearch via Searchkick gem - Requires `OPENSEARCH_URL` environment variable - Requires `advanced_search` account feature flag - Enforces 90-day lookback limit via `Limits::MESSAGE_SEARCH_TIME_RANGE_LIMIT_DAYS` - Validates inbox access permissions before filtering - Returns paginated results (15 per page) --- ## Type of change - [x] New feature (non-breaking change which adds functionality) - [x] Enhancement (improves existing functionality) --- ## How Has This Been Tested? ### Unit Tests - **Contact Search Tests**: 3 new test cases for time filtering (`since`, `until`, combined) - **Conversation Search Tests**: 3 new test cases for time filtering - **Message Search Tests**: 10+ test cases covering: - Individual filters (`from`, `inbox_id`, time range) - Combined filters - Permission validation for inbox access - Feature flag checks - 90-day limit enforcement - Error handling for exceeded time limits ### Test Commands ```bash # Run all search controller tests bundle exec rspec spec/controllers/api/v1/accounts/search_controller_spec.rb # Run search service tests (includes enterprise specs) bundle exec rspec spec/services/search_service_spec.rb ``` ### Manual Testing Setup A rake task is provided to create 50,000 test messages across multiple inboxes: ```bash # 1. Create test data bundle exec rake search:setup_test_data # 2. Start OpenSearch mise elasticsearch-start # 3. Reindex messages rails runner "Message.search_index.import Message.all" # 4. Enable feature flag rails runner "Account.first.enable_features('advanced_search')" # 5. Test via API or Rails console ``` --- ## Checklist - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation (this PR description) - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- ## Additional Notes ### Requirements - **OpenSearch/Elasticsearch**: Required for advanced message search - Set `OPENSEARCH_URL` environment variable - Example: `export OPENSEARCH_URL=http://localhost:9200` - **Feature Flags**: - `advanced_search`: Account-level flag for message advanced search - `search_with_gin` (optional): Account-level flag for GIN-based SQL search ### Performance Considerations - 90-day limit prevents expensive long-range queries on large datasets - GIN indexes recommended for high-volume search on SQL-based resources - OpenSearch/Elasticsearch provides faster full-text search for messages ### Breaking Changes - None. All new parameters are optional and backward compatible. ### Frontend Integration - Frontend PR tracking advanced search UI will consume these endpoints - Time range pickers should convert JavaScript `Date` to Unix timestamps (seconds) - Date conversion: `Math.floor(date.getTime() / 1000)` ### Error Handling - Invalid `from` parameter format is silently ignored (filter not applied) - Time range exceeding 90 days returns `422` with error message - Missing `q` parameter returns `422` (existing behavior) - Unauthorized inbox access is filtered out (no error, just excluded from results) --------- Co-authored-by: iamsivin Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Pranav Co-authored-by: Muhsin Keloth --- .circleci/config.yml | 44 ++ .../api/v1/accounts/search_controller.rb | 2 + app/javascript/dashboard/api/search.js | 18 +- .../dashboard/api/specs/search.spec.js | 134 +++++ .../dashboard/components-next/CardLayout.vue | 2 +- .../dropdown-menu/DropdownMenu.vue | 35 +- .../components-next/message/chips/Audio.vue | 6 +- .../components-next/tabbar/TabBar.vue | 24 +- .../dashboard/constants/localStorage.js | 1 + app/javascript/dashboard/featureFlags.js | 1 + .../dashboard/i18n/locale/en/search.json | 46 +- .../search/components/MessageContent.vue | 114 ++-- .../modules/search/components/ReadMore.vue | 38 -- .../search/components/RecentSearches.vue | 116 +++++ .../components/SearchContactAgentSelector.vue | 239 +++++++++ .../components/SearchDateRangeSelector.vue | 271 ++++++++++ .../search/components/SearchFilters.vue | 104 ++++ .../search/components/SearchHeader.vue | 119 ++--- .../search/components/SearchInboxSelector.vue | 125 +++++ .../modules/search/components/SearchInput.vue | 136 +++++ .../components/SearchResultArticleItem.vue | 88 +++- .../components/SearchResultArticlesList.vue | 7 +- .../components/SearchResultContactItem.vue | 138 ++++- .../components/SearchResultContactsList.vue | 6 +- .../SearchResultConversationItem.vue | 111 ++-- .../SearchResultConversationsList.vue | 9 +- .../components/SearchResultMessageItem.vue | 169 ++++++ .../components/SearchResultMessagesList.vue | 16 +- .../search/components/SearchResultSection.vue | 18 +- .../modules/search/components/SearchTabs.vue | 44 +- .../modules/search/components/SearchView.vue | 110 +++- .../search/components/TranscribedText.vue | 45 ++ .../modules/search/helpers/searchHelper.js | 75 +++ .../search/helpers/specs/searchHelper.spec.js | 379 ++++++++++++++ .../store/modules/conversationSearch.js | 33 +- .../specs/conversationSearch/actions.spec.js | 26 + .../specs/useExpandableContent.spec.js | 130 +++++ .../composables/useExpandableContent.js | 62 +++ app/services/search_service.rb | 117 ++++- .../v1/accounts/search/_article.json.jbuilder | 2 + .../v1/accounts/search/_contact.json.jbuilder | 2 + config/locales/en.yml | 3 + .../app/services/enterprise/search_service.rb | 77 ++- lib/limits.rb | 1 + lib/tasks/search_test_data.rake | 183 +++++++ .../api/v1/accounts/search_controller_spec.rb | 240 +++++++++ spec/lib/webhooks/trigger_spec.rb | 2 + spec/services/search_service_spec.rb | 493 ++++++++++++++++++ spec/support/opensearch_check.rb | 40 ++ 49 files changed, 3816 insertions(+), 385 deletions(-) create mode 100644 app/javascript/dashboard/api/specs/search.spec.js delete mode 100644 app/javascript/dashboard/modules/search/components/ReadMore.vue create mode 100644 app/javascript/dashboard/modules/search/components/RecentSearches.vue create mode 100644 app/javascript/dashboard/modules/search/components/SearchContactAgentSelector.vue create mode 100644 app/javascript/dashboard/modules/search/components/SearchDateRangeSelector.vue create mode 100644 app/javascript/dashboard/modules/search/components/SearchFilters.vue create mode 100644 app/javascript/dashboard/modules/search/components/SearchInboxSelector.vue create mode 100644 app/javascript/dashboard/modules/search/components/SearchInput.vue create mode 100644 app/javascript/dashboard/modules/search/components/SearchResultMessageItem.vue create mode 100644 app/javascript/dashboard/modules/search/components/TranscribedText.vue create mode 100644 app/javascript/dashboard/modules/search/helpers/searchHelper.js create mode 100644 app/javascript/dashboard/modules/search/helpers/specs/searchHelper.spec.js create mode 100644 app/javascript/shared/composables/specs/useExpandableContent.spec.js create mode 100644 app/javascript/shared/composables/useExpandableContent.js create mode 100644 lib/tasks/search_test_data.rake create mode 100644 spec/support/opensearch_check.rb diff --git a/.circleci/config.yml b/.circleci/config.yml index 804c63857..24119bc75 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -218,6 +218,49 @@ jobs: source ~/.rvm/scripts/rvm bundle install + # Install and configure OpenSearch + - run: + name: Install OpenSearch + command: | + # Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients) + wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz + tar -xzf opensearch-2.11.0-linux-x64.tar.gz + sudo mv opensearch-2.11.0 /opt/opensearch + + - run: + name: Configure and Start OpenSearch + command: | + # Configure OpenSearch for single-node testing + cat > /opt/opensearch/config/opensearch.yml \<< EOF + cluster.name: chatwoot-test + node.name: node-1 + network.host: 0.0.0.0 + http.port: 9200 + discovery.type: single-node + plugins.security.disabled: true + EOF + + # Set ownership and permissions + sudo chown -R $USER:$USER /opt/opensearch + + # Start OpenSearch in background + /opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid + + - run: + name: Wait for OpenSearch to be ready + command: | + echo "Waiting for OpenSearch to start..." + for i in {1..30}; do + if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then + echo "OpenSearch is ready!" + exit 0 + fi + echo "Waiting... ($i/30)" + sleep 2 + done + echo "OpenSearch failed to start" + exit 1 + # Configure environment and database - run: name: Database Setup and Configure Environment Variables @@ -234,6 +277,7 @@ jobs: sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env echo -en "\nINSTALLATION_ENV=circleci" >> ".env" + echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env" # Database setup - run: diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb index 13e3a6a6c..7ee25e02e 100644 --- a/app/controllers/api/v1/accounts/search_controller.rb +++ b/app/controllers/api/v1/accounts/search_controller.rb @@ -28,5 +28,7 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController search_type: search_type, params: params ).perform + rescue ArgumentError => e + render json: { error: e.message }, status: :unprocessable_entity end end diff --git a/app/javascript/dashboard/api/search.js b/app/javascript/dashboard/api/search.js index d533c2f28..10214f3f5 100644 --- a/app/javascript/dashboard/api/search.js +++ b/app/javascript/dashboard/api/search.js @@ -14,38 +14,48 @@ class SearchAPI extends ApiClient { }); } - contacts({ q, page = 1 }) { + contacts({ q, page = 1, since, until }) { return axios.get(`${this.url}/contacts`, { params: { q, page: page, + since, + until, }, }); } - conversations({ q, page = 1 }) { + conversations({ q, page = 1, since, until }) { return axios.get(`${this.url}/conversations`, { params: { q, page: page, + since, + until, }, }); } - messages({ q, page = 1 }) { + messages({ q, page = 1, since, until, from, inboxId }) { return axios.get(`${this.url}/messages`, { params: { q, page: page, + since, + until, + from, + inbox_id: inboxId, }, }); } - articles({ q, page = 1 }) { + articles({ q, page = 1, since, until }) { return axios.get(`${this.url}/articles`, { params: { q, page: page, + since, + until, }, }); } diff --git a/app/javascript/dashboard/api/specs/search.spec.js b/app/javascript/dashboard/api/specs/search.spec.js new file mode 100644 index 000000000..251ea760e --- /dev/null +++ b/app/javascript/dashboard/api/specs/search.spec.js @@ -0,0 +1,134 @@ +import searchAPI from '../search'; +import ApiClient from '../ApiClient'; + +describe('#SearchAPI', () => { + it('creates correct instance', () => { + expect(searchAPI).toBeInstanceOf(ApiClient); + expect(searchAPI).toHaveProperty('get'); + expect(searchAPI).toHaveProperty('contacts'); + expect(searchAPI).toHaveProperty('conversations'); + expect(searchAPI).toHaveProperty('messages'); + expect(searchAPI).toHaveProperty('articles'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + get: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + vi.clearAllMocks(); + }); + + it('#get', () => { + searchAPI.get({ q: 'test query' }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search', { + params: { q: 'test query' }, + }); + }); + + it('#contacts', () => { + searchAPI.contacts({ q: 'test', page: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', { + params: { q: 'test', page: 1, since: undefined, until: undefined }, + }); + }); + + it('#contacts with date filters', () => { + searchAPI.contacts({ + q: 'test', + page: 2, + since: 1700000000, + until: 1732000000, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', { + params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 }, + }); + }); + + it('#conversations', () => { + searchAPI.conversations({ q: 'test', page: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/search/conversations', + { + params: { q: 'test', page: 1, since: undefined, until: undefined }, + } + ); + }); + + it('#conversations with date filters', () => { + searchAPI.conversations({ + q: 'test', + page: 1, + since: 1700000000, + until: 1732000000, + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/search/conversations', + { + params: { q: 'test', page: 1, since: 1700000000, until: 1732000000 }, + } + ); + }); + + it('#messages', () => { + searchAPI.messages({ q: 'test', page: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', { + params: { + q: 'test', + page: 1, + since: undefined, + until: undefined, + from: undefined, + inbox_id: undefined, + }, + }); + }); + + it('#messages with all filters', () => { + searchAPI.messages({ + q: 'test', + page: 1, + since: 1700000000, + until: 1732000000, + from: 'contact:42', + inboxId: 10, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', { + params: { + q: 'test', + page: 1, + since: 1700000000, + until: 1732000000, + from: 'contact:42', + inbox_id: 10, + }, + }); + }); + + it('#articles', () => { + searchAPI.articles({ q: 'test', page: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', { + params: { q: 'test', page: 1, since: undefined, until: undefined }, + }); + }); + + it('#articles with date filters', () => { + searchAPI.articles({ + q: 'test', + page: 2, + since: 1700000000, + until: 1732000000, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', { + params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 }, + }); + }); + }); +}); diff --git a/app/javascript/dashboard/components-next/CardLayout.vue b/app/javascript/dashboard/components-next/CardLayout.vue index 462402167..166f5ea4c 100644 --- a/app/javascript/dashboard/components-next/CardLayout.vue +++ b/app/javascript/dashboard/components-next/CardLayout.vue @@ -19,7 +19,7 @@ const handleClick = () => { diff --git a/app/javascript/dashboard/components-next/message/chips/Audio.vue b/app/javascript/dashboard/components-next/message/chips/Audio.vue index 667d2d7b6..9c7a44b23 100644 --- a/app/javascript/dashboard/components-next/message/chips/Audio.vue +++ b/app/javascript/dashboard/components-next/message/chips/Audio.vue @@ -17,6 +17,10 @@ const { attachment } = defineProps({ type: Object, required: true, }, + showTranscribedText: { + type: Boolean, + default: true, + }, }); defineOptions({ @@ -182,7 +186,7 @@ const downloadAudio = async () => {
{{ attachment.transcribedText }} diff --git a/app/javascript/dashboard/components-next/tabbar/TabBar.vue b/app/javascript/dashboard/components-next/tabbar/TabBar.vue index 1b08b838c..fbe105825 100644 --- a/app/javascript/dashboard/components-next/tabbar/TabBar.vue +++ b/app/javascript/dashboard/components-next/tabbar/TabBar.vue @@ -1,5 +1,5 @@ diff --git a/app/javascript/dashboard/modules/search/components/ReadMore.vue b/app/javascript/dashboard/modules/search/components/ReadMore.vue deleted file mode 100644 index 7b4296c89..000000000 --- a/app/javascript/dashboard/modules/search/components/ReadMore.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - diff --git a/app/javascript/dashboard/modules/search/components/RecentSearches.vue b/app/javascript/dashboard/modules/search/components/RecentSearches.vue new file mode 100644 index 000000000..0c52ea3ac --- /dev/null +++ b/app/javascript/dashboard/modules/search/components/RecentSearches.vue @@ -0,0 +1,116 @@ + + +