Merge branch 'develop' into feat/scenario-tools-association

This commit is contained in:
Shivam Mishra
2025-07-16 09:54:45 +05:30
committed by GitHub
30 changed files with 1155 additions and 113 deletions
@@ -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
@@ -0,0 +1,37 @@
<script setup>
import { ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
});
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="flex flex-col gap-4 p-px">
<CampaignCard
v-for="campaign in ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT"
:key="campaign.id"
:title="campaign.title"
:message="campaign.message"
:is-enabled="campaign.enabled"
:status="campaign.campaign_status"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
/>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,48 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
import WhatsAppCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue';
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const addCampaign = async campaignDetails => {
try {
await store.dispatch('campaigns/create', campaignDetails);
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
type: CAMPAIGN_TYPES.ONE_OFF,
});
useAlert(t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleSubmit = campaignDetails => {
addCampaign(campaignDetails);
};
const handleClose = () => emit('close');
</script>
<template>
<div
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6"
>
<h3 class="text-base font-medium text-n-slate-12">
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
</h3>
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
</div>
</template>
@@ -0,0 +1,357 @@
<script setup>
import { reactive, computed, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('campaigns/getUIFlags'),
labels: useMapGetter('labels/getLabels'),
inboxes: useMapGetter('inboxes/getWhatsAppInboxes'),
getWhatsAppTemplates: useMapGetter('inboxes/getWhatsAppTemplates'),
};
const initialState = {
title: '',
inboxId: null,
templateId: null,
scheduledAt: null,
selectedAudience: [],
};
const state = reactive({ ...initialState });
const processedParams = ref({});
const rules = {
title: { required, minLength: minLength(1) },
inboxId: { required },
templateId: { required },
scheduledAt: { required },
selectedAudience: { required },
};
const v$ = useVuelidate(rules, state);
const isCreating = computed(() => formState.uiFlags.value.isCreating);
const currentDateTime = computed(() => {
// Added to disable the scheduled at field from being set to the current time
const now = new Date();
const localTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
return localTime.toISOString().slice(0, 16);
});
const mapToOptions = (items, valueKey, labelKey) =>
items?.map(item => ({
value: item[valueKey],
label: item[labelKey],
})) ?? [];
const audienceList = computed(() =>
mapToOptions(formState.labels.value, 'id', 'title')
);
const inboxOptions = computed(() =>
mapToOptions(formState.inboxes.value, 'id', 'name')
);
const templateOptions = computed(() => {
if (!state.inboxId) return [];
const templates = formState.getWhatsAppTemplates.value(state.inboxId);
return templates.map(template => {
// Create a more user-friendly label from template name
const friendlyName = template.name
.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase());
return {
value: template.id,
label: `${friendlyName} (${template.language || 'en'})`,
template: template,
};
});
});
const selectedTemplate = computed(() => {
if (!state.templateId) return null;
return templateOptions.value.find(option => option.value === state.templateId)
?.template;
});
const templateString = computed(() => {
if (!selectedTemplate.value) return '';
try {
return (
selectedTemplate.value.components?.find(
component => component.type === 'BODY'
)?.text || ''
);
} catch (error) {
return '';
}
});
const processedString = computed(() => {
if (!templateString.value) return '';
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
return processedParams.value[variable] || `{{${variable}}}`;
});
});
const getErrorMessage = (field, errorKey) => {
const baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM';
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
};
const formErrors = computed(() => ({
title: getErrorMessage('title', 'TITLE'),
inbox: getErrorMessage('inboxId', 'INBOX'),
template: getErrorMessage('templateId', 'TEMPLATE'),
scheduledAt: getErrorMessage('scheduledAt', 'SCHEDULED_AT'),
audience: getErrorMessage('selectedAudience', 'AUDIENCE'),
}));
const hasRequiredTemplateParams = computed(() => {
const params = Object.values(processedParams.value);
return params.length === 0 || params.every(param => param.trim() !== '');
});
const isSubmitDisabled = computed(
() => v$.value.$invalid || !hasRequiredTemplateParams.value
);
const formatToUTCString = localDateTime =>
localDateTime ? new Date(localDateTime).toISOString() : null;
const resetState = () => {
Object.assign(state, initialState);
processedParams.value = {};
v$.value.$reset();
};
const handleCancel = () => emit('cancel');
const generateVariables = () => {
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
if (!matchedVariables) {
processedParams.value = {};
return;
}
const finalVars = matchedVariables.map(match => match.replace(/{{|}}/g, ''));
processedParams.value = finalVars.reduce((acc, variable) => {
acc[variable] = processedParams.value[variable] || '';
return acc;
}, {});
};
const prepareCampaignDetails = () => {
// Find the selected template to get its content
const currentTemplate = selectedTemplate.value;
// Extract template content - this should be the template message body
const templateContent = templateString.value;
// Prepare template_params object with the same structure as used in contacts
const templateParams = {
name: currentTemplate?.name || '',
namespace: currentTemplate?.namespace || '',
category: currentTemplate?.category || 'UTILITY',
language: currentTemplate?.language || 'en_US',
processed_params: processedParams.value,
};
return {
title: state.title,
message: templateContent,
template_params: templateParams,
inbox_id: state.inboxId,
scheduled_at: formatToUTCString(state.scheduledAt),
audience: state.selectedAudience?.map(id => ({
id,
type: 'Label',
})),
};
};
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
emit('submit', prepareCampaignDetails());
resetState();
handleCancel();
};
// Reset template selection when inbox changes
watch(
() => state.inboxId,
() => {
state.templateId = null;
processedParams.value = {};
}
);
// Generate variables when template changes
watch(
() => state.templateId,
() => {
generateVariables();
}
);
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.title"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.LABEL')"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.PLACEHOLDER')"
:message="formErrors.title"
:message-type="formErrors.title ? 'error' : 'info'"
/>
<div class="flex flex-col gap-1">
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.INBOX.LABEL') }}
</label>
<ComboBox
id="inbox"
v-model="state.inboxId"
:options="inboxOptions"
:has-error="!!formErrors.inbox"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.INBOX.PLACEHOLDER')"
:message="formErrors.inbox"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
</div>
<div class="flex flex-col gap-1">
<label for="template" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LABEL') }}
</label>
<ComboBox
id="template"
v-model="state.templateId"
:options="templateOptions"
:has-error="!!formErrors.template"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.PLACEHOLDER')"
:message="formErrors.template"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
<p class="mt-1 text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.INFO') }}
</p>
</div>
<!-- Template Preview -->
<div
v-if="selectedTemplate"
class="flex flex-col gap-4 p-4 rounded-lg bg-n-alpha-black2"
>
<div class="flex justify-between items-center">
<h3 class="text-sm font-medium text-n-slate-12">
{{ selectedTemplate.name }}
</h3>
<span class="text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LANGUAGE') }}:
{{ selectedTemplate.language || 'en' }}
</span>
</div>
<div class="flex flex-col gap-2">
<div class="rounded-md bg-n-alpha-black3">
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
{{ processedString }}
</div>
</div>
</div>
<div class="text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.CATEGORY') }}:
{{ selectedTemplate.category || 'UTILITY' }}
</div>
</div>
<!-- Template Variables -->
<div
v-if="Object.keys(processedParams).length > 0"
class="flex flex-col gap-3"
>
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLES_LABEL') }}
</label>
<div class="flex flex-col gap-2">
<div
v-for="(value, key) in processedParams"
:key="key"
class="flex gap-2 items-center"
>
<Input
v-model="processedParams[key]"
type="text"
class="flex-1"
:placeholder="
t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLE_PLACEHOLDER', {
variable: key,
})
"
/>
</div>
</div>
</div>
<div class="flex flex-col gap-1">
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL') }}
</label>
<TagMultiSelectComboBox
v-model="state.selectedAudience"
:options="audienceList"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL')"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.PLACEHOLDER')"
:has-error="!!formErrors.audience"
:message="formErrors.audience"
class="[&>div>button]:bg-n-alpha-black2"
/>
</div>
<Input
v-model="state.scheduledAt"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.SCHEDULED_AT.LABEL')"
type="datetime-local"
:min="currentDateTime"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.SCHEDULED_AT.PLACEHOLDER')"
:message="formErrors.scheduledAt"
:message-type="formErrors.scheduledAt ? 'error' : 'info'"
/>
<div class="flex gap-3 justify-between items-center w-full">
<Button
variant="faded"
color="slate"
type="button"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CREATE')"
class="w-full"
type="submit"
:is-loading="isCreating"
:disabled="isCreating || isSubmitDisabled"
/>
</div>
</form>
</template>
@@ -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'),
},
],
},
{
+1
View File
@@ -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',
@@ -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.",
@@ -319,6 +319,7 @@
"CSAT": "CSAT",
"LIVE_CHAT": "Live Chat",
"SMS": "SMS",
"WHATSAPP": "WhatsApp",
"CAMPAIGNS": "Campaigns",
"ONGOING": "Ongoing",
"ONE_OFF": "One off",
@@ -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,
},
],
},
],
@@ -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 => {
<div
v-if="isFetchingCampaigns"
class="flex items-center justify-center py-10 text-n-slate-11"
class="flex justify-center items-center py-10 text-n-slate-11"
>
<Spinner />
</div>
@@ -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
@@ -0,0 +1,74 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
import CampaignList from 'dashboard/components-next/Campaigns/Pages/CampaignPage/CampaignList.vue';
import WhatsAppCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue';
import ConfirmDeleteCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue';
import WhatsAppCampaignEmptyState from 'dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue';
const { t } = useI18n();
const getters = useStoreGetters();
const selectedCampaign = ref(null);
const [showWhatsAppCampaignDialog, toggleWhatsAppCampaignDialog] = useToggle();
const uiFlags = useMapGetter('campaigns/getUIFlags');
const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
const confirmDeleteCampaignDialogRef = ref(null);
const WhatsAppCampaigns = computed(
() => getters['campaigns/getWhatsAppCampaigns'].value
);
const hasNoWhatsAppCampaigns = computed(
() => WhatsAppCampaigns.value?.length === 0 && !isFetchingCampaigns.value
);
const handleDelete = campaign => {
selectedCampaign.value = campaign;
confirmDeleteCampaignDialogRef.value.dialogRef.open();
};
</script>
<template>
<CampaignLayout
:header-title="t('CAMPAIGN.WHATSAPP.HEADER_TITLE')"
:button-label="t('CAMPAIGN.WHATSAPP.NEW_CAMPAIGN')"
@click="toggleWhatsAppCampaignDialog()"
@close="toggleWhatsAppCampaignDialog(false)"
>
<template #action>
<WhatsAppCampaignDialog
v-if="showWhatsAppCampaignDialog"
@close="toggleWhatsAppCampaignDialog(false)"
/>
</template>
<div
v-if="isFetchingCampaigns"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<CampaignList
v-else-if="!hasNoWhatsAppCampaigns"
:campaigns="WhatsAppCampaigns"
@delete="handleDelete"
/>
<WhatsAppCampaignEmptyState
v-else
:title="t('CAMPAIGN.WHATSAPP.EMPTY_STATE.TITLE')"
:subtitle="t('CAMPAIGN.WHATSAPP.EMPTY_STATE.SUBTITLE')"
class="pt-14"
/>
<ConfirmDeleteCampaignDialog
ref="confirmDeleteCampaignDialogRef"
:selected-campaign="selectedCampaign"
/>
</CampaignLayout>
</template>
@@ -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;
@@ -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
@@ -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',
},
];
@@ -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],
]);
});
@@ -1,5 +1,5 @@
class Conversations::UpdateMessageStatusJob < ApplicationJob
queue_as :low
queue_as :deferred
# This job only support marking messages as read or delivered, update this array if we want to support more statuses
VALID_STATUSES = %w[read delivered].freeze
+15 -4
View File
@@ -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
@@ -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
@@ -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?
@@ -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
@@ -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
+3
View File
@@ -183,3 +183,6 @@
- name: whatsapp_embedded_signup
display_name: WhatsApp Embedded Signup
enabled: false
- name: whatsapp_campaign
display_name: WhatsApp Campaign
enabled: false
+1
View File
@@ -23,6 +23,7 @@
- action_mailbox_routing
- low
- scheduled_jobs
- deferred
- housekeeping
- async_database_migration
- active_storage_analysis
@@ -0,0 +1,5 @@
class AddTemplateParamsToCampaigns < ActiveRecord::Migration[7.1]
def change
add_column :campaigns, :template_params, :jsonb, default: {}, null: false
end
end
+1
View File
@@ -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"
+17
View File
@@ -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
@@ -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',
@@ -10,7 +10,7 @@ RSpec.describe Conversations::UpdateMessageStatusJob do
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(conversation.id, conversation.contact_last_seen_at, :read)
.on_queue('low')
.on_queue('deferred')
end
context 'when called' do
@@ -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