Merge branch 'develop' into feat/scenario-tools-association
This commit is contained in:
@@ -29,6 +29,6 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
def campaign_params
|
def campaign_params
|
||||||
params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id,
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
+37
@@ -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>
|
||||||
+48
@@ -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>
|
||||||
+357
@@ -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'),
|
label: t('SIDEBAR.SMS'),
|
||||||
to: accountScopedRoute('campaigns_sms_index'),
|
to: accountScopedRoute('campaigns_sms_index'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'WhatsApp',
|
||||||
|
label: t('SIDEBAR.WHATSAPP'),
|
||||||
|
to: accountScopedRoute('campaigns_whatsapp_index'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const FEATURE_FLAGS = {
|
|||||||
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
|
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
|
||||||
AUTOMATIONS: 'automations',
|
AUTOMATIONS: 'automations',
|
||||||
CAMPAIGNS: 'campaigns',
|
CAMPAIGNS: 'campaigns',
|
||||||
|
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
|
||||||
CANNED_RESPONSES: 'canned_responses',
|
CANNED_RESPONSES: 'canned_responses',
|
||||||
CRM: 'crm',
|
CRM: 'crm',
|
||||||
CUSTOM_ATTRIBUTES: 'custom_attributes',
|
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": {
|
"CONFIRM_DELETE": {
|
||||||
"TITLE": "Are you sure to delete?",
|
"TITLE": "Are you sure to delete?",
|
||||||
"DESCRIPTION": "The delete action is permanent and cannot be reversed.",
|
"DESCRIPTION": "The delete action is permanent and cannot be reversed.",
|
||||||
|
|||||||
@@ -319,6 +319,7 @@
|
|||||||
"CSAT": "CSAT",
|
"CSAT": "CSAT",
|
||||||
"LIVE_CHAT": "Live Chat",
|
"LIVE_CHAT": "Live Chat",
|
||||||
"SMS": "SMS",
|
"SMS": "SMS",
|
||||||
|
"WHATSAPP": "WhatsApp",
|
||||||
"CAMPAIGNS": "Campaigns",
|
"CAMPAIGNS": "Campaigns",
|
||||||
"ONGOING": "Ongoing",
|
"ONGOING": "Ongoing",
|
||||||
"ONE_OFF": "One off",
|
"ONE_OFF": "One off",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { frontendURL } from 'dashboard/helper/URLHelper.js';
|
|||||||
import CampaignsPageRouteView from './pages/CampaignsPageRouteView.vue';
|
import CampaignsPageRouteView from './pages/CampaignsPageRouteView.vue';
|
||||||
import LiveChatCampaignsPage from './pages/LiveChatCampaignsPage.vue';
|
import LiveChatCampaignsPage from './pages/LiveChatCampaignsPage.vue';
|
||||||
import SMSCampaignsPage from './pages/SMSCampaignsPage.vue';
|
import SMSCampaignsPage from './pages/SMSCampaignsPage.vue';
|
||||||
|
import WhatsAppCampaignsPage from './pages/WhatsAppCampaignsPage.vue';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
@@ -50,6 +51,15 @@ const campaignsRoutes = {
|
|||||||
meta,
|
meta,
|
||||||
component: SMSCampaignsPage,
|
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 { useI18n } from 'vue-i18n';
|
||||||
import { useToggle } from '@vueuse/core';
|
import { useToggle } from '@vueuse/core';
|
||||||
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
|
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 Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.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 [showLiveChatCampaignDialog, toggleLiveChatCampaignDialog] = useToggle();
|
||||||
|
|
||||||
const liveChatCampaigns = computed(() =>
|
const liveChatCampaigns = computed(
|
||||||
getters['campaigns/getCampaigns'].value(CAMPAIGN_TYPES.ONGOING)
|
() => getters['campaigns/getLiveChatCampaigns'].value
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasNoLiveChatCampaigns = computed(
|
const hasNoLiveChatCampaigns = computed(
|
||||||
@@ -59,7 +58,7 @@ const handleDelete = campaign => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="isFetchingCampaigns"
|
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 />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useToggle } from '@vueuse/core';
|
import { useToggle } from '@vueuse/core';
|
||||||
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
|
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 Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.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 confirmDeleteCampaignDialogRef = ref(null);
|
||||||
|
|
||||||
const SMSCampaigns = computed(() =>
|
const SMSCampaigns = computed(() => getters['campaigns/getSMSCampaigns'].value);
|
||||||
getters['campaigns/getCampaigns'].value(CAMPAIGN_TYPES.ONE_OFF)
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasNoSMSCampaigns = computed(
|
const hasNoSMSCampaigns = computed(
|
||||||
() => SMSCampaigns.value?.length === 0 && !isFetchingCampaigns.value
|
() => 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 CampaignsAPI from '../../api/campaigns';
|
||||||
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
||||||
import { CAMPAIGNS_EVENTS } from '../../helper/AnalyticsHelper/events';
|
import { CAMPAIGNS_EVENTS } from '../../helper/AnalyticsHelper/events';
|
||||||
|
import { CAMPAIGN_TYPES } from 'shared/constants/campaign';
|
||||||
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
|
|
||||||
export const state = {
|
export const state = {
|
||||||
records: [],
|
records: [],
|
||||||
@@ -16,10 +18,35 @@ export const getters = {
|
|||||||
getUIFlags(_state) {
|
getUIFlags(_state) {
|
||||||
return _state.uiFlags;
|
return _state.uiFlags;
|
||||||
},
|
},
|
||||||
getCampaigns: _state => campaignType => {
|
getCampaigns:
|
||||||
return _state.records
|
_state =>
|
||||||
.filter(record => record.campaign_type === campaignType)
|
(campaignType, inboxChannelTypes = null) => {
|
||||||
.sort((a1, a2) => a1.id - a2.id);
|
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 => {
|
getAllCampaigns: _state => {
|
||||||
return _state.records;
|
return _state.records;
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ export const getters = {
|
|||||||
(item.channel_type === INBOX_TYPES.TWILIO && item.medium === 'sms')
|
(item.channel_type === INBOX_TYPES.TWILIO && item.medium === 'sms')
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
getWhatsAppInboxes($state) {
|
||||||
|
return $state.records.filter(
|
||||||
|
item => item.channel_type === INBOX_TYPES.WHATSAPP
|
||||||
|
);
|
||||||
|
},
|
||||||
dialogFlowEnabledInboxes($state) {
|
dialogFlowEnabledInboxes($state) {
|
||||||
return $state.records.filter(
|
return $state.records.filter(
|
||||||
item => item.channel_type !== INBOX_TYPES.EMAIL
|
item => item.channel_type !== INBOX_TYPES.EMAIL
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export default [
|
|||||||
url: 'https://github.com',
|
url: 'https://github.com',
|
||||||
time_on_page: 10,
|
time_on_page: 10,
|
||||||
},
|
},
|
||||||
|
inbox: {
|
||||||
|
id: 1,
|
||||||
|
channel_type: 'Channel::WebWidget',
|
||||||
|
name: 'Web Widget',
|
||||||
|
},
|
||||||
created_at: '2021-05-03T04:53:36.354Z',
|
created_at: '2021-05-03T04:53:36.354Z',
|
||||||
updated_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',
|
url: 'https://chatwoot.com',
|
||||||
time_on_page: '20',
|
time_on_page: '20',
|
||||||
},
|
},
|
||||||
|
inbox: {
|
||||||
|
id: 2,
|
||||||
|
channel_type: 'Channel::TwilioSms',
|
||||||
|
name: 'Twilio SMS',
|
||||||
|
},
|
||||||
created_at: '2021-05-03T08:15:35.828Z',
|
created_at: '2021-05-03T08:15:35.828Z',
|
||||||
updated_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',
|
url: 'https://noshow.com',
|
||||||
time_on_page: 10,
|
time_on_page: 10,
|
||||||
},
|
},
|
||||||
|
inbox: {
|
||||||
|
id: 3,
|
||||||
|
channel_type: 'Channel::WebWidget',
|
||||||
|
name: 'Web Widget 2',
|
||||||
|
},
|
||||||
created_at: '2021-05-03T10:22:51.025Z',
|
created_at: '2021-05-03T10:22:51.025Z',
|
||||||
updated_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', () => {
|
it('get one_off campaigns', () => {
|
||||||
const state = { records: campaigns };
|
const state = { records: campaigns };
|
||||||
expect(getters.getCampaigns(state)('one_off')).toEqual([
|
expect(getters.getCampaigns(state)('one_off')).toEqual([
|
||||||
{
|
campaigns[1],
|
||||||
id: 2,
|
campaigns[3],
|
||||||
title: 'Onboarding Campaign',
|
campaigns[4],
|
||||||
description: null,
|
]);
|
||||||
account_id: 1,
|
});
|
||||||
campaign_type: 'one_off',
|
|
||||||
|
|
||||||
trigger_rules: {
|
it('get campaigns by channel type', () => {
|
||||||
url: 'https://chatwoot.com',
|
const state = { records: campaigns };
|
||||||
time_on_page: '20',
|
expect(
|
||||||
},
|
getters.getCampaigns(state)('one_off', ['Channel::Whatsapp'])
|
||||||
created_at: '2021-05-03T08:15:35.828Z',
|
).toEqual([campaigns[3]]);
|
||||||
updated_at: '2021-05-03T08:15:35.828Z',
|
});
|
||||||
},
|
|
||||||
|
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
|
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
|
# 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
|
VALID_STATUSES = %w[read delivered].freeze
|
||||||
|
|||||||
+15
-4
@@ -10,6 +10,7 @@
|
|||||||
# enabled :boolean default(TRUE)
|
# enabled :boolean default(TRUE)
|
||||||
# message :text not null
|
# message :text not null
|
||||||
# scheduled_at :datetime
|
# scheduled_at :datetime
|
||||||
|
# template_params :jsonb
|
||||||
# title :string not null
|
# title :string not null
|
||||||
# trigger_only_during_business_hours :boolean default(FALSE)
|
# trigger_only_during_business_hours :boolean default(FALSE)
|
||||||
# trigger_rules :jsonb
|
# trigger_rules :jsonb
|
||||||
@@ -57,12 +58,22 @@ class Campaign < ApplicationRecord
|
|||||||
return unless one_off?
|
return unless one_off?
|
||||||
return if completed?
|
return if completed?
|
||||||
|
|
||||||
Twilio::OneoffSmsCampaignService.new(campaign: self).perform if inbox.inbox_type == 'Twilio SMS'
|
execute_campaign
|
||||||
Sms::OneoffSmsCampaignService.new(campaign: self).perform if inbox.inbox_type == 'Sms'
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
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
|
def set_display_id
|
||||||
reload
|
reload
|
||||||
end
|
end
|
||||||
@@ -70,14 +81,14 @@ class Campaign < ApplicationRecord
|
|||||||
def validate_campaign_inbox
|
def validate_campaign_inbox
|
||||||
return unless 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
|
end
|
||||||
|
|
||||||
# TO-DO we clean up with better validations when campaigns evolve into more inboxes
|
# TO-DO we clean up with better validations when campaigns evolve into more inboxes
|
||||||
def ensure_correct_campaign_attributes
|
def ensure_correct_campaign_attributes
|
||||||
return if inbox.blank?
|
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.campaign_type = 'one_off'
|
||||||
self.scheduled_at ||= Time.now.utc
|
self.scheduled_at ||= Time.now.utc
|
||||||
else
|
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
|
end
|
||||||
|
|
||||||
def send_template_message
|
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?
|
return if name.blank?
|
||||||
|
|
||||||
@@ -28,86 +34,6 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
|
|||||||
message.update!(source_id: message_id) if message_id.present?
|
message.update!(source_id: message_id) if message_id.present?
|
||||||
end
|
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
|
def send_session_message
|
||||||
message_id = channel.send_message(message.conversation.contact_inbox.source_id, message)
|
message_id = channel.send_message(message.conversation.contact_inbox.source_id, message)
|
||||||
message.update!(source_id: message_id) if message_id.present?
|
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?
|
json.partial! 'api/v1/models/agent', formats: [:json], resource: resource.sender if resource.sender.present?
|
||||||
end
|
end
|
||||||
json.message resource.message
|
json.message resource.message
|
||||||
|
json.template_params resource.template_params
|
||||||
json.campaign_status resource.campaign_status
|
json.campaign_status resource.campaign_status
|
||||||
json.enabled resource.enabled
|
json.enabled resource.enabled
|
||||||
json.campaign_type resource.campaign_type
|
json.campaign_type resource.campaign_type
|
||||||
|
|||||||
@@ -183,3 +183,6 @@
|
|||||||
- name: whatsapp_embedded_signup
|
- name: whatsapp_embedded_signup
|
||||||
display_name: WhatsApp Embedded Signup
|
display_name: WhatsApp Embedded Signup
|
||||||
enabled: false
|
enabled: false
|
||||||
|
- name: whatsapp_campaign
|
||||||
|
display_name: WhatsApp Campaign
|
||||||
|
enabled: false
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
- action_mailbox_routing
|
- action_mailbox_routing
|
||||||
- low
|
- low
|
||||||
- scheduled_jobs
|
- scheduled_jobs
|
||||||
|
- deferred
|
||||||
- housekeeping
|
- housekeeping
|
||||||
- async_database_migration
|
- async_database_migration
|
||||||
- active_storage_analysis
|
- 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
|
||||||
@@ -237,6 +237,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_14_104358) do
|
|||||||
t.jsonb "audience", default: []
|
t.jsonb "audience", default: []
|
||||||
t.datetime "scheduled_at", precision: nil
|
t.datetime "scheduled_at", precision: nil
|
||||||
t.boolean "trigger_only_during_business_hours", default: false
|
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 ["account_id"], name: "index_campaigns_on_account_id"
|
||||||
t.index ["campaign_status"], name: "index_campaigns_on_campaign_status"
|
t.index ["campaign_status"], name: "index_campaigns_on_campaign_status"
|
||||||
t.index ["campaign_type"], name: "index_campaigns_on_campaign_type"
|
t.index ["campaign_type"], name: "index_campaigns_on_campaign_type"
|
||||||
|
|||||||
@@ -12,5 +12,22 @@ FactoryBot.define do
|
|||||||
channel: create(:channel_widget, account: campaign.account)
|
channel: create(:channel_widget, account: campaign.account)
|
||||||
)
|
)
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ FactoryBot.define do
|
|||||||
'status' => 'APPROVED',
|
'status' => 'APPROVED',
|
||||||
'category' => 'UTILITY',
|
'category' => 'UTILITY',
|
||||||
'language' => 'en',
|
'language' => 'en',
|
||||||
|
'namespace' => '23423423_2342423_324234234_2343224',
|
||||||
'components' => [
|
'components' => [
|
||||||
{ 'text' => "Hello {{name}}, Your support ticket with ID: \#{{ticket_id}} has been updated by the support agent.",
|
{ 'text' => "Hello {{name}}, Your support ticket with ID: \#{{ticket_id}} has been updated by the support agent.",
|
||||||
'type' => 'BODY',
|
'type' => 'BODY',
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ RSpec.describe Conversations::UpdateMessageStatusJob do
|
|||||||
it 'enqueues the job' do
|
it 'enqueues the job' do
|
||||||
expect { job }.to have_enqueued_job(described_class)
|
expect { job }.to have_enqueued_job(described_class)
|
||||||
.with(conversation.id, conversation.contact_last_seen_at, :read)
|
.with(conversation.id, conversation.contact_last_seen_at, :read)
|
||||||
.on_queue('low')
|
.on_queue('deferred')
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when called' do
|
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
|
||||||
Reference in New Issue
Block a user