Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2eff74c854 | ||
|
|
566de02385 | ||
|
|
02ab856520 | ||
|
|
e58600d1b9 | ||
|
|
3e5b2979eb | ||
|
|
bd698cb12c | ||
|
|
79381a4c5f | ||
|
|
d57170712d | ||
|
|
e7be8c14bd |
@@ -274,3 +274,5 @@ AZURE_APP_SECRET=
|
||||
# Set to true if you want to remove stale contact inboxes
|
||||
# contact_inboxes with no conversation older than 90 days will be removed
|
||||
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
|
||||
|
||||
# REDIS_ALFRED_SIZE=10
|
||||
|
||||
@@ -32,6 +32,16 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
syncTemplates(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/sync_templates`);
|
||||
}
|
||||
|
||||
createCSATTemplate(inboxId, template) {
|
||||
return axios.post(`${this.url}/${inboxId}/csat_template`, {
|
||||
template,
|
||||
});
|
||||
}
|
||||
|
||||
getCSATTemplateStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/csat_template`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
buttonText: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
|
||||
<div class="p-3 rounded-xl bg-n-alpha-2">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="text-sm font-medium prose prose-bubble"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button :label="buttonText" slate class="!text-n-blue-text w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -859,6 +859,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
max-height: none !important;
|
||||
min-height: 0 !important;
|
||||
padding: 0 !important;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
> .ProseMirror {
|
||||
|
||||
+3
@@ -41,6 +41,9 @@ const getTemplateType = template => {
|
||||
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
|
||||
}
|
||||
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.CALL_TO_ACTION) {
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.CALL_TO_ACTION');
|
||||
}
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
|
||||
};
|
||||
|
||||
|
||||
@@ -140,6 +140,11 @@ export const FORMATTING = {
|
||||
nodes: [],
|
||||
menu: ['strong', 'em', 'link', 'undo', 'redo'],
|
||||
},
|
||||
'Context::Plain': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
};
|
||||
|
||||
// Editor menu options for Full Editor
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"TYPES": {
|
||||
"MEDIA": "Media",
|
||||
"QUICK_REPLY": "Quick Reply",
|
||||
"CALL_TO_ACTION": "Call to Action",
|
||||
"TEXT": "Text"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -808,6 +808,35 @@
|
||||
"LABEL": "Message",
|
||||
"PLACEHOLDER": "Please enter a message to show users with the form"
|
||||
},
|
||||
"BUTTON_TEXT": {
|
||||
"LABEL": "Button text",
|
||||
"PLACEHOLDER": "Please rate us"
|
||||
},
|
||||
"LANGUAGE": {
|
||||
"LABEL": "Language",
|
||||
"PLACEHOLDER": "Select template language"
|
||||
},
|
||||
"MESSAGE_PREVIEW": {
|
||||
"LABEL": "Message preview",
|
||||
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
|
||||
},
|
||||
"TEMPLATE_STATUS": {
|
||||
"APPROVED": "Approved by WhatsApp",
|
||||
"PENDING": "Pending WhatsApp approval",
|
||||
"REJECTED": "Meta rejected the template",
|
||||
"DEFAULT": "Needs WhatsApp approval",
|
||||
"NOT_FOUND": "The template does not exist in the Meta platform."
|
||||
},
|
||||
"TEMPLATE_CREATION": {
|
||||
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
|
||||
"ERROR_MESSAGE": "Failed to create WhatsApp template"
|
||||
},
|
||||
"TEMPLATE_UPDATE_DIALOG": {
|
||||
"TITLE": "Edit survey details",
|
||||
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
|
||||
"CONFIRM": "Create new template",
|
||||
"CANCEL": "Go back"
|
||||
},
|
||||
"SURVEY_RULE": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@@ -819,6 +848,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"NOTE": "Note: CSAT surveys are sent only once per conversation",
|
||||
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
|
||||
+339
-11
@@ -3,15 +3,22 @@ import { reactive, onMounted, ref, defineProps, watch, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||
import SectionLayout from 'dashboard/routes/dashboard/settings/account/components/SectionLayout.vue';
|
||||
import CSATDisplayTypeSelector from './components/CSATDisplayTypeSelector.vue';
|
||||
import CSATTemplate from 'dashboard/components-next/message/bubbles/Template/CSAT.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Switch from 'next/switch/Switch.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
||||
import ConfirmTemplateUpdateDialog from './components/ConfirmTemplateUpdateDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inbox: { type: Object, required: true },
|
||||
@@ -21,6 +28,10 @@ const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
|
||||
const { isAWhatsAppCloudChannel: isWhatsAppChannel } = useInbox(
|
||||
props.inbox?.id
|
||||
);
|
||||
|
||||
const isUpdating = ref(false);
|
||||
const selectedLabelValues = ref([]);
|
||||
const currentLabel = ref('');
|
||||
@@ -29,7 +40,19 @@ const state = reactive({
|
||||
csatSurveyEnabled: false,
|
||||
displayType: 'emoji',
|
||||
message: '',
|
||||
templateButtonText: 'Please rate us',
|
||||
surveyRuleOperator: 'contains',
|
||||
templateLanguage: '',
|
||||
});
|
||||
|
||||
const templateStatus = ref(null);
|
||||
const templateLoading = ref(false);
|
||||
const confirmDialog = ref(null);
|
||||
|
||||
const originalTemplateValues = ref({
|
||||
message: '',
|
||||
templateButtonText: '',
|
||||
templateLanguage: '',
|
||||
});
|
||||
|
||||
const filterTypes = [
|
||||
@@ -51,6 +74,59 @@ const labelOptions = computed(() =>
|
||||
: []
|
||||
);
|
||||
|
||||
const languageOptions = computed(() =>
|
||||
languages.map(({ name, id }) => ({ label: `${name} (${id})`, value: id }))
|
||||
);
|
||||
|
||||
const messagePreviewData = computed(() => ({
|
||||
content: state.message || t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER'),
|
||||
}));
|
||||
|
||||
const shouldShowTemplateStatus = computed(
|
||||
() => templateStatus.value && !templateLoading.value
|
||||
);
|
||||
|
||||
const templateApprovalStatus = computed(() => {
|
||||
const statusMap = {
|
||||
APPROVED: {
|
||||
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.APPROVED'),
|
||||
icon: 'i-lucide-circle-check',
|
||||
color: 'text-n-teal-11',
|
||||
},
|
||||
PENDING: {
|
||||
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.PENDING'),
|
||||
icon: 'i-lucide-clock',
|
||||
color: 'text-n-amber-11',
|
||||
},
|
||||
REJECTED: {
|
||||
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.REJECTED'),
|
||||
icon: 'i-lucide-circle-x',
|
||||
color: 'text-n-ruby-10',
|
||||
},
|
||||
};
|
||||
|
||||
// Handle template not found case
|
||||
if (templateStatus.value?.error === 'TEMPLATE_NOT_FOUND') {
|
||||
return {
|
||||
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.NOT_FOUND'),
|
||||
icon: 'i-lucide-alert-triangle',
|
||||
color: 'text-n-ruby-10',
|
||||
};
|
||||
}
|
||||
|
||||
// Handle existing template with status
|
||||
if (templateStatus.value?.template_exists && templateStatus.value.status) {
|
||||
return statusMap[templateStatus.value.status] || statusMap.PENDING;
|
||||
}
|
||||
|
||||
// Default case - no template exists
|
||||
return {
|
||||
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.DEFAULT'),
|
||||
icon: 'i-lucide-stamp',
|
||||
color: 'text-n-slate-11',
|
||||
};
|
||||
});
|
||||
|
||||
const initializeState = () => {
|
||||
if (!props.inbox) return;
|
||||
|
||||
@@ -63,21 +139,63 @@ const initializeState = () => {
|
||||
const {
|
||||
display_type: displayType = CSAT_DISPLAY_TYPES.EMOJI,
|
||||
message = '',
|
||||
button_text: buttonText = 'Please rate us',
|
||||
language = 'en',
|
||||
survey_rules: surveyRules = {},
|
||||
} = csat_config;
|
||||
|
||||
state.displayType = displayType;
|
||||
state.message = message;
|
||||
state.templateButtonText = buttonText;
|
||||
state.templateLanguage = language;
|
||||
state.surveyRuleOperator = surveyRules.operator || 'contains';
|
||||
|
||||
selectedLabelValues.value = Array.isArray(surveyRules.values)
|
||||
? [...surveyRules.values]
|
||||
: [];
|
||||
|
||||
// Store original template values for change detection
|
||||
if (isWhatsAppChannel.value) {
|
||||
originalTemplateValues.value = {
|
||||
message: state.message,
|
||||
templateButtonText: state.templateButtonText,
|
||||
templateLanguage: state.templateLanguage,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const checkTemplateStatus = async () => {
|
||||
if (!isWhatsAppChannel.value) return;
|
||||
|
||||
try {
|
||||
templateLoading.value = true;
|
||||
const response = await store.dispatch('inboxes/getCSATTemplateStatus', {
|
||||
inboxId: props.inbox.id,
|
||||
});
|
||||
|
||||
// Handle case where template doesn't exist
|
||||
if (!response.template_exists && response.error === 'Template not found') {
|
||||
templateStatus.value = {
|
||||
template_exists: false,
|
||||
error: 'TEMPLATE_NOT_FOUND',
|
||||
};
|
||||
} else {
|
||||
templateStatus.value = response;
|
||||
}
|
||||
} catch (error) {
|
||||
templateStatus.value = {
|
||||
template_exists: false,
|
||||
error: 'API_ERROR',
|
||||
};
|
||||
} finally {
|
||||
templateLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeState();
|
||||
if (!labels.value?.length) store.dispatch('labels/get');
|
||||
if (isWhatsAppChannel.value) checkTemplateStatus();
|
||||
});
|
||||
|
||||
watch(() => props.inbox, initializeState, { immediate: true });
|
||||
@@ -105,6 +223,49 @@ const removeLabel = label => {
|
||||
}
|
||||
};
|
||||
|
||||
// Check if template-related fields have changed
|
||||
const hasTemplateChanges = () => {
|
||||
if (!isWhatsAppChannel.value) return false;
|
||||
|
||||
const original = originalTemplateValues.value;
|
||||
return (
|
||||
original.message !== state.message ||
|
||||
original.templateButtonText !== state.templateButtonText ||
|
||||
original.templateLanguage !== state.templateLanguage
|
||||
);
|
||||
};
|
||||
|
||||
// Check if there's an existing template
|
||||
const hasExistingTemplate = () => {
|
||||
const { template_exists, error } = templateStatus.value || {};
|
||||
return template_exists && !error;
|
||||
};
|
||||
|
||||
// Check if we should create a template
|
||||
const shouldCreateTemplate = () => {
|
||||
// Create template if no existing template
|
||||
if (!hasExistingTemplate()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Create template if there are changes to template fields
|
||||
return hasTemplateChanges();
|
||||
};
|
||||
|
||||
// Build template config for saving
|
||||
const buildTemplateConfig = () => {
|
||||
if (!hasExistingTemplate()) return null;
|
||||
|
||||
const { template_name, template_id, template, status } =
|
||||
templateStatus.value || {};
|
||||
return {
|
||||
name: template_name,
|
||||
template_id,
|
||||
language: template?.language || state.templateLanguage,
|
||||
status,
|
||||
};
|
||||
};
|
||||
|
||||
const updateInbox = async attributes => {
|
||||
const payload = {
|
||||
id: props.inbox.id,
|
||||
@@ -115,31 +276,103 @@ const updateInbox = async attributes => {
|
||||
return store.dispatch('inboxes/updateInbox', payload);
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
const createTemplate = async () => {
|
||||
if (!isWhatsAppChannel.value) return null;
|
||||
|
||||
const response = await store.dispatch('inboxes/createCSATTemplate', {
|
||||
inboxId: props.inbox.id,
|
||||
template: {
|
||||
message: state.message,
|
||||
button_text: state.templateButtonText,
|
||||
language: state.templateLanguage,
|
||||
},
|
||||
});
|
||||
useAlert(t('INBOX_MGMT.CSAT.TEMPLATE_CREATION.SUCCESS_MESSAGE'));
|
||||
return response.template;
|
||||
};
|
||||
|
||||
const performSave = async () => {
|
||||
try {
|
||||
isUpdating.value = true;
|
||||
let newTemplateData = null;
|
||||
|
||||
// For WhatsApp channels, create template first if needed
|
||||
if (
|
||||
isWhatsAppChannel.value &&
|
||||
state.csatSurveyEnabled &&
|
||||
shouldCreateTemplate()
|
||||
) {
|
||||
try {
|
||||
newTemplateData = await createTemplate();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error.response?.data?.error ||
|
||||
t('INBOX_MGMT.CSAT.TEMPLATE_CREATION.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const csatConfig = {
|
||||
display_type: state.displayType,
|
||||
message: state.message,
|
||||
button_text: state.templateButtonText,
|
||||
language: state.templateLanguage,
|
||||
survey_rules: {
|
||||
operator: state.surveyRuleOperator,
|
||||
values: selectedLabelValues.value,
|
||||
},
|
||||
};
|
||||
|
||||
// Use new template data if created, otherwise preserve existing template information
|
||||
if (newTemplateData) {
|
||||
csatConfig.template = {
|
||||
name: newTemplateData.name,
|
||||
template_id: newTemplateData.template_id,
|
||||
language: newTemplateData.language,
|
||||
status: newTemplateData.status,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
} else {
|
||||
const templateConfig = buildTemplateConfig();
|
||||
if (templateConfig) {
|
||||
csatConfig.template = templateConfig;
|
||||
}
|
||||
}
|
||||
|
||||
await updateInbox({
|
||||
csat_survey_enabled: state.csatSurveyEnabled,
|
||||
csat_config: csatConfig,
|
||||
});
|
||||
|
||||
useAlert(t('INBOX_MGMT.CSAT.API.SUCCESS_MESSAGE'));
|
||||
checkTemplateStatus();
|
||||
} catch (error) {
|
||||
useAlert(t('INBOX_MGMT.CSAT.API.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
isUpdating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
// Check if we need to show confirmation dialog for WhatsApp template changes
|
||||
if (
|
||||
isWhatsAppChannel.value &&
|
||||
state.csatSurveyEnabled &&
|
||||
hasExistingTemplate() &&
|
||||
hasTemplateChanges()
|
||||
) {
|
||||
confirmDialog.value?.open();
|
||||
return;
|
||||
}
|
||||
|
||||
await performSave();
|
||||
};
|
||||
|
||||
const handleConfirmTemplateUpdate = async () => {
|
||||
// We will delete the template before creating the template
|
||||
await performSave();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -155,7 +388,9 @@ const saveSettings = async () => {
|
||||
</template>
|
||||
|
||||
<div class="grid gap-5">
|
||||
<!-- Show display type only for non-WhatsApp channels -->
|
||||
<WithLabel
|
||||
v-if="!isWhatsAppChannel"
|
||||
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
|
||||
name="display_type"
|
||||
>
|
||||
@@ -165,14 +400,97 @@ const saveSettings = async () => {
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
<WithLabel :label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')" name="message">
|
||||
<Editor
|
||||
v-model="state.message"
|
||||
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
|
||||
:max-length="200"
|
||||
class="w-full"
|
||||
/>
|
||||
</WithLabel>
|
||||
<template v-if="isWhatsAppChannel">
|
||||
<div
|
||||
class="flex flex-col gap-4 justify-between w-full lg:flex-row lg:gap-6"
|
||||
>
|
||||
<div class="flex flex-col gap-3 basis-3/5">
|
||||
<WithLabel
|
||||
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
|
||||
name="message"
|
||||
>
|
||||
<Editor
|
||||
v-model="state.message"
|
||||
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
|
||||
:max-length="200"
|
||||
channel-type="Context::Plain"
|
||||
class="w-full"
|
||||
/>
|
||||
</WithLabel>
|
||||
<Input
|
||||
v-model="state.templateButtonText"
|
||||
:label="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.PLACEHOLDER')"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<WithLabel
|
||||
:label="$t('INBOX_MGMT.CSAT.LANGUAGE.LABEL')"
|
||||
name="language"
|
||||
>
|
||||
<ComboBox
|
||||
v-model="state.templateLanguage"
|
||||
:options="languageOptions"
|
||||
:placeholder="$t('INBOX_MGMT.CSAT.LANGUAGE.PLACEHOLDER')"
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
<div
|
||||
v-if="shouldShowTemplateStatus"
|
||||
class="flex gap-2 items-center mt-4"
|
||||
>
|
||||
<Icon
|
||||
:icon="templateApprovalStatus.icon"
|
||||
:class="templateApprovalStatus.color"
|
||||
class="size-4"
|
||||
/>
|
||||
<span
|
||||
:class="templateApprovalStatus.color"
|
||||
class="text-sm font-medium"
|
||||
>
|
||||
{{ templateApprovalStatus.text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col flex-shrink-0 justify-start items-center p-6 mt-1 rounded-xl basis-2/5 bg-n-slate-2 outline outline-1 outline-n-weak"
|
||||
>
|
||||
<p
|
||||
class="inline-flex items-center text-sm font-medium text-n-slate-11"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.LABEL') }}
|
||||
<Icon
|
||||
v-tooltip.top-end="
|
||||
$t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.TOOLTIP')
|
||||
"
|
||||
icon="i-lucide-info"
|
||||
class="flex-shrink-0 mx-1 size-4"
|
||||
/>
|
||||
</p>
|
||||
<CSATTemplate
|
||||
:message="messagePreviewData"
|
||||
:button-text="state.templateButtonText"
|
||||
class="pt-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Non-WhatsApp channels layout -->
|
||||
<template v-else>
|
||||
<WithLabel
|
||||
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
|
||||
name="message"
|
||||
>
|
||||
<Editor
|
||||
v-model="state.message"
|
||||
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
|
||||
:max-length="200"
|
||||
class="w-full"
|
||||
/>
|
||||
</WithLabel>
|
||||
</template>
|
||||
|
||||
<WithLabel
|
||||
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.LABEL')"
|
||||
@@ -180,7 +498,7 @@ const saveSettings = async () => {
|
||||
>
|
||||
<div class="mb-4">
|
||||
<span
|
||||
class="inline-flex flex-wrap items-center gap-1.5 text-sm text-n-slate-12"
|
||||
class="inline-flex flex-wrap gap-1.5 items-center text-sm text-n-slate-12"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_PREFIX') }}
|
||||
<FilterSelect
|
||||
@@ -217,7 +535,11 @@ const saveSettings = async () => {
|
||||
</div>
|
||||
</WithLabel>
|
||||
<p class="text-sm italic text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.CSAT.NOTE') }}
|
||||
{{
|
||||
isWhatsAppChannel
|
||||
? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE')
|
||||
: $t('INBOX_MGMT.CSAT.NOTE')
|
||||
}}
|
||||
</p>
|
||||
<div>
|
||||
<NextButton
|
||||
@@ -229,5 +551,11 @@ const saveSettings = async () => {
|
||||
</div>
|
||||
</div>
|
||||
</SectionLayout>
|
||||
|
||||
<!-- Template Update Confirmation Dialog -->
|
||||
<ConfirmTemplateUpdateDialog
|
||||
ref="confirmDialog"
|
||||
@confirm="handleConfirmTemplateUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const emit = defineEmits(['confirm']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const handleDialogConfirm = () => {
|
||||
emit('confirm');
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
dialogRef,
|
||||
open: () => dialogRef.value?.open(),
|
||||
close: () => dialogRef.value?.close(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="alert"
|
||||
:title="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.TITLE')"
|
||||
:description="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.DESCRIPTION')"
|
||||
:confirm-button-label="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.CONFIRM')"
|
||||
:cancel-button-label="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.CANCEL')"
|
||||
@confirm="handleDialogConfirm"
|
||||
/>
|
||||
</template>
|
||||
@@ -83,6 +83,14 @@ export const getters = {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out CSAT templates (customer_satisfaction_survey and its versions)
|
||||
if (
|
||||
template.name &&
|
||||
template.name.startsWith('customer_satisfaction_survey')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
|
||||
const hasUnsupportedComponents = template.components.some(
|
||||
component =>
|
||||
@@ -344,6 +352,14 @@ export const actions = {
|
||||
throw new Error(error);
|
||||
}
|
||||
},
|
||||
createCSATTemplate: async (_, { inboxId, template }) => {
|
||||
const response = await InboxesAPI.createCSATTemplate(inboxId, template);
|
||||
return response.data;
|
||||
},
|
||||
getCSATTemplateStatus: async (_, { inboxId }) => {
|
||||
const response = await InboxesAPI.getCSATTemplateStatus(inboxId);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
|
||||
@@ -164,4 +164,5 @@ export const TWILIO_CONTENT_TEMPLATE_TYPES = {
|
||||
TEXT: 'text',
|
||||
MEDIA: 'media',
|
||||
QUICK_REPLY: 'quick_reply',
|
||||
CALL_TO_ACTION: 'call_to_action',
|
||||
};
|
||||
|
||||
+19
-5
@@ -322,12 +322,21 @@ class Message < ApplicationRecord
|
||||
end
|
||||
|
||||
def update_waiting_since
|
||||
if human_response? && !private && conversation.waiting_since.present?
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
|
||||
)
|
||||
conversation.update(waiting_since: nil)
|
||||
waiting_present = conversation.waiting_since.present?
|
||||
|
||||
if waiting_present && !private
|
||||
if human_response?
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
|
||||
)
|
||||
conversation.update(waiting_since: nil)
|
||||
elsif bot_response?
|
||||
# Bot responses also clear waiting_since (simpler than checking on next customer message)
|
||||
conversation.update(waiting_since: nil)
|
||||
end
|
||||
end
|
||||
|
||||
# Set waiting_since when customer sends a message (if currently blank)
|
||||
conversation.update(waiting_since: created_at) if incoming? && conversation.waiting_since.blank?
|
||||
end
|
||||
|
||||
@@ -341,6 +350,11 @@ class Message < ApplicationRecord
|
||||
sender.is_a?(User)
|
||||
end
|
||||
|
||||
def bot_response?
|
||||
# Check if this is a response from AgentBot or Captain::Assistant
|
||||
outgoing? && sender_type.in?(['AgentBot', 'Captain::Assistant'])
|
||||
end
|
||||
|
||||
def dispatch_create_events
|
||||
Rails.configuration.dispatcher.dispatch(MESSAGE_CREATED, Time.zone.now, message: self, performed_by: Current.executed_by)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# id :bigint not null, primary key
|
||||
# name :string
|
||||
# subscriptions :jsonb
|
||||
# url :string
|
||||
# url :text
|
||||
# webhook_type :integer default("account_type")
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
|
||||
@@ -95,6 +95,7 @@ class MailPresenter < SimpleDelegator
|
||||
content_type: content_type,
|
||||
date: date,
|
||||
from: from,
|
||||
headers: headers_data,
|
||||
html_content: html_content,
|
||||
in_reply_to: in_reply_to,
|
||||
message_id: message_id,
|
||||
@@ -136,6 +137,16 @@ class MailPresenter < SimpleDelegator
|
||||
from_email_address(@mail[:reply_to].try(:value)) || @mail['X-Original-Sender'].try(:value) || from_email_address(from.first)
|
||||
end
|
||||
|
||||
def headers_data
|
||||
headers = {
|
||||
'x-original-from' => @mail['X-Original-From']&.value,
|
||||
'x-original-sender' => @mail['X-Original-Sender']&.value,
|
||||
'x-forwarded-for' => @mail['X-Forwarded-For']&.value
|
||||
}.compact
|
||||
|
||||
headers.presence
|
||||
end
|
||||
|
||||
def from_email_address(email)
|
||||
Mail::Address.new(email).address
|
||||
end
|
||||
|
||||
@@ -4,7 +4,9 @@ class CsatSurveyService
|
||||
def perform
|
||||
return unless should_send_csat_survey?
|
||||
|
||||
if within_messaging_window?
|
||||
if whatsapp_channel? && template_available_and_approved?
|
||||
send_whatsapp_template_survey
|
||||
elsif within_messaging_window?
|
||||
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
|
||||
else
|
||||
create_csat_not_sent_activity_message
|
||||
@@ -35,6 +37,64 @@ class CsatSurveyService
|
||||
conversation.can_reply?
|
||||
end
|
||||
|
||||
def whatsapp_channel?
|
||||
inbox.channel_type == 'Channel::Whatsapp'
|
||||
end
|
||||
|
||||
def template_available_and_approved?
|
||||
template_config = inbox.csat_config&.dig('template')
|
||||
return false unless template_config
|
||||
|
||||
template_name = template_config['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(inbox.id)
|
||||
|
||||
status_result = inbox.channel.provider_service.get_template_status(template_name)
|
||||
|
||||
status_result[:success] && status_result[:template][:status] == 'APPROVED'
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error checking CSAT template status: #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
def send_whatsapp_template_survey
|
||||
template_config = inbox.csat_config&.dig('template')
|
||||
template_name = template_config['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(inbox.id)
|
||||
|
||||
phone_number = conversation.contact_inbox.source_id
|
||||
template_info = build_template_info(template_name, template_config)
|
||||
message = build_csat_message
|
||||
|
||||
message_id = inbox.channel.provider_service.send_template(phone_number, template_info, message)
|
||||
|
||||
message.update!(source_id: message_id) if message_id.present?
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error sending WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}"
|
||||
end
|
||||
|
||||
def build_template_info(template_name, template_config)
|
||||
{
|
||||
name: template_name,
|
||||
lang_code: template_config['language'] || 'en',
|
||||
parameters: [
|
||||
{
|
||||
type: 'button',
|
||||
sub_type: 'url',
|
||||
index: '0',
|
||||
parameters: [{ type: 'text', text: conversation.uuid }]
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def build_csat_message
|
||||
conversation.messages.build(
|
||||
account: conversation.account,
|
||||
inbox: inbox,
|
||||
message_type: :outgoing,
|
||||
content: inbox.csat_config&.dig('message') || 'Please rate this conversation',
|
||||
content_type: :input_csat
|
||||
)
|
||||
end
|
||||
|
||||
def create_csat_not_sent_activity_message
|
||||
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
|
||||
activity_message_params = {
|
||||
|
||||
@@ -42,6 +42,10 @@ class Messages::MarkdownRenderers::InstagramRenderer < Messages::MarkdownRendere
|
||||
cr
|
||||
end
|
||||
|
||||
def code_block(node)
|
||||
out(node.string_content)
|
||||
end
|
||||
|
||||
def softbreak(_node)
|
||||
out("\n")
|
||||
end
|
||||
|
||||
@@ -30,6 +30,10 @@ class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderer
|
||||
cr
|
||||
end
|
||||
|
||||
def code_block(node)
|
||||
out(node.string_content)
|
||||
end
|
||||
|
||||
def softbreak(_node)
|
||||
out("\n")
|
||||
end
|
||||
|
||||
@@ -23,8 +23,8 @@ class Twilio::TemplateProcessorService
|
||||
|
||||
def build_content_variables(template)
|
||||
case template['template_type']
|
||||
when 'text', 'quick_reply'
|
||||
convert_text_template(template_params) # Text and quick reply templates use body variables
|
||||
when 'text', 'quick_reply', 'call_to_action'
|
||||
convert_text_template(template_params) # Text, quick reply and call-to-action templates use body variables
|
||||
when 'media'
|
||||
convert_media_template(template_params)
|
||||
else
|
||||
|
||||
@@ -63,6 +63,8 @@ class Twilio::TemplateSyncService
|
||||
'media'
|
||||
elsif template_types.include?('twilio/quick-reply')
|
||||
'quick_reply'
|
||||
elsif template_types.include?('twilio/call-to-action')
|
||||
'call_to_action'
|
||||
elsif template_types.include?('twilio/catalog')
|
||||
'catalog'
|
||||
else
|
||||
@@ -107,6 +109,8 @@ class Twilio::TemplateSyncService
|
||||
template_types['twilio/media']['body']
|
||||
elsif template_types['twilio/quick-reply']
|
||||
template_types['twilio/quick-reply']['body']
|
||||
elsif template_types['twilio/call-to-action']
|
||||
template_types['twilio/call-to-action']['body']
|
||||
elsif template_types['twilio/catalog']
|
||||
template_types['twilio/catalog']['body']
|
||||
else
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
# Alfred
|
||||
# Add here as you use it for more features
|
||||
# Used for Round Robin, Conversation Emails & Online Presence
|
||||
$alfred = ConnectionPool.new(size: 5, timeout: 1) do
|
||||
alfred_size = ENV.fetch('REDIS_ALFRED_SIZE', 5)
|
||||
$alfred = ConnectionPool.new(size: alfred_size, timeout: 1) do
|
||||
redis = Rails.env.test? ? MockRedis.new : Redis.new(Redis::Config.app)
|
||||
Redis::Namespace.new('alfred', redis: redis, warning: true)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
class ChangeWebhookUrlToText < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
change_column :webhooks, :url, :text
|
||||
end
|
||||
|
||||
def down
|
||||
change_column :webhooks, :url, :string
|
||||
end
|
||||
end
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_11_19_161025) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_12_29_081141) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -1239,7 +1239,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_11_19_161025) do
|
||||
create_table "webhooks", force: :cascade do |t|
|
||||
t.integer "account_id"
|
||||
t.integer "inbox_id"
|
||||
t.string "url"
|
||||
t.text "url"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "webhook_type", default: 0
|
||||
|
||||
@@ -16,9 +16,17 @@ module Enterprise::Account::PlanUsageAndLimits
|
||||
end
|
||||
|
||||
def increment_response_usage
|
||||
current_usage = custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
|
||||
custom_attributes[CAPTAIN_RESPONSES_USAGE] = current_usage + 1
|
||||
save
|
||||
increment_sql = <<~SQL.squish
|
||||
custom_attributes = jsonb_set(
|
||||
custom_attributes,
|
||||
'{#{CAPTAIN_RESPONSES_USAGE}}',
|
||||
to_jsonb(COALESCE((custom_attributes->>'#{CAPTAIN_RESPONSES_USAGE}')::int, 0) + 1),
|
||||
true
|
||||
)
|
||||
SQL
|
||||
|
||||
updated = self.class.where(id: id).update_all(increment_sql)
|
||||
reload if updated.positive?
|
||||
end
|
||||
|
||||
def reset_response_usage
|
||||
|
||||
@@ -78,6 +78,46 @@ RSpec.describe Account, type: :model do
|
||||
expect(responses_limits[:current_available]).to eq captain_limits[:startups][:responses] - 1
|
||||
end
|
||||
|
||||
it 'handles concurrent increments without losing updates' do
|
||||
# Simulate concurrent calls to increment_response_usage
|
||||
# This tests that the atomic SQL update prevents race conditions
|
||||
concurrent_calls = 50
|
||||
|
||||
# Use a barrier to ensure all threads start at the same time
|
||||
# This maximizes the chance of overlapping reads/writes
|
||||
ready = Concurrent::CountDownLatch.new(concurrent_calls)
|
||||
start = Concurrent::CountDownLatch.new(1)
|
||||
|
||||
threads = concurrent_calls.times.map do
|
||||
Thread.new do
|
||||
# Reload account in each thread to simulate separate workers
|
||||
thread_account = Account.find(account.id)
|
||||
|
||||
# Signal ready and wait for all threads to be ready
|
||||
ready.count_down
|
||||
start.wait
|
||||
|
||||
# Now all threads will execute simultaneously
|
||||
thread_account.increment_response_usage
|
||||
end
|
||||
end
|
||||
|
||||
# Wait for all threads to be ready
|
||||
ready.wait
|
||||
# Release all threads at once
|
||||
start.count_down
|
||||
|
||||
# Wait for all threads to complete
|
||||
threads.each(&:join)
|
||||
|
||||
# Verify all increments were captured without lost updates
|
||||
expect(account.reload.custom_attributes['captain_responses_usage']).to eq concurrent_calls
|
||||
|
||||
responses_limits = account.usage_limits[:captain][:responses]
|
||||
expect(responses_limits[:consumed]).to eq concurrent_calls
|
||||
expect(responses_limits[:current_available]).to eq captain_limits[:startups][:responses] - concurrent_calls
|
||||
end
|
||||
|
||||
it 'reseting responses limits updates usage_limits' do
|
||||
account.custom_attributes['captain_responses_usage'] = 30
|
||||
account.save!
|
||||
|
||||
@@ -14,8 +14,9 @@ RSpec.describe Message do
|
||||
|
||||
create(:message, message_type: :outgoing, conversation: conversation, sender: captain_assistant)
|
||||
|
||||
# Captain::Assistant responses clear waiting_since (like AgentBot)
|
||||
expect(conversation.first_reply_created_at).to be_nil
|
||||
expect(conversation.waiting_since).to be_within(0.000001.seconds).of(conversation.created_at)
|
||||
expect(conversation.waiting_since).to be_nil
|
||||
|
||||
create(:message, message_type: :outgoing, conversation: conversation)
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ RSpec.describe ReplyMailbox do
|
||||
let(:conversation) { create(:conversation, assignee: agent, inbox: create(:inbox, account: account, greeting_enabled: false), account: account) }
|
||||
let(:described_subject) { described_class.receive reply_mail }
|
||||
let(:serialized_attributes) do
|
||||
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject text_content to
|
||||
auto_reply]
|
||||
%w[bcc cc content_type date from headers html_content in_reply_to message_id multipart number_of_attachments references subject text_content
|
||||
to auto_reply]
|
||||
end
|
||||
|
||||
context 'with reply uuid present' do
|
||||
@@ -397,7 +397,7 @@ RSpec.describe ReplyMailbox do
|
||||
let(:support_in_reply_to_mail) { create_inbound_email_from_fixture('support_in_reply_to.eml') }
|
||||
let(:described_subject) { described_class.receive support_mail }
|
||||
let(:serialized_attributes) do
|
||||
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject
|
||||
%w[bcc cc content_type date from headers html_content in_reply_to message_id multipart number_of_attachments references subject
|
||||
text_content to auto_reply]
|
||||
end
|
||||
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
|
||||
|
||||
@@ -978,5 +978,70 @@ RSpec.describe Conversation do
|
||||
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
|
||||
expect(reply_events.count).to eq(0)
|
||||
end
|
||||
|
||||
context 'when AgentBot responds between customer messages' do
|
||||
let(:agent_bot) { create(:agent_bot, account: account) }
|
||||
|
||||
def create_bot_message(conversation, created_at: Time.current)
|
||||
message = nil
|
||||
perform_enqueued_jobs do
|
||||
message = create(:message,
|
||||
message_type: 'outgoing',
|
||||
account: conversation.account,
|
||||
inbox: conversation.inbox,
|
||||
conversation: conversation,
|
||||
sender: agent_bot,
|
||||
created_at: created_at)
|
||||
end
|
||||
message
|
||||
end
|
||||
|
||||
it 'calculates reply time from the most recent customer message after bot response' do
|
||||
# Initial conversation: customer message -> agent first reply (to establish first_reply_created_at)
|
||||
create_customer_message(conversation, created_at: 10.hours.ago)
|
||||
create_agent_message(conversation, created_at: 9.hours.ago)
|
||||
|
||||
# Customer message 1
|
||||
create_customer_message(conversation, created_at: 5.hours.ago)
|
||||
|
||||
# Bot responds
|
||||
create_bot_message(conversation, created_at: 4.hours.ago)
|
||||
|
||||
# Customer message 2 (after bot response) - should reset waiting_since
|
||||
create_customer_message(conversation, created_at: 2.hours.ago)
|
||||
|
||||
# Human agent replies - should create reply_time event from customer message 2
|
||||
create_agent_message(conversation, created_at: 1.hour.ago)
|
||||
|
||||
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
|
||||
expect(reply_events.count).to eq(1) # Only the second agent reply creates a reply_time event
|
||||
# Reply time should be 1 hour (from customer message 2 to agent reply)
|
||||
expect(reply_events.first.value).to be_within(60).of(3600)
|
||||
end
|
||||
|
||||
it 'handles multiple bot responses before customer messages again' do
|
||||
# Initial conversation: customer message -> agent first reply
|
||||
create_customer_message(conversation, created_at: 10.hours.ago)
|
||||
create_agent_message(conversation, created_at: 9.hours.ago)
|
||||
|
||||
# Customer message 1
|
||||
create_customer_message(conversation, created_at: 6.hours.ago)
|
||||
|
||||
# Bot responds multiple times
|
||||
create_bot_message(conversation, created_at: 5.hours.ago)
|
||||
create_bot_message(conversation, created_at: 4.hours.ago)
|
||||
|
||||
# Customer message 2 (after multiple bot responses) - should reset waiting_since
|
||||
create_customer_message(conversation, created_at: 2.hours.ago)
|
||||
|
||||
# Human agent replies
|
||||
create_agent_message(conversation, created_at: 1.hour.ago)
|
||||
|
||||
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
|
||||
expect(reply_events.count).to eq(1) # Only the second agent reply creates a reply_time event
|
||||
# Reply time should be 1 hour (from customer message 2 to agent reply)
|
||||
expect(reply_events.first.value).to be_within(60).of(3600)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -300,6 +300,63 @@ RSpec.describe Message do
|
||||
|
||||
expect(conversation.waiting_since).to eq old_waiting_since
|
||||
end
|
||||
|
||||
context 'when bot has responded to the conversation' do
|
||||
let(:agent_bot) { create(:agent_bot, account: conversation.account) }
|
||||
|
||||
before do
|
||||
# Create initial customer message
|
||||
create(:message, conversation: conversation, message_type: :incoming,
|
||||
created_at: 2.hours.ago)
|
||||
conversation.update(waiting_since: 2.hours.ago)
|
||||
|
||||
# Bot responds
|
||||
create(:message, conversation: conversation, message_type: :outgoing,
|
||||
sender: agent_bot, created_at: 1.hour.ago)
|
||||
end
|
||||
|
||||
it 'resets waiting_since when customer sends a new message after bot response' do
|
||||
new_message = build(:message, conversation: conversation, message_type: :incoming)
|
||||
new_message.save!
|
||||
|
||||
conversation.reload
|
||||
expect(conversation.waiting_since).to be_within(1.second).of(new_message.created_at)
|
||||
end
|
||||
|
||||
it 'does not reset waiting_since if last response was from human agent' do
|
||||
# Human agent responds (clears waiting_since)
|
||||
create(:message, conversation: conversation, message_type: :outgoing,
|
||||
sender: agent)
|
||||
conversation.reload
|
||||
expect(conversation.waiting_since).to be_nil
|
||||
|
||||
# Customer sends new message
|
||||
new_message = build(:message, conversation: conversation, message_type: :incoming)
|
||||
new_message.save!
|
||||
|
||||
conversation.reload
|
||||
expect(conversation.waiting_since).to be_within(1.second).of(new_message.created_at)
|
||||
end
|
||||
|
||||
it 'clears waiting_since when bot responds' do
|
||||
# After the bot response in before block, waiting_since should already be cleared
|
||||
conversation.reload
|
||||
expect(conversation.waiting_since).to be_nil
|
||||
|
||||
# Customer sends another message
|
||||
create(:message, conversation: conversation, message_type: :incoming,
|
||||
created_at: 30.minutes.ago)
|
||||
conversation.reload
|
||||
expect(conversation.waiting_since).to be_within(1.second).of(30.minutes.ago)
|
||||
|
||||
# Another bot response should clear it again
|
||||
create(:message, conversation: conversation, message_type: :outgoing,
|
||||
sender: agent_bot, created_at: 15.minutes.ago)
|
||||
|
||||
conversation.reload
|
||||
expect(conversation.waiting_since).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with webhook_data' do
|
||||
|
||||
@@ -41,6 +41,7 @@ RSpec.describe MailPresenter do
|
||||
:content_type,
|
||||
:date,
|
||||
:from,
|
||||
:headers,
|
||||
:html_content,
|
||||
:in_reply_to,
|
||||
:message_id,
|
||||
@@ -60,6 +61,39 @@ RSpec.describe MailPresenter do
|
||||
expect(data[:auto_reply]).to eq(decorated_mail.auto_reply?)
|
||||
end
|
||||
|
||||
it 'includes forwarded headers in serialized_data' do
|
||||
mail_with_headers = Mail.new do
|
||||
from 'Sender <sender@example.com>'
|
||||
to 'Inbox <inbox@example.com>'
|
||||
subject :header
|
||||
body 'Hi'
|
||||
header['X-Original-From'] = 'Original <original@example.com>'
|
||||
header['X-Original-Sender'] = 'original@example.com'
|
||||
header['X-Forwarded-For'] = 'forwarder@example.com'
|
||||
end
|
||||
|
||||
data = described_class.new(mail_with_headers).serialized_data
|
||||
|
||||
expect(data[:headers]).to eq(
|
||||
'x-original-from' => 'Original <original@example.com>',
|
||||
'x-original-sender' => 'original@example.com',
|
||||
'x-forwarded-for' => 'forwarder@example.com'
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns nil headers when forwarding headers are missing' do
|
||||
mail_without_headers = Mail.new do
|
||||
from 'Sender <sender@example.com>'
|
||||
to 'Inbox <inbox@example.com>'
|
||||
subject :header
|
||||
body 'Hi'
|
||||
end
|
||||
|
||||
data = described_class.new(mail_without_headers).serialized_data
|
||||
|
||||
expect(data[:headers]).to be_nil
|
||||
end
|
||||
|
||||
it 'give email from in downcased format' do
|
||||
expect(decorated_mail.from.first.eql?(mail.from.first.downcase)).to be true
|
||||
end
|
||||
|
||||
@@ -3,7 +3,9 @@ require 'rails_helper'
|
||||
describe CsatSurveyService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account, csat_survey_enabled: true) }
|
||||
let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :resolved) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: '+1234567890') }
|
||||
let(:conversation) { create(:conversation, contact_inbox: contact_inbox, inbox: inbox, account: account, status: :resolved) }
|
||||
let(:service) { described_class.new(conversation: conversation) }
|
||||
|
||||
describe '#perform' do
|
||||
@@ -87,5 +89,269 @@ describe CsatSurveyService do
|
||||
expect(Conversations::ActivityMessageJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is a WhatsApp channel' do
|
||||
let(:whatsapp_channel) do
|
||||
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
|
||||
sync_templates: false, validate_provider_config: false)
|
||||
end
|
||||
let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account, csat_survey_enabled: true) }
|
||||
let(:whatsapp_contact) { create(:contact, account: account) }
|
||||
let(:whatsapp_contact_inbox) { create(:contact_inbox, contact: whatsapp_contact, inbox: whatsapp_inbox, source_id: '1234567890') }
|
||||
let(:whatsapp_conversation) do
|
||||
create(:conversation, contact_inbox: whatsapp_contact_inbox, inbox: whatsapp_inbox, account: account, status: :resolved)
|
||||
end
|
||||
let(:whatsapp_service) { described_class.new(conversation: whatsapp_conversation) }
|
||||
let(:mock_provider_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) }
|
||||
|
||||
before do
|
||||
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(mock_provider_service)
|
||||
allow(whatsapp_conversation).to receive(:can_reply?).and_return(true)
|
||||
end
|
||||
|
||||
context 'when template is available and approved' do
|
||||
before do
|
||||
setup_approved_template('customer_survey_template')
|
||||
end
|
||||
|
||||
it 'sends WhatsApp template survey instead of regular survey' do
|
||||
mock_successful_template_send('template_message_id_123')
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
expect(mock_provider_service).to have_received(:send_template).with(
|
||||
'1234567890',
|
||||
hash_including(
|
||||
name: 'customer_survey_template',
|
||||
lang_code: 'en',
|
||||
parameters: array_including(
|
||||
hash_including(
|
||||
type: 'button',
|
||||
sub_type: 'url',
|
||||
index: '0',
|
||||
parameters: array_including(
|
||||
hash_including(type: 'text', text: whatsapp_conversation.uuid)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
instance_of(Message)
|
||||
)
|
||||
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
|
||||
end
|
||||
|
||||
it 'updates message with returned message ID' do
|
||||
mock_successful_template_send('template_message_id_123')
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
|
||||
expect(csat_message).to be_present
|
||||
expect(csat_message.source_id).to eq('template_message_id_123')
|
||||
end
|
||||
|
||||
it 'builds correct template info with default template name' do
|
||||
expected_template_name = "customer_satisfaction_survey_#{whatsapp_inbox.id}"
|
||||
whatsapp_inbox.update(csat_config: { 'template' => {}, 'message' => 'Rate us' })
|
||||
allow(mock_provider_service).to receive(:get_template_status)
|
||||
.with(expected_template_name)
|
||||
.and_return({ success: true, template: { status: 'APPROVED' } })
|
||||
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
|
||||
message.save!
|
||||
'msg_id'
|
||||
end
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
expect(mock_provider_service).to have_received(:send_template).with(
|
||||
'1234567890',
|
||||
hash_including(
|
||||
name: expected_template_name,
|
||||
lang_code: 'en'
|
||||
),
|
||||
anything
|
||||
)
|
||||
end
|
||||
|
||||
it 'builds CSAT message with correct attributes' do
|
||||
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
|
||||
message.save!
|
||||
'msg_id'
|
||||
end
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
|
||||
expect(csat_message.account).to eq(account)
|
||||
expect(csat_message.inbox).to eq(whatsapp_inbox)
|
||||
expect(csat_message.message_type).to eq('outgoing')
|
||||
expect(csat_message.content).to eq('Please rate your experience')
|
||||
expect(csat_message.content_type).to eq('input_csat')
|
||||
end
|
||||
|
||||
it 'uses default message when not configured' do
|
||||
setup_approved_template('test', { 'template' => { 'name' => 'test' } })
|
||||
mock_successful_template_send('msg_id')
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
|
||||
expect(csat_message.content).to eq('Please rate this conversation')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when template is not available or not approved' do
|
||||
it 'falls back to regular survey when template is pending' do
|
||||
setup_template_with_status('pending_template', 'PENDING')
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
|
||||
expect(csat_template).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'falls back to regular survey when template is rejected' do
|
||||
setup_template_with_status('pending_template', 'REJECTED')
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
|
||||
expect(csat_template).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'falls back to regular survey when template API call fails' do
|
||||
allow(mock_provider_service).to receive(:get_template_status)
|
||||
.with('pending_template')
|
||||
.and_return({ success: false, error: 'Template not found' })
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
|
||||
expect(csat_template).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'falls back to regular survey when template status check raises error' do
|
||||
allow(mock_provider_service).to receive(:get_template_status)
|
||||
.and_raise(StandardError, 'API connection failed')
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
|
||||
expect(csat_template).to have_received(:perform)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no template is configured' do
|
||||
it 'falls back to regular survey' do
|
||||
whatsapp_service.perform
|
||||
|
||||
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
|
||||
expect(csat_template).to have_received(:perform)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when template sending fails' do
|
||||
before do
|
||||
setup_approved_template('working_template', {
|
||||
'template' => { 'name' => 'working_template' },
|
||||
'message' => 'Rate us'
|
||||
})
|
||||
end
|
||||
|
||||
it 'handles template sending errors gracefully' do
|
||||
mock_template_send_failure('Template send failed')
|
||||
|
||||
expect { whatsapp_service.perform }.not_to raise_error
|
||||
|
||||
# Should still create the CSAT message even if sending fails
|
||||
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
|
||||
expect(csat_message).to be_present
|
||||
expect(csat_message.source_id).to be_nil
|
||||
end
|
||||
|
||||
it 'does not update message when send_template returns nil' do
|
||||
mock_template_send_with_no_id
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
|
||||
expect(csat_message).to be_present
|
||||
expect(csat_message.source_id).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when outside messaging window' do
|
||||
before do
|
||||
allow(whatsapp_conversation).to receive(:can_reply?).and_return(false)
|
||||
end
|
||||
|
||||
it 'sends template survey even when outside messaging window if template is approved' do
|
||||
setup_approved_template('approved_template', { 'template' => { 'name' => 'approved_template' } })
|
||||
mock_successful_template_send('msg_id')
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
expect(mock_provider_service).to have_received(:send_template)
|
||||
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
|
||||
# No activity message should be created when template is successfully sent
|
||||
end
|
||||
|
||||
it 'creates activity message when template is not available and outside window' do
|
||||
whatsapp_service.perform
|
||||
|
||||
expect(Conversations::ActivityMessageJob).to have_received(:perform_later).with(
|
||||
whatsapp_conversation,
|
||||
hash_including(content: I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window'))
|
||||
)
|
||||
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def setup_approved_template(template_name, config = nil)
|
||||
template_config = config || {
|
||||
'template' => {
|
||||
'name' => template_name,
|
||||
'language' => 'en'
|
||||
},
|
||||
'message' => 'Please rate your experience'
|
||||
}
|
||||
whatsapp_inbox.update(csat_config: template_config)
|
||||
allow(mock_provider_service).to receive(:get_template_status)
|
||||
.with(template_name)
|
||||
.and_return({ success: true, template: { status: 'APPROVED' } })
|
||||
end
|
||||
|
||||
def setup_template_with_status(template_name, status)
|
||||
whatsapp_inbox.update(csat_config: {
|
||||
'template' => { 'name' => template_name }
|
||||
})
|
||||
allow(mock_provider_service).to receive(:get_template_status)
|
||||
.with(template_name)
|
||||
.and_return({ success: true, template: { status: status } })
|
||||
end
|
||||
|
||||
def mock_successful_template_send(message_id)
|
||||
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
|
||||
message.save!
|
||||
message_id
|
||||
end
|
||||
end
|
||||
|
||||
def mock_template_send_failure(error_message = 'Template send failed')
|
||||
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
|
||||
message.save!
|
||||
raise StandardError, error_message
|
||||
end
|
||||
end
|
||||
|
||||
def mock_template_send_with_no_id
|
||||
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
|
||||
message.save!
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -74,6 +74,24 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
|
||||
expect(result.scan("\n").count).to eq(4)
|
||||
expect(result).to include("Para 1\n\n\n\nPara 2")
|
||||
end
|
||||
|
||||
it 'renders code blocks as plain text' do
|
||||
content = "```\ncode here\n```"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('code here')
|
||||
end
|
||||
|
||||
it 'renders indented code blocks as plain text preserving exact content' do
|
||||
content = ' indented code line'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq('indented code line')
|
||||
end
|
||||
|
||||
it 'handles code blocks with emojis and special characters without stack overflow' do
|
||||
content = " first line\n 🌐 second line\n"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq("first line\n🌐 second line")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::Instagram' do
|
||||
@@ -130,6 +148,24 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
|
||||
expect(result.scan("\n").count).to eq(4)
|
||||
expect(result).to include("Para 1\n\n\n\nPara 2")
|
||||
end
|
||||
|
||||
it 'renders code blocks as plain text' do
|
||||
content = "```\ncode here\n```"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('code here')
|
||||
end
|
||||
|
||||
it 'renders indented code blocks as plain text preserving exact content' do
|
||||
content = ' indented code line'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq('indented code line')
|
||||
end
|
||||
|
||||
it 'handles code blocks with emojis and special characters without stack overflow' do
|
||||
content = " first line\n 🌐 second line\n"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq("first line\n🌐 second line")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::Line' do
|
||||
@@ -358,6 +394,18 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
|
||||
expect(result).to include('1. first step')
|
||||
expect(result).to include('2. second step')
|
||||
end
|
||||
|
||||
it 'renders code blocks as plain text' do
|
||||
content = "```\ncode here\n```"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('code here')
|
||||
end
|
||||
|
||||
it 'handles code blocks with emojis and special characters without stack overflow' do
|
||||
content = " first line\n 🌐 second line\n"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq("first line\n🌐 second line")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::TwilioSms' do
|
||||
|
||||
@@ -81,7 +81,29 @@ RSpec.describe Twilio::TemplateSyncService do
|
||||
)
|
||||
end
|
||||
|
||||
let(:templates) { [text_template, media_template, quick_reply_template, catalog_template] }
|
||||
let(:call_to_action_template) do
|
||||
instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
sid: 'HX444555666',
|
||||
friendly_name: 'payment_reminder',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
variables: {},
|
||||
types: {
|
||||
'twilio/call-to-action' => {
|
||||
'body' => 'Hello, this is a gentle reminder regarding your RVA Astrology course fee.' \
|
||||
'\n\n• Vignana Course: ₹3,000\n• Panditha Course: ₹6,000' \
|
||||
'\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience',
|
||||
'actions' => [
|
||||
{ 'id' => 'make_payment', 'title' => 'Make Payment', 'url' => 'https://example.com/payment' }
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
let(:templates) { [text_template, media_template, quick_reply_template, catalog_template, call_to_action_template] }
|
||||
|
||||
before do
|
||||
allow(twilio_channel).to receive(:send).and_call_original
|
||||
@@ -104,7 +126,7 @@ RSpec.describe Twilio::TemplateSyncService do
|
||||
twilio_channel.reload
|
||||
expect(twilio_channel.content_templates).to be_present
|
||||
expect(twilio_channel.content_templates['templates']).to be_an(Array)
|
||||
expect(twilio_channel.content_templates['templates'].size).to eq(4)
|
||||
expect(twilio_channel.content_templates['templates'].size).to eq(5)
|
||||
expect(twilio_channel.content_templates_last_updated).to be_within(1.second).of(Time.current)
|
||||
end
|
||||
end
|
||||
@@ -172,6 +194,32 @@ RSpec.describe Twilio::TemplateSyncService do
|
||||
)
|
||||
end
|
||||
|
||||
it 'correctly formats call-to-action templates with variables' do
|
||||
sync_service.call
|
||||
|
||||
twilio_channel.reload
|
||||
call_to_action_data = twilio_channel.content_templates['templates'].find do |t|
|
||||
t['friendly_name'] == 'payment_reminder'
|
||||
end
|
||||
|
||||
expect(call_to_action_data).to include(
|
||||
'content_sid' => 'HX444555666',
|
||||
'friendly_name' => 'payment_reminder',
|
||||
'language' => 'en',
|
||||
'status' => 'approved',
|
||||
'template_type' => 'call_to_action',
|
||||
'media_type' => nil,
|
||||
'variables' => {},
|
||||
'category' => 'utility'
|
||||
)
|
||||
|
||||
expected_body = 'Hello, this is a gentle reminder regarding your RVA Astrology course fee.' \
|
||||
'\n\n• Vignana Course: ₹3,000\n• Panditha Course: ₹6,000' \
|
||||
'\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience'
|
||||
expect(call_to_action_data['body']).to eq(expected_body)
|
||||
expect(call_to_action_data['body']).to match(/{{date}}/)
|
||||
end
|
||||
|
||||
it 'categorizes marketing templates correctly' do
|
||||
marketing_template = instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
|
||||
Reference in New Issue
Block a user