-
- {{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.TEMPLATE_BODY') }}
+
+
+
+ {{ t('WHATSAPP_TEMPLATES.PICKER.HEADER') || 'HEADER' }}
-
{{ getTemplatebody(template) }}
+
+ {{ getTemplateHeader(template).text }}
+
+
+ {{
+ t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT', {
+ format: getTemplateHeader(template).format,
+ }) ||
+ `${getTemplateHeader(template).format} ${t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT_FALLBACK')}`
+ }}
+
-
-
- {{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.CATEGORY') }}
+
+
+
+
+ {{ t('WHATSAPP_TEMPLATES.PICKER.BODY') || 'BODY' }}
-
{{ template.category }}
+
{{ getTemplateBody(template) }}
+
+
+
+
+
+ {{ t('WHATSAPP_TEMPLATES.PICKER.FOOTER') || 'FOOTER' }}
+
+
+ {{ getTemplateFooter(template).text }}
+
+
+
+
+
+
+ {{ t('WHATSAPP_TEMPLATES.PICKER.BUTTONS') || 'BUTTONS' }}
+
+
+
+ {{ button.text }}
+
+
+
+
+
+
+ {{ t('WHATSAPP_TEMPLATES.PICKER.CATEGORY') || 'CATEGORY' }}
+
+
{{ template.category }}
@@ -128,13 +191,13 @@ export default {
- {{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
+ {{ t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
{{ query }}
- {{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
+ {{ t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
diff --git a/app/javascript/dashboard/helper/specs/templateHelper.spec.js b/app/javascript/dashboard/helper/specs/templateHelper.spec.js
new file mode 100644
index 000000000..6e0661152
--- /dev/null
+++ b/app/javascript/dashboard/helper/specs/templateHelper.spec.js
@@ -0,0 +1,368 @@
+import {
+ replaceTemplateVariables,
+ buildTemplateParameters,
+ processVariable,
+ allKeysRequired,
+} from '../templateHelper';
+import { templates } from '../../store/modules/specs/inboxes/templateFixtures';
+
+describe('templateHelper', () => {
+ const technicianTemplate = templates.find(t => t.name === 'technician_visit');
+
+ describe('processVariable', () => {
+ it('should remove curly braces from variables', () => {
+ expect(processVariable('{{name}}')).toBe('name');
+ expect(processVariable('{{1}}')).toBe('1');
+ expect(processVariable('{{customer_id}}')).toBe('customer_id');
+ });
+ });
+
+ describe('allKeysRequired', () => {
+ it('should return true when all keys have values', () => {
+ const obj = { name: 'John', age: '30' };
+ expect(allKeysRequired(obj)).toBe(true);
+ });
+
+ it('should return false when some keys are empty', () => {
+ const obj = { name: 'John', age: '' };
+ expect(allKeysRequired(obj)).toBe(false);
+ });
+
+ it('should return true for empty object', () => {
+ expect(allKeysRequired({})).toBe(true);
+ });
+ });
+
+ describe('replaceTemplateVariables', () => {
+ const templateText =
+ "Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.";
+
+ it('should replace all variables with provided values', () => {
+ const processedParams = {
+ body: {
+ 1: 'John',
+ 2: '123 Main St',
+ 3: '2025-01-15',
+ 4: '10:00 AM',
+ 5: '2:00 PM',
+ },
+ };
+
+ const result = replaceTemplateVariables(templateText, processedParams);
+ expect(result).toBe(
+ "Hi John, we're scheduling a technician visit to 123 Main St on 2025-01-15 between 10:00 AM and 2:00 PM. Please confirm if this time slot works for you."
+ );
+ });
+
+ it('should keep original variable format when no replacement value provided', () => {
+ const processedParams = {
+ body: {
+ 1: 'John',
+ 3: '2025-01-15',
+ },
+ };
+
+ const result = replaceTemplateVariables(templateText, processedParams);
+ expect(result).toContain('John');
+ expect(result).toContain('2025-01-15');
+ expect(result).toContain('{{2}}');
+ expect(result).toContain('{{4}}');
+ expect(result).toContain('{{5}}');
+ });
+
+ it('should handle empty processedParams', () => {
+ const result = replaceTemplateVariables(templateText, {});
+ expect(result).toBe(templateText);
+ });
+ });
+
+ describe('buildTemplateParameters', () => {
+ it('should build parameters for template with body variables', () => {
+ const result = buildTemplateParameters(technicianTemplate, false);
+
+ expect(result.body).toEqual({
+ 1: '',
+ 2: '',
+ 3: '',
+ 4: '',
+ 5: '',
+ });
+ });
+
+ it('should include header parameters when hasMediaHeader is true', () => {
+ const imageTemplate = templates.find(
+ t => t.name === 'order_confirmation'
+ );
+ const result = buildTemplateParameters(imageTemplate, true);
+
+ expect(result.header).toEqual({
+ media_url: '',
+ media_type: 'image',
+ });
+ });
+
+ it('should not include header parameters when hasMediaHeader is false', () => {
+ const result = buildTemplateParameters(technicianTemplate, false);
+ expect(result.header).toBeUndefined();
+ });
+
+ it('should handle template with no body component', () => {
+ const templateWithoutBody = {
+ components: [{ type: 'HEADER', format: 'TEXT' }],
+ };
+
+ const result = buildTemplateParameters(templateWithoutBody, false);
+ expect(result).toEqual({});
+ });
+
+ it('should handle template with no variables', () => {
+ const templateWithoutVars = templates.find(
+ t => t.name === 'no_variable_template'
+ );
+ const result = buildTemplateParameters(templateWithoutVars, false);
+
+ expect(result.body).toBeUndefined();
+ });
+
+ it('should handle URL buttons with variables for non-authentication templates', () => {
+ const templateWithUrlButton = {
+ category: 'MARKETING',
+ components: [
+ {
+ type: 'BODY',
+ text: 'Check out our website at {{site_url}}',
+ },
+ {
+ type: 'BUTTONS',
+ buttons: [
+ {
+ type: 'URL',
+ url: 'https://example.com/{{campaign_id}}',
+ text: 'Visit Site',
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = buildTemplateParameters(templateWithUrlButton, false);
+ expect(result.buttons).toEqual([
+ {
+ type: 'url',
+ parameter: '',
+ url: 'https://example.com/{{campaign_id}}',
+ variables: ['campaign_id'],
+ },
+ ]);
+ });
+
+ it('should handle templates with no variables', () => {
+ const emptyTemplate = templates.find(
+ t => t.name === 'no_variable_template'
+ );
+ const result = buildTemplateParameters(emptyTemplate, false);
+ expect(result).toEqual({});
+ });
+
+ it('should build parameters for templates with multiple component types', () => {
+ const complexTemplate = {
+ components: [
+ { type: 'HEADER', format: 'IMAGE' },
+ { type: 'BODY', text: 'Hi {{1}}, your order {{2}} is ready!' },
+ { type: 'FOOTER', text: 'Thank you for your business' },
+ {
+ type: 'BUTTONS',
+ buttons: [{ type: 'URL', url: 'https://example.com/{{3}}' }],
+ },
+ ],
+ };
+
+ const result = buildTemplateParameters(complexTemplate, true);
+
+ expect(result.header).toEqual({
+ media_url: '',
+ media_type: 'image',
+ });
+ expect(result.body).toEqual({ 1: '', 2: '' });
+ expect(result.buttons).toEqual([
+ {
+ type: 'url',
+ parameter: '',
+ url: 'https://example.com/{{3}}',
+ variables: ['3'],
+ },
+ ]);
+ });
+
+ it('should handle copy code buttons correctly', () => {
+ const copyCodeTemplate = templates.find(
+ t => t.name === 'discount_coupon'
+ );
+ const result = buildTemplateParameters(copyCodeTemplate, false);
+
+ expect(result.body).toBeDefined();
+ expect(result.buttons).toEqual([
+ {
+ type: 'copy_code',
+ parameter: '',
+ },
+ ]);
+ });
+
+ it('should handle templates with document headers', () => {
+ const documentTemplate = templates.find(
+ t => t.name === 'purchase_receipt'
+ );
+ const result = buildTemplateParameters(documentTemplate, true);
+
+ expect(result.header).toEqual({
+ media_url: '',
+ media_type: 'document',
+ });
+ expect(result.body).toEqual({
+ 1: '',
+ 2: '',
+ 3: '',
+ });
+ });
+
+ it('should handle video header templates', () => {
+ const videoTemplate = templates.find(t => t.name === 'training_video');
+ const result = buildTemplateParameters(videoTemplate, true);
+
+ expect(result.header).toEqual({
+ media_url: '',
+ media_type: 'video',
+ });
+ expect(result.body).toEqual({
+ name: '',
+ date: '',
+ });
+ });
+ });
+
+ describe('enhanced format validation', () => {
+ it('should validate enhanced format structure', () => {
+ const processedParams = {
+ body: { 1: 'John', 2: 'Order123' },
+ header: {
+ media_url: 'https://example.com/image.jpg',
+ media_type: 'image',
+ },
+ buttons: [{ type: 'copy_code', parameter: 'SAVE20' }],
+ };
+
+ // Test that structure is properly formed
+ expect(processedParams.body).toBeDefined();
+ expect(typeof processedParams.body).toBe('object');
+ expect(processedParams.header).toBeDefined();
+ expect(Array.isArray(processedParams.buttons)).toBe(true);
+ });
+
+ it('should handle empty component sections', () => {
+ const processedParams = {
+ body: {},
+ header: {},
+ buttons: [],
+ };
+
+ expect(allKeysRequired(processedParams.body)).toBe(true);
+ expect(allKeysRequired(processedParams.header)).toBe(true);
+ expect(processedParams.buttons.length).toBe(0);
+ });
+
+ it('should validate parameter completeness', () => {
+ const incompleteParams = {
+ body: { 1: 'John', 2: '' },
+ };
+
+ expect(allKeysRequired(incompleteParams.body)).toBe(false);
+ });
+
+ it('should handle edge cases in processVariable', () => {
+ expect(processVariable('{{')).toBe('');
+ expect(processVariable('}}')).toBe('');
+ expect(processVariable('')).toBe('');
+ expect(processVariable('{{nested{{variable}}}}')).toBe('nestedvariable');
+ });
+
+ it('should handle special characters in template variables', () => {
+ /* eslint-disable no-template-curly-in-string */
+ const templateText =
+ 'Welcome {{user_name}}, your order #{{order_id}} costs ${{amount}}';
+ /* eslint-enable no-template-curly-in-string */
+ const processedParams = {
+ body: {
+ user_name: 'John & Jane',
+ order_id: '12345',
+ amount: '99.99',
+ },
+ };
+
+ const result = replaceTemplateVariables(templateText, processedParams);
+ expect(result).toBe(
+ 'Welcome John & Jane, your order #12345 costs $99.99'
+ );
+ });
+
+ it('should handle templates with mixed parameter types', () => {
+ const mixedTemplate = {
+ components: [
+ { type: 'HEADER', format: 'VIDEO' },
+ { type: 'BODY', text: 'Order {{order_id}} status: {{status}}' },
+ { type: 'FOOTER', text: 'Thank you' },
+ {
+ type: 'BUTTONS',
+ buttons: [
+ { type: 'URL', url: 'https://track.com/{{order_id}}' },
+ { type: 'COPY_CODE' },
+ { type: 'PHONE_NUMBER', phone_number: '+1234567890' },
+ ],
+ },
+ ],
+ };
+
+ const result = buildTemplateParameters(mixedTemplate, true);
+
+ expect(result.header).toEqual({
+ media_url: '',
+ media_type: 'video',
+ });
+ expect(result.body).toEqual({
+ order_id: '',
+ status: '',
+ });
+ expect(result.buttons).toHaveLength(2); // URL and COPY_CODE (PHONE_NUMBER doesn't need parameters)
+ expect(result.buttons[0].type).toBe('url');
+ expect(result.buttons[1].type).toBe('copy_code');
+ });
+
+ it('should handle templates with no processable components', () => {
+ const emptyTemplate = {
+ components: [
+ { type: 'HEADER', format: 'TEXT', text: 'Static Header' },
+ { type: 'BODY', text: 'Static body with no variables' },
+ { type: 'FOOTER', text: 'Static footer' },
+ ],
+ };
+
+ const result = buildTemplateParameters(emptyTemplate, false);
+ expect(result).toEqual({});
+ });
+
+ it('should validate that replaceTemplateVariables preserves unreplaced variables', () => {
+ const templateText = 'Hi {{name}}, order {{order_id}} is {{status}}';
+ const partialParams = {
+ body: {
+ name: 'John',
+ // order_id missing
+ status: 'ready',
+ },
+ };
+
+ const result = replaceTemplateVariables(templateText, partialParams);
+ expect(result).toBe('Hi John, order {{order_id}} is ready');
+ expect(result).toContain('{{order_id}}'); // Unreplaced variable preserved
+ });
+ });
+});
diff --git a/app/javascript/dashboard/helper/templateHelper.js b/app/javascript/dashboard/helper/templateHelper.js
new file mode 100644
index 000000000..5c9bbff05
--- /dev/null
+++ b/app/javascript/dashboard/helper/templateHelper.js
@@ -0,0 +1,91 @@
+// Constants
+export const DEFAULT_LANGUAGE = 'en';
+export const DEFAULT_CATEGORY = 'UTILITY';
+export const COMPONENT_TYPES = {
+ HEADER: 'HEADER',
+ BODY: 'BODY',
+ BUTTONS: 'BUTTONS',
+};
+export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT'];
+
+export const findComponentByType = (template, type) =>
+ template.components?.find(component => component.type === type);
+
+export const processVariable = str => {
+ return str.replace(/{{|}}/g, '');
+};
+
+export const allKeysRequired = value => {
+ const keys = Object.keys(value);
+ return keys.every(key => value[key]);
+};
+
+export const replaceTemplateVariables = (templateText, processedParams) => {
+ return templateText.replace(/{{([^}]+)}}/g, (match, variable) => {
+ const variableKey = processVariable(variable);
+ return processedParams.body?.[variableKey] || `{{${variable}}}`;
+ });
+};
+
+export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
+ const allVariables = {};
+
+ const bodyComponent = findComponentByType(template, COMPONENT_TYPES.BODY);
+ const headerComponent = findComponentByType(template, COMPONENT_TYPES.HEADER);
+
+ if (!bodyComponent) return allVariables;
+
+ const templateString = bodyComponent.text;
+
+ // Process body variables
+ const matchedVariables = templateString.match(/{{([^}]+)}}/g);
+ if (matchedVariables) {
+ allVariables.body = {};
+ matchedVariables.forEach(variable => {
+ const key = processVariable(variable);
+ allVariables.body[key] = '';
+ });
+ }
+
+ if (hasMediaHeaderValue) {
+ if (!allVariables.header) allVariables.header = {};
+ allVariables.header.media_url = '';
+ allVariables.header.media_type = headerComponent.format.toLowerCase();
+ }
+
+ // Process button variables
+ const buttonComponents = template.components.filter(
+ component => component.type === COMPONENT_TYPES.BUTTONS
+ );
+
+ buttonComponents.forEach(buttonComponent => {
+ if (buttonComponent.buttons) {
+ buttonComponent.buttons.forEach((button, index) => {
+ // Handle URL buttons with variables
+ if (button.type === 'URL' && button.url && button.url.includes('{{')) {
+ const buttonVars = button.url.match(/{{([^}]+)}}/g) || [];
+ if (buttonVars.length > 0) {
+ if (!allVariables.buttons) allVariables.buttons = [];
+ allVariables.buttons[index] = {
+ type: 'url',
+ parameter: '',
+ url: button.url,
+ variables: buttonVars.map(v => processVariable(v)),
+ };
+ }
+ }
+
+ // Handle copy code buttons
+ if (button.type === 'COPY_CODE') {
+ if (!allVariables.buttons) allVariables.buttons = [];
+ allVariables.buttons[index] = {
+ type: 'copy_code',
+ parameter: '',
+ };
+ }
+ });
+ }
+ });
+
+ return allVariables;
+};
diff --git a/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json b/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json
index 4887d07b6..5f53faaa8 100644
--- a/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json
+++ b/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json
@@ -1,29 +1,46 @@
{
- "WHATSAPP_TEMPLATES": {
- "MODAL": {
- "TITLE": "Whatsapp Templates",
- "SUBTITLE": "Select the whatsapp template you want to send",
- "TEMPLATE_SELECTED_SUBTITLE": "Process {templateName}"
- },
- "PICKER": {
- "SEARCH_PLACEHOLDER": "Search Templates",
- "NO_TEMPLATES_FOUND": "No templates found for",
- "NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
- "REFRESH_BUTTON": "Refresh templates",
- "REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
- "REFRESH_ERROR": "Failed to refresh templates. Please try again.",
- "LABELS": {
- "LANGUAGE": "Language",
- "TEMPLATE_BODY": "Template Body",
- "CATEGORY": "Category"
- }
- },
- "PARSER": {
- "VARIABLES_LABEL": "Variables",
- "VARIABLE_PLACEHOLDER": "Enter {variable} value",
- "GO_BACK_LABEL": "Go Back",
- "SEND_MESSAGE_LABEL": "Send Message",
- "FORM_ERROR_MESSAGE": "Please fill all variables before sending"
- }
+ "WHATSAPP_TEMPLATES": {
+ "MODAL": {
+ "TITLE": "Whatsapp Templates",
+ "SUBTITLE": "Select the whatsapp template you want to send",
+ "TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
+ },
+ "PICKER": {
+ "SEARCH_PLACEHOLDER": "Search Templates",
+ "NO_TEMPLATES_FOUND": "No templates found for",
+ "HEADER": "Header",
+ "BODY": "Body",
+ "FOOTER": "Footer",
+ "BUTTONS": "Buttons",
+ "CATEGORY": "Category",
+ "MEDIA_CONTENT": "Media Content",
+ "MEDIA_CONTENT_FALLBACK": "media content",
+ "NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
+ "REFRESH_BUTTON": "Refresh templates",
+ "REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
+ "REFRESH_ERROR": "Failed to refresh templates. Please try again.",
+ "LABELS": {
+ "LANGUAGE": "Language",
+ "TEMPLATE_BODY": "Template Body",
+ "CATEGORY": "Category"
+ }
+ },
+ "PARSER": {
+ "VARIABLES_LABEL": "Variables",
+ "LANGUAGE": "Language",
+ "CATEGORY": "Category",
+ "VARIABLE_PLACEHOLDER": "Enter {variable} value",
+ "GO_BACK_LABEL": "Go Back",
+ "SEND_MESSAGE_LABEL": "Send Message",
+ "FORM_ERROR_MESSAGE": "Please fill all variables before sending",
+ "MEDIA_HEADER_LABEL": "{type} Header",
+ "OTP_CODE": "Enter 4-8 digit OTP",
+ "EXPIRY_MINUTES": "Enter expiry minutes",
+ "BUTTON_PARAMETERS": "Button Parameters",
+ "BUTTON_LABEL": "Button {index}",
+ "COUPON_CODE": "Enter coupon code (max 15 chars)",
+ "MEDIA_URL_LABEL": "Enter {type} URL",
+ "BUTTON_PARAMETER": "Enter button parameter"
}
+ }
}
diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js
index a6b93c923..c4789a7a9 100644
--- a/app/javascript/dashboard/store/modules/inboxes.js
+++ b/app/javascript/dashboard/store/modules/inboxes.js
@@ -44,15 +44,52 @@ export const getters = {
const messagesTemplates =
whatsAppMessageTemplates || apiInboxMessageTemplates;
- // filtering out the whatsapp templates with media
- if (messagesTemplates instanceof Array) {
- return messagesTemplates.filter(template => {
- return !template.components.some(
- i => i.format === 'IMAGE' || i.format === 'VIDEO'
- );
- });
+ return messagesTemplates;
+ },
+ getFilteredWhatsAppTemplates: $state => inboxId => {
+ const [inbox] = $state.records.filter(
+ record => record.id === Number(inboxId)
+ );
+
+ const {
+ message_templates: whatsAppMessageTemplates,
+ additional_attributes: additionalAttributes,
+ } = inbox || {};
+
+ const { message_templates: apiInboxMessageTemplates } =
+ additionalAttributes || {};
+ const templates = whatsAppMessageTemplates || apiInboxMessageTemplates;
+
+ if (!templates || !Array.isArray(templates)) {
+ return [];
}
- return [];
+
+ return templates.filter(template => {
+ // Ensure template has required properties
+ if (!template || !template.status || !template.components) {
+ return false;
+ }
+
+ // Only show approved templates
+ if (template.status.toLowerCase() !== 'approved') {
+ return false;
+ }
+
+ // Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
+ const hasUnsupportedComponents = template.components.some(
+ component =>
+ ['LIST', 'PRODUCT', 'CATALOG', 'CALL_PERMISSION_REQUEST'].includes(
+ component.type
+ ) ||
+ (component.type === 'HEADER' && component.format === 'LOCATION')
+ );
+
+ if (hasUnsupportedComponents) {
+ return false;
+ }
+
+ return true;
+ });
},
getNewConversationInboxes($state) {
return $state.records.filter(inbox => {
diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js b/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js
index f9ed57d63..eeb52b1dc 100644
--- a/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js
@@ -1,5 +1,6 @@
import { getters } from '../../inboxes';
import inboxList from './fixtures';
+import { templates } from './templateFixtures';
describe('#getters', () => {
it('getInboxes', () => {
@@ -93,4 +94,269 @@ describe('#getters', () => {
provider: 'default',
});
});
+
+ describe('getFilteredWhatsAppTemplates', () => {
+ it('returns empty array when inbox not found', () => {
+ const state = { records: [] };
+ expect(getters.getFilteredWhatsAppTemplates(state)(999)).toEqual([]);
+ });
+
+ it('returns empty array when templates is null or undefined', () => {
+ const state = {
+ records: [
+ {
+ id: 1,
+ channel_type: 'Channel::Whatsapp',
+ message_templates: null,
+ additional_attributes: { message_templates: undefined },
+ },
+ ],
+ };
+ expect(getters.getFilteredWhatsAppTemplates(state)(1)).toEqual([]);
+ });
+
+ it('returns empty array when templates is not an array', () => {
+ const state = {
+ records: [
+ {
+ id: 1,
+ channel_type: 'Channel::Whatsapp',
+ message_templates: 'invalid',
+ additional_attributes: {},
+ },
+ ],
+ };
+ expect(getters.getFilteredWhatsAppTemplates(state)(1)).toEqual([]);
+ });
+
+ it('filters out templates without required properties', () => {
+ const invalidTemplates = [
+ { name: 'incomplete_template' }, // missing status and components
+ { status: 'approved' }, // missing name and components
+ { name: 'another_incomplete', status: 'approved' }, // missing components
+ ];
+
+ const state = {
+ records: [
+ {
+ id: 1,
+ channel_type: 'Channel::Whatsapp',
+ message_templates: invalidTemplates,
+ },
+ ],
+ };
+ expect(getters.getFilteredWhatsAppTemplates(state)(1)).toEqual([]);
+ });
+
+ it('filters out non-approved templates', () => {
+ const mixedStatusTemplates = [
+ {
+ name: 'pending_template',
+ status: 'pending',
+ components: [{ type: 'BODY', text: 'Test' }],
+ },
+ {
+ name: 'rejected_template',
+ status: 'rejected',
+ components: [{ type: 'BODY', text: 'Test' }],
+ },
+ {
+ name: 'approved_template',
+ status: 'approved',
+ components: [{ type: 'BODY', text: 'Test' }],
+ },
+ ];
+
+ const state = {
+ records: [
+ {
+ id: 1,
+ channel_type: 'Channel::Whatsapp',
+ message_templates: mixedStatusTemplates,
+ },
+ ],
+ };
+
+ const result = getters.getFilteredWhatsAppTemplates(state)(1);
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('approved_template');
+ });
+
+ it('filters out interactive templates (LIST, PRODUCT, CATALOG)', () => {
+ const interactiveTemplates = [
+ {
+ name: 'list_template',
+ status: 'approved',
+ components: [
+ { type: 'BODY', text: 'Choose an option' },
+ { type: 'LIST', sections: [] },
+ ],
+ },
+ {
+ name: 'product_template',
+ status: 'approved',
+ components: [
+ { type: 'BODY', text: 'Product info' },
+ { type: 'PRODUCT', catalog_id: '123' },
+ ],
+ },
+ {
+ name: 'catalog_template',
+ status: 'approved',
+ components: [
+ { type: 'BODY', text: 'Catalog' },
+ { type: 'CATALOG', thumbnail_product_retailer_id: '123' },
+ ],
+ },
+ {
+ name: 'regular_template',
+ status: 'approved',
+ components: [{ type: 'BODY', text: 'Regular message' }],
+ },
+ ];
+
+ const state = {
+ records: [
+ {
+ id: 1,
+ channel_type: 'Channel::Whatsapp',
+ message_templates: interactiveTemplates,
+ },
+ ],
+ };
+
+ const result = getters.getFilteredWhatsAppTemplates(state)(1);
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('regular_template');
+ });
+
+ it('filters out location templates', () => {
+ const locationTemplates = [
+ {
+ name: 'location_template',
+ status: 'approved',
+ components: [
+ { type: 'HEADER', format: 'LOCATION' },
+ { type: 'BODY', text: 'Location message' },
+ ],
+ },
+ {
+ name: 'regular_template',
+ status: 'approved',
+ components: [
+ { type: 'HEADER', format: 'TEXT', text: 'Header' },
+ { type: 'BODY', text: 'Regular message' },
+ ],
+ },
+ ];
+
+ const state = {
+ records: [
+ {
+ id: 1,
+ channel_type: 'Channel::Whatsapp',
+ message_templates: locationTemplates,
+ },
+ ],
+ };
+
+ const result = getters.getFilteredWhatsAppTemplates(state)(1);
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('regular_template');
+ });
+
+ it('returns valid templates from fixture data', () => {
+ const state = {
+ records: [
+ {
+ id: 1,
+ channel_type: 'Channel::Whatsapp',
+ message_templates: templates,
+ },
+ ],
+ };
+
+ const result = getters.getFilteredWhatsAppTemplates(state)(1);
+
+ // All templates in fixtures should be approved and valid
+ expect(result.length).toBeGreaterThan(0);
+
+ // Verify all returned templates are approved
+ result.forEach(template => {
+ expect(template.status).toBe('approved');
+ expect(template.components).toBeDefined();
+ expect(Array.isArray(template.components)).toBe(true);
+ });
+
+ // Verify specific templates from fixtures are included
+ const templateNames = result.map(t => t.name);
+ expect(templateNames).toContain('sample_flight_confirmation');
+ expect(templateNames).toContain('sample_issue_resolution');
+ expect(templateNames).toContain('sample_shipping_confirmation');
+ expect(templateNames).toContain('no_variable_template');
+ expect(templateNames).toContain('order_confirmation');
+ });
+
+ it('prioritizes message_templates over additional_attributes.message_templates', () => {
+ const primaryTemplates = [
+ {
+ name: 'primary_template',
+ status: 'approved',
+ components: [{ type: 'BODY', text: 'Primary' }],
+ },
+ ];
+
+ const fallbackTemplates = [
+ {
+ name: 'fallback_template',
+ status: 'approved',
+ components: [{ type: 'BODY', text: 'Fallback' }],
+ },
+ ];
+
+ const state = {
+ records: [
+ {
+ id: 1,
+ channel_type: 'Channel::Whatsapp',
+ message_templates: primaryTemplates,
+ additional_attributes: {
+ message_templates: fallbackTemplates,
+ },
+ },
+ ],
+ };
+
+ const result = getters.getFilteredWhatsAppTemplates(state)(1);
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('primary_template');
+ });
+
+ it('falls back to additional_attributes.message_templates when message_templates is null', () => {
+ const fallbackTemplates = [
+ {
+ name: 'fallback_template',
+ status: 'approved',
+ components: [{ type: 'BODY', text: 'Fallback' }],
+ },
+ ];
+
+ const state = {
+ records: [
+ {
+ id: 1,
+ channel_type: 'Channel::Whatsapp',
+ message_templates: null,
+ additional_attributes: {
+ message_templates: fallbackTemplates,
+ },
+ },
+ ],
+ };
+
+ const result = getters.getFilteredWhatsAppTemplates(state)(1);
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('fallback_template');
+ });
+ });
});
diff --git a/app/javascript/shared/mixins/specs/whatsappTemplates/fixtures.js b/app/javascript/dashboard/store/modules/specs/inboxes/templateFixtures.js
similarity index 50%
rename from app/javascript/shared/mixins/specs/whatsappTemplates/fixtures.js
rename to app/javascript/dashboard/store/modules/specs/inboxes/templateFixtures.js
index 02c24b2bb..c4c6a0b40 100644
--- a/app/javascript/shared/mixins/specs/whatsappTemplates/fixtures.js
+++ b/app/javascript/dashboard/store/modules/specs/inboxes/templateFixtures.js
@@ -260,4 +260,285 @@ export const templates = [
],
rejected_reason: 'NONE',
},
+ {
+ name: 'order_confirmation',
+ status: 'approved',
+ category: 'TICKET_UPDATE',
+ language: 'en_US',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ type: 'HEADER',
+ format: 'IMAGE',
+ example: {
+ header_handle: ['https://example.com/shoes.jpg'],
+ },
+ },
+ {
+ text: 'Hi your order {{1}} is confirmed. Please wait for further updates',
+ type: 'BODY',
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'technician_visit',
+ status: 'approved',
+ category: 'UTILITY',
+ language: 'en_US',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ text: 'Technician visit',
+ type: 'HEADER',
+ format: 'TEXT',
+ },
+ {
+ text: "Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.",
+ type: 'BODY',
+ },
+ {
+ type: 'BUTTONS',
+ buttons: [
+ {
+ text: 'Confirm',
+ type: 'QUICK_REPLY',
+ },
+ {
+ text: 'Reschedule',
+ type: 'QUICK_REPLY',
+ },
+ ],
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'event_invitation_static',
+ status: 'approved',
+ category: 'MARKETING',
+ language: 'en',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ text: "You're invited to {{event_name}} at {{location}}, Join us for an amazing experience!",
+ type: 'BODY',
+ },
+ {
+ type: 'BUTTONS',
+ buttons: [
+ {
+ url: 'https://events.example.com/register',
+ text: 'Visit website',
+ type: 'URL',
+ },
+ {
+ url: 'https://maps.app.goo.gl/YoWAzRj1GDuxs6qz8',
+ text: 'Get Directions',
+ type: 'URL',
+ },
+ ],
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'purchase_receipt',
+ status: 'approved',
+ category: 'UTILITY',
+ language: 'en_US',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ type: 'HEADER',
+ format: 'DOCUMENT',
+ },
+ {
+ text: 'Thank you for using your {{1}} card at {{2}}. Your {{3}} is attached as a PDF.',
+ type: 'BODY',
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'discount_coupon',
+ status: 'approved',
+ category: 'MARKETING',
+ language: 'en',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ text: '🎉 Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
+ type: 'BODY',
+ },
+ {
+ type: 'BUTTONS',
+ buttons: [
+ {
+ text: 'Copy offer code',
+ type: 'COPY_CODE',
+ },
+ ],
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'support_callback',
+ status: 'approved',
+ category: 'UTILITY',
+ language: 'en',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ text: 'Hello {{name}}, our support team will call you regarding ticket # {{ticket_id}}.',
+ type: 'BODY',
+ },
+ {
+ type: 'BUTTONS',
+ buttons: [
+ {
+ text: 'Call Support',
+ type: 'PHONE_NUMBER',
+ phone_number: '+16506677566',
+ },
+ ],
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'training_video',
+ status: 'approved',
+ category: 'MARKETING',
+ language: 'en',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ type: 'HEADER',
+ format: 'VIDEO',
+ },
+ {
+ text: "Hi {{name}}, here's your training video. Please watch by{{date}}.",
+ type: 'BODY',
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'product_launch',
+ status: 'approved',
+ category: 'MARKETING',
+ language: 'en',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ type: 'HEADER',
+ format: 'IMAGE',
+ },
+ {
+ text: 'New arrival! Our stunning coat now available in {{color}} color.',
+ type: 'BODY',
+ },
+ {
+ text: 'Free shipping on orders over $100. Limited time offer.',
+ type: 'FOOTER',
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'greet',
+ status: 'approved',
+ category: 'MARKETING',
+ language: 'en',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ text: 'Hey {{customer_name}} how may I help you?',
+ type: 'BODY',
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'hello_world',
+ status: 'approved',
+ category: 'UTILITY',
+ language: 'en_US',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ text: 'Hello World',
+ type: 'HEADER',
+ format: 'TEXT',
+ },
+ {
+ text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
+ type: 'BODY',
+ },
+ {
+ text: 'WhatsApp Business Platform sample message',
+ type: 'FOOTER',
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'feedback_request',
+ status: 'approved',
+ category: 'MARKETING',
+ language: 'en',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ text: "Hey {{name}}, how was your experience with Puma? We'd love your feedback!",
+ type: 'BODY',
+ },
+ {
+ type: 'BUTTONS',
+ buttons: [
+ {
+ url: 'https://feedback.example.com/survey',
+ text: 'Leave Feedback',
+ type: 'URL',
+ },
+ ],
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'address_update',
+ status: 'approved',
+ category: 'UTILITY',
+ language: 'en_US',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ text: 'Address update',
+ type: 'HEADER',
+ format: 'TEXT',
+ },
+ {
+ text: 'Hi {{1}}, your delivery address has been successfully updated to {{2}}. Contact {{3}} for any inquiries.',
+ type: 'BODY',
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
+ {
+ name: 'delivery_confirmation',
+ status: 'approved',
+ category: 'UTILITY',
+ language: 'en_US',
+ namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ components: [
+ {
+ text: '{{1}}, your order was successfully delivered on {{2}}.\n\nThank you for your purchase.\n',
+ type: 'BODY',
+ },
+ ],
+ rejected_reason: 'NONE',
+ },
];
diff --git a/app/javascript/shared/mixins/specs/whatsappTemplates/whatsappTemplates.spec.js b/app/javascript/shared/mixins/specs/whatsappTemplates/whatsappTemplates.spec.js
deleted file mode 100644
index adad15bf7..000000000
--- a/app/javascript/shared/mixins/specs/whatsappTemplates/whatsappTemplates.spec.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import TemplateParser from '../../../../dashboard/components/widgets/conversation/WhatsappTemplates/TemplateParser.vue';
-import { templates } from './fixtures';
-import { nextTick } from 'vue';
-
-const config = {
- global: {
- stubs: {
- NextButton: { template: '
' },
- WootInput: { template: '
' },
- },
- },
-};
-
-describe('#WhatsAppTemplates', () => {
- it('returns all variables from a template string', async () => {
- const wrapper = shallowMount(TemplateParser, {
- ...config,
- props: { template: templates[0] },
- });
- await nextTick();
- expect(wrapper.vm.variables).toEqual(['{{1}}', '{{2}}', '{{3}}']);
- });
-
- it('returns no variables from a template string if it does not contain variables', async () => {
- const wrapper = shallowMount(TemplateParser, {
- ...config,
- props: { template: templates[12] },
- });
- await nextTick();
- expect(wrapper.vm.variables).toBeNull();
- });
-
- it('returns the body of a template', async () => {
- const wrapper = shallowMount(TemplateParser, {
- ...config,
- props: { template: templates[1] },
- });
- await nextTick();
- const expectedOutput =
- templates[1].components.find(i => i.type === 'BODY')?.text || '';
- expect(wrapper.vm.templateString).toEqual(expectedOutput);
- });
-
- it('generates the templates from variable input', async () => {
- const wrapper = shallowMount(TemplateParser, {
- ...config,
- props: { template: templates[0] },
- });
- await nextTick();
-
- // Instead of using `setData`, directly modify the `processedParams` using the component's logic
- await wrapper.vm.$nextTick();
- wrapper.vm.processedParams = { 1: 'abc', 2: 'xyz', 3: 'qwerty' };
- await wrapper.vm.$nextTick();
-
- const expectedOutput =
- 'Esta é a sua confirmação de voo para abc-xyz em qwerty.';
- expect(wrapper.vm.processedString).toEqual(expectedOutput);
- });
-});
From ad4ec9e93b792ca18c73654c07fad354e546bcab Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Tue, 12 Aug 2025 16:42:18 +0200
Subject: [PATCH 02/11] fix: Flaky Instagram webhook specs (#12170)
### Summary
Fixed flaky Instagram webhook specs that failed intermittently in cloud
environments due to shared let blocks creating conflicting inboxes. The
Instagram channel factory already creates an inbox automatically, but
tests were adding extra ones in shared contexts.
Moved channel/inbox creation to isolated test contexts to prevent race
conditions between Facebook Page and Instagram Direct tests.
### Testing
```
for i in {1..30}; do
echo "=== Run $i ==="
RAILS_ENV=test bundle exec rspec spec/jobs/webhooks/instagram_events_job_spec.rb --fail-fast || break
done
```
Previously, intermittent failures could be reproduced locally. With
these changes, tests achieve ~100% pass rate.
---
.../webhooks/instagram_events_job_spec.rb | 125 ++++++++----------
1 file changed, 57 insertions(+), 68 deletions(-)
diff --git a/spec/jobs/webhooks/instagram_events_job_spec.rb b/spec/jobs/webhooks/instagram_events_job_spec.rb
index 9edd9a34d..21f042f1f 100644
--- a/spec/jobs/webhooks/instagram_events_job_spec.rb
+++ b/spec/jobs/webhooks/instagram_events_job_spec.rb
@@ -10,23 +10,6 @@ describe Webhooks::InstagramEventsJob do
end
let!(:account) { create(:account) }
- let!(:instagram_messenger_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
- let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_messenger_channel, account: account, greeting_enabled: false) }
- let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') }
- let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
- # Combined message events into one helper
- let(:message_events) do
- {
- dm: build(:instagram_message_create_event).with_indifferent_access,
- standby: build(:instagram_message_standby_event).with_indifferent_access,
- unsend: build(:instagram_message_unsend_event).with_indifferent_access,
- attachment: build(:instagram_message_attachment_event).with_indifferent_access,
- story_mention: build(:instagram_story_mention_event).with_indifferent_access,
- story_mention_echo: build(:instagram_story_mention_event_with_echo).with_indifferent_access,
- messaging_seen: build(:messaging_seen_event).with_indifferent_access,
- unsupported: build(:instagram_message_unsupported_event).with_indifferent_access
- }
- end
def return_object_for(sender_id)
{ name: 'Jane',
@@ -38,21 +21,19 @@ describe Webhooks::InstagramEventsJob do
describe '#perform' do
context 'when handling messaging events for Instagram via Facebook page' do
+ let!(:instagram_messenger_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
+ let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_messenger_channel, account: account, greeting_enabled: false) }
let(:fb_object) { double }
- before do
- instagram_inbox.destroy
- end
-
it 'creates incoming message in the instagram inbox' do
+ dm_event = build(:instagram_message_create_event).with_indifferent_access
+ sender_id = dm_event[:entry][0][:messaging][0][:sender][:id]
+
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
- sender_id = message_events[:dm][:entry][0][:messaging][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access
)
- instagram_webhook.perform_now(message_events[:dm][:entry])
-
- instagram_messenger_inbox.reload
+ instagram_webhook.perform_now(dm_event[:entry])
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
@@ -62,14 +43,14 @@ describe Webhooks::InstagramEventsJob do
end
it 'creates standby message in the instagram inbox' do
+ standby_event = build(:instagram_message_standby_event).with_indifferent_access
+ sender_id = standby_event[:entry][0][:standby][0][:sender][:id]
+
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
- sender_id = message_events[:standby][:entry][0][:standby][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access
)
- instagram_webhook.perform_now(message_events[:standby][:entry])
-
- instagram_messenger_inbox.reload
+ instagram_webhook.perform_now(standby_event[:entry])
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
@@ -81,9 +62,11 @@ describe Webhooks::InstagramEventsJob do
end
it 'handle instagram unsend message event' do
+ unsend_event = build(:instagram_message_unsend_event).with_indifferent_access
+ sender_id = unsend_event[:entry][0][:messaging][0][:sender][:id]
+
message = create(:message, inbox_id: instagram_messenger_inbox.id, source_id: 'message-id-to-delete')
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
- sender_id = message_events[:unsend][:entry][0][:messaging][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
@@ -96,7 +79,7 @@ describe Webhooks::InstagramEventsJob do
expect(instagram_messenger_inbox.messages.count).to be 1
- instagram_webhook.perform_now(message_events[:unsend][:entry])
+ instagram_webhook.perform_now(unsend_event[:entry])
expect(instagram_messenger_inbox.messages.last.content).to eq 'This message was deleted'
expect(instagram_messenger_inbox.messages.last.deleted).to be true
@@ -105,14 +88,14 @@ describe Webhooks::InstagramEventsJob do
end
it 'creates incoming message with attachments in the instagram inbox' do
+ attachment_event = build(:instagram_message_attachment_event).with_indifferent_access
+ sender_id = attachment_event[:entry][0][:messaging][0][:sender][:id]
+
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
- sender_id = message_events[:attachment][:entry][0][:messaging][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access
)
- instagram_webhook.perform_now(message_events[:attachment][:entry])
-
- instagram_messenger_inbox.reload
+ instagram_webhook.perform_now(attachment_event[:entry])
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
@@ -120,8 +103,10 @@ describe Webhooks::InstagramEventsJob do
end
it 'creates incoming message with attachments in the instagram inbox for story mention' do
+ story_mention_event = build(:instagram_story_mention_event).with_indifferent_access
+ sender_id = story_mention_event[:entry][0][:messaging][0][:sender][:id]
+
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
- sender_id = message_events[:story_mention][:entry][0][:messaging][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access,
{ story:
@@ -137,9 +122,7 @@ describe Webhooks::InstagramEventsJob do
id: 'instagram-message-id-1234' }.with_indifferent_access
)
- instagram_webhook.perform_now(message_events[:story_mention][:entry])
-
- instagram_messenger_inbox.reload
+ instagram_webhook.perform_now(story_mention_event[:entry])
expect(instagram_messenger_inbox.messages.count).to be 1
expect(instagram_messenger_inbox.messages.last.attachments.count).to be 1
@@ -149,12 +132,12 @@ describe Webhooks::InstagramEventsJob do
end
it 'does not create contact or messages when Facebook API call fails' do
+ story_mention_echo_event = build(:instagram_story_mention_event_with_echo).with_indifferent_access
+
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError)
- instagram_webhook.perform_now(message_events[:story_mention_echo][:entry])
-
- instagram_messenger_inbox.reload
+ instagram_webhook.perform_now(story_mention_echo_event[:entry])
expect(instagram_messenger_inbox.contacts.count).to be 0
expect(instagram_messenger_inbox.contact_inboxes.count).to be 0
@@ -162,21 +145,23 @@ describe Webhooks::InstagramEventsJob do
end
it 'handle messaging_seen callback' do
- expect(Instagram::ReadStatusService).to receive(:new).with(params: message_events[:messaging_seen][:entry][0][:messaging][0],
+ messaging_seen_event = build(:messaging_seen_event).with_indifferent_access
+
+ expect(Instagram::ReadStatusService).to receive(:new).with(params: messaging_seen_event[:entry][0][:messaging][0],
channel: instagram_messenger_inbox.channel).and_call_original
- instagram_webhook.perform_now(message_events[:messaging_seen][:entry])
+ instagram_webhook.perform_now(messaging_seen_event[:entry])
end
it 'handles unsupported message' do
+ unsupported_event = build(:instagram_message_unsupported_event).with_indifferent_access
+ sender_id = unsupported_event[:entry][0][:messaging][0][:sender][:id]
+
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
- sender_id = message_events[:unsupported][:entry][0][:messaging][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access
)
- instagram_webhook.perform_now(message_events[:unsupported][:entry])
- instagram_messenger_inbox.reload
-
+ instagram_webhook.perform_now(unsupported_event[:entry])
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_messenger_inbox.conversations.count).to be 1
@@ -186,6 +171,9 @@ describe Webhooks::InstagramEventsJob do
end
context 'when handling messaging events for Instagram via Instagram login' do
+ let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') }
+ let!(:instagram_inbox) { instagram_channel.inbox }
+
before do
instagram_channel.update(access_token: 'valid_instagram_token')
@@ -210,9 +198,8 @@ describe Webhooks::InstagramEventsJob do
end
it 'creates incoming message with correct contact info in the instagram direct inbox' do
- instagram_webhook.perform_now(message_events[:dm][:entry])
- instagram_inbox.reload
-
+ dm_event = build(:instagram_message_create_event).with_indifferent_access
+ instagram_webhook.perform_now(dm_event[:entry])
expect(instagram_inbox.contacts.count).to eq 1
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to eq 1
@@ -221,7 +208,8 @@ describe Webhooks::InstagramEventsJob do
end
it 'sets correct instagram attributes on contact' do
- instagram_webhook.perform_now(message_events[:dm][:entry])
+ dm_event = build(:instagram_message_create_event).with_indifferent_access
+ instagram_webhook.perform_now(dm_event[:entry])
instagram_inbox.reload
contact = instagram_inbox.contacts.last
@@ -233,6 +221,8 @@ describe Webhooks::InstagramEventsJob do
end
it 'handle instagram unsend message event' do
+ unsend_event = build(:instagram_message_unsend_event).with_indifferent_access
+
message = create(:message, inbox_id: instagram_inbox.id, source_id: 'message-id-to-delete', content: 'random_text')
# Create attachment correctly with account association
@@ -244,7 +234,7 @@ describe Webhooks::InstagramEventsJob do
expect(instagram_inbox.messages.count).to be 1
- instagram_webhook.perform_now(message_events[:unsend][:entry])
+ instagram_webhook.perform_now(unsend_event[:entry])
message.reload
@@ -254,9 +244,8 @@ describe Webhooks::InstagramEventsJob do
end
it 'creates incoming message with attachments in the instagram direct inbox' do
- instagram_webhook.perform_now(message_events[:attachment][:entry])
-
- instagram_inbox.reload
+ attachment_event = build(:instagram_message_attachment_event).with_indifferent_access
+ instagram_webhook.perform_now(attachment_event[:entry])
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.messages.count).to be 1
@@ -264,9 +253,8 @@ describe Webhooks::InstagramEventsJob do
end
it 'handles unsupported message' do
- instagram_webhook.perform_now(message_events[:unsupported][:entry])
- instagram_inbox.reload
-
+ unsupported_event = build(:instagram_message_unsupported_event).with_indifferent_access
+ instagram_webhook.perform_now(unsupported_event[:entry])
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to be 1
@@ -275,12 +263,12 @@ describe Webhooks::InstagramEventsJob do
end
it 'does not create contact or messages when Instagram API call fails' do
+ story_mention_echo_event = build(:instagram_story_mention_event_with_echo).with_indifferent_access
+
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*})
.to_return(status: 401, body: { error: { message: 'Invalid OAuth access token' } }.to_json)
- instagram_webhook.perform_now(message_events[:story_mention_echo][:entry])
-
- instagram_inbox.reload
+ instagram_webhook.perform_now(story_mention_echo_event[:entry])
expect(instagram_inbox.contacts.count).to be 0
expect(instagram_inbox.contact_inboxes.count).to be 0
@@ -288,19 +276,20 @@ describe Webhooks::InstagramEventsJob do
end
it 'handles messaging_seen callback' do
- expect(Instagram::ReadStatusService).to receive(:new).with(params: message_events[:messaging_seen][:entry][0][:messaging][0],
+ messaging_seen_event = build(:messaging_seen_event).with_indifferent_access
+
+ expect(Instagram::ReadStatusService).to receive(:new).with(params: messaging_seen_event[:entry][0][:messaging][0],
channel: instagram_inbox.channel).and_call_original
- instagram_webhook.perform_now(message_events[:messaging_seen][:entry])
+ instagram_webhook.perform_now(messaging_seen_event[:entry])
end
it 'creates contact when Instagram API call returns `No matching Instagram user` (9010 error code)' do
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*})
.to_return(status: 401, body: { error: { message: 'No matching Instagram user', code: 9010 } }.to_json)
- sender_id = message_events[:dm][:entry][0][:messaging][0][:sender][:id]
- instagram_webhook.perform_now(message_events[:dm][:entry])
-
- instagram_inbox.reload
+ dm_event = build(:instagram_message_create_event).with_indifferent_access
+ sender_id = dm_event[:entry][0][:messaging][0][:sender][:id]
+ instagram_webhook.perform_now(dm_event[:entry])
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.contacts.last.name).to eq "Unknown (IG: #{sender_id})"
From 0c101b1f6b2a07ab898de4a69dd03e3347c351ac Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Tue, 12 Aug 2025 20:21:05 +0530
Subject: [PATCH 03/11] chore: UI improvements to compose new conversation form
(#12173)
---
.../NewConversation/ComposeConversation.vue | 19 ++++++++++++++-----
.../components-next/sidebar/Sidebar.vue | 6 ++----
2 files changed, 16 insertions(+), 9 deletions(-)
diff --git a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue
index 610d11dab..d3686cfd7 100644
--- a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue
+++ b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue
@@ -2,6 +2,7 @@
import { ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
+import { useWindowSize } from '@vueuse/core';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { vOnClickOutside } from '@vueuse/components';
import { useAlert } from 'dashboard/composables';
@@ -15,6 +16,7 @@ import {
processContactableInboxes,
mergeInboxDetails,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
+import wootConstants from 'dashboard/constants/globals';
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
@@ -37,9 +39,16 @@ const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
+const { width: windowWidth } = useWindowSize();
const { fetchSignatureFlagFromUISettings } = useUISettings();
+const isSmallScreen = computed(
+ () => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
+);
+
+const viewInModal = computed(() => props.isModal || isSmallScreen.value);
+
const contacts = ref([]);
const selectedContact = ref(null);
const targetInbox = ref(null);
@@ -67,7 +76,7 @@ const directUploadsEnabled = computed(
const activeContact = computed(() => contactById.value(props.contactId));
const composePopoverClass = computed(() => {
- if (props.isModal) return '';
+ if (viewInModal.value) return '';
return props.alignPosition === 'right'
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
@@ -202,7 +211,7 @@ const handleClickOutside = () => {
};
const onModalBackdropClick = () => {
- if (!props.isModal) return;
+ if (!viewInModal.value) return;
handleClickOutside();
};
@@ -231,7 +240,7 @@ useKeyboardEvents(keyboardEvents);
]"
class="relative"
:class="{
- 'z-40': showComposeNewConversation,
+ 'z-50': showComposeNewConversation && !viewInModal,
}"
>
{
class="bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak flex flex-col text-sm pb-1 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 transition-transform duration-200 ease-in-out md:static w-[200px] basis-[200px] md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:-translate-x-0"
:class="[
{
- 'ltr:translate-x-0 rtl:-translate-x-0 shadow-lg md:shadow-none':
- isMobileSidebarOpen,
- 'ltr:-translate-x-full rtl:translate-x-full md:translate-x-0':
- !isMobileSidebarOpen,
+ 'shadow-lg md:shadow-none': isMobileSidebarOpen,
+ 'ltr:-translate-x-full rtl:translate-x-full': !isMobileSidebarOpen,
},
]"
>
From 469e724e3a4ec7b70066189f8ab1ae739a146044 Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Tue, 12 Aug 2025 20:25:09 +0530
Subject: [PATCH 04/11] docs: add swagger spec for whatsapp templates changes
(#12169)
Added swagger changes for the PR
https://github.com/chatwoot/chatwoot/pull/11997
---
.../conversation/create_message_payload.yml | 60 +++++++++++--
.../conversation/messages/create.yml | 52 +++++++++++-
swagger/swagger.json | 84 +++++++++++++++++--
swagger/tag_groups/application_swagger.json | 84 +++++++++++++++++--
swagger/tag_groups/client_swagger.json | 82 ++++++++++++++++--
swagger/tag_groups/other_swagger.json | 82 ++++++++++++++++--
swagger/tag_groups/platform_swagger.json | 82 ++++++++++++++++--
7 files changed, 479 insertions(+), 47 deletions(-)
diff --git a/swagger/definitions/request/conversation/create_message_payload.yml b/swagger/definitions/request/conversation/create_message_payload.yml
index 4b1851293..71d073d75 100644
--- a/swagger/definitions/request/conversation/create_message_payload.yml
+++ b/swagger/definitions/request/conversation/create_message_payload.yml
@@ -30,22 +30,64 @@ properties:
example: 1
template_params:
type: object
- description: The template params for the message in case of whatsapp Channel
+ description: WhatsApp template parameters for sending structured messages
+ required:
+ - name
+ - category
+ - language
+ - processed_params
properties:
name:
type: string
- description: Name of the template
- example: 'sample_issue_resolution'
+ description: Name of the WhatsApp template (must be approved in WhatsApp Business Manager)
+ example: 'purchase_receipt'
category:
type: string
+ enum: ['UTILITY', 'MARKETING', 'SHIPPING_UPDATE', 'TICKET_UPDATE', 'ISSUE_RESOLUTION']
description: Category of the template
- example: UTILITY
+ example: 'UTILITY'
language:
type: string
- description: Language of the template
- example: en_US
+ description: Language code of the template (BCP 47 format)
+ example: 'en_US'
processed_params:
type: object
- description: The processed param values for template variables in template
- example:
- 1: 'Chatwoot'
\ No newline at end of file
+ description: Processed template parameters organized by component type
+ properties:
+ body:
+ type: object
+ description: Body component parameters with variable placeholders
+ additionalProperties:
+ type: string
+ example:
+ '1': 'Visa'
+ '2': 'Nike'
+ '3': 'Bill'
+ header:
+ type: object
+ description: Header component parameters for media templates
+ properties:
+ media_url:
+ type: string
+ format: uri
+ description: Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers
+ example: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf'
+ media_type:
+ type: string
+ enum: ['image', 'video', 'document']
+ description: Type of media for the header
+ example: 'document'
+ buttons:
+ type: array
+ description: Button component parameters for interactive templates
+ items:
+ type: object
+ properties:
+ type:
+ type: string
+ enum: ['url', 'copy_code']
+ description: Type of button parameter
+ parameter:
+ type: string
+ description: Dynamic parameter value for the button
+ example: 'SSFSDFSD'
\ No newline at end of file
diff --git a/swagger/paths/application/conversation/messages/create.yml b/swagger/paths/application/conversation/messages/create.yml
index f8cd35f3c..1b8272585 100644
--- a/swagger/paths/application/conversation/messages/create.yml
+++ b/swagger/paths/application/conversation/messages/create.yml
@@ -2,7 +2,57 @@ tags:
- Messages
operationId: create-a-new-message-in-a-conversation
summary: Create New Message
-description: Create a new message in the conversation
+description: |
+ Create a new message in the conversation.
+
+ ## WhatsApp Template Messages
+
+ For WhatsApp channels, you can send structured template messages using the `template_params` field.
+ Templates must be pre-approved in WhatsApp Business Manager.
+
+ ### Example Templates
+
+ **Text with Image Header:**
+ ```json
+ {
+ "content": "Hi your order 121212 is confirmed. Please wait for further updates",
+ "template_params": {
+ "name": "order_confirmation",
+ "category": "MARKETING",
+ "language": "en",
+ "processed_params": {
+ "body": {
+ "1": "121212"
+ },
+ "header": {
+ "media_url": "https://picsum.photos/200/300",
+ "media_type": "image"
+ }
+ }
+ }
+ }
+ ```
+
+ **Text with Copy Code Button:**
+ ```json
+ {
+ "content": "Special offer! Get 30% off your next purchase. Use the code below",
+ "template_params": {
+ "name": "discount_coupon",
+ "category": "MARKETING",
+ "language": "en",
+ "processed_params": {
+ "body": {
+ "discount_percentage": "30"
+ },
+ "buttons": [{
+ "type": "copy_code",
+ "parameter": "SAVE20"
+ }]
+ }
+ }
+ }
+ ```
security:
- userApiKey: []
- agentBotApiKey: []
diff --git a/swagger/swagger.json b/swagger/swagger.json
index e849f8119..8da8e2c5b 100644
--- a/swagger/swagger.json
+++ b/swagger/swagger.json
@@ -5937,7 +5937,7 @@
],
"operationId": "create-a-new-message-in-a-conversation",
"summary": "Create New Message",
- "description": "Create a new message in the conversation",
+ "description": "Create a new message in the conversation.\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can send structured template messages using the `template_params` field. \nTemplates must be pre-approved in WhatsApp Business Manager.\n\n### Example Templates\n\n**Text with Image Header:**\n```json\n{\n \"content\": \"Hi your order 121212 is confirmed. Please wait for further updates\",\n \"template_params\": {\n \"name\": \"order_confirmation\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"1\": \"121212\"\n },\n \"header\": {\n \"media_url\": \"https://picsum.photos/200/300\",\n \"media_type\": \"image\"\n }\n }\n }\n}\n```\n\n**Text with Copy Code Button:**\n```json\n{\n \"content\": \"Special offer! Get 30% off your next purchase. Use the code below\",\n \"template_params\": {\n \"name\": \"discount_coupon\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"discount_percentage\": \"30\"\n },\n \"buttons\": [{\n \"type\": \"copy_code\",\n \"parameter\": \"SAVE20\"\n }]\n }\n }\n}\n```\n",
"security": [
{
"userApiKey": []
@@ -10148,28 +10148,96 @@
},
"template_params": {
"type": "object",
- "description": "The template params for the message in case of whatsapp Channel",
+ "description": "WhatsApp template parameters for sending structured messages",
+ "required": [
+ "name",
+ "category",
+ "language",
+ "processed_params"
+ ],
"properties": {
"name": {
"type": "string",
- "description": "Name of the template",
- "example": "sample_issue_resolution"
+ "description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
+ "example": "purchase_receipt"
},
"category": {
"type": "string",
+ "enum": [
+ "UTILITY",
+ "MARKETING",
+ "SHIPPING_UPDATE",
+ "TICKET_UPDATE",
+ "ISSUE_RESOLUTION"
+ ],
"description": "Category of the template",
"example": "UTILITY"
},
"language": {
"type": "string",
- "description": "Language of the template",
+ "description": "Language code of the template (BCP 47 format)",
"example": "en_US"
},
"processed_params": {
"type": "object",
- "description": "The processed param values for template variables in template",
- "example": {
- "1": "Chatwoot"
+ "description": "Processed template parameters organized by component type",
+ "properties": {
+ "body": {
+ "type": "object",
+ "description": "Body component parameters with variable placeholders",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "example": {
+ "1": "Visa",
+ "2": "Nike",
+ "3": "Bill"
+ }
+ },
+ "header": {
+ "type": "object",
+ "description": "Header component parameters for media templates",
+ "properties": {
+ "media_url": {
+ "type": "string",
+ "format": "uri",
+ "description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
+ "example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
+ },
+ "media_type": {
+ "type": "string",
+ "enum": [
+ "image",
+ "video",
+ "document"
+ ],
+ "description": "Type of media for the header",
+ "example": "document"
+ }
+ }
+ },
+ "buttons": {
+ "type": "array",
+ "description": "Button component parameters for interactive templates",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "url",
+ "copy_code"
+ ],
+ "description": "Type of button parameter"
+ },
+ "parameter": {
+ "type": "string",
+ "description": "Dynamic parameter value for the button",
+ "example": "SSFSDFSD"
+ }
+ }
+ }
+ }
}
}
}
diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json
index f06819d1d..23a9ab0b2 100644
--- a/swagger/tag_groups/application_swagger.json
+++ b/swagger/tag_groups/application_swagger.json
@@ -4334,7 +4334,7 @@
],
"operationId": "create-a-new-message-in-a-conversation",
"summary": "Create New Message",
- "description": "Create a new message in the conversation",
+ "description": "Create a new message in the conversation.\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can send structured template messages using the `template_params` field. \nTemplates must be pre-approved in WhatsApp Business Manager.\n\n### Example Templates\n\n**Text with Image Header:**\n```json\n{\n \"content\": \"Hi your order 121212 is confirmed. Please wait for further updates\",\n \"template_params\": {\n \"name\": \"order_confirmation\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"1\": \"121212\"\n },\n \"header\": {\n \"media_url\": \"https://picsum.photos/200/300\",\n \"media_type\": \"image\"\n }\n }\n }\n}\n```\n\n**Text with Copy Code Button:**\n```json\n{\n \"content\": \"Special offer! Get 30% off your next purchase. Use the code below\",\n \"template_params\": {\n \"name\": \"discount_coupon\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"discount_percentage\": \"30\"\n },\n \"buttons\": [{\n \"type\": \"copy_code\",\n \"parameter\": \"SAVE20\"\n }]\n }\n }\n}\n```\n",
"security": [
{
"userApiKey": []
@@ -8509,28 +8509,96 @@
},
"template_params": {
"type": "object",
- "description": "The template params for the message in case of whatsapp Channel",
+ "description": "WhatsApp template parameters for sending structured messages",
+ "required": [
+ "name",
+ "category",
+ "language",
+ "processed_params"
+ ],
"properties": {
"name": {
"type": "string",
- "description": "Name of the template",
- "example": "sample_issue_resolution"
+ "description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
+ "example": "purchase_receipt"
},
"category": {
"type": "string",
+ "enum": [
+ "UTILITY",
+ "MARKETING",
+ "SHIPPING_UPDATE",
+ "TICKET_UPDATE",
+ "ISSUE_RESOLUTION"
+ ],
"description": "Category of the template",
"example": "UTILITY"
},
"language": {
"type": "string",
- "description": "Language of the template",
+ "description": "Language code of the template (BCP 47 format)",
"example": "en_US"
},
"processed_params": {
"type": "object",
- "description": "The processed param values for template variables in template",
- "example": {
- "1": "Chatwoot"
+ "description": "Processed template parameters organized by component type",
+ "properties": {
+ "body": {
+ "type": "object",
+ "description": "Body component parameters with variable placeholders",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "example": {
+ "1": "Visa",
+ "2": "Nike",
+ "3": "Bill"
+ }
+ },
+ "header": {
+ "type": "object",
+ "description": "Header component parameters for media templates",
+ "properties": {
+ "media_url": {
+ "type": "string",
+ "format": "uri",
+ "description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
+ "example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
+ },
+ "media_type": {
+ "type": "string",
+ "enum": [
+ "image",
+ "video",
+ "document"
+ ],
+ "description": "Type of media for the header",
+ "example": "document"
+ }
+ }
+ },
+ "buttons": {
+ "type": "array",
+ "description": "Button component parameters for interactive templates",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "url",
+ "copy_code"
+ ],
+ "description": "Type of button parameter"
+ },
+ "parameter": {
+ "type": "string",
+ "description": "Dynamic parameter value for the button",
+ "example": "SSFSDFSD"
+ }
+ }
+ }
+ }
}
}
}
diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json
index c6a3ba408..6bbae4fb2 100644
--- a/swagger/tag_groups/client_swagger.json
+++ b/swagger/tag_groups/client_swagger.json
@@ -3132,28 +3132,96 @@
},
"template_params": {
"type": "object",
- "description": "The template params for the message in case of whatsapp Channel",
+ "description": "WhatsApp template parameters for sending structured messages",
+ "required": [
+ "name",
+ "category",
+ "language",
+ "processed_params"
+ ],
"properties": {
"name": {
"type": "string",
- "description": "Name of the template",
- "example": "sample_issue_resolution"
+ "description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
+ "example": "purchase_receipt"
},
"category": {
"type": "string",
+ "enum": [
+ "UTILITY",
+ "MARKETING",
+ "SHIPPING_UPDATE",
+ "TICKET_UPDATE",
+ "ISSUE_RESOLUTION"
+ ],
"description": "Category of the template",
"example": "UTILITY"
},
"language": {
"type": "string",
- "description": "Language of the template",
+ "description": "Language code of the template (BCP 47 format)",
"example": "en_US"
},
"processed_params": {
"type": "object",
- "description": "The processed param values for template variables in template",
- "example": {
- "1": "Chatwoot"
+ "description": "Processed template parameters organized by component type",
+ "properties": {
+ "body": {
+ "type": "object",
+ "description": "Body component parameters with variable placeholders",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "example": {
+ "1": "Visa",
+ "2": "Nike",
+ "3": "Bill"
+ }
+ },
+ "header": {
+ "type": "object",
+ "description": "Header component parameters for media templates",
+ "properties": {
+ "media_url": {
+ "type": "string",
+ "format": "uri",
+ "description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
+ "example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
+ },
+ "media_type": {
+ "type": "string",
+ "enum": [
+ "image",
+ "video",
+ "document"
+ ],
+ "description": "Type of media for the header",
+ "example": "document"
+ }
+ }
+ },
+ "buttons": {
+ "type": "array",
+ "description": "Button component parameters for interactive templates",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "url",
+ "copy_code"
+ ],
+ "description": "Type of button parameter"
+ },
+ "parameter": {
+ "type": "string",
+ "description": "Dynamic parameter value for the button",
+ "example": "SSFSDFSD"
+ }
+ }
+ }
+ }
}
}
}
diff --git a/swagger/tag_groups/other_swagger.json b/swagger/tag_groups/other_swagger.json
index e2a16245b..414a44e63 100644
--- a/swagger/tag_groups/other_swagger.json
+++ b/swagger/tag_groups/other_swagger.json
@@ -2547,28 +2547,96 @@
},
"template_params": {
"type": "object",
- "description": "The template params for the message in case of whatsapp Channel",
+ "description": "WhatsApp template parameters for sending structured messages",
+ "required": [
+ "name",
+ "category",
+ "language",
+ "processed_params"
+ ],
"properties": {
"name": {
"type": "string",
- "description": "Name of the template",
- "example": "sample_issue_resolution"
+ "description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
+ "example": "purchase_receipt"
},
"category": {
"type": "string",
+ "enum": [
+ "UTILITY",
+ "MARKETING",
+ "SHIPPING_UPDATE",
+ "TICKET_UPDATE",
+ "ISSUE_RESOLUTION"
+ ],
"description": "Category of the template",
"example": "UTILITY"
},
"language": {
"type": "string",
- "description": "Language of the template",
+ "description": "Language code of the template (BCP 47 format)",
"example": "en_US"
},
"processed_params": {
"type": "object",
- "description": "The processed param values for template variables in template",
- "example": {
- "1": "Chatwoot"
+ "description": "Processed template parameters organized by component type",
+ "properties": {
+ "body": {
+ "type": "object",
+ "description": "Body component parameters with variable placeholders",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "example": {
+ "1": "Visa",
+ "2": "Nike",
+ "3": "Bill"
+ }
+ },
+ "header": {
+ "type": "object",
+ "description": "Header component parameters for media templates",
+ "properties": {
+ "media_url": {
+ "type": "string",
+ "format": "uri",
+ "description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
+ "example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
+ },
+ "media_type": {
+ "type": "string",
+ "enum": [
+ "image",
+ "video",
+ "document"
+ ],
+ "description": "Type of media for the header",
+ "example": "document"
+ }
+ }
+ },
+ "buttons": {
+ "type": "array",
+ "description": "Button component parameters for interactive templates",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "url",
+ "copy_code"
+ ],
+ "description": "Type of button parameter"
+ },
+ "parameter": {
+ "type": "string",
+ "description": "Dynamic parameter value for the button",
+ "example": "SSFSDFSD"
+ }
+ }
+ }
+ }
}
}
}
diff --git a/swagger/tag_groups/platform_swagger.json b/swagger/tag_groups/platform_swagger.json
index b4feb0024..215fcd38b 100644
--- a/swagger/tag_groups/platform_swagger.json
+++ b/swagger/tag_groups/platform_swagger.json
@@ -3308,28 +3308,96 @@
},
"template_params": {
"type": "object",
- "description": "The template params for the message in case of whatsapp Channel",
+ "description": "WhatsApp template parameters for sending structured messages",
+ "required": [
+ "name",
+ "category",
+ "language",
+ "processed_params"
+ ],
"properties": {
"name": {
"type": "string",
- "description": "Name of the template",
- "example": "sample_issue_resolution"
+ "description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
+ "example": "purchase_receipt"
},
"category": {
"type": "string",
+ "enum": [
+ "UTILITY",
+ "MARKETING",
+ "SHIPPING_UPDATE",
+ "TICKET_UPDATE",
+ "ISSUE_RESOLUTION"
+ ],
"description": "Category of the template",
"example": "UTILITY"
},
"language": {
"type": "string",
- "description": "Language of the template",
+ "description": "Language code of the template (BCP 47 format)",
"example": "en_US"
},
"processed_params": {
"type": "object",
- "description": "The processed param values for template variables in template",
- "example": {
- "1": "Chatwoot"
+ "description": "Processed template parameters organized by component type",
+ "properties": {
+ "body": {
+ "type": "object",
+ "description": "Body component parameters with variable placeholders",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "example": {
+ "1": "Visa",
+ "2": "Nike",
+ "3": "Bill"
+ }
+ },
+ "header": {
+ "type": "object",
+ "description": "Header component parameters for media templates",
+ "properties": {
+ "media_url": {
+ "type": "string",
+ "format": "uri",
+ "description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
+ "example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
+ },
+ "media_type": {
+ "type": "string",
+ "enum": [
+ "image",
+ "video",
+ "document"
+ ],
+ "description": "Type of media for the header",
+ "example": "document"
+ }
+ }
+ },
+ "buttons": {
+ "type": "array",
+ "description": "Button component parameters for interactive templates",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "url",
+ "copy_code"
+ ],
+ "description": "Type of button parameter"
+ },
+ "parameter": {
+ "type": "string",
+ "description": "Dynamic parameter value for the button",
+ "example": "SSFSDFSD"
+ }
+ }
+ }
+ }
}
}
}
From 48fa7bf72b7b029bb9315f43bd6b9fd0e1872a41 Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Tue, 12 Aug 2025 22:56:53 +0530
Subject: [PATCH 05/11] fix: Handle nil `processed_params` for WhatsApp
templates without params (#12177)
WhatsApp templates without parameters (body-only templates like
notifications, confirmations) were failing to send with the error:
ArgumentError (Unknown legacy format: NilClass). This affected all
parameter-less templates across marketing messages, notifications, and
utility templates.
---
.../template_parameter_converter_service.rb | 3 +
spec/factories/channel/channel_whatsapp.rb | 19 ++++++
...mplate_parameter_converter_service_spec.rb | 62 +++++++++++++++++++
3 files changed, 84 insertions(+)
diff --git a/app/services/whatsapp/template_parameter_converter_service.rb b/app/services/whatsapp/template_parameter_converter_service.rb
index b9a9d55d9..641a29b95 100644
--- a/app/services/whatsapp/template_parameter_converter_service.rb
+++ b/app/services/whatsapp/template_parameter_converter_service.rb
@@ -86,6 +86,9 @@ class Whatsapp::TemplateParameterConverterService
# Hash format: {"1": "John", "name": "Jane"} → {body: {"1": "John", "name": "Jane"}}
body_params = convert_hash_to_body_params(legacy_params)
enhanced['body'] = body_params unless body_params.empty?
+ when NilClass
+ # Templates without parameters (nil processed_params)
+ # Return empty enhanced structure
else
raise ArgumentError, "Unknown legacy format: #{legacy_params.class}"
end
diff --git a/spec/factories/channel/channel_whatsapp.rb b/spec/factories/channel/channel_whatsapp.rb
index ad2bab241..dae7eb04f 100644
--- a/spec/factories/channel/channel_whatsapp.rb
+++ b/spec/factories/channel/channel_whatsapp.rb
@@ -63,6 +63,25 @@ FactoryBot.define do
],
'sub_category' => 'CUSTOM',
'parameter_format' => 'NAMED'
+ },
+ {
+ 'name' => 'test_no_params_template',
+ 'status' => 'APPROVED',
+ 'category' => 'UTILITY',
+ 'language' => 'en',
+ 'namespace' => 'ed41a221_133a_4558_a1d6_192960e3aee9',
+ 'id' => '9876543210987654',
+ 'length' => 1,
+ 'parameter_format' => 'POSITIONAL',
+ 'previous_category' => 'MARKETING',
+ 'sub_category' => 'CUSTOM',
+ 'components' => [
+ {
+ 'text' => 'Thank you for contacting us! Your request has been processed successfully. Have a great day! 🙂',
+ 'type' => 'BODY'
+ }
+ ],
+ 'rejected_reason' => 'NONE'
}]
end
message_templates_last_updated { Time.now.utc }
diff --git a/spec/services/whatsapp/template_parameter_converter_service_spec.rb b/spec/services/whatsapp/template_parameter_converter_service_spec.rb
index 2994bb472..570c5c6cc 100644
--- a/spec/services/whatsapp/template_parameter_converter_service_spec.rb
+++ b/spec/services/whatsapp/template_parameter_converter_service_spec.rb
@@ -133,6 +133,48 @@ describe Whatsapp::TemplateParameterConverterService do
end
end
+ context 'when processed_params is nil (parameter-less templates)' do
+ let(:nil_params) do
+ {
+ 'processed_params' => nil
+ }
+ end
+
+ let(:parameterless_template) do
+ {
+ 'name' => 'test_no_params_template',
+ 'language' => 'en',
+ 'parameter_format' => 'POSITIONAL',
+ 'id' => '9876543210987654',
+ 'status' => 'APPROVED',
+ 'category' => 'UTILITY',
+ 'previous_category' => 'MARKETING',
+ 'sub_category' => 'CUSTOM',
+ 'components' => [
+ {
+ 'type' => 'BODY',
+ 'text' => 'Thank you for contacting us! Your request has been processed successfully. Have a great day! 🙂'
+ }
+ ]
+ }
+ end
+
+ it 'converts nil to empty enhanced format' do
+ converter = described_class.new(nil_params, parameterless_template)
+ result = converter.normalize_to_enhanced
+
+ expect(result['processed_params']).to eq({})
+ expect(result['format_version']).to eq('legacy')
+ end
+
+ it 'does not raise ArgumentError for nil processed_params' do
+ expect do
+ converter = described_class.new(nil_params, parameterless_template)
+ converter.normalize_to_enhanced
+ end.not_to raise_error
+ end
+ end
+
context 'when invalid format' do
let(:invalid_params) do
{
@@ -174,6 +216,26 @@ describe Whatsapp::TemplateParameterConverterService do
end
describe 'simplified conversion methods' do
+ describe '#convert_legacy_to_enhanced' do
+ it 'handles nil processed_params without raising error' do
+ converter = described_class.new({}, template)
+ result = converter.send(:convert_legacy_to_enhanced, nil, template)
+ expect(result).to eq({})
+ end
+
+ it 'returns empty hash for parameter-less templates' do
+ parameterless_template = {
+ 'name' => 'no_params_template',
+ 'language' => 'en',
+ 'components' => [{ 'type' => 'BODY', 'text' => 'Hello World!' }]
+ }
+
+ converter = described_class.new({}, parameterless_template)
+ result = converter.send(:convert_legacy_to_enhanced, nil, parameterless_template)
+ expect(result).to eq({})
+ end
+ end
+
describe '#convert_array_to_body_params' do
it 'converts empty array' do
converter = described_class.new({}, template)
From 9a7318a9dbf01b4b9d6da8d691f590a886bf576c Mon Sep 17 00:00:00 2001
From: Vishnu Narayanan
Date: Wed, 13 Aug 2025 07:56:58 +0530
Subject: [PATCH 06/11] fix: cw-5411 handle unrepresentable image attachments
(#12178)
# Pull Request Template
## Description
Fixes
https://linear.app/chatwoot/issue/CW-5411/actionviewtemplateerror-activestorageunrepresentableerror
### Problem
API endpoints return 500 errors when conversations contain image
attachments that can't be processed by ActiveStorage (e.g., files with
non-ASCII filenames, corrupted images, or malicious XSS filenames).
Root Cause: Commit 6cab74139 removed the representable? safety check
from thumb_url, causing `ActiveStorage::UnrepresentableError` to bubble
up and crash the API when it encountered a malformed image file.
Fix: Rescue `thumb_url` method to catch UnrepresentableError and return
an empty string while logging problematic names for future debugging.
This ensures the messages/attachments api does not break due to a single
corrupted image file.
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
- Added specs
## 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
- [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
- [x] Any dependent changes have been merged and published in downstream
modules
---
app/models/attachment.rb | 7 ++++++-
spec/models/attachment_spec.rb | 10 ++++++++++
2 files changed, 16 insertions(+), 1 deletion(-)
diff --git a/app/models/attachment.rb b/app/models/attachment.rb
index 8c5750148..42ca79d6c 100644
--- a/app/models/attachment.rb
+++ b/app/models/attachment.rb
@@ -62,7 +62,12 @@ class Attachment < ApplicationRecord
def thumb_url
return '' unless file.attached? && image?
- url_for(file.representation(resize_to_fill: [250, nil]))
+ begin
+ url_for(file.representation(resize_to_fill: [250, nil]))
+ rescue ActiveStorage::UnrepresentableError => e
+ Rails.logger.warn "Unrepresentable image attachment: #{id} (#{file.filename}) - #{e.message}"
+ ''
+ end
end
def with_attached_file?
diff --git a/spec/models/attachment_spec.rb b/spec/models/attachment_spec.rb
index 0b03a56ad..cc00eab5d 100644
--- a/spec/models/attachment_spec.rb
+++ b/spec/models/attachment_spec.rb
@@ -82,6 +82,16 @@ RSpec.describe Attachment do
expect(attachment.thumb_url).to be_present
end
+
+ it 'handles unrepresentable images gracefully' do
+ attachment = message.attachments.create!(account_id: message.account_id, file_type: :image)
+ attachment.file.attach(io: StringIO.new('fake image'), filename: 'test.jpg', content_type: 'image/jpeg')
+
+ allow(attachment.file).to receive(:representation).and_raise(ActiveStorage::UnrepresentableError.new('Cannot represent'))
+
+ expect(Rails.logger).to receive(:warn).with(/Unrepresentable image attachment: #{attachment.id}/)
+ expect(attachment.thumb_url).to eq('')
+ end
end
describe 'meta data handling' do
From 42af4b1d01399937dabcf0c95cce872bebe3f5b5 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Wed, 13 Aug 2025 12:42:57 +0530
Subject: [PATCH 07/11] fix: Reset inbox on conversation switch in compose
conversation modal (#12174)
---
.../NewConversation/ComposeConversation.vue | 14 +++++++++-----
.../components/ComposeNewConversationForm.vue | 2 +-
2 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue
index d3686cfd7..fa9102d59 100644
--- a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue
+++ b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue
@@ -188,14 +188,18 @@ const toggle = () => {
watch(
activeContact,
- () => {
- if (activeContact.value && props.contactId) {
- const contactInboxes = activeContact.value?.contactInboxes || [];
+ (currentContact, previousContact) => {
+ if (currentContact && props.contactId) {
+ // Reset on contact change
+ if (currentContact?.id !== previousContact?.id) clearSelectedContact();
+
// First process the contactable inboxes to get the right structure
- const processedInboxes = processContactableInboxes(contactInboxes);
+ const processedInboxes = processContactableInboxes(
+ currentContact.contactInboxes || []
+ );
// Then Merge processedInboxes with the inboxes list
selectedContact.value = {
- ...activeContact.value,
+ ...currentContact,
contactInboxes: mergeInboxDetails(processedInboxes, inboxesList.value),
};
}
diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue
index 5540940cd..e3e063740 100644
--- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue
+++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue
@@ -265,7 +265,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
Date: Wed, 13 Aug 2025 04:27:14 -0300
Subject: [PATCH 08/11] feat: Add automation rule event conversation resolved
(#9669)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Description
add automation rule event conversation resolved
---------
Co-authored-by: Sojan
Co-authored-by: Shivam Mishra
Co-authored-by: Muhsin Keloth
---
.../composables/spec/useAutomation.spec.js | 1 +
.../dashboard/constants/automation.js | 2 +-
.../dashboard/helper/automationHelper.js | 9 +-
.../dashboard/i18n/locale/en/automation.json | 1 +
.../settings/automation/constants.js | 104 ++++++++++++++++++
app/listeners/automation_rule_listener.rb | 69 +++++-------
.../automation_rule_listener_spec.rb | 36 ++++++
.../automation_rule/create_update_payload.yml | 1 +
.../definitions/resource/automation_rule.yml | 2 +-
swagger/swagger.json | 1 +
swagger/tag_groups/application_swagger.json | 1 +
swagger/tag_groups/client_swagger.json | 1 +
swagger/tag_groups/other_swagger.json | 1 +
swagger/tag_groups/platform_swagger.json | 1 +
14 files changed, 184 insertions(+), 46 deletions(-)
diff --git a/app/javascript/dashboard/composables/spec/useAutomation.spec.js b/app/javascript/dashboard/composables/spec/useAutomation.spec.js
index 46c0bde50..a66f0c7b4 100644
--- a/app/javascript/dashboard/composables/spec/useAutomation.spec.js
+++ b/app/javascript/dashboard/composables/spec/useAutomation.spec.js
@@ -196,6 +196,7 @@ describe('useAutomation', () => {
automationTypes.conversation_created = { conditions: [] };
automationTypes.conversation_updated = { conditions: [] };
automationTypes.conversation_opened = { conditions: [] };
+ automationTypes.conversation_resolved = { conditions: [] };
automationHelper.generateCustomAttributeTypes.mockReturnValue([]);
automationHelper.generateCustomAttributes.mockReturnValue([]);
diff --git a/app/javascript/dashboard/constants/automation.js b/app/javascript/dashboard/constants/automation.js
index 399dd6153..a903075b8 100644
--- a/app/javascript/dashboard/constants/automation.js
+++ b/app/javascript/dashboard/constants/automation.js
@@ -8,7 +8,7 @@ export const DEFAULT_MESSAGE_CREATED_CONDITION = [
},
];
-export const DEFAULT_CONVERSATION_OPENED_CONDITION = [
+export const DEFAULT_CONVERSATION_CONDITION = [
{
attribute_key: 'browser_language',
filter_operator: 'equal_to',
diff --git a/app/javascript/dashboard/helper/automationHelper.js b/app/javascript/dashboard/helper/automationHelper.js
index c9852814a..3723fd4d5 100644
--- a/app/javascript/dashboard/helper/automationHelper.js
+++ b/app/javascript/dashboard/helper/automationHelper.js
@@ -5,7 +5,7 @@ import {
} from 'dashboard/routes/dashboard/settings/automation/operators';
import {
DEFAULT_MESSAGE_CREATED_CONDITION,
- DEFAULT_CONVERSATION_OPENED_CONDITION,
+ DEFAULT_CONVERSATION_CONDITION,
DEFAULT_OTHER_CONDITION,
DEFAULT_ACTIONS,
} from 'dashboard/constants/automation';
@@ -169,8 +169,11 @@ export const getDefaultConditions = eventName => {
if (eventName === 'message_created') {
return DEFAULT_MESSAGE_CREATED_CONDITION;
}
- if (eventName === 'conversation_opened') {
- return DEFAULT_CONVERSATION_OPENED_CONDITION;
+ if (
+ eventName === 'conversation_opened' ||
+ eventName === 'conversation_resolved'
+ ) {
+ return DEFAULT_CONVERSATION_CONDITION;
}
return DEFAULT_OTHER_CONDITION;
};
diff --git a/app/javascript/dashboard/i18n/locale/en/automation.json b/app/javascript/dashboard/i18n/locale/en/automation.json
index cf63de81c..80274f488 100644
--- a/app/javascript/dashboard/i18n/locale/en/automation.json
+++ b/app/javascript/dashboard/i18n/locale/en/automation.json
@@ -131,6 +131,7 @@
"CONVERSATION_CREATED": "Conversation Created",
"CONVERSATION_UPDATED": "Conversation Updated",
"MESSAGE_CREATED": "Message Created",
+ "CONVERSATION_RESOLVED": "Conversation Resolved",
"CONVERSATION_OPENED": "Conversation Opened"
},
"ACTIONS": {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js
index dfa6163d8..0a6905039 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js
@@ -468,6 +468,106 @@ export const AUTOMATIONS = {
},
],
},
+ conversation_resolved: {
+ conditions: [
+ {
+ key: 'browser_language',
+ name: 'BROWSER_LANGUAGE',
+ inputType: 'search_select',
+ filterOperators: OPERATOR_TYPES_1,
+ },
+ {
+ key: 'email',
+ name: 'EMAIL',
+ inputType: 'plain_text',
+ filterOperators: OPERATOR_TYPES_2,
+ },
+ {
+ key: 'mail_subject',
+ name: 'MAIL_SUBJECT',
+ inputType: 'plain_text',
+ filterOperators: OPERATOR_TYPES_2,
+ },
+ {
+ key: 'country_code',
+ name: 'COUNTRY_NAME',
+ inputType: 'search_select',
+ filterOperators: OPERATOR_TYPES_1,
+ },
+ {
+ key: 'referer',
+ name: 'REFERER_LINK',
+ inputType: 'plain_text',
+ filterOperators: OPERATOR_TYPES_2,
+ },
+ {
+ key: 'assignee_id',
+ name: 'ASSIGNEE_NAME',
+ inputType: 'search_select',
+ filterOperators: OPERATOR_TYPES_3,
+ },
+ {
+ key: 'phone_number',
+ name: 'PHONE_NUMBER',
+ inputType: 'plain_text',
+ filterOperators: OPERATOR_TYPES_6,
+ },
+ {
+ key: 'team_id',
+ name: 'TEAM_NAME',
+ inputType: 'search_select',
+ filterOperators: OPERATOR_TYPES_3,
+ },
+ {
+ key: 'inbox_id',
+ name: 'INBOX',
+ inputType: 'multi_select',
+ filterOperators: OPERATOR_TYPES_1,
+ },
+ {
+ key: 'conversation_language',
+ name: 'CONVERSATION_LANGUAGE',
+ inputType: 'multi_select',
+ filterOperators: OPERATOR_TYPES_1,
+ },
+ {
+ key: 'priority',
+ name: 'PRIORITY',
+ inputType: 'multi_select',
+ filterOperators: OPERATOR_TYPES_1,
+ },
+ ],
+ actions: [
+ {
+ key: 'assign_agent',
+ name: 'ASSIGN_AGENT',
+ },
+ {
+ key: 'assign_team',
+ name: 'ASSIGN_TEAM',
+ },
+ {
+ key: 'send_email_to_team',
+ name: 'SEND_EMAIL_TO_TEAM',
+ },
+ {
+ key: 'send_message',
+ name: 'SEND_MESSAGE',
+ },
+ {
+ key: 'send_email_transcript',
+ name: 'SEND_EMAIL_TRANSCRIPT',
+ },
+ {
+ key: 'send_webhook_event',
+ name: 'SEND_WEBHOOK_EVENT',
+ },
+ {
+ key: 'send_attachment',
+ name: 'SEND_ATTACHMENT',
+ },
+ ],
+ },
};
export const AUTOMATION_RULE_EVENTS = [
@@ -479,6 +579,10 @@ export const AUTOMATION_RULE_EVENTS = [
key: 'conversation_updated',
value: 'CONVERSATION_UPDATED',
},
+ {
+ key: 'conversation_resolved',
+ value: 'CONVERSATION_RESOLVED',
+ },
{
key: 'message_created',
value: 'MESSAGE_CREATED',
diff --git a/app/listeners/automation_rule_listener.rb b/app/listeners/automation_rule_listener.rb
index 6974e227a..0515d6952 100644
--- a/app/listeners/automation_rule_listener.rb
+++ b/app/listeners/automation_rule_listener.rb
@@ -1,53 +1,18 @@
class AutomationRuleListener < BaseListener
def conversation_updated(event)
- return if performed_by_automation?(event)
-
- conversation = event.data[:conversation]
- account = conversation.account
- changed_attributes = event.data[:changed_attributes]
-
- return unless rule_present?('conversation_updated', account)
-
- rules = current_account_rules('conversation_updated', account)
-
- rules.each do |rule|
- conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
- AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
- end
+ process_conversation_event(event, 'conversation_updated')
end
def conversation_created(event)
- return if performed_by_automation?(event) || ignore_auto_reply_event?(event)
-
- conversation = event.data[:conversation]
- account = conversation.account
- changed_attributes = event.data[:changed_attributes]
-
- return unless rule_present?('conversation_created', account)
-
- rules = current_account_rules('conversation_created', account)
-
- rules.each do |rule|
- conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
- ::AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
- end
+ process_conversation_event(event, 'conversation_created')
end
def conversation_opened(event)
- return if performed_by_automation?(event) || ignore_auto_reply_event?(event)
+ process_conversation_event(event, 'conversation_opened')
+ end
- conversation = event.data[:conversation]
- account = conversation.account
- changed_attributes = event.data[:changed_attributes]
-
- return unless rule_present?('conversation_opened', account)
-
- rules = current_account_rules('conversation_opened', account)
-
- rules.each do |rule|
- conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
- AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
- end
+ def conversation_resolved(event)
+ process_conversation_event(event, 'conversation_resolved')
end
def message_created(event)
@@ -69,6 +34,28 @@ class AutomationRuleListener < BaseListener
end
end
+ private
+
+ def process_conversation_event(event, event_name)
+ return if performed_by_automation?(event)
+
+ auto_reply_skip_events = %w[conversation_created conversation_opened]
+ return if auto_reply_skip_events.include?(event_name) && ignore_auto_reply_event?(event)
+
+ conversation = event.data[:conversation]
+ account = conversation.account
+ changed_attributes = event.data[:changed_attributes]
+
+ return unless rule_present?(event_name, account)
+
+ rules = current_account_rules(event_name, account)
+
+ rules.each do |rule|
+ conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
+ AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
+ end
+ end
+
def rule_present?(event_name, account)
return if account.blank?
diff --git a/spec/listeners/automation_rule_listener_spec.rb b/spec/listeners/automation_rule_listener_spec.rb
index e1c365f94..57a096a10 100644
--- a/spec/listeners/automation_rule_listener_spec.rb
+++ b/spec/listeners/automation_rule_listener_spec.rb
@@ -130,6 +130,42 @@ describe AutomationRuleListener do
end
end
+ describe 'conversation_resolved' do
+ let!(:automation_rule) { create(:automation_rule, event_name: 'conversation_resolved', account: account) }
+ let(:event) do
+ Events::Base.new('conversation_resolved', Time.zone.now, { conversation: conversation,
+ changed_attributes: { status: %w[Snoozed Open] } })
+ end
+
+ context 'when matching rules are present' do
+ it 'calls AutomationRules::ActionService if conditions match' do
+ allow(condition_match).to receive(:present?).and_return(true)
+ listener.conversation_resolved(event)
+ expect(AutomationRules::ActionService).to have_received(:new).with(automation_rule, account, conversation)
+ end
+
+ it 'does not call AutomationRules::ActionService if conditions do not match' do
+ allow(condition_match).to receive(:present?).and_return(false)
+ listener.conversation_resolved(event)
+ expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
+ end
+
+ it 'calls AutomationRules::ActionService for each rule when multiple rules are present' do
+ create(:automation_rule, event_name: 'conversation_resolved', account: account)
+ allow(condition_match).to receive(:present?).and_return(true)
+ listener.conversation_resolved(event)
+ expect(AutomationRules::ActionService).to have_received(:new).twice
+ end
+
+ it 'does not call AutomationRules::ActionService if performed by automation' do
+ event.data[:performed_by] = automation_rule
+ allow(condition_match).to receive(:present?).and_return(true)
+ listener.conversation_resolved(event)
+ expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
+ end
+ end
+ end
+
describe 'message_created' do
let!(:automation_rule) { create(:automation_rule, event_name: 'message_created', account: account) }
let!(:message) { create(:message, account: account, conversation: conversation) }
diff --git a/swagger/definitions/request/automation_rule/create_update_payload.yml b/swagger/definitions/request/automation_rule/create_update_payload.yml
index 091fa2aaa..75aedd41b 100644
--- a/swagger/definitions/request/automation_rule/create_update_payload.yml
+++ b/swagger/definitions/request/automation_rule/create_update_payload.yml
@@ -13,6 +13,7 @@ properties:
enum:
- conversation_created
- conversation_updated
+ - conversation_resolved
- message_created
example: message_created
description: The event when you want to execute the automation actions
diff --git a/swagger/definitions/resource/automation_rule.yml b/swagger/definitions/resource/automation_rule.yml
index b561441ff..ad96c58ff 100644
--- a/swagger/definitions/resource/automation_rule.yml
+++ b/swagger/definitions/resource/automation_rule.yml
@@ -10,4 +10,4 @@ properties:
- type: object
description: Single automation rule (for show/create/update endpoints)
allOf:
- - $ref: '#/components/schemas/automation_rule_item'
\ No newline at end of file
+ - $ref: '#/components/schemas/automation_rule_item'
diff --git a/swagger/swagger.json b/swagger/swagger.json
index 8da8e2c5b..aa7455e7f 100644
--- a/swagger/swagger.json
+++ b/swagger/swagger.json
@@ -10610,6 +10610,7 @@
"enum": [
"conversation_created",
"conversation_updated",
+ "conversation_resolved",
"message_created"
],
"example": "message_created",
diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json
index 23a9ab0b2..a36443f81 100644
--- a/swagger/tag_groups/application_swagger.json
+++ b/swagger/tag_groups/application_swagger.json
@@ -8971,6 +8971,7 @@
"enum": [
"conversation_created",
"conversation_updated",
+ "conversation_resolved",
"message_created"
],
"example": "message_created",
diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json
index 6bbae4fb2..16c1d5bc7 100644
--- a/swagger/tag_groups/client_swagger.json
+++ b/swagger/tag_groups/client_swagger.json
@@ -3594,6 +3594,7 @@
"enum": [
"conversation_created",
"conversation_updated",
+ "conversation_resolved",
"message_created"
],
"example": "message_created",
diff --git a/swagger/tag_groups/other_swagger.json b/swagger/tag_groups/other_swagger.json
index 414a44e63..c8a5294d3 100644
--- a/swagger/tag_groups/other_swagger.json
+++ b/swagger/tag_groups/other_swagger.json
@@ -3009,6 +3009,7 @@
"enum": [
"conversation_created",
"conversation_updated",
+ "conversation_resolved",
"message_created"
],
"example": "message_created",
diff --git a/swagger/tag_groups/platform_swagger.json b/swagger/tag_groups/platform_swagger.json
index 215fcd38b..f816b8c94 100644
--- a/swagger/tag_groups/platform_swagger.json
+++ b/swagger/tag_groups/platform_swagger.json
@@ -3770,6 +3770,7 @@
"enum": [
"conversation_created",
"conversation_updated",
+ "conversation_resolved",
"message_created"
],
"example": "message_created",
From ee9f1d7adb1939442880c6e9faf5534d38452d29 Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Wed, 13 Aug 2025 13:32:22 +0530
Subject: [PATCH 09/11] chore: Handle WebPush rate limiting in push
notification service (#12184)
Implemented a rescue block for WebPush::TooManyRequests that logs
warnings during rate limiting events. This captures user email and
account ID for better traceability. We will implement a proper
throttling mechanism after identifying patterns across accounts.
---
.../notification/push_notification_service.rb | 23 +++++++++++++------
1 file changed, 16 insertions(+), 7 deletions(-)
diff --git a/app/services/notification/push_notification_service.rb b/app/services/notification/push_notification_service.rb
index 9878107c1..125ad9113 100644
--- a/app/services/notification/push_notification_service.rb
+++ b/app/services/notification/push_notification_service.rb
@@ -68,14 +68,23 @@ class Notification::PushNotificationService
WebPush.payload_send(**browser_push_payload(subscription))
Rails.logger.info("Browser push sent to #{user.email} with title #{push_message[:title]}")
- rescue WebPush::ExpiredSubscription, WebPush::InvalidSubscription, WebPush::Unauthorized => e
- Rails.logger.info "WebPush subscription expired: #{e.message}"
- subscription.destroy!
- rescue Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout => e
- Rails.logger.error "WebPush operation error: #{e.message}"
rescue StandardError => e
- ChatwootExceptionTracker.new(e, account: notification.account).capture_exception
- true
+ handle_browser_push_error(e, subscription)
+ end
+
+ def handle_browser_push_error(error, subscription)
+ case error
+ when WebPush::ExpiredSubscription, WebPush::InvalidSubscription, WebPush::Unauthorized
+ Rails.logger.info "WebPush subscription expired: #{error.message}"
+ subscription.destroy!
+ when WebPush::TooManyRequests
+ Rails.logger.warn "WebPush rate limited for #{user.email} on account #{notification.account.id}: #{error.message}"
+ when Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout
+ Rails.logger.error "WebPush operation error: #{error.message}"
+ else
+ ChatwootExceptionTracker.new(error, account: notification.account).capture_exception
+ true
+ end
end
def send_fcm_push(subscription)
From a88fef2e1d273086f9cc9d6b6d7c525e4eb5b065 Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Wed, 13 Aug 2025 16:39:43 +0530
Subject: [PATCH 10/11] fix: incorrect first response time for reopened
conversations (#12058)
Co-authored-by: Muhsin Keloth
---
app/helpers/reporting_event_helper.rb | 23 ++-
app/listeners/reporting_event_listener.rb | 39 ++++
spec/helpers/reporting_event_helper_spec.rb | 177 ++++++++++++++++++
.../reporting_event_listener_spec.rb | 173 +++++++++++++++++
4 files changed, 407 insertions(+), 5 deletions(-)
create mode 100644 spec/helpers/reporting_event_helper_spec.rb
diff --git a/app/helpers/reporting_event_helper.rb b/app/helpers/reporting_event_helper.rb
index e08b5691c..f0a419cc8 100644
--- a/app/helpers/reporting_event_helper.rb
+++ b/app/helpers/reporting_event_helper.rb
@@ -18,12 +18,25 @@ module ReportingEventHelper
end
def last_non_human_activity(conversation)
- # check if a handoff event already exists
- handoff_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_handoff').last
+ # Try to get either a handoff or reopened event first
+ # These will always take precedence over any other activity
+ # Also, any of these events can happen at any time in the course of a conversation lifecycle.
+ # So we pick the latest event
+ event = ReportingEvent.where(
+ conversation_id: conversation.id,
+ name: %w[conversation_bot_handoff conversation_opened]
+ ).order(event_end_time: :desc).first
- # if a handoff exists, last non human activity is when the handoff ended,
- # otherwise it's when the conversation was created
- handoff_event&.event_end_time || conversation.created_at
+ return event.event_end_time if event&.event_end_time
+
+ # Fallback to bot resolved event
+ # Because this will be closest to the most accurate activity instead of conversation.created_at
+ bot_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_resolved').last
+
+ return bot_event.event_end_time if bot_event&.event_end_time
+
+ # If no events found, return conversation creation time
+ conversation.created_at
end
private
diff --git a/app/listeners/reporting_event_listener.rb b/app/listeners/reporting_event_listener.rb
index 9f22fe8de..9f683a97f 100644
--- a/app/listeners/reporting_event_listener.rb
+++ b/app/listeners/reporting_event_listener.rb
@@ -90,8 +90,47 @@ class ReportingEventListener < BaseListener
reporting_event.save!
end
+ def conversation_opened(event)
+ conversation = extract_conversation_and_account(event)[0]
+
+ # Find the most recent resolved event for this conversation
+ last_resolved_event = ReportingEvent.where(
+ conversation_id: conversation.id,
+ name: 'conversation_resolved'
+ ).order(event_end_time: :desc).first
+
+ # For first-time openings, value is 0
+ # For reopenings, calculate time since resolution
+ if last_resolved_event
+ time_since_resolved = conversation.updated_at.to_i - last_resolved_event.event_end_time.to_i
+ business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, conversation.updated_at)
+ start_time = last_resolved_event.event_end_time
+ else
+ time_since_resolved = 0
+ business_hours_value = 0
+ start_time = conversation.created_at
+ end
+
+ create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
+ end
+
private
+ def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
+ reporting_event = ReportingEvent.new(
+ name: 'conversation_opened',
+ value: time_since_resolved,
+ value_in_business_hours: business_hours_value,
+ account_id: conversation.account_id,
+ inbox_id: conversation.inbox_id,
+ user_id: conversation.assignee_id,
+ conversation_id: conversation.id,
+ event_start_time: start_time,
+ event_end_time: conversation.updated_at
+ )
+ reporting_event.save!
+ end
+
def create_bot_resolved_event(conversation, reporting_event)
return unless conversation.inbox.active_bot?
# We don't want to create a bot_resolved event if there is user interaction on the conversation
diff --git a/spec/helpers/reporting_event_helper_spec.rb b/spec/helpers/reporting_event_helper_spec.rb
new file mode 100644
index 000000000..e4a3c0255
--- /dev/null
+++ b/spec/helpers/reporting_event_helper_spec.rb
@@ -0,0 +1,177 @@
+require 'rails_helper'
+
+RSpec.describe ReportingEventHelper, type: :helper do
+ describe '#last_non_human_activity' do
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:user) { create(:user, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
+
+ context 'when conversation has no events' do
+ it 'returns conversation created_at' do
+ expect(helper.last_non_human_activity(conversation)).to eq(conversation.created_at)
+ end
+ end
+
+ context 'when conversation has bot handoff event' do
+ let!(:handoff_event) do
+ create(:reporting_event,
+ name: 'conversation_bot_handoff',
+ conversation_id: conversation.id,
+ account_id: account.id,
+ inbox_id: inbox.id,
+ event_end_time: 2.hours.ago)
+ end
+
+ it 'returns handoff event end time' do
+ expect(helper.last_non_human_activity(conversation).to_i).to eq(handoff_event.event_end_time.to_i)
+ end
+ end
+
+ context 'when conversation has bot resolved event' do
+ let!(:bot_resolved_event) do
+ create(:reporting_event,
+ name: 'conversation_bot_resolved',
+ conversation_id: conversation.id,
+ account_id: account.id,
+ inbox_id: inbox.id,
+ event_end_time: 3.hours.ago)
+ end
+
+ it 'returns bot resolved event end time' do
+ expect(helper.last_non_human_activity(conversation).to_i).to eq(bot_resolved_event.event_end_time.to_i)
+ end
+ end
+
+ context 'when conversation is reopened after bot resolution' do
+ let(:creation_time) { 5.days.ago }
+ let(:bot_resolution_time) { 5.days.ago + 5.minutes }
+ let(:reopening_time) { 1.hour.ago }
+
+ let!(:conversation) do
+ create(:conversation,
+ account: account,
+ inbox: inbox,
+ assignee: user,
+ created_at: creation_time)
+ end
+
+ before do
+ # First opened event
+ create(:reporting_event,
+ name: 'conversation_opened',
+ conversation_id: conversation.id,
+ account_id: account.id,
+ inbox_id: inbox.id,
+ value: 0,
+ event_start_time: creation_time,
+ event_end_time: creation_time)
+
+ # Bot resolved event
+ create(:reporting_event,
+ name: 'conversation_bot_resolved',
+ conversation_id: conversation.id,
+ account_id: account.id,
+ inbox_id: inbox.id,
+ event_start_time: creation_time,
+ event_end_time: bot_resolution_time)
+
+ # Resolved event
+ create(:reporting_event,
+ name: 'conversation_resolved',
+ conversation_id: conversation.id,
+ account_id: account.id,
+ inbox_id: inbox.id,
+ event_start_time: creation_time,
+ event_end_time: bot_resolution_time)
+
+ # Reopened event
+ create(:reporting_event,
+ name: 'conversation_opened',
+ conversation_id: conversation.id,
+ account_id: account.id,
+ inbox_id: inbox.id,
+ value: (reopening_time - bot_resolution_time).to_i,
+ event_start_time: bot_resolution_time,
+ event_end_time: reopening_time)
+ end
+
+ it 'returns the reopening event time, not the creation time' do
+ # This is the key test: last_non_human_activity should return the reopening time
+ # so that first response time is calculated from when the conversation was reopened,
+ # not from when it was originally created
+ expect(helper.last_non_human_activity(conversation).to_i).to eq(reopening_time.to_i)
+
+ # Verify it's not returning the creation time or bot resolution time
+ expect(helper.last_non_human_activity(conversation).to_i).not_to eq(creation_time.to_i)
+ expect(helper.last_non_human_activity(conversation).to_i).not_to eq(bot_resolution_time.to_i)
+ end
+ end
+
+ context 'when conversation has multiple types of events' do
+ let(:opened_event_time) { 1.hour.ago }
+
+ before do
+ create(:reporting_event,
+ name: 'conversation_bot_resolved',
+ conversation_id: conversation.id,
+ account_id: account.id,
+ inbox_id: inbox.id,
+ event_end_time: 4.hours.ago)
+
+ create(:reporting_event,
+ name: 'conversation_bot_handoff',
+ conversation_id: conversation.id,
+ account_id: account.id,
+ inbox_id: inbox.id,
+ event_end_time: 3.hours.ago)
+
+ create(:reporting_event,
+ name: 'conversation_opened',
+ conversation_id: conversation.id,
+ account_id: account.id,
+ inbox_id: inbox.id,
+ event_end_time: opened_event_time)
+ end
+
+ it 'returns the most recent handoff or opened event' do
+ # opened_event is more recent than handoff_event
+ expect(helper.last_non_human_activity(conversation).to_i).to eq(opened_event_time.to_i)
+ end
+ end
+
+ context 'when conversation has multiple reopenings' do
+ let(:third_opened_time) { 30.minutes.ago }
+
+ before do
+ create(:reporting_event,
+ name: 'conversation_opened',
+ conversation_id: conversation.id,
+ account_id: account.id,
+ inbox_id: inbox.id,
+ value: 0,
+ event_end_time: 5.days.ago)
+
+ create(:reporting_event,
+ name: 'conversation_opened',
+ conversation_id: conversation.id,
+ account_id: account.id,
+ inbox_id: inbox.id,
+ value: 3600,
+ event_end_time: 2.days.ago)
+
+ create(:reporting_event,
+ name: 'conversation_opened',
+ conversation_id: conversation.id,
+ account_id: account.id,
+ inbox_id: inbox.id,
+ value: 7200,
+ event_end_time: third_opened_time)
+ end
+
+ it 'returns the most recent opened event' do
+ expect(helper.last_non_human_activity(conversation).to_i).to eq(third_opened_time.to_i)
+ end
+ end
+ end
+end
diff --git a/spec/listeners/reporting_event_listener_spec.rb b/spec/listeners/reporting_event_listener_spec.rb
index 0edf79556..8004349bf 100644
--- a/spec/listeners/reporting_event_listener_spec.rb
+++ b/spec/listeners/reporting_event_listener_spec.rb
@@ -267,4 +267,177 @@ describe ReportingEventListener do
end
end
end
+
+ describe '#conversation_opened' do
+ context 'when conversation is opened for the first time' do
+ let(:new_conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
+
+ it 'creates conversation_opened event with value 0' do
+ expect(account.reporting_events.where(name: 'conversation_opened').count).to be 0
+ event = Events::Base.new('conversation.opened', Time.zone.now, conversation: new_conversation)
+ listener.conversation_opened(event)
+ expect(account.reporting_events.where(name: 'conversation_opened').count).to be 1
+
+ opened_event = account.reporting_events.where(name: 'conversation_opened').first
+ expect(opened_event.value).to eq 0
+ expect(opened_event.value_in_business_hours).to eq 0
+ expect(opened_event.event_start_time).to be_within(1.second).of(new_conversation.created_at)
+ expect(opened_event.event_end_time).to be_within(1.second).of(new_conversation.updated_at)
+ end
+ end
+
+ context 'when conversation is reopened after being resolved' do
+ let(:resolved_time) { 2.hours.ago }
+ let(:reopened_time) { 1.hour.ago }
+ let(:reopened_conversation) do
+ create(:conversation, account: account, inbox: inbox, assignee: user, updated_at: reopened_time)
+ end
+
+ before do
+ # Create a resolved event first
+ create(:reporting_event,
+ name: 'conversation_resolved',
+ account_id: account.id,
+ inbox_id: inbox.id,
+ conversation_id: reopened_conversation.id,
+ user_id: user.id,
+ value: 3600,
+ event_start_time: reopened_conversation.created_at,
+ event_end_time: resolved_time)
+ end
+
+ it 'creates conversation_opened event' do
+ expect(account.reporting_events.where(name: 'conversation_opened').count).to be 0
+ event = Events::Base.new('conversation.opened', reopened_time, conversation: reopened_conversation)
+ listener.conversation_opened(event)
+ expect(account.reporting_events.where(name: 'conversation_opened').count).to be 1
+ end
+
+ it 'calculates correct time since resolution' do
+ event = Events::Base.new('conversation.opened', reopened_time, conversation: reopened_conversation)
+ listener.conversation_opened(event)
+
+ reopened_event = account.reporting_events.where(name: 'conversation_opened').first
+ expect(reopened_event.value).to be_within(1).of(3600) # 1 hour = 3600 seconds
+ expect(reopened_event.event_start_time).to be_within(1.second).of(resolved_time)
+ expect(reopened_event.event_end_time).to be_within(1.second).of(reopened_time)
+ end
+
+ it 'sets correct attributes for conversation_opened event' do
+ event = Events::Base.new('conversation.opened', reopened_time, conversation: reopened_conversation)
+ listener.conversation_opened(event)
+
+ reopened_event = account.reporting_events.where(name: 'conversation_opened').first
+ expect(reopened_event.account_id).to eq(account.id)
+ expect(reopened_event.inbox_id).to eq(inbox.id)
+ expect(reopened_event.conversation_id).to eq(reopened_conversation.id)
+ expect(reopened_event.user_id).to eq(user.id)
+ end
+
+ context 'when business hours enabled for inbox' do
+ let(:resolved_time) { Time.zone.parse('March 20, 2022 12:00') }
+ let(:reopened_time) { Time.zone.parse('March 21, 2022 14:00') }
+ let!(:business_hours_inbox) { create(:inbox, working_hours_enabled: true, account: account) }
+ let!(:business_hours_conversation) do
+ create(:conversation, account: account, inbox: business_hours_inbox, assignee: user, updated_at: reopened_time)
+ end
+
+ before do
+ create(:reporting_event,
+ name: 'conversation_resolved',
+ account_id: account.id,
+ inbox_id: business_hours_inbox.id,
+ conversation_id: business_hours_conversation.id,
+ user_id: user.id,
+ value: 3600,
+ event_start_time: business_hours_conversation.created_at,
+ event_end_time: resolved_time)
+ end
+
+ it 'creates conversation_opened event with business hour value' do
+ event = Events::Base.new('conversation.opened', reopened_time, conversation: business_hours_conversation)
+ listener.conversation_opened(event)
+
+ reopened_event = account.reporting_events.where(name: 'conversation_opened').first
+ expect(reopened_event.value_in_business_hours).to be 18_000.0 # 5 business hours (26 hours total - 21 non-business hours)
+ end
+ end
+ end
+
+ context 'when conversation has multiple resolutions' do
+ let(:first_resolved_time) { 3.hours.ago }
+ let(:second_resolved_time) { 1.hour.ago }
+ let(:reopened_time) { 30.minutes.ago }
+ let(:multiple_resolution_conversation) do
+ create(:conversation, account: account, inbox: inbox, assignee: user, updated_at: reopened_time)
+ end
+
+ before do
+ # Create first resolved event
+ create(:reporting_event,
+ name: 'conversation_resolved',
+ account_id: account.id,
+ inbox_id: inbox.id,
+ conversation_id: multiple_resolution_conversation.id,
+ user_id: user.id,
+ value: 3600,
+ event_start_time: multiple_resolution_conversation.created_at,
+ event_end_time: first_resolved_time)
+
+ # Create second resolved event (more recent)
+ create(:reporting_event,
+ name: 'conversation_resolved',
+ account_id: account.id,
+ inbox_id: inbox.id,
+ conversation_id: multiple_resolution_conversation.id,
+ user_id: user.id,
+ value: 1800,
+ event_start_time: first_resolved_time,
+ event_end_time: second_resolved_time)
+ end
+
+ it 'uses the most recent resolved event for calculation' do
+ event = Events::Base.new('conversation.opened', reopened_time, conversation: multiple_resolution_conversation)
+ listener.conversation_opened(event)
+
+ reopened_event = account.reporting_events.where(name: 'conversation_opened').first
+ expect(reopened_event.value).to be_within(1).of(1800) # 30 minutes from second resolution
+ expect(reopened_event.event_start_time).to be_within(1.second).of(second_resolved_time)
+ end
+ end
+
+ context 'when agent bot resolves and conversation is reopened' do
+ # This implicitly tests that the first_response time is correctly calculated
+ # By checking that a conversation reopened event is created with the correct values
+ let(:agent_bot) { create(:agent_bot, account: account) }
+ let(:agent_bot_inbox) { create(:inbox, account: account) }
+ let(:bot_resolved_time) { 2.hours.ago }
+ let(:reopened_time) { 1.hour.ago }
+ let(:bot_conversation) do
+ create(:conversation, account: account, inbox: agent_bot_inbox, assignee: user, updated_at: reopened_time)
+ end
+
+ before do
+ create(:agent_bot_inbox, agent_bot: agent_bot, inbox: agent_bot_inbox)
+
+ create(:reporting_event,
+ name: 'conversation_resolved',
+ account_id: account.id,
+ inbox_id: agent_bot_inbox.id,
+ conversation_id: bot_conversation.id,
+ user_id: user.id,
+ event_end_time: bot_resolved_time)
+ end
+
+ it 'creates conversation_opened event for agent bot reopening' do
+ event = Events::Base.new('conversation.opened', reopened_time, conversation: bot_conversation)
+ listener.conversation_opened(event)
+
+ reopened_event = account.reporting_events.where(name: 'conversation_opened').first
+ expect(reopened_event.value).to be_within(1).of(3600) # 1 hour since resolution
+ expect(reopened_event.event_start_time).to be_within(1.second).of(bot_resolved_time)
+ expect(reopened_event.event_end_time).to be_within(1.second).of(reopened_time)
+ end
+ end
+ end
end
From 6b42ff8d39bfbdfb84f433a43149552b5b8bea94 Mon Sep 17 00:00:00 2001
From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Date: Wed, 13 Aug 2025 20:53:31 +0530
Subject: [PATCH 11/11] fix: setup webhook for create and update should be done
after db commit (#12176)
## Reference
https://github.com/chatwoot/chatwoot/pull/12149#issuecomment-3178108388
## Description
setup_webhook was done before the save, and hence the meta webhook
validation might fail because of a race condition where the facebook
validation is done before we saving the entry to the database.
## Type of change
Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
- New inbox creation, webhook validation
- Existing inbox update, webhook validation
-
## 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
---------
Co-authored-by: Muhsin Keloth
---
app/models/channel/whatsapp.rb | 42 +---
.../whatsapp/channel_creation_service.rb | 7 +-
.../whatsapp/embedded_signup_service.rb | 36 ++--
.../whatsapp/channel_creation_service_spec.rb | 5 +
.../whatsapp/embedded_signup_service_spec.rb | 184 +++++++++---------
.../whatsapp/webhook_setup_service_spec.rb | 89 ++++++++-
6 files changed, 223 insertions(+), 140 deletions(-)
diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb
index 7471cf807..7318cd978 100644
--- a/app/models/channel/whatsapp.rb
+++ b/app/models/channel/whatsapp.rb
@@ -32,7 +32,6 @@ class Channel::Whatsapp < ApplicationRecord
validates :phone_number, presence: true, uniqueness: true
validate :validate_provider_config
- before_save :setup_webhooks
after_create :sync_templates
before_destroy :teardown_webhooks
@@ -60,6 +59,13 @@ class Channel::Whatsapp < ApplicationRecord
delegate :media_url, to: :provider_service
delegate :api_headers, to: :provider_service
+ def setup_webhooks
+ perform_webhook_setup
+ rescue StandardError => e
+ Rails.logger.error "[WHATSAPP] Webhook setup failed: #{e.message}"
+ prompt_reauthorization!
+ end
+
private
def ensure_webhook_verify_token
@@ -70,34 +76,6 @@ class Channel::Whatsapp < ApplicationRecord
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
end
- def setup_webhooks
- return unless should_setup_webhooks?
-
- perform_webhook_setup
- rescue StandardError => e
- handle_webhook_setup_error(e)
- end
-
- def provider_config_changed?
- will_save_change_to_provider_config?
- end
-
- def should_setup_webhooks?
- whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present? && provider_config_changed?
- end
-
- def whatsapp_cloud_provider?
- provider == 'whatsapp_cloud'
- end
-
- def embedded_signup_source?
- provider_config['source'] == 'embedded_signup'
- end
-
- def webhook_config_present?
- provider_config['business_account_id'].present? && provider_config['api_key'].present?
- end
-
def perform_webhook_setup
business_account_id = provider_config['business_account_id']
api_key = provider_config['api_key']
@@ -105,12 +83,6 @@ class Channel::Whatsapp < ApplicationRecord
Whatsapp::WebhookSetupService.new(self, business_account_id, api_key).perform
end
- def handle_webhook_setup_error(error)
- Rails.logger.error "[WHATSAPP] Webhook setup failed: #{error.message}"
- # Don't raise the error to prevent channel creation from failing
- # Webhooks can be retried later
- end
-
def teardown_webhooks
Whatsapp::WebhookTeardownService.new(self).perform
end
diff --git a/app/services/whatsapp/channel_creation_service.rb b/app/services/whatsapp/channel_creation_service.rb
index 3039ca003..154f55520 100644
--- a/app/services/whatsapp/channel_creation_service.rb
+++ b/app/services/whatsapp/channel_creation_service.rb
@@ -33,15 +33,14 @@ class Whatsapp::ChannelCreationService
def create_channel_with_inbox
ActiveRecord::Base.transaction do
- channel = create_channel
+ channel = build_channel
create_inbox(channel)
- channel.reload
channel
end
end
- def create_channel
- Channel::Whatsapp.create!(
+ def build_channel
+ Channel::Whatsapp.build(
account: @account,
phone_number: @phone_info[:phone_number],
provider: 'whatsapp_cloud',
diff --git a/app/services/whatsapp/embedded_signup_service.rb b/app/services/whatsapp/embedded_signup_service.rb
index e66506638..1b882b1f1 100644
--- a/app/services/whatsapp/embedded_signup_service.rb
+++ b/app/services/whatsapp/embedded_signup_service.rb
@@ -11,16 +11,34 @@ class Whatsapp::EmbeddedSignupService
def perform
validate_parameters!
- # Exchange code for user access token
- access_token = Whatsapp::TokenExchangeService.new(@code).perform
+ access_token = exchange_code_for_token
+ phone_info = fetch_phone_info(access_token)
+ validate_token_access(access_token)
- # Fetch phone information
- phone_info = Whatsapp::PhoneInfoService.new(@waba_id, @phone_number_id, access_token).perform
+ channel = create_or_reauthorize_channel(access_token, phone_info)
+ channel.setup_webhooks
+ channel
- # Validate token has access to the WABA
+ rescue StandardError => e
+ Rails.logger.error("[WHATSAPP] Embedded signup failed: #{e.message}")
+ raise e
+ end
+
+ private
+
+ def exchange_code_for_token
+ Whatsapp::TokenExchangeService.new(@code).perform
+ end
+
+ def fetch_phone_info(access_token)
+ Whatsapp::PhoneInfoService.new(@waba_id, @phone_number_id, access_token).perform
+ end
+
+ def validate_token_access(access_token)
Whatsapp::TokenValidationService.new(access_token, @waba_id).perform
+ end
- # Reauthorization flow if inbox_id is present
+ def create_or_reauthorize_channel(access_token, phone_info)
if @inbox_id.present?
Whatsapp::ReauthorizationService.new(
account: @account,
@@ -29,17 +47,11 @@ class Whatsapp::EmbeddedSignupService
business_id: @business_id
).perform(access_token, phone_info)
else
- # Create channel for new authorization
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
Whatsapp::ChannelCreationService.new(@account, waba_info, phone_info, access_token).perform
end
- rescue StandardError => e
- Rails.logger.error("[WHATSAPP] Embedded signup failed: #{e.message}")
- raise e
end
- private
-
def validate_parameters!
missing_params = []
missing_params << 'code' if @code.blank?
diff --git a/spec/services/whatsapp/channel_creation_service_spec.rb b/spec/services/whatsapp/channel_creation_service_spec.rb
index 1c1f46232..403fb71b8 100644
--- a/spec/services/whatsapp/channel_creation_service_spec.rb
+++ b/spec/services/whatsapp/channel_creation_service_spec.rb
@@ -16,6 +16,11 @@ describe Whatsapp::ChannelCreationService do
describe '#perform' do
before do
+ # Stub the webhook teardown service to prevent HTTP calls during cleanup
+ teardown_service = instance_double(Whatsapp::WebhookTeardownService)
+ allow(Whatsapp::WebhookTeardownService).to receive(:new).and_return(teardown_service)
+ allow(teardown_service).to receive(:perform)
+
# Clean up any existing channels to avoid phone number conflicts
Channel::Whatsapp.destroy_all
diff --git a/spec/services/whatsapp/embedded_signup_service_spec.rb b/spec/services/whatsapp/embedded_signup_service_spec.rb
index 12a4d32df..1db94928e 100644
--- a/spec/services/whatsapp/embedded_signup_service_spec.rb
+++ b/spec/services/whatsapp/embedded_signup_service_spec.rb
@@ -10,121 +10,100 @@ describe Whatsapp::EmbeddedSignupService do
phone_number_id: 'test_phone_number_id'
}
end
- let(:service) do
- described_class.new(
- account: account,
- params: params
- )
+ let(:service) { described_class.new(account: account, params: params) }
+ let(:access_token) { 'test_access_token' }
+ let(:phone_info) do
+ {
+ phone_number_id: params[:phone_number_id],
+ phone_number: '+1234567890',
+ verified: true,
+ business_name: 'Test Business'
+ }
end
+ let(:channel) { instance_double(Channel::Whatsapp) }
describe '#perform' do
- let(:access_token) { 'test_access_token' }
- let(:phone_info) do
- {
- phone_number_id: params[:phone_number_id],
- phone_number: '+1234567890',
- verified: true,
- business_name: 'Test Business'
- }
- end
- let(:channel) { instance_double(Channel::Whatsapp) }
- let(:service_doubles) do
- {
- token_exchange: instance_double(Whatsapp::TokenExchangeService),
- phone_info: instance_double(Whatsapp::PhoneInfoService),
- token_validation: instance_double(Whatsapp::TokenValidationService),
- channel_creation: instance_double(Whatsapp::ChannelCreationService)
- }
- end
-
before do
allow(GlobalConfig).to receive(:clear_cache)
- allow(Whatsapp::TokenExchangeService).to receive(:new).with(params[:code]).and_return(service_doubles[:token_exchange])
- allow(service_doubles[:token_exchange]).to receive(:perform).and_return(access_token)
+ # Mock service dependencies
+ token_exchange = instance_double(Whatsapp::TokenExchangeService)
+ allow(Whatsapp::TokenExchangeService).to receive(:new).with(params[:code]).and_return(token_exchange)
+ allow(token_exchange).to receive(:perform).and_return(access_token)
+ phone_service = instance_double(Whatsapp::PhoneInfoService)
allow(Whatsapp::PhoneInfoService).to receive(:new)
- .with(params[:waba_id], params[:phone_number_id], access_token).and_return(service_doubles[:phone_info])
- allow(service_doubles[:phone_info]).to receive(:perform).and_return(phone_info)
+ .with(params[:waba_id], params[:phone_number_id], access_token).and_return(phone_service)
+ allow(phone_service).to receive(:perform).and_return(phone_info)
+ validation_service = instance_double(Whatsapp::TokenValidationService)
allow(Whatsapp::TokenValidationService).to receive(:new)
- .with(access_token, params[:waba_id]).and_return(service_doubles[:token_validation])
- allow(service_doubles[:token_validation]).to receive(:perform)
+ .with(access_token, params[:waba_id]).and_return(validation_service)
+ allow(validation_service).to receive(:perform)
+ channel_creation = instance_double(Whatsapp::ChannelCreationService)
allow(Whatsapp::ChannelCreationService).to receive(:new)
.with(account, { waba_id: params[:waba_id], business_name: 'Test Business' }, phone_info, access_token)
- .and_return(service_doubles[:channel_creation])
- allow(service_doubles[:channel_creation]).to receive(:perform).and_return(channel)
+ .and_return(channel_creation)
+ allow(channel_creation).to receive(:perform).and_return(channel)
- # Webhook setup is now handled in the channel after_create callback
- # So we stub it at the model level
- webhook_service = instance_double(Whatsapp::WebhookSetupService)
- allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
- allow(webhook_service).to receive(:perform)
+ allow(channel).to receive(:setup_webhooks)
end
- it 'orchestrates all services in the correct order' do
- expect(service_doubles[:token_exchange]).to receive(:perform).ordered
- expect(service_doubles[:phone_info]).to receive(:perform).ordered
- expect(service_doubles[:token_validation]).to receive(:perform).ordered
- expect(service_doubles[:channel_creation]).to receive(:perform).ordered
+ it 'creates channel and sets up webhooks' do
+ expect(channel).to receive(:setup_webhooks)
result = service.perform
expect(result).to eq(channel)
end
- context 'when required parameters are missing' do
- it 'raises error when code is blank' do
- service = described_class.new(
- account: account,
- params: params.merge(code: '')
- )
- expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code/)
- end
-
- it 'raises error when business_id is blank' do
- service = described_class.new(
- account: account,
- params: params.merge(business_id: '')
- )
- expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: business_id/)
- end
-
- it 'raises error when waba_id is blank' do
- service = described_class.new(
- account: account,
- params: params.merge(waba_id: '')
- )
- expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: waba_id/)
- end
-
- it 'raises error when multiple parameters are blank' do
- service = described_class.new(
- account: account,
- params: params.merge(code: '', business_id: '')
- )
- expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code, business_id/)
+ context 'when parameters are invalid' do
+ it 'raises ArgumentError for missing parameters' do
+ invalid_service = described_class.new(account: account, params: { code: '', business_id: '', waba_id: '' })
+ expect { invalid_service.perform }.to raise_error(ArgumentError, /Required parameters are missing/)
end
end
- context 'when any service fails' do
- it 'logs and re-raises the error' do
- allow(service_doubles[:token_exchange]).to receive(:perform).and_raise('Token error')
+ context 'when service fails' do
+ it 'logs and re-raises errors' do
+ token_exchange = instance_double(Whatsapp::TokenExchangeService)
+ allow(Whatsapp::TokenExchangeService).to receive(:new).and_return(token_exchange)
+ allow(token_exchange).to receive(:perform).and_raise('Token error')
expect(Rails.logger).to receive(:error).with('[WHATSAPP] Embedded signup failed: Token error')
expect { service.perform }.to raise_error('Token error')
end
+
+ it 'prompts reauthorization when webhook setup fails' do
+ # Create a real channel to test the actual webhook failure behavior
+ real_channel = create(:channel_whatsapp, account: account, phone_number: '+1234567890',
+ validate_provider_config: false, sync_templates: false)
+
+ # Mock the channel creation to return our real channel
+ channel_creation = instance_double(Whatsapp::ChannelCreationService)
+ allow(Whatsapp::ChannelCreationService).to receive(:new).and_return(channel_creation)
+ allow(channel_creation).to receive(:perform).and_return(real_channel)
+
+ # Mock webhook setup to fail
+ allow(real_channel).to receive(:perform_webhook_setup).and_raise('Webhook setup error')
+
+ # Verify channel is not marked for reauthorization initially
+ expect(real_channel.reauthorization_required?).to be false
+
+ # The service completes successfully even if webhook fails (webhook error is rescued in setup_webhooks)
+ result = service.perform
+ expect(result).to eq(real_channel)
+
+ # Verify the channel is now marked for reauthorization
+ expect(real_channel.reauthorization_required?).to be true
+ end
end
- context 'when inbox_id is provided (reauthorization flow)' do
+ context 'with reauthorization flow' do
let(:inbox_id) { 123 }
let(:reauth_service) { instance_double(Whatsapp::ReauthorizationService) }
let(:service_with_inbox) do
- described_class.new(
- account: account,
- params: params,
- inbox_id: inbox_id
- )
+ described_class.new(account: account, params: params, inbox_id: inbox_id)
end
before do
@@ -137,16 +116,45 @@ describe Whatsapp::EmbeddedSignupService do
allow(reauth_service).to receive(:perform).with(access_token, phone_info).and_return(channel)
end
- it 'uses ReauthorizationService instead of ChannelCreationService' do
- expect(service_doubles[:token_exchange]).to receive(:perform).ordered
- expect(service_doubles[:phone_info]).to receive(:perform).ordered
- expect(service_doubles[:token_validation]).to receive(:perform).ordered
- expect(reauth_service).to receive(:perform).with(access_token, phone_info).ordered
- expect(service_doubles[:channel_creation]).not_to receive(:perform)
+ it 'uses ReauthorizationService and sets up webhooks' do
+ expect(reauth_service).to receive(:perform)
+ expect(channel).to receive(:setup_webhooks)
result = service_with_inbox.perform
expect(result).to eq(channel)
end
+
+ it 'clears reauthorization flag' do
+ inbox = create(:inbox, account: account)
+ whatsapp_channel = create(:channel_whatsapp, account: account, phone_number: '+1234567890',
+ validate_provider_config: false, sync_templates: false)
+ inbox.update!(channel: whatsapp_channel)
+ whatsapp_channel.prompt_reauthorization!
+
+ service_with_real_inbox = described_class.new(account: account, params: params, inbox_id: inbox.id)
+
+ # Mock the ReauthorizationService to return our test channel
+ reauth_service = instance_double(Whatsapp::ReauthorizationService)
+ allow(Whatsapp::ReauthorizationService).to receive(:new).with(
+ account: account,
+ inbox_id: inbox.id,
+ phone_number_id: params[:phone_number_id],
+ business_id: params[:business_id]
+ ).and_return(reauth_service)
+
+ # Perform the reauthorization and clear the flag
+ allow(reauth_service).to receive(:perform) do
+ whatsapp_channel.reauthorized!
+ whatsapp_channel
+ end
+
+ allow(whatsapp_channel).to receive(:setup_webhooks).and_return(true)
+
+ expect(whatsapp_channel.reauthorization_required?).to be true
+ result = service_with_real_inbox.perform
+ expect(result).to eq(whatsapp_channel)
+ expect(whatsapp_channel.reauthorization_required?).to be false
+ end
end
end
end
diff --git a/spec/services/whatsapp/webhook_setup_service_spec.rb b/spec/services/whatsapp/webhook_setup_service_spec.rb
index 7cee115c2..e6a246e5d 100644
--- a/spec/services/whatsapp/webhook_setup_service_spec.rb
+++ b/spec/services/whatsapp/webhook_setup_service_spec.rb
@@ -5,7 +5,7 @@ describe Whatsapp::WebhookSetupService do
create(:channel_whatsapp,
phone_number: '+1234567890',
provider_config: {
- 'phone_number_id' => 'test_phone_id',
+ 'phone_number_id' => '123456789',
'webhook_verify_token' => 'test_verify_token'
},
provider: 'whatsapp_cloud',
@@ -18,9 +18,14 @@ describe Whatsapp::WebhookSetupService do
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
before do
+ # Stub webhook teardown to prevent HTTP calls during cleanup
+ stub_request(:delete, /graph.facebook.com/).to_return(status: 200, body: '{}', headers: {})
+
# Clean up any existing channels to avoid phone number conflicts
Channel::Whatsapp.destroy_all
allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
+ # Default stub for phone_number_verified? with any argument
+ allow(api_client).to receive(:phone_number_verified?).and_return(false)
end
describe '#perform' do
@@ -148,5 +153,87 @@ describe Whatsapp::WebhookSetupService do
end
end
end
+
+ context 'when webhook setup fails and should trigger reauthorization' do
+ before do
+ allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
+ allow(api_client).to receive(:subscribe_waba_webhook).and_raise('Invalid access token')
+ end
+
+ it 'raises error with webhook setup failure message' do
+ with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
+ expect { service.perform }.to raise_error(/Webhook setup failed: Invalid access token/)
+ end
+ end
+
+ it 'logs the webhook setup failure' do
+ with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
+ expect(Rails.logger).to receive(:error).with('[WHATSAPP] Webhook setup failed: Invalid access token')
+ expect { service.perform }.to raise_error(/Webhook setup failed/)
+ end
+ end
+ end
+
+ context 'when used during reauthorization flow' do
+ let(:existing_channel) do
+ create(:channel_whatsapp,
+ phone_number: '+1234567890',
+ provider_config: {
+ 'phone_number_id' => '123456789',
+ 'webhook_verify_token' => 'existing_verify_token',
+ 'business_id' => 'existing_business_id',
+ 'waba_id' => 'existing_waba_id'
+ },
+ provider: 'whatsapp_cloud',
+ sync_templates: false,
+ validate_provider_config: false)
+ end
+ let(:new_access_token) { 'new_access_token' }
+ let(:service_reauth) { described_class.new(existing_channel, waba_id, new_access_token) }
+
+ before do
+ allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
+ allow(api_client).to receive(:subscribe_waba_webhook)
+ .with(waba_id, anything, 'existing_verify_token').and_return({ 'success' => true })
+ end
+
+ it 'successfully reauthorizes with new access token' do
+ with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
+ expect(api_client).not_to receive(:register_phone_number)
+ expect(api_client).to receive(:subscribe_waba_webhook)
+ .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'existing_verify_token')
+ service_reauth.perform
+ end
+ end
+
+ it 'uses the existing webhook verify token during reauthorization' do
+ with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
+ expect(api_client).to receive(:subscribe_waba_webhook)
+ .with(waba_id, anything, 'existing_verify_token')
+ service_reauth.perform
+ end
+ end
+ end
+
+ context 'when webhook setup is successful in creation flow' do
+ before do
+ allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
+ allow(api_client).to receive(:subscribe_waba_webhook)
+ .with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
+ end
+
+ it 'completes successfully without errors' do
+ with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
+ expect { service.perform }.not_to raise_error
+ end
+ end
+
+ it 'does not log any errors' do
+ with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
+ expect(Rails.logger).not_to receive(:error)
+ service.perform
+ end
+ end
+ end
end
end