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:
parent
f2635a69ed
commit
3b8a38b153
@ -24,6 +24,26 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
|||||||
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def fetch_inbox
|
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
|
render json: { error: 'Message is required' }, status: :unprocessable_entity
|
||||||
end
|
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)
|
def render_template_creation_result(result)
|
||||||
if result[:success]
|
if result[:success]
|
||||||
render_successful_template_creation(result)
|
render_successful_template_creation(result)
|
||||||
|
|||||||
@ -214,7 +214,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController #
|
|||||||
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
||||||
{ csat_config: [:display_type, :message, :button_text, :language,
|
{ csat_config: [:display_type, :message, :button_text, :language,
|
||||||
{ survey_rules: [:operator, { values: [] }],
|
{ 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
|
end
|
||||||
|
|
||||||
def permitted_params(channel_attributes = [])
|
def permitted_params(channel_attributes = [])
|
||||||
|
|||||||
@ -24,8 +24,33 @@ class ConversationDrop < BaseDrop
|
|||||||
custom_attributes.transform_keys(&:to_s)
|
custom_attributes.transform_keys(&:to_s)
|
||||||
end
|
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
|
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)
|
def message_sender_name(sender)
|
||||||
return 'Bot' if sender.blank?
|
return 'Bot' if sender.blank?
|
||||||
return contact_name if sender.instance_of?(Contact)
|
return contact_name if sender.instance_of?(Contact)
|
||||||
|
|||||||
@ -43,6 +43,18 @@ class Inboxes extends CacheEnabledApiClient {
|
|||||||
return axios.get(`${this.url}/${inboxId}/csat_template`);
|
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) {
|
setupChannelProvider(inboxId) {
|
||||||
return axios.post(`${this.url}/${inboxId}/setup_channel_provider`);
|
return axios.post(`${this.url}/${inboxId}/setup_channel_provider`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -953,6 +953,32 @@
|
|||||||
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
|
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
|
||||||
"ERROR_MESSAGE": "Failed to create WhatsApp template"
|
"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": {
|
"TEMPLATE_UPDATE_DIALOG": {
|
||||||
"TITLE": "Edit survey details",
|
"TITLE": "Edit survey details",
|
||||||
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
|
"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"
|
"SELECT_PLACEHOLDER": "select labels"
|
||||||
},
|
},
|
||||||
"NOTE": "Note: CSAT surveys are sent only once per conversation",
|
"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": {
|
"API": {
|
||||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||||
|
|||||||
@ -906,6 +906,32 @@
|
|||||||
"SUCCESS_MESSAGE": "Modelo do WhatsApp criado com sucesso e enviado para aprovação",
|
"SUCCESS_MESSAGE": "Modelo do WhatsApp criado com sucesso e enviado para aprovação",
|
||||||
"ERROR_MESSAGE": "Falha ao criar o modelo do WhatsApp"
|
"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": {
|
"TEMPLATE_UPDATE_DIALOG": {
|
||||||
"TITLE": "Editar detalhes da pesquisa",
|
"TITLE": "Editar detalhes da pesquisa",
|
||||||
"DESCRIPTION": "Vamos excluir o modelo anterior e criar um que será enviado novamente para aprovação do WhatsApp",
|
"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"
|
"SELECT_PLACEHOLDER": "selecionar etiquetas"
|
||||||
},
|
},
|
||||||
"NOTE": "Nota: pesquisas de CSAT são enviadas apenas uma vez por conversa",
|
"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": {
|
"API": {
|
||||||
"SUCCESS_MESSAGE": "Configurações de CSAT atualizadas com sucesso",
|
"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."
|
"ERROR_MESSAGE": "Não foi possível atualizar as configurações do CSAT. Por favor, tente novamente mais tarde."
|
||||||
|
|||||||
@ -15,10 +15,12 @@ import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
|||||||
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
|
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
import Switch from 'next/switch/Switch.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 Input from 'dashboard/components-next/input/Input.vue';
|
||||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||||
import whatsappTemplateLanguages from './whatsappTemplateLanguages.js';
|
import whatsappTemplateLanguages from './whatsappTemplateLanguages.js';
|
||||||
import ConfirmTemplateUpdateDialog from './components/ConfirmTemplateUpdateDialog.vue';
|
import ConfirmTemplateUpdateDialog from './components/ConfirmTemplateUpdateDialog.vue';
|
||||||
|
import ExistingTemplateSelector from './components/ExistingTemplateSelector.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
inbox: { type: Object, required: true },
|
inbox: { type: Object, required: true },
|
||||||
@ -53,6 +55,25 @@ const state = reactive({
|
|||||||
const templateStatus = ref(null);
|
const templateStatus = ref(null);
|
||||||
const templateLoading = ref(false);
|
const templateLoading = ref(false);
|
||||||
const confirmDialog = ref(null);
|
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({
|
const originalTemplateValues = ref({
|
||||||
message: '',
|
message: '',
|
||||||
@ -86,14 +107,59 @@ const languageOptions = computed(() =>
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const messagePreviewData = computed(() => ({
|
const resolvedExistingTemplateBody = computed(() => {
|
||||||
content: state.message || t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER'),
|
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(
|
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 templateApprovalStatus = computed(() => {
|
||||||
const statusMap = {
|
const statusMap = {
|
||||||
APPROVED: {
|
APPROVED: {
|
||||||
@ -171,6 +237,21 @@ const initializeState = () => {
|
|||||||
templateButtonText: state.templateButtonText,
|
templateButtonText: state.templateButtonText,
|
||||||
templateLanguage: state.templateLanguage,
|
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;
|
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 () => {
|
const performSave = async () => {
|
||||||
try {
|
try {
|
||||||
isUpdating.value = true;
|
isUpdating.value = true;
|
||||||
let newTemplateData = null;
|
let newTemplateData = null;
|
||||||
|
|
||||||
// For WhatsApp channels, create template first if needed
|
// For WhatsApp channels, handle template based on mode
|
||||||
if (
|
if (isTemplateRequiredWhatsAppChannel.value && state.csatSurveyEnabled) {
|
||||||
isTemplateRequiredWhatsAppChannel.value &&
|
if (
|
||||||
state.csatSurveyEnabled &&
|
templateMode.value === 'use_existing' &&
|
||||||
shouldCreateTemplate()
|
isAWhatsAppCloudChannel.value
|
||||||
) {
|
) {
|
||||||
try {
|
// Link existing template mode — require selection
|
||||||
newTemplateData = await createTemplate();
|
if (!selectedExistingTemplateName.value) return;
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
// Validate all body variables are filled
|
||||||
error.response?.data?.error ||
|
if (
|
||||||
t('INBOX_MGMT.CSAT.TEMPLATE_CREATION.ERROR_MESSAGE');
|
templateSelectorRef.value &&
|
||||||
useAlert(errorMessage);
|
!templateSelectorRef.value.validate()
|
||||||
return;
|
) {
|
||||||
|
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 = {
|
const csatConfig = {
|
||||||
display_type: state.displayType,
|
display_type: state.displayType,
|
||||||
message: state.message,
|
message:
|
||||||
button_text: state.templateButtonText,
|
templateMode.value === 'use_existing'
|
||||||
|
? existingTemplateBody.value
|
||||||
|
: state.message,
|
||||||
|
button_text:
|
||||||
|
templateMode.value === 'use_existing'
|
||||||
|
? existingTemplateButtonText.value
|
||||||
|
: state.templateButtonText,
|
||||||
language: state.templateLanguage,
|
language: state.templateLanguage,
|
||||||
survey_rules: {
|
survey_rules: {
|
||||||
operator: state.surveyRuleOperator,
|
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 (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
|
// Twilio WhatsApp template format
|
||||||
csatConfig.template = {
|
csatConfig.template = {
|
||||||
friendly_name: newTemplateData.friendly_name,
|
friendly_name: newTemplateData.friendly_name,
|
||||||
content_sid: newTemplateData.content_sid,
|
content_sid: newTemplateData.content_sid,
|
||||||
language: newTemplateData.language,
|
language: newTemplateData.language,
|
||||||
status: newTemplateData.status,
|
status: newTemplateData.status,
|
||||||
|
source: 'auto_created',
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@ -370,6 +515,7 @@ const performSave = async () => {
|
|||||||
template_id: newTemplateData.template_id,
|
template_id: newTemplateData.template_id,
|
||||||
language: newTemplateData.language,
|
language: newTemplateData.language,
|
||||||
status: newTemplateData.status,
|
status: newTemplateData.status,
|
||||||
|
source: 'auto_created',
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -395,6 +541,12 @@ const performSave = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveSettings = 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
|
// Check if we need to show confirmation dialog for WhatsApp template changes
|
||||||
// This applies to both WhatsApp Cloud and Twilio WhatsApp channels
|
// This applies to both WhatsApp Cloud and Twilio WhatsApp channels
|
||||||
if (
|
if (
|
||||||
@ -403,8 +555,12 @@ const saveSettings = async () => {
|
|||||||
hasExistingTemplate() &&
|
hasExistingTemplate() &&
|
||||||
hasTemplateChanges()
|
hasTemplateChanges()
|
||||||
) {
|
) {
|
||||||
confirmDialog.value?.open();
|
// Only show dialog if the existing template was auto-created (will be deleted)
|
||||||
return;
|
const existingSource = props.inbox?.csat_config?.template?.source;
|
||||||
|
if (existingSource !== 'user_selected') {
|
||||||
|
confirmDialog.value?.open();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await performSave();
|
await performSave();
|
||||||
@ -442,40 +598,72 @@ const handleConfirmTemplateUpdate = async () => {
|
|||||||
</WithLabel>
|
</WithLabel>
|
||||||
|
|
||||||
<template v-if="isTemplateRequiredWhatsAppChannel">
|
<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
|
<div
|
||||||
class="flex flex-col gap-4 justify-between w-full lg:flex-row lg:gap-6"
|
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">
|
<div class="flex flex-col gap-3 basis-3/5">
|
||||||
<WithLabel
|
<!-- Use existing template mode -->
|
||||||
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
|
<template
|
||||||
name="message"
|
v-if="
|
||||||
|
templateMode === 'use_existing' && isAWhatsAppCloudChannel
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<Editor
|
<ExistingTemplateSelector
|
||||||
v-model="state.message"
|
ref="templateSelectorRef"
|
||||||
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
|
v-model="selectedExistingTemplateName"
|
||||||
:max-length="200"
|
:inbox-id="inbox.id"
|
||||||
channel-type="Context::Plain"
|
: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"
|
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
|
<WithLabel
|
||||||
v-if="shouldShowTemplateStatus"
|
v-if="shouldShowTemplateStatus"
|
||||||
:label="$t('INBOX_MGMT.CSAT.LANGUAGE.LABEL')"
|
:label="$t('INBOX_MGMT.CSAT.LANGUAGE.LABEL')"
|
||||||
name="language"
|
name="language"
|
||||||
>
|
>
|
||||||
<ComboBox
|
<ComboBox
|
||||||
v-model="state.templateLanguage"
|
v-model="state.templateLanguage"
|
||||||
:options="languageOptions"
|
:options="languageOptions"
|
||||||
:placeholder="$t('INBOX_MGMT.CSAT.LANGUAGE.PLACEHOLDER')"
|
:placeholder="$t('INBOX_MGMT.CSAT.LANGUAGE.PLACEHOLDER')"
|
||||||
/>
|
/>
|
||||||
</WithLabel>
|
</WithLabel>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="shouldShowTemplateStatus"
|
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"
|
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
|
<p
|
||||||
v-if="shouldShowTemplateStatus"
|
|
||||||
class="inline-flex items-center text-sm font-medium text-n-slate-11"
|
class="inline-flex items-center text-sm font-medium text-n-slate-11"
|
||||||
>
|
>
|
||||||
{{ $t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.LABEL') }}
|
{{ $t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.LABEL') }}
|
||||||
@ -513,8 +700,8 @@ const handleConfirmTemplateUpdate = async () => {
|
|||||||
</p>
|
</p>
|
||||||
<CSATTemplate
|
<CSATTemplate
|
||||||
:message="messagePreviewData"
|
:message="messagePreviewData"
|
||||||
:button-text="state.templateButtonText"
|
:button-text="previewButtonText"
|
||||||
:class="shouldShowTemplateStatus ? 'pt-12' : ''"
|
class="pt-12"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -589,6 +776,7 @@ const handleConfirmTemplateUpdate = async () => {
|
|||||||
type="submit"
|
type="submit"
|
||||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||||
:is-loading="isUpdating"
|
:is-loading="isUpdating"
|
||||||
|
:disabled="isUpdateDisabled"
|
||||||
@click="saveSettings"
|
@click="saveSettings"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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>
|
||||||
@ -360,6 +360,14 @@ export const actions = {
|
|||||||
const response = await InboxesAPI.getCSATTemplateStatus(inboxId);
|
const response = await InboxesAPI.getCSATTemplateStatus(inboxId);
|
||||||
return response.data;
|
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) => {
|
setupChannelProvider: async (_, inboxId) => {
|
||||||
try {
|
try {
|
||||||
await InboxesAPI.setupChannelProvider(inboxId);
|
await InboxesAPI.setupChannelProvider(inboxId);
|
||||||
|
|||||||
@ -120,26 +120,66 @@ class CsatSurveyService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def build_template_info(template_name, template_config)
|
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,
|
name: template_name,
|
||||||
lang_code: template_config['language'] || 'en',
|
lang_code: template_config['language'] || 'en',
|
||||||
parameters: [
|
parameters: components
|
||||||
{
|
}
|
||||||
type: 'button',
|
end
|
||||||
sub_type: 'url',
|
|
||||||
index: '0',
|
def build_body_parameters(template_config)
|
||||||
parameters: [{ type: 'text', text: conversation.uuid }]
|
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
|
end
|
||||||
|
|
||||||
def build_csat_message
|
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(
|
conversation.messages.build(
|
||||||
account: conversation.account,
|
account: conversation.account,
|
||||||
inbox: inbox,
|
inbox: inbox,
|
||||||
message_type: :outgoing,
|
message_type: :outgoing,
|
||||||
content: inbox.csat_config&.dig('message') || 'Please rate this conversation',
|
content: content,
|
||||||
content_type: :input_csat
|
content_type: :input_csat
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
class CsatTemplateManagementService
|
class CsatTemplateManagementService # rubocop:disable Metrics/ClassLength
|
||||||
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
|
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
|
||||||
DEFAULT_LANGUAGE = 'en'.freeze
|
DEFAULT_LANGUAGE = 'en'.freeze
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ class CsatTemplateManagementService
|
|||||||
delete_existing_template_if_needed
|
delete_existing_template_if_needed
|
||||||
|
|
||||||
result = create_template_via_provider(template_params)
|
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
|
result
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
@ -34,8 +34,62 @@ class CsatTemplateManagementService
|
|||||||
{ success: false, service_error: 'Template creation failed' }
|
{ success: false, service_error: 'Template creation failed' }
|
||||||
end
|
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
|
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)
|
def validate_template_params!(template_params)
|
||||||
raise ActionController::ParameterMissing, 'message' if template_params[:message].blank?
|
raise ActionController::ParameterMissing, 'message' if template_params[:message].blank?
|
||||||
end
|
end
|
||||||
@ -69,9 +123,36 @@ class CsatTemplateManagementService
|
|||||||
}
|
}
|
||||||
end
|
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 || {}
|
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)
|
updated_config = current_config.merge('template' => template_data)
|
||||||
@inbox.update!(csat_config: updated_config)
|
@inbox.update!(csat_config: updated_config)
|
||||||
end
|
end
|
||||||
@ -149,6 +230,7 @@ class CsatTemplateManagementService
|
|||||||
def delete_existing_template_if_needed
|
def delete_existing_template_if_needed
|
||||||
template = @inbox.csat_config&.dig('template')
|
template = @inbox.csat_config&.dig('template')
|
||||||
return true if template.blank?
|
return true if template.blank?
|
||||||
|
return true if template['source'] == 'user_selected'
|
||||||
|
|
||||||
if @inbox.twilio_whatsapp?
|
if @inbox.twilio_whatsapp?
|
||||||
delete_existing_twilio_template(template)
|
delete_existing_twilio_template(template)
|
||||||
|
|||||||
@ -47,8 +47,50 @@ class Whatsapp::CsatTemplateService
|
|||||||
{ success: false, error: e.message }
|
{ success: false, error: e.message }
|
||||||
end
|
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
|
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)
|
def generate_template_name(base_name)
|
||||||
current_template_name = current_template_name_from_config
|
current_template_name = current_template_name_from_config
|
||||||
CsatTemplateNameService.generate_next_template_name(base_name, @whatsapp_channel.inbox.id, current_template_name)
|
CsatTemplateNameService.generate_next_template_name(base_name, @whatsapp_channel.inbox.id, current_template_name)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
json.id resource.id
|
json.id resource.id
|
||||||
json.csat_survey_response resource.csat_survey_response
|
json.csat_survey_response resource.csat_survey_response
|
||||||
json.display_type resource.inbox.csat_config.try(:[], 'display_type') || 'emoji'
|
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_avatar_url resource.inbox.avatar_url
|
||||||
json.inbox_name resource.inbox.name
|
json.inbox_name resource.inbox.name
|
||||||
json.locale resource.account.locale
|
json.locale resource.account.locale
|
||||||
|
|||||||
@ -230,7 +230,10 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
resources :inbox_members, only: [:create, :show], param: :inbox_id do
|
resources :inbox_members, only: [:create, :show], param: :inbox_id do
|
||||||
|
|||||||
@ -380,4 +380,200 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
|||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
68
spec/drops/conversation_drop_spec.rb
Normal file
68
spec/drops/conversation_drop_spec.rb
Normal 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
|
||||||
@ -348,6 +348,95 @@ describe CsatSurveyService do
|
|||||||
expect(whatsapp_conversation.messages.where(content_type: :input_csat)).to be_empty
|
expect(whatsapp_conversation.messages.where(content_type: :input_csat)).to be_empty
|
||||||
end
|
end
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -373,4 +373,121 @@ RSpec.describe Whatsapp::CsatTemplateService do
|
|||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user