feat: Implement existing template linking for CSAT surveys (#218)

* feat: Implement existing template linking for CSAT surveys

- Added functionality to link existing CSAT templates for WhatsApp channels.
- Introduced a new component for selecting existing templates.
- Updated the dashboard settings page to support template mode switching between creating new and using existing templates.
- Enhanced the CSAT template management service to handle linking existing templates and fetching available templates.
- Updated API routes to include linking and fetching available templates.
- Added tests for the new linking functionality and template availability checks.

* feat: Enhance CSAT template handling and validation across services and components

* feat: Refactor body variable extraction for CSAT templates and update related validations

* feat: Add linked_at field to CSAT template responses and update related handling

* feat: Add tests for ConversationDrop date formatting and CSAT template body variable handling
This commit is contained in:
Gabriel Jablonski 2026-02-18 18:00:29 -03:00 committed by GitHub
parent f2635a69ed
commit 3b8a38b153
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1320 additions and 71 deletions

View File

@ -24,6 +24,26 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
end
def link
link_params = params.require(:template).permit(:name, :language, body_variables: {})
return render json: { error: 'Template name is required' }, status: :unprocessable_entity if link_params[:name].blank?
service = CsatTemplateManagementService.new(@inbox)
result = service.link_existing_template(
link_params[:name], link_params[:language], body_variables: link_params[:body_variables].to_h
)
render_link_result(result)
rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
end
def available_templates
service = CsatTemplateManagementService.new(@inbox)
templates = service.available_templates
render json: { templates: templates }
end
private
def fetch_inbox
@ -46,6 +66,22 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Message is required' }, status: :unprocessable_entity
end
def render_link_result(result)
if result[:success]
render json: {
template: {
name: result[:template_name], template_id: result[:template_id],
status: result[:status], language: result[:language], source: result[:source],
linked_at: result[:linked_at]
}
}, status: :ok
elsif result[:error]
render json: { error: result[:error] }, status: :unprocessable_entity
else
render json: { error: result[:service_error] || 'An unexpected error occurred' }, status: :internal_server_error
end
end
def render_template_creation_result(result)
if result[:success]
render_successful_template_creation(result)

View File

@ -214,7 +214,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController #
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
{ csat_config: [:display_type, :message, :button_text, :language,
{ survey_rules: [:operator, { values: [] }],
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }]
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid,
:created_at, :linked_at, :language, :source, :status, { body_variables: {} }] }] }]
end
def permitted_params(channel_attributes = [])

View File

@ -24,8 +24,33 @@ class ConversationDrop < BaseDrop
custom_attributes.transform_keys(&:to_s)
end
def first_reply_created_at
format_datetime(@obj.try(:first_reply_created_at))
end
def first_reply_created_at_time
format_datetime(@obj.try(:first_reply_created_at), include_time: true)
end
def last_activity_at
format_datetime(@obj.try(:last_activity_at))
end
def last_activity_at_time
format_datetime(@obj.try(:last_activity_at), include_time: true)
end
private
def format_datetime(datetime, include_time: false)
return '' if datetime.blank?
locale = @obj.try(:account)&.locale || 'en'
date_format = locale == 'pt_BR' ? '%d/%m/%Y' : '%b %d, %Y'
date_format += ' %H:%M' if include_time
datetime.strftime(date_format)
end
def message_sender_name(sender)
return 'Bot' if sender.blank?
return contact_name if sender.instance_of?(Contact)

View File

@ -43,6 +43,18 @@ class Inboxes extends CacheEnabledApiClient {
return axios.get(`${this.url}/${inboxId}/csat_template`);
}
linkCSATTemplate(inboxId, template) {
return axios.post(`${this.url}/${inboxId}/csat_template/link`, {
template,
});
}
getAvailableCSATTemplates(inboxId) {
return axios.get(
`${this.url}/${inboxId}/csat_template/available_templates`
);
}
setupChannelProvider(inboxId) {
return axios.post(`${this.url}/${inboxId}/setup_channel_provider`);
}

View File

@ -953,6 +953,32 @@
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_MODE": {
"LABEL": "Template source",
"CREATE_NEW": "Create new template",
"USE_EXISTING": "Use existing template"
},
"EXISTING_TEMPLATE": {
"LABEL": "Select template",
"PLACEHOLDER": "Select an approved template",
"SYNC_BUTTON": "Sync templates",
"SYNCING": "Syncing...",
"EMPTY_STATE": "No compatible templates found. Templates must have a URL button with a dynamic parameter.",
"LINK_SUCCESS": "Template linked successfully",
"LINK_ERROR": "Failed to link template",
"LOADING": "Loading templates...",
"USING_LABEL": "Using existing template: {name}",
"SYNC_ERROR": "Failed to sync templates. Please try again."
},
"TEMPLATE_VARIABLES": {
"TITLE": "Template variables",
"DESCRIPTION": "Set values for the template body variables. You can use dynamic variables like {'{{'} contact.name {'}}'} that will be resolved at send time.",
"VARIABLE_LABEL": "Variable {variable}",
"VARIABLE_PLACEHOLDER": "Enter value for {variable}",
"VARIABLE_REQUIRED": "This variable is required",
"VALIDATION_ERROR": "Please set all template variables before saving.",
"INSERT_VARIABLE": "Insert variable"
},
"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",
@ -970,7 +996,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.",
"WHATSAPP_NOTE": "Note: You can create a new template (which will be sent for WhatsApp approval) or use an existing approved template from your Meta template manager. Surveys are 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."

View File

@ -906,6 +906,32 @@
"SUCCESS_MESSAGE": "Modelo do WhatsApp criado com sucesso e enviado para aprovação",
"ERROR_MESSAGE": "Falha ao criar o modelo do WhatsApp"
},
"TEMPLATE_MODE": {
"LABEL": "Origem do modelo",
"CREATE_NEW": "Criar novo modelo",
"USE_EXISTING": "Usar modelo existente"
},
"EXISTING_TEMPLATE": {
"LABEL": "Selecionar modelo",
"PLACEHOLDER": "Selecione um modelo aprovado",
"SYNC_BUTTON": "Sincronizar modelos",
"SYNCING": "Sincronizando...",
"EMPTY_STATE": "Nenhum modelo compatível encontrado. Os modelos devem ter um botão de URL com um parâmetro dinâmico.",
"LINK_SUCCESS": "Modelo vinculado com sucesso",
"LINK_ERROR": "Falha ao vincular o modelo",
"LOADING": "Carregando modelos...",
"USING_LABEL": "Usando modelo existente: {name}",
"SYNC_ERROR": "Falha ao sincronizar modelos. Por favor, tente novamente."
},
"TEMPLATE_VARIABLES": {
"TITLE": "Variáveis do modelo",
"DESCRIPTION": "Defina os valores para as variáveis do corpo do modelo. Você pode usar variáveis dinâmicas como {'{{'} contact.name {'}}'} que serão resolvidas no momento do envio.",
"VARIABLE_LABEL": "Variável {variable}",
"VARIABLE_PLACEHOLDER": "Insira o valor para {variable}",
"VARIABLE_REQUIRED": "Esta variável é obrigatória",
"VALIDATION_ERROR": "Por favor, defina todas as variáveis do modelo antes de salvar.",
"INSERT_VARIABLE": "Inserir variável"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Editar detalhes da pesquisa",
"DESCRIPTION": "Vamos excluir o modelo anterior e criar um que será enviado novamente para aprovação do WhatsApp",
@ -923,7 +949,7 @@
"SELECT_PLACEHOLDER": "selecionar etiquetas"
},
"NOTE": "Nota: pesquisas de CSAT são enviadas apenas uma vez por conversa",
"WHATSAPP_NOTE": "Nota: Vamos criar um modelo e enviá-lo para aprovação do WhatsApp. Após a aprovação, as pesquisas serão enviadas apenas uma vez por conversa, conforme a regra da pesquisa.",
"WHATSAPP_NOTE": "Nota: Você pode criar um novo modelo (que será enviado para aprovação do WhatsApp) ou usar um modelo aprovado existente do seu gerenciador de modelos da Meta. As pesquisas são enviadas apenas uma vez por conversa, conforme a regra da pesquisa.",
"API": {
"SUCCESS_MESSAGE": "Configurações de CSAT atualizadas com sucesso",
"ERROR_MESSAGE": "Não foi possível atualizar as configurações do CSAT. Por favor, tente novamente mais tarde."

View File

@ -15,10 +15,12 @@ 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 TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import whatsappTemplateLanguages from './whatsappTemplateLanguages.js';
import ConfirmTemplateUpdateDialog from './components/ConfirmTemplateUpdateDialog.vue';
import ExistingTemplateSelector from './components/ExistingTemplateSelector.vue';
const props = defineProps({
inbox: { type: Object, required: true },
@ -53,6 +55,25 @@ const state = reactive({
const templateStatus = ref(null);
const templateLoading = ref(false);
const confirmDialog = ref(null);
const templateSelectorRef = ref(null);
const templateMode = ref('create_new');
const selectedExistingTemplateName = ref('');
const bodyVariables = ref({});
const existingTemplateBody = ref('');
const existingTemplateButtonText = ref('');
const templateModeTabs = computed(() => [
{ label: t('INBOX_MGMT.CSAT.TEMPLATE_MODE.CREATE_NEW'), key: 0 },
{ label: t('INBOX_MGMT.CSAT.TEMPLATE_MODE.USE_EXISTING'), key: 1 },
]);
const activeTemplateModeTabIndex = computed(() =>
templateMode.value === 'use_existing' ? 1 : 0
);
const onTemplateModeTabChange = tab => {
templateMode.value = tab.key === 1 ? 'use_existing' : 'create_new';
};
const originalTemplateValues = ref({
message: '',
@ -86,14 +107,59 @@ const languageOptions = computed(() =>
}))
);
const messagePreviewData = computed(() => ({
content: state.message || t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER'),
}));
const resolvedExistingTemplateBody = computed(() => {
if (!existingTemplateBody.value) return '';
let message = existingTemplateBody.value;
Object.entries(bodyVariables.value).forEach(([key, value]) => {
if (value) {
message = message.replaceAll(`{{${key}}}`, value);
}
});
return message;
});
const messagePreviewData = computed(() => {
if (templateMode.value === 'use_existing') {
return {
content:
resolvedExistingTemplateBody.value ||
t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER'),
};
}
return {
content: state.message || t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER'),
};
});
const previewButtonText = computed(() => {
if (templateMode.value === 'use_existing') {
return (
existingTemplateButtonText.value ||
t('INBOX_MGMT.CSAT.BUTTON_TEXT.PLACEHOLDER')
);
}
return state.templateButtonText;
});
const shouldShowTemplateStatus = computed(
() => templateStatus.value && !templateLoading.value
() =>
templateStatus.value &&
!templateLoading.value &&
templateMode.value !== 'use_existing'
);
const isUpdateDisabled = computed(() => {
if (
templateMode.value === 'use_existing' &&
isAWhatsAppCloudChannel.value &&
state.csatSurveyEnabled &&
!selectedExistingTemplateName.value
) {
return true;
}
return false;
});
const templateApprovalStatus = computed(() => {
const statusMap = {
APPROVED: {
@ -171,6 +237,21 @@ const initializeState = () => {
templateButtonText: state.templateButtonText,
templateLanguage: state.templateLanguage,
};
// Set template mode based on stored source
const templateSource = csat_config?.template?.source;
if (templateSource === 'user_selected') {
templateMode.value = 'use_existing';
selectedExistingTemplateName.value = csat_config.template.name || '';
bodyVariables.value = csat_config.template.body_variables || {};
existingTemplateBody.value = message;
existingTemplateButtonText.value = buttonText;
// Reset create-new fields so they don't show stale data
state.message = '';
state.templateButtonText = 'Please rate us';
} else {
templateMode.value = 'create_new';
}
}
};
@ -319,32 +400,84 @@ const createTemplate = async () => {
return response.template;
};
const linkTemplate = async () => {
if (!selectedExistingTemplateName.value) return null;
const response = await store.dispatch('inboxes/linkCSATTemplate', {
inboxId: props.inbox.id,
template: {
name: selectedExistingTemplateName.value,
language: state.templateLanguage,
body_variables: bodyVariables.value,
},
});
useAlert(t('INBOX_MGMT.CSAT.EXISTING_TEMPLATE.LINK_SUCCESS'));
return response.template;
};
const handleTemplateSelected = template => {
state.templateLanguage = template.language || 'en';
existingTemplateBody.value = template.body_text || '';
existingTemplateButtonText.value =
template.button_text || t('INBOX_MGMT.CSAT.BUTTON_TEXT.PLACEHOLDER');
};
const performSave = async () => {
try {
isUpdating.value = true;
let newTemplateData = null;
// For WhatsApp channels, create template first if needed
if (
isTemplateRequiredWhatsAppChannel.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;
// For WhatsApp channels, handle template based on mode
if (isTemplateRequiredWhatsAppChannel.value && state.csatSurveyEnabled) {
if (
templateMode.value === 'use_existing' &&
isAWhatsAppCloudChannel.value
) {
// Link existing template mode require selection
if (!selectedExistingTemplateName.value) return;
// Validate all body variables are filled
if (
templateSelectorRef.value &&
!templateSelectorRef.value.validate()
) {
useAlert(t('INBOX_MGMT.CSAT.TEMPLATE_VARIABLES.VALIDATION_ERROR'));
return;
}
try {
newTemplateData = await linkTemplate();
} catch (error) {
const errorMessage =
error.response?.data?.error ||
t('INBOX_MGMT.CSAT.EXISTING_TEMPLATE.LINK_ERROR');
useAlert(errorMessage);
return;
}
} else if (shouldCreateTemplate()) {
// Create new template mode
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,
message:
templateMode.value === 'use_existing'
? existingTemplateBody.value
: state.message,
button_text:
templateMode.value === 'use_existing'
? existingTemplateButtonText.value
: state.templateButtonText,
language: state.templateLanguage,
survey_rules: {
operator: state.surveyRuleOperator,
@ -352,15 +485,27 @@ const performSave = async () => {
},
};
// Use new template data if created, otherwise preserve existing template information
// Use new template data if created/linked, otherwise preserve existing template information
if (newTemplateData) {
if (isATwilioWhatsAppChannel.value) {
if (newTemplateData.source === 'user_selected') {
// User-selected existing template
csatConfig.template = {
name: newTemplateData.name,
template_id: newTemplateData.template_id,
language: newTemplateData.language,
status: newTemplateData.status,
source: 'user_selected',
linked_at: newTemplateData.linked_at,
body_variables: bodyVariables.value,
};
} else if (isATwilioWhatsAppChannel.value) {
// Twilio WhatsApp template format
csatConfig.template = {
friendly_name: newTemplateData.friendly_name,
content_sid: newTemplateData.content_sid,
language: newTemplateData.language,
status: newTemplateData.status,
source: 'auto_created',
created_at: new Date().toISOString(),
};
} else {
@ -370,6 +515,7 @@ const performSave = async () => {
template_id: newTemplateData.template_id,
language: newTemplateData.language,
status: newTemplateData.status,
source: 'auto_created',
created_at: new Date().toISOString(),
};
}
@ -395,6 +541,12 @@ const performSave = async () => {
};
const saveSettings = async () => {
// For "use existing" mode, no confirmation needed just save
if (templateMode.value === 'use_existing') {
await performSave();
return;
}
// Check if we need to show confirmation dialog for WhatsApp template changes
// This applies to both WhatsApp Cloud and Twilio WhatsApp channels
if (
@ -403,8 +555,12 @@ const saveSettings = async () => {
hasExistingTemplate() &&
hasTemplateChanges()
) {
confirmDialog.value?.open();
return;
// Only show dialog if the existing template was auto-created (will be deleted)
const existingSource = props.inbox?.csat_config?.template?.source;
if (existingSource !== 'user_selected') {
confirmDialog.value?.open();
return;
}
}
await performSave();
@ -442,40 +598,72 @@ const handleConfirmTemplateUpdate = async () => {
</WithLabel>
<template v-if="isTemplateRequiredWhatsAppChannel">
<!-- Template source toggle (only for WhatsApp Cloud) -->
<WithLabel
v-if="isAWhatsAppCloudChannel"
:label="$t('INBOX_MGMT.CSAT.TEMPLATE_MODE.LABEL')"
name="template_mode"
>
<TabBar
:tabs="templateModeTabs"
:initial-active-tab="activeTemplateModeTabIndex"
@tab-changed="onTemplateModeTabChange"
/>
</WithLabel>
<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"
<!-- Use existing template mode -->
<template
v-if="
templateMode === 'use_existing' && isAWhatsAppCloudChannel
"
>
<Editor
v-model="state.message"
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
:max-length="200"
channel-type="Context::Plain"
<ExistingTemplateSelector
ref="templateSelectorRef"
v-model="selectedExistingTemplateName"
:inbox-id="inbox.id"
:body-variables="bodyVariables"
@template-selected="handleTemplateSelected"
@update:body-variables="bodyVariables = $event"
/>
</template>
<!-- Create new template mode -->
<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"
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>
<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
v-if="shouldShowTemplateStatus"
:label="$t('INBOX_MGMT.CSAT.LANGUAGE.LABEL')"
name="language"
>
<ComboBox
v-model="state.templateLanguage"
:options="languageOptions"
:placeholder="$t('INBOX_MGMT.CSAT.LANGUAGE.PLACEHOLDER')"
/>
</WithLabel>
<WithLabel
v-if="shouldShowTemplateStatus"
:label="$t('INBOX_MGMT.CSAT.LANGUAGE.LABEL')"
name="language"
>
<ComboBox
v-model="state.templateLanguage"
:options="languageOptions"
:placeholder="$t('INBOX_MGMT.CSAT.LANGUAGE.PLACEHOLDER')"
/>
</WithLabel>
</template>
<div
v-if="shouldShowTemplateStatus"
@ -499,7 +687,6 @@ const handleConfirmTemplateUpdate = async () => {
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
v-if="shouldShowTemplateStatus"
class="inline-flex items-center text-sm font-medium text-n-slate-11"
>
{{ $t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.LABEL') }}
@ -513,8 +700,8 @@ const handleConfirmTemplateUpdate = async () => {
</p>
<CSATTemplate
:message="messagePreviewData"
:button-text="state.templateButtonText"
:class="shouldShowTemplateStatus ? 'pt-12' : ''"
:button-text="previewButtonText"
class="pt-12"
/>
</div>
</div>
@ -589,6 +776,7 @@ const handleConfirmTemplateUpdate = async () => {
type="submit"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:is-loading="isUpdating"
:disabled="isUpdateDisabled"
@click="saveSettings"
/>
</div>

View File

@ -0,0 +1,290 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
const props = defineProps({
inboxId: { type: Number, required: true },
modelValue: { type: String, default: '' },
bodyVariables: { type: Object, default: () => ({}) },
});
const emit = defineEmits([
'update:modelValue',
'templateSelected',
'update:bodyVariables',
]);
const LIQUID_VARIABLES = [
{ label: 'contact.name', value: '{{contact.name}}' },
{ label: 'contact.first_name', value: '{{contact.first_name}}' },
{ label: 'contact.last_name', value: '{{contact.last_name}}' },
{ label: 'contact.email', value: '{{contact.email}}' },
{ label: 'contact.phone_number', value: '{{contact.phone_number}}' },
{ label: 'conversation.display_id', value: '{{conversation.display_id}}' },
{
label: 'conversation.first_reply_created_at',
value: '{{conversation.first_reply_created_at}}',
},
{
label: 'conversation.first_reply_created_at_time',
value: '{{conversation.first_reply_created_at_time}}',
},
{
label: 'conversation.last_activity_at',
value: '{{conversation.last_activity_at}}',
},
{
label: 'conversation.last_activity_at_time',
value: '{{conversation.last_activity_at_time}}',
},
{ label: 'account.name', value: '{{account.name}}' },
{ label: 'inbox.name', value: '{{inbox.name}}' },
];
const { t } = useI18n();
const store = useStore();
const templates = ref([]);
const isLoading = ref(false);
const isSyncing = ref(false);
const localBodyVariables = ref({});
const templateOptions = computed(() =>
templates.value.map(tpl => ({
label: `${tpl.name} (${tpl.language})`,
value: tpl.name,
description: tpl.body_text,
}))
);
const selectedTemplate = computed(() =>
templates.value.find(tpl => tpl.name === props.modelValue)
);
const bodyVariableKeys = computed(
() => selectedTemplate.value?.body_variables || []
);
const hasBodyVariables = computed(() => bodyVariableKeys.value.length > 0);
const isLiquidVariable = value => /^\{\{.+\}\}$/.test(value || '');
const liquidVariableLabel = value => {
const match = LIQUID_VARIABLES.find(v => v.value === value);
return match ? match.label : value.replace(/^\{\{|\}\}$/g, '');
};
// Watch for template changes to initialize/reset body variables
watch(selectedTemplate, (newTemplate, oldTemplate) => {
if (!newTemplate) {
localBodyVariables.value = {};
emit('update:bodyVariables', {});
return;
}
if (!newTemplate.body_variables?.length) {
localBodyVariables.value = {};
emit('update:bodyVariables', {});
return;
}
const isInitialLoad = !oldTemplate;
const vars = {};
newTemplate.body_variables.forEach(key => {
vars[key] = isInitialLoad ? props.bodyVariables[key] || '' : '';
});
localBodyVariables.value = vars;
emit('update:bodyVariables', vars);
});
const updateBodyVariable = (key, value) => {
localBodyVariables.value[key] = value;
emit('update:bodyVariables', { ...localBodyVariables.value });
};
const selectLiquidVariable = (key, liquidVar) => {
localBodyVariables.value[key] = liquidVar;
emit('update:bodyVariables', { ...localBodyVariables.value });
};
const clearVariable = key => {
localBodyVariables.value[key] = '';
emit('update:bodyVariables', { ...localBodyVariables.value });
};
const variableErrors = ref({});
const validate = () => {
const errors = {};
bodyVariableKeys.value.forEach(key => {
if (!localBodyVariables.value[key]?.trim()) {
errors[key] = true;
}
});
variableErrors.value = errors;
return Object.keys(errors).length === 0;
};
const clearErrors = () => {
variableErrors.value = {};
};
defineExpose({ validate, clearErrors });
const fetchAvailableTemplates = async () => {
try {
isLoading.value = true;
const response = await store.dispatch('inboxes/getAvailableCSATTemplates', {
inboxId: props.inboxId,
});
templates.value = response.templates || [];
// Emit initial template data if already selected (page load)
const initial = templates.value.find(tpl => tpl.name === props.modelValue);
if (initial) emit('templateSelected', initial);
} catch {
templates.value = [];
} finally {
isLoading.value = false;
}
};
const syncAndRefetch = async () => {
try {
isSyncing.value = true;
await store.dispatch('inboxes/syncTemplates', props.inboxId);
await fetchAvailableTemplates();
} catch {
useAlert(t('INBOX_MGMT.CSAT.EXISTING_TEMPLATE.SYNC_ERROR'));
} finally {
isSyncing.value = false;
}
};
const handleSelect = value => {
emit('update:modelValue', value);
const selected = templates.value.find(tpl => tpl.name === value);
if (selected) {
emit('templateSelected', selected);
}
};
onMounted(fetchAvailableTemplates);
</script>
<template>
<div class="flex flex-col gap-3">
<div class="flex gap-2 items-end">
<WithLabel
:label="$t('INBOX_MGMT.CSAT.EXISTING_TEMPLATE.LABEL')"
name="existing_template"
class="flex-1"
>
<ComboBox
:model-value="modelValue"
:options="templateOptions"
:placeholder="
isLoading
? $t('INBOX_MGMT.CSAT.EXISTING_TEMPLATE.LOADING')
: $t('INBOX_MGMT.CSAT.EXISTING_TEMPLATE.PLACEHOLDER')
"
:disabled="isLoading"
:empty-state="$t('INBOX_MGMT.CSAT.EXISTING_TEMPLATE.EMPTY_STATE')"
@update:model-value="handleSelect"
/>
</WithLabel>
<NextButton
faded
slate
:label="
isSyncing
? $t('INBOX_MGMT.CSAT.EXISTING_TEMPLATE.SYNCING')
: $t('INBOX_MGMT.CSAT.EXISTING_TEMPLATE.SYNC_BUTTON')
"
icon="i-lucide-refresh-cw"
:is-loading="isSyncing"
@click="syncAndRefetch"
/>
</div>
<!-- Body variable inputs -->
<div v-if="hasBodyVariables" class="flex flex-col gap-3 mt-1">
<div>
<p class="text-sm font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.CSAT.TEMPLATE_VARIABLES.TITLE') }}
</p>
<p class="mt-0.5 text-xs text-n-slate-11">
{{ $t('INBOX_MGMT.CSAT.TEMPLATE_VARIABLES.DESCRIPTION') }}
</p>
</div>
<div
v-for="key in bodyVariableKeys"
:key="key"
class="flex flex-col gap-1"
>
<label class="text-xs font-medium text-n-slate-11">
{{
$t('INBOX_MGMT.CSAT.TEMPLATE_VARIABLES.VARIABLE_LABEL', {
variable: key,
})
}}
</label>
<!-- Show locked pill when a liquid variable is selected -->
<div
v-if="isLiquidVariable(localBodyVariables[key])"
class="flex gap-2 items-center px-3 h-9 rounded-lg border border-n-weak bg-n-alpha-black2"
>
<Icon icon="i-lucide-braces" class="text-n-slate-11 size-3.5" />
<span class="flex-1 text-sm text-n-slate-12">
{{ liquidVariableLabel(localBodyVariables[key]) }}
</span>
<button
type="button"
class="flex items-center p-0.5 rounded transition-colors text-n-slate-10 hover:text-n-slate-12 hover:bg-n-alpha-black2"
@click="clearVariable(key)"
>
<Icon icon="i-lucide-x" class="size-3.5" />
</button>
</div>
<!-- Show text input + variable picker when no liquid variable is selected -->
<div v-else class="flex flex-col gap-1.5">
<Input
:model-value="localBodyVariables[key] || ''"
:placeholder="
$t('INBOX_MGMT.CSAT.TEMPLATE_VARIABLES.VARIABLE_PLACEHOLDER', {
variable: key,
})
"
:message-type="variableErrors[key] ? 'error' : 'info'"
:message="
variableErrors[key]
? $t('INBOX_MGMT.CSAT.TEMPLATE_VARIABLES.VARIABLE_REQUIRED')
: ''
"
@update:model-value="
value => {
updateBodyVariable(key, value);
if (value?.trim()) variableErrors[key] = false;
}
"
/>
<ComboBox
:options="LIQUID_VARIABLES"
:placeholder="
$t('INBOX_MGMT.CSAT.TEMPLATE_VARIABLES.INSERT_VARIABLE')
"
@update:model-value="value => selectLiquidVariable(key, value)"
/>
</div>
</div>
</div>
</div>
</template>

View File

@ -360,6 +360,14 @@ export const actions = {
const response = await InboxesAPI.getCSATTemplateStatus(inboxId);
return response.data;
},
linkCSATTemplate: async (_, { inboxId, template }) => {
const response = await InboxesAPI.linkCSATTemplate(inboxId, template);
return response.data;
},
getAvailableCSATTemplates: async (_, { inboxId }) => {
const response = await InboxesAPI.getAvailableCSATTemplates(inboxId);
return response.data;
},
setupChannelProvider: async (_, inboxId) => {
try {
await InboxesAPI.setupChannelProvider(inboxId);

View File

@ -120,26 +120,66 @@ class CsatSurveyService
end
def build_template_info(template_name, template_config)
components = [
{
type: 'button',
sub_type: 'url',
index: '0',
parameters: [{ type: 'text', text: conversation.uuid }]
}
]
body_params = build_body_parameters(template_config)
components.unshift({ type: 'body', parameters: body_params }) if body_params.present?
{
name: template_name,
lang_code: template_config['language'] || 'en',
parameters: [
{
type: 'button',
sub_type: 'url',
index: '0',
parameters: [{ type: 'text', text: conversation.uuid }]
}
]
parameters: components
}
end
def build_body_parameters(template_config)
body_variables = template_config&.dig('body_variables')
return nil if body_variables.blank?
sorted_keys = body_variables.keys.sort_by(&:to_i)
sorted_keys.map do |key|
value = body_variables[key]
resolved_value = resolve_liquid_variable(value)
{ type: 'text', text: resolved_value }
end
end
def resolve_liquid_variable(value)
return value if value.blank?
template = Liquid::Template.parse(value)
result = template.render(liquid_drops)
result.presence || value
rescue Liquid::Error
value
end
def liquid_drops
@liquid_drops ||= {
'contact' => ContactDrop.new(conversation.contact),
'conversation' => ConversationDrop.new(conversation),
'inbox' => InboxDrop.new(inbox),
'account' => AccountDrop.new(conversation.account)
}
end
def build_csat_message
content = inbox.csat_config&.dig('message') || 'Please rate this conversation'
body_variables = inbox.csat_config&.dig('template', 'body_variables')
body_variables&.each { |key, value| content = content.gsub("{{#{key}}}", resolve_liquid_variable(value)) }
conversation.messages.build(
account: conversation.account,
inbox: inbox,
message_type: :outgoing,
content: inbox.csat_config&.dig('message') || 'Please rate this conversation',
content: content,
content_type: :input_csat
)
end

View File

@ -1,4 +1,4 @@
class CsatTemplateManagementService
class CsatTemplateManagementService # rubocop:disable Metrics/ClassLength
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
DEFAULT_LANGUAGE = 'en'.freeze
@ -26,7 +26,7 @@ class CsatTemplateManagementService
delete_existing_template_if_needed
result = create_template_via_provider(template_params)
update_inbox_csat_config(result) if result[:success]
update_inbox_csat_config(result, source: 'auto_created') if result[:success]
result
rescue StandardError => e
@ -34,8 +34,62 @@ class CsatTemplateManagementService
{ success: false, service_error: 'Template creation failed' }
end
def link_existing_template(template_name, language, body_variables: {})
raise ArgumentError, 'Template name is required' if template_name.blank?
raise ArgumentError, 'Only WhatsApp Cloud channels are supported' unless whatsapp_cloud_channel?
csat_service = Whatsapp::CsatTemplateService.new(@inbox.channel)
status_result = csat_service.get_template_status(template_name)
raise ArgumentError, 'Template not found on Meta' unless status_result[:success]
template_data = find_template_in_synced(template_name)
validate_synced_template!(csat_service, template_data, body_variables)
delete_existing_template_if_needed
save_linked_template(template_name, language, status_result, body_variables: body_variables)
rescue ArgumentError => e
{ success: false, error: e.message }
rescue StandardError => e
Rails.logger.error "Error linking existing CSAT template: #{e.message}"
{ success: false, service_error: 'Failed to link template' }
end
def available_templates
raise ArgumentError, 'Only WhatsApp Cloud channels are supported' unless whatsapp_cloud_channel?
Whatsapp::CsatTemplateService.new(@inbox.channel).available_csat_templates
rescue ArgumentError
[]
rescue StandardError => e
Rails.logger.error "Error fetching available CSAT templates: #{e.message}"
[]
end
private
def whatsapp_cloud_channel?
@inbox.whatsapp? && @inbox.channel&.provider == 'whatsapp_cloud'
end
def validate_body_variables!(required_variables, body_variables)
return if required_variables.blank?
missing = required_variables.select { |key| body_variables[key].blank? }
raise ArgumentError, "All body variables must be set. Missing: #{missing.join(', ')}" if missing.any?
end
def validate_synced_template!(csat_service, template_data, body_variables)
raise ArgumentError, 'Template not found in synced templates. Please sync templates first.' if template_data.blank?
unless csat_service.valid_csat_template?(template_data)
raise ArgumentError, 'Template is not compatible with CSAT surveys. It must have a URL button with a dynamic parameter.'
end
body_text = template_data['components']&.find { |c| c['type'] == 'BODY' }&.dig('text')
validate_body_variables!(csat_service.extract_body_variables(body_text), body_variables)
end
def validate_template_params!(template_params)
raise ActionController::ParameterMissing, 'message' if template_params[:message].blank?
end
@ -69,9 +123,36 @@ class CsatTemplateManagementService
}
end
def update_inbox_csat_config(result)
def find_template_in_synced(template_name)
@inbox.channel.message_templates&.find { |t| t['name'] == template_name }
end
def save_linked_template(template_name, language, status_result, body_variables: {})
current_config = @inbox.csat_config || {}
template_data = build_template_data_from_result(result)
template_data = {
'name' => template_name,
'template_id' => status_result.dig(:template, :id),
'language' => language || status_result.dig(:template, :language) || 'en',
'source' => 'user_selected',
'linked_at' => Time.current.iso8601
}
template_data['body_variables'] = body_variables if body_variables.present?
@inbox.update!(csat_config: current_config.merge('template' => template_data))
{
success: true,
template_name: template_name,
template_id: template_data['template_id'],
language: template_data['language'],
status: status_result.dig(:template, :status),
source: 'user_selected',
linked_at: template_data['linked_at']
}
end
def update_inbox_csat_config(result, source: 'auto_created')
current_config = @inbox.csat_config || {}
template_data = build_template_data_from_result(result).merge('source' => source)
updated_config = current_config.merge('template' => template_data)
@inbox.update!(csat_config: updated_config)
end
@ -149,6 +230,7 @@ class CsatTemplateManagementService
def delete_existing_template_if_needed
template = @inbox.csat_config&.dig('template')
return true if template.blank?
return true if template['source'] == 'user_selected'
if @inbox.twilio_whatsapp?
delete_existing_twilio_template(template)

View File

@ -47,8 +47,50 @@ class Whatsapp::CsatTemplateService
{ success: false, error: e.message }
end
def valid_csat_template?(template)
url_button = extract_url_button(template)
url_button&.dig('url')&.include?('{{1}}') || false
end
def available_csat_templates
templates = @whatsapp_channel.message_templates
return [] if templates.blank?
approved_csat_templates = templates.select { |t| t['status']&.downcase == 'approved' && valid_csat_template?(t) }
approved_csat_templates.map { |template| build_available_template_entry(template) }
end
def extract_body_variables(body_text)
return [] if body_text.blank?
body_text.scan(/\{\{(\d+)\}\}/).flatten.uniq.sort_by(&:to_i)
end
private
def extract_url_button(template)
buttons_component = template['components']&.find { |c| c['type'] == 'BUTTONS' }
return nil unless buttons_component
buttons_component['buttons']&.find { |b| b['type'] == 'URL' }
end
def build_available_template_entry(template)
body = template['components']&.find { |c| c['type'] == 'BODY' }
url_button = extract_url_button(template)
body_text = body&.dig('text')
{
name: template['name'],
language: template['language'],
status: template['status'],
body_text: body_text,
button_text: url_button&.dig('text'),
button_url: url_button&.dig('url'),
body_variables: extract_body_variables(body_text)
}
end
def generate_template_name(base_name)
current_template_name = current_template_name_from_config
CsatTemplateNameService.generate_next_template_name(base_name, @whatsapp_channel.inbox.id, current_template_name)

View File

@ -1,7 +1,7 @@
json.id resource.id
json.csat_survey_response resource.csat_survey_response
json.display_type resource.inbox.csat_config.try(:[], 'display_type') || 'emoji'
json.content resource.inbox.csat_config.try(:[], 'message')
json.content resource.content || resource.inbox.csat_config.try(:[], 'message')
json.inbox_avatar_url resource.inbox.avatar_url
json.inbox_name resource.inbox.name
json.locale resource.account.locale

View File

@ -230,7 +230,10 @@ Rails.application.routes.draw do
end
end
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates'
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates' do
post :link, on: :member
get :available_templates, on: :member
end
end
resources :inbox_members, only: [:create, :show], param: :inbox_id do

View File

@ -380,4 +380,200 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
end
end
end
describe 'POST /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template/link' do
let(:valid_link_params) do
{
template: {
name: 'my_existing_template',
language: 'en'
}
}
end
let(:compatible_template) do
{
'name' => 'my_existing_template',
'status' => 'approved',
'language' => 'en',
'components' => [
{ 'type' => 'BODY', 'text' => 'How was your experience?' },
{
'type' => 'BUTTONS',
'buttons' => [
{ 'type' => 'URL', 'text' => 'Rate us', 'url' => 'https://example.com/survey/{{1}}' }
]
}
]
}
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/link",
params: valid_link_params,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is a WhatsApp channel' do
it 'returns error when template name is missing' do
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/link",
headers: admin.create_new_auth_token,
params: { template: { language: 'en' } },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Template name is required')
end
it 'links an existing template successfully' do
whatsapp_channel.update!(message_templates: [compatible_template])
allow(mock_service).to receive(:get_template_status)
.with('my_existing_template')
.and_return({ success: true, template: { id: '999', name: 'my_existing_template', status: 'APPROVED', language: 'en' } })
allow(mock_service).to receive(:valid_csat_template?)
.with(compatible_template)
.and_return(true)
allow(mock_service).to receive(:extract_body_variables)
.and_return([])
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/link",
headers: admin.create_new_auth_token,
params: valid_link_params,
as: :json
expect(response).to have_http_status(:ok)
response_data = response.parsed_body
expect(response_data['template']['name']).to eq('my_existing_template')
expect(response_data['template']['source']).to eq('user_selected')
whatsapp_inbox.reload
expect(whatsapp_inbox.csat_config.dig('template', 'source')).to eq('user_selected')
end
it 'returns error when template is not found on Meta' do
whatsapp_channel.update!(message_templates: [compatible_template])
allow(mock_service).to receive(:get_template_status)
.with('nonexistent_template')
.and_return({ success: false, error: 'Template not found' })
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/link",
headers: admin.create_new_auth_token,
params: { template: { name: 'nonexistent_template', language: 'en' } },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Template not found on Meta')
end
it 'returns error when template structure is incompatible' do
incompatible_template = {
'name' => 'my_existing_template',
'status' => 'approved',
'language' => 'en',
'components' => [
{ 'type' => 'BODY', 'text' => 'No buttons here' }
]
}
whatsapp_channel.update!(message_templates: [incompatible_template])
allow(mock_service).to receive(:get_template_status)
.with('my_existing_template')
.and_return({ success: true, template: { id: '999', status: 'APPROVED', language: 'en' } })
allow(mock_service).to receive(:valid_csat_template?)
.with(incompatible_template)
.and_return(false)
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/link",
headers: admin.create_new_auth_token,
params: valid_link_params,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to include('not compatible')
end
end
end
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template/available_templates' do
let(:compatible_template) do
{
'name' => 'survey_template',
'status' => 'approved',
'language' => 'en',
'components' => [
{ 'type' => 'BODY', 'text' => 'Rate your experience' },
{
'type' => 'BUTTONS',
'buttons' => [
{ 'type' => 'URL', 'text' => 'Rate', 'url' => 'https://example.com/survey/{{1}}' }
]
}
]
}
end
let(:incompatible_template) do
{
'name' => 'promo_template',
'status' => 'approved',
'language' => 'en',
'components' => [
{ 'type' => 'BODY', 'text' => 'Check out our promo' }
]
}
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/available_templates"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is a WhatsApp channel' do
it 'returns only compatible templates' do
whatsapp_channel.update!(message_templates: [compatible_template, incompatible_template])
allow(mock_service).to receive(:available_csat_templates).and_return([
{
name: 'survey_template',
language: 'en',
status: 'approved',
body_text: 'Rate your experience',
button_text: 'Rate',
button_url: 'https://example.com/survey/{{1}}'
}
])
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/available_templates",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
templates = response.parsed_body['templates']
expect(templates.length).to eq(1)
expect(templates.first['name']).to eq('survey_template')
end
it 'returns empty array when no compatible templates exist' do
whatsapp_channel.update!(message_templates: [incompatible_template])
allow(mock_service).to receive(:available_csat_templates).and_return([])
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/available_templates",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(response.parsed_body['templates']).to eq([])
end
end
end
end

View File

@ -0,0 +1,68 @@
require 'rails_helper'
describe ConversationDrop do
subject(:conversation_drop) { described_class.new(conversation) }
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
describe '#first_reply_created_at' do
it 'returns empty string when first_reply_created_at is nil' do
expect(conversation_drop.first_reply_created_at).to eq ''
end
it 'returns formatted date for en locale' do
conversation.update!(first_reply_created_at: Time.zone.parse('2025-03-15 14:30:00'))
expect(conversation_drop.first_reply_created_at).to eq 'Mar 15, 2025'
end
it 'returns formatted date for pt_BR locale' do
account.update!(locale: 'pt_BR')
conversation.update!(first_reply_created_at: Time.zone.parse('2025-03-15 14:30:00'))
expect(conversation_drop.first_reply_created_at).to eq '15/03/2025'
end
end
describe '#first_reply_created_at_time' do
it 'returns empty string when first_reply_created_at is nil' do
expect(conversation_drop.first_reply_created_at_time).to eq ''
end
it 'returns formatted date with time for en locale' do
conversation.update!(first_reply_created_at: Time.zone.parse('2025-03-15 14:30:00'))
expect(conversation_drop.first_reply_created_at_time).to eq 'Mar 15, 2025 14:30'
end
it 'returns formatted date with time for pt_BR locale' do
account.update!(locale: 'pt_BR')
conversation.update!(first_reply_created_at: Time.zone.parse('2025-03-15 14:30:00'))
expect(conversation_drop.first_reply_created_at_time).to eq '15/03/2025 14:30'
end
end
describe '#last_activity_at' do
it 'returns formatted date' do
conversation.update!(last_activity_at: Time.zone.parse('2025-06-20 09:15:00'))
expect(conversation_drop.last_activity_at).to eq 'Jun 20, 2025'
end
it 'returns formatted date for pt_BR locale' do
account.update!(locale: 'pt_BR')
conversation.update!(last_activity_at: Time.zone.parse('2025-06-20 09:15:00'))
expect(conversation_drop.last_activity_at).to eq '20/06/2025'
end
end
describe '#last_activity_at_time' do
it 'returns formatted date with time' do
conversation.update!(last_activity_at: Time.zone.parse('2025-06-20 09:15:00'))
expect(conversation_drop.last_activity_at_time).to eq 'Jun 20, 2025 09:15'
end
it 'returns formatted date with time for pt_BR locale' do
account.update!(locale: 'pt_BR')
conversation.update!(last_activity_at: Time.zone.parse('2025-06-20 09:15:00'))
expect(conversation_drop.last_activity_at_time).to eq '20/06/2025 09:15'
end
end
end

View File

@ -348,6 +348,95 @@ describe CsatSurveyService do
expect(whatsapp_conversation.messages.where(content_type: :input_csat)).to be_empty
end
end
context 'when template has body variables' do
before do
whatsapp_inbox.update!(csat_config: {
'template' => {
'name' => 'customer_survey_template',
'language' => 'en',
'body_variables' => { '1' => '{{contact.name}}', '2' => 'static text' }
},
'message' => 'Hi {{1}}, {{2}}'
})
allow(mock_provider_service).to receive(:get_template_status)
.with('customer_survey_template')
.and_return({ success: true, template: { status: 'APPROVED' } })
end
it 'includes resolved body parameters in template payload' do
allow(mock_provider_service).to receive(:send_template) do |_phone, template_info, message|
message.save!
body_component = template_info[:parameters].find { |p| p[:type] == 'body' }
expect(body_component).to be_present
expect(body_component[:parameters].length).to eq(2)
expect(body_component[:parameters][0][:type]).to eq('text')
expect(body_component[:parameters][0][:text]).to be_present
expect(body_component[:parameters][1]).to eq({ type: 'text', text: 'static text' })
'msg_id'
end
whatsapp_service.perform
end
it 'resolves liquid variables in CSAT message content' do
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 include('static text')
expect(csat_message.content).not_to include('{{1}}')
expect(csat_message.content).not_to include('{{2}}')
end
it 'falls back to original value in body parameters when liquid variable resolves to empty' do
whatsapp_inbox.update!(csat_config: {
'template' => {
'name' => 'customer_survey_template',
'language' => 'en',
'body_variables' => { '1' => '{{contact.nonexistent_attr}}' }
},
'message' => 'Attr: {{1}}'
})
allow(mock_provider_service).to receive(:send_template) do |_phone, template_info, message|
message.save!
# The body parameter should contain the original liquid variable string as fallback
body_component = template_info[:parameters].find { |p| p[:type] == 'body' }
expect(body_component[:parameters][0][:text]).to eq('{{contact.nonexistent_attr}}')
'msg_id'
end
whatsapp_service.perform
end
end
context 'when template has no body variables' do
before do
whatsapp_inbox.update!(csat_config: {
'template' => {
'name' => 'customer_survey_template',
'language' => 'en'
},
'message' => 'Please rate your experience'
})
allow(mock_provider_service).to receive(:get_template_status)
.with('customer_survey_template')
.and_return({ success: true, template: { status: 'APPROVED' } })
end
it 'does not include body parameters in template payload' do
allow(mock_provider_service).to receive(:send_template) do |_phone, template_info, message|
message.save!
body_component = template_info[:parameters].find { |p| p[:type] == 'body' }
expect(body_component).to be_nil
'msg_id'
end
whatsapp_service.perform
end
end
end
end

View File

@ -373,4 +373,121 @@ RSpec.describe Whatsapp::CsatTemplateService do
end
end
end
describe '#valid_csat_template?' do
it 'returns true for a template with URL button containing {{1}}' do
template = {
'components' => [
{ 'type' => 'BODY', 'text' => 'How was your experience?' },
{
'type' => 'BUTTONS',
'buttons' => [
{ 'type' => 'URL', 'text' => 'Rate us', 'url' => 'https://example.com/survey/{{1}}' }
]
}
]
}
expect(service.valid_csat_template?(template)).to be true
end
it 'returns false for a template without BUTTONS component' do
template = {
'components' => [
{ 'type' => 'BODY', 'text' => 'No buttons here' }
]
}
expect(service.valid_csat_template?(template)).to be false
end
it 'returns false for a template without URL button' do
template = {
'components' => [
{
'type' => 'BUTTONS',
'buttons' => [
{ 'type' => 'QUICK_REPLY', 'text' => 'Yes' }
]
}
]
}
expect(service.valid_csat_template?(template)).to be false
end
it 'returns false for a URL button without {{1}} parameter' do
template = {
'components' => [
{
'type' => 'BUTTONS',
'buttons' => [
{ 'type' => 'URL', 'text' => 'Visit', 'url' => 'https://example.com/static' }
]
}
]
}
expect(service.valid_csat_template?(template)).to be false
end
it 'returns false when components are nil' do
template = { 'components' => nil }
expect(service.valid_csat_template?(template)).to be false
end
end
describe '#available_csat_templates' do
it 'returns approved templates with valid CSAT structure' do
valid_template = {
'name' => 'survey_1',
'status' => 'approved',
'language' => 'en',
'components' => [
{ 'type' => 'BODY', 'text' => 'Rate us please' },
{
'type' => 'BUTTONS',
'buttons' => [
{ 'type' => 'URL', 'text' => 'Rate', 'url' => 'https://example.com/{{1}}' }
]
}
]
}
invalid_template = {
'name' => 'promo',
'status' => 'approved',
'language' => 'en',
'components' => [{ 'type' => 'BODY', 'text' => 'Buy now' }]
}
pending_template = {
'name' => 'survey_pending',
'status' => 'pending',
'language' => 'en',
'components' => [
{ 'type' => 'BODY', 'text' => 'Rate us' },
{
'type' => 'BUTTONS',
'buttons' => [
{ 'type' => 'URL', 'text' => 'Rate', 'url' => 'https://example.com/{{1}}' }
]
}
]
}
whatsapp_channel.update!(message_templates: [valid_template, invalid_template, pending_template])
result = service.available_csat_templates
expect(result.length).to eq(1)
expect(result.first[:name]).to eq('survey_1')
expect(result.first[:body_text]).to eq('Rate us please')
expect(result.first[:button_text]).to eq('Rate')
end
it 'returns empty array when no templates exist' do
whatsapp_channel.update!(message_templates: nil)
expect(service.available_csat_templates).to eq([])
end
end
end