diff --git a/app/controllers/api/v1/accounts/campaigns_controller.rb b/app/controllers/api/v1/accounts/campaigns_controller.rb index b1a132246..2a1650e53 100644 --- a/app/controllers/api/v1/accounts/campaigns_controller.rb +++ b/app/controllers/api/v1/accounts/campaigns_controller.rb @@ -29,6 +29,6 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController def campaign_params params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id, - :scheduled_at, audience: [:type, :id], trigger_rules: {}) + :scheduled_at, audience: [:type, :id], trigger_rules: {}, template_params: {}) end end diff --git a/app/javascript/dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue b/app/javascript/dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue new file mode 100644 index 000000000..ab01acb4a --- /dev/null +++ b/app/javascript/dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue @@ -0,0 +1,37 @@ + + + diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue new file mode 100644 index 000000000..12a789fee --- /dev/null +++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue @@ -0,0 +1,48 @@ + + + diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue new file mode 100644 index 000000000..df76ae901 --- /dev/null +++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue @@ -0,0 +1,357 @@ + + + diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index 171f4a4d8..12f15ce4b 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -331,6 +331,11 @@ const menuItems = computed(() => { label: t('SIDEBAR.SMS'), to: accountScopedRoute('campaigns_sms_index'), }, + { + name: 'WhatsApp', + label: t('SIDEBAR.WHATSAPP'), + to: accountScopedRoute('campaigns_whatsapp_index'), + }, ], }, { diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 78109e82c..28b6b09b7 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -4,6 +4,7 @@ export const FEATURE_FLAGS = { AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations', AUTOMATIONS: 'automations', CAMPAIGNS: 'campaigns', + WHATSAPP_CAMPAIGNS: 'whatsapp_campaign', CANNED_RESPONSES: 'canned_responses', CRM: 'crm', CUSTOM_ATTRIBUTES: 'custom_attributes', diff --git a/app/javascript/dashboard/i18n/locale/en/campaign.json b/app/javascript/dashboard/i18n/locale/en/campaign.json index e2418d52e..10366e79e 100644 --- a/app/javascript/dashboard/i18n/locale/en/campaign.json +++ b/app/javascript/dashboard/i18n/locale/en/campaign.json @@ -137,6 +137,70 @@ } } }, + "WHATSAPP": { + "HEADER_TITLE": "WhatsApp campaigns", + "NEW_CAMPAIGN": "Create campaign", + "EMPTY_STATE": { + "TITLE": "No WhatsApp campaigns are available", + "SUBTITLE": "Launch a WhatsApp campaign to reach your customers directly. Send offers or make announcements with ease. Click 'Create campaign' to get started." + }, + "CARD": { + "STATUS": { + "COMPLETED": "Completed", + "SCHEDULED": "Scheduled" + }, + "CAMPAIGN_DETAILS": { + "SENT_FROM": "Sent from", + "ON": "on" + } + }, + "CREATE": { + "TITLE": "Create WhatsApp campaign", + "CANCEL_BUTTON_TEXT": "Cancel", + "CREATE_BUTTON_TEXT": "Create", + "FORM": { + "TITLE": { + "LABEL": "Title", + "PLACEHOLDER": "Please enter the title of campaign", + "ERROR": "Title is required" + }, + "INBOX": { + "LABEL": "Select Inbox", + "PLACEHOLDER": "Select Inbox", + "ERROR": "Inbox is required" + }, + "TEMPLATE": { + "LABEL": "WhatsApp Template", + "PLACEHOLDER": "Select a template", + "INFO": "Select a template to use for this campaign.", + "ERROR": "Template is required", + "PREVIEW_TITLE": "Process {templateName}", + "LANGUAGE": "Language", + "CATEGORY": "Category", + "VARIABLES_LABEL": "Variables", + "VARIABLE_PLACEHOLDER": "Enter value for {variable}" + }, + "AUDIENCE": { + "LABEL": "Audience", + "PLACEHOLDER": "Select the customer labels", + "ERROR": "Audience is required" + }, + "SCHEDULED_AT": { + "LABEL": "Scheduled time", + "PLACEHOLDER": "Please select the time", + "ERROR": "Scheduled time is required" + }, + "BUTTONS": { + "CREATE": "Create", + "CANCEL": "Cancel" + }, + "API": { + "SUCCESS_MESSAGE": "WhatsApp campaign created successfully", + "ERROR_MESSAGE": "There was an error. Please try again." + } + } + } + }, "CONFIRM_DELETE": { "TITLE": "Are you sure to delete?", "DESCRIPTION": "The delete action is permanent and cannot be reversed.", diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 219041d75..fde198c92 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -319,6 +319,7 @@ "CSAT": "CSAT", "LIVE_CHAT": "Live Chat", "SMS": "SMS", + "WHATSAPP": "WhatsApp", "CAMPAIGNS": "Campaigns", "ONGOING": "Ongoing", "ONE_OFF": "One off", diff --git a/app/javascript/dashboard/routes/dashboard/campaigns/campaigns.routes.js b/app/javascript/dashboard/routes/dashboard/campaigns/campaigns.routes.js index e0c1f3a17..c5f2dbdb1 100644 --- a/app/javascript/dashboard/routes/dashboard/campaigns/campaigns.routes.js +++ b/app/javascript/dashboard/routes/dashboard/campaigns/campaigns.routes.js @@ -3,6 +3,7 @@ import { frontendURL } from 'dashboard/helper/URLHelper.js'; import CampaignsPageRouteView from './pages/CampaignsPageRouteView.vue'; import LiveChatCampaignsPage from './pages/LiveChatCampaignsPage.vue'; import SMSCampaignsPage from './pages/SMSCampaignsPage.vue'; +import WhatsAppCampaignsPage from './pages/WhatsAppCampaignsPage.vue'; import { FEATURE_FLAGS } from 'dashboard/featureFlags'; const meta = { @@ -50,6 +51,15 @@ const campaignsRoutes = { meta, component: SMSCampaignsPage, }, + { + path: 'whatsapp', + name: 'campaigns_whatsapp_index', + meta: { + ...meta, + featureFlag: FEATURE_FLAGS.WHATSAPP_CAMPAIGNS, + }, + component: WhatsAppCampaignsPage, + }, ], }, ], diff --git a/app/javascript/dashboard/routes/dashboard/campaigns/pages/LiveChatCampaignsPage.vue b/app/javascript/dashboard/routes/dashboard/campaigns/pages/LiveChatCampaignsPage.vue index 93d5d1ec6..6cc2bf181 100644 --- a/app/javascript/dashboard/routes/dashboard/campaigns/pages/LiveChatCampaignsPage.vue +++ b/app/javascript/dashboard/routes/dashboard/campaigns/pages/LiveChatCampaignsPage.vue @@ -3,7 +3,6 @@ import { computed, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { useToggle } from '@vueuse/core'; import { useStoreGetters, useMapGetter } from 'dashboard/composables/store'; -import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js'; import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue'; @@ -25,8 +24,8 @@ const isFetchingCampaigns = computed(() => uiFlags.value.isFetching); const [showLiveChatCampaignDialog, toggleLiveChatCampaignDialog] = useToggle(); -const liveChatCampaigns = computed(() => - getters['campaigns/getCampaigns'].value(CAMPAIGN_TYPES.ONGOING) +const liveChatCampaigns = computed( + () => getters['campaigns/getLiveChatCampaigns'].value ); const hasNoLiveChatCampaigns = computed( @@ -59,7 +58,7 @@ const handleDelete = campaign => {
diff --git a/app/javascript/dashboard/routes/dashboard/campaigns/pages/SMSCampaignsPage.vue b/app/javascript/dashboard/routes/dashboard/campaigns/pages/SMSCampaignsPage.vue index a38a818f0..c04726ebe 100644 --- a/app/javascript/dashboard/routes/dashboard/campaigns/pages/SMSCampaignsPage.vue +++ b/app/javascript/dashboard/routes/dashboard/campaigns/pages/SMSCampaignsPage.vue @@ -3,7 +3,6 @@ import { computed, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { useToggle } from '@vueuse/core'; import { useStoreGetters, useMapGetter } from 'dashboard/composables/store'; -import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js'; import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue'; @@ -23,9 +22,7 @@ const isFetchingCampaigns = computed(() => uiFlags.value.isFetching); const confirmDeleteCampaignDialogRef = ref(null); -const SMSCampaigns = computed(() => - getters['campaigns/getCampaigns'].value(CAMPAIGN_TYPES.ONE_OFF) -); +const SMSCampaigns = computed(() => getters['campaigns/getSMSCampaigns'].value); const hasNoSMSCampaigns = computed( () => SMSCampaigns.value?.length === 0 && !isFetchingCampaigns.value diff --git a/app/javascript/dashboard/routes/dashboard/campaigns/pages/WhatsAppCampaignsPage.vue b/app/javascript/dashboard/routes/dashboard/campaigns/pages/WhatsAppCampaignsPage.vue new file mode 100644 index 000000000..96aa21b5a --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/campaigns/pages/WhatsAppCampaignsPage.vue @@ -0,0 +1,74 @@ + + + diff --git a/app/javascript/dashboard/store/modules/campaigns.js b/app/javascript/dashboard/store/modules/campaigns.js index c5a73d1d3..c558716f0 100644 --- a/app/javascript/dashboard/store/modules/campaigns.js +++ b/app/javascript/dashboard/store/modules/campaigns.js @@ -3,6 +3,8 @@ import types from '../mutation-types'; import CampaignsAPI from '../../api/campaigns'; import AnalyticsHelper from '../../helper/AnalyticsHelper'; import { CAMPAIGNS_EVENTS } from '../../helper/AnalyticsHelper/events'; +import { CAMPAIGN_TYPES } from 'shared/constants/campaign'; +import { INBOX_TYPES } from 'dashboard/helper/inbox'; export const state = { records: [], @@ -16,10 +18,35 @@ export const getters = { getUIFlags(_state) { return _state.uiFlags; }, - getCampaigns: _state => campaignType => { - return _state.records - .filter(record => record.campaign_type === campaignType) - .sort((a1, a2) => a1.id - a2.id); + getCampaigns: + _state => + (campaignType, inboxChannelTypes = null) => { + let filteredRecords = _state.records.filter( + record => record.campaign_type === campaignType + ); + + if (inboxChannelTypes && Array.isArray(inboxChannelTypes)) { + filteredRecords = filteredRecords.filter(record => { + return ( + record.inbox && + inboxChannelTypes.includes(record.inbox.channel_type) + ); + }); + } + + return filteredRecords.sort((a1, a2) => a1.id - a2.id); + }, + getSMSCampaigns: (_state, _getters) => { + const smsChannelTypes = [INBOX_TYPES.SMS, INBOX_TYPES.TWILIO]; + return _getters.getCampaigns(CAMPAIGN_TYPES.ONE_OFF, smsChannelTypes); + }, + getWhatsAppCampaigns: (_state, _getters) => { + const whatsappChannelTypes = [INBOX_TYPES.WHATSAPP]; + return _getters.getCampaigns(CAMPAIGN_TYPES.ONE_OFF, whatsappChannelTypes); + }, + getLiveChatCampaigns: (_state, _getters) => { + const liveChatChannelTypes = [INBOX_TYPES.WEB]; + return _getters.getCampaigns(CAMPAIGN_TYPES.ONGOING, liveChatChannelTypes); }, getAllCampaigns: _state => { return _state.records; diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js index 68f4780e9..14bd4c2a9 100644 --- a/app/javascript/dashboard/store/modules/inboxes.js +++ b/app/javascript/dashboard/store/modules/inboxes.js @@ -96,6 +96,11 @@ export const getters = { (item.channel_type === INBOX_TYPES.TWILIO && item.medium === 'sms') ); }, + getWhatsAppInboxes($state) { + return $state.records.filter( + item => item.channel_type === INBOX_TYPES.WHATSAPP + ); + }, dialogFlowEnabledInboxes($state) { return $state.records.filter( item => item.channel_type !== INBOX_TYPES.EMAIL diff --git a/app/javascript/dashboard/store/modules/specs/campaigns/fixtures.js b/app/javascript/dashboard/store/modules/specs/campaigns/fixtures.js index 08fd398e2..400b43638 100644 --- a/app/javascript/dashboard/store/modules/specs/campaigns/fixtures.js +++ b/app/javascript/dashboard/store/modules/specs/campaigns/fixtures.js @@ -11,6 +11,11 @@ export default [ url: 'https://github.com', time_on_page: 10, }, + inbox: { + id: 1, + channel_type: 'Channel::WebWidget', + name: 'Web Widget', + }, created_at: '2021-05-03T04:53:36.354Z', updated_at: '2021-05-03T04:53:36.354Z', }, @@ -24,6 +29,11 @@ export default [ url: 'https://chatwoot.com', time_on_page: '20', }, + inbox: { + id: 2, + channel_type: 'Channel::TwilioSms', + name: 'Twilio SMS', + }, created_at: '2021-05-03T08:15:35.828Z', updated_at: '2021-05-03T08:15:35.828Z', }, @@ -39,7 +49,52 @@ export default [ url: 'https://noshow.com', time_on_page: 10, }, + inbox: { + id: 3, + channel_type: 'Channel::WebWidget', + name: 'Web Widget 2', + }, created_at: '2021-05-03T10:22:51.025Z', updated_at: '2021-05-03T10:22:51.025Z', }, + { + id: 4, + title: 'WhatsApp Campaign', + description: null, + account_id: 1, + campaign_type: 'one_off', + message: 'Hello {{name}}, your order is ready!', + enabled: true, + trigger_rules: {}, + inbox: { + id: 4, + channel_type: 'Channel::Whatsapp', + name: 'WhatsApp Business', + }, + template_params: { + name: 'order_ready', + namespace: 'business_namespace', + language: 'en_US', + processed_params: { name: 'John' }, + }, + created_at: '2021-05-03T12:15:35.828Z', + updated_at: '2021-05-03T12:15:35.828Z', + }, + { + id: 5, + title: 'SMS Promotion', + description: null, + account_id: 1, + campaign_type: 'one_off', + message: 'Get 20% off your next order!', + enabled: true, + trigger_rules: {}, + inbox: { + id: 5, + channel_type: 'Channel::Sms', + name: 'SMS Channel', + }, + created_at: '2021-05-03T14:15:35.828Z', + updated_at: '2021-05-03T14:15:35.828Z', + }, ]; diff --git a/app/javascript/dashboard/store/modules/specs/campaigns/getters.spec.js b/app/javascript/dashboard/store/modules/specs/campaigns/getters.spec.js index 52f14c296..231b83adc 100644 --- a/app/javascript/dashboard/store/modules/specs/campaigns/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/campaigns/getters.spec.js @@ -13,20 +13,58 @@ describe('#getters', () => { it('get one_off campaigns', () => { const state = { records: campaigns }; expect(getters.getCampaigns(state)('one_off')).toEqual([ - { - id: 2, - title: 'Onboarding Campaign', - description: null, - account_id: 1, - campaign_type: 'one_off', + campaigns[1], + campaigns[3], + campaigns[4], + ]); + }); - trigger_rules: { - url: 'https://chatwoot.com', - time_on_page: '20', - }, - created_at: '2021-05-03T08:15:35.828Z', - updated_at: '2021-05-03T08:15:35.828Z', - }, + it('get campaigns by channel type', () => { + const state = { records: campaigns }; + expect( + getters.getCampaigns(state)('one_off', ['Channel::Whatsapp']) + ).toEqual([campaigns[3]]); + }); + + it('get campaigns by multiple channel types', () => { + const state = { records: campaigns }; + expect( + getters.getCampaigns(state)('one_off', [ + 'Channel::TwilioSms', + 'Channel::Sms', + ]) + ).toEqual([campaigns[1], campaigns[4]]); + }); + + it('get SMS campaigns', () => { + const state = { records: campaigns }; + const mockGetters = { + getCampaigns: getters.getCampaigns(state), + }; + expect(getters.getSMSCampaigns(state, mockGetters)).toEqual([ + campaigns[1], + campaigns[4], + ]); + }); + + it('get WhatsApp campaigns', () => { + const state = { records: campaigns }; + const mockGetters = { + getCampaigns: getters.getCampaigns(state), + }; + expect(getters.getWhatsAppCampaigns(state, mockGetters)).toEqual([ + campaigns[3], + ]); + }); + + it('get Live Chat campaigns', () => { + const state = { records: campaigns }; + const mockGetters = { + getCampaigns: getters.getCampaigns(state), + }; + expect(getters.getLiveChatCampaigns(state, mockGetters)).toEqual([ + campaigns[0], + campaigns[2], ]); }); diff --git a/app/models/campaign.rb b/app/models/campaign.rb index f937b1d71..2927181c6 100644 --- a/app/models/campaign.rb +++ b/app/models/campaign.rb @@ -10,6 +10,7 @@ # enabled :boolean default(TRUE) # message :text not null # scheduled_at :datetime +# template_params :jsonb # title :string not null # trigger_only_during_business_hours :boolean default(FALSE) # trigger_rules :jsonb @@ -57,12 +58,22 @@ class Campaign < ApplicationRecord return unless one_off? return if completed? - Twilio::OneoffSmsCampaignService.new(campaign: self).perform if inbox.inbox_type == 'Twilio SMS' - Sms::OneoffSmsCampaignService.new(campaign: self).perform if inbox.inbox_type == 'Sms' + execute_campaign end private + def execute_campaign + case inbox.inbox_type + when 'Twilio SMS' + Twilio::OneoffSmsCampaignService.new(campaign: self).perform + when 'Sms' + Sms::OneoffSmsCampaignService.new(campaign: self).perform + when 'Whatsapp' + Whatsapp::OneoffCampaignService.new(campaign: self).perform if account.feature_enabled?(:whatsapp_campaign) + end + end + def set_display_id reload end @@ -70,14 +81,14 @@ class Campaign < ApplicationRecord def validate_campaign_inbox return unless inbox - errors.add :inbox, 'Unsupported Inbox type' unless ['Website', 'Twilio SMS', 'Sms'].include? inbox.inbox_type + errors.add :inbox, 'Unsupported Inbox type' unless ['Website', 'Twilio SMS', 'Sms', 'Whatsapp'].include? inbox.inbox_type end # TO-DO we clean up with better validations when campaigns evolve into more inboxes def ensure_correct_campaign_attributes return if inbox.blank? - if ['Twilio SMS', 'Sms'].include?(inbox.inbox_type) + if ['Twilio SMS', 'Sms', 'Whatsapp'].include?(inbox.inbox_type) self.campaign_type = 'one_off' self.scheduled_at ||= Time.now.utc else diff --git a/app/services/whatsapp/oneoff_campaign_service.rb b/app/services/whatsapp/oneoff_campaign_service.rb new file mode 100644 index 000000000..c2f0080f3 --- /dev/null +++ b/app/services/whatsapp/oneoff_campaign_service.rb @@ -0,0 +1,94 @@ +class Whatsapp::OneoffCampaignService + pattr_initialize [:campaign!] + + def perform + validate_campaign! + process_audience(extract_audience_labels) + campaign.completed! + end + + private + + delegate :inbox, to: :campaign + delegate :channel, to: :inbox + + def validate_campaign_type! + raise "Invalid campaign #{campaign.id}" unless whatsapp_campaign? && campaign.one_off? + end + + def whatsapp_campaign? + campaign.inbox.inbox_type == 'Whatsapp' + end + + def validate_campaign_status! + raise 'Completed Campaign' if campaign.completed? + end + + def validate_provider! + raise 'WhatsApp Cloud provider required' if channel.provider != 'whatsapp_cloud' + end + + def validate_feature_flag! + raise 'WhatsApp campaigns feature not enabled' unless campaign.account.feature_enabled?(:whatsapp_campaign) + end + + def validate_campaign! + validate_campaign_type! + validate_campaign_status! + validate_provider! + validate_feature_flag! + end + + def extract_audience_labels + audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id') + campaign.account.labels.where(id: audience_label_ids).pluck(:title) + end + + def process_contact(contact) + Rails.logger.info "Processing contact: #{contact.name} (#{contact.phone_number})" + + if contact.phone_number.blank? + Rails.logger.info "Skipping contact #{contact.name} - no phone number" + return + end + + if campaign.template_params.blank? + Rails.logger.error "Skipping contact #{contact.name} - no template_params found for WhatsApp campaign" + return + end + + send_whatsapp_template_message(to: contact.phone_number) + end + + def process_audience(audience_labels) + contacts = campaign.account.contacts.tagged_with(audience_labels, any: true) + Rails.logger.info "Processing #{contacts.count} contacts for campaign #{campaign.id}" + + contacts.each { |contact| process_contact(contact) } + + Rails.logger.info "Campaign #{campaign.id} processing completed" + end + + def send_whatsapp_template_message(to:) + processor = Whatsapp::TemplateProcessorService.new( + channel: channel, + template_params: campaign.template_params + ) + + name, namespace, lang_code, processed_parameters = processor.call + + return if name.blank? + + channel.send_template(to, { + name: name, + namespace: namespace, + lang_code: lang_code, + parameters: processed_parameters + }) + + rescue StandardError => e + Rails.logger.error "Failed to send WhatsApp template message to #{to}: #{e.message}" + Rails.logger.error "Backtrace: #{e.backtrace.first(5).join('\n')}" + raise e + end +end diff --git a/app/services/whatsapp/send_on_whatsapp_service.rb b/app/services/whatsapp/send_on_whatsapp_service.rb index 61b78f892..8cecfd41f 100644 --- a/app/services/whatsapp/send_on_whatsapp_service.rb +++ b/app/services/whatsapp/send_on_whatsapp_service.rb @@ -15,7 +15,13 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService end def send_template_message - name, namespace, lang_code, processed_parameters = processable_channel_message_template + processor = Whatsapp::TemplateProcessorService.new( + channel: channel, + template_params: template_params, + message: message + ) + + name, namespace, lang_code, processed_parameters = processor.call return if name.blank? @@ -28,86 +34,6 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService message.update!(source_id: message_id) if message_id.present? end - def processable_channel_message_template - if template_params.present? - return [ - template_params['name'], - template_params['namespace'], - template_params['language'], - processed_templates_params(template_params) - ] - end - - # Delete the following logic once the update for template_params is stable - # see if we can match the message content to a template - # An example template may look like "Your package has been shipped. It will be delivered in {{1}} business days. - # We want to iterate over these templates with our message body and see if we can fit it to any of the templates - # Then we use regex to parse the template varibles and convert them into the proper payload - channel.message_templates&.each do |template| - match_obj = template_match_object(template) - next if match_obj.blank? - - # we have a match, now we need to parse the template variables and convert them into the wa recommended format - processed_parameters = match_obj.captures.map { |x| { type: 'text', text: x } } - - # no need to look up further end the search - return [template['name'], template['namespace'], template['language'], processed_parameters] - end - [nil, nil, nil, nil] - end - - def template_match_object(template) - body_object = validated_body_object(template) - return if body_object.blank? - - template_match_regex = build_template_match_regex(body_object['text']) - message.outgoing_content.match(template_match_regex) - end - - def build_template_match_regex(template_text) - # Converts the whatsapp template to a comparable regex string to check against the message content - # the variables are of the format {{num}} ex:{{1}} - - # transform the template text into a regex string - # we need to replace the {{num}} with matchers that can be used to capture the variables - template_text = template_text.gsub(/{{\d}}/, '(.*)') - # escape if there are regex characters in the template text - template_text = Regexp.escape(template_text) - # ensuring only the variables remain as capture groups - template_text = template_text.gsub(Regexp.escape('(.*)'), '(.*)') - - template_match_string = "^#{template_text}$" - Regexp.new template_match_string - end - - def template(template_params) - channel.message_templates.find do |t| - t['name'] == template_params['name'] && t['language'] == template_params['language'] - end - end - - def processed_templates_params(template_params) - template = template(template_params) - return if template.blank? - - parameter_format = template['parameter_format'] - - if parameter_format == 'NAMED' - template_params['processed_params']&.map { |key, value| { type: 'text', parameter_name: key, text: value } } - else - template_params['processed_params']&.map { |_, value| { type: 'text', text: value } } - end - end - - def validated_body_object(template) - # we don't care if its not approved template - return if template['status'] != 'approved' - - # we only care about text body object in template. if not present we discard the template - # we don't support other forms of templates - template['components'].find { |obj| obj['type'] == 'BODY' && obj.key?('text') } - end - def send_session_message message_id = channel.send_message(message.conversation.contact_inbox.source_id, message) message.update!(source_id: message_id) if message_id.present? diff --git a/app/services/whatsapp/template_processor_service.rb b/app/services/whatsapp/template_processor_service.rb new file mode 100644 index 000000000..2ce9fcf8f --- /dev/null +++ b/app/services/whatsapp/template_processor_service.rb @@ -0,0 +1,95 @@ +class Whatsapp::TemplateProcessorService + pattr_initialize [:channel!, :template_params, :message] + + def call + if template_params.present? + process_template_with_params + else + process_template_from_message + end + end + + private + + def process_template_with_params + [ + template_params['name'], + template_params['namespace'], + template_params['language'], + processed_templates_params + ] + end + + def process_template_from_message + return [nil, nil, nil, nil] if message.blank? + + # Delete the following logic once the update for template_params is stable + # see if we can match the message content to a template + # An example template may look like "Your package has been shipped. It will be delivered in {{1}} business days. + # We want to iterate over these templates with our message body and see if we can fit it to any of the templates + # Then we use regex to parse the template varibles and convert them into the proper payload + channel.message_templates&.each do |template| + match_obj = template_match_object(template) + next if match_obj.blank? + + # we have a match, now we need to parse the template variables and convert them into the wa recommended format + processed_parameters = match_obj.captures.map { |x| { type: 'text', text: x } } + + # no need to look up further end the search + return [template['name'], template['namespace'], template['language'], processed_parameters] + end + [nil, nil, nil, nil] + end + + def template_match_object(template) + body_object = validated_body_object(template) + return if body_object.blank? + + template_match_regex = build_template_match_regex(body_object['text']) + message.outgoing_content.match(template_match_regex) + end + + def build_template_match_regex(template_text) + # Converts the whatsapp template to a comparable regex string to check against the message content + # the variables are of the format {{num}} ex:{{1}} + + # transform the template text into a regex string + # we need to replace the {{num}} with matchers that can be used to capture the variables + template_text = template_text.gsub(/{{\d}}/, '(.*)') + # escape if there are regex characters in the template text + template_text = Regexp.escape(template_text) + # ensuring only the variables remain as capture groups + template_text = template_text.gsub(Regexp.escape('(.*)'), '(.*)') + + template_match_string = "^#{template_text}$" + Regexp.new template_match_string + end + + def find_template + channel.message_templates.find do |t| + t['name'] == template_params['name'] && t['language'] == template_params['language'] && t['status']&.downcase == 'approved' + end + end + + def processed_templates_params + template = find_template + return if template.blank? + + parameter_format = template['parameter_format'] + + if parameter_format == 'NAMED' + template_params['processed_params']&.map { |key, value| { type: 'text', parameter_name: key, text: value } } + else + template_params['processed_params']&.map { |_, value| { type: 'text', text: value } } + end + end + + def validated_body_object(template) + # we don't care if its not approved template + return if template['status'] != 'approved' + + # we only care about text body object in template. if not present we discard the template + # we don't support other forms of templates + template['components'].find { |obj| obj['type'] == 'BODY' && obj.key?('text') } + end +end diff --git a/app/views/api/v1/models/_campaign.json.jbuilder b/app/views/api/v1/models/_campaign.json.jbuilder index 8706175df..8757fd0f8 100644 --- a/app/views/api/v1/models/_campaign.json.jbuilder +++ b/app/views/api/v1/models/_campaign.json.jbuilder @@ -9,6 +9,7 @@ json.sender do json.partial! 'api/v1/models/agent', formats: [:json], resource: resource.sender if resource.sender.present? end json.message resource.message +json.template_params resource.template_params json.campaign_status resource.campaign_status json.enabled resource.enabled json.campaign_type resource.campaign_type diff --git a/config/features.yml b/config/features.yml index 99d7bea6d..85b95a732 100644 --- a/config/features.yml +++ b/config/features.yml @@ -183,3 +183,6 @@ - name: whatsapp_embedded_signup display_name: WhatsApp Embedded Signup enabled: false +- name: whatsapp_campaign + display_name: WhatsApp Campaign + enabled: false diff --git a/db/migrate/20250709102213_add_template_params_to_campaigns.rb b/db/migrate/20250709102213_add_template_params_to_campaigns.rb new file mode 100644 index 000000000..d70359b30 --- /dev/null +++ b/db/migrate/20250709102213_add_template_params_to_campaigns.rb @@ -0,0 +1,5 @@ +class AddTemplateParamsToCampaigns < ActiveRecord::Migration[7.1] + def change + add_column :campaigns, :template_params, :jsonb, default: {}, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 1806ab23f..34637315b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -237,6 +237,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_14_104358) do t.jsonb "audience", default: [] t.datetime "scheduled_at", precision: nil t.boolean "trigger_only_during_business_hours", default: false + t.jsonb "template_params" t.index ["account_id"], name: "index_campaigns_on_account_id" t.index ["campaign_status"], name: "index_campaigns_on_campaign_status" t.index ["campaign_type"], name: "index_campaigns_on_campaign_type" diff --git a/spec/factories/campaigns.rb b/spec/factories/campaigns.rb index 4d4c6de18..c3c32d149 100644 --- a/spec/factories/campaigns.rb +++ b/spec/factories/campaigns.rb @@ -12,5 +12,22 @@ FactoryBot.define do channel: create(:channel_widget, account: campaign.account) ) end + + trait :whatsapp do + after(:build) do |campaign| + campaign.inbox = create( + :inbox, + account: campaign.account, + channel: create(:channel_whatsapp, account: campaign.account) + ) + campaign.template_params = { + 'name' => 'ticket_status_updated', + 'namespace' => '23423423_2342423_324234234_2343224', + 'category' => 'UTILITY', + 'language' => 'en', + 'processed_params' => { 'name' => 'John', 'ticket_id' => '2332' } + } + end + end end end diff --git a/spec/factories/channel/channel_whatsapp.rb b/spec/factories/channel/channel_whatsapp.rb index d437ed346..ad2bab241 100644 --- a/spec/factories/channel/channel_whatsapp.rb +++ b/spec/factories/channel/channel_whatsapp.rb @@ -36,6 +36,7 @@ FactoryBot.define do 'status' => 'APPROVED', 'category' => 'UTILITY', 'language' => 'en', + 'namespace' => '23423423_2342423_324234234_2343224', 'components' => [ { 'text' => "Hello {{name}}, Your support ticket with ID: \#{{ticket_id}} has been updated by the support agent.", 'type' => 'BODY', diff --git a/spec/services/whatsapp/oneoff_campaign_service_spec.rb b/spec/services/whatsapp/oneoff_campaign_service_spec.rb new file mode 100644 index 000000000..33107e8de --- /dev/null +++ b/spec/services/whatsapp/oneoff_campaign_service_spec.rb @@ -0,0 +1,169 @@ +require 'rails_helper' + +describe Whatsapp::OneoffCampaignService do + let(:account) { create(:account) } + let!(:whatsapp_channel) do + create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false) + end + let!(:whatsapp_inbox) { whatsapp_channel.inbox } + let(:label1) { create(:label, account: account) } + let(:label2) { create(:label, account: account) } + let!(:campaign) do + create(:campaign, inbox: whatsapp_inbox, account: account, + audience: [{ type: 'Label', id: label1.id }, { type: 'Label', id: label2.id }], + template_params: template_params) + end + let(:template_params) do + { + 'name' => 'ticket_status_updated', + 'namespace' => '23423423_2342423_324234234_2343224', + 'category' => 'UTILITY', + 'language' => 'en', + 'processed_params' => { 'name' => 'John', 'ticket_id' => '2332' } + } + end + + before do + # Stub HTTP requests to WhatsApp API + stub_request(:post, /graph\.facebook\.com.*messages/) + .to_return(status: 200, body: { messages: [{ id: 'message_id_123' }] }.to_json, headers: { 'Content-Type' => 'application/json' }) + + # Ensure the service uses our mocked channel object by stubbing the whole delegation chain + # Using allow_any_instance_of here because the service is instantiated within individual tests + # and we need to mock the delegated channel method for proper test isolation + allow_any_instance_of(described_class).to receive(:channel).and_return(whatsapp_channel) # rubocop:disable RSpec/AnyInstance + end + + describe '#perform' do + before do + # Enable WhatsApp campaigns feature flag for all tests + account.enable_features!(:whatsapp_campaign) + end + + context 'when campaign validation fails' do + it 'raises error if campaign is completed' do + campaign.completed! + + expect { described_class.new(campaign: campaign).perform }.to raise_error 'Completed Campaign' + end + + it 'raises error when campaign is not a WhatsApp campaign' do + sms_channel = create(:channel_sms, account: account) + sms_inbox = create(:inbox, channel: sms_channel, account: account) + invalid_campaign = create(:campaign, inbox: sms_inbox, account: account) + + expect { described_class.new(campaign: invalid_campaign).perform } + .to raise_error "Invalid campaign #{invalid_campaign.id}" + end + + it 'raises error when campaign is not oneoff' do + allow(campaign).to receive(:one_off?).and_return(false) + + expect { described_class.new(campaign: campaign).perform }.to raise_error "Invalid campaign #{campaign.id}" + end + + it 'raises error when channel provider is not whatsapp_cloud' do + whatsapp_channel.update!(provider: 'default') + + expect { described_class.new(campaign: campaign).perform }.to raise_error 'WhatsApp Cloud provider required' + end + + it 'raises error when WhatsApp campaigns feature is not enabled' do + account.disable_features!(:whatsapp_campaign) + + expect { described_class.new(campaign: campaign).perform }.to raise_error 'WhatsApp campaigns feature not enabled' + end + end + + context 'when campaign is valid' do + it 'marks campaign as completed' do + described_class.new(campaign: campaign).perform + + expect(campaign.reload.completed?).to be true + end + + it 'processes contacts with matching labels' do + contact_with_label1, contact_with_label2, contact_with_both_labels = + create_list(:contact, 3, :with_phone_number, account: account) + contact_with_label1.update_labels([label1.title]) + contact_with_label2.update_labels([label2.title]) + contact_with_both_labels.update_labels([label1.title, label2.title]) + + expect(whatsapp_channel).to receive(:send_template).exactly(3).times + + described_class.new(campaign: campaign).perform + end + + it 'skips contacts without phone numbers' do + contact_without_phone = create(:contact, account: account, phone_number: nil) + contact_without_phone.update_labels([label1.title]) + + expect(whatsapp_channel).not_to receive(:send_template) + + described_class.new(campaign: campaign).perform + end + + it 'uses template processor service to process templates' do + contact = create(:contact, :with_phone_number, account: account) + contact.update_labels([label1.title]) + + expect(Whatsapp::TemplateProcessorService).to receive(:new) + .with(channel: whatsapp_channel, template_params: template_params) + .and_call_original + + described_class.new(campaign: campaign).perform + end + + it 'sends template message with correct parameters' do + contact = create(:contact, :with_phone_number, account: account) + contact.update_labels([label1.title]) + + expect(whatsapp_channel).to receive(:send_template).with( + contact.phone_number, + hash_including( + name: 'ticket_status_updated', + namespace: '23423423_2342423_324234234_2343224', + lang_code: 'en', + parameters: array_including( + hash_including(type: 'text', parameter_name: 'name', text: 'John'), + hash_including(type: 'text', parameter_name: 'ticket_id', text: '2332') + ) + ) + ) + + described_class.new(campaign: campaign).perform + end + end + + context 'when template_params is missing' do + let(:template_params) { nil } + + it 'skips contacts and logs error' do + contact = create(:contact, :with_phone_number, account: account) + contact.update_labels([label1.title]) + + expect(Rails.logger).to receive(:error) + .with("Skipping contact #{contact.name} - no template_params found for WhatsApp campaign") + expect(whatsapp_channel).not_to receive(:send_template) + + described_class.new(campaign: campaign).perform + end + end + + context 'when send_template raises an error' do + it 'logs error and re-raises' do + contact = create(:contact, :with_phone_number, account: account) + contact.update_labels([label1.title]) + error_message = 'WhatsApp API error' + + allow(whatsapp_channel).to receive(:send_template).and_raise(StandardError, error_message) + + expect(Rails.logger).to receive(:error) + .with("Failed to send WhatsApp template message to #{contact.phone_number}: #{error_message}") + expect(Rails.logger).to receive(:error).with(/Backtrace:/) + + expect { described_class.new(campaign: campaign).perform }.to raise_error(StandardError, error_message) + end + end + end +end