Merge branch 'develop' into feat/email-forwarding

This commit is contained in:
Sivin Varghese
2025-05-16 22:21:22 +05:30
committed by GitHub
59 changed files with 1345 additions and 91 deletions
@@ -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",
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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',
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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
+34 -10
View File
@@ -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>
+1
View File
@@ -33,6 +33,7 @@
#
class Article < ApplicationRecord
include PgSearch::Model
include LlmFormattable
has_many :associated_articles,
class_name: :Article,
+2 -2
View File
@@ -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
+1
View File
@@ -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
+17 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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
+27
View File
@@ -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
+26
View File
@@ -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
@@ -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
@@ -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
+8
View File
@@ -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
+22
View File
@@ -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