Merge branch 'develop' into feat/email-forwarding
This commit is contained in:
@@ -42,7 +42,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def update
|
||||
@inbox.update!(permitted_params.except(:channel))
|
||||
inbox_params = permitted_params.except(:channel, :csat_config)
|
||||
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
|
||||
@inbox.update!(inbox_params)
|
||||
update_inbox_working_hours
|
||||
update_channel if channel_update_required?
|
||||
end
|
||||
@@ -121,10 +123,22 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
@inbox.channel.save!
|
||||
end
|
||||
|
||||
def format_csat_config(config)
|
||||
{
|
||||
display_type: config['display_type'] || 'emoji',
|
||||
message: config['message'] || '',
|
||||
survey_rules: {
|
||||
operator: config.dig('survey_rules', 'operator') || 'contains',
|
||||
values: config.dig('survey_rules', 'values') || []
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def inbox_attributes
|
||||
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
||||
: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, { survey_rules: [:operator, { values: [] }] }] }]
|
||||
end
|
||||
|
||||
def permitted_params(channel_attributes = [])
|
||||
|
||||
@@ -24,9 +24,10 @@ class Twilio::CallbackController < ApplicationController
|
||||
:Body,
|
||||
:ToCountry,
|
||||
:FromState,
|
||||
:MediaUrl0,
|
||||
:MediaContentType0,
|
||||
:MessagingServiceSid
|
||||
*Array.new(10) { |i| :"MediaUrl#{i}" },
|
||||
*Array.new(10) { |i| :"MediaContentType#{i}" },
|
||||
:MessagingServiceSid,
|
||||
:NumMedia
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -81,6 +81,7 @@ onMounted(() => {
|
||||
<button
|
||||
v-for="(item, index) in filteredMenuItems"
|
||||
:key="index"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
||||
:class="{
|
||||
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
||||
|
||||
@@ -25,6 +25,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'faded',
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const selected = defineModel({
|
||||
@@ -56,7 +60,7 @@ const updateSelected = newValue => {
|
||||
:variant
|
||||
:icon="iconToRender"
|
||||
:trailing-icon="selectedOption.icon ? false : true"
|
||||
:label="hideLabel ? null : selectedOption.label"
|
||||
:label="label || (hideLabel ? null : selectedOption.label)"
|
||||
@click="toggle"
|
||||
/>
|
||||
</slot>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import { computed } from 'vue';
|
||||
import BaseBubble from './Base.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { CSAT_RATINGS } from 'shared/constants/messages';
|
||||
import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
|
||||
const { contentAttributes } = useMessageContext();
|
||||
const { contentAttributes, content } = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
|
||||
const response = computed(() => {
|
||||
@@ -16,6 +16,14 @@ const isRatingSubmitted = computed(() => {
|
||||
return !!response.value.rating;
|
||||
});
|
||||
|
||||
const displayType = computed(() => {
|
||||
return contentAttributes.value?.displayType || CSAT_DISPLAY_TYPES.EMOJI;
|
||||
});
|
||||
|
||||
const isStarRating = computed(() => {
|
||||
return displayType.value === CSAT_DISPLAY_TYPES.STAR;
|
||||
});
|
||||
|
||||
const rating = computed(() => {
|
||||
if (isRatingSubmitted.value) {
|
||||
return CSAT_RATINGS.find(
|
||||
@@ -25,16 +33,33 @@ const rating = computed(() => {
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const starRatingValue = computed(() => {
|
||||
return response.value.rating || 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="px-4 py-3" data-bubble-name="csat">
|
||||
<h4>{{ t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
|
||||
<h4>{{ content || t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
|
||||
<dl v-if="isRatingSubmitted" class="mt-4">
|
||||
<dt class="text-n-slate-11 italic">
|
||||
{{ t('CONVERSATION.RATING_TITLE') }}
|
||||
</dt>
|
||||
<dd>{{ t(rating.translationKey) }}</dd>
|
||||
<dd v-if="!isStarRating">
|
||||
{{ t(rating.translationKey) }}
|
||||
</dd>
|
||||
<dd v-else class="flex mt-1">
|
||||
<span v-for="n in 5" :key="n" class="text-2xl mr-1">
|
||||
<i
|
||||
:class="[
|
||||
n <= starRatingValue
|
||||
? 'i-ri-star-fill text-n-amber-9'
|
||||
: 'i-ri-star-line text-n-slate-10',
|
||||
]"
|
||||
/>
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dt v-if="response.feedbackMessage" class="text-n-slate-11 italic mt-2">
|
||||
{{ t('CONVERSATION.FEEDBACK_TITLE') }}
|
||||
|
||||
@@ -185,6 +185,7 @@ watch(
|
||||
"
|
||||
trailing-icon
|
||||
:disabled="disabled"
|
||||
type="button"
|
||||
class="!h-[1.875rem] top-1 ltr:ml-px rtl:mr-px !px-2 outline-0 !outline-none !rounded-lg border-0 ltr:!rounded-r-none rtl:!rounded-l-none"
|
||||
@click="toggleCountryDropdown"
|
||||
>
|
||||
|
||||
@@ -49,12 +49,12 @@ export default {
|
||||
if (this.isAttributeTypeDate) {
|
||||
return this.value
|
||||
? new Date(this.value || new Date()).toLocaleDateString()
|
||||
: '';
|
||||
: '---';
|
||||
}
|
||||
if (this.isAttributeTypeCheckbox) {
|
||||
return this.value === 'false' ? false : this.value;
|
||||
}
|
||||
return this.value;
|
||||
return this.hasValue ? this.value : '---';
|
||||
},
|
||||
formattedValue() {
|
||||
return this.isAttributeTypeDate
|
||||
@@ -83,6 +83,9 @@ export default {
|
||||
isAttributeTypeDate() {
|
||||
return this.attributeType === 'date';
|
||||
},
|
||||
hasValue() {
|
||||
return this.value !== null && this.value !== '';
|
||||
},
|
||||
urlValue() {
|
||||
return isValidURL(this.value) ? this.value : '---';
|
||||
},
|
||||
@@ -223,7 +226,7 @@ export default {
|
||||
/>
|
||||
</span>
|
||||
<NextButton
|
||||
v-if="showActions && value"
|
||||
v-if="showActions && hasValue"
|
||||
v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
|
||||
slate
|
||||
sm
|
||||
@@ -281,13 +284,13 @@ export default {
|
||||
v-else
|
||||
class="group-hover:bg-n-slate-3 group-hover:dark:bg-n-solid-3 inline-block rounded-sm mb-0 break-all py-0.5 px-1"
|
||||
>
|
||||
{{ displayValue || '---' }}
|
||||
{{ displayValue }}
|
||||
</p>
|
||||
<div
|
||||
class="flex items-center max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0"
|
||||
>
|
||||
<NextButton
|
||||
v-if="showActions && value"
|
||||
v-if="showActions && hasValue"
|
||||
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
|
||||
xs
|
||||
slate
|
||||
|
||||
@@ -481,7 +481,8 @@
|
||||
"PRE_CHAT_FORM": "Pre Chat Form",
|
||||
"BUSINESS_HOURS": "Business Hours",
|
||||
"WIDGET_BUILDER": "Widget Builder",
|
||||
"BOT_CONFIGURATION": "Bot Configuration"
|
||||
"BOT_CONFIGURATION": "Bot Configuration",
|
||||
"CSAT": "CSAT"
|
||||
},
|
||||
"SETTINGS": "Settings",
|
||||
"FEATURES": {
|
||||
@@ -502,9 +503,7 @@
|
||||
"ENABLE_EMAIL_COLLECT_BOX": "Enable email collect box",
|
||||
"ENABLE_EMAIL_COLLECT_BOX_SUB_TEXT": "Enable or disable email collect box on new conversation",
|
||||
"AUTO_ASSIGNMENT": "Enable auto assignment",
|
||||
"ENABLE_CSAT": "Enable CSAT",
|
||||
"SENDER_NAME_SECTION": "Enable Agent Name in Email",
|
||||
"ENABLE_CSAT_SUB_TEXT": "Enable/Disable CSAT(Customer satisfaction) survey after resolving a conversation",
|
||||
"SENDER_NAME_SECTION_TEXT": "Enable/Disable showing Agent's name in email, if disabled it will show business name",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.",
|
||||
@@ -578,6 +577,32 @@
|
||||
"LABEL": "Visitors should provide their name and email address before starting the chat"
|
||||
}
|
||||
},
|
||||
"CSAT": {
|
||||
"TITLE": "Enable CSAT",
|
||||
"SUBTITLE": "Automatically trigger CSAT surveys at the end of conversations to understand how customers feel about their support experience. Track satisfaction trends and identify areas for improvement over time.",
|
||||
"DISPLAY_TYPE": {
|
||||
"LABEL": "Display type"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"LABEL": "Message",
|
||||
"PLACEHOLDER": "Please enter a message to show users with the form"
|
||||
},
|
||||
"SURVEY_RULE": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
"DESCRIPTION_SUFFIX": "any of the labels",
|
||||
"OPERATOR": {
|
||||
"CONTAINS": "contains",
|
||||
"DOES_NOT_CONTAINS": "does not contain"
|
||||
},
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"NOTE": "Note: CSAT surveys are sent only once per conversation",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
}
|
||||
},
|
||||
"BUSINESS_HOURS": {
|
||||
"TITLE": "Set your availability",
|
||||
"SUBTITLE": "Set your availability on your livechat widget",
|
||||
|
||||
+3
-7
@@ -80,17 +80,13 @@ const filteredCustomAttributes = computed(() =>
|
||||
customAttributes.value,
|
||||
attribute.attribute_key
|
||||
);
|
||||
const isCheckbox = attribute.attribute_display_type === 'checkbox';
|
||||
const defaultValue = isCheckbox ? false : '';
|
||||
|
||||
return {
|
||||
...attribute,
|
||||
type: 'custom_attribute',
|
||||
key: attribute.attribute_key,
|
||||
// Set value from customAttributes if it exists, otherwise use default value
|
||||
value: hasValue
|
||||
? customAttributes.value[attribute.attribute_key]
|
||||
: defaultValue,
|
||||
// Set value from customAttributes if it exists, otherwise use ''
|
||||
value: hasValue ? customAttributes.value[attribute.attribute_key] : '',
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -215,7 +211,7 @@ const onUpdate = async (key, value) => {
|
||||
} else {
|
||||
store.dispatch('contacts/update', {
|
||||
id: props.contactId,
|
||||
custom_attributes: updatedAttributes,
|
||||
customAttributes: updatedAttributes,
|
||||
});
|
||||
}
|
||||
useAlert(t('CUSTOM_ATTRIBUTES.FORM.UPDATE.SUCCESS'));
|
||||
|
||||
@@ -15,6 +15,7 @@ import PreChatFormSettings from './PreChatForm/Settings.vue';
|
||||
import WeeklyAvailability from './components/WeeklyAvailability.vue';
|
||||
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
|
||||
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
|
||||
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
|
||||
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
|
||||
import WidgetBuilder from './WidgetBuilder.vue';
|
||||
import BotConfiguration from './components/BotConfiguration.vue';
|
||||
@@ -28,6 +29,7 @@ export default {
|
||||
BotConfiguration,
|
||||
CollaboratorsPage,
|
||||
ConfigurationPage,
|
||||
CustomerSatisfactionPage,
|
||||
FacebookReauthorize,
|
||||
GreetingsEditor,
|
||||
PreChatFormSettings,
|
||||
@@ -53,7 +55,6 @@ export default {
|
||||
greetingEnabled: true,
|
||||
greetingMessage: '',
|
||||
emailCollectEnabled: false,
|
||||
csatSurveyEnabled: false,
|
||||
senderNameType: 'friendly',
|
||||
businessName: '',
|
||||
locktoSingleConversation: false,
|
||||
@@ -107,6 +108,10 @@ export default {
|
||||
key: 'businesshours',
|
||||
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
|
||||
},
|
||||
{
|
||||
key: 'csat',
|
||||
name: this.$t('INBOX_MGMT.TABS.CSAT'),
|
||||
},
|
||||
];
|
||||
|
||||
if (this.isAWebWidgetInbox) {
|
||||
@@ -277,7 +282,6 @@ export default {
|
||||
this.greetingEnabled = this.inbox.greeting_enabled || false;
|
||||
this.greetingMessage = this.inbox.greeting_message || '';
|
||||
this.emailCollectEnabled = this.inbox.enable_email_collect;
|
||||
this.csatSurveyEnabled = this.inbox.csat_survey_enabled;
|
||||
this.senderNameType = this.inbox.sender_name_type;
|
||||
this.businessName = this.inbox.business_name;
|
||||
this.allowMessagesAfterResolved =
|
||||
@@ -300,7 +304,6 @@ export default {
|
||||
id: this.currentInboxId,
|
||||
name: this.selectedInboxName,
|
||||
enable_email_collect: this.emailCollectEnabled,
|
||||
csat_survey_enabled: this.csatSurveyEnabled,
|
||||
allow_messages_after_resolved: this.allowMessagesAfterResolved,
|
||||
greeting_enabled: this.greetingEnabled,
|
||||
greeting_message: this.greetingMessage || '',
|
||||
@@ -589,21 +592,6 @@ export default {
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<label class="pb-4">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_CSAT') }}
|
||||
<select v-model="csatSurveyEnabled">
|
||||
<option :value="true">
|
||||
{{ $t('INBOX_MGMT.EDIT.ENABLE_CSAT.ENABLED') }}
|
||||
</option>
|
||||
<option :value="false">
|
||||
{{ $t('INBOX_MGMT.EDIT.ENABLE_CSAT.DISABLED') }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="pb-1 text-sm not-italic text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_CSAT_SUB_TEXT') }}
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<label v-if="isAWebWidgetInbox" class="pb-4">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ALLOW_MESSAGES_AFTER_RESOLVED') }}
|
||||
<select v-model="allowMessagesAfterResolved">
|
||||
@@ -802,6 +790,9 @@ export default {
|
||||
<div v-if="selectedTabKey === 'configuration'">
|
||||
<ConfigurationPage :inbox="inbox" />
|
||||
</div>
|
||||
<div v-if="selectedTabKey === 'csat'">
|
||||
<CustomerSatisfactionPage :inbox="inbox" />
|
||||
</div>
|
||||
<div v-if="selectedTabKey === 'preChatForm'">
|
||||
<PreChatFormSettings :inbox="inbox" />
|
||||
</div>
|
||||
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
<script setup>
|
||||
import { reactive, onMounted, ref, defineProps, watch, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||
|
||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||
import SectionLayout from 'dashboard/routes/dashboard/settings/account/components/SectionLayout.vue';
|
||||
import CSATDisplayTypeSelector from './components/CSATDisplayTypeSelector.vue';
|
||||
import 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';
|
||||
|
||||
const props = defineProps({
|
||||
inbox: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
|
||||
const isUpdating = ref(false);
|
||||
const selectedLabelValues = ref([]);
|
||||
const currentLabel = ref('');
|
||||
|
||||
const state = reactive({
|
||||
csatSurveyEnabled: false,
|
||||
displayType: 'emoji',
|
||||
message: '',
|
||||
surveyRuleOperator: 'contains',
|
||||
});
|
||||
|
||||
const filterTypes = [
|
||||
{
|
||||
label: t('INBOX_MGMT.CSAT.SURVEY_RULE.OPERATOR.CONTAINS'),
|
||||
value: 'contains',
|
||||
},
|
||||
{
|
||||
label: t('INBOX_MGMT.CSAT.SURVEY_RULE.OPERATOR.DOES_NOT_CONTAINS'),
|
||||
value: 'does_not_contain',
|
||||
},
|
||||
];
|
||||
|
||||
const labelOptions = computed(() =>
|
||||
labels.value?.length
|
||||
? labels.value
|
||||
.map(label => ({ label: label.title, value: label.title }))
|
||||
.filter(label => !selectedLabelValues.value.includes(label.value))
|
||||
: []
|
||||
);
|
||||
|
||||
const initializeState = () => {
|
||||
if (!props.inbox) return;
|
||||
|
||||
const { csat_survey_enabled, csat_config } = props.inbox;
|
||||
|
||||
state.csatSurveyEnabled = csat_survey_enabled || false;
|
||||
|
||||
if (!csat_config) return;
|
||||
|
||||
const {
|
||||
display_type: displayType = CSAT_DISPLAY_TYPES.EMOJI,
|
||||
message = '',
|
||||
survey_rules: surveyRules = {},
|
||||
} = csat_config;
|
||||
|
||||
state.displayType = displayType;
|
||||
state.message = message;
|
||||
state.surveyRuleOperator = surveyRules.operator || 'contains';
|
||||
|
||||
selectedLabelValues.value = Array.isArray(surveyRules.values)
|
||||
? [...surveyRules.values]
|
||||
: [];
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeState();
|
||||
if (!labels.value?.length) store.dispatch('labels/get');
|
||||
});
|
||||
|
||||
watch(() => props.inbox, initializeState, { immediate: true });
|
||||
|
||||
const handleLabelSelect = value => {
|
||||
if (!value || selectedLabelValues.value.includes(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedLabelValues.value.push(value);
|
||||
};
|
||||
|
||||
const updateDisplayType = type => {
|
||||
state.displayType = type;
|
||||
};
|
||||
|
||||
const updateSurveyRuleOperator = operator => {
|
||||
state.surveyRuleOperator = operator;
|
||||
};
|
||||
|
||||
const removeLabel = label => {
|
||||
const index = selectedLabelValues.value.indexOf(label);
|
||||
if (index !== -1) {
|
||||
selectedLabelValues.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const updateInbox = async attributes => {
|
||||
const payload = {
|
||||
id: props.inbox.id,
|
||||
formData: false,
|
||||
...attributes,
|
||||
};
|
||||
|
||||
return store.dispatch('inboxes/updateInbox', payload);
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
isUpdating.value = true;
|
||||
|
||||
const csatConfig = {
|
||||
display_type: state.displayType,
|
||||
message: state.message,
|
||||
survey_rules: {
|
||||
operator: state.surveyRuleOperator,
|
||||
values: selectedLabelValues.value,
|
||||
},
|
||||
};
|
||||
|
||||
await updateInbox({
|
||||
csat_survey_enabled: state.csatSurveyEnabled,
|
||||
csat_config: csatConfig,
|
||||
});
|
||||
|
||||
useAlert(t('INBOX_MGMT.CSAT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(t('INBOX_MGMT.CSAT.API.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
isUpdating.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-8">
|
||||
<SectionLayout
|
||||
:title="$t('INBOX_MGMT.CSAT.TITLE')"
|
||||
:description="$t('INBOX_MGMT.CSAT.SUBTITLE')"
|
||||
>
|
||||
<template #headerActions>
|
||||
<div class="flex justify-end">
|
||||
<Switch v-model="state.csatSurveyEnabled" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="grid gap-5">
|
||||
<WithLabel
|
||||
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
|
||||
name="display_type"
|
||||
>
|
||||
<CSATDisplayTypeSelector
|
||||
:selected-type="state.displayType"
|
||||
@update="updateDisplayType"
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
<WithLabel :label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')" name="message">
|
||||
<Editor
|
||||
v-model="state.message"
|
||||
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
|
||||
:max-length="200"
|
||||
class="w-full"
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
<WithLabel
|
||||
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.LABEL')"
|
||||
name="survey_rule"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<span
|
||||
class="inline-flex flex-wrap items-center gap-1.5 text-sm text-n-slate-12"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_PREFIX') }}
|
||||
<FilterSelect
|
||||
v-model="state.surveyRuleOperator"
|
||||
variant="faded"
|
||||
:options="filterTypes"
|
||||
class="inline-flex shrink-0"
|
||||
@update:model-value="updateSurveyRuleOperator"
|
||||
/>
|
||||
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_SUFFIX') }}
|
||||
|
||||
<NextButton
|
||||
v-for="label in selectedLabelValues"
|
||||
:key="label"
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
trailing-icon
|
||||
:label="label"
|
||||
icon="i-lucide-x"
|
||||
class="inline-flex shrink-0"
|
||||
@click="removeLabel(label)"
|
||||
/>
|
||||
<FilterSelect
|
||||
v-model="currentLabel"
|
||||
:options="labelOptions"
|
||||
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.SELECT_PLACEHOLDER')"
|
||||
hide-label
|
||||
variant="faded"
|
||||
class="inline-flex shrink-0"
|
||||
@update:model-value="handleLabelSelect"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</WithLabel>
|
||||
<p class="text-sm italic text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.CSAT.NOTE') }}
|
||||
</p>
|
||||
<div>
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||
:is-loading="isUpdating"
|
||||
@click="saveSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionLayout>
|
||||
</div>
|
||||
</template>
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||
import CSATEmojiInput from './CSATEmojiInput.vue';
|
||||
import CSATStarInput from './CSATStarInput.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedType: {
|
||||
type: String,
|
||||
default: CSAT_DISPLAY_TYPES.EMOJI,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['update']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-6 mt-2">
|
||||
<CSATEmojiInput
|
||||
:selected="props.selectedType === CSAT_DISPLAY_TYPES.EMOJI"
|
||||
@update="emit('update', $event)"
|
||||
/>
|
||||
<CSATStarInput
|
||||
:selected="props.selectedType === CSAT_DISPLAY_TYPES.STAR"
|
||||
@update="emit('update', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||
|
||||
const props = defineProps({
|
||||
selected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const selectionClass = computed(() => {
|
||||
return props.selected
|
||||
? 'outline-n-brand bg-n-brand/5'
|
||||
: 'outline-n-weak bg-n-alpha-black2';
|
||||
});
|
||||
|
||||
const emojis = CSAT_RATINGS;
|
||||
const selectedEmoji = ref(5);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="flex items-center rounded-lg transition-all duration-500 cursor-pointer outline outline-1 px-4 py-2 gap-2 min-w-56"
|
||||
:class="selectionClass"
|
||||
@click="emit('update', CSAT_DISPLAY_TYPES.EMOJI)"
|
||||
>
|
||||
<div
|
||||
v-for="emoji in emojis"
|
||||
:key="emoji.key"
|
||||
class="rounded-full p-1 transition-transform duration-150 focus:outline-none flex items-center flex-shrink-0"
|
||||
>
|
||||
<span
|
||||
class="text-2xl"
|
||||
:class="selectedEmoji === emoji.value ? '' : 'grayscale opacity-60'"
|
||||
>
|
||||
{{ emoji.emoji }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
selected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const selectionClass = computed(() => {
|
||||
return props.selected
|
||||
? 'bg-n-brand/5 outline-n-brand'
|
||||
: 'bg-n-alpha-black2 outline-n-weak';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="flex items-center rounded-lg transition-all duration-300 cursor-pointer outline outline-1 px-4 py-2 gap-2 min-w-56"
|
||||
:class="selectionClass"
|
||||
@click="emit('update', CSAT_DISPLAY_TYPES.STAR)"
|
||||
>
|
||||
<div
|
||||
v-for="n in 5"
|
||||
:key="'star-' + n"
|
||||
class="rounded-full p-1 transition-transform duration-150 focus:outline-none flex items-center flex-shrink-0"
|
||||
:aria-label="`Star ${n}`"
|
||||
>
|
||||
<i class="i-ri-star-fill text-n-amber-9 text-2xl" />
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,14 +1,16 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import { CSAT_RATINGS } from 'shared/constants/messages';
|
||||
import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import StarRating from 'shared/components/StarRating.vue';
|
||||
import { getContrastingTextColor } from '@chatwoot/utils';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Spinner,
|
||||
FluentIcon,
|
||||
StarRating,
|
||||
},
|
||||
props: {
|
||||
messageContentAttributes: {
|
||||
@@ -19,6 +21,14 @@ export default {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
displayType: {
|
||||
type: String,
|
||||
default: CSAT_DISPLAY_TYPES.EMOJI,
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -47,7 +57,13 @@ export default {
|
||||
title() {
|
||||
return this.isRatingSubmitted
|
||||
? this.$t('CSAT.SUBMITTED_TITLE')
|
||||
: this.$t('CSAT.TITLE');
|
||||
: this.message || this.$t('CSAT.TITLE');
|
||||
},
|
||||
isEmojiType() {
|
||||
return this.displayType === CSAT_DISPLAY_TYPES.EMOJI;
|
||||
},
|
||||
isStarType() {
|
||||
return this.displayType === CSAT_DISPLAY_TYPES.STAR;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -88,10 +104,15 @@ export default {
|
||||
this.isUpdating = false;
|
||||
}
|
||||
},
|
||||
|
||||
selectRating(rating) {
|
||||
this.selectedRating = rating.value;
|
||||
this.onSubmit();
|
||||
},
|
||||
selectStarRating(value) {
|
||||
this.selectedRating = value;
|
||||
this.onSubmit();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -104,7 +125,7 @@ export default {
|
||||
<h6 class="text-n-slate-12 text-sm font-medium pt-5 px-2.5 text-center">
|
||||
{{ title }}
|
||||
</h6>
|
||||
<div class="ratings flex justify-around py-5 px-4">
|
||||
<div v-if="isEmojiType" class="ratings flex justify-around py-5 px-4">
|
||||
<button
|
||||
v-for="rating in ratings"
|
||||
:key="rating.key"
|
||||
@@ -114,6 +135,12 @@ export default {
|
||||
{{ rating.emoji }}
|
||||
</button>
|
||||
</div>
|
||||
<StarRating
|
||||
v-else-if="isStarType"
|
||||
:selected-rating="selectedRating"
|
||||
:is-disabled="isRatingSubmitted"
|
||||
@select-rating="selectStarRating"
|
||||
/>
|
||||
<form
|
||||
v-if="!isFeedbackSubmitted"
|
||||
class="feedback-form flex"
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { ref, defineProps, defineEmits } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedRating: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
isDisabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectRating']);
|
||||
|
||||
const starRatings = [1, 2, 3, 4, 5];
|
||||
const hoveredRating = ref(0);
|
||||
|
||||
const onHoverRating = value => {
|
||||
if (props.isDisabled) return;
|
||||
hoveredRating.value = value;
|
||||
};
|
||||
|
||||
const selectRating = value => {
|
||||
if (props.isDisabled) return;
|
||||
emit('selectRating', value);
|
||||
};
|
||||
|
||||
const getStarClass = value => {
|
||||
const isStarActive =
|
||||
(hoveredRating.value > 0 &&
|
||||
!props.isDisabled &&
|
||||
hoveredRating.value >= value) ||
|
||||
props.selectedRating >= value;
|
||||
|
||||
const starTypeClass = isStarActive
|
||||
? 'i-ri-star-fill text-n-amber-9'
|
||||
: 'i-ri-star-line text-n-slate-10';
|
||||
|
||||
return starTypeClass;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-center py-5 px-4 gap-3">
|
||||
<button
|
||||
v-for="value in starRatings"
|
||||
:key="value"
|
||||
type="button"
|
||||
class="rounded-full p-1 transition-all duration-200 focus:enabled:scale-[1.2] focus-within:enabled:scale-[1.2] hover:enabled:scale-[1.2] focus:outline-none flex items-center flex-shrink-0"
|
||||
:class="{ 'cursor-not-allowed opacity-50': isDisabled }"
|
||||
:disabled="isDisabled"
|
||||
:aria-label="'Star ' + value"
|
||||
@click="selectRating(value)"
|
||||
@mouseenter="onHoverRating(value)"
|
||||
@mouseleave="onHoverRating(0)"
|
||||
>
|
||||
<span
|
||||
:class="getStarClass(value)"
|
||||
class="transition-all duration-500 text-2xl"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -100,6 +100,11 @@ export const CSAT_RATINGS = [
|
||||
},
|
||||
];
|
||||
|
||||
export const CSAT_DISPLAY_TYPES = {
|
||||
EMOJI: 'emoji',
|
||||
STAR: 'star',
|
||||
};
|
||||
|
||||
export const AUDIO_FORMATS = {
|
||||
WEBM: 'audio/webm',
|
||||
OGG: 'audio/ogg',
|
||||
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app" class="woot-survey-wrap min-h-screen">
|
||||
<div id="app" dir="ltr" class="woot-survey-wrap min-h-screen">
|
||||
<Response />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@import 'tailwindcss/utilities';
|
||||
@import 'widget/assets/scss/reset';
|
||||
@import 'shared/assets/fonts/widget_fonts';
|
||||
@import 'dashboard/assets/scss/next-colors';
|
||||
|
||||
html,
|
||||
body {
|
||||
|
||||
@@ -24,7 +24,7 @@ export default {
|
||||
class="ion-checkmark-circled text-3xl text-green-500 mr-1"
|
||||
/>
|
||||
<i v-if="showError" class="ion-android-alert text-3xl text-red-600 mr-1" />
|
||||
<label class="text-base font-medium text-black-800 mt-4 mb-4">
|
||||
<label class="text-base font-medium text-n-slate-12 mt-4 mb-4">
|
||||
{{ message }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div class="mt-6">
|
||||
<label class="text-base font-medium text-black-800">
|
||||
<label class="text-base font-medium text-n-slate-12">
|
||||
{{ $t('SURVEY.FEEDBACK.LABEL') }}
|
||||
</label>
|
||||
<TextArea
|
||||
|
||||
@@ -5,8 +5,11 @@ import Spinner from 'shared/components/Spinner.vue';
|
||||
import Rating from 'survey/components/Rating.vue';
|
||||
import Feedback from 'survey/components/Feedback.vue';
|
||||
import Banner from 'survey/components/Banner.vue';
|
||||
import StarRating from 'shared/components/StarRating.vue';
|
||||
import { getSurveyDetails, updateSurvey } from 'survey/api/survey';
|
||||
|
||||
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||
|
||||
export default {
|
||||
name: 'Response',
|
||||
components: {
|
||||
@@ -15,6 +18,7 @@ export default {
|
||||
Spinner,
|
||||
Banner,
|
||||
Feedback,
|
||||
StarRating,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -26,6 +30,8 @@ export default {
|
||||
isUpdating: false,
|
||||
logo: '',
|
||||
inboxName: '',
|
||||
displayType: CSAT_DISPLAY_TYPES.EMOJI,
|
||||
messageContent: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -42,16 +48,22 @@ export default {
|
||||
isButtonDisabled() {
|
||||
return !(this.selectedRating && this.feedback);
|
||||
},
|
||||
isEmojiType() {
|
||||
return this.displayType === CSAT_DISPLAY_TYPES.EMOJI;
|
||||
},
|
||||
isStarType() {
|
||||
return this.displayType === CSAT_DISPLAY_TYPES.STAR;
|
||||
},
|
||||
shouldShowBanner() {
|
||||
return this.isRatingSubmitted || this.errorMessage;
|
||||
},
|
||||
enableFeedbackForm() {
|
||||
return !this.isFeedbackSubmitted && this.isRatingSubmitted;
|
||||
},
|
||||
shouldShowErrorMesage() {
|
||||
shouldShowErrorMessage() {
|
||||
return !!this.errorMessage;
|
||||
},
|
||||
shouldShowSuccessMesage() {
|
||||
shouldShowSuccessMessage() {
|
||||
return !!this.isRatingSubmitted;
|
||||
},
|
||||
message() {
|
||||
@@ -82,6 +94,10 @@ export default {
|
||||
this.surveyDetails = result?.data?.csat_survey_response;
|
||||
this.selectedRating = this.surveyDetails?.rating;
|
||||
this.feedbackMessage = this.surveyDetails?.feedback_message || '';
|
||||
this.displayType = result.data.display_type || CSAT_DISPLAY_TYPES.EMOJI;
|
||||
this.messageContent =
|
||||
result.data.content ||
|
||||
this.$t('SURVEY.DESCRIPTION', { inboxName: this.inboxName });
|
||||
this.setLocale(result.data.locale);
|
||||
} catch (error) {
|
||||
const errorMessage = error?.response?.data?.message;
|
||||
@@ -129,41 +145,49 @@ export default {
|
||||
<template>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center flex-1 h-full min-h-screen bg-black-25"
|
||||
class="flex items-center justify-center flex-1 h-full min-h-screen bg-n-background"
|
||||
>
|
||||
<Spinner size="" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center w-full h-full min-h-screen overflow-auto bg-slate-50"
|
||||
class="flex items-center justify-center w-full h-full min-h-screen overflow-auto bg-n-background"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col w-full h-full bg-white rounded-lg shadow-lg lg:w-2/5 lg:h-auto"
|
||||
class="flex flex-col w-full h-full bg-n-solid-1 rounded-lg border border-solid border-n-weak shadow-md lg:w-2/5 lg:h-auto"
|
||||
>
|
||||
<div class="w-full px-12 pt-12 pb-6 m-auto my-0">
|
||||
<img v-if="logo" :src="logo" alt="Chatwoot logo" class="mb-6 logo" />
|
||||
<p
|
||||
v-if="!isRatingSubmitted"
|
||||
class="mb-8 text-lg leading-relaxed text-black-700"
|
||||
class="mb-8 text-lg leading-relaxed text-n-slate-12"
|
||||
>
|
||||
{{ $t('SURVEY.DESCRIPTION', { inboxName }) }}
|
||||
{{ messageContent }}
|
||||
</p>
|
||||
<Banner
|
||||
v-if="shouldShowBanner"
|
||||
:show-success="shouldShowSuccessMesage"
|
||||
:show-error="shouldShowErrorMesage"
|
||||
:show-success="shouldShowSuccessMessage"
|
||||
:show-error="shouldShowErrorMessage"
|
||||
:message="message"
|
||||
/>
|
||||
<label
|
||||
v-if="!isRatingSubmitted"
|
||||
class="mb-4 text-base font-medium text-black-800"
|
||||
class="mb-4 text-base font-medium text-n-slate-11"
|
||||
>
|
||||
{{ $t('SURVEY.RATING.LABEL') }}
|
||||
</label>
|
||||
<Rating
|
||||
v-if="isEmojiType"
|
||||
:selected-rating="selectedRating"
|
||||
@select-rating="selectRating"
|
||||
/>
|
||||
<StarRating
|
||||
v-if="isStarType"
|
||||
:selected-rating="selectedRating"
|
||||
:is-disabled="isRatingSubmitted"
|
||||
class="[&>button>span]:text-4xl !justify-start !px-0"
|
||||
@select-rating="selectRating"
|
||||
/>
|
||||
<Feedback
|
||||
v-if="enableFeedbackForm"
|
||||
:is-updating="isUpdating"
|
||||
|
||||
@@ -144,6 +144,8 @@ export default {
|
||||
<CustomerSatisfaction
|
||||
v-if="isCSAT"
|
||||
:message-content-attributes="messageContentAttributes.submitted_values"
|
||||
:display-type="messageContentAttributes.display_type"
|
||||
:message="message"
|
||||
:message-id="messageId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
#
|
||||
class Article < ApplicationRecord
|
||||
include PgSearch::Model
|
||||
include LlmFormattable
|
||||
|
||||
has_many :associated_articles,
|
||||
class_name: :Article,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
module LlmFormattable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def to_llm_text
|
||||
LlmFormatter::LlmTextFormatterService.new(self).format
|
||||
def to_llm_text(config = {})
|
||||
LlmFormatter::LlmTextFormatterService.new(self).format(config)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
# auto_assignment_config :jsonb
|
||||
# business_name :string
|
||||
# channel_type :string
|
||||
# csat_config :jsonb not null
|
||||
# csat_survey_enabled :boolean default(FALSE)
|
||||
# email_address :string
|
||||
# enable_auto_assignment :boolean default(TRUE)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
class LlmFormatter::ArticleLlmFormatter
|
||||
attr_reader :article
|
||||
|
||||
def initialize(article)
|
||||
@article = article
|
||||
end
|
||||
|
||||
def format(*)
|
||||
<<~TEXT
|
||||
Title: #{article.title}
|
||||
ID: #{article.id}
|
||||
Status: #{article.status}
|
||||
Category: #{article.category&.name || 'Uncategorized'}
|
||||
Author: #{article.author&.name || 'Unknown'}
|
||||
Views: #{article.views}
|
||||
Created At: #{article.created_at}
|
||||
Updated At: #{article.updated_at}
|
||||
Content:
|
||||
#{article.content}
|
||||
TEXT
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
class LlmFormatter::ContactLlmFormatter < LlmFormatter::DefaultLlmFormatter
|
||||
def format
|
||||
def format(*)
|
||||
sections = []
|
||||
sections << "Contact ID: ##{@record.id}"
|
||||
sections << 'Contact Attributes:'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
|
||||
def format
|
||||
def format(config = {})
|
||||
sections = []
|
||||
sections << "Conversation ID: ##{@record.display_id}"
|
||||
sections << "Channel: #{@record.inbox.channel.name}"
|
||||
@@ -10,6 +10,7 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
|
||||
'No messages in this conversation'
|
||||
end
|
||||
|
||||
sections << "Contact Details: #{@record.contact.to_llm_text}" if config[:include_contact_details]
|
||||
sections.join("\n")
|
||||
end
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ class LlmFormatter::DefaultLlmFormatter
|
||||
@record = record
|
||||
end
|
||||
|
||||
def format
|
||||
def format(*)
|
||||
# override this
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,9 +3,9 @@ class LlmFormatter::LlmTextFormatterService
|
||||
@record = record
|
||||
end
|
||||
|
||||
def format
|
||||
def format(config = {})
|
||||
formatter_class = find_formatter
|
||||
formatter_class.new(@record).format
|
||||
formatter_class.new(@record).format(config)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -2,6 +2,8 @@ class MessageTemplates::Template::CsatSurvey
|
||||
pattr_initialize [:conversation!]
|
||||
|
||||
def perform
|
||||
return unless should_send_csat_survey?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
conversation.messages.create!(csat_survey_message_params)
|
||||
end
|
||||
@@ -9,8 +11,47 @@ class MessageTemplates::Template::CsatSurvey
|
||||
|
||||
private
|
||||
|
||||
delegate :contact, :account, to: :conversation
|
||||
delegate :inbox, to: :message
|
||||
delegate :contact, :account, :inbox, to: :conversation
|
||||
delegate :csat_config, to: :inbox
|
||||
|
||||
def should_send_csat_survey?
|
||||
return true unless survey_rules_configured?
|
||||
|
||||
labels = conversation.label_list
|
||||
|
||||
return true if rule_values.empty?
|
||||
|
||||
case rule_operator
|
||||
when 'contains'
|
||||
rule_values.any? { |label| labels.include?(label) }
|
||||
when 'does_not_contain'
|
||||
rule_values.none? { |label| labels.include?(label) }
|
||||
else
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def survey_rules_configured?
|
||||
return false if csat_config.blank?
|
||||
return false if csat_config['survey_rules'].blank?
|
||||
return false if rule_values.empty?
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def rule_operator
|
||||
csat_config.dig('survey_rules', 'operator') || 'contains'
|
||||
end
|
||||
|
||||
def rule_values
|
||||
csat_config.dig('survey_rules', 'values') || []
|
||||
end
|
||||
|
||||
def message_content
|
||||
return I18n.t('conversations.templates.csat_input_message_body') if csat_config.blank? || csat_config['message'].blank?
|
||||
|
||||
csat_config['message']
|
||||
end
|
||||
|
||||
def csat_survey_message_params
|
||||
{
|
||||
@@ -18,7 +59,18 @@ class MessageTemplates::Template::CsatSurvey
|
||||
inbox_id: @conversation.inbox_id,
|
||||
message_type: :template,
|
||||
content_type: :input_csat,
|
||||
content: I18n.t('conversations.templates.csat_input_message_body')
|
||||
content: message_content,
|
||||
content_attributes: content_attributes
|
||||
}
|
||||
end
|
||||
|
||||
def csat_config
|
||||
inbox.csat_config || {}
|
||||
end
|
||||
|
||||
def content_attributes
|
||||
{
|
||||
display_type: csat_config['display_type'] || 'emoji'
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -106,15 +106,22 @@ class Twilio::IncomingMessageService
|
||||
end
|
||||
|
||||
def attach_files
|
||||
return if params[:MediaUrl0].blank?
|
||||
num_media = params[:NumMedia].to_i
|
||||
return if num_media.zero?
|
||||
|
||||
attachment_file = download_attachment_file
|
||||
num_media.times do |i|
|
||||
media_url = params[:"MediaUrl#{i}"]
|
||||
attach_single_file(media_url) if media_url.present?
|
||||
end
|
||||
end
|
||||
|
||||
def attach_single_file(media_url)
|
||||
attachment_file = download_attachment_file(media_url)
|
||||
return if attachment_file.blank?
|
||||
|
||||
@message.attachments.new(
|
||||
account_id: @message.account_id,
|
||||
file_type: file_type(params[:MediaContentType0]),
|
||||
file_type: file_type(attachment_file.content_type),
|
||||
file: {
|
||||
io: attachment_file,
|
||||
filename: attachment_file.original_filename,
|
||||
@@ -123,24 +130,22 @@ class Twilio::IncomingMessageService
|
||||
)
|
||||
end
|
||||
|
||||
def download_attachment_file
|
||||
download_with_auth
|
||||
def download_attachment_file(media_url)
|
||||
download_with_auth(media_url)
|
||||
rescue Down::Error, Down::ClientError => e
|
||||
handle_download_attachment_error(e)
|
||||
handle_download_attachment_error(e, media_url)
|
||||
end
|
||||
|
||||
def download_with_auth
|
||||
def download_with_auth(media_url)
|
||||
Down.download(
|
||||
params[:MediaUrl0],
|
||||
# https://support.twilio.com/hc/en-us/articles/223183748-Protect-Media-Access-with-HTTP-Basic-Authentication-for-Programmable-Messaging
|
||||
media_url,
|
||||
http_basic_authentication: [twilio_channel.account_sid, twilio_channel.auth_token || twilio_channel.api_key_sid]
|
||||
)
|
||||
end
|
||||
|
||||
# This is just a temporary workaround since some users have not yet enabled media protection. We will remove this in the future.
|
||||
def handle_download_attachment_error(error)
|
||||
def handle_download_attachment_error(error, media_url)
|
||||
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying"
|
||||
Down.download(params[:MediaUrl0])
|
||||
Down.download(media_url)
|
||||
rescue StandardError => e
|
||||
Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping"
|
||||
nil
|
||||
|
||||
@@ -8,6 +8,7 @@ json.greeting_message resource.greeting_message
|
||||
json.working_hours_enabled resource.working_hours_enabled
|
||||
json.enable_email_collect resource.enable_email_collect
|
||||
json.csat_survey_enabled resource.csat_survey_enabled
|
||||
json.csat_config resource.csat_config
|
||||
json.enable_auto_assignment resource.enable_auto_assignment
|
||||
json.auto_assignment_config resource.auto_assignment_config
|
||||
json.out_of_office_message resource.out_of_office_message
|
||||
|
||||
@@ -1,5 +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.inbox_avatar_url resource.inbox.avatar_url
|
||||
json.inbox_name resource.inbox.name
|
||||
json.locale resource.account.locale
|
||||
|
||||
+4
-1
@@ -58,9 +58,12 @@ Rails.application.routes.draw do
|
||||
end
|
||||
resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id
|
||||
end
|
||||
resources :documents, only: [:index, :show, :create, :destroy]
|
||||
resources :assistant_responses
|
||||
resources :bulk_actions, only: [:create]
|
||||
resources :copilot_threads, only: [:index] do
|
||||
resources :copilot_messages, only: [:index]
|
||||
end
|
||||
resources :documents, only: [:index, :show, :create, :destroy]
|
||||
end
|
||||
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
|
||||
delete :avatar, on: :member
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
class CreateCopilotThreads < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :copilot_threads do |t|
|
||||
t.string :title, null: false
|
||||
t.references :user, null: false, index: true
|
||||
t.references :account, null: false, index: true
|
||||
t.uuid :uuid, null: false, default: 'gen_random_uuid()'
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :copilot_threads, :uuid, unique: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
class CreateCopilotMessages < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :copilot_messages do |t|
|
||||
t.references :copilot_thread, null: false, index: true
|
||||
t.references :user, null: false, index: true
|
||||
t.references :account, null: false, index: true
|
||||
t.string :message_type, null: false
|
||||
t.jsonb :message, null: false, default: {}
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddCsatConfigToInboxes < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :inboxes, :csat_config, :jsonb, default: {}, null: false
|
||||
end
|
||||
end
|
||||
+27
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.0].define(version: 2025_04_21_085134) do
|
||||
ActiveRecord::Schema[7.0].define(version: 2025_05_14_045638) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -575,6 +575,31 @@ ActiveRecord::Schema[7.0].define(version: 2025_04_21_085134) do
|
||||
t.index ["waiting_since"], name: "index_conversations_on_waiting_since"
|
||||
end
|
||||
|
||||
create_table "copilot_messages", force: :cascade do |t|
|
||||
t.bigint "copilot_thread_id", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.bigint "account_id", null: false
|
||||
t.string "message_type", null: false
|
||||
t.jsonb "message", default: {}, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_copilot_messages_on_account_id"
|
||||
t.index ["copilot_thread_id"], name: "index_copilot_messages_on_copilot_thread_id"
|
||||
t.index ["user_id"], name: "index_copilot_messages_on_user_id"
|
||||
end
|
||||
|
||||
create_table "copilot_threads", force: :cascade do |t|
|
||||
t.string "title", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.bigint "account_id", null: false
|
||||
t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_copilot_threads_on_account_id"
|
||||
t.index ["user_id"], name: "index_copilot_threads_on_user_id"
|
||||
t.index ["uuid"], name: "index_copilot_threads_on_uuid", unique: true
|
||||
end
|
||||
|
||||
create_table "csat_survey_responses", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
@@ -704,6 +729,7 @@ ActiveRecord::Schema[7.0].define(version: 2025_04_21_085134) do
|
||||
t.bigint "portal_id"
|
||||
t.integer "sender_name_type", default: 0, null: false
|
||||
t.string "business_name"
|
||||
t.jsonb "csat_config", default: {}, null: false
|
||||
t.index ["account_id"], name: "index_inboxes_on_account_id"
|
||||
t.index ["channel_id", "channel_type"], name: "index_inboxes_on_channel_id_and_channel_type"
|
||||
t.index ["portal_id"], name: "index_inboxes_on_portal_id"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action -> { check_authorization(Captain::Assistant) }
|
||||
before_action :set_copilot_thread
|
||||
|
||||
def index
|
||||
@copilot_messages = @copilot_thread
|
||||
.copilot_messages
|
||||
.order(created_at: :asc)
|
||||
.page(permitted_params[:page] || 1)
|
||||
.per(1000)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_copilot_thread
|
||||
@copilot_thread = Current.account.copilot_threads.find_by!(
|
||||
uuid: params[:copilot_thread_id], user_id: Current.user.id
|
||||
)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:page)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action -> { check_authorization(Captain::Assistant) }
|
||||
|
||||
def index
|
||||
@copilot_threads = Current.account.copilot_threads
|
||||
.where(user_id: Current.user.id)
|
||||
.includes(:user)
|
||||
.order(created_at: :desc)
|
||||
.page(permitted_params[:page] || 1)
|
||||
.per(5)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def permitted_params
|
||||
params.permit(:page)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: copilot_messages
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# message :jsonb not null
|
||||
# message_type :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# copilot_thread_id :bigint not null
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_copilot_messages_on_account_id (account_id)
|
||||
# index_copilot_messages_on_copilot_thread_id (copilot_thread_id)
|
||||
# index_copilot_messages_on_user_id (user_id)
|
||||
#
|
||||
class CopilotMessage < ApplicationRecord
|
||||
belongs_to :copilot_thread
|
||||
belongs_to :user
|
||||
belongs_to :account
|
||||
|
||||
validates :message_type, presence: true, inclusion: { in: %w[user assistant assistant_thinking] }
|
||||
validates :message, presence: true
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: copilot_threads
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# title :string not null
|
||||
# uuid :uuid not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_copilot_threads_on_account_id (account_id)
|
||||
# index_copilot_threads_on_user_id (user_id)
|
||||
# index_copilot_threads_on_uuid (uuid) UNIQUE
|
||||
#
|
||||
class CopilotThread < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :account
|
||||
has_many :copilot_messages, dependent: :destroy
|
||||
|
||||
validates :title, presence: true
|
||||
validates :uuid, presence: true, uniqueness: true
|
||||
end
|
||||
@@ -9,5 +9,7 @@ module Enterprise::Concerns::Account
|
||||
has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant'
|
||||
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
|
||||
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
|
||||
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,8 @@ module Enterprise::Concerns::User
|
||||
before_validation :ensure_installation_pricing_plan_quantity, on: :create
|
||||
|
||||
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :copilot_messages, dependent: :destroy_async
|
||||
end
|
||||
|
||||
def ensure_installation_pricing_plan_quantity
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
json.payload do
|
||||
json.array! @copilot_messages do |message|
|
||||
json.id message.id
|
||||
json.message message.message
|
||||
json.message_type message.message_type
|
||||
json.created_at message.created_at.to_i
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
json.payload do
|
||||
json.array! @copilot_threads do |thread|
|
||||
json.id thread.id
|
||||
json.title thread.title
|
||||
json.uuid thread.uuid
|
||||
json.created_at thread.created_at.to_i
|
||||
json.user do
|
||||
json.id thread.user.id
|
||||
json.name thread.user.name
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -717,6 +717,94 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
expect(email_channel.reload.smtp_authentication).to eq('plain')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling CSAT configuration' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:csat_config) do
|
||||
{
|
||||
'display_type' => 'emoji',
|
||||
'message' => 'How would you rate your experience?',
|
||||
'survey_rules' => {
|
||||
'operator' => 'contains',
|
||||
'values' => %w[support help]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'successfully updates the inbox with CSAT configuration' do
|
||||
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
|
||||
params: {
|
||||
csat_survey_enabled: true,
|
||||
csat_config: csat_config
|
||||
},
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
context 'when CSAT is configured' do
|
||||
before do
|
||||
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
|
||||
params: {
|
||||
csat_survey_enabled: true,
|
||||
csat_config: csat_config
|
||||
},
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end
|
||||
|
||||
it 'returns configured CSAT settings in inbox details' do
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['csat_survey_enabled']).to be true
|
||||
|
||||
saved_config = json_response['csat_config']
|
||||
expect(saved_config).to be_present
|
||||
expect(saved_config['display_type']).to eq('emoji')
|
||||
end
|
||||
|
||||
it 'returns configured CSAT message' do
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
json_response = response.parsed_body
|
||||
saved_config = json_response['csat_config']
|
||||
expect(saved_config['message']).to eq('How would you rate your experience?')
|
||||
end
|
||||
|
||||
it 'returns configured CSAT survey rules' do
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
json_response = response.parsed_body
|
||||
saved_config = json_response['csat_config']
|
||||
expect(saved_config['survey_rules']['operator']).to eq('contains')
|
||||
expect(saved_config['survey_rules']['values']).to match_array(%w[support help])
|
||||
end
|
||||
|
||||
it 'includes CSAT configuration in inbox list' do
|
||||
get "/api/v1/accounts/#{account.id}/inboxes",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
inbox_list = response.parsed_body
|
||||
found_inbox = inbox_list['payload'].find { |i| i['id'] == inbox.id }
|
||||
|
||||
expect(found_inbox['csat_survey_enabled']).to be true
|
||||
expect(found_inbox['csat_config']).to be_present
|
||||
expect(found_inbox['csat_config']['display_type']).to eq('emoji')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/agent_bot' do
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account, role: :administrator) }
|
||||
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) }
|
||||
let!(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread, user: user, account: account) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.uuid}/copilot_messages' do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns all messages' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{copilot_thread.uuid}/copilot_messages",
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['payload'].length).to eq(1)
|
||||
expect(json_response['payload'][0]['id']).to eq(copilot_message.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when thread uuid is invalid' do
|
||||
it 'returns not found error' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/invalid-uuid/copilot_messages",
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads' do
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'does not fetch copilot threads' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'fetches copilot threads for the current user' do
|
||||
# Create threads for the current agent
|
||||
create_list(:captain_copilot_thread, 3, account: account, user: agent)
|
||||
# Create threads for another user (should not be included)
|
||||
create_list(:captain_copilot_thread, 2, account: account, user: admin)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:payload].length).to eq(3)
|
||||
|
||||
expect(json_response[:payload].map { |thread| thread[:user][:id] }.uniq).to eq([agent.id])
|
||||
end
|
||||
|
||||
it 'returns threads in descending order of creation' do
|
||||
threads = create_list(:captain_copilot_thread, 3, account: account, user: agent)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:payload].pluck(:id)).to eq(threads.reverse.pluck(:id))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
FactoryBot.define do
|
||||
factory :captain_copilot_message, class: 'CopilotMessage' do
|
||||
account
|
||||
user
|
||||
copilot_thread { association :captain_copilot_thread }
|
||||
message { { content: 'This is a test message' } }
|
||||
message_type { 'user' }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
FactoryBot.define do
|
||||
factory :captain_copilot_thread, class: 'CopilotThread' do
|
||||
account
|
||||
user
|
||||
title { Faker::Lorem.sentence }
|
||||
uuid { SecureRandom.uuid }
|
||||
end
|
||||
end
|
||||
@@ -167,4 +167,26 @@ RSpec.describe Article do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#to_llm_text' do
|
||||
it 'returns formatted article text' do
|
||||
category = create(:category, name: 'Test Category', slug: 'test_category', portal_id: portal_1.id)
|
||||
article = create(:article, title: 'Test Article', category_id: category.id, content: 'This is the content', portal_id: portal_1.id,
|
||||
author_id: user.id)
|
||||
expected_output = <<~TEXT
|
||||
Title: #{article.title}
|
||||
ID: #{article.id}
|
||||
Status: #{article.status}
|
||||
Category: #{category.name}
|
||||
Author: #{user.name}
|
||||
Views: #{article.views}
|
||||
Created At: #{article.created_at}
|
||||
Updated At: #{article.updated_at}
|
||||
Content:
|
||||
#{article.content}
|
||||
TEXT
|
||||
|
||||
expect(article.to_llm_text).to eq(expected_output)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe LlmFormatter::ArticleLlmFormatter do
|
||||
let(:account) { create(:account) }
|
||||
let(:portal) { create(:portal, account: account) }
|
||||
let(:category) { create(:category, slug: 'test_category', portal: portal, account: account) }
|
||||
let(:author) { create(:user, account: account) }
|
||||
let(:formatter) { described_class.new(article) }
|
||||
|
||||
describe '#format' do
|
||||
context 'when article has all details' do
|
||||
let(:article) do
|
||||
create(:article,
|
||||
slug: 'test_article',
|
||||
portal: portal, category: category, author: author, views: 100, account: account)
|
||||
end
|
||||
|
||||
it 'formats article details correctly' do
|
||||
expected_output = <<~TEXT
|
||||
Title: #{article.title}
|
||||
ID: #{article.id}
|
||||
Status: #{article.status}
|
||||
Category: #{category.name}
|
||||
Author: #{author.name}
|
||||
Views: #{article.views}
|
||||
Created At: #{article.created_at}
|
||||
Updated At: #{article.updated_at}
|
||||
Content:
|
||||
#{article.content}
|
||||
TEXT
|
||||
|
||||
expect(formatter.format).to eq(expected_output)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when article has no category' do
|
||||
let(:article) { create(:article, portal: portal, category: nil, author: author, account: account) }
|
||||
|
||||
it 'shows Uncategorized for category' do
|
||||
expect(formatter.format).to include('Category: Uncategorized')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe LlmFormatter::ContactLlmFormatter do
|
||||
let(:account) { create(:account) }
|
||||
let(:contact) { create(:contact, account: account, name: 'John Doe', email: 'john@example.com', phone_number: '+1234567890') }
|
||||
let(:formatter) { described_class.new(contact) }
|
||||
|
||||
describe '#format' do
|
||||
context 'when contact has no notes' do
|
||||
it 'formats contact details correctly' do
|
||||
expected_output = [
|
||||
"Contact ID: ##{contact.id}",
|
||||
'Contact Attributes:',
|
||||
'Name: John Doe',
|
||||
'Email: john@example.com',
|
||||
'Phone: +1234567890',
|
||||
'Location: ',
|
||||
'Country Code: ',
|
||||
'Contact Notes:',
|
||||
'No notes for this contact'
|
||||
].join("\n")
|
||||
|
||||
expect(formatter.format).to eq(expected_output)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact has notes' do
|
||||
before do
|
||||
create(:note, account: account, contact: contact, content: 'First interaction')
|
||||
create(:note, account: account, contact: contact, content: 'Follow up needed')
|
||||
end
|
||||
|
||||
it 'includes notes in the output' do
|
||||
expected_output = [
|
||||
"Contact ID: ##{contact.id}",
|
||||
'Contact Attributes:',
|
||||
'Name: John Doe',
|
||||
'Email: john@example.com',
|
||||
'Phone: +1234567890',
|
||||
'Location: ',
|
||||
'Country Code: ',
|
||||
'Contact Notes:',
|
||||
' - First interaction',
|
||||
' - Follow up needed'
|
||||
].join("\n")
|
||||
|
||||
expect(formatter.format).to eq(expected_output)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact has custom attributes' do
|
||||
let!(:custom_attribute) do
|
||||
create(:custom_attribute_definition, account: account, attribute_model: 'contact_attribute', attribute_display_name: 'Company')
|
||||
end
|
||||
|
||||
before do
|
||||
contact.update(custom_attributes: { custom_attribute.attribute_key => 'Acme Inc' })
|
||||
end
|
||||
|
||||
it 'includes custom attributes in the output' do
|
||||
expected_output = [
|
||||
"Contact ID: ##{contact.id}",
|
||||
'Contact Attributes:',
|
||||
'Name: John Doe',
|
||||
'Email: john@example.com',
|
||||
'Phone: +1234567890',
|
||||
'Location: ',
|
||||
'Country Code: ',
|
||||
'Company: Acme Inc',
|
||||
'Contact Notes:',
|
||||
'No notes for this contact'
|
||||
].join("\n")
|
||||
|
||||
expect(formatter.format).to eq(expected_output)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -47,5 +47,19 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do
|
||||
expect(formatter.format).to eq(expected_output)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when include_contact_details is true' do
|
||||
it 'includes contact details' do
|
||||
expected_output = [
|
||||
"Conversation ID: ##{conversation.display_id}",
|
||||
"Channel: #{conversation.inbox.channel.name}",
|
||||
'Message History:',
|
||||
'No messages in this conversation',
|
||||
"Contact Details: #{conversation.contact.to_llm_text}"
|
||||
].join("\n")
|
||||
|
||||
expect(formatter.format(include_contact_details: true)).to eq(expected_output)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,13 +1,100 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe MessageTemplates::Template::CsatSurvey do
|
||||
context 'when this hook is called' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:service) { described_class.new(conversation: conversation) }
|
||||
|
||||
it 'creates the out of office messages' do
|
||||
described_class.new(conversation: conversation).perform
|
||||
expect(conversation.messages.template.count).to eq(1)
|
||||
expect(conversation.messages.template.first.content_type).to eq('input_csat')
|
||||
describe '#perform' do
|
||||
context 'when no survey rules are configured' do
|
||||
it 'creates a CSAT survey message' do
|
||||
inbox.update(csat_config: {})
|
||||
|
||||
service.perform
|
||||
|
||||
expect(conversation.messages.template.count).to eq(1)
|
||||
expect(conversation.messages.template.first.content_type).to eq('input_csat')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with contains operator' do
|
||||
let(:csat_config) do
|
||||
{
|
||||
'display_type' => 'emoji',
|
||||
'message' => 'Please rate your experience',
|
||||
'survey_rules' => {
|
||||
'operator' => 'contains',
|
||||
'values' => %w[support help]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
inbox.update(csat_config: csat_config)
|
||||
end
|
||||
|
||||
context 'when conversation has matching labels' do
|
||||
it 'creates a CSAT survey message' do
|
||||
conversation.update(label_list: %w[support urgent])
|
||||
|
||||
service.perform
|
||||
|
||||
expect(conversation.messages.template.count).to eq(1)
|
||||
message = conversation.messages.template.first
|
||||
expect(message.content_type).to eq('input_csat')
|
||||
expect(message.content).to eq('Please rate your experience')
|
||||
expect(message.content_attributes['display_type']).to eq('emoji')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation has no matching labels' do
|
||||
it 'does not create a CSAT survey message' do
|
||||
conversation.update(label_list: %w[billing-support payment])
|
||||
|
||||
service.perform
|
||||
|
||||
expect(conversation.messages.template.count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with does_not_contain operator' do
|
||||
let(:csat_config) do
|
||||
{
|
||||
'display_type' => 'emoji',
|
||||
'message' => 'Please rate your experience',
|
||||
'survey_rules' => {
|
||||
'operator' => 'does_not_contain',
|
||||
'values' => %w[support help]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
inbox.update(csat_config: csat_config)
|
||||
end
|
||||
|
||||
context 'when conversation does not have matching labels' do
|
||||
it 'creates a CSAT survey message' do
|
||||
conversation.update(label_list: %w[billing payment])
|
||||
|
||||
service.perform
|
||||
|
||||
expect(conversation.messages.template.count).to eq(1)
|
||||
expect(conversation.messages.template.first.content_type).to eq('input_csat')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation has matching labels' do
|
||||
it 'does not create a CSAT survey message' do
|
||||
conversation.update(label_list: %w[support urgent])
|
||||
|
||||
service.perform
|
||||
|
||||
expect(conversation.messages.template.count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -173,7 +173,7 @@ describe Twilio::IncomingMessageService do
|
||||
context 'when a message with an attachment is received' do
|
||||
before do
|
||||
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
|
||||
.to_return(status: 200, body: 'image data', headers: {})
|
||||
.to_return(status: 200, body: 'image data', headers: { 'Content-Type' => 'image/png' })
|
||||
end
|
||||
|
||||
let(:params_with_attachment) do
|
||||
@@ -203,7 +203,7 @@ describe Twilio::IncomingMessageService do
|
||||
.to_raise(Down::Error.new('Download error'))
|
||||
|
||||
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
|
||||
.to_return(status: 200, body: 'image data', headers: {})
|
||||
.to_return(status: 200, body: 'image data', headers: { 'Content-Type' => 'image/png' })
|
||||
end
|
||||
|
||||
let(:params_with_attachment_error) do
|
||||
@@ -229,5 +229,36 @@ describe Twilio::IncomingMessageService do
|
||||
expect(conversation.reload.messages.last.attachments.first.file_type).to eq('image')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a message with multiple attachments is received' do
|
||||
before do
|
||||
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
|
||||
.to_return(status: 200, body: 'image data 1', headers: { 'Content-Type' => 'image/png' })
|
||||
stub_request(:get, 'https://chatwoot-assets.local/sample.jpg')
|
||||
.to_return(status: 200, body: 'image data 2', headers: { 'Content-Type' => 'image/jpeg' })
|
||||
end
|
||||
|
||||
let(:params_with_multiple_attachments) do
|
||||
{
|
||||
SmsSid: 'SMxx',
|
||||
From: '+12345',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
Body: 'testing multiple media',
|
||||
NumMedia: '2',
|
||||
MediaContentType0: 'image/png',
|
||||
MediaUrl0: 'https://chatwoot-assets.local/sample.png',
|
||||
MediaContentType1: 'image/jpeg',
|
||||
MediaUrl1: 'https://chatwoot-assets.local/sample.jpg'
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates a new message with multiple media attachments in existing conversation' do
|
||||
described_class.new(params: params_with_multiple_attachments).perform
|
||||
expect(conversation.reload.messages.last.content).to eq('testing multiple media')
|
||||
expect(conversation.reload.messages.last.attachments.count).to eq(2)
|
||||
expect(conversation.reload.messages.last.attachments.map(&:file_type)).to contain_exactly('image', 'image')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user