Merge branch 'develop' into feature/10945-search-results-page
This commit is contained in:
+15
-106
@@ -9,6 +9,7 @@ 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';
|
||||
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
|
||||
@@ -18,7 +19,9 @@ const formState = {
|
||||
uiFlags: useMapGetter('campaigns/getUIFlags'),
|
||||
labels: useMapGetter('labels/getLabels'),
|
||||
inboxes: useMapGetter('inboxes/getWhatsAppInboxes'),
|
||||
getWhatsAppTemplates: useMapGetter('inboxes/getWhatsAppTemplates'),
|
||||
getFilteredWhatsAppTemplates: useMapGetter(
|
||||
'inboxes/getFilteredWhatsAppTemplates'
|
||||
),
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
@@ -30,7 +33,7 @@ const initialState = {
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
const processedParams = ref({});
|
||||
const templateParserRef = ref(null);
|
||||
|
||||
const rules = {
|
||||
title: { required, minLength: minLength(1) },
|
||||
@@ -67,7 +70,7 @@ const inboxOptions = computed(() =>
|
||||
|
||||
const templateOptions = computed(() => {
|
||||
if (!state.inboxId) return [];
|
||||
const templates = formState.getWhatsAppTemplates.value(state.inboxId);
|
||||
const templates = formState.getFilteredWhatsAppTemplates.value(state.inboxId);
|
||||
return templates.map(template => {
|
||||
// Create a more user-friendly label from template name
|
||||
const friendlyName = template.name
|
||||
@@ -88,26 +91,6 @@ const selectedTemplate = computed(() => {
|
||||
?.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`) : '';
|
||||
@@ -122,8 +105,7 @@ const formErrors = computed(() => ({
|
||||
}));
|
||||
|
||||
const hasRequiredTemplateParams = computed(() => {
|
||||
const params = Object.values(processedParams.value);
|
||||
return params.length === 0 || params.every(param => param.trim() !== '');
|
||||
return templateParserRef.value?.v$?.$invalid === false || true;
|
||||
});
|
||||
|
||||
const isSubmitDisabled = computed(
|
||||
@@ -135,32 +117,18 @@ const formatToUTCString = localDateTime =>
|
||||
|
||||
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;
|
||||
const parserData = templateParserRef.value;
|
||||
|
||||
// Extract template content - this should be the template message body
|
||||
const templateContent = templateString.value;
|
||||
const templateContent = parserData?.renderedTemplate || '';
|
||||
|
||||
// Prepare template_params object with the same structure as used in contacts
|
||||
const templateParams = {
|
||||
@@ -168,7 +136,7 @@ const prepareCampaignDetails = () => {
|
||||
namespace: currentTemplate?.namespace || '',
|
||||
category: currentTemplate?.category || 'UTILITY',
|
||||
language: currentTemplate?.language || 'en_US',
|
||||
processed_params: processedParams.value,
|
||||
processed_params: parserData?.processedParams || {},
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -198,15 +166,6 @@ watch(
|
||||
() => state.inboxId,
|
||||
() => {
|
||||
state.templateId = null;
|
||||
processedParams.value = {};
|
||||
}
|
||||
);
|
||||
|
||||
// Generate variables when template changes
|
||||
watch(
|
||||
() => state.templateId,
|
||||
() => {
|
||||
generateVariables();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
@@ -254,62 +213,12 @@ watch(
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Template Preview -->
|
||||
<div
|
||||
<!-- Template Parser -->
|
||||
<WhatsAppTemplateParser
|
||||
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>
|
||||
ref="templateParserRef"
|
||||
:template="selectedTemplate"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
|
||||
+3
-1
@@ -315,7 +315,9 @@ const handleAvatarDelete = () => {
|
||||
class="[&>div>button:not(.focused)]:!outline-n-weak"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-start justify-between w-full gap-2">
|
||||
<div
|
||||
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
||||
>
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useWindowSize } from '@vueuse/core';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
processContactableInboxes,
|
||||
mergeInboxDetails,
|
||||
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
|
||||
|
||||
@@ -37,9 +39,16 @@ const emit = defineEmits(['close']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
const { fetchSignatureFlagFromUISettings } = useUISettings();
|
||||
|
||||
const isSmallScreen = computed(
|
||||
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
|
||||
);
|
||||
|
||||
const viewInModal = computed(() => props.isModal || isSmallScreen.value);
|
||||
|
||||
const contacts = ref([]);
|
||||
const selectedContact = ref(null);
|
||||
const targetInbox = ref(null);
|
||||
@@ -67,7 +76,7 @@ const directUploadsEnabled = computed(
|
||||
const activeContact = computed(() => contactById.value(props.contactId));
|
||||
|
||||
const composePopoverClass = computed(() => {
|
||||
if (props.isModal) return '';
|
||||
if (viewInModal.value) return '';
|
||||
|
||||
return props.alignPosition === 'right'
|
||||
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
|
||||
@@ -179,14 +188,18 @@ const toggle = () => {
|
||||
|
||||
watch(
|
||||
activeContact,
|
||||
() => {
|
||||
if (activeContact.value && props.contactId) {
|
||||
const contactInboxes = activeContact.value?.contactInboxes || [];
|
||||
(currentContact, previousContact) => {
|
||||
if (currentContact && props.contactId) {
|
||||
// Reset on contact change
|
||||
if (currentContact?.id !== previousContact?.id) clearSelectedContact();
|
||||
|
||||
// First process the contactable inboxes to get the right structure
|
||||
const processedInboxes = processContactableInboxes(contactInboxes);
|
||||
const processedInboxes = processContactableInboxes(
|
||||
currentContact.contactInboxes || []
|
||||
);
|
||||
// Then Merge processedInboxes with the inboxes list
|
||||
selectedContact.value = {
|
||||
...activeContact.value,
|
||||
...currentContact,
|
||||
contactInboxes: mergeInboxDetails(processedInboxes, inboxesList.value),
|
||||
};
|
||||
}
|
||||
@@ -202,7 +215,7 @@ const handleClickOutside = () => {
|
||||
};
|
||||
|
||||
const onModalBackdropClick = () => {
|
||||
if (!props.isModal) return;
|
||||
if (!viewInModal.value) return;
|
||||
handleClickOutside();
|
||||
};
|
||||
|
||||
@@ -231,7 +244,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
]"
|
||||
class="relative"
|
||||
:class="{
|
||||
'z-40': showComposeNewConversation,
|
||||
'z-50': showComposeNewConversation && !viewInModal,
|
||||
}"
|
||||
>
|
||||
<slot
|
||||
@@ -243,12 +256,12 @@ useKeyboardEvents(keyboardEvents);
|
||||
v-if="showComposeNewConversation"
|
||||
:class="{
|
||||
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
|
||||
isModal,
|
||||
viewInModal,
|
||||
}"
|
||||
@click.self="onModalBackdropClick"
|
||||
>
|
||||
<ComposeNewConversationForm
|
||||
:class="[{ 'mt-2': !isModal }, composePopoverClass]"
|
||||
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
||||
:contacts="contacts"
|
||||
:contact-id="contactId"
|
||||
:is-loading="isSearching"
|
||||
|
||||
+4
-2
@@ -25,6 +25,7 @@ const props = defineProps({
|
||||
hasNoInbox: { type: Boolean, default: false },
|
||||
isDropdownActive: { type: Boolean, default: false },
|
||||
messageSignature: { type: String, default: '' },
|
||||
inboxId: { type: Number, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -150,9 +151,10 @@ useKeyboardEvents(keyboardEvents);
|
||||
<div
|
||||
class="flex items-center justify-between w-full h-[3.25rem] gap-2 px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex gap-2 items-center">
|
||||
<WhatsAppOptions
|
||||
v-if="isWhatsappInbox"
|
||||
:inbox-id="inboxId"
|
||||
:message-templates="messageTemplates"
|
||||
@send-message="emit('sendWhatsappMessage', $event)"
|
||||
/>
|
||||
@@ -206,7 +208,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex gap-2 items-center">
|
||||
<Button
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
|
||||
variant="faded"
|
||||
|
||||
+2
-1
@@ -265,7 +265,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
|
||||
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0"
|
||||
>
|
||||
<ContactSelector
|
||||
:contacts="contacts"
|
||||
@@ -336,6 +336,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
:is-loading="isCreating"
|
||||
:disable-send-button="isCreating"
|
||||
:has-selected-inbox="!!targetInbox"
|
||||
:inbox-id="targetInbox?.id"
|
||||
:has-no-inbox="showNoInboxAlert"
|
||||
:is-dropdown-active="isAnyDropdownActive"
|
||||
:message-signature="messageSignature"
|
||||
|
||||
+12
-23
@@ -1,24 +1,25 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import WhatsappTemplateParser from './WhatsappTemplateParser.vue';
|
||||
import WhatsappTemplate from './WhatsappTemplate.vue';
|
||||
|
||||
const props = defineProps({
|
||||
messageTemplates: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
inboxId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// TODO: Remove this when we support all formats
|
||||
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
|
||||
const getFilteredWhatsAppTemplates = useMapGetter(
|
||||
'inboxes/getFilteredWhatsAppTemplates'
|
||||
);
|
||||
|
||||
const searchQuery = ref('');
|
||||
const selectedTemplate = ref(null);
|
||||
@@ -26,19 +27,7 @@ const selectedTemplate = ref(null);
|
||||
const showTemplatesMenu = ref(false);
|
||||
|
||||
const whatsAppTemplateMessages = computed(() => {
|
||||
// Add null check and ensure it's an array
|
||||
const templates = Array.isArray(props.messageTemplates)
|
||||
? props.messageTemplates
|
||||
: [];
|
||||
|
||||
// TODO: Remove the last filter when we support all formats
|
||||
return templates
|
||||
.filter(template => template?.status?.toLowerCase() === 'approved')
|
||||
.filter(template => {
|
||||
return template?.components?.every(component => {
|
||||
return !formatsToRemove.includes(component.format);
|
||||
});
|
||||
});
|
||||
return getFilteredWhatsAppTemplates.value(props.inboxId);
|
||||
});
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
@@ -106,7 +95,7 @@ const handleSendMessage = template => {
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.id"
|
||||
class="flex flex-col w-full gap-2 p-2 rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||
@click="handleTemplateClick(template)"
|
||||
>
|
||||
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
|
||||
@@ -115,12 +104,12 @@ const handleSendMessage = template => {
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="filteredTemplates.length === 0">
|
||||
<p class="w-full pt-2 text-sm text-n-slate-11">
|
||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<WhatsappTemplateParser
|
||||
<WhatsappTemplate
|
||||
v-if="selectedTemplate"
|
||||
:template="selectedTemplate"
|
||||
@send-message="handleSendMessage"
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage', 'back']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleSendMessage = payload => {
|
||||
emit('sendMessage', payload);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
emit('back');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="w-full">
|
||||
<WhatsAppTemplateParser
|
||||
:template="template"
|
||||
@send-message="handleSendMessage"
|
||||
@back="handleBack"
|
||||
>
|
||||
<template #actions="{ sendMessage, goBack, disabled }">
|
||||
<div class="flex gap-3 justify-between items-end w-full h-14">
|
||||
<Button
|
||||
:label="
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK'
|
||||
)
|
||||
"
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="w-full font-medium"
|
||||
@click="goBack"
|
||||
/>
|
||||
<Button
|
||||
:label="
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE'
|
||||
)
|
||||
"
|
||||
class="w-full font-medium"
|
||||
:disabled="disabled"
|
||||
@click="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</WhatsAppTemplateParser>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -509,10 +509,8 @@ const menuItems = computed(() => {
|
||||
class="bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak flex flex-col text-sm pb-1 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 transition-transform duration-200 ease-in-out md:static w-[200px] basis-[200px] md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:-translate-x-0"
|
||||
:class="[
|
||||
{
|
||||
'ltr:translate-x-0 rtl:-translate-x-0 shadow-lg md:shadow-none':
|
||||
isMobileSidebarOpen,
|
||||
'ltr:-translate-x-full rtl:translate-x-full md:translate-x-0':
|
||||
!isMobileSidebarOpen,
|
||||
'shadow-lg md:shadow-none': isMobileSidebarOpen,
|
||||
'ltr:-translate-x-full rtl:translate-x-full': !isMobileSidebarOpen,
|
||||
},
|
||||
]"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import {
|
||||
buildTemplateParameters,
|
||||
allKeysRequired,
|
||||
replaceTemplateVariables,
|
||||
DEFAULT_LANGUAGE,
|
||||
DEFAULT_CATEGORY,
|
||||
COMPONENT_TYPES,
|
||||
MEDIA_FORMATS,
|
||||
findComponentByType,
|
||||
} from 'dashboard/helper/templateHelper';
|
||||
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
validator: value => {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
if (!value.components || !Array.isArray(value.components)) return false;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage', 'resetTemplate', 'back']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const processedParams = ref({});
|
||||
|
||||
const languageLabel = computed(() => {
|
||||
return `${t('WHATSAPP_TEMPLATES.PARSER.LANGUAGE')}: ${props.template.language || DEFAULT_LANGUAGE}`;
|
||||
});
|
||||
|
||||
const categoryLabel = computed(() => {
|
||||
return `${t('WHATSAPP_TEMPLATES.PARSER.CATEGORY')}: ${props.template.category || DEFAULT_CATEGORY}`;
|
||||
});
|
||||
|
||||
const headerComponent = computed(() => {
|
||||
return findComponentByType(props.template, COMPONENT_TYPES.HEADER);
|
||||
});
|
||||
|
||||
const bodyComponent = computed(() => {
|
||||
return findComponentByType(props.template, COMPONENT_TYPES.BODY);
|
||||
});
|
||||
|
||||
const bodyText = computed(() => {
|
||||
return bodyComponent.value?.text || '';
|
||||
});
|
||||
|
||||
const hasMediaHeader = computed(() =>
|
||||
MEDIA_FORMATS.includes(headerComponent.value?.format)
|
||||
);
|
||||
|
||||
const formatType = computed(() => {
|
||||
const format = headerComponent.value?.format;
|
||||
return format ? format.charAt(0) + format.slice(1).toLowerCase() : '';
|
||||
});
|
||||
|
||||
const hasVariables = computed(() => {
|
||||
return bodyText.value?.match(/{{([^}]+)}}/g) !== null;
|
||||
});
|
||||
|
||||
const renderedTemplate = computed(() => {
|
||||
return replaceTemplateVariables(bodyText.value, processedParams.value);
|
||||
});
|
||||
|
||||
const isFormInvalid = computed(() => {
|
||||
if (!hasVariables.value && !hasMediaHeader.value) return false;
|
||||
|
||||
if (hasMediaHeader.value && !processedParams.value.header?.media_url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasVariables.value && processedParams.value.body) {
|
||||
const hasEmptyBodyVariable = Object.values(processedParams.value.body).some(
|
||||
value => !value
|
||||
);
|
||||
if (hasEmptyBodyVariable) return true;
|
||||
}
|
||||
|
||||
if (processedParams.value.buttons) {
|
||||
const hasEmptyButtonParameter = processedParams.value.buttons.some(
|
||||
button => !button.parameter
|
||||
);
|
||||
if (hasEmptyButtonParameter) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const v$ = useVuelidate(
|
||||
{
|
||||
processedParams: {
|
||||
requiredIfKeysPresent: requiredIf(hasVariables),
|
||||
allKeysRequired,
|
||||
},
|
||||
},
|
||||
{ processedParams }
|
||||
);
|
||||
|
||||
const initializeTemplateParameters = () => {
|
||||
processedParams.value = buildTemplateParameters(
|
||||
props.template,
|
||||
hasMediaHeader.value
|
||||
);
|
||||
};
|
||||
|
||||
const updateMediaUrl = value => {
|
||||
processedParams.value.header ??= {};
|
||||
processedParams.value.header.media_url = value;
|
||||
};
|
||||
|
||||
const sendMessage = () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) return;
|
||||
|
||||
const { name, category, language, namespace } = props.template;
|
||||
|
||||
const payload = {
|
||||
message: renderedTemplate.value,
|
||||
templateParams: {
|
||||
name,
|
||||
category,
|
||||
language,
|
||||
namespace,
|
||||
processed_params: processedParams.value,
|
||||
},
|
||||
};
|
||||
emit('sendMessage', payload);
|
||||
};
|
||||
|
||||
const resetTemplate = () => {
|
||||
emit('resetTemplate');
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
emit('back');
|
||||
};
|
||||
|
||||
onMounted(initializeTemplateParameters);
|
||||
|
||||
watch(
|
||||
() => props.template,
|
||||
() => {
|
||||
initializeTemplateParameters();
|
||||
v$.value.$reset();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
processedParams,
|
||||
hasVariables,
|
||||
hasMediaHeader,
|
||||
headerComponent,
|
||||
renderedTemplate,
|
||||
v$,
|
||||
updateMediaUrl,
|
||||
sendMessage,
|
||||
resetTemplate,
|
||||
goBack,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col gap-4 p-4 mb-4 rounded-lg bg-n-alpha-black2">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ template.name }}
|
||||
</h3>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ languageLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="rounded-md">
|
||||
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
|
||||
{{ renderedTemplate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-n-slate-11">
|
||||
{{ categoryLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasVariables || hasMediaHeader">
|
||||
<div v-if="hasMediaHeader" class="mb-4">
|
||||
<p class="mb-2.5 text-sm font-semibold">
|
||||
{{
|
||||
$t('WHATSAPP_TEMPLATES.PARSER.MEDIA_HEADER_LABEL', {
|
||||
type: formatType,
|
||||
}) || `${formatType} Header`
|
||||
}}
|
||||
</p>
|
||||
<div class="flex items-center mb-2.5">
|
||||
<Input
|
||||
:model-value="processedParams.header?.media_url || ''"
|
||||
type="url"
|
||||
class="flex-1"
|
||||
:placeholder="
|
||||
t('WHATSAPP_TEMPLATES.PARSER.MEDIA_URL_LABEL', {
|
||||
type: formatType,
|
||||
})
|
||||
"
|
||||
@update:model-value="updateMediaUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body Variables Section -->
|
||||
<div v-if="processedParams.body">
|
||||
<p class="mb-2.5 text-sm font-semibold">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="(variable, key) in processedParams.body"
|
||||
:key="`body-${key}`"
|
||||
class="flex items-center mb-2.5"
|
||||
>
|
||||
<Input
|
||||
v-model="processedParams.body[key]"
|
||||
type="text"
|
||||
class="flex-1"
|
||||
:placeholder="
|
||||
t('WHATSAPP_TEMPLATES.PARSER.VARIABLE_PLACEHOLDER', {
|
||||
variable: key,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Button Variables Section -->
|
||||
<div v-if="processedParams.buttons">
|
||||
<p class="mb-2.5 text-sm font-semibold">
|
||||
{{ t('WHATSAPP_TEMPLATES.PARSER.BUTTON_PARAMETERS') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="(button, index) in processedParams.buttons"
|
||||
:key="`button-${index}`"
|
||||
class="flex items-center mb-2.5"
|
||||
>
|
||||
<Input
|
||||
v-model="processedParams.buttons[index].parameter"
|
||||
type="text"
|
||||
class="flex-1"
|
||||
:placeholder="t('WHATSAPP_TEMPLATES.PARSER.BUTTON_PARAMETER')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="v$.$dirty && v$.$invalid"
|
||||
class="p-2.5 text-center rounded-md bg-n-ruby-9/20 text-n-ruby-9"
|
||||
>
|
||||
{{ $t('WHATSAPP_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<slot
|
||||
name="actions"
|
||||
:send-message="sendMessage"
|
||||
:reset-template="resetTemplate"
|
||||
:go-back="goBack"
|
||||
:is-valid="!v$.$invalid"
|
||||
:disabled="isFormInvalid"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+36
-143
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script setup>
|
||||
/**
|
||||
* This component handles parsing and sending WhatsApp message templates.
|
||||
* It works as follows:
|
||||
@@ -8,158 +8,51 @@
|
||||
* 4. Replaces placeholders with user-provided values.
|
||||
* 5. Emits events to send the processed message or reset the template.
|
||||
*/
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
|
||||
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
props: {
|
||||
template: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
emits: ['sendMessage', 'resetTemplate'],
|
||||
setup(props, { emit }) {
|
||||
const processVariable = str => {
|
||||
return str.replace(/{{|}}/g, '');
|
||||
};
|
||||
});
|
||||
|
||||
const allKeysRequired = value => {
|
||||
const keys = Object.keys(value);
|
||||
return keys.every(key => value[key]);
|
||||
};
|
||||
const emit = defineEmits(['sendMessage', 'resetTemplate']);
|
||||
|
||||
const processedParams = ref({});
|
||||
const handleSendMessage = payload => {
|
||||
emit('sendMessage', payload);
|
||||
};
|
||||
|
||||
const templateString = computed(() => {
|
||||
return props.template.components.find(
|
||||
component => component.type === 'BODY'
|
||||
).text;
|
||||
});
|
||||
|
||||
const variables = computed(() => {
|
||||
return templateString.value.match(/{{([^}]+)}}/g);
|
||||
});
|
||||
|
||||
const processedString = computed(() => {
|
||||
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
|
||||
const variableKey = processVariable(variable);
|
||||
return processedParams.value[variableKey] || `{{${variable}}}`;
|
||||
});
|
||||
});
|
||||
|
||||
const v$ = useVuelidate(
|
||||
{
|
||||
processedParams: {
|
||||
requiredIfKeysPresent: requiredIf(variables),
|
||||
allKeysRequired,
|
||||
},
|
||||
},
|
||||
{ processedParams }
|
||||
);
|
||||
|
||||
const generateVariables = () => {
|
||||
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
|
||||
if (!matchedVariables) return;
|
||||
|
||||
const finalVars = matchedVariables.map(i => processVariable(i));
|
||||
processedParams.value = finalVars.reduce((acc, variable) => {
|
||||
acc[variable] = '';
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const resetTemplate = () => {
|
||||
emit('resetTemplate');
|
||||
};
|
||||
|
||||
const sendMessage = () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) return;
|
||||
|
||||
const payload = {
|
||||
message: processedString.value,
|
||||
templateParams: {
|
||||
name: props.template.name,
|
||||
category: props.template.category,
|
||||
language: props.template.language,
|
||||
namespace: props.template.namespace,
|
||||
processed_params: processedParams.value,
|
||||
},
|
||||
};
|
||||
emit('sendMessage', payload);
|
||||
};
|
||||
|
||||
onMounted(generateVariables);
|
||||
|
||||
return {
|
||||
processedParams,
|
||||
variables,
|
||||
templateString,
|
||||
processedString,
|
||||
v$,
|
||||
resetTemplate,
|
||||
sendMessage,
|
||||
};
|
||||
},
|
||||
const handleResetTemplate = () => {
|
||||
emit('resetTemplate');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<textarea
|
||||
v-model="processedString"
|
||||
rows="4"
|
||||
readonly
|
||||
class="template-input"
|
||||
/>
|
||||
<div v-if="variables" class="p-2.5">
|
||||
<p class="text-sm font-semibold mb-2.5">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="(variable, key) in processedParams"
|
||||
:key="key"
|
||||
class="items-center flex mb-2.5"
|
||||
>
|
||||
<span
|
||||
class="bg-n-alpha-black2 text-n-slate-12 inline-block rounded-md text-xs py-2.5 px-6"
|
||||
>
|
||||
{{ key }}
|
||||
</span>
|
||||
<woot-input
|
||||
v-model="processedParams[key]"
|
||||
type="text"
|
||||
class="flex-1 text-sm ml-2.5"
|
||||
:styles="{ marginBottom: 0 }"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="v$.$dirty && v$.$invalid"
|
||||
class="bg-n-ruby-9/20 rounded-md text-n-ruby-9 p-2.5 text-center"
|
||||
>
|
||||
{{ $t('WHATSAPP_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
<footer class="flex justify-end gap-2">
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL')"
|
||||
@click="resetTemplate"
|
||||
/>
|
||||
<NextButton
|
||||
type="button"
|
||||
:label="$t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
|
||||
@click="sendMessage"
|
||||
/>
|
||||
</footer>
|
||||
<WhatsAppTemplateParser
|
||||
:template="template"
|
||||
@send-message="handleSendMessage"
|
||||
@reset-template="handleResetTemplate"
|
||||
>
|
||||
<template #actions="{ sendMessage, resetTemplate, disabled }">
|
||||
<footer class="flex gap-2 justify-end">
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL')"
|
||||
@click="resetTemplate"
|
||||
/>
|
||||
<NextButton
|
||||
type="button"
|
||||
:label="$t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
|
||||
:disabled="disabled"
|
||||
@click="sendMessage"
|
||||
/>
|
||||
</footer>
|
||||
</template>
|
||||
</WhatsAppTemplateParser>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+130
-67
@@ -1,60 +1,71 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, computed, toRef } from 'vue';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useFunctionGetter, useStore } from 'dashboard/composables/store';
|
||||
import {
|
||||
COMPONENT_TYPES,
|
||||
MEDIA_FORMATS,
|
||||
findComponentByType,
|
||||
} from 'dashboard/helper/templateHelper';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
// TODO: Remove this when we support all formats
|
||||
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Icon,
|
||||
},
|
||||
props: {
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ['onSelect'],
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
isRefreshing: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
whatsAppTemplateMessages() {
|
||||
// TODO: Remove the last filter when we support all formats
|
||||
return this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId)
|
||||
.filter(template => template.status.toLowerCase() === 'approved')
|
||||
.filter(template => {
|
||||
return template.components.every(component => {
|
||||
return !formatsToRemove.includes(component.format);
|
||||
});
|
||||
});
|
||||
},
|
||||
filteredTemplateMessages() {
|
||||
return this.whatsAppTemplateMessages.filter(template =>
|
||||
template.name.toLowerCase().includes(this.query.toLowerCase())
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getTemplatebody(template) {
|
||||
return template.components.find(component => component.type === 'BODY')
|
||||
.text;
|
||||
},
|
||||
async refreshTemplates() {
|
||||
this.isRefreshing = true;
|
||||
try {
|
||||
await this.$store.dispatch('inboxes/syncTemplates', this.inboxId);
|
||||
useAlert(this.$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_ERROR'));
|
||||
} finally {
|
||||
this.isRefreshing = false;
|
||||
}
|
||||
},
|
||||
const props = defineProps({
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['onSelect']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const query = ref('');
|
||||
const isRefreshing = ref(false);
|
||||
|
||||
const whatsAppTemplateMessages = useFunctionGetter(
|
||||
'inboxes/getFilteredWhatsAppTemplates',
|
||||
toRef(props, 'inboxId')
|
||||
);
|
||||
|
||||
const filteredTemplateMessages = computed(() =>
|
||||
whatsAppTemplateMessages.value.filter(template =>
|
||||
template.name.toLowerCase().includes(query.value.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
const getTemplateBody = template => {
|
||||
return findComponentByType(template, COMPONENT_TYPES.BODY)?.text || '';
|
||||
};
|
||||
|
||||
const getTemplateHeader = template => {
|
||||
return findComponentByType(template, COMPONENT_TYPES.HEADER);
|
||||
};
|
||||
|
||||
const getTemplateFooter = template => {
|
||||
return findComponentByType(template, COMPONENT_TYPES.FOOTER);
|
||||
};
|
||||
|
||||
const getTemplateButtons = template => {
|
||||
return findComponentByType(template, COMPONENT_TYPES.BUTTONS);
|
||||
};
|
||||
|
||||
const hasMediaContent = template => {
|
||||
const header = getTemplateHeader(template);
|
||||
return header && MEDIA_FORMATS.includes(header.format);
|
||||
};
|
||||
|
||||
const refreshTemplates = async () => {
|
||||
isRefreshing.value = true;
|
||||
try {
|
||||
await store.dispatch('inboxes/syncTemplates', props.inboxId);
|
||||
useAlert(t('WHATSAPP_TEMPLATES.PICKER.REFRESH_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('WHATSAPP_TEMPLATES.PICKER.REFRESH_ERROR'));
|
||||
} finally {
|
||||
isRefreshing.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -68,14 +79,14 @@ export default {
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="$t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
|
||||
:placeholder="t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
|
||||
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
:disabled="isRefreshing"
|
||||
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:title="$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_BUTTON')"
|
||||
:title="t('WHATSAPP_TEMPLATES.PICKER.REFRESH_BUTTON')"
|
||||
@click="refreshTemplates"
|
||||
>
|
||||
<Icon
|
||||
@@ -91,7 +102,7 @@ export default {
|
||||
<div v-for="(template, i) in filteredTemplateMessages" :key="template.id">
|
||||
<button
|
||||
class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
|
||||
@click="$emit('onSelect', template)"
|
||||
@click="emit('onSelect', template)"
|
||||
>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-2.5">
|
||||
@@ -101,21 +112,73 @@ export default {
|
||||
<span
|
||||
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
|
||||
>
|
||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }} :
|
||||
{{ t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }}:
|
||||
{{ template.language }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.TEMPLATE_BODY') }}
|
||||
<!-- Header -->
|
||||
<div v-if="getTemplateHeader(template)" class="mb-3">
|
||||
<p class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('WHATSAPP_TEMPLATES.PICKER.HEADER') || 'HEADER' }}
|
||||
</p>
|
||||
<p class="label-body">{{ getTemplatebody(template) }}</p>
|
||||
<div
|
||||
v-if="getTemplateHeader(template).format === 'TEXT'"
|
||||
class="text-sm label-body"
|
||||
>
|
||||
{{ getTemplateHeader(template).text }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="hasMediaContent(template)"
|
||||
class="text-sm italic text-n-slate-11"
|
||||
>
|
||||
{{
|
||||
t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT', {
|
||||
format: getTemplateHeader(template).format,
|
||||
}) ||
|
||||
`${getTemplateHeader(template).format} ${t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT_FALLBACK')}`
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<p class="font-medium">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.CATEGORY') }}
|
||||
|
||||
<!-- Body -->
|
||||
<div>
|
||||
<p class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('WHATSAPP_TEMPLATES.PICKER.BODY') || 'BODY' }}
|
||||
</p>
|
||||
<p>{{ template.category }}</p>
|
||||
<p class="text-sm label-body">{{ getTemplateBody(template) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div v-if="getTemplateFooter(template)" class="mt-3">
|
||||
<p class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('WHATSAPP_TEMPLATES.PICKER.FOOTER') || 'FOOTER' }}
|
||||
</p>
|
||||
<p class="text-sm label-body">
|
||||
{{ getTemplateFooter(template).text }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div v-if="getTemplateButtons(template)" class="mt-3">
|
||||
<p class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('WHATSAPP_TEMPLATES.PICKER.BUTTONS') || 'BUTTONS' }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
<span
|
||||
v-for="button in getTemplateButtons(template).buttons"
|
||||
:key="button.text"
|
||||
class="px-2 py-1 text-xs rounded bg-n-slate-3 text-n-slate-12"
|
||||
>
|
||||
{{ button.text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<p class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('WHATSAPP_TEMPLATES.PICKER.CATEGORY') || 'CATEGORY' }}
|
||||
</p>
|
||||
<p class="text-sm">{{ template.category }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -128,13 +191,13 @@ export default {
|
||||
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
|
||||
<div v-if="query && whatsAppTemplateMessages.length">
|
||||
<p>
|
||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
|
||||
{{ t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
|
||||
<strong>{{ query }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="!whatsAppTemplateMessages.length" class="space-y-4">
|
||||
<p class="text-n-slate-11">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
|
||||
{{ t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -196,6 +196,7 @@ describe('useAutomation', () => {
|
||||
automationTypes.conversation_created = { conditions: [] };
|
||||
automationTypes.conversation_updated = { conditions: [] };
|
||||
automationTypes.conversation_opened = { conditions: [] };
|
||||
automationTypes.conversation_resolved = { conditions: [] };
|
||||
|
||||
automationHelper.generateCustomAttributeTypes.mockReturnValue([]);
|
||||
automationHelper.generateCustomAttributes.mockReturnValue([]);
|
||||
|
||||
@@ -8,7 +8,7 @@ export const DEFAULT_MESSAGE_CREATED_CONDITION = [
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_CONVERSATION_OPENED_CONDITION = [
|
||||
export const DEFAULT_CONVERSATION_CONDITION = [
|
||||
{
|
||||
attribute_key: 'browser_language',
|
||||
filter_operator: 'equal_to',
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from 'dashboard/routes/dashboard/settings/automation/operators';
|
||||
import {
|
||||
DEFAULT_MESSAGE_CREATED_CONDITION,
|
||||
DEFAULT_CONVERSATION_OPENED_CONDITION,
|
||||
DEFAULT_CONVERSATION_CONDITION,
|
||||
DEFAULT_OTHER_CONDITION,
|
||||
DEFAULT_ACTIONS,
|
||||
} from 'dashboard/constants/automation';
|
||||
@@ -169,8 +169,11 @@ export const getDefaultConditions = eventName => {
|
||||
if (eventName === 'message_created') {
|
||||
return DEFAULT_MESSAGE_CREATED_CONDITION;
|
||||
}
|
||||
if (eventName === 'conversation_opened') {
|
||||
return DEFAULT_CONVERSATION_OPENED_CONDITION;
|
||||
if (
|
||||
eventName === 'conversation_opened' ||
|
||||
eventName === 'conversation_resolved'
|
||||
) {
|
||||
return DEFAULT_CONVERSATION_CONDITION;
|
||||
}
|
||||
return DEFAULT_OTHER_CONDITION;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import {
|
||||
replaceTemplateVariables,
|
||||
buildTemplateParameters,
|
||||
processVariable,
|
||||
allKeysRequired,
|
||||
} from '../templateHelper';
|
||||
import { templates } from '../../store/modules/specs/inboxes/templateFixtures';
|
||||
|
||||
describe('templateHelper', () => {
|
||||
const technicianTemplate = templates.find(t => t.name === 'technician_visit');
|
||||
|
||||
describe('processVariable', () => {
|
||||
it('should remove curly braces from variables', () => {
|
||||
expect(processVariable('{{name}}')).toBe('name');
|
||||
expect(processVariable('{{1}}')).toBe('1');
|
||||
expect(processVariable('{{customer_id}}')).toBe('customer_id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('allKeysRequired', () => {
|
||||
it('should return true when all keys have values', () => {
|
||||
const obj = { name: 'John', age: '30' };
|
||||
expect(allKeysRequired(obj)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when some keys are empty', () => {
|
||||
const obj = { name: 'John', age: '' };
|
||||
expect(allKeysRequired(obj)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for empty object', () => {
|
||||
expect(allKeysRequired({})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceTemplateVariables', () => {
|
||||
const templateText =
|
||||
"Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.";
|
||||
|
||||
it('should replace all variables with provided values', () => {
|
||||
const processedParams = {
|
||||
body: {
|
||||
1: 'John',
|
||||
2: '123 Main St',
|
||||
3: '2025-01-15',
|
||||
4: '10:00 AM',
|
||||
5: '2:00 PM',
|
||||
},
|
||||
};
|
||||
|
||||
const result = replaceTemplateVariables(templateText, processedParams);
|
||||
expect(result).toBe(
|
||||
"Hi John, we're scheduling a technician visit to 123 Main St on 2025-01-15 between 10:00 AM and 2:00 PM. Please confirm if this time slot works for you."
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep original variable format when no replacement value provided', () => {
|
||||
const processedParams = {
|
||||
body: {
|
||||
1: 'John',
|
||||
3: '2025-01-15',
|
||||
},
|
||||
};
|
||||
|
||||
const result = replaceTemplateVariables(templateText, processedParams);
|
||||
expect(result).toContain('John');
|
||||
expect(result).toContain('2025-01-15');
|
||||
expect(result).toContain('{{2}}');
|
||||
expect(result).toContain('{{4}}');
|
||||
expect(result).toContain('{{5}}');
|
||||
});
|
||||
|
||||
it('should handle empty processedParams', () => {
|
||||
const result = replaceTemplateVariables(templateText, {});
|
||||
expect(result).toBe(templateText);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTemplateParameters', () => {
|
||||
it('should build parameters for template with body variables', () => {
|
||||
const result = buildTemplateParameters(technicianTemplate, false);
|
||||
|
||||
expect(result.body).toEqual({
|
||||
1: '',
|
||||
2: '',
|
||||
3: '',
|
||||
4: '',
|
||||
5: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include header parameters when hasMediaHeader is true', () => {
|
||||
const imageTemplate = templates.find(
|
||||
t => t.name === 'order_confirmation'
|
||||
);
|
||||
const result = buildTemplateParameters(imageTemplate, true);
|
||||
|
||||
expect(result.header).toEqual({
|
||||
media_url: '',
|
||||
media_type: 'image',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not include header parameters when hasMediaHeader is false', () => {
|
||||
const result = buildTemplateParameters(technicianTemplate, false);
|
||||
expect(result.header).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle template with no body component', () => {
|
||||
const templateWithoutBody = {
|
||||
components: [{ type: 'HEADER', format: 'TEXT' }],
|
||||
};
|
||||
|
||||
const result = buildTemplateParameters(templateWithoutBody, false);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle template with no variables', () => {
|
||||
const templateWithoutVars = templates.find(
|
||||
t => t.name === 'no_variable_template'
|
||||
);
|
||||
const result = buildTemplateParameters(templateWithoutVars, false);
|
||||
|
||||
expect(result.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle URL buttons with variables for non-authentication templates', () => {
|
||||
const templateWithUrlButton = {
|
||||
category: 'MARKETING',
|
||||
components: [
|
||||
{
|
||||
type: 'BODY',
|
||||
text: 'Check out our website at {{site_url}}',
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
type: 'URL',
|
||||
url: 'https://example.com/{{campaign_id}}',
|
||||
text: 'Visit Site',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = buildTemplateParameters(templateWithUrlButton, false);
|
||||
expect(result.buttons).toEqual([
|
||||
{
|
||||
type: 'url',
|
||||
parameter: '',
|
||||
url: 'https://example.com/{{campaign_id}}',
|
||||
variables: ['campaign_id'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle templates with no variables', () => {
|
||||
const emptyTemplate = templates.find(
|
||||
t => t.name === 'no_variable_template'
|
||||
);
|
||||
const result = buildTemplateParameters(emptyTemplate, false);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should build parameters for templates with multiple component types', () => {
|
||||
const complexTemplate = {
|
||||
components: [
|
||||
{ type: 'HEADER', format: 'IMAGE' },
|
||||
{ type: 'BODY', text: 'Hi {{1}}, your order {{2}} is ready!' },
|
||||
{ type: 'FOOTER', text: 'Thank you for your business' },
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [{ type: 'URL', url: 'https://example.com/{{3}}' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = buildTemplateParameters(complexTemplate, true);
|
||||
|
||||
expect(result.header).toEqual({
|
||||
media_url: '',
|
||||
media_type: 'image',
|
||||
});
|
||||
expect(result.body).toEqual({ 1: '', 2: '' });
|
||||
expect(result.buttons).toEqual([
|
||||
{
|
||||
type: 'url',
|
||||
parameter: '',
|
||||
url: 'https://example.com/{{3}}',
|
||||
variables: ['3'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle copy code buttons correctly', () => {
|
||||
const copyCodeTemplate = templates.find(
|
||||
t => t.name === 'discount_coupon'
|
||||
);
|
||||
const result = buildTemplateParameters(copyCodeTemplate, false);
|
||||
|
||||
expect(result.body).toBeDefined();
|
||||
expect(result.buttons).toEqual([
|
||||
{
|
||||
type: 'copy_code',
|
||||
parameter: '',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle templates with document headers', () => {
|
||||
const documentTemplate = templates.find(
|
||||
t => t.name === 'purchase_receipt'
|
||||
);
|
||||
const result = buildTemplateParameters(documentTemplate, true);
|
||||
|
||||
expect(result.header).toEqual({
|
||||
media_url: '',
|
||||
media_type: 'document',
|
||||
});
|
||||
expect(result.body).toEqual({
|
||||
1: '',
|
||||
2: '',
|
||||
3: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle video header templates', () => {
|
||||
const videoTemplate = templates.find(t => t.name === 'training_video');
|
||||
const result = buildTemplateParameters(videoTemplate, true);
|
||||
|
||||
expect(result.header).toEqual({
|
||||
media_url: '',
|
||||
media_type: 'video',
|
||||
});
|
||||
expect(result.body).toEqual({
|
||||
name: '',
|
||||
date: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('enhanced format validation', () => {
|
||||
it('should validate enhanced format structure', () => {
|
||||
const processedParams = {
|
||||
body: { 1: 'John', 2: 'Order123' },
|
||||
header: {
|
||||
media_url: 'https://example.com/image.jpg',
|
||||
media_type: 'image',
|
||||
},
|
||||
buttons: [{ type: 'copy_code', parameter: 'SAVE20' }],
|
||||
};
|
||||
|
||||
// Test that structure is properly formed
|
||||
expect(processedParams.body).toBeDefined();
|
||||
expect(typeof processedParams.body).toBe('object');
|
||||
expect(processedParams.header).toBeDefined();
|
||||
expect(Array.isArray(processedParams.buttons)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty component sections', () => {
|
||||
const processedParams = {
|
||||
body: {},
|
||||
header: {},
|
||||
buttons: [],
|
||||
};
|
||||
|
||||
expect(allKeysRequired(processedParams.body)).toBe(true);
|
||||
expect(allKeysRequired(processedParams.header)).toBe(true);
|
||||
expect(processedParams.buttons.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should validate parameter completeness', () => {
|
||||
const incompleteParams = {
|
||||
body: { 1: 'John', 2: '' },
|
||||
};
|
||||
|
||||
expect(allKeysRequired(incompleteParams.body)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle edge cases in processVariable', () => {
|
||||
expect(processVariable('{{')).toBe('');
|
||||
expect(processVariable('}}')).toBe('');
|
||||
expect(processVariable('')).toBe('');
|
||||
expect(processVariable('{{nested{{variable}}}}')).toBe('nestedvariable');
|
||||
});
|
||||
|
||||
it('should handle special characters in template variables', () => {
|
||||
/* eslint-disable no-template-curly-in-string */
|
||||
const templateText =
|
||||
'Welcome {{user_name}}, your order #{{order_id}} costs ${{amount}}';
|
||||
/* eslint-enable no-template-curly-in-string */
|
||||
const processedParams = {
|
||||
body: {
|
||||
user_name: 'John & Jane',
|
||||
order_id: '12345',
|
||||
amount: '99.99',
|
||||
},
|
||||
};
|
||||
|
||||
const result = replaceTemplateVariables(templateText, processedParams);
|
||||
expect(result).toBe(
|
||||
'Welcome John & Jane, your order #12345 costs $99.99'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle templates with mixed parameter types', () => {
|
||||
const mixedTemplate = {
|
||||
components: [
|
||||
{ type: 'HEADER', format: 'VIDEO' },
|
||||
{ type: 'BODY', text: 'Order {{order_id}} status: {{status}}' },
|
||||
{ type: 'FOOTER', text: 'Thank you' },
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{ type: 'URL', url: 'https://track.com/{{order_id}}' },
|
||||
{ type: 'COPY_CODE' },
|
||||
{ type: 'PHONE_NUMBER', phone_number: '+1234567890' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = buildTemplateParameters(mixedTemplate, true);
|
||||
|
||||
expect(result.header).toEqual({
|
||||
media_url: '',
|
||||
media_type: 'video',
|
||||
});
|
||||
expect(result.body).toEqual({
|
||||
order_id: '',
|
||||
status: '',
|
||||
});
|
||||
expect(result.buttons).toHaveLength(2); // URL and COPY_CODE (PHONE_NUMBER doesn't need parameters)
|
||||
expect(result.buttons[0].type).toBe('url');
|
||||
expect(result.buttons[1].type).toBe('copy_code');
|
||||
});
|
||||
|
||||
it('should handle templates with no processable components', () => {
|
||||
const emptyTemplate = {
|
||||
components: [
|
||||
{ type: 'HEADER', format: 'TEXT', text: 'Static Header' },
|
||||
{ type: 'BODY', text: 'Static body with no variables' },
|
||||
{ type: 'FOOTER', text: 'Static footer' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = buildTemplateParameters(emptyTemplate, false);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should validate that replaceTemplateVariables preserves unreplaced variables', () => {
|
||||
const templateText = 'Hi {{name}}, order {{order_id}} is {{status}}';
|
||||
const partialParams = {
|
||||
body: {
|
||||
name: 'John',
|
||||
// order_id missing
|
||||
status: 'ready',
|
||||
},
|
||||
};
|
||||
|
||||
const result = replaceTemplateVariables(templateText, partialParams);
|
||||
expect(result).toBe('Hi John, order {{order_id}} is ready');
|
||||
expect(result).toContain('{{order_id}}'); // Unreplaced variable preserved
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
// Constants
|
||||
export const DEFAULT_LANGUAGE = 'en';
|
||||
export const DEFAULT_CATEGORY = 'UTILITY';
|
||||
export const COMPONENT_TYPES = {
|
||||
HEADER: 'HEADER',
|
||||
BODY: 'BODY',
|
||||
BUTTONS: 'BUTTONS',
|
||||
};
|
||||
export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT'];
|
||||
|
||||
export const findComponentByType = (template, type) =>
|
||||
template.components?.find(component => component.type === type);
|
||||
|
||||
export const processVariable = str => {
|
||||
return str.replace(/{{|}}/g, '');
|
||||
};
|
||||
|
||||
export const allKeysRequired = value => {
|
||||
const keys = Object.keys(value);
|
||||
return keys.every(key => value[key]);
|
||||
};
|
||||
|
||||
export const replaceTemplateVariables = (templateText, processedParams) => {
|
||||
return templateText.replace(/{{([^}]+)}}/g, (match, variable) => {
|
||||
const variableKey = processVariable(variable);
|
||||
return processedParams.body?.[variableKey] || `{{${variable}}}`;
|
||||
});
|
||||
};
|
||||
|
||||
export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
|
||||
const allVariables = {};
|
||||
|
||||
const bodyComponent = findComponentByType(template, COMPONENT_TYPES.BODY);
|
||||
const headerComponent = findComponentByType(template, COMPONENT_TYPES.HEADER);
|
||||
|
||||
if (!bodyComponent) return allVariables;
|
||||
|
||||
const templateString = bodyComponent.text;
|
||||
|
||||
// Process body variables
|
||||
const matchedVariables = templateString.match(/{{([^}]+)}}/g);
|
||||
if (matchedVariables) {
|
||||
allVariables.body = {};
|
||||
matchedVariables.forEach(variable => {
|
||||
const key = processVariable(variable);
|
||||
allVariables.body[key] = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (hasMediaHeaderValue) {
|
||||
if (!allVariables.header) allVariables.header = {};
|
||||
allVariables.header.media_url = '';
|
||||
allVariables.header.media_type = headerComponent.format.toLowerCase();
|
||||
}
|
||||
|
||||
// Process button variables
|
||||
const buttonComponents = template.components.filter(
|
||||
component => component.type === COMPONENT_TYPES.BUTTONS
|
||||
);
|
||||
|
||||
buttonComponents.forEach(buttonComponent => {
|
||||
if (buttonComponent.buttons) {
|
||||
buttonComponent.buttons.forEach((button, index) => {
|
||||
// Handle URL buttons with variables
|
||||
if (button.type === 'URL' && button.url && button.url.includes('{{')) {
|
||||
const buttonVars = button.url.match(/{{([^}]+)}}/g) || [];
|
||||
if (buttonVars.length > 0) {
|
||||
if (!allVariables.buttons) allVariables.buttons = [];
|
||||
allVariables.buttons[index] = {
|
||||
type: 'url',
|
||||
parameter: '',
|
||||
url: button.url,
|
||||
variables: buttonVars.map(v => processVariable(v)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle copy code buttons
|
||||
if (button.type === 'COPY_CODE') {
|
||||
if (!allVariables.buttons) allVariables.buttons = [];
|
||||
allVariables.buttons[index] = {
|
||||
type: 'copy_code',
|
||||
parameter: '',
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return allVariables;
|
||||
};
|
||||
@@ -131,6 +131,7 @@
|
||||
"CONVERSATION_CREATED": "Conversation Created",
|
||||
"CONVERSATION_UPDATED": "Conversation Updated",
|
||||
"MESSAGE_CREATED": "Message Created",
|
||||
"CONVERSATION_RESOLVED": "Conversation Resolved",
|
||||
"CONVERSATION_OPENED": "Conversation Opened"
|
||||
},
|
||||
"ACTIONS": {
|
||||
|
||||
@@ -1,29 +1,46 @@
|
||||
{
|
||||
"WHATSAPP_TEMPLATES": {
|
||||
"MODAL": {
|
||||
"TITLE": "Whatsapp Templates",
|
||||
"SUBTITLE": "Select the whatsapp template you want to send",
|
||||
"TEMPLATE_SELECTED_SUBTITLE": "Process {templateName}"
|
||||
},
|
||||
"PICKER": {
|
||||
"SEARCH_PLACEHOLDER": "Search Templates",
|
||||
"NO_TEMPLATES_FOUND": "No templates found for",
|
||||
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
|
||||
"REFRESH_BUTTON": "Refresh templates",
|
||||
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
|
||||
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
|
||||
"LABELS": {
|
||||
"LANGUAGE": "Language",
|
||||
"TEMPLATE_BODY": "Template Body",
|
||||
"CATEGORY": "Category"
|
||||
}
|
||||
},
|
||||
"PARSER": {
|
||||
"VARIABLES_LABEL": "Variables",
|
||||
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
|
||||
"GO_BACK_LABEL": "Go Back",
|
||||
"SEND_MESSAGE_LABEL": "Send Message",
|
||||
"FORM_ERROR_MESSAGE": "Please fill all variables before sending"
|
||||
}
|
||||
"WHATSAPP_TEMPLATES": {
|
||||
"MODAL": {
|
||||
"TITLE": "Whatsapp Templates",
|
||||
"SUBTITLE": "Select the whatsapp template you want to send",
|
||||
"TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
|
||||
},
|
||||
"PICKER": {
|
||||
"SEARCH_PLACEHOLDER": "Search Templates",
|
||||
"NO_TEMPLATES_FOUND": "No templates found for",
|
||||
"HEADER": "Header",
|
||||
"BODY": "Body",
|
||||
"FOOTER": "Footer",
|
||||
"BUTTONS": "Buttons",
|
||||
"CATEGORY": "Category",
|
||||
"MEDIA_CONTENT": "Media Content",
|
||||
"MEDIA_CONTENT_FALLBACK": "media content",
|
||||
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
|
||||
"REFRESH_BUTTON": "Refresh templates",
|
||||
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
|
||||
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
|
||||
"LABELS": {
|
||||
"LANGUAGE": "Language",
|
||||
"TEMPLATE_BODY": "Template Body",
|
||||
"CATEGORY": "Category"
|
||||
}
|
||||
},
|
||||
"PARSER": {
|
||||
"VARIABLES_LABEL": "Variables",
|
||||
"LANGUAGE": "Language",
|
||||
"CATEGORY": "Category",
|
||||
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
|
||||
"GO_BACK_LABEL": "Go Back",
|
||||
"SEND_MESSAGE_LABEL": "Send Message",
|
||||
"FORM_ERROR_MESSAGE": "Please fill all variables before sending",
|
||||
"MEDIA_HEADER_LABEL": "{type} Header",
|
||||
"OTP_CODE": "Enter 4-8 digit OTP",
|
||||
"EXPIRY_MINUTES": "Enter expiry minutes",
|
||||
"BUTTON_PARAMETERS": "Button Parameters",
|
||||
"BUTTON_LABEL": "Button {index}",
|
||||
"COUPON_CODE": "Enter coupon code (max 15 chars)",
|
||||
"MEDIA_URL_LABEL": "Enter {type} URL",
|
||||
"BUTTON_PARAMETER": "Enter button parameter"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,6 +468,106 @@ export const AUTOMATIONS = {
|
||||
},
|
||||
],
|
||||
},
|
||||
conversation_resolved: {
|
||||
conditions: [
|
||||
{
|
||||
key: 'browser_language',
|
||||
name: 'BROWSER_LANGUAGE',
|
||||
inputType: 'search_select',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
name: 'EMAIL',
|
||||
inputType: 'plain_text',
|
||||
filterOperators: OPERATOR_TYPES_2,
|
||||
},
|
||||
{
|
||||
key: 'mail_subject',
|
||||
name: 'MAIL_SUBJECT',
|
||||
inputType: 'plain_text',
|
||||
filterOperators: OPERATOR_TYPES_2,
|
||||
},
|
||||
{
|
||||
key: 'country_code',
|
||||
name: 'COUNTRY_NAME',
|
||||
inputType: 'search_select',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
},
|
||||
{
|
||||
key: 'referer',
|
||||
name: 'REFERER_LINK',
|
||||
inputType: 'plain_text',
|
||||
filterOperators: OPERATOR_TYPES_2,
|
||||
},
|
||||
{
|
||||
key: 'assignee_id',
|
||||
name: 'ASSIGNEE_NAME',
|
||||
inputType: 'search_select',
|
||||
filterOperators: OPERATOR_TYPES_3,
|
||||
},
|
||||
{
|
||||
key: 'phone_number',
|
||||
name: 'PHONE_NUMBER',
|
||||
inputType: 'plain_text',
|
||||
filterOperators: OPERATOR_TYPES_6,
|
||||
},
|
||||
{
|
||||
key: 'team_id',
|
||||
name: 'TEAM_NAME',
|
||||
inputType: 'search_select',
|
||||
filterOperators: OPERATOR_TYPES_3,
|
||||
},
|
||||
{
|
||||
key: 'inbox_id',
|
||||
name: 'INBOX',
|
||||
inputType: 'multi_select',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
},
|
||||
{
|
||||
key: 'conversation_language',
|
||||
name: 'CONVERSATION_LANGUAGE',
|
||||
inputType: 'multi_select',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
},
|
||||
{
|
||||
key: 'priority',
|
||||
name: 'PRIORITY',
|
||||
inputType: 'multi_select',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
key: 'assign_agent',
|
||||
name: 'ASSIGN_AGENT',
|
||||
},
|
||||
{
|
||||
key: 'assign_team',
|
||||
name: 'ASSIGN_TEAM',
|
||||
},
|
||||
{
|
||||
key: 'send_email_to_team',
|
||||
name: 'SEND_EMAIL_TO_TEAM',
|
||||
},
|
||||
{
|
||||
key: 'send_message',
|
||||
name: 'SEND_MESSAGE',
|
||||
},
|
||||
{
|
||||
key: 'send_email_transcript',
|
||||
name: 'SEND_EMAIL_TRANSCRIPT',
|
||||
},
|
||||
{
|
||||
key: 'send_webhook_event',
|
||||
name: 'SEND_WEBHOOK_EVENT',
|
||||
},
|
||||
{
|
||||
key: 'send_attachment',
|
||||
name: 'SEND_ATTACHMENT',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const AUTOMATION_RULE_EVENTS = [
|
||||
@@ -479,6 +579,10 @@ export const AUTOMATION_RULE_EVENTS = [
|
||||
key: 'conversation_updated',
|
||||
value: 'CONVERSATION_UPDATED',
|
||||
},
|
||||
{
|
||||
key: 'conversation_resolved',
|
||||
value: 'CONVERSATION_RESOLVED',
|
||||
},
|
||||
{
|
||||
key: 'message_created',
|
||||
value: 'MESSAGE_CREATED',
|
||||
|
||||
@@ -44,15 +44,52 @@ export const getters = {
|
||||
const messagesTemplates =
|
||||
whatsAppMessageTemplates || apiInboxMessageTemplates;
|
||||
|
||||
// filtering out the whatsapp templates with media
|
||||
if (messagesTemplates instanceof Array) {
|
||||
return messagesTemplates.filter(template => {
|
||||
return !template.components.some(
|
||||
i => i.format === 'IMAGE' || i.format === 'VIDEO'
|
||||
);
|
||||
});
|
||||
return messagesTemplates;
|
||||
},
|
||||
getFilteredWhatsAppTemplates: $state => inboxId => {
|
||||
const [inbox] = $state.records.filter(
|
||||
record => record.id === Number(inboxId)
|
||||
);
|
||||
|
||||
const {
|
||||
message_templates: whatsAppMessageTemplates,
|
||||
additional_attributes: additionalAttributes,
|
||||
} = inbox || {};
|
||||
|
||||
const { message_templates: apiInboxMessageTemplates } =
|
||||
additionalAttributes || {};
|
||||
const templates = whatsAppMessageTemplates || apiInboxMessageTemplates;
|
||||
|
||||
if (!templates || !Array.isArray(templates)) {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
|
||||
return templates.filter(template => {
|
||||
// Ensure template has required properties
|
||||
if (!template || !template.status || !template.components) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only show approved templates
|
||||
if (template.status.toLowerCase() !== 'approved') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
|
||||
const hasUnsupportedComponents = template.components.some(
|
||||
component =>
|
||||
['LIST', 'PRODUCT', 'CATALOG', 'CALL_PERMISSION_REQUEST'].includes(
|
||||
component.type
|
||||
) ||
|
||||
(component.type === 'HEADER' && component.format === 'LOCATION')
|
||||
);
|
||||
|
||||
if (hasUnsupportedComponents) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
},
|
||||
getNewConversationInboxes($state) {
|
||||
return $state.records.filter(inbox => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getters } from '../../inboxes';
|
||||
import inboxList from './fixtures';
|
||||
import { templates } from './templateFixtures';
|
||||
|
||||
describe('#getters', () => {
|
||||
it('getInboxes', () => {
|
||||
@@ -93,4 +94,269 @@ describe('#getters', () => {
|
||||
provider: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFilteredWhatsAppTemplates', () => {
|
||||
it('returns empty array when inbox not found', () => {
|
||||
const state = { records: [] };
|
||||
expect(getters.getFilteredWhatsAppTemplates(state)(999)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when templates is null or undefined', () => {
|
||||
const state = {
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
message_templates: null,
|
||||
additional_attributes: { message_templates: undefined },
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(getters.getFilteredWhatsAppTemplates(state)(1)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when templates is not an array', () => {
|
||||
const state = {
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
message_templates: 'invalid',
|
||||
additional_attributes: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(getters.getFilteredWhatsAppTemplates(state)(1)).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters out templates without required properties', () => {
|
||||
const invalidTemplates = [
|
||||
{ name: 'incomplete_template' }, // missing status and components
|
||||
{ status: 'approved' }, // missing name and components
|
||||
{ name: 'another_incomplete', status: 'approved' }, // missing components
|
||||
];
|
||||
|
||||
const state = {
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
message_templates: invalidTemplates,
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(getters.getFilteredWhatsAppTemplates(state)(1)).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters out non-approved templates', () => {
|
||||
const mixedStatusTemplates = [
|
||||
{
|
||||
name: 'pending_template',
|
||||
status: 'pending',
|
||||
components: [{ type: 'BODY', text: 'Test' }],
|
||||
},
|
||||
{
|
||||
name: 'rejected_template',
|
||||
status: 'rejected',
|
||||
components: [{ type: 'BODY', text: 'Test' }],
|
||||
},
|
||||
{
|
||||
name: 'approved_template',
|
||||
status: 'approved',
|
||||
components: [{ type: 'BODY', text: 'Test' }],
|
||||
},
|
||||
];
|
||||
|
||||
const state = {
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
message_templates: mixedStatusTemplates,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getters.getFilteredWhatsAppTemplates(state)(1);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('approved_template');
|
||||
});
|
||||
|
||||
it('filters out interactive templates (LIST, PRODUCT, CATALOG)', () => {
|
||||
const interactiveTemplates = [
|
||||
{
|
||||
name: 'list_template',
|
||||
status: 'approved',
|
||||
components: [
|
||||
{ type: 'BODY', text: 'Choose an option' },
|
||||
{ type: 'LIST', sections: [] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'product_template',
|
||||
status: 'approved',
|
||||
components: [
|
||||
{ type: 'BODY', text: 'Product info' },
|
||||
{ type: 'PRODUCT', catalog_id: '123' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'catalog_template',
|
||||
status: 'approved',
|
||||
components: [
|
||||
{ type: 'BODY', text: 'Catalog' },
|
||||
{ type: 'CATALOG', thumbnail_product_retailer_id: '123' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'regular_template',
|
||||
status: 'approved',
|
||||
components: [{ type: 'BODY', text: 'Regular message' }],
|
||||
},
|
||||
];
|
||||
|
||||
const state = {
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
message_templates: interactiveTemplates,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getters.getFilteredWhatsAppTemplates(state)(1);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('regular_template');
|
||||
});
|
||||
|
||||
it('filters out location templates', () => {
|
||||
const locationTemplates = [
|
||||
{
|
||||
name: 'location_template',
|
||||
status: 'approved',
|
||||
components: [
|
||||
{ type: 'HEADER', format: 'LOCATION' },
|
||||
{ type: 'BODY', text: 'Location message' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'regular_template',
|
||||
status: 'approved',
|
||||
components: [
|
||||
{ type: 'HEADER', format: 'TEXT', text: 'Header' },
|
||||
{ type: 'BODY', text: 'Regular message' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const state = {
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
message_templates: locationTemplates,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getters.getFilteredWhatsAppTemplates(state)(1);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('regular_template');
|
||||
});
|
||||
|
||||
it('returns valid templates from fixture data', () => {
|
||||
const state = {
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
message_templates: templates,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getters.getFilteredWhatsAppTemplates(state)(1);
|
||||
|
||||
// All templates in fixtures should be approved and valid
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify all returned templates are approved
|
||||
result.forEach(template => {
|
||||
expect(template.status).toBe('approved');
|
||||
expect(template.components).toBeDefined();
|
||||
expect(Array.isArray(template.components)).toBe(true);
|
||||
});
|
||||
|
||||
// Verify specific templates from fixtures are included
|
||||
const templateNames = result.map(t => t.name);
|
||||
expect(templateNames).toContain('sample_flight_confirmation');
|
||||
expect(templateNames).toContain('sample_issue_resolution');
|
||||
expect(templateNames).toContain('sample_shipping_confirmation');
|
||||
expect(templateNames).toContain('no_variable_template');
|
||||
expect(templateNames).toContain('order_confirmation');
|
||||
});
|
||||
|
||||
it('prioritizes message_templates over additional_attributes.message_templates', () => {
|
||||
const primaryTemplates = [
|
||||
{
|
||||
name: 'primary_template',
|
||||
status: 'approved',
|
||||
components: [{ type: 'BODY', text: 'Primary' }],
|
||||
},
|
||||
];
|
||||
|
||||
const fallbackTemplates = [
|
||||
{
|
||||
name: 'fallback_template',
|
||||
status: 'approved',
|
||||
components: [{ type: 'BODY', text: 'Fallback' }],
|
||||
},
|
||||
];
|
||||
|
||||
const state = {
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
message_templates: primaryTemplates,
|
||||
additional_attributes: {
|
||||
message_templates: fallbackTemplates,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getters.getFilteredWhatsAppTemplates(state)(1);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('primary_template');
|
||||
});
|
||||
|
||||
it('falls back to additional_attributes.message_templates when message_templates is null', () => {
|
||||
const fallbackTemplates = [
|
||||
{
|
||||
name: 'fallback_template',
|
||||
status: 'approved',
|
||||
components: [{ type: 'BODY', text: 'Fallback' }],
|
||||
},
|
||||
];
|
||||
|
||||
const state = {
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
message_templates: null,
|
||||
additional_attributes: {
|
||||
message_templates: fallbackTemplates,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getters.getFilteredWhatsAppTemplates(state)(1);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('fallback_template');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+281
@@ -260,4 +260,285 @@ export const templates = [
|
||||
],
|
||||
rejected_reason: 'NONE',
|
||||
},
|
||||
{
|
||||
name: 'order_confirmation',
|
||||
status: 'approved',
|
||||
category: 'TICKET_UPDATE',
|
||||
language: 'en_US',
|
||||
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'IMAGE',
|
||||
example: {
|
||||
header_handle: ['https://example.com/shoes.jpg'],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Hi your order {{1}} is confirmed. Please wait for further updates',
|
||||
type: 'BODY',
|
||||
},
|
||||
],
|
||||
rejected_reason: 'NONE',
|
||||
},
|
||||
{
|
||||
name: 'technician_visit',
|
||||
status: 'approved',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||
components: [
|
||||
{
|
||||
text: 'Technician visit',
|
||||
type: 'HEADER',
|
||||
format: 'TEXT',
|
||||
},
|
||||
{
|
||||
text: "Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.",
|
||||
type: 'BODY',
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Confirm',
|
||||
type: 'QUICK_REPLY',
|
||||
},
|
||||
{
|
||||
text: 'Reschedule',
|
||||
type: 'QUICK_REPLY',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
rejected_reason: 'NONE',
|
||||
},
|
||||
{
|
||||
name: 'event_invitation_static',
|
||||
status: 'approved',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||
components: [
|
||||
{
|
||||
text: "You're invited to {{event_name}} at {{location}}, Join us for an amazing experience!",
|
||||
type: 'BODY',
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
url: 'https://events.example.com/register',
|
||||
text: 'Visit website',
|
||||
type: 'URL',
|
||||
},
|
||||
{
|
||||
url: 'https://maps.app.goo.gl/YoWAzRj1GDuxs6qz8',
|
||||
text: 'Get Directions',
|
||||
type: 'URL',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
rejected_reason: 'NONE',
|
||||
},
|
||||
{
|
||||
name: 'purchase_receipt',
|
||||
status: 'approved',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'DOCUMENT',
|
||||
},
|
||||
{
|
||||
text: 'Thank you for using your {{1}} card at {{2}}. Your {{3}} is attached as a PDF.',
|
||||
type: 'BODY',
|
||||
},
|
||||
],
|
||||
rejected_reason: 'NONE',
|
||||
},
|
||||
{
|
||||
name: 'discount_coupon',
|
||||
status: 'approved',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||
components: [
|
||||
{
|
||||
text: '🎉 Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
|
||||
type: 'BODY',
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Copy offer code',
|
||||
type: 'COPY_CODE',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
rejected_reason: 'NONE',
|
||||
},
|
||||
{
|
||||
name: 'support_callback',
|
||||
status: 'approved',
|
||||
category: 'UTILITY',
|
||||
language: 'en',
|
||||
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||
components: [
|
||||
{
|
||||
text: 'Hello {{name}}, our support team will call you regarding ticket # {{ticket_id}}.',
|
||||
type: 'BODY',
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Call Support',
|
||||
type: 'PHONE_NUMBER',
|
||||
phone_number: '+16506677566',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
rejected_reason: 'NONE',
|
||||
},
|
||||
{
|
||||
name: 'training_video',
|
||||
status: 'approved',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'VIDEO',
|
||||
},
|
||||
{
|
||||
text: "Hi {{name}}, here's your training video. Please watch by{{date}}.",
|
||||
type: 'BODY',
|
||||
},
|
||||
],
|
||||
rejected_reason: 'NONE',
|
||||
},
|
||||
{
|
||||
name: 'product_launch',
|
||||
status: 'approved',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'IMAGE',
|
||||
},
|
||||
{
|
||||
text: 'New arrival! Our stunning coat now available in {{color}} color.',
|
||||
type: 'BODY',
|
||||
},
|
||||
{
|
||||
text: 'Free shipping on orders over $100. Limited time offer.',
|
||||
type: 'FOOTER',
|
||||
},
|
||||
],
|
||||
rejected_reason: 'NONE',
|
||||
},
|
||||
{
|
||||
name: 'greet',
|
||||
status: 'approved',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||
components: [
|
||||
{
|
||||
text: 'Hey {{customer_name}} how may I help you?',
|
||||
type: 'BODY',
|
||||
},
|
||||
],
|
||||
rejected_reason: 'NONE',
|
||||
},
|
||||
{
|
||||
name: 'hello_world',
|
||||
status: 'approved',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||
components: [
|
||||
{
|
||||
text: 'Hello World',
|
||||
type: 'HEADER',
|
||||
format: 'TEXT',
|
||||
},
|
||||
{
|
||||
text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
|
||||
type: 'BODY',
|
||||
},
|
||||
{
|
||||
text: 'WhatsApp Business Platform sample message',
|
||||
type: 'FOOTER',
|
||||
},
|
||||
],
|
||||
rejected_reason: 'NONE',
|
||||
},
|
||||
{
|
||||
name: 'feedback_request',
|
||||
status: 'approved',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||
components: [
|
||||
{
|
||||
text: "Hey {{name}}, how was your experience with Puma? We'd love your feedback!",
|
||||
type: 'BODY',
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
url: 'https://feedback.example.com/survey',
|
||||
text: 'Leave Feedback',
|
||||
type: 'URL',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
rejected_reason: 'NONE',
|
||||
},
|
||||
{
|
||||
name: 'address_update',
|
||||
status: 'approved',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||
components: [
|
||||
{
|
||||
text: 'Address update',
|
||||
type: 'HEADER',
|
||||
format: 'TEXT',
|
||||
},
|
||||
{
|
||||
text: 'Hi {{1}}, your delivery address has been successfully updated to {{2}}. Contact {{3}} for any inquiries.',
|
||||
type: 'BODY',
|
||||
},
|
||||
],
|
||||
rejected_reason: 'NONE',
|
||||
},
|
||||
{
|
||||
name: 'delivery_confirmation',
|
||||
status: 'approved',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||
components: [
|
||||
{
|
||||
text: '{{1}}, your order was successfully delivered on {{2}}.\n\nThank you for your purchase.\n',
|
||||
type: 'BODY',
|
||||
},
|
||||
],
|
||||
rejected_reason: 'NONE',
|
||||
},
|
||||
];
|
||||
@@ -1,61 +0,0 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import TemplateParser from '../../../../dashboard/components/widgets/conversation/WhatsappTemplates/TemplateParser.vue';
|
||||
import { templates } from './fixtures';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
const config = {
|
||||
global: {
|
||||
stubs: {
|
||||
NextButton: { template: '<button />' },
|
||||
WootInput: { template: '<input />' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('#WhatsAppTemplates', () => {
|
||||
it('returns all variables from a template string', async () => {
|
||||
const wrapper = shallowMount(TemplateParser, {
|
||||
...config,
|
||||
props: { template: templates[0] },
|
||||
});
|
||||
await nextTick();
|
||||
expect(wrapper.vm.variables).toEqual(['{{1}}', '{{2}}', '{{3}}']);
|
||||
});
|
||||
|
||||
it('returns no variables from a template string if it does not contain variables', async () => {
|
||||
const wrapper = shallowMount(TemplateParser, {
|
||||
...config,
|
||||
props: { template: templates[12] },
|
||||
});
|
||||
await nextTick();
|
||||
expect(wrapper.vm.variables).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the body of a template', async () => {
|
||||
const wrapper = shallowMount(TemplateParser, {
|
||||
...config,
|
||||
props: { template: templates[1] },
|
||||
});
|
||||
await nextTick();
|
||||
const expectedOutput =
|
||||
templates[1].components.find(i => i.type === 'BODY')?.text || '';
|
||||
expect(wrapper.vm.templateString).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it('generates the templates from variable input', async () => {
|
||||
const wrapper = shallowMount(TemplateParser, {
|
||||
...config,
|
||||
props: { template: templates[0] },
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
// Instead of using `setData`, directly modify the `processedParams` using the component's logic
|
||||
await wrapper.vm.$nextTick();
|
||||
wrapper.vm.processedParams = { 1: 'abc', 2: 'xyz', 3: 'qwerty' };
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const expectedOutput =
|
||||
'Esta é a sua confirmação de voo para abc-xyz em qwerty.';
|
||||
expect(wrapper.vm.processedString).toEqual(expectedOutput);
|
||||
});
|
||||
});
|
||||
@@ -1,53 +1,18 @@
|
||||
class AutomationRuleListener < BaseListener
|
||||
def conversation_updated(event)
|
||||
return if performed_by_automation?(event)
|
||||
|
||||
conversation = event.data[:conversation]
|
||||
account = conversation.account
|
||||
changed_attributes = event.data[:changed_attributes]
|
||||
|
||||
return unless rule_present?('conversation_updated', account)
|
||||
|
||||
rules = current_account_rules('conversation_updated', account)
|
||||
|
||||
rules.each do |rule|
|
||||
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
|
||||
AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
|
||||
end
|
||||
process_conversation_event(event, 'conversation_updated')
|
||||
end
|
||||
|
||||
def conversation_created(event)
|
||||
return if performed_by_automation?(event) || ignore_auto_reply_event?(event)
|
||||
|
||||
conversation = event.data[:conversation]
|
||||
account = conversation.account
|
||||
changed_attributes = event.data[:changed_attributes]
|
||||
|
||||
return unless rule_present?('conversation_created', account)
|
||||
|
||||
rules = current_account_rules('conversation_created', account)
|
||||
|
||||
rules.each do |rule|
|
||||
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
|
||||
::AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
|
||||
end
|
||||
process_conversation_event(event, 'conversation_created')
|
||||
end
|
||||
|
||||
def conversation_opened(event)
|
||||
return if performed_by_automation?(event) || ignore_auto_reply_event?(event)
|
||||
process_conversation_event(event, 'conversation_opened')
|
||||
end
|
||||
|
||||
conversation = event.data[:conversation]
|
||||
account = conversation.account
|
||||
changed_attributes = event.data[:changed_attributes]
|
||||
|
||||
return unless rule_present?('conversation_opened', account)
|
||||
|
||||
rules = current_account_rules('conversation_opened', account)
|
||||
|
||||
rules.each do |rule|
|
||||
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
|
||||
AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
|
||||
end
|
||||
def conversation_resolved(event)
|
||||
process_conversation_event(event, 'conversation_resolved')
|
||||
end
|
||||
|
||||
def message_created(event)
|
||||
@@ -69,6 +34,28 @@ class AutomationRuleListener < BaseListener
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_conversation_event(event, event_name)
|
||||
return if performed_by_automation?(event)
|
||||
|
||||
auto_reply_skip_events = %w[conversation_created conversation_opened]
|
||||
return if auto_reply_skip_events.include?(event_name) && ignore_auto_reply_event?(event)
|
||||
|
||||
conversation = event.data[:conversation]
|
||||
account = conversation.account
|
||||
changed_attributes = event.data[:changed_attributes]
|
||||
|
||||
return unless rule_present?(event_name, account)
|
||||
|
||||
rules = current_account_rules(event_name, account)
|
||||
|
||||
rules.each do |rule|
|
||||
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
|
||||
AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
|
||||
end
|
||||
end
|
||||
|
||||
def rule_present?(event_name, account)
|
||||
return if account.blank?
|
||||
|
||||
|
||||
+17
-15
@@ -2,24 +2,26 @@
|
||||
#
|
||||
# Table name: account_users
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# active_at :datetime
|
||||
# auto_offline :boolean default(TRUE), not null
|
||||
# availability :integer default("online"), not null
|
||||
# role :integer default("agent")
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint
|
||||
# custom_role_id :bigint
|
||||
# inviter_id :bigint
|
||||
# user_id :bigint
|
||||
# id :bigint not null, primary key
|
||||
# active_at :datetime
|
||||
# auto_offline :boolean default(TRUE), not null
|
||||
# availability :integer default("online"), not null
|
||||
# role :integer default("agent")
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint
|
||||
# agent_capacity_policy_id :bigint
|
||||
# custom_role_id :bigint
|
||||
# inviter_id :bigint
|
||||
# user_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_account_users_on_account_id (account_id)
|
||||
# index_account_users_on_custom_role_id (custom_role_id)
|
||||
# index_account_users_on_user_id (user_id)
|
||||
# uniq_user_id_per_account_id (account_id,user_id) UNIQUE
|
||||
# index_account_users_on_account_id (account_id)
|
||||
# index_account_users_on_agent_capacity_policy_id (agent_capacity_policy_id)
|
||||
# index_account_users_on_custom_role_id (custom_role_id)
|
||||
# index_account_users_on_user_id (user_id)
|
||||
# uniq_user_id_per_account_id (account_id,user_id) UNIQUE
|
||||
#
|
||||
|
||||
class AccountUser < ApplicationRecord
|
||||
|
||||
@@ -62,7 +62,12 @@ class Attachment < ApplicationRecord
|
||||
def thumb_url
|
||||
return '' unless file.attached? && image?
|
||||
|
||||
url_for(file.representation(resize_to_fill: [250, nil]))
|
||||
begin
|
||||
url_for(file.representation(resize_to_fill: [250, nil]))
|
||||
rescue ActiveStorage::UnrepresentableError => e
|
||||
Rails.logger.warn "Unrepresentable image attachment: #{id} (#{file.filename}) - #{e.message}"
|
||||
''
|
||||
end
|
||||
end
|
||||
|
||||
def with_attached_file?
|
||||
|
||||
@@ -68,14 +68,23 @@ class Notification::PushNotificationService
|
||||
|
||||
WebPush.payload_send(**browser_push_payload(subscription))
|
||||
Rails.logger.info("Browser push sent to #{user.email} with title #{push_message[:title]}")
|
||||
rescue WebPush::ExpiredSubscription, WebPush::InvalidSubscription, WebPush::Unauthorized => e
|
||||
Rails.logger.info "WebPush subscription expired: #{e.message}"
|
||||
subscription.destroy!
|
||||
rescue Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout => e
|
||||
Rails.logger.error "WebPush operation error: #{e.message}"
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: notification.account).capture_exception
|
||||
true
|
||||
handle_browser_push_error(e, subscription)
|
||||
end
|
||||
|
||||
def handle_browser_push_error(error, subscription)
|
||||
case error
|
||||
when WebPush::ExpiredSubscription, WebPush::InvalidSubscription, WebPush::Unauthorized
|
||||
Rails.logger.info "WebPush subscription expired: #{error.message}"
|
||||
subscription.destroy!
|
||||
when WebPush::TooManyRequests
|
||||
Rails.logger.warn "WebPush rate limited for #{user.email} on account #{notification.account.id}: #{error.message}"
|
||||
when Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout
|
||||
Rails.logger.error "WebPush operation error: #{error.message}"
|
||||
else
|
||||
ChatwootExceptionTracker.new(error, account: notification.account).capture_exception
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def send_fcm_push(subscription)
|
||||
|
||||
@@ -92,6 +92,9 @@ class Whatsapp::IncomingMessageBaseService
|
||||
|
||||
@contact_inbox = contact_inbox
|
||||
@contact = contact_inbox.contact
|
||||
|
||||
# Update existing contact name if ProfileName is available and current name is just phone number
|
||||
update_contact_with_profile_name(contact_params)
|
||||
end
|
||||
|
||||
def set_conversation
|
||||
@@ -171,4 +174,21 @@ class Whatsapp::IncomingMessageBaseService
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def update_contact_with_profile_name(contact_params)
|
||||
profile_name = contact_params.dig(:profile, :name)
|
||||
return if profile_name.blank?
|
||||
return if @contact.name == profile_name
|
||||
|
||||
# Only update if current name exactly matches the phone number or formatted phone number
|
||||
return unless contact_name_matches_phone_number?
|
||||
|
||||
@contact.update!(name: profile_name)
|
||||
end
|
||||
|
||||
def contact_name_matches_phone_number?
|
||||
phone_number = "+#{@processed_params[:messages].first[:from]}"
|
||||
formatted_phone_number = TelephoneNumber.parse(phone_number).international_number
|
||||
@contact.name == phone_number || @contact.name == formatted_phone_number
|
||||
end
|
||||
end
|
||||
|
||||
@@ -84,7 +84,7 @@ class Whatsapp::OneoffCampaignService
|
||||
namespace: namespace,
|
||||
lang_code: lang_code,
|
||||
parameters: processed_parameters
|
||||
})
|
||||
}, nil)
|
||||
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to send WhatsApp template message to #{to}: #{e.message}"
|
||||
|
||||
@@ -15,7 +15,7 @@ class Whatsapp::Providers::BaseService
|
||||
raise 'Overwrite this method in child class'
|
||||
end
|
||||
|
||||
def send_template(_phone_number, _template_info)
|
||||
def send_template(_phone_number, _template_info, _message)
|
||||
raise 'Overwrite this method in child class'
|
||||
end
|
||||
|
||||
@@ -31,27 +31,27 @@ class Whatsapp::Providers::BaseService
|
||||
raise 'Overwrite this method in child class'
|
||||
end
|
||||
|
||||
def process_response(response)
|
||||
def process_response(response, message)
|
||||
parsed_response = response.parsed_response
|
||||
if response.success? && parsed_response['error'].blank?
|
||||
parsed_response['messages'].first['id']
|
||||
else
|
||||
handle_error(response)
|
||||
handle_error(response, message)
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def handle_error(response)
|
||||
def handle_error(response, message)
|
||||
Rails.logger.error response.body
|
||||
return if @message.blank?
|
||||
return if message.blank?
|
||||
|
||||
# https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/#sample-response
|
||||
error_message = error_message(response)
|
||||
return if error_message.blank?
|
||||
|
||||
@message.external_error = error_message
|
||||
@message.status = :failed
|
||||
@message.save!
|
||||
message.external_error = error_message
|
||||
message.status = :failed
|
||||
message.save!
|
||||
end
|
||||
|
||||
def create_buttons(items)
|
||||
|
||||
@@ -10,7 +10,7 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
|
||||
end
|
||||
end
|
||||
|
||||
def send_template(phone_number, template_info)
|
||||
def send_template(phone_number, template_info, message)
|
||||
response = HTTParty.post(
|
||||
"#{api_base_path}/messages",
|
||||
headers: api_headers,
|
||||
@@ -21,7 +21,7 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
|
||||
}.to_json
|
||||
)
|
||||
|
||||
process_response(response)
|
||||
process_response(response, message)
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
@@ -68,7 +68,7 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
|
||||
}.to_json
|
||||
)
|
||||
|
||||
process_response(response)
|
||||
process_response(response, message)
|
||||
end
|
||||
|
||||
def send_attachment_message(phone_number, message)
|
||||
@@ -90,7 +90,7 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
|
||||
}.to_json
|
||||
)
|
||||
|
||||
process_response(response)
|
||||
process_response(response, message)
|
||||
end
|
||||
|
||||
def error_message(response)
|
||||
@@ -123,6 +123,6 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
|
||||
}.to_json
|
||||
)
|
||||
|
||||
process_response(response)
|
||||
process_response(response, message)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,7 +11,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
end
|
||||
end
|
||||
|
||||
def send_template(phone_number, template_info)
|
||||
def send_template(phone_number, template_info, message)
|
||||
template_body = template_body_parameters(template_info)
|
||||
|
||||
request_body = {
|
||||
@@ -28,7 +28,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
body: request_body.to_json
|
||||
)
|
||||
|
||||
process_response(response)
|
||||
process_response(response, message)
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
@@ -92,7 +92,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
}.to_json
|
||||
)
|
||||
|
||||
process_response(response)
|
||||
process_response(response, message)
|
||||
end
|
||||
|
||||
def send_attachment_message(phone_number, message)
|
||||
@@ -115,7 +115,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
}.to_json
|
||||
)
|
||||
|
||||
process_response(response)
|
||||
process_response(response, message)
|
||||
end
|
||||
|
||||
def error_message(response)
|
||||
@@ -179,6 +179,6 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
}.to_json
|
||||
)
|
||||
|
||||
process_response(response)
|
||||
process_response(response, message)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,7 +33,7 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
|
||||
namespace: namespace,
|
||||
lang_code: lang_code,
|
||||
parameters: processed_parameters
|
||||
})
|
||||
}, message)
|
||||
message.update!(source_id: message_id) if message_id.present?
|
||||
end
|
||||
|
||||
|
||||
@@ -86,6 +86,9 @@ class Whatsapp::TemplateParameterConverterService
|
||||
# Hash format: {"1": "John", "name": "Jane"} → {body: {"1": "John", "name": "Jane"}}
|
||||
body_params = convert_hash_to_body_params(legacy_params)
|
||||
enhanced['body'] = body_params unless body_params.empty?
|
||||
when NilClass
|
||||
# Templates without parameters (nil processed_params)
|
||||
# Return empty enhanced structure
|
||||
else
|
||||
raise ArgumentError, "Unknown legacy format: #{legacy_params.class}"
|
||||
end
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateAssignmentPolicies < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :assignment_policies do |t|
|
||||
t.references :account, null: false, index: true
|
||||
t.string :name, null: false, limit: 255
|
||||
t.text :description
|
||||
t.integer :assignment_order, null: false, default: 0 # 0: round_robin, 1: balanced
|
||||
t.integer :conversation_priority, null: false, default: 0 # 0: earliest_created, 1: longest_waiting
|
||||
t.integer :fair_distribution_limit, null: false, default: 100
|
||||
t.integer :fair_distribution_window, null: false, default: 3600 # seconds
|
||||
t.boolean :enabled, null: false, default: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :assignment_policies, [:account_id, :name], unique: true
|
||||
add_index :assignment_policies, :enabled
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateInboxAssignmentPolicies < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :inbox_assignment_policies do |t|
|
||||
t.references :inbox, null: false, index: { unique: true }
|
||||
t.references :assignment_policy, null: false, index: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateAgentCapacityPolicies < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :agent_capacity_policies do |t|
|
||||
t.references :account, null: false, index: true
|
||||
t.string :name, null: false, limit: 255
|
||||
t.text :description
|
||||
t.jsonb :exclusion_rules, default: {}, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateInboxCapacityLimits < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :inbox_capacity_limits do |t|
|
||||
t.references :agent_capacity_policy, null: false, index: true
|
||||
t.references :inbox, null: false, index: true
|
||||
t.integer :conversation_limit, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :inbox_capacity_limits, [:agent_capacity_policy_id, :inbox_id], unique: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddAgentCapacityPolicyToAccountUsers < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_reference :account_users, :agent_capacity_policy, null: true, index: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateLeaves < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :leaves do |t|
|
||||
t.references :account, null: false
|
||||
t.references :user, null: false
|
||||
t.date :start_date, null: false
|
||||
t.date :end_date, null: false
|
||||
t.integer :leave_type, null: false, default: 0
|
||||
t.integer :status, null: false, default: 0
|
||||
t.text :reason
|
||||
t.references :approved_by
|
||||
t.datetime :approved_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :leaves, [:account_id, :status]
|
||||
end
|
||||
end
|
||||
@@ -39,8 +39,10 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do
|
||||
t.integer "availability", default: 0, null: false
|
||||
t.boolean "auto_offline", default: true, null: false
|
||||
t.bigint "custom_role_id"
|
||||
t.bigint "agent_capacity_policy_id"
|
||||
t.index ["account_id", "user_id"], name: "uniq_user_id_per_account_id", unique: true
|
||||
t.index ["account_id"], name: "index_account_users_on_account_id"
|
||||
t.index ["agent_capacity_policy_id"], name: "index_account_users_on_agent_capacity_policy_id"
|
||||
t.index ["custom_role_id"], name: "index_account_users_on_custom_role_id"
|
||||
t.index ["user_id"], name: "index_account_users_on_user_id"
|
||||
end
|
||||
@@ -120,6 +122,16 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do
|
||||
t.index ["account_id"], name: "index_agent_bots_on_account_id"
|
||||
end
|
||||
|
||||
create_table "agent_capacity_policies", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "name", limit: 255, null: false
|
||||
t.text "description"
|
||||
t.jsonb "exclusion_rules", default: {}, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_agent_capacity_policies_on_account_id"
|
||||
end
|
||||
|
||||
create_table "applied_slas", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "sla_policy_id", null: false
|
||||
@@ -169,6 +181,22 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do
|
||||
t.index ["views"], name: "index_articles_on_views"
|
||||
end
|
||||
|
||||
create_table "assignment_policies", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "name", limit: 255, null: false
|
||||
t.text "description"
|
||||
t.integer "assignment_order", default: 0, null: false
|
||||
t.integer "conversation_priority", default: 0, null: false
|
||||
t.integer "fair_distribution_limit", default: 100, null: false
|
||||
t.integer "fair_distribution_window", default: 3600, null: false
|
||||
t.boolean "enabled", default: true, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "name"], name: "index_assignment_policies_on_account_id_and_name", unique: true
|
||||
t.index ["account_id"], name: "index_assignment_policies_on_account_id"
|
||||
t.index ["enabled"], name: "index_assignment_policies_on_enabled"
|
||||
end
|
||||
|
||||
create_table "attachments", id: :serial, force: :cascade do |t|
|
||||
t.integer "file_type", default: 0
|
||||
t.string "external_url"
|
||||
@@ -728,6 +756,26 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "inbox_assignment_policies", force: :cascade do |t|
|
||||
t.bigint "inbox_id", null: false
|
||||
t.bigint "assignment_policy_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["assignment_policy_id"], name: "index_inbox_assignment_policies_on_assignment_policy_id"
|
||||
t.index ["inbox_id"], name: "index_inbox_assignment_policies_on_inbox_id", unique: true
|
||||
end
|
||||
|
||||
create_table "inbox_capacity_limits", force: :cascade do |t|
|
||||
t.bigint "agent_capacity_policy_id", null: false
|
||||
t.bigint "inbox_id", null: false
|
||||
t.integer "conversation_limit", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["agent_capacity_policy_id", "inbox_id"], name: "idx_on_agent_capacity_policy_id_inbox_id_71c7ec4caf", unique: true
|
||||
t.index ["agent_capacity_policy_id"], name: "index_inbox_capacity_limits_on_agent_capacity_policy_id"
|
||||
t.index ["inbox_id"], name: "index_inbox_capacity_limits_on_inbox_id"
|
||||
end
|
||||
|
||||
create_table "inbox_members", id: :serial, force: :cascade do |t|
|
||||
t.integer "user_id", null: false
|
||||
t.integer "inbox_id", null: false
|
||||
@@ -800,6 +848,24 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do
|
||||
t.index ["title", "account_id"], name: "index_labels_on_title_and_account_id", unique: true
|
||||
end
|
||||
|
||||
create_table "leaves", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.date "start_date", null: false
|
||||
t.date "end_date", null: false
|
||||
t.integer "leave_type", default: 0, null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.text "reason"
|
||||
t.bigint "approved_by_id"
|
||||
t.datetime "approved_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "status"], name: "index_leaves_on_account_id_and_status"
|
||||
t.index ["account_id"], name: "index_leaves_on_account_id"
|
||||
t.index ["approved_by_id"], name: "index_leaves_on_approved_by_id"
|
||||
t.index ["user_id"], name: "index_leaves_on_user_id"
|
||||
end
|
||||
|
||||
create_table "macros", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "name", null: false
|
||||
|
||||
@@ -63,6 +63,25 @@ FactoryBot.define do
|
||||
],
|
||||
'sub_category' => 'CUSTOM',
|
||||
'parameter_format' => 'NAMED'
|
||||
},
|
||||
{
|
||||
'name' => 'test_no_params_template',
|
||||
'status' => 'APPROVED',
|
||||
'category' => 'UTILITY',
|
||||
'language' => 'en',
|
||||
'namespace' => 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||
'id' => '9876543210987654',
|
||||
'length' => 1,
|
||||
'parameter_format' => 'POSITIONAL',
|
||||
'previous_category' => 'MARKETING',
|
||||
'sub_category' => 'CUSTOM',
|
||||
'components' => [
|
||||
{
|
||||
'text' => 'Thank you for contacting us! Your request has been processed successfully. Have a great day! 🙂',
|
||||
'type' => 'BODY'
|
||||
}
|
||||
],
|
||||
'rejected_reason' => 'NONE'
|
||||
}]
|
||||
end
|
||||
message_templates_last_updated { Time.now.utc }
|
||||
|
||||
@@ -10,23 +10,6 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let!(:instagram_messenger_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
|
||||
let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_messenger_channel, account: account, greeting_enabled: false) }
|
||||
let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') }
|
||||
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
|
||||
# Combined message events into one helper
|
||||
let(:message_events) do
|
||||
{
|
||||
dm: build(:instagram_message_create_event).with_indifferent_access,
|
||||
standby: build(:instagram_message_standby_event).with_indifferent_access,
|
||||
unsend: build(:instagram_message_unsend_event).with_indifferent_access,
|
||||
attachment: build(:instagram_message_attachment_event).with_indifferent_access,
|
||||
story_mention: build(:instagram_story_mention_event).with_indifferent_access,
|
||||
story_mention_echo: build(:instagram_story_mention_event_with_echo).with_indifferent_access,
|
||||
messaging_seen: build(:messaging_seen_event).with_indifferent_access,
|
||||
unsupported: build(:instagram_message_unsupported_event).with_indifferent_access
|
||||
}
|
||||
end
|
||||
|
||||
def return_object_for(sender_id)
|
||||
{ name: 'Jane',
|
||||
@@ -38,21 +21,19 @@ describe Webhooks::InstagramEventsJob do
|
||||
|
||||
describe '#perform' do
|
||||
context 'when handling messaging events for Instagram via Facebook page' do
|
||||
let!(:instagram_messenger_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
|
||||
let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_messenger_channel, account: account, greeting_enabled: false) }
|
||||
let(:fb_object) { double }
|
||||
|
||||
before do
|
||||
instagram_inbox.destroy
|
||||
end
|
||||
|
||||
it 'creates incoming message in the instagram inbox' do
|
||||
dm_event = build(:instagram_message_create_event).with_indifferent_access
|
||||
sender_id = dm_event[:entry][0][:messaging][0][:sender][:id]
|
||||
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
sender_id = message_events[:dm][:entry][0][:messaging][0][:sender][:id]
|
||||
allow(fb_object).to receive(:get_object).and_return(
|
||||
return_object_for(sender_id).with_indifferent_access
|
||||
)
|
||||
instagram_webhook.perform_now(message_events[:dm][:entry])
|
||||
|
||||
instagram_messenger_inbox.reload
|
||||
instagram_webhook.perform_now(dm_event[:entry])
|
||||
|
||||
expect(instagram_messenger_inbox.contacts.count).to be 1
|
||||
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
|
||||
@@ -62,14 +43,14 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'creates standby message in the instagram inbox' do
|
||||
standby_event = build(:instagram_message_standby_event).with_indifferent_access
|
||||
sender_id = standby_event[:entry][0][:standby][0][:sender][:id]
|
||||
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
sender_id = message_events[:standby][:entry][0][:standby][0][:sender][:id]
|
||||
allow(fb_object).to receive(:get_object).and_return(
|
||||
return_object_for(sender_id).with_indifferent_access
|
||||
)
|
||||
instagram_webhook.perform_now(message_events[:standby][:entry])
|
||||
|
||||
instagram_messenger_inbox.reload
|
||||
instagram_webhook.perform_now(standby_event[:entry])
|
||||
|
||||
expect(instagram_messenger_inbox.contacts.count).to be 1
|
||||
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
|
||||
@@ -81,9 +62,11 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'handle instagram unsend message event' do
|
||||
unsend_event = build(:instagram_message_unsend_event).with_indifferent_access
|
||||
sender_id = unsend_event[:entry][0][:messaging][0][:sender][:id]
|
||||
|
||||
message = create(:message, inbox_id: instagram_messenger_inbox.id, source_id: 'message-id-to-delete')
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
sender_id = message_events[:unsend][:entry][0][:messaging][0][:sender][:id]
|
||||
allow(fb_object).to receive(:get_object).and_return(
|
||||
{
|
||||
name: 'Jane',
|
||||
@@ -96,7 +79,7 @@ describe Webhooks::InstagramEventsJob do
|
||||
|
||||
expect(instagram_messenger_inbox.messages.count).to be 1
|
||||
|
||||
instagram_webhook.perform_now(message_events[:unsend][:entry])
|
||||
instagram_webhook.perform_now(unsend_event[:entry])
|
||||
|
||||
expect(instagram_messenger_inbox.messages.last.content).to eq 'This message was deleted'
|
||||
expect(instagram_messenger_inbox.messages.last.deleted).to be true
|
||||
@@ -105,14 +88,14 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'creates incoming message with attachments in the instagram inbox' do
|
||||
attachment_event = build(:instagram_message_attachment_event).with_indifferent_access
|
||||
sender_id = attachment_event[:entry][0][:messaging][0][:sender][:id]
|
||||
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
sender_id = message_events[:attachment][:entry][0][:messaging][0][:sender][:id]
|
||||
allow(fb_object).to receive(:get_object).and_return(
|
||||
return_object_for(sender_id).with_indifferent_access
|
||||
)
|
||||
instagram_webhook.perform_now(message_events[:attachment][:entry])
|
||||
|
||||
instagram_messenger_inbox.reload
|
||||
instagram_webhook.perform_now(attachment_event[:entry])
|
||||
|
||||
expect(instagram_messenger_inbox.contacts.count).to be 1
|
||||
expect(instagram_messenger_inbox.messages.count).to be 1
|
||||
@@ -120,8 +103,10 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'creates incoming message with attachments in the instagram inbox for story mention' do
|
||||
story_mention_event = build(:instagram_story_mention_event).with_indifferent_access
|
||||
sender_id = story_mention_event[:entry][0][:messaging][0][:sender][:id]
|
||||
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
sender_id = message_events[:story_mention][:entry][0][:messaging][0][:sender][:id]
|
||||
allow(fb_object).to receive(:get_object).and_return(
|
||||
return_object_for(sender_id).with_indifferent_access,
|
||||
{ story:
|
||||
@@ -137,9 +122,7 @@ describe Webhooks::InstagramEventsJob do
|
||||
id: 'instagram-message-id-1234' }.with_indifferent_access
|
||||
)
|
||||
|
||||
instagram_webhook.perform_now(message_events[:story_mention][:entry])
|
||||
|
||||
instagram_messenger_inbox.reload
|
||||
instagram_webhook.perform_now(story_mention_event[:entry])
|
||||
|
||||
expect(instagram_messenger_inbox.messages.count).to be 1
|
||||
expect(instagram_messenger_inbox.messages.last.attachments.count).to be 1
|
||||
@@ -149,12 +132,12 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'does not create contact or messages when Facebook API call fails' do
|
||||
story_mention_echo_event = build(:instagram_story_mention_event_with_echo).with_indifferent_access
|
||||
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError)
|
||||
|
||||
instagram_webhook.perform_now(message_events[:story_mention_echo][:entry])
|
||||
|
||||
instagram_messenger_inbox.reload
|
||||
instagram_webhook.perform_now(story_mention_echo_event[:entry])
|
||||
|
||||
expect(instagram_messenger_inbox.contacts.count).to be 0
|
||||
expect(instagram_messenger_inbox.contact_inboxes.count).to be 0
|
||||
@@ -162,21 +145,23 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'handle messaging_seen callback' do
|
||||
expect(Instagram::ReadStatusService).to receive(:new).with(params: message_events[:messaging_seen][:entry][0][:messaging][0],
|
||||
messaging_seen_event = build(:messaging_seen_event).with_indifferent_access
|
||||
|
||||
expect(Instagram::ReadStatusService).to receive(:new).with(params: messaging_seen_event[:entry][0][:messaging][0],
|
||||
channel: instagram_messenger_inbox.channel).and_call_original
|
||||
instagram_webhook.perform_now(message_events[:messaging_seen][:entry])
|
||||
instagram_webhook.perform_now(messaging_seen_event[:entry])
|
||||
end
|
||||
|
||||
it 'handles unsupported message' do
|
||||
unsupported_event = build(:instagram_message_unsupported_event).with_indifferent_access
|
||||
sender_id = unsupported_event[:entry][0][:messaging][0][:sender][:id]
|
||||
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
sender_id = message_events[:unsupported][:entry][0][:messaging][0][:sender][:id]
|
||||
allow(fb_object).to receive(:get_object).and_return(
|
||||
return_object_for(sender_id).with_indifferent_access
|
||||
)
|
||||
|
||||
instagram_webhook.perform_now(message_events[:unsupported][:entry])
|
||||
instagram_messenger_inbox.reload
|
||||
|
||||
instagram_webhook.perform_now(unsupported_event[:entry])
|
||||
expect(instagram_messenger_inbox.contacts.count).to be 1
|
||||
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
|
||||
expect(instagram_messenger_inbox.conversations.count).to be 1
|
||||
@@ -186,6 +171,9 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
context 'when handling messaging events for Instagram via Instagram login' do
|
||||
let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') }
|
||||
let!(:instagram_inbox) { instagram_channel.inbox }
|
||||
|
||||
before do
|
||||
instagram_channel.update(access_token: 'valid_instagram_token')
|
||||
|
||||
@@ -210,9 +198,8 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'creates incoming message with correct contact info in the instagram direct inbox' do
|
||||
instagram_webhook.perform_now(message_events[:dm][:entry])
|
||||
instagram_inbox.reload
|
||||
|
||||
dm_event = build(:instagram_message_create_event).with_indifferent_access
|
||||
instagram_webhook.perform_now(dm_event[:entry])
|
||||
expect(instagram_inbox.contacts.count).to eq 1
|
||||
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
|
||||
expect(instagram_inbox.conversations.count).to eq 1
|
||||
@@ -221,7 +208,8 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'sets correct instagram attributes on contact' do
|
||||
instagram_webhook.perform_now(message_events[:dm][:entry])
|
||||
dm_event = build(:instagram_message_create_event).with_indifferent_access
|
||||
instagram_webhook.perform_now(dm_event[:entry])
|
||||
instagram_inbox.reload
|
||||
|
||||
contact = instagram_inbox.contacts.last
|
||||
@@ -233,6 +221,8 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'handle instagram unsend message event' do
|
||||
unsend_event = build(:instagram_message_unsend_event).with_indifferent_access
|
||||
|
||||
message = create(:message, inbox_id: instagram_inbox.id, source_id: 'message-id-to-delete', content: 'random_text')
|
||||
|
||||
# Create attachment correctly with account association
|
||||
@@ -244,7 +234,7 @@ describe Webhooks::InstagramEventsJob do
|
||||
|
||||
expect(instagram_inbox.messages.count).to be 1
|
||||
|
||||
instagram_webhook.perform_now(message_events[:unsend][:entry])
|
||||
instagram_webhook.perform_now(unsend_event[:entry])
|
||||
|
||||
message.reload
|
||||
|
||||
@@ -254,9 +244,8 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'creates incoming message with attachments in the instagram direct inbox' do
|
||||
instagram_webhook.perform_now(message_events[:attachment][:entry])
|
||||
|
||||
instagram_inbox.reload
|
||||
attachment_event = build(:instagram_message_attachment_event).with_indifferent_access
|
||||
instagram_webhook.perform_now(attachment_event[:entry])
|
||||
|
||||
expect(instagram_inbox.contacts.count).to be 1
|
||||
expect(instagram_inbox.messages.count).to be 1
|
||||
@@ -264,9 +253,8 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'handles unsupported message' do
|
||||
instagram_webhook.perform_now(message_events[:unsupported][:entry])
|
||||
instagram_inbox.reload
|
||||
|
||||
unsupported_event = build(:instagram_message_unsupported_event).with_indifferent_access
|
||||
instagram_webhook.perform_now(unsupported_event[:entry])
|
||||
expect(instagram_inbox.contacts.count).to be 1
|
||||
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
|
||||
expect(instagram_inbox.conversations.count).to be 1
|
||||
@@ -275,12 +263,12 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'does not create contact or messages when Instagram API call fails' do
|
||||
story_mention_echo_event = build(:instagram_story_mention_event_with_echo).with_indifferent_access
|
||||
|
||||
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*})
|
||||
.to_return(status: 401, body: { error: { message: 'Invalid OAuth access token' } }.to_json)
|
||||
|
||||
instagram_webhook.perform_now(message_events[:story_mention_echo][:entry])
|
||||
|
||||
instagram_inbox.reload
|
||||
instagram_webhook.perform_now(story_mention_echo_event[:entry])
|
||||
|
||||
expect(instagram_inbox.contacts.count).to be 0
|
||||
expect(instagram_inbox.contact_inboxes.count).to be 0
|
||||
@@ -288,19 +276,20 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'handles messaging_seen callback' do
|
||||
expect(Instagram::ReadStatusService).to receive(:new).with(params: message_events[:messaging_seen][:entry][0][:messaging][0],
|
||||
messaging_seen_event = build(:messaging_seen_event).with_indifferent_access
|
||||
|
||||
expect(Instagram::ReadStatusService).to receive(:new).with(params: messaging_seen_event[:entry][0][:messaging][0],
|
||||
channel: instagram_inbox.channel).and_call_original
|
||||
instagram_webhook.perform_now(message_events[:messaging_seen][:entry])
|
||||
instagram_webhook.perform_now(messaging_seen_event[:entry])
|
||||
end
|
||||
|
||||
it 'creates contact when Instagram API call returns `No matching Instagram user` (9010 error code)' do
|
||||
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*})
|
||||
.to_return(status: 401, body: { error: { message: 'No matching Instagram user', code: 9010 } }.to_json)
|
||||
|
||||
sender_id = message_events[:dm][:entry][0][:messaging][0][:sender][:id]
|
||||
instagram_webhook.perform_now(message_events[:dm][:entry])
|
||||
|
||||
instagram_inbox.reload
|
||||
dm_event = build(:instagram_message_create_event).with_indifferent_access
|
||||
sender_id = dm_event[:entry][0][:messaging][0][:sender][:id]
|
||||
instagram_webhook.perform_now(dm_event[:entry])
|
||||
|
||||
expect(instagram_inbox.contacts.count).to be 1
|
||||
expect(instagram_inbox.contacts.last.name).to eq "Unknown (IG: #{sender_id})"
|
||||
|
||||
@@ -130,6 +130,42 @@ describe AutomationRuleListener do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'conversation_resolved' do
|
||||
let!(:automation_rule) { create(:automation_rule, event_name: 'conversation_resolved', account: account) }
|
||||
let(:event) do
|
||||
Events::Base.new('conversation_resolved', Time.zone.now, { conversation: conversation,
|
||||
changed_attributes: { status: %w[Snoozed Open] } })
|
||||
end
|
||||
|
||||
context 'when matching rules are present' do
|
||||
it 'calls AutomationRules::ActionService if conditions match' do
|
||||
allow(condition_match).to receive(:present?).and_return(true)
|
||||
listener.conversation_resolved(event)
|
||||
expect(AutomationRules::ActionService).to have_received(:new).with(automation_rule, account, conversation)
|
||||
end
|
||||
|
||||
it 'does not call AutomationRules::ActionService if conditions do not match' do
|
||||
allow(condition_match).to receive(:present?).and_return(false)
|
||||
listener.conversation_resolved(event)
|
||||
expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
|
||||
end
|
||||
|
||||
it 'calls AutomationRules::ActionService for each rule when multiple rules are present' do
|
||||
create(:automation_rule, event_name: 'conversation_resolved', account: account)
|
||||
allow(condition_match).to receive(:present?).and_return(true)
|
||||
listener.conversation_resolved(event)
|
||||
expect(AutomationRules::ActionService).to have_received(:new).twice
|
||||
end
|
||||
|
||||
it 'does not call AutomationRules::ActionService if performed by automation' do
|
||||
event.data[:performed_by] = automation_rule
|
||||
allow(condition_match).to receive(:present?).and_return(true)
|
||||
listener.conversation_resolved(event)
|
||||
expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'message_created' do
|
||||
let!(:automation_rule) { create(:automation_rule, event_name: 'message_created', account: account) }
|
||||
let!(:message) { create(:message, account: account, conversation: conversation) }
|
||||
|
||||
@@ -82,6 +82,16 @@ RSpec.describe Attachment do
|
||||
|
||||
expect(attachment.thumb_url).to be_present
|
||||
end
|
||||
|
||||
it 'handles unrepresentable images gracefully' do
|
||||
attachment = message.attachments.create!(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: StringIO.new('fake image'), filename: 'test.jpg', content_type: 'image/jpeg')
|
||||
|
||||
allow(attachment.file).to receive(:representation).and_raise(ActiveStorage::UnrepresentableError.new('Cannot represent'))
|
||||
|
||||
expect(Rails.logger).to receive(:warn).with(/Unrepresentable image attachment: #{attachment.id}/)
|
||||
expect(attachment.thumb_url).to eq('')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'meta data handling' do
|
||||
|
||||
@@ -371,5 +371,100 @@ describe Whatsapp::IncomingMessageService do
|
||||
Redis::Alfred.delete(key)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when profile name is available for contact updates' do
|
||||
let(:wa_id) { '1234567890' }
|
||||
let(:phone_number) { "+#{wa_id}" }
|
||||
|
||||
it 'updates existing contact name when current name matches phone number' do
|
||||
# Create contact with phone number as name
|
||||
existing_contact = create(:contact,
|
||||
account: whatsapp_channel.inbox.account,
|
||||
name: phone_number,
|
||||
phone_number: phone_number)
|
||||
create(:contact_inbox,
|
||||
contact: existing_contact,
|
||||
inbox: whatsapp_channel.inbox,
|
||||
source_id: wa_id)
|
||||
|
||||
params = {
|
||||
'contacts' => [{ 'profile' => { 'name' => 'Jane Smith' }, 'wa_id' => wa_id }],
|
||||
'messages' => [{ 'from' => wa_id, 'id' => 'message123', 'text' => { 'body' => 'Hello' },
|
||||
'timestamp' => '1633034394', 'type' => 'text' }]
|
||||
}.with_indifferent_access
|
||||
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||
existing_contact.reload
|
||||
expect(existing_contact.name).to eq('Jane Smith')
|
||||
end
|
||||
|
||||
it 'does not update contact name when current name is different from phone number' do
|
||||
# Create contact with human name
|
||||
existing_contact = create(:contact,
|
||||
account: whatsapp_channel.inbox.account,
|
||||
name: 'John Doe',
|
||||
phone_number: phone_number)
|
||||
create(:contact_inbox,
|
||||
contact: existing_contact,
|
||||
inbox: whatsapp_channel.inbox,
|
||||
source_id: wa_id)
|
||||
|
||||
params = {
|
||||
'contacts' => [{ 'profile' => { 'name' => 'Jane Smith' }, 'wa_id' => wa_id }],
|
||||
'messages' => [{ 'from' => wa_id, 'id' => 'message123', 'text' => { 'body' => 'Hello' },
|
||||
'timestamp' => '1633034394', 'type' => 'text' }]
|
||||
}.with_indifferent_access
|
||||
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||
existing_contact.reload
|
||||
expect(existing_contact.name).to eq('John Doe') # Should not change
|
||||
end
|
||||
|
||||
it 'updates contact name when current name matches formatted phone number' do
|
||||
formatted_number = TelephoneNumber.parse(phone_number).international_number
|
||||
|
||||
# Create contact with formatted phone number as name
|
||||
existing_contact = create(:contact,
|
||||
account: whatsapp_channel.inbox.account,
|
||||
name: formatted_number,
|
||||
phone_number: phone_number)
|
||||
create(:contact_inbox,
|
||||
contact: existing_contact,
|
||||
inbox: whatsapp_channel.inbox,
|
||||
source_id: wa_id)
|
||||
|
||||
params = {
|
||||
'contacts' => [{ 'profile' => { 'name' => 'Alice Johnson' }, 'wa_id' => wa_id }],
|
||||
'messages' => [{ 'from' => wa_id, 'id' => 'message123', 'text' => { 'body' => 'Hello' },
|
||||
'timestamp' => '1633034394', 'type' => 'text' }]
|
||||
}.with_indifferent_access
|
||||
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||
existing_contact.reload
|
||||
expect(existing_contact.name).to eq('Alice Johnson')
|
||||
end
|
||||
|
||||
it 'does not update when profile name is blank' do
|
||||
# Create contact with phone number as name
|
||||
existing_contact = create(:contact,
|
||||
account: whatsapp_channel.inbox.account,
|
||||
name: phone_number,
|
||||
phone_number: phone_number)
|
||||
create(:contact_inbox,
|
||||
contact: existing_contact,
|
||||
inbox: whatsapp_channel.inbox,
|
||||
source_id: wa_id)
|
||||
|
||||
params = {
|
||||
'contacts' => [{ 'profile' => { 'name' => '' }, 'wa_id' => wa_id }],
|
||||
'messages' => [{ 'from' => wa_id, 'id' => 'message123', 'text' => { 'body' => 'Hello' },
|
||||
'timestamp' => '1633034394', 'type' => 'text' }]
|
||||
}.with_indifferent_access
|
||||
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||
existing_contact.reload
|
||||
expect(existing_contact.name).to eq(phone_number) # Should not change
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -133,7 +133,8 @@ describe Whatsapp::OneoffCampaignService do
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
nil
|
||||
)
|
||||
|
||||
described_class.new(campaign: campaign).perform
|
||||
@@ -164,8 +165,8 @@ describe Whatsapp::OneoffCampaignService do
|
||||
|
||||
allow(whatsapp_channel).to receive(:send_template).and_return(nil)
|
||||
|
||||
expect(whatsapp_channel).to receive(:send_template).with(contact_error.phone_number, anything).and_raise(StandardError, error_message)
|
||||
expect(whatsapp_channel).to receive(:send_template).with(contact_success.phone_number, anything).once
|
||||
expect(whatsapp_channel).to receive(:send_template).with(contact_error.phone_number, anything, nil).and_raise(StandardError, error_message)
|
||||
expect(whatsapp_channel).to receive(:send_template).with(contact_success.phone_number, anything, nil).once
|
||||
|
||||
expect(Rails.logger).to receive(:error)
|
||||
.with("Failed to send WhatsApp template message to #{contact_error.phone_number}: #{error_message}")
|
||||
|
||||
@@ -187,7 +187,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
||||
)
|
||||
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
|
||||
|
||||
expect(service.send_template('+123456789', template_info)).to eq('message_id')
|
||||
expect(service.send_template('+123456789', template_info, message)).to eq('message_id')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -287,7 +287,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
||||
context 'when there is a message' do
|
||||
it 'logs error and updates message status' do
|
||||
service.instance_variable_set(:@message, message)
|
||||
service.send(:handle_error, error_response_object)
|
||||
service.send(:handle_error, error_response_object, message)
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.reload.external_error).to eq(error_message)
|
||||
@@ -305,7 +305,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
||||
|
||||
it 'logs error but does not update message' do
|
||||
service.instance_variable_set(:@message, message)
|
||||
service.send(:handle_error, error_response_object)
|
||||
service.send(:handle_error, error_response_object, message)
|
||||
|
||||
expect(message.reload.status).not_to eq('failed')
|
||||
expect(message.reload.external_error).to be_nil
|
||||
|
||||
@@ -133,6 +133,48 @@ describe Whatsapp::TemplateParameterConverterService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processed_params is nil (parameter-less templates)' do
|
||||
let(:nil_params) do
|
||||
{
|
||||
'processed_params' => nil
|
||||
}
|
||||
end
|
||||
|
||||
let(:parameterless_template) do
|
||||
{
|
||||
'name' => 'test_no_params_template',
|
||||
'language' => 'en',
|
||||
'parameter_format' => 'POSITIONAL',
|
||||
'id' => '9876543210987654',
|
||||
'status' => 'APPROVED',
|
||||
'category' => 'UTILITY',
|
||||
'previous_category' => 'MARKETING',
|
||||
'sub_category' => 'CUSTOM',
|
||||
'components' => [
|
||||
{
|
||||
'type' => 'BODY',
|
||||
'text' => 'Thank you for contacting us! Your request has been processed successfully. Have a great day! 🙂'
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'converts nil to empty enhanced format' do
|
||||
converter = described_class.new(nil_params, parameterless_template)
|
||||
result = converter.normalize_to_enhanced
|
||||
|
||||
expect(result['processed_params']).to eq({})
|
||||
expect(result['format_version']).to eq('legacy')
|
||||
end
|
||||
|
||||
it 'does not raise ArgumentError for nil processed_params' do
|
||||
expect do
|
||||
converter = described_class.new(nil_params, parameterless_template)
|
||||
converter.normalize_to_enhanced
|
||||
end.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'when invalid format' do
|
||||
let(:invalid_params) do
|
||||
{
|
||||
@@ -174,6 +216,26 @@ describe Whatsapp::TemplateParameterConverterService do
|
||||
end
|
||||
|
||||
describe 'simplified conversion methods' do
|
||||
describe '#convert_legacy_to_enhanced' do
|
||||
it 'handles nil processed_params without raising error' do
|
||||
converter = described_class.new({}, template)
|
||||
result = converter.send(:convert_legacy_to_enhanced, nil, template)
|
||||
expect(result).to eq({})
|
||||
end
|
||||
|
||||
it 'returns empty hash for parameter-less templates' do
|
||||
parameterless_template = {
|
||||
'name' => 'no_params_template',
|
||||
'language' => 'en',
|
||||
'components' => [{ 'type' => 'BODY', 'text' => 'Hello World!' }]
|
||||
}
|
||||
|
||||
converter = described_class.new({}, parameterless_template)
|
||||
result = converter.send(:convert_legacy_to_enhanced, nil, parameterless_template)
|
||||
expect(result).to eq({})
|
||||
end
|
||||
end
|
||||
|
||||
describe '#convert_array_to_body_params' do
|
||||
it 'converts empty array' do
|
||||
converter = described_class.new({}, template)
|
||||
|
||||
@@ -13,6 +13,7 @@ properties:
|
||||
enum:
|
||||
- conversation_created
|
||||
- conversation_updated
|
||||
- conversation_resolved
|
||||
- message_created
|
||||
example: message_created
|
||||
description: The event when you want to execute the automation actions
|
||||
|
||||
@@ -30,22 +30,64 @@ properties:
|
||||
example: 1
|
||||
template_params:
|
||||
type: object
|
||||
description: The template params for the message in case of whatsapp Channel
|
||||
description: WhatsApp template parameters for sending structured messages
|
||||
required:
|
||||
- name
|
||||
- category
|
||||
- language
|
||||
- processed_params
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Name of the template
|
||||
example: 'sample_issue_resolution'
|
||||
description: Name of the WhatsApp template (must be approved in WhatsApp Business Manager)
|
||||
example: 'purchase_receipt'
|
||||
category:
|
||||
type: string
|
||||
enum: ['UTILITY', 'MARKETING', 'SHIPPING_UPDATE', 'TICKET_UPDATE', 'ISSUE_RESOLUTION']
|
||||
description: Category of the template
|
||||
example: UTILITY
|
||||
example: 'UTILITY'
|
||||
language:
|
||||
type: string
|
||||
description: Language of the template
|
||||
example: en_US
|
||||
description: Language code of the template (BCP 47 format)
|
||||
example: 'en_US'
|
||||
processed_params:
|
||||
type: object
|
||||
description: The processed param values for template variables in template
|
||||
example:
|
||||
1: 'Chatwoot'
|
||||
description: Processed template parameters organized by component type
|
||||
properties:
|
||||
body:
|
||||
type: object
|
||||
description: Body component parameters with variable placeholders
|
||||
additionalProperties:
|
||||
type: string
|
||||
example:
|
||||
'1': 'Visa'
|
||||
'2': 'Nike'
|
||||
'3': 'Bill'
|
||||
header:
|
||||
type: object
|
||||
description: Header component parameters for media templates
|
||||
properties:
|
||||
media_url:
|
||||
type: string
|
||||
format: uri
|
||||
description: Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers
|
||||
example: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf'
|
||||
media_type:
|
||||
type: string
|
||||
enum: ['image', 'video', 'document']
|
||||
description: Type of media for the header
|
||||
example: 'document'
|
||||
buttons:
|
||||
type: array
|
||||
description: Button component parameters for interactive templates
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: ['url', 'copy_code']
|
||||
description: Type of button parameter
|
||||
parameter:
|
||||
type: string
|
||||
description: Dynamic parameter value for the button
|
||||
example: 'SSFSDFSD'
|
||||
@@ -10,4 +10,4 @@ properties:
|
||||
- type: object
|
||||
description: Single automation rule (for show/create/update endpoints)
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/automation_rule_item'
|
||||
- $ref: '#/components/schemas/automation_rule_item'
|
||||
|
||||
@@ -2,7 +2,57 @@ tags:
|
||||
- Messages
|
||||
operationId: create-a-new-message-in-a-conversation
|
||||
summary: Create New Message
|
||||
description: Create a new message in the conversation
|
||||
description: |
|
||||
Create a new message in the conversation.
|
||||
|
||||
## WhatsApp Template Messages
|
||||
|
||||
For WhatsApp channels, you can send structured template messages using the `template_params` field.
|
||||
Templates must be pre-approved in WhatsApp Business Manager.
|
||||
|
||||
### Example Templates
|
||||
|
||||
**Text with Image Header:**
|
||||
```json
|
||||
{
|
||||
"content": "Hi your order 121212 is confirmed. Please wait for further updates",
|
||||
"template_params": {
|
||||
"name": "order_confirmation",
|
||||
"category": "MARKETING",
|
||||
"language": "en",
|
||||
"processed_params": {
|
||||
"body": {
|
||||
"1": "121212"
|
||||
},
|
||||
"header": {
|
||||
"media_url": "https://picsum.photos/200/300",
|
||||
"media_type": "image"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Text with Copy Code Button:**
|
||||
```json
|
||||
{
|
||||
"content": "Special offer! Get 30% off your next purchase. Use the code below",
|
||||
"template_params": {
|
||||
"name": "discount_coupon",
|
||||
"category": "MARKETING",
|
||||
"language": "en",
|
||||
"processed_params": {
|
||||
"body": {
|
||||
"discount_percentage": "30"
|
||||
},
|
||||
"buttons": [{
|
||||
"type": "copy_code",
|
||||
"parameter": "SAVE20"
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
security:
|
||||
- userApiKey: []
|
||||
- agentBotApiKey: []
|
||||
|
||||
+77
-8
@@ -5937,7 +5937,7 @@
|
||||
],
|
||||
"operationId": "create-a-new-message-in-a-conversation",
|
||||
"summary": "Create New Message",
|
||||
"description": "Create a new message in the conversation",
|
||||
"description": "Create a new message in the conversation.\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can send structured template messages using the `template_params` field. \nTemplates must be pre-approved in WhatsApp Business Manager.\n\n### Example Templates\n\n**Text with Image Header:**\n```json\n{\n \"content\": \"Hi your order 121212 is confirmed. Please wait for further updates\",\n \"template_params\": {\n \"name\": \"order_confirmation\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"1\": \"121212\"\n },\n \"header\": {\n \"media_url\": \"https://picsum.photos/200/300\",\n \"media_type\": \"image\"\n }\n }\n }\n}\n```\n\n**Text with Copy Code Button:**\n```json\n{\n \"content\": \"Special offer! Get 30% off your next purchase. Use the code below\",\n \"template_params\": {\n \"name\": \"discount_coupon\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"discount_percentage\": \"30\"\n },\n \"buttons\": [{\n \"type\": \"copy_code\",\n \"parameter\": \"SAVE20\"\n }]\n }\n }\n}\n```\n",
|
||||
"security": [
|
||||
{
|
||||
"userApiKey": []
|
||||
@@ -10148,28 +10148,96 @@
|
||||
},
|
||||
"template_params": {
|
||||
"type": "object",
|
||||
"description": "The template params for the message in case of whatsapp Channel",
|
||||
"description": "WhatsApp template parameters for sending structured messages",
|
||||
"required": [
|
||||
"name",
|
||||
"category",
|
||||
"language",
|
||||
"processed_params"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the template",
|
||||
"example": "sample_issue_resolution"
|
||||
"description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
|
||||
"example": "purchase_receipt"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"UTILITY",
|
||||
"MARKETING",
|
||||
"SHIPPING_UPDATE",
|
||||
"TICKET_UPDATE",
|
||||
"ISSUE_RESOLUTION"
|
||||
],
|
||||
"description": "Category of the template",
|
||||
"example": "UTILITY"
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language of the template",
|
||||
"description": "Language code of the template (BCP 47 format)",
|
||||
"example": "en_US"
|
||||
},
|
||||
"processed_params": {
|
||||
"type": "object",
|
||||
"description": "The processed param values for template variables in template",
|
||||
"example": {
|
||||
"1": "Chatwoot"
|
||||
"description": "Processed template parameters organized by component type",
|
||||
"properties": {
|
||||
"body": {
|
||||
"type": "object",
|
||||
"description": "Body component parameters with variable placeholders",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": {
|
||||
"1": "Visa",
|
||||
"2": "Nike",
|
||||
"3": "Bill"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"type": "object",
|
||||
"description": "Header component parameters for media templates",
|
||||
"properties": {
|
||||
"media_url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
|
||||
"example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
|
||||
},
|
||||
"media_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"image",
|
||||
"video",
|
||||
"document"
|
||||
],
|
||||
"description": "Type of media for the header",
|
||||
"example": "document"
|
||||
}
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"type": "array",
|
||||
"description": "Button component parameters for interactive templates",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"url",
|
||||
"copy_code"
|
||||
],
|
||||
"description": "Type of button parameter"
|
||||
},
|
||||
"parameter": {
|
||||
"type": "string",
|
||||
"description": "Dynamic parameter value for the button",
|
||||
"example": "SSFSDFSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10542,6 +10610,7 @@
|
||||
"enum": [
|
||||
"conversation_created",
|
||||
"conversation_updated",
|
||||
"conversation_resolved",
|
||||
"message_created"
|
||||
],
|
||||
"example": "message_created",
|
||||
|
||||
@@ -4334,7 +4334,7 @@
|
||||
],
|
||||
"operationId": "create-a-new-message-in-a-conversation",
|
||||
"summary": "Create New Message",
|
||||
"description": "Create a new message in the conversation",
|
||||
"description": "Create a new message in the conversation.\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can send structured template messages using the `template_params` field. \nTemplates must be pre-approved in WhatsApp Business Manager.\n\n### Example Templates\n\n**Text with Image Header:**\n```json\n{\n \"content\": \"Hi your order 121212 is confirmed. Please wait for further updates\",\n \"template_params\": {\n \"name\": \"order_confirmation\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"1\": \"121212\"\n },\n \"header\": {\n \"media_url\": \"https://picsum.photos/200/300\",\n \"media_type\": \"image\"\n }\n }\n }\n}\n```\n\n**Text with Copy Code Button:**\n```json\n{\n \"content\": \"Special offer! Get 30% off your next purchase. Use the code below\",\n \"template_params\": {\n \"name\": \"discount_coupon\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"discount_percentage\": \"30\"\n },\n \"buttons\": [{\n \"type\": \"copy_code\",\n \"parameter\": \"SAVE20\"\n }]\n }\n }\n}\n```\n",
|
||||
"security": [
|
||||
{
|
||||
"userApiKey": []
|
||||
@@ -8509,28 +8509,96 @@
|
||||
},
|
||||
"template_params": {
|
||||
"type": "object",
|
||||
"description": "The template params for the message in case of whatsapp Channel",
|
||||
"description": "WhatsApp template parameters for sending structured messages",
|
||||
"required": [
|
||||
"name",
|
||||
"category",
|
||||
"language",
|
||||
"processed_params"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the template",
|
||||
"example": "sample_issue_resolution"
|
||||
"description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
|
||||
"example": "purchase_receipt"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"UTILITY",
|
||||
"MARKETING",
|
||||
"SHIPPING_UPDATE",
|
||||
"TICKET_UPDATE",
|
||||
"ISSUE_RESOLUTION"
|
||||
],
|
||||
"description": "Category of the template",
|
||||
"example": "UTILITY"
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language of the template",
|
||||
"description": "Language code of the template (BCP 47 format)",
|
||||
"example": "en_US"
|
||||
},
|
||||
"processed_params": {
|
||||
"type": "object",
|
||||
"description": "The processed param values for template variables in template",
|
||||
"example": {
|
||||
"1": "Chatwoot"
|
||||
"description": "Processed template parameters organized by component type",
|
||||
"properties": {
|
||||
"body": {
|
||||
"type": "object",
|
||||
"description": "Body component parameters with variable placeholders",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": {
|
||||
"1": "Visa",
|
||||
"2": "Nike",
|
||||
"3": "Bill"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"type": "object",
|
||||
"description": "Header component parameters for media templates",
|
||||
"properties": {
|
||||
"media_url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
|
||||
"example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
|
||||
},
|
||||
"media_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"image",
|
||||
"video",
|
||||
"document"
|
||||
],
|
||||
"description": "Type of media for the header",
|
||||
"example": "document"
|
||||
}
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"type": "array",
|
||||
"description": "Button component parameters for interactive templates",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"url",
|
||||
"copy_code"
|
||||
],
|
||||
"description": "Type of button parameter"
|
||||
},
|
||||
"parameter": {
|
||||
"type": "string",
|
||||
"description": "Dynamic parameter value for the button",
|
||||
"example": "SSFSDFSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8903,6 +8971,7 @@
|
||||
"enum": [
|
||||
"conversation_created",
|
||||
"conversation_updated",
|
||||
"conversation_resolved",
|
||||
"message_created"
|
||||
],
|
||||
"example": "message_created",
|
||||
|
||||
@@ -3132,28 +3132,96 @@
|
||||
},
|
||||
"template_params": {
|
||||
"type": "object",
|
||||
"description": "The template params for the message in case of whatsapp Channel",
|
||||
"description": "WhatsApp template parameters for sending structured messages",
|
||||
"required": [
|
||||
"name",
|
||||
"category",
|
||||
"language",
|
||||
"processed_params"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the template",
|
||||
"example": "sample_issue_resolution"
|
||||
"description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
|
||||
"example": "purchase_receipt"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"UTILITY",
|
||||
"MARKETING",
|
||||
"SHIPPING_UPDATE",
|
||||
"TICKET_UPDATE",
|
||||
"ISSUE_RESOLUTION"
|
||||
],
|
||||
"description": "Category of the template",
|
||||
"example": "UTILITY"
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language of the template",
|
||||
"description": "Language code of the template (BCP 47 format)",
|
||||
"example": "en_US"
|
||||
},
|
||||
"processed_params": {
|
||||
"type": "object",
|
||||
"description": "The processed param values for template variables in template",
|
||||
"example": {
|
||||
"1": "Chatwoot"
|
||||
"description": "Processed template parameters organized by component type",
|
||||
"properties": {
|
||||
"body": {
|
||||
"type": "object",
|
||||
"description": "Body component parameters with variable placeholders",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": {
|
||||
"1": "Visa",
|
||||
"2": "Nike",
|
||||
"3": "Bill"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"type": "object",
|
||||
"description": "Header component parameters for media templates",
|
||||
"properties": {
|
||||
"media_url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
|
||||
"example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
|
||||
},
|
||||
"media_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"image",
|
||||
"video",
|
||||
"document"
|
||||
],
|
||||
"description": "Type of media for the header",
|
||||
"example": "document"
|
||||
}
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"type": "array",
|
||||
"description": "Button component parameters for interactive templates",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"url",
|
||||
"copy_code"
|
||||
],
|
||||
"description": "Type of button parameter"
|
||||
},
|
||||
"parameter": {
|
||||
"type": "string",
|
||||
"description": "Dynamic parameter value for the button",
|
||||
"example": "SSFSDFSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3526,6 +3594,7 @@
|
||||
"enum": [
|
||||
"conversation_created",
|
||||
"conversation_updated",
|
||||
"conversation_resolved",
|
||||
"message_created"
|
||||
],
|
||||
"example": "message_created",
|
||||
|
||||
@@ -2547,28 +2547,96 @@
|
||||
},
|
||||
"template_params": {
|
||||
"type": "object",
|
||||
"description": "The template params for the message in case of whatsapp Channel",
|
||||
"description": "WhatsApp template parameters for sending structured messages",
|
||||
"required": [
|
||||
"name",
|
||||
"category",
|
||||
"language",
|
||||
"processed_params"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the template",
|
||||
"example": "sample_issue_resolution"
|
||||
"description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
|
||||
"example": "purchase_receipt"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"UTILITY",
|
||||
"MARKETING",
|
||||
"SHIPPING_UPDATE",
|
||||
"TICKET_UPDATE",
|
||||
"ISSUE_RESOLUTION"
|
||||
],
|
||||
"description": "Category of the template",
|
||||
"example": "UTILITY"
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language of the template",
|
||||
"description": "Language code of the template (BCP 47 format)",
|
||||
"example": "en_US"
|
||||
},
|
||||
"processed_params": {
|
||||
"type": "object",
|
||||
"description": "The processed param values for template variables in template",
|
||||
"example": {
|
||||
"1": "Chatwoot"
|
||||
"description": "Processed template parameters organized by component type",
|
||||
"properties": {
|
||||
"body": {
|
||||
"type": "object",
|
||||
"description": "Body component parameters with variable placeholders",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": {
|
||||
"1": "Visa",
|
||||
"2": "Nike",
|
||||
"3": "Bill"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"type": "object",
|
||||
"description": "Header component parameters for media templates",
|
||||
"properties": {
|
||||
"media_url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
|
||||
"example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
|
||||
},
|
||||
"media_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"image",
|
||||
"video",
|
||||
"document"
|
||||
],
|
||||
"description": "Type of media for the header",
|
||||
"example": "document"
|
||||
}
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"type": "array",
|
||||
"description": "Button component parameters for interactive templates",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"url",
|
||||
"copy_code"
|
||||
],
|
||||
"description": "Type of button parameter"
|
||||
},
|
||||
"parameter": {
|
||||
"type": "string",
|
||||
"description": "Dynamic parameter value for the button",
|
||||
"example": "SSFSDFSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2941,6 +3009,7 @@
|
||||
"enum": [
|
||||
"conversation_created",
|
||||
"conversation_updated",
|
||||
"conversation_resolved",
|
||||
"message_created"
|
||||
],
|
||||
"example": "message_created",
|
||||
|
||||
@@ -3308,28 +3308,96 @@
|
||||
},
|
||||
"template_params": {
|
||||
"type": "object",
|
||||
"description": "The template params for the message in case of whatsapp Channel",
|
||||
"description": "WhatsApp template parameters for sending structured messages",
|
||||
"required": [
|
||||
"name",
|
||||
"category",
|
||||
"language",
|
||||
"processed_params"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the template",
|
||||
"example": "sample_issue_resolution"
|
||||
"description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
|
||||
"example": "purchase_receipt"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"UTILITY",
|
||||
"MARKETING",
|
||||
"SHIPPING_UPDATE",
|
||||
"TICKET_UPDATE",
|
||||
"ISSUE_RESOLUTION"
|
||||
],
|
||||
"description": "Category of the template",
|
||||
"example": "UTILITY"
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language of the template",
|
||||
"description": "Language code of the template (BCP 47 format)",
|
||||
"example": "en_US"
|
||||
},
|
||||
"processed_params": {
|
||||
"type": "object",
|
||||
"description": "The processed param values for template variables in template",
|
||||
"example": {
|
||||
"1": "Chatwoot"
|
||||
"description": "Processed template parameters organized by component type",
|
||||
"properties": {
|
||||
"body": {
|
||||
"type": "object",
|
||||
"description": "Body component parameters with variable placeholders",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": {
|
||||
"1": "Visa",
|
||||
"2": "Nike",
|
||||
"3": "Bill"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"type": "object",
|
||||
"description": "Header component parameters for media templates",
|
||||
"properties": {
|
||||
"media_url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
|
||||
"example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
|
||||
},
|
||||
"media_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"image",
|
||||
"video",
|
||||
"document"
|
||||
],
|
||||
"description": "Type of media for the header",
|
||||
"example": "document"
|
||||
}
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"type": "array",
|
||||
"description": "Button component parameters for interactive templates",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"url",
|
||||
"copy_code"
|
||||
],
|
||||
"description": "Type of button parameter"
|
||||
},
|
||||
"parameter": {
|
||||
"type": "string",
|
||||
"description": "Dynamic parameter value for the button",
|
||||
"example": "SSFSDFSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3702,6 +3770,7 @@
|
||||
"enum": [
|
||||
"conversation_created",
|
||||
"conversation_updated",
|
||||
"conversation_resolved",
|
||||
"message_created"
|
||||
],
|
||||
"example": "message_created",
|
||||
|
||||
Reference in New Issue
Block a user