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 @@
+
+
+
+
+
+ {{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
+
+
+
+
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