Merge branch 'chatwoot:develop' into chatwoot/develop

This commit is contained in:
Gabriel Jablonski 2025-06-09 19:48:11 -03:00 committed by GitHub
commit 53310d89c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
85 changed files with 1556 additions and 351 deletions

View File

@ -14,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search, :filter] before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes] before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update] before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
def index def index
@contacts_count = resolved_contacts.count @contacts_count = resolved_contacts.count
@ -56,7 +56,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
.get_available_contact_ids(Current.account.id)) .get_available_contact_ids(Current.account.id))
@contacts_count = contacts.count @contacts_count = contacts.count
@contacts = contacts.page(@current_page) @contacts = fetch_contacts(contacts)
end end
def show; end def show; end

View File

@ -124,6 +124,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversation.save! @conversation.save!
end end
def destroy
authorize @conversation, :destroy?
::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip)
head :ok
end
private private
def permitted_update_params def permitted_update_params

View File

@ -92,7 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
end end
def settings_params def settings_params
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :auto_resolve_label) params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
end end
def check_signup_enabled def check_signup_enabled

View File

@ -71,6 +71,7 @@ class OauthCallbackController < ApplicationController
def create_channel_with_inbox def create_channel_with_inbox
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
channel_email = Channel::Email.create!(email: users_data['email'], account: account) channel_email = Channel::Email.create!(email: users_data['email'], account: account)
account.inboxes.create!( account.inboxes.create!(
account: account, account: account,
channel: channel_email, channel: channel_email,

View File

@ -2,6 +2,13 @@ class SuperAdmin::AccountUsersController < SuperAdmin::ApplicationController
# Overwrite any of the RESTful controller actions to implement custom behavior # Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated. # For example, you may want to send an email after a foo is updated.
# #
# Since account/user page - account user role attribute links to the show page
# Handle with a redirect to the user show page
def show
redirect_to super_admin_user_path(requested_resource.user)
end
def create def create
resource = resource_class.new(resource_params) resource = resource_class.new(resource_params)
authorize_resource(resource) authorize_resource(resource)

View File

@ -61,6 +61,11 @@ class ContactAPI extends ApiClient {
return axios.get(requestURL); return axios.get(requestURL);
} }
active(page = 1, sortAttr = 'name') {
let requestURL = `${this.url}/active?${buildContactParams(page, sortAttr)}`;
return axios.get(requestURL);
}
// eslint-disable-next-line default-param-last // eslint-disable-next-line default-param-last
filter(page = 1, sortAttr = 'name', queryPayload) { filter(page = 1, sortAttr = 'name', queryPayload) {
let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`; let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`;

View File

@ -137,6 +137,10 @@ class ConversationApi extends ApiClient {
getInboxAssistant(conversationId) { getInboxAssistant(conversationId) {
return axios.get(`${this.url}/${conversationId}/inbox_assistant`); return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
} }
delete(conversationId) {
return axios.delete(`${this.url}/${conversationId}`);
}
} }
export default new ConversationApi(); export default new ConversationApi();

View File

@ -17,6 +17,7 @@ const props = defineProps({
additionalAttributes: { type: Object, default: () => ({}) }, additionalAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' }, phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' }, thumbnail: { type: String, default: '' },
availabilityStatus: { type: String, default: null },
isExpanded: { type: Boolean, default: false }, isExpanded: { type: Boolean, default: false },
isUpdating: { type: Boolean, default: false }, isUpdating: { type: Boolean, default: false },
}); });
@ -92,7 +93,13 @@ const onClickViewDetails = () => emit('showContact', props.id);
<template> <template>
<CardLayout :key="id" layout="row"> <CardLayout :key="id" layout="row">
<div class="flex items-center justify-start flex-1 gap-4"> <div class="flex items-center justify-start flex-1 gap-4">
<Avatar :name="name" :src="thumbnail" :size="48" rounded-full /> <Avatar
:name="name"
:src="thumbnail"
:size="48"
:status="availabilityStatus"
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1"> <div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1"> <div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12"> <span class="text-base font-medium truncate text-n-slate-12">

View File

@ -7,42 +7,16 @@ import ContactMoreActions from './components/ContactMoreActions.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue'; import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
defineProps({ defineProps({
showSearch: { showSearch: { type: Boolean, default: true },
type: Boolean, searchValue: { type: String, default: '' },
default: true, headerTitle: { type: String, required: true },
}, buttonLabel: { type: String, default: '' },
searchValue: { activeSort: { type: String, default: 'last_activity_at' },
type: String, activeOrdering: { type: String, default: '' },
default: '', isSegmentsView: { type: Boolean, default: false },
}, hasActiveFilters: { type: Boolean, default: false },
headerTitle: { isLabelView: { type: Boolean, default: false },
type: String, isActiveView: { type: Boolean, default: false },
required: true,
},
buttonLabel: {
type: String,
default: '',
},
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
isSegmentsView: {
type: Boolean,
default: false,
},
hasActiveFilters: {
type: Boolean,
default: false,
},
isLabelView: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits([ const emit = defineEmits([
@ -85,7 +59,7 @@ const emit = defineEmits([
</Input> </Input>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div v-if="!isLabelView" class="relative"> <div v-if="!isLabelView && !isActiveView" class="relative">
<Button <Button
id="toggleContactsFilterButton" id="toggleContactsFilterButton"
:icon=" :icon="
@ -105,7 +79,12 @@ const emit = defineEmits([
<slot name="filter" /> <slot name="filter" />
</div> </div>
<Button <Button
v-if="hasActiveFilters && !isSegmentsView && !isLabelView" v-if="
hasActiveFilters &&
!isSegmentsView &&
!isLabelView &&
!isActiveView
"
icon="i-lucide-save" icon="i-lucide-save"
color="slate" color="slate"
size="sm" size="sm"
@ -113,7 +92,7 @@ const emit = defineEmits([
@click="emit('createSegment')" @click="emit('createSegment')"
/> />
<Button <Button
v-if="isSegmentsView && !isLabelView" v-if="isSegmentsView && !isLabelView && !isActiveView"
icon="i-lucide-trash" icon="i-lucide-trash"
color="slate" color="slate"
size="sm" size="sm"

View File

@ -36,6 +36,7 @@ const props = defineProps({
activeSegment: { type: Object, default: null }, activeSegment: { type: Object, default: null },
hasAppliedFilters: { type: Boolean, default: false }, hasAppliedFilters: { type: Boolean, default: false },
isLabelView: { type: Boolean, default: false }, isLabelView: { type: Boolean, default: false },
isActiveView: { type: Boolean, default: false },
}); });
const emit = defineEmits([ const emit = defineEmits([
@ -277,6 +278,7 @@ defineExpose({
:header-title="headerTitle" :header-title="headerTitle"
:is-segments-view="hasActiveSegments" :is-segments-view="hasActiveSegments"
:is-label-view="isLabelView" :is-label-view="isLabelView"
:is-active-view="isActiveView"
:has-active-filters="hasAppliedFilters" :has-active-filters="hasAppliedFilters"
:button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')" :button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
@search="emit('search', $event)" @search="emit('search', $event)"

View File

@ -6,7 +6,7 @@ import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/Contact
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue'; import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue'; import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
defineProps({ const props = defineProps({
searchValue: { type: String, default: '' }, searchValue: { type: String, default: '' },
headerTitle: { type: String, default: '' }, headerTitle: { type: String, default: '' },
showPaginationFooter: { type: Boolean, default: true }, showPaginationFooter: { type: Boolean, default: true },
@ -37,10 +37,23 @@ const isNotSegmentView = computed(() => {
return route.name !== 'contacts_dashboard_segments_index'; return route.name !== 'contacts_dashboard_segments_index';
}); });
const isActiveView = computed(() => {
return route.name === 'contacts_dashboard_active';
});
const isLabelView = computed( const isLabelView = computed(
() => route.name === 'contacts_dashboard_labels_index' () => route.name === 'contacts_dashboard_labels_index'
); );
const showActiveFiltersPreview = computed(() => {
return (
(props.hasAppliedFilters || !isNotSegmentView.value) &&
!props.isFetchingList &&
!isLabelView.value &&
!isActiveView.value
);
});
const updateCurrentPage = page => { const updateCurrentPage = page => {
emit('update:currentPage', page); emit('update:currentPage', page);
}; };
@ -57,7 +70,7 @@ const openFilter = () => {
<div class="flex flex-col w-full h-full transition-all duration-300"> <div class="flex flex-col w-full h-full transition-all duration-300">
<ContactListHeaderWrapper <ContactListHeaderWrapper
ref="contactListHeaderWrapper" ref="contactListHeaderWrapper"
:show-search="isNotSegmentView" :show-search="isNotSegmentView && !isActiveView"
:search-value="searchValue" :search-value="searchValue"
:active-sort="activeSort" :active-sort="activeSort"
:active-ordering="activeOrdering" :active-ordering="activeOrdering"
@ -66,6 +79,7 @@ const openFilter = () => {
:segments-id="segmentsId" :segments-id="segmentsId"
:has-applied-filters="hasAppliedFilters" :has-applied-filters="hasAppliedFilters"
:is-label-view="isLabelView" :is-label-view="isLabelView"
:is-active-view="isActiveView"
@update:sort="emit('update:sort', $event)" @update:sort="emit('update:sort', $event)"
@search="emit('search', $event)" @search="emit('search', $event)"
@apply-filter="emit('applyFilter', $event)" @apply-filter="emit('applyFilter', $event)"
@ -74,11 +88,7 @@ const openFilter = () => {
<main class="flex-1 overflow-y-auto"> <main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[60rem]"> <div class="w-full mx-auto max-w-[60rem]">
<ContactsActiveFiltersPreview <ContactsActiveFiltersPreview
v-if=" v-if="showActiveFiltersPreview"
(hasAppliedFilters || !isNotSegmentView) &&
!isFetchingList &&
!isLabelView
"
:active-segment="activeSegment" :active-segment="activeSegment"
@clear-filters="emit('clearFilters')" @clear-filters="emit('clearFilters')"
@open-filter="openFilter" @open-filter="openFilter"

View File

@ -71,6 +71,7 @@ const toggleExpanded = id => {
:thumbnail="contact.thumbnail" :thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber" :phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes" :additional-attributes="contact.additionalAttributes"
:availability-status="contact.availabilityStatus"
:is-expanded="expandedCardId === contact.id" :is-expanded="expandedCardId === contact.id"
:is-updating="isUpdating" :is-updating="isUpdating"
@toggle="toggleExpanded(contact.id)" @toggle="toggleExpanded(contact.id)"

View File

@ -4,38 +4,14 @@ import { computed, ref, watch, useSlots } from 'vue';
import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue'; import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: { type: String, default: '' },
type: String, label: { type: String, default: '' },
default: '', placeholder: { type: String, default: '' },
}, focusOnMount: { type: Boolean, default: false },
label: { maxLength: { type: Number, default: 200 },
type: String, showCharacterCount: { type: Boolean, default: true },
default: '', disabled: { type: Boolean, default: false },
}, message: { type: String, default: '' },
placeholder: {
type: String,
default: '',
},
focusOnMount: {
type: Boolean,
default: false,
},
maxLength: {
type: Number,
default: 200,
},
showCharacterCount: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
message: {
type: String,
default: '',
},
messageType: { messageType: {
type: String, type: String,
default: 'info', default: 'info',
@ -43,6 +19,7 @@ const props = defineProps({
}, },
enableVariables: { type: Boolean, default: false }, enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true }, enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] },
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
@ -120,6 +97,7 @@ watch(
:disabled="disabled" :disabled="disabled"
:enable-variables="enableVariables" :enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses" :enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions"
@input="handleInput" @input="handleInput"
@focus="handleFocus" @focus="handleFocus"
@blur="handleBlur" @blur="handleBlur"

View File

@ -46,8 +46,6 @@ const getCurrentRoute = () => {
const path = route.path; const path = route.path;
if (path.includes('/conversations')) return 'conversations'; if (path.includes('/conversations')) return 'conversations';
if (path.includes('/dashboard')) return 'dashboard'; if (path.includes('/dashboard')) return 'dashboard';
if (path.includes('/contacts')) return 'contacts';
if (path.includes('/articles')) return 'articles';
return 'dashboard'; return 'dashboard';
}; };

View File

@ -109,49 +109,58 @@ const downloadAudio = async () => {
</audio> </audio>
<div <div
v-bind="$attrs" v-bind="$attrs"
class="rounded-xl w-full gap-1 p-1.5 bg-n-alpha-white flex items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]" class="rounded-xl w-full gap-2 p-1.5 bg-n-alpha-white flex flex-col items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]"
> >
<button class="p-0 border-0 size-8" @click="playOrPause"> <div class="flex gap-1 w-full flex-1 items-center justify-start">
<Icon <button class="p-0 border-0 size-8" @click="playOrPause">
v-if="isPlaying" <Icon
class="size-8" v-if="isPlaying"
icon="i-teenyicons-pause-small-solid" class="size-8"
/> icon="i-teenyicons-pause-small-solid"
<Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" /> />
</button> <Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" />
<div class="tabular-nums text-xs"> </button>
{{ formatTime(currentTime) }} / {{ formatTime(duration) }} <div class="tabular-nums text-xs">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
<div class="flex-1 items-center flex px-2">
<input
type="range"
min="0"
:max="duration"
:value="currentTime"
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
@input="seek"
/>
</div>
<button
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
@click="changePlaybackSpeed"
>
<span class="text-xs text-n-slate-11 font-medium">
{{ playbackSpeedLabel }}
</span>
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute"
>
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="downloadAudio"
>
<Icon class="size-4" icon="i-lucide-download" />
</button>
</div> </div>
<div class="flex-1 items-center flex px-2">
<input <div
type="range" v-if="attachment.transcribedText"
min="0" class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
:max="duration" >
:value="currentTime" {{ attachment.transcribedText }}
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
@input="seek"
/>
</div> </div>
<button
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
@click="changePlaybackSpeed"
>
<span class="text-xs text-n-slate-11 font-medium">
{{ playbackSpeedLabel }}
</span>
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute"
>
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="downloadAudio"
>
<Icon class="size-4" icon="i-lucide-download" />
</button>
</div> </div>
</template> </template>

View File

@ -235,6 +235,12 @@ const menuItems = computed(() => {
), ),
activeOn: ['contacts_dashboard_index', 'contacts_edit'], activeOn: ['contacts_dashboard_index', 'contacts_edit'],
}, },
{
name: 'Active',
label: t('SIDEBAR.ACTIVE'),
to: accountScopedRoute('contacts_dashboard_active'),
activeOn: ['contacts_dashboard_active'],
},
{ {
name: 'Segments', name: 'Segments',
icon: 'i-lucide-group', icon: 'i-lucide-group',

View File

@ -22,6 +22,7 @@ import {
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable // https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'; import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import ChatListHeader from './ChatListHeader.vue'; import ChatListHeader from './ChatListHeader.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConversationFilter from 'next/filter/ConversationFilter.vue'; import ConversationFilter from 'next/filter/ConversationFilter.vue';
import SaveCustomView from 'next/filter/SaveCustomView.vue'; import SaveCustomView from 'next/filter/SaveCustomView.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue'; import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
@ -82,6 +83,7 @@ const emit = defineEmits(['conversationLoad']);
const { uiSettings } = useUISettings(); const { uiSettings } = useUISettings();
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const route = useRoute();
const store = useStore(); const store = useStore();
const conversationListRef = ref(null); const conversationListRef = ref(null);
@ -646,6 +648,30 @@ function openLastItemAfterDeleteInFolder() {
} }
} }
function redirectToConversationList() {
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = route;
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
router.push(
conversationListPageURL({
accountId,
conversationType: conversationType,
customViewId: props.foldersId,
inboxId,
label,
teamId,
})
);
}
async function assignPriority(priority, conversationId = null) { async function assignPriority(priority, conversationId = null) {
store.dispatch('setCurrentChatPriority', { store.dispatch('setCurrentChatPriority', {
priority, priority,
@ -670,26 +696,7 @@ async function markAsUnread(conversationId) {
await store.dispatch('markMessagesUnread', { await store.dispatch('markMessagesUnread', {
id: conversationId, id: conversationId,
}); });
const { redirectToConversationList();
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = useRoute();
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
router.push(
conversationListPageURL({
accountId,
conversationType: conversationType,
customViewId: props.foldersId,
inboxId,
label,
teamId,
})
);
} catch (error) { } catch (error) {
// Ignore error // Ignore error
} }
@ -703,6 +710,7 @@ async function markAsRead(conversationId) {
// Ignore error // Ignore error
} }
} }
async function onAssignTeam(team, conversationId = null) { async function onAssignTeam(team, conversationId = null) {
try { try {
await store.dispatch('assignTeam', { await store.dispatch('assignTeam', {
@ -764,6 +772,26 @@ onMounted(() => {
} }
}); });
const deleteConversationDialogRef = ref(null);
const selectedConversationId = ref(null);
async function deleteConversation() {
try {
await store.dispatch('deleteConversation', selectedConversationId.value);
redirectToConversationList();
selectedConversationId.value = null;
deleteConversationDialogRef.value.close();
useAlert(t('CONVERSATION.SUCCESS_DELETE_CONVERSATION'));
} catch (error) {
useAlert(t('CONVERSATION.FAIL_DELETE_CONVERSATION'));
}
}
const handleDelete = conversationId => {
selectedConversationId.value = conversationId;
deleteConversationDialogRef.value.open();
};
provide('selectConversation', selectConversation); provide('selectConversation', selectConversation);
provide('deSelectConversation', deSelectConversation); provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent); provide('assignAgent', onAssignAgent);
@ -775,6 +803,7 @@ provide('markAsUnread', markAsUnread);
provide('markAsRead', markAsRead); provide('markAsRead', markAsRead);
provide('assignPriority', assignPriority); provide('assignPriority', assignPriority);
provide('isConversationSelected', isConversationSelected); provide('isConversationSelected', isConversationSelected);
provide('deleteConversation', handleDelete);
watch(activeTeam, () => resetAndFetchData()); watch(activeTeam, () => resetAndFetchData());
@ -938,6 +967,19 @@ watch(conversationFilters, (newVal, oldVal) => {
</template> </template>
</DynamicScroller> </DynamicScroller>
</div> </div>
<Dialog
ref="deleteConversationDialogRef"
type="alert"
:title="
$t('CONVERSATION.DELETE_CONVERSATION.TITLE', {
conversationId: selectedConversationId,
})
"
:description="$t('CONVERSATION.DELETE_CONVERSATION.DESCRIPTION')"
:confirm-button-label="$t('CONVERSATION.DELETE_CONVERSATION.CONFIRM')"
@confirm="deleteConversation"
@close="selectedConversationId = null"
/>
<TeleportWithDirection <TeleportWithDirection
v-if="showAdvancedFilters" v-if="showAdvancedFilters"
to="#conversationFilterTeleportTarget" to="#conversationFilterTeleportTarget"

View File

@ -16,6 +16,7 @@ export default {
'markAsRead', 'markAsRead',
'assignPriority', 'assignPriority',
'isConversationSelected', 'isConversationSelected',
'deleteConversation',
], ],
props: { props: {
source: { source: {
@ -67,5 +68,6 @@ export default {
@mark-as-unread="markAsUnread" @mark-as-unread="markAsUnread"
@mark-as-read="markAsRead" @mark-as-read="markAsRead"
@assign-priority="assignPriority" @assign-priority="assignPriority"
@delete-conversation="deleteConversation"
/> />
</template> </template>

View File

@ -9,6 +9,7 @@ const contacts = accountId => ({
'contacts_edit', 'contacts_edit',
'contacts_edit_segment', 'contacts_edit_segment',
'contacts_edit_label', 'contacts_edit_label',
'contacts_dashboard_active',
], ],
menuItems: [ menuItems: [
{ {
@ -18,6 +19,13 @@ const contacts = accountId => ({
toState: frontendURL(`accounts/${accountId}/contacts?page=1`), toState: frontendURL(`accounts/${accountId}/contacts?page=1`),
toStateName: 'contacts_dashboard_index', toStateName: 'contacts_dashboard_index',
}, },
{
icon: 'visitor-contacts',
label: 'ACTIVE',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts/active`),
toStateName: 'contacts_dashboard_active',
},
], ],
}); });

View File

@ -78,6 +78,7 @@ export default {
'markAsRead', 'markAsRead',
'assignPriority', 'assignPriority',
'updateConversationStatus', 'updateConversationStatus',
'deleteConversation',
], ],
data() { data() {
return { return {
@ -237,6 +238,10 @@ export default {
this.$emit('assignPriority', priority, this.chat.id); this.$emit('assignPriority', priority, this.chat.id);
this.closeContextMenu(); this.closeContextMenu();
}, },
async deleteConversation() {
this.$emit('deleteConversation', this.chat.id);
this.closeContextMenu();
},
}, },
}; };
</script> </script>
@ -363,6 +368,7 @@ export default {
@mark-as-unread="markAsUnread" @mark-as-unread="markAsUnread"
@mark-as-read="markAsRead" @mark-as-read="markAsRead"
@assign-priority="assignPriority" @assign-priority="assignPriority"
@delete-conversation="deleteConversation"
/> />
</ContextMenu> </ContextMenu>
</div> </div>

View File

@ -8,6 +8,7 @@ import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue'; import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue'; import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
import { useAdmin } from 'dashboard/composables/useAdmin';
export default { export default {
components: { components: {
@ -45,7 +46,14 @@ export default {
'assignAgent', 'assignAgent',
'assignTeam', 'assignTeam',
'assignLabel', 'assignLabel',
'deleteConversation',
], ],
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
data() { data() {
return { return {
STATUS_TYPE: wootConstants.STATUS_TYPE, STATUS_TYPE: wootConstants.STATUS_TYPE,
@ -121,6 +129,11 @@ export default {
icon: 'people-team-add', icon: 'people-team-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'), label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
}, },
deleteOption: {
key: 'delete',
icon: 'delete',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.DELETE'),
},
}; };
}, },
computed: { computed: {
@ -178,6 +191,9 @@ export default {
assignPriority(priority) { assignPriority(priority) {
this.$emit('assignPriority', priority); this.$emit('assignPriority', priority);
}, },
deleteConversation() {
this.$emit('deleteConversation', this.chatId);
},
show(key) { show(key) {
// If the conversation status is same as the action, then don't display the option // If the conversation status is same as the action, then don't display the option
// i.e.: Don't show an option to resolve if the conversation is already resolved. // i.e.: Don't show an option to resolve if the conversation is already resolved.
@ -277,5 +293,13 @@ export default {
@click.stop="$emit('assignTeam', team)" @click.stop="$emit('assignTeam', team)"
/> />
</MenuItemWithSubmenu> </MenuItemWithSubmenu>
<template v-if="isAdmin">
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<MenuItem
:option="deleteOption"
variant="icon"
@click.stop="deleteConversation"
/>
</template>
</div> </div>
</template> </template>

View File

@ -33,6 +33,14 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'code', 'code',
]; ];
export const WIDGET_BUILDER_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
];
export const MESSAGE_EDITOR_IMAGE_RESIZES = [ export const MESSAGE_EDITOR_IMAGE_RESIZES = [
{ {
name: 'Small', name: 'Small',

View File

@ -36,6 +36,7 @@ const translationKeys = {
'teammember:create': `AUDIT_LOGS.TEAM_MEMBER.ADD`, 'teammember:create': `AUDIT_LOGS.TEAM_MEMBER.ADD`,
'teammember:destroy': `AUDIT_LOGS.TEAM_MEMBER.REMOVE`, 'teammember:destroy': `AUDIT_LOGS.TEAM_MEMBER.REMOVE`,
'account:update': `AUDIT_LOGS.ACCOUNT.EDIT`, 'account:update': `AUDIT_LOGS.ACCOUNT.EDIT`,
'conversation:destroy': `AUDIT_LOGS.CONVERSATION.DELETE`,
}; };
function extractAttrChange(attrChange) { function extractAttrChange(attrChange) {
@ -168,6 +169,11 @@ export function generateTranslationPayload(auditLogItem, agentList) {
const auditableType = auditLogItem.auditable_type.toLowerCase(); const auditableType = auditLogItem.auditable_type.toLowerCase();
const action = auditLogItem.action.toLowerCase(); const action = auditLogItem.action.toLowerCase();
if (auditableType === 'conversation' && action === 'destroy') {
translationPayload.id =
auditLogItem.audited_changes?.display_id || auditLogItem.auditable_id;
}
if (auditableType === 'accountuser') { if (auditableType === 'accountuser') {
translationPayload = handleAccountUser( translationPayload = handleAccountUser(
auditLogItem, auditLogItem,

View File

@ -69,6 +69,9 @@
}, },
"ACCOUNT": { "ACCOUNT": {
"EDIT": "{agentName} updated the account configuration (#{id})" "EDIT": "{agentName} updated the account configuration (#{id})"
},
"CONVERSATION": {
"DELETE": "{agentName} deleted conversation #{id}"
} }
} }
} }

View File

@ -286,6 +286,7 @@
"HEADER": { "HEADER": {
"TITLE": "Contacts", "TITLE": "Contacts",
"SEARCH_TITLE": "Search contacts", "SEARCH_TITLE": "Search contacts",
"ACTIVE_TITLE": "Active contacts",
"SEARCH_PLACEHOLDER": "Search...", "SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Message", "MESSAGE_BUTTON": "Message",
"SEND_MESSAGE": "Send message", "SEND_MESSAGE": "Send message",
@ -560,7 +561,8 @@
"SUBTITLE": "Start adding new contacts by clicking on the button below", "SUBTITLE": "Start adding new contacts by clicking on the button below",
"BUTTON_LABEL": "Add contact", "BUTTON_LABEL": "Add contact",
"SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍", "SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍",
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋" "LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋",
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
} }
}, },

View File

@ -118,6 +118,11 @@
"FAILED": "Couldn't change priority. Please try again." "FAILED": "Couldn't change priority. Please try again."
} }
}, },
"DELETE_CONVERSATION": {
"TITLE": "Delete conversation #{conversationId}",
"DESCRIPTION": "Are you sure you want to delete this conversation?",
"CONFIRM": "Delete"
},
"CARD_CONTEXT_MENU": { "CARD_CONTEXT_MENU": {
"PENDING": "Mark as pending", "PENDING": "Mark as pending",
"RESOLVED": "Mark as resolved", "RESOLVED": "Mark as resolved",
@ -134,6 +139,7 @@
"ASSIGN_LABEL": "Assign label", "ASSIGN_LABEL": "Assign label",
"AGENTS_LOADING": "Loading agents...", "AGENTS_LOADING": "Loading agents...",
"ASSIGN_TEAM": "Assign team", "ASSIGN_TEAM": "Assign team",
"DELETE": "Delete conversation",
"API": { "API": {
"AGENT_ASSIGNMENT": { "AGENT_ASSIGNMENT": {
"SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"", "SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"",
@ -208,6 +214,8 @@
"ASSIGN_LABEL_SUCCESFUL": "Label assigned successfully", "ASSIGN_LABEL_SUCCESFUL": "Label assigned successfully",
"ASSIGN_LABEL_FAILED": "Label assignment failed", "ASSIGN_LABEL_FAILED": "Label assignment failed",
"CHANGE_TEAM": "Conversation team changed", "CHANGE_TEAM": "Conversation team changed",
"SUCCESS_DELETE_CONVERSATION": "Conversation deleted successfully",
"FAIL_DELETE_CONVERSATION": "Couldn't delete conversation! Try again",
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit", "FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit",
"MESSAGE_ERROR": "Unable to send this message, please try again later", "MESSAGE_ERROR": "Unable to send this message, please try again later",
"SENT_BY": "Sent by:", "SENT_BY": "Sent by:",

View File

@ -92,6 +92,32 @@
"PLACEHOLDER": "Your company's support email", "PLACEHOLDER": "Your company's support email",
"ERROR": "" "ERROR": ""
}, },
"AUTO_RESOLVE_IGNORE_WAITING": {
"LABEL": "Exclude unattended conversations",
"HELP": "When enabled, the system will skip resolving conversations that are still waiting for an agent's reply."
},
"AUDIO_TRANSCRIPTION": {
"TITLE": "Transcribe Audio Messages",
"NOTE": "Automatically transcribe audio messages in conversations. Generate a text transcript whenever an audio message is sent or received, and display it alongside the message.",
"API": {
"SUCCESS": "Audio transcription setting updated successfully",
"ERROR": "Failed to update audio transcription setting"
}
},
"AUTO_RESOLVE_DURATION": {
"LABEL": "Inactivity duration for resolution",
"HELP": "Duration after a conversation should auto resolve if there is no activity",
"PLACEHOLDER": "30",
"ERROR": "Auto resolve duration should be between 10 minutes and 999 days",
"API": {
"SUCCESS": "Auto resolve settings updated successfully",
"ERROR": "Failed to update auto resolve settings"
},
"UPDATE_BUTTON": "Update",
"MESSAGE_LABEL": "Custom resolution message",
"MESSAGE_PLACEHOLDER": "Conversation was marked resolved by system due to 15 days of inactivity",
"MESSAGE_HELP": "This message is sent to the customer when a conversation is automatically resolved by the system due to inactivity."
},
"FEATURES": { "FEATURES": {
"INBOUND_EMAIL_ENABLED": "Conversation continuity with emails is enabled for your account.", "INBOUND_EMAIL_ENABLED": "Conversation continuity with emails is enabled for your account.",
"CUSTOM_EMAIL_DOMAIN_ENABLED": "You can receive emails in your custom domain now." "CUSTOM_EMAIL_DOMAIN_ENABLED": "You can receive emails in your custom domain now."

View File

@ -286,6 +286,7 @@
"REPORTS": "Reports", "REPORTS": "Reports",
"SETTINGS": "Settings", "SETTINGS": "Settings",
"CONTACTS": "Contacts", "CONTACTS": "Contacts",
"ACTIVE": "Active",
"CAPTAIN": "Captain", "CAPTAIN": "Captain",
"CAPTAIN_ASSISTANTS": "Assistants", "CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents", "CAPTAIN_DOCUMENTS": "Documents",

View File

@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
const props = defineProps({ const props = defineProps({
config: { config: {
@ -8,6 +9,8 @@ const props = defineProps({
}, },
}); });
const { formatMessage } = useMessageFormatter();
const isDefaultScreen = computed(() => { const isDefaultScreen = computed(() => {
return ( return (
props.config.isDefaultScreen && props.config.isDefaultScreen &&
@ -53,12 +56,13 @@ const isDefaultScreen = computed(() => {
</div> </div>
</div> </div>
<div v-if="isDefaultScreen" class="overflow-auto max-h-60"> <div v-if="isDefaultScreen" class="overflow-auto max-h-60">
<h2 class="mb-2 text-2xl break-words text-slate-900 dark:text-white"> <h2 class="mb-2 text-2xl break-words text-n-slate-12">
{{ config.welcomeHeading }} {{ config.welcomeHeading }}
</h2> </h2>
<p class="text-sm break-words text-slate-600 dark:text-slate-100"> <p
{{ config.welcomeTagline }} v-dompurify-html="formatMessage(config.welcomeTagline)"
</p> class="text-sm break-words text-n-slate-11 [&_a]:!text-n-slate-11 [&_a]:underline"
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -67,9 +67,11 @@ const hasContacts = computed(() => contacts.value.length > 0);
const isContactIndexView = computed( const isContactIndexView = computed(
() => route.name === 'contacts_dashboard_index' && pageNumber.value === 1 () => route.name === 'contacts_dashboard_index' && pageNumber.value === 1
); );
const isActiveView = computed(() => route.name === 'contacts_dashboard_active');
const hasAppliedFilters = computed(() => { const hasAppliedFilters = computed(() => {
return appliedFilters.value.length > 0; return appliedFilters.value.length > 0;
}); });
const showEmptyStateLayout = computed(() => { const showEmptyStateLayout = computed(() => {
return ( return (
!searchQuery.value && !searchQuery.value &&
@ -89,11 +91,20 @@ const showEmptyText = computed(() => {
const headerTitle = computed(() => { const headerTitle = computed(() => {
if (searchQuery.value) return t('CONTACTS_LAYOUT.HEADER.SEARCH_TITLE'); if (searchQuery.value) return t('CONTACTS_LAYOUT.HEADER.SEARCH_TITLE');
if (isActiveView.value) return t('CONTACTS_LAYOUT.HEADER.ACTIVE_TITLE');
if (activeSegmentId.value) return activeSegment.value?.name; if (activeSegmentId.value) return activeSegment.value?.name;
if (activeLabel.value) return `#${activeLabel.value}`; if (activeLabel.value) return `#${activeLabel.value}`;
return t('CONTACTS_LAYOUT.HEADER.TITLE'); return t('CONTACTS_LAYOUT.HEADER.TITLE');
}); });
const emptyStateMessage = computed(() => {
if (isActiveView.value)
return t('CONTACTS_LAYOUT.EMPTY_STATE.ACTIVE_EMPTY_STATE_TITLE');
if (!searchQuery.value || hasAppliedFilters.value)
return t('CONTACTS_LAYOUT.EMPTY_STATE.LIST_EMPTY_STATE_TITLE');
return t('CONTACTS_LAYOUT.EMPTY_STATE.SEARCH_EMPTY_STATE_TITLE');
});
const updatePageParam = (page, search = '') => { const updatePageParam = (page, search = '') => {
const query = { const query = {
...route.query, ...route.query,
@ -132,6 +143,15 @@ const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
updatePageParam(page); updatePageParam(page);
}; };
const fetchActiveContacts = async (page = 1) => {
await store.dispatch('contacts/clearContactFilters');
await store.dispatch('contacts/active', {
page,
sortAttr: buildSortAttr(),
});
updatePageParam(page);
};
const searchContacts = debounce(async (value, page = 1) => { const searchContacts = debounce(async (value, page = 1) => {
await store.dispatch('contacts/clearContactFilters'); await store.dispatch('contacts/clearContactFilters');
searchValue.value = value; searchValue.value = value;
@ -158,6 +178,11 @@ const fetchContactsBasedOnContext = async page => {
} }
// Reset the search value when we change the view // Reset the search value when we change the view
searchValue.value = ''; searchValue.value = '';
// If we're on the active route, fetch active contacts
if (isActiveView.value) {
await fetchActiveContacts(page);
return;
}
// If there are applied filters or active segment with query // If there are applied filters or active segment with query
if ( if (
(hasAppliedFilters.value || activeSegment.value?.query) && (hasAppliedFilters.value || activeSegment.value?.query) &&
@ -184,6 +209,11 @@ const handleSort = async ({ sort, order }) => {
return; return;
} }
if (isActiveView.value) {
await fetchActiveContacts();
return;
}
await (activeSegmentId.value || hasAppliedFilters.value await (activeSegmentId.value || hasAppliedFilters.value
? fetchSavedOrAppliedFilteredContact( ? fetchSavedOrAppliedFilteredContact(
activeSegmentId.value activeSegmentId.value
@ -210,7 +240,7 @@ watch(
); );
watch( watch(
[activeLabel, activeSegment], [activeLabel, activeSegment, isActiveView],
() => { () => {
fetchContactsBasedOnContext(pageNumber.value); fetchContactsBasedOnContext(pageNumber.value);
}, },
@ -222,6 +252,13 @@ watch(searchQuery, value => {
searchValue.value = value || ''; searchValue.value = value || '';
// Reset the view if there is search query when we click on the sidebar group // Reset the view if there is search query when we click on the sidebar group
if (value === undefined) { if (value === undefined) {
if (
isActiveView.value ||
activeLabel.value ||
activeSegment.value ||
hasAppliedFilters.value
)
return;
fetchContacts(); fetchContacts();
} }
}); });
@ -232,6 +269,10 @@ onMounted(async () => {
await searchContacts(searchQuery.value, pageNumber.value); await searchContacts(searchQuery.value, pageNumber.value);
return; return;
} }
if (isActiveView.value) {
await fetchActiveContacts(pageNumber.value);
return;
}
await fetchContacts(pageNumber.value); await fetchContacts(pageNumber.value);
} else if (activeSegment.value && activeSegmentId.value) { } else if (activeSegment.value && activeSegmentId.value) {
await fetchSavedOrAppliedFilteredContact( await fetchSavedOrAppliedFilteredContact(
@ -286,11 +327,7 @@ onMounted(async () => {
class="flex items-center justify-center py-10" class="flex items-center justify-center py-10"
> >
<span class="text-base text-n-slate-11"> <span class="text-base text-n-slate-11">
{{ {{ emptyStateMessage }}
searchQuery || !hasAppliedFilters
? t('CONTACTS_LAYOUT.EMPTY_STATE.SEARCH_EMPTY_STATE_TITLE')
: t('CONTACTS_LAYOUT.EMPTY_STATE.LIST_EMPTY_STATE_TITLE')
}}
</span> </span>
</div> </div>

View File

@ -32,6 +32,12 @@ export const routes = [
component: ContactsIndex, component: ContactsIndex,
meta: commonMeta, meta: commonMeta,
}, },
{
path: 'active',
name: 'contacts_dashboard_active',
component: ContactsIndex,
meta: commonMeta,
},
], ],
}, },
{ {

View File

@ -16,6 +16,7 @@ import AccountId from './components/AccountId.vue';
import BuildInfo from './components/BuildInfo.vue'; import BuildInfo from './components/BuildInfo.vue';
import AccountDelete from './components/AccountDelete.vue'; import AccountDelete from './components/AccountDelete.vue';
import AutoResolve from './components/AutoResolve.vue'; import AutoResolve from './components/AutoResolve.vue';
import AudioTranscription from './components/AudioTranscription.vue';
import SectionLayout from './components/SectionLayout.vue'; import SectionLayout from './components/SectionLayout.vue';
export default { export default {
@ -26,6 +27,7 @@ export default {
BuildInfo, BuildInfo,
AccountDelete, AccountDelete,
AutoResolve, AutoResolve,
AudioTranscription,
SectionLayout, SectionLayout,
WithLabel, WithLabel,
NextInput, NextInput,
@ -235,6 +237,7 @@ export default {
<woot-loading-state v-if="uiFlags.isFetchingItem" /> <woot-loading-state v-if="uiFlags.isFetchingItem" />
</div> </div>
<AutoResolve v-if="showAutoResolutionConfig" /> <AutoResolve v-if="showAutoResolutionConfig" />
<AudioTranscription v-if="isOnChatwootCloud" />
<AccountId /> <AccountId />
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud"> <div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
<AccountDelete /> <AccountDelete />

View File

@ -0,0 +1,51 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import SectionLayout from './SectionLayout.vue';
import Switch from 'next/switch/Switch.vue';
const { t } = useI18n();
const isEnabled = ref(false);
const { currentAccount, updateAccount } = useAccount();
watch(
currentAccount,
() => {
const { audio_transcriptions } = currentAccount.value?.settings || {};
isEnabled.value = !!audio_transcriptions;
},
{ deep: true, immediate: true }
);
const updateAccountSettings = async settings => {
try {
await updateAccount(settings);
useAlert(t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.API.SUCCESS'));
} catch (error) {
useAlert(t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.API.ERROR'));
}
};
const toggleAudioTranscription = async () => {
return updateAccountSettings({
audio_transcriptions: isEnabled.value,
});
};
</script>
<template>
<SectionLayout
:title="t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.TITLE')"
:description="t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.NOTE')"
with-border
>
<template #headerActions>
<div class="flex justify-end">
<Switch v-model="isEnabled" @change="toggleAudioTranscription" />
</div>
</template>
</SectionLayout>
</template>

View File

@ -23,6 +23,8 @@ import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue'; import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default { export default {
components: { components: {
@ -43,6 +45,7 @@ export default {
NextButton, NextButton,
InstagramReauthorize, InstagramReauthorize,
DuplicateInboxBanner, DuplicateInboxBanner,
Editor,
}, },
mixins: [inboxMixin], mixins: [inboxMixin],
setup() { setup() {
@ -70,6 +73,7 @@ export default {
selectedTabIndex: 0, selectedTabIndex: 0,
selectedPortalSlug: '', selectedPortalSlug: '',
showBusinessNameInput: false, showBusinessNameInput: false,
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
}; };
}, },
computed: { computed: {
@ -480,10 +484,10 @@ export default {
" "
/> />
<woot-input <Editor
v-if="isAWebWidgetInbox" v-if="isAWebWidgetInbox"
v-model="channelWelcomeTagline" v-model="channelWelcomeTagline"
class="pb-4" class="mb-4"
:label=" :label="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.LABEL') $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.LABEL')
" "
@ -492,6 +496,8 @@ export default {
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.PLACEHOLDER' 'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.PLACEHOLDER'
) )
" "
:max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
/> />
<label v-if="isAWebWidgetInbox" class="pb-4"> <label v-if="isAWebWidgetInbox" class="pb-4">

View File

@ -7,13 +7,16 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators'; import { required } from '@vuelidate/validators';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage'; import { LocalStorage } from 'shared/helpers/localStorage';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default { export default {
components: { components: {
Widget, Widget,
InputRadioGroup, InputRadioGroup,
NextButton, NextButton,
Editor,
}, },
props: { props: {
inbox: { inbox: {
@ -71,6 +74,7 @@ export default {
checked: false, checked: false,
}, },
], ],
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
}; };
}, },
computed: { computed: {
@ -310,7 +314,7 @@ export default {
) )
" "
/> />
<woot-input <Editor
v-model="welcomeTagline" v-model="welcomeTagline"
:label=" :label="
$t( $t(
@ -322,6 +326,9 @@ export default {
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_TAGLINE.PLACE_HOLDER' 'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_TAGLINE.PLACE_HOLDER'
) )
" "
:max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
class="mb-4"
/> />
<label> <label>
{{ {{

View File

@ -5,12 +5,15 @@ import router from '../../../../index';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import PageHeader from '../../SettingsSubPageHeader.vue'; import PageHeader from '../../SettingsSubPageHeader.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue'; import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default { export default {
components: { components: {
PageHeader, PageHeader,
GreetingsEditor, GreetingsEditor,
NextButton, NextButton,
Editor,
}, },
data() { data() {
return { return {
@ -21,6 +24,7 @@ export default {
channelWelcomeTagline: '', channelWelcomeTagline: '',
greetingEnabled: false, greetingEnabled: false,
greetingMessage: '', greetingMessage: '',
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
}; };
}, },
computed: { computed: {
@ -134,22 +138,21 @@ export default {
/> />
</label> </label>
</div> </div>
<div class="w-full"> <Editor
<label> v-model="channelWelcomeTagline"
{{ :label="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.LABEL') $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.LABEL')
}} "
<input :placeholder="
v-model="channelWelcomeTagline" $t(
type="text" 'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.PLACEHOLDER'
:placeholder=" )
$t( "
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.PLACEHOLDER' :max-length="255"
) :enabled-menu-options="welcomeTaglineEditorMenuOptions"
" class="mb-4"
/> />
</label>
</div>
<label class="w-full"> <label class="w-full">
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_TOGGLE.LABEL') }} {{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_TOGGLE.LABEL') }}
<select v-model="greetingEnabled"> <select v-model="greetingEnabled">

View File

@ -75,6 +75,21 @@ export const actions = {
} }
}, },
active: async ({ commit }, { page = 1, sortAttr } = {}) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetching: true });
try {
const {
data: { payload, meta },
} = await ContactAPI.active(page, sortAttr);
commit(types.CLEAR_CONTACTS);
commit(types.SET_CONTACTS, payload);
commit(types.SET_CONTACT_META, meta);
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
}
},
show: async ({ commit }, { id }) => { show: async ({ commit }, { id }) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetchingItem: true }); commit(types.SET_CONTACT_UI_FLAG, { isFetchingItem: true });
try { try {

View File

@ -327,6 +327,16 @@ const actions = {
} }
}, },
deleteConversation: async ({ commit, dispatch }, conversationId) => {
try {
await ConversationApi.delete(conversationId);
commit(types.DELETE_CONVERSATION, conversationId);
dispatch('conversationStats/get', {}, { root: true });
} catch (error) {
throw new Error(error);
}
},
addConversation({ commit, state, dispatch, rootState }, conversation) { addConversation({ commit, state, dispatch, rootState }, conversation) {
const { currentInbox, appliedFilters } = state; const { currentInbox, appliedFilters } = state;
const { const {

View File

@ -47,6 +47,7 @@
* 3. Nested properties in custom_attributes (conversation_type, etc.) * 3. Nested properties in custom_attributes (conversation_type, etc.)
*/ */
import jsonLogic from 'json-logic-js'; import jsonLogic from 'json-logic-js';
import { coerceToDate } from '@chatwoot/utils';
/** /**
* Gets a value from a conversation based on the attribute key * Gets a value from a conversation based on the attribute key
@ -157,6 +158,20 @@ const contains = (filterValue, conversationValue) => {
return false; return false;
}; };
/**
* Compares two date values using a comparison function
* @param {*} conversationValue - The conversation value to compare
* @param {*} filterValue - The filter value to compare against
* @param {Function} compareFn - The comparison function to apply
* @returns {Boolean} - Returns true if the comparison succeeds, false otherwise
*/
const compareDates = (conversationValue, filterValue, compareFn) => {
const conversationDate = coerceToDate(conversationValue);
const filterDate = coerceToDate(filterValue);
if (conversationDate === null || filterDate === null) return false;
return compareFn(conversationDate, filterDate);
};
/** /**
* Checks if a value matches a filter condition * Checks if a value matches a filter condition
* @param {*} conversationValue - The value to check * @param {*} conversationValue - The value to check
@ -195,10 +210,10 @@ const matchesCondition = (conversationValue, filter) => {
return false; // We already handled null/undefined above return false; // We already handled null/undefined above
case 'is_greater_than': case 'is_greater_than':
return new Date(conversationValue) > new Date(filterValue); return compareDates(conversationValue, filterValue, (a, b) => a > b);
case 'is_less_than': case 'is_less_than':
return new Date(conversationValue) < new Date(filterValue); return compareDates(conversationValue, filterValue, (a, b) => a < b);
case 'days_before': { case 'days_before': {
const today = new Date(); const today = new Date();
@ -347,6 +362,7 @@ export const matchesFilters = (conversation, filters) => {
conversation, conversation,
filters[0].attribute_key filters[0].attribute_key
); );
return matchesCondition(value, filters[0]); return matchesCondition(value, filters[0]);
} }

View File

@ -463,6 +463,241 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(true); expect(matchesFilters(conversation, filters)).toBe(true);
}); });
// Test conversation with 10-digit timestamp (seconds) vs standard date filter
it('should match conversation with 10-digit timestamp against date string filter', () => {
const conversation = { created_at: 1647777600 }; // March 20, 2022 in seconds (10 digits)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19', // Standard YYYY-MM-DD format
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test conversation with 13-digit timestamp (milliseconds) vs standard date filter
it('should match conversation with 13-digit timestamp against date string filter', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022 in milliseconds (13 digits)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19', // Standard YYYY-MM-DD format
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test conversation with string timestamp vs standard date filter
it('should match conversation with string 10-digit timestamp against date string filter', () => {
const conversation = { created_at: '1647777600' }; // March 20, 2022 as string (10 digits)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19', // Standard YYYY-MM-DD format
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test conversation with string 13-digit timestamp vs standard date filter
it('should match conversation with string 13-digit timestamp against date string filter', () => {
const conversation = { created_at: '1647777600000' }; // March 20, 2022 as string (13 digits)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19', // Standard YYYY-MM-DD format
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test conversation with mixed format vs standard date filter with time
it('should match conversation with numeric timestamp against ISO date string filter', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022 12:00:00 GMT (numeric)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19T10:30:00Z', // Standard ISO format from filter
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test parseDate with date string without time (should default to 00:00:00)
it('should match conversation with is_greater_than operator using date string without time', () => {
const conversation = { created_at: 1647820800000 }; // March 21, 2022 00:00:00 GMT
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-20', // March 20, 2022 (should become 00:00:00)
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test parseDate with ISO date string
it('should match conversation with is_greater_than operator using ISO date string', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19T00:00:00.000Z', // March 19, 2022 ISO format
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test parseDate with null/undefined values
it('should handle null filter values in date comparison', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: null,
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should handle undefined filter values in date comparison', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: undefined,
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
// Test parseDate with invalid date strings
it('should handle invalid date strings in date comparison', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: 'invalid-date-string',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should handle non-date string values in date comparison', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: 'not-a-date',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
// Test is_less_than with various date formats
it('should match conversation with is_less_than operator using numeric timestamp', () => {
const conversation = { created_at: 1647691200000 }; // March 19, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_less_than',
values: 1647777600, // March 20, 2022 as 10-digit timestamp
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should not match conversation with is_less_than operator when date is later', () => {
const conversation = { created_at: 1647864000000 }; // March 21, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_less_than',
values: '2022-03-20T12:00:00Z', // March 20, 2022 with time
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
// Edge case: Test with conversation having string timestamp
it('should handle conversation with string timestamp value', () => {
const conversation = { created_at: '1647777600000' }; // March 20, 2022 as string
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Edge case: Test with conversation having 10-digit timestamp
it('should handle conversation with 10-digit timestamp value', () => {
const conversation = { created_at: 1647777600 }; // March 20, 2022 as seconds (10 digits)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test date string with different time formats
it('should handle date string with space-separated time', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19 10:30:00', // Date with space-separated time
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test parseDate with object input (should return null and fail comparison)
it('should handle non-string, non-number filter values', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: { date: '2022-03-19' }, // Object instead of string/number
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
describe('days_before operator', () => { describe('days_before operator', () => {
beforeEach(() => { beforeEach(() => {
// Set the date to March 25, 2022 // Set the date to March 25, 2022

View File

@ -204,6 +204,12 @@ export const mutations = {
_state.allConversations.push(conversation); _state.allConversations.push(conversation);
}, },
[types.DELETE_CONVERSATION](_state, conversationId) {
_state.allConversations = _state.allConversations.filter(
c => c.id !== conversationId
);
},
[types.UPDATE_CONVERSATION](_state, conversation) { [types.UPDATE_CONVERSATION](_state, conversation) {
const { allConversations } = _state; const { allConversations } = _state;
const index = allConversations.findIndex(c => c.id === conversation.id); const index = allConversations.findIndex(c => c.id === conversation.id);

View File

@ -70,6 +70,30 @@ describe('#actions', () => {
}); });
}); });
describe('#active', () => {
it('sends correct mutations if API is success', async () => {
axios.get.mockResolvedValue({
data: { payload: contactList, meta: { count: 100, current_page: 1 } },
});
await actions.active({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isFetching: true }],
[types.CLEAR_CONTACTS],
[types.SET_CONTACTS, contactList],
[types.SET_CONTACT_META, { count: 100, current_page: 1 }],
[types.SET_CONTACT_UI_FLAG, { isFetching: false }],
]);
});
it('sends correct mutations if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.active({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isFetching: true }],
[types.SET_CONTACT_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#update', () => { describe('#update', () => {
it('sends correct mutations if API is success', async () => { it('sends correct mutations if API is success', async () => {
axios.patch.mockResolvedValue({ data: { payload: contactList[0] } }); axios.patch.mockResolvedValue({ data: { payload: contactList[0] } });

View File

@ -513,6 +513,28 @@ describe('#deleteMessage', () => {
expect(commit.mock.calls).toEqual([]); expect(commit.mock.calls).toEqual([]);
}); });
describe('#deleteConversation', () => {
it('send correct actions if API is success', async () => {
axios.delete.mockResolvedValue({
data: { id: 1 },
});
await actions.deleteConversation({ commit, dispatch }, 1);
expect(commit.mock.calls).toEqual([[types.DELETE_CONVERSATION, 1]]);
expect(dispatch.mock.calls).toEqual([
['conversationStats/get', {}, { root: true }],
]);
});
it('send no actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.deleteConversation({ commit, dispatch }, 1)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([]);
expect(dispatch.mock.calls).toEqual([]);
});
});
describe('#updateCustomAttributes', () => { describe('#updateCustomAttributes', () => {
it('update conversation custom attributes', async () => { it('update conversation custom attributes', async () => {
axios.post.mockResolvedValue({ axios.post.mockResolvedValue({

View File

@ -884,6 +884,17 @@ describe('#mutations', () => {
}); });
}); });
describe('#DELETE_CONVERSATION', () => {
it('should delete a conversation', () => {
const state = {
allConversations: [{ id: 1, messages: [] }],
};
mutations[types.DELETE_CONVERSATION](state, 1);
expect(state.allConversations).toEqual([]);
});
});
describe('#SET_LIST_LOADING_STATUS', () => { describe('#SET_LIST_LOADING_STATUS', () => {
it('should set listLoadingStatus to true', () => { it('should set listLoadingStatus to true', () => {
const state = { const state = {

View File

@ -56,6 +56,7 @@ export default {
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS', SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
ADD_CONVERSATION_ATTACHMENTS: 'ADD_CONVERSATION_ATTACHMENTS', ADD_CONVERSATION_ATTACHMENTS: 'ADD_CONVERSATION_ATTACHMENTS',
DELETE_CONVERSATION_ATTACHMENTS: 'DELETE_CONVERSATION_ATTACHMENTS', DELETE_CONVERSATION_ATTACHMENTS: 'DELETE_CONVERSATION_ATTACHMENTS',
DELETE_CONVERSATION: 'DELETE_CONVERSATION',
SET_CONVERSATION_CAN_REPLY: 'SET_CONVERSATION_CAN_REPLY', SET_CONVERSATION_CAN_REPLY: 'SET_CONVERSATION_CAN_REPLY',

View File

@ -1,3 +1,4 @@
// scss-lint:disable SpaceAfterPropertyColon
@import 'tailwindcss/base'; @import 'tailwindcss/base';
@import 'tailwindcss/components'; @import 'tailwindcss/components';
@import 'tailwindcss/utilities'; @import 'tailwindcss/utilities';
@ -7,7 +8,21 @@
html, html,
body { body {
font-family: 'InterDisplay', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; font-family:
'InterDisplay',
-apple-system,
system-ui,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Tahoma,
Arial,
sans-serif,
'Noto Sans',
'Apple Color Emoji',
'Segoe UI Emoji',
'Noto Color Emoji';
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
height: 100%; height: 100%;

View File

@ -37,7 +37,7 @@ export default {
} }
if (el.tag === 'h2') { if (el.tag === 'h2') {
if (this.h1Count > 0) { if (this.h1Count > 0) {
return 'ml-2'; return 'ltr:ml-2 rtl:mr-2';
} }
return ''; return '';
} }
@ -46,7 +46,7 @@ export default {
if (!this.h1Count && !this.h2Count) { if (!this.h1Count && !this.h2Count) {
return ''; return '';
} }
return 'ml-5'; return 'ltr:ml-5 rtl:mr-5';
} }
return ''; return '';
@ -94,17 +94,19 @@ export default {
</script> </script>
<template> <template>
<div class="hidden lg:block flex-1 py-6 scroll-mt-24 pl-4 sticky top-24"> <div
class="hidden lg:block flex-1 py-6 scroll-mt-24 ltr:pl-4 rtl:pr-4 sticky top-24"
>
<div v-if="rows.length > 0" class="py-2 overflow-auto"> <div v-if="rows.length > 0" class="py-2 overflow-auto">
<nav class="max-w-2xl"> <nav class="max-w-2xl">
<ol <ol
role="list" role="list"
class="flex flex-col gap-2 text-base border-l-2 border-solid border-slate-100 dark:border-slate-800" class="flex flex-col gap-2 text-base ltr:border-l-2 rtl:border-r-2 border-solid border-slate-100 dark:border-slate-800"
> >
<li <li
v-for="element in rows" v-for="element in rows"
:key="element.slug" :key="element.slug"
class="leading-6 border-l-2 relative -left-0.5 border-solid" class="leading-6 ltr:border-l-2 rtl:border-r-2 relative ltr:-left-0.5 rtl:-right-0.5 border-solid"
:class="elementBorderStyles(element)" :class="elementBorderStyles(element)"
> >
<p class="py-1 px-3" :class="getClassName(element)"> <p class="py-1 px-3" :class="getClassName(element)">

View File

@ -8,6 +8,7 @@ import slugifyWithCounter from '@sindresorhus/slugify';
import PublicArticleSearch from './components/PublicArticleSearch.vue'; import PublicArticleSearch from './components/PublicArticleSearch.vue';
import TableOfContents from './components/TableOfContents.vue'; import TableOfContents from './components/TableOfContents.vue';
import { initializeTheme } from './portalThemeHelper.js'; import { initializeTheme } from './portalThemeHelper.js';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
export const getHeadingsfromTheArticle = () => { export const getHeadingsfromTheArticle = () => {
const rows = []; const rows = [];
@ -114,10 +115,19 @@ export const InitializationHelpers = {
}); });
}, },
setDirectionAttribute: () => {
const portalElement = document.getElementById('portal');
if (!portalElement) return;
const locale = document.querySelector('.locale-switcher')?.value;
portalElement.dir = locale && getLanguageDirection(locale) ? 'rtl' : 'ltr';
},
initializeThemesInPortal: initializeTheme, initializeThemesInPortal: initializeTheme,
initialize: () => { initialize: () => {
openExternalLinksInNewTab(); openExternalLinksInNewTab();
InitializationHelpers.setDirectionAttribute();
if (window.portalConfig.isPlainLayoutEnabled === 'true') { if (window.portalConfig.isPlainLayoutEnabled === 'true') {
InitializationHelpers.appendPlainParamToURLs(); InitializationHelpers.appendPlainParamToURLs();
} else { } else {

View File

@ -1,6 +1,7 @@
<script setup> <script setup>
import HeaderActions from './HeaderActions.vue'; import HeaderActions from './HeaderActions.vue';
import { computed } from 'vue'; import { computed } from 'vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
const props = defineProps({ const props = defineProps({
avatarUrl: { avatarUrl: {
@ -21,6 +22,8 @@ const props = defineProps({
}, },
}); });
const { formatMessage } = useMessageFormatter();
const containerClasses = computed(() => [ const containerClasses = computed(() => [
props.avatarUrl ? 'justify-between' : 'justify-end', props.avatarUrl ? 'justify-between' : 'justify-end',
]); ]);
@ -47,8 +50,8 @@ const containerClasses = computed(() => [
class="mt-4 text-2xl mb-1.5 font-medium text-n-slate-12" class="mt-4 text-2xl mb-1.5 font-medium text-n-slate-12"
/> />
<p <p
v-dompurify-html="introBody" v-dompurify-html="formatMessage(introBody)"
class="text-lg leading-normal text-n-slate-11" class="text-lg leading-normal text-n-slate-11 [&_a]:underline"
/> />
</header> </header>
</template> </template>

View File

@ -4,7 +4,8 @@ class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer
@agent = agent @agent = agent
@conversation = conversation @conversation = conversation
subject = "#{@agent.available_name}, A new conversation [ID - #{@conversation.display_id}] has been created in #{@conversation.inbox&.name}." inbox_name = @conversation.inbox&.sanitized_name
subject = "#{@agent.available_name}, A new conversation [ID - #{@conversation.display_id}] has been created in #{inbox_name}."
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id) @action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
send_mail_with_liquid(to: @agent.email, subject: subject) and return send_mail_with_liquid(to: @agent.email, subject: subject) and return
end end

View File

@ -104,7 +104,7 @@ class ConversationReplyMailer < ApplicationMailer
end end
def business_name def business_name
@inbox.business_name || @inbox.name @inbox.business_name || @inbox.sanitized_name
end end
def from_email def from_email

View File

@ -36,6 +36,7 @@ class Account < ApplicationRecord
'auto_resolve_after': { 'type': %w[integer null], 'minimum': 10, 'maximum': 1_439_856 }, 'auto_resolve_after': { 'type': %w[integer null], 'minimum': 10, 'maximum': 1_439_856 },
'auto_resolve_message': { 'type': %w[string null] }, 'auto_resolve_message': { 'type': %w[string null] },
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] }, 'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
'audio_transcriptions': { 'type': %w[boolean null] },
'auto_resolve_label': { 'type': %w[string null] } 'auto_resolve_label': { 'type': %w[string null] }
}, },
'required': [], 'required': [],
@ -52,7 +53,8 @@ class Account < ApplicationRecord
schema: SETTINGS_PARAMS_SCHEMA, schema: SETTINGS_PARAMS_SCHEMA,
attribute_resolver: ->(record) { record.settings } attribute_resolver: ->(record) { record.settings }
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :auto_resolve_label store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
has_many :account_users, dependent: :destroy_async has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async has_many :agent_bot_inboxes, dependent: :destroy_async

View File

@ -44,11 +44,8 @@ class Attachment < ApplicationRecord
def push_event_data def push_event_data
return unless file_type return unless file_type
return base_data.merge(location_metadata) if file_type.to_sym == :location
return base_data.merge(fallback_data) if file_type.to_sym == :fallback
return base_data.merge(contact_metadata) if file_type.to_sym == :contact
base_data.merge(file_metadata) base_data.merge(metadata_for_file_type)
end end
# NOTE: the URl returned does a 301 redirect to the actual file # NOTE: the URl returned does a 301 redirect to the actual file
@ -76,6 +73,30 @@ class Attachment < ApplicationRecord
private private
def metadata_for_file_type
case file_type.to_sym
when :location
location_metadata
when :fallback
fallback_data
when :contact
contact_metadata
when :audio
audio_metadata
else
file_metadata
end
end
def audio_metadata
audio_file_data = base_data.merge(file_metadata)
audio_file_data.merge(
{
transcribed_text: meta&.[]('transcribed_text') || ''
}
)
end
def file_metadata def file_metadata
metadata = { metadata = {
extension: extension, extension: extension,
@ -149,3 +170,5 @@ class Attachment < ApplicationRecord
file_content_type.start_with?('image/', 'video/', 'audio/') file_content_type.start_with?('image/', 'video/', 'audio/')
end end
end end
Attachment.include_mod_with('Concerns::Attachment')

View File

@ -305,5 +305,6 @@ class Conversation < ApplicationRecord
end end
end end
Conversation.include_mod_with('Audit::Conversation')
Conversation.include_mod_with('Concerns::Conversation') Conversation.include_mod_with('Concerns::Conversation')
Conversation.prepend_mod_with('Conversation') Conversation.prepend_mod_with('Conversation')

View File

@ -47,8 +47,6 @@ class Inbox < ApplicationRecord
# Not allowing characters: # Not allowing characters:
validates :name, presence: true validates :name, presence: true
validates :name, if: :check_channel_type?, format: { with: %r{^^\b[^/\\<>@]*\b$}, multiline: true,
message: I18n.t('errors.inboxes.validations.name') }
validates :account_id, presence: true validates :account_id, presence: true
validates :timezone, inclusion: { in: TZInfo::Timezone.all_identifiers } validates :timezone, inclusion: { in: TZInfo::Timezone.all_identifiers }
validates :out_of_office_message, length: { maximum: Limits::OUT_OF_OFFICE_MESSAGE_MAX_LENGTH } validates :out_of_office_message, length: { maximum: Limits::OUT_OF_OFFICE_MESSAGE_MAX_LENGTH }
@ -99,6 +97,16 @@ class Inbox < ApplicationRecord
update_account_cache update_account_cache
end end
# Sanitizes inbox name for balanced email provider compatibility
# ALLOWS: /'._- and Unicode letters/numbers/emojis
# REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
def sanitized_name
return default_name_for_blank_name if name.blank?
sanitized = apply_sanitization_rules(name)
sanitized.blank? && email? ? display_name_from_email : sanitized
end
def sms? def sms?
channel_type == 'Channel::Sms' channel_type == 'Channel::Sms'
end end
@ -178,6 +186,22 @@ class Inbox < ApplicationRecord
private private
def default_name_for_blank_name
email? ? display_name_from_email : ''
end
def apply_sanitization_rules(name)
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~]/, '') # Remove forbidden chars
.gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces
.gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation
.gsub(/\s+/, ' ') # Normalize spaces
.strip
end
def display_name_from_email
channel.email.split('@').first.parameterize.titleize
end
def dispatch_create_event def dispatch_create_event
return if ENV['ENABLE_INBOX_EVENTS'].blank? return if ENV['ENABLE_INBOX_EVENTS'].blank?

View File

@ -224,6 +224,11 @@ class Message < ApplicationRecord
save! save!
end end
def send_update_event
Rails.configuration.dispatcher.dispatch(MESSAGE_UPDATED, Time.zone.now, message: self, performed_by: Current.executed_by,
previous_changes: previous_changes)
end
private private
def prevent_message_flooding def prevent_message_flooding
@ -313,8 +318,7 @@ class Message < ApplicationRecord
# we want to skip the update event if the message is not updated # we want to skip the update event if the message is not updated
return if previous_changes.blank? return if previous_changes.blank?
Rails.configuration.dispatcher.dispatch(MESSAGE_UPDATED, Time.zone.now, message: self, performed_by: Current.executed_by, send_update_event
previous_changes: previous_changes)
end end
def send_reply def send_reply

View File

@ -2,4 +2,8 @@ class ConversationPolicy < ApplicationPolicy
def index? def index?
true true
end end
def destroy?
@account_user&.administrator?
end
end end

View File

@ -1,10 +1,10 @@
<% author_count = category.articles.published.order(position: :asc).map(&:author).uniq.size %> <% author_count = category.articles.published.order(position: :asc).map(&:author).uniq.size %>
<% if author_count > 0 %> <% if author_count > 0 %>
<div class="flex flex-row items-center gap-1"> <div class="flex flex-row items-center gap-1">
<div class="flex flex-row items-center -space-x-2"> <div class="flex items-center ltr:flex-row rtl:flex-row-reverse -space-x-2">
<% category.articles.published.order(position: :asc).map(&:author).uniq.take(3).each do |author| %> <% category.articles.published.order(position: :asc).map(&:author).uniq.take(3).each do |author| %>
<%= render "public/api/v1/portals/thumbnail", author: author, size: 5 %> <%= render "public/api/v1/portals/thumbnail", author: author, size: 5 %>
<% end %> <% end %>
</div> </div>
<% first_author = category.articles.published.order(position: :asc).map(&:author).uniq.first&.name || '' %> <% first_author = category.articles.published.order(position: :asc).map(&:author).uniq.first&.name || '' %>

View File

@ -3,7 +3,7 @@
<div class="flex items-center w-full py-5 overflow-hidden"> <div class="flex items-center w-full py-5 overflow-hidden">
<a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="flex items-center h-10 text-lg font-semibold text-slate-900 dark:text-white"> <a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="flex items-center h-10 text-lg font-semibold text-slate-900 dark:text-white">
<% if @portal.logo.present? %> <% if @portal.logo.present? %>
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 mr-2" /> <img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" />
<% end %> <% end %>
<%= @portal.name %> <%= @portal.name %>
</a> </a>
@ -12,7 +12,7 @@
<%# Go to homepage link section %> <%# Go to homepage link section %>
<div class="flex items-center justify-between gap-2 sm:gap-5"> <div class="flex items-center justify-between gap-2 sm:gap-5">
<% if @portal.homepage_link %> <% if @portal.homepage_link %>
<div class="hidden px-1 py-2 ml-8 cursor-pointer border-l-1 border-slate-50 dark:border-slate-800 md:block"> <div class="hidden px-1 py-2 ltr:ml-8 rtl:mr-8 cursor-pointer md:block">
<div class="flex-grow flex-shrink-0"> <div class="flex-grow flex-shrink-0">
<a id="header-action-button" target="_blank" rel="noopener noreferrer nofollow" href="<%= @portal.homepage_link %>" class="flex flex-row items-center gap-1 text-sm font-medium whitespace-nowrap text-slate-800 dark:text-slate-100 stroke-slate-700 dark:stroke-slate-200"> <a id="header-action-button" target="_blank" rel="noopener noreferrer nofollow" href="<%= @portal.homepage_link %>" class="flex flex-row items-center gap-1 text-sm font-medium whitespace-nowrap text-slate-800 dark:text-slate-100 stroke-slate-700 dark:stroke-slate-200">
<%= render partial: 'icons/redirect' %> <%= render partial: 'icons/redirect' %>
@ -42,7 +42,7 @@
</div> </div>
</button> </button>
<%# Appearance dropdown section %> <%# Appearance dropdown section %>
<div id="appearance-dropdown" data-current-theme="<%= @theme_from_params %>" class="absolute flex-col w-32 h-auto bg-white border border-solid rounded dark:bg-slate-900 top-9 right-1 border-slate-100 dark:border-slate-800" aria-hidden="true" style="display: none;" data-dropdown="appearance-dropdown"> <div id="appearance-dropdown" data-current-theme="<%= @theme_from_params %>" class="absolute flex-col w-32 h-auto bg-white border border-solid rounded dark:bg-slate-900 top-9 ltr:right-1 rtl:left-1 border-slate-100 dark:border-slate-800" aria-hidden="true" style="display: none;" data-dropdown="appearance-dropdown">
<button id="toggle-theme-button" data-theme="system" class="flex flex-row items-center justify-between gap-1 px-2 py-2 border-b border-solid border-slate-100 dark:border-slate-800 stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100"> <button id="toggle-theme-button" data-theme="system" class="flex flex-row items-center justify-between gap-1 px-2 py-2 border-b border-solid border-slate-100 dark:border-slate-800 stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100">
<div class="flex flex-row items-center gap-1"> <div class="flex flex-row items-center gap-1">
<%= render partial: 'icons/monitor' %> <%= render partial: 'icons/monitor' %>

View File

@ -91,55 +91,7 @@ as well as a link to its edit page.
</div> </div>
</section> </section>
<section class="main-content__body"> <%= render 'super_admin/shared/account_user_form', page: page, namespace: namespace, resource_type: 'account' %>
<% account_user_page =
Administrate::Page::Form.new(AccountUserDashboard.new, AccountUser.new) %>
<%= form_for([namespace, account_user_page.resource], html: { class: "form" }) do |f| %>
<% if account_user_page.resource.errors.any? %>
<div id="error_explanation">
<h2>
<%= t(
"administrate.form.errors",
pluralized_errors:
pluralize(
account_user_page.resource.errors.count,
t("administrate.form.error"),
),
resource_name: display_resource_name(account_user_page.resource_name),
) %>
</h2>
<ul>
<% account_user_page.resource.errors.full_messages.each do |message| %>
<li class="flash-error"><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<% account_user_page.attributes.each do |title, attributes| -%>
<% attributes.each do |attribute| %>
<% if attribute.name == "account" %>
<%= f.hidden_field("account_id", value: page.resource.id) %>
<% else %>
<div
class="
field-unit field-unit--<%= attribute.html_class %>
field-unit--<%= requireness(attribute) %>
"
>
<%= render_field attribute, f: f %>
</div>
<% end %>
<% end %>
<% end -%>
<div class="form-actions">
<%= f.submit %>
</div>
<% end %>
</section>
<%= render partial: "seed_data", locals: { page: page } %> <%= render partial: "seed_data", locals: { page: page } %>

View File

@ -0,0 +1,54 @@
<%#
# Account User Form Partial
This partial renders the account user creation form.
Used by both account and user show pages.
## Local variables:
- `page`: The Administrate page object (Account or User)
- `namespace`: The current namespace (usually :super_admin)
- `resource_type`: Either 'account' or 'user' to determine hidden field
%>
<section class="main-content__body">
<% account_user_page = Administrate::Page::Form.new(AccountUserDashboard.new, AccountUser.new) %>
<%= form_for([namespace, account_user_page.resource], html: { class: "form" }) do |f| %>
<% if account_user_page.resource.errors.any? %>
<div id="error_explanation">
<h2>
<%= t(
"administrate.form.errors",
pluralized_errors: pluralize(
account_user_page.resource.errors.count,
t("administrate.form.error")
),
resource_name: display_resource_name(account_user_page.resource_name)
) %>
</h2>
<ul>
<% account_user_page.resource.errors.full_messages.each do |message| %>
<li class="flash-error"><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<% account_user_page.attributes.each do |title, attributes| -%>
<% attributes.each do |attribute| %>
<% if attribute.name == resource_type %>
<%= f.hidden_field("#{resource_type}_id", value: page.resource.id) %>
<% else %>
<div class="field-unit field-unit--<%= attribute.html_class %> field-unit--<%= requireness(attribute) %>">
<%= render_field attribute, f: f %>
</div>
<% end %>
<% end %>
<% end -%>
<div class="form-actions">
<%= f.submit %>
</div>
<% end %>
</section>

View File

@ -56,44 +56,6 @@ as well as a link to its edit page.
</dl> </dl>
</section> </section>
<section class="main-content__body"> <%= render 'super_admin/shared/account_user_form', page: page, namespace: namespace, resource_type: 'user' %>
<% account_user_page = Administrate::Page::Form.new(AccountUserDashboard.new, AccountUser.new) %>
<%= form_for([namespace, account_user_page.resource], html: { class: "form" }) do |f| %>
<% if account_user_page.resource.errors.any? %>
<div id="error_explanation">
<h2>
<%= t(
"administrate.form.errors",
pluralized_errors: pluralize(account_user_page.resource.errors.count, t("administrate.form.error")),
resource_name: display_resource_name(account_user_page.resource_name)
) %>
</h2>
<ul>
<% account_user_page.resource.errors.full_messages.each do |message| %>
<li class="flash-error"><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<% account_user_page.attributes.each do |title, attributes| -%>
<% attributes.each do |attribute| %>
<% if attribute.name == "user" %>
<%= f.hidden_field('user_id', value: page.resource.id) %>
<% else %>
<div class="field-unit field-unit--<%= attribute.html_class %> field-unit--<%= requireness(attribute) %>">
<%= render_field attribute, f: f %>
</div>
<% end %>
<% end %>
<% end -%>
<div class="form-actions">
<%= f.submit %>
</div>
<% end %>
</section>
<%= render partial: "impersonate", locals: {page: page} %> <%= render partial: "impersonate", locals: {page: page} %>

View File

@ -11,7 +11,7 @@
locale: '<%= @web_widget.account.locale %>', locale: '<%= @web_widget.account.locale %>',
websiteName: '<%= @web_widget.inbox.name %>', websiteName: '<%= @web_widget.inbox.name %>',
websiteToken: '<%= @web_widget.website_token %>', websiteToken: '<%= @web_widget.website_token %>',
welcomeTagline: '<%= @web_widget.welcome_tagline %>', welcomeTagline: <%= @web_widget.welcome_tagline.to_json.html_safe %>,
welcomeTitle: '<%= @web_widget.welcome_title %>', welcomeTitle: '<%= @web_widget.welcome_title %>',
widgetColor: '<%= @web_widget.widget_color %>', widgetColor: '<%= @web_widget.widget_color %>',
portal: <%= @web_widget.inbox.portal.to_json.html_safe %>, portal: <%= @web_widget.inbox.portal.to_json.html_safe %>,

View File

@ -84,6 +84,7 @@ dialogflow:
{ {
'project_id': { 'type': 'string' }, 'project_id': { 'type': 'string' },
'credentials': { 'type': 'object' }, 'credentials': { 'type': 'object' },
'region': { 'type': 'string' },
}, },
'required': ['project_id', 'credentials'], 'required': ['project_id', 'credentials'],
'additionalProperties': false, 'additionalProperties': false,
@ -106,8 +107,21 @@ dialogflow:
'validation-messages': 'validation-messages':
{ 'JSON': 'Invalid JSON', 'required': 'Credentials is required' }, { 'JSON': 'Invalid JSON', 'required': 'Credentials is required' },
}, },
{
'label': 'Dialogflow Region',
'type': 'select',
'name': 'region',
'default': 'global',
'options': [
{ 'label': 'Global - Default', 'value': 'global' },
{ 'label': 'AS-NE1 - Tokyo, Japan', 'value': 'asia-northeast1' },
{ 'label': 'AU-SE1 - Sydney, Australia', 'value': 'australia-southeast1' },
{ 'label': 'EU-W1 - St. Ghislain, Belgium', 'value': 'europe-west1' },
{ 'label': 'EU-W2 - London, England', 'value': 'europe-west2' },
],
},
] ]
visible_properties: ['project_id'] visible_properties: ['project_id', 'region']
google_translate: google_translate:
id: google_translate id: google_translate
logo: google-translate.png logo: google-translate.png

View File

@ -98,7 +98,7 @@ Rails.application.routes.draw do
namespace :channels do namespace :channels do
resource :twilio_channel, only: [:create] resource :twilio_channel, only: [:create]
end end
resources :conversations, only: [:index, :create, :show, :update] do resources :conversations, only: [:index, :create, :show, :update, :destroy] do
collection do collection do
get :meta get :meta
get :search get :search
@ -533,7 +533,7 @@ Rails.application.routes.draw do
end end
# resources that doesn't appear in primary navigation in super admin # resources that doesn't appear in primary navigation in super admin
resources :account_users, only: [:new, :create, :destroy] resources :account_users, only: [:new, :create, :show, :destroy]
end end
authenticated :super_admin do authenticated :super_admin do
mount Sidekiq::Web => '/monitoring/sidekiq' mount Sidekiq::Web => '/monitoring/sidekiq'

View File

@ -1,5 +1,6 @@
class Captain::Conversation::ResponseBuilderJob < ApplicationJob class Captain::Conversation::ResponseBuilderJob < ApplicationJob
MAX_MESSAGE_LENGTH = 10_000 MAX_MESSAGE_LENGTH = 10_000
retry_on ActiveStorage::FileNotFoundError, attempts: 3
def perform(conversation, assistant) def perform(conversation, assistant)
@conversation = conversation @conversation = conversation
@ -12,6 +13,8 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
generate_and_process_response generate_and_process_response
end end
rescue StandardError => e rescue StandardError => e
raise e if e.is_a?(ActiveJob::FileNotFoundError)
handle_error(e) handle_error(e)
ensure ensure
Current.executed_by = nil Current.executed_by = nil
@ -49,10 +52,24 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def message_content(message) def message_content(message)
return message.content if message.content.present? return message.content if message.content.present?
return 'User has shared a message without content' unless message.attachments.any?
return 'User has shared an attachment' if message.attachments.any? audio_transcriptions = extract_audio_transcriptions(message.attachments)
return audio_transcriptions if audio_transcriptions.present?
'User has shared a message without content' 'User has shared an attachment'
end
def extract_audio_transcriptions(attachments)
audio_attachments = attachments.where(file_type: :audio)
return '' if audio_attachments.blank?
transcriptions = ''
audio_attachments.each do |attachment|
result = Messages::AudioTranscriptionService.new(attachment).perform
transcriptions += result[:transcriptions] if result[:success]
end
transcriptions
end end
def determine_role(message) def determine_role(message)

View File

@ -4,7 +4,7 @@ module Enterprise::DeleteObjectJob
end end
def create_audit_entry(object, user, ip) def create_audit_entry(object, user, ip)
return unless ['Inbox'].include?(object.class.to_s) && user.present? return unless %w[Inbox Conversation].include?(object.class.to_s) && user.present?
Enterprise::AuditLog.create( Enterprise::AuditLog.create(
auditable: object, auditable: object,

View File

@ -0,0 +1,12 @@
class Messages::AudioTranscriptionJob < ApplicationJob
queue_as :low
retry_on ActiveStorage::FileNotFoundError, wait: 2.seconds, attempts: 3
def perform(attachment_id)
attachment = Attachment.find_by(id: attachment_id)
return if attachment.blank?
Messages::AudioTranscriptionService.new(attachment).perform
end
end

View File

@ -0,0 +1,7 @@
module Enterprise::Audit::Conversation
extend ActiveSupport::Concern
included do
audited only: [], on: [:destroy]
end
end

View File

@ -0,0 +1,15 @@
module Enterprise::Concerns::Attachment
extend ActiveSupport::Concern
included do
after_create_commit :enqueue_audio_transcription
end
private
def enqueue_audio_transcription
return unless file_type.to_sym == :audio
Messages::AudioTranscriptionJob.perform_later(id)
end
end

View File

@ -0,0 +1,61 @@
class Messages::AudioTranscriptionService < Llm::BaseOpenAiService
attr_reader :attachment, :message, :account
def initialize(attachment)
super()
@attachment = attachment
@message = attachment.message
@account = message.account
end
def perform
return { error: 'Transcription limit exceeded' } unless can_transcribe?
return { error: 'Message not found' } if message.blank?
transcriptions = transcribe_audio
Rails.logger.info "Audio transcription successful: #{transcriptions}"
{ success: true, transcriptions: transcriptions }
end
private
def can_transcribe?
account.audio_transcriptions.present? && account.usage_limits[:captain][:responses][:current_available].positive?
end
def fetch_audio_file
temp_dir = Rails.root.join('tmp/uploads')
FileUtils.mkdir_p(temp_dir)
temp_file_path = File.join(temp_dir, attachment.file.filename.to_s)
File.write(temp_file_path, attachment.file.download, mode: 'wb')
temp_file_path
end
def transcribe_audio
transcribed_text = attachment.meta&.[]('transcribed_text') || ''
return transcribed_text if transcribed_text.present?
temp_file_path = fetch_audio_file
response = @client.audio.transcribe(
parameters: {
model: 'whisper-1',
file: File.open(temp_file_path),
temperature: 0.4
}
)
FileUtils.rm_f(temp_file_path)
update_transcription(response['text'])
response['text']
end
def update_transcription(transcribed_text)
return if transcribed_text.blank?
attachment.update!(meta: { transcribed_text: transcribed_text })
message.reload.send_update_event
message.account.increment_response_usage
end
end

View File

@ -65,13 +65,37 @@ class Integrations::Dialogflow::ProcessorService < Integrations::BotProcessorSer
::Google::Cloud::Dialogflow::V2::Sessions::Client.configure do |config| ::Google::Cloud::Dialogflow::V2::Sessions::Client.configure do |config|
config.timeout = 10.0 config.timeout = 10.0
config.credentials = hook.settings['credentials'] config.credentials = hook.settings['credentials']
config.endpoint = dialogflow_endpoint
end end
end end
def normalized_region
region = hook.settings['region'].to_s.strip
(region.presence || 'global')
end
def dialogflow_endpoint
region = normalized_region
return 'dialogflow.googleapis.com' if region == 'global'
"#{region}-dialogflow.googleapis.com"
end
def detect_intent(session_id, message) def detect_intent(session_id, message)
client = ::Google::Cloud::Dialogflow::V2::Sessions::Client.new client = ::Google::Cloud::Dialogflow::V2::Sessions::Client.new
session = "projects/#{hook.settings['project_id']}/agent/sessions/#{session_id}" session = build_session_path(session_id)
query_input = { text: { text: message, language_code: 'en-US' } } query_input = { text: { text: message, language_code: 'en-US' } }
client.detect_intent session: session, query_input: query_input client.detect_intent session: session, query_input: query_input
end end
def build_session_path(session_id)
project_id = hook.settings['project_id']
region = normalized_region
if region == 'global'
"projects/#{project_id}/agent/sessions/#{session_id}"
else
"projects/#{project_id}/locations/#{region}/agent/sessions/#{session_id}"
end
end
end end

View File

@ -34,7 +34,7 @@
"@breezystack/lamejs": "^1.2.7", "@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3", "@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.1.1-next", "@chatwoot/prosemirror-schema": "1.1.1-next",
"@chatwoot/utils": "^0.0.45", "@chatwoot/utils": "^0.0.46",
"@formkit/core": "^1.6.7", "@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7", "@formkit/vue": "^1.6.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0", "@hcaptcha/vue3-hcaptcha": "^1.3.0",

10
pnpm-lock.yaml generated
View File

@ -23,8 +23,8 @@ importers:
specifier: 1.1.1-next specifier: 1.1.1-next
version: 1.1.1-next version: 1.1.1-next
'@chatwoot/utils': '@chatwoot/utils':
specifier: ^0.0.45 specifier: ^0.0.46
version: 0.0.45 version: 0.0.46
'@formkit/core': '@formkit/core':
specifier: ^1.6.7 specifier: ^1.6.7
version: 1.6.7 version: 1.6.7
@ -406,8 +406,8 @@ packages:
'@chatwoot/prosemirror-schema@1.1.1-next': '@chatwoot/prosemirror-schema@1.1.1-next':
resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==} resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==}
'@chatwoot/utils@0.0.45': '@chatwoot/utils@0.0.46':
resolution: {integrity: sha512-zqmuri6MrEFAY1tLv7Z3HBy4Ig60LhSrLkEiHegVsOVSxPv4Bedq+xmAW7LphvcLNgbkkvu17MU91gvMVlpEHw==} resolution: {integrity: sha512-a68CQ+aPFfyMr7dnXUUSt/kwHEazBd7Y8aidDZeDp5eL7sych7EpmT5XMTmhttlqMiRsmwETblXJJ2fBH6I44A==}
engines: {node: '>=10'} engines: {node: '>=10'}
'@codemirror/commands@6.7.0': '@codemirror/commands@6.7.0':
@ -5255,7 +5255,7 @@ snapshots:
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3) prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
prosemirror-view: 1.34.1 prosemirror-view: 1.34.1
'@chatwoot/utils@0.0.45': '@chatwoot/utils@0.0.46':
dependencies: dependencies:
date-fns: 2.30.0 date-fns: 2.30.0

View File

@ -926,4 +926,63 @@ RSpec.describe 'Conversations API', type: :request do
end end
end end
end end
describe 'DELETE /api/v1/accounts/{account.id}/conversations/:id' do
let(:conversation) { create(:conversation, account: account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:administrator) { create(:user, account: account, role: :administrator) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated agent' do
before do
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
response_body = response.parsed_body
expect(response_body['error']).to eq('You are not authorized to do this action')
end
end
context 'when it is an authenticated administrator' do
before do
create(:inbox_member, user: administrator, inbox: conversation.inbox)
end
it 'successfully deletes the conversation' do
expect do
delete "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
headers: administrator.create_new_auth_token,
as: :json
end.to have_enqueued_job(DeleteObjectJob).with(conversation, administrator, anything)
expect(response).to have_http_status(:ok)
end
it 'can delete conversations from inboxes without direct access' do
other_inbox = create(:inbox, account: account)
other_conversation = create(:conversation, account: account, inbox: other_inbox)
expect do
delete "/api/v1/accounts/#{account.id}/conversations/#{other_conversation.display_id}",
headers: administrator.create_new_auth_token,
as: :json
end.to have_enqueued_job(DeleteObjectJob).with(other_conversation, administrator, anything)
expect(response).to have_http_status(:ok)
end
end
end
end end

View File

@ -5,7 +5,7 @@ RSpec.describe 'Integration Hooks API', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) } let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) } let(:agent) { create(:user, account: account, role: :agent) }
let(:inbox) { create(:inbox, account: account) } let(:inbox) { create(:inbox, account: account) }
let(:params) { { app_id: 'dialogflow', inbox_id: inbox.id, settings: { project_id: 'xx', credentials: { test: 'test' } } } } let(:params) { { app_id: 'dialogflow', inbox_id: inbox.id, settings: { project_id: 'xx', credentials: { test: 'test' }, region: 'europe-west1' } } }
describe 'POST /api/v1/accounts/{account.id}/integrations/hooks' do describe 'POST /api/v1/accounts/{account.id}/integrations/hooks' do
context 'when it is an unauthenticated user' do context 'when it is an unauthenticated user' do

View File

@ -0,0 +1,41 @@
require 'rails_helper'
RSpec.describe Messages::AudioTranscriptionJob do
subject(:job) { described_class.perform_later(attachment_id) }
let(:message) { create(:message) }
let(:attachment) do
message.attachments.create!(
account_id: message.account_id,
file_type: :audio,
file: fixture_file_upload('public/audio/widget/ding.mp3')
)
end
let(:attachment_id) { attachment.id }
let(:conversation) { message.conversation }
let(:transcription_service) { instance_double(Messages::AudioTranscriptionService) }
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(attachment_id)
.on_queue('low')
end
context 'when performing the job' do
before do
allow(Messages::AudioTranscriptionService).to receive(:new).with(attachment).and_return(transcription_service)
allow(transcription_service).to receive(:perform)
end
it 'calls AudioTranscriptionService with the attachment' do
expect(Messages::AudioTranscriptionService).to receive(:new).with(attachment)
expect(transcription_service).to receive(:perform)
described_class.perform_now(attachment_id)
end
it 'does nothing when attachment is not found' do
expect(Messages::AudioTranscriptionService).not_to receive(:new)
described_class.perform_now(999_999)
end
end
end

View File

@ -0,0 +1,57 @@
require 'rails_helper'
RSpec.describe Messages::AudioTranscriptionService, type: :service do
let(:account) { create(:account, audio_transcriptions: true) }
let(:conversation) { create(:conversation, account: account) }
let(:message) { create(:message, conversation: conversation) }
let(:attachment) { message.attachments.create!(account: account, file_type: :audio) }
before do
# Create required installation configs
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-api-key')
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini')
# Mock usage limits for transcription to be available
allow(account).to receive(:usage_limits).and_return({ captain: { responses: { current_available: 100 } } })
end
describe '#perform' do
let(:service) { described_class.new(attachment) }
context 'when transcription is successful' do
before do
# Mock can_transcribe? to return true and transcribe_audio method
allow(service).to receive(:can_transcribe?).and_return(true)
allow(service).to receive(:transcribe_audio).and_return('Hello world transcription')
end
it 'returns successful transcription' do
result = service.perform
expect(result).to eq({ success: true, transcriptions: 'Hello world transcription' })
end
end
context 'when audio transcriptions are disabled' do
before do
account.update!(audio_transcriptions: false)
end
it 'returns error for transcription limit exceeded' do
result = service.perform
expect(result).to eq({ error: 'Transcription limit exceeded' })
end
end
context 'when attachment already has transcribed text' do
before do
attachment.update!(meta: { transcribed_text: 'Existing transcription' })
allow(service).to receive(:can_transcribe?).and_return(true)
end
it 'returns existing transcription without calling API' do
result = service.perform
expect(result).to eq({ success: true, transcriptions: 'Existing transcription' })
end
end
end
end

View File

@ -9,7 +9,7 @@ FactoryBot.define do
trait :dialogflow do trait :dialogflow do
app_id { 'dialogflow' } app_id { 'dialogflow' }
settings { { project_id: 'test', credentials: {} } } settings { { project_id: 'test', credentials: {}, region: 'global' } }
end end
trait :dyte do trait :dyte do

View File

@ -175,4 +175,65 @@ describe Integrations::Dialogflow::ProcessorService do
.to change(hook, :status).from('enabled').to('disabled') .to change(hook, :status).from('enabled').to('disabled')
end end
end end
describe 'region configuration' do
let(:processor) { described_class.new(event_name: event_name, hook: hook, event_data: event_data) }
context 'when region is global or not specified' do
it 'uses global endpoint and session path' do
hook.update(settings: { 'project_id' => 'test-project', 'credentials' => {} })
expect(processor.send(:dialogflow_endpoint)).to eq('dialogflow.googleapis.com')
expect(processor.send(:build_session_path, 'test-session')).to eq('projects/test-project/agent/sessions/test-session')
end
end
context 'when region is specified' do
it 'uses regional endpoint and session path' do
hook.update(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'region' => 'europe-west1' })
expect(processor.send(:dialogflow_endpoint)).to eq('europe-west1-dialogflow.googleapis.com')
expect(processor.send(:build_session_path, 'test-session')).to eq('projects/test-project/locations/europe-west1/agent/sessions/test-session')
end
end
it 'configures client with correct endpoint' do
hook.update(settings: { 'project_id' => 'test', 'credentials' => {}, 'region' => 'europe-west1' })
config = OpenStruct.new
expect(Google::Cloud::Dialogflow::V2::Sessions::Client).to receive(:configure).and_yield(config)
processor.send(:configure_dialogflow_client_defaults)
expect(config.endpoint).to eq('europe-west1-dialogflow.googleapis.com')
end
context 'when calling detect_intent' do
let(:mock_client) { instance_double(Google::Cloud::Dialogflow::V2::Sessions::Client) }
before do
allow(Google::Cloud::Dialogflow::V2::Sessions::Client).to receive(:new).and_return(mock_client)
end
it 'uses global session path when region is not specified' do
hook.update(settings: { 'project_id' => 'test-project', 'credentials' => {} })
expect(mock_client).to receive(:detect_intent).with(
session: 'projects/test-project/agent/sessions/test-session',
query_input: { text: { text: 'Hello', language_code: 'en-US' } }
)
processor.send(:detect_intent, 'test-session', 'Hello')
end
it 'uses regional session path when region is specified' do
hook.update(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'region' => 'europe-west1' })
expect(mock_client).to receive(:detect_intent).with(
session: 'projects/test-project/locations/europe-west1/agent/sessions/test-session',
query_input: { text: { text: 'Hello', language_code: 'en-US' } }
)
processor.send(:detect_intent, 'test-session', 'Hello')
end
end
end
end end

View File

@ -18,7 +18,7 @@ RSpec.describe AgentNotifications::ConversationNotificationsMailer do
it 'renders the subject' do it 'renders the subject' do
expect(mail.subject).to eq("#{agent.available_name}, A new conversation [ID - #{conversation expect(mail.subject).to eq("#{agent.available_name}, A new conversation [ID - #{conversation
.display_id}] has been created in #{conversation.inbox&.name}.") .display_id}] has been created in #{conversation.inbox&.sanitized_name}.")
end end
it 'renders the receiver email' do it 'renders the receiver email' do

View File

@ -87,7 +87,7 @@ RSpec.describe ConversationReplyMailer do
let(:mail) { described_class.reply_with_summary(message.conversation, message.id).deliver_now } let(:mail) { described_class.reply_with_summary(message.conversation, message.id).deliver_now }
it 'has correct name' do it 'has correct name' do
expect(mail[:from].display_names).to eq(["#{message.sender.available_name} from Inbox"]) expect(mail[:from].display_names).to eq(["#{message.sender.available_name} from #{message.conversation.inbox.sanitized_name}"])
end end
end end
@ -224,11 +224,11 @@ RSpec.describe ConversationReplyMailer do
end end
context 'when smtp enabled for email channel' do context 'when smtp enabled for email channel' do
let(:smtp_email_channel) do let(:smtp_channel) do
create(:channel_email, smtp_enabled: true, smtp_address: 'smtp.gmail.com', smtp_port: 587, smtp_login: 'smtp@gmail.com', create(:channel_email, smtp_enabled: true, smtp_address: 'smtp.gmail.com', smtp_port: 587, smtp_login: 'smtp@gmail.com',
smtp_password: 'password', smtp_domain: 'smtp.gmail.com', account: account) smtp_password: 'password', smtp_domain: 'smtp.gmail.com', account: account)
end end
let(:conversation) { create(:conversation, assignee: agent, inbox: smtp_email_channel.inbox, account: account).reload } let(:conversation) { create(:conversation, assignee: agent, inbox: smtp_channel.inbox, account: account).reload }
let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') } let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') }
it 'use smtp mail server' do it 'use smtp mail server' do
@ -240,19 +240,19 @@ RSpec.describe ConversationReplyMailer do
it 'renders sender name in the from address' do it 'renders sender name in the from address' do
mail = described_class.email_reply(message) mail = described_class.email_reply(message)
expect(mail['from'].value).to eq "#{message.sender.available_name} from #{smtp_email_channel.inbox.name} <#{smtp_email_channel.email}>" expect(mail['from'].value).to eq "#{message.sender.available_name} from #{smtp_channel.inbox.sanitized_name} <#{smtp_channel.email}>"
end end
it 'renders sender name even when assignee is not present' do it 'renders sender name even when assignee is not present' do
conversation.update(assignee_id: nil) conversation.update(assignee_id: nil)
mail = described_class.email_reply(message) mail = described_class.email_reply(message)
expect(mail['from'].value).to eq "#{message.sender.available_name} from #{smtp_email_channel.inbox.name} <#{smtp_email_channel.email}>" expect(mail['from'].value).to eq "#{message.sender.available_name} from #{smtp_channel.inbox.sanitized_name} <#{smtp_channel.email}>"
end end
it 'renders assignee name in the from address when sender_name not available' do it 'renders assignee name in the from address when sender_name not available' do
message.update(sender_id: nil) message.update(sender_id: nil)
mail = described_class.email_reply(message) mail = described_class.email_reply(message)
expect(mail['from'].value).to eq "#{conversation.assignee.available_name} from #{smtp_email_channel.inbox.name} <#{smtp_email_channel.email}>" expect(mail['from'].value).to eq "#{conversation.assignee.available_name} from #{smtp_channel.inbox.sanitized_name} <#{smtp_channel.email}>"
end end
it 'renders inbox name as sender and assignee or business_name not present' do it 'renders inbox name as sender and assignee or business_name not present' do
@ -260,7 +260,7 @@ RSpec.describe ConversationReplyMailer do
conversation.update(assignee_id: nil) conversation.update(assignee_id: nil)
mail = described_class.email_reply(message) mail = described_class.email_reply(message)
expect(mail['from'].value).to eq "Notifications from #{smtp_email_channel.inbox.name} <#{smtp_email_channel.email}>" expect(mail['from'].value).to eq "Notifications from #{smtp_channel.inbox.sanitized_name} <#{smtp_channel.email}>"
end end
context 'when friendly name enabled' do context 'when friendly name enabled' do
@ -276,7 +276,7 @@ RSpec.describe ConversationReplyMailer do
mail = described_class.email_reply(message) mail = described_class.email_reply(message)
expect(mail['from'].value).to eq "Notifications from #{conversation.inbox.name} <#{smtp_email_channel.email}>" expect(mail['from'].value).to eq "Notifications from #{conversation.inbox.sanitized_name} <#{smtp_channel.email}>"
end end
it 'renders sender name as sender and assignee nil and business_name present' do it 'renders sender name as sender and assignee nil and business_name present' do
@ -286,7 +286,7 @@ RSpec.describe ConversationReplyMailer do
mail = described_class.email_reply(message) mail = described_class.email_reply(message)
expect(mail['from'].value).to eq( expect(mail['from'].value).to eq(
"Notifications from #{conversation.inbox.business_name} <#{smtp_email_channel.email}>" "Notifications from #{conversation.inbox.business_name} <#{smtp_channel.email}>"
) )
end end
@ -295,7 +295,7 @@ RSpec.describe ConversationReplyMailer do
conversation.update(assignee_id: agent.id) conversation.update(assignee_id: agent.id)
mail = described_class.email_reply(message) mail = described_class.email_reply(message)
expect(mail['from'].value).to eq "#{agent.available_name} from #{conversation.inbox.business_name} <#{smtp_email_channel.email}>" expect(mail['from'].value).to eq "#{agent.available_name} from #{conversation.inbox.business_name} <#{smtp_channel.email}>"
end end
it 'renders sender name as sender and assignee and business_name present' do it 'renders sender name as sender and assignee and business_name present' do
@ -304,7 +304,7 @@ RSpec.describe ConversationReplyMailer do
conversation.update(assignee_id: agent.id) conversation.update(assignee_id: agent.id)
mail = described_class.email_reply(message) mail = described_class.email_reply(message)
expect(mail['from'].value).to eq "#{agent_2.available_name} from #{conversation.inbox.business_name} <#{smtp_email_channel.email}>" expect(mail['from'].value).to eq "#{agent_2.available_name} from #{conversation.inbox.business_name} <#{smtp_channel.email}>"
end end
end end
@ -321,7 +321,7 @@ RSpec.describe ConversationReplyMailer do
mail = described_class.email_reply(message) mail = described_class.email_reply(message)
expect(mail['from'].value).to eq "#{conversation.inbox.name} <#{smtp_email_channel.email}>" expect(mail['from'].value).to eq "#{conversation.inbox.sanitized_name} <#{smtp_channel.email}>"
end end
it 'renders sender name as business_name present' do it 'renders sender name as business_name present' do
@ -330,17 +330,17 @@ RSpec.describe ConversationReplyMailer do
mail = described_class.email_reply(message) mail = described_class.email_reply(message)
expect(mail['from'].value).to eq "#{conversation.inbox.business_name} <#{smtp_email_channel.email}>" expect(mail['from'].value).to eq "#{conversation.inbox.business_name} <#{smtp_channel.email}>"
end end
end end
end end
context 'when smtp enabled for microsoft email channel' do context 'when smtp enabled for microsoft email channel' do
let(:ms_smtp_email_channel) do let(:ms_smtp_channel) do
create(:channel_email, imap_login: 'smtp@outlook.com', create(:channel_email, imap_login: 'smtp@outlook.com',
imap_enabled: true, account: account, provider: 'microsoft', provider_config: { access_token: 'access_token' }) imap_enabled: true, account: account, provider: 'microsoft', provider_config: { access_token: 'access_token' })
end end
let(:conversation) { create(:conversation, assignee: agent, inbox: ms_smtp_email_channel.inbox, account: account).reload } let(:conversation) { create(:conversation, assignee: agent, inbox: ms_smtp_channel.inbox, account: account).reload }
let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') } let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') }
it 'use smtp mail server' do it 'use smtp mail server' do
@ -352,11 +352,11 @@ RSpec.describe ConversationReplyMailer do
end end
context 'when smtp enabled for google email channel' do context 'when smtp enabled for google email channel' do
let(:ms_smtp_email_channel) do let(:ms_smtp_channel) do
create(:channel_email, imap_login: 'smtp@gmail.com', create(:channel_email, imap_login: 'smtp@gmail.com',
imap_enabled: true, account: account, provider: 'google', provider_config: { access_token: 'access_token' }) imap_enabled: true, account: account, provider: 'google', provider_config: { access_token: 'access_token' })
end end
let(:conversation) { create(:conversation, assignee: agent, inbox: ms_smtp_email_channel.inbox, account: account).reload } let(:conversation) { create(:conversation, assignee: agent, inbox: ms_smtp_channel.inbox, account: account).reload }
let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') } let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') }
it 'use smtp mail server' do it 'use smtp mail server' do
@ -430,7 +430,7 @@ RSpec.describe ConversationReplyMailer do
it 'sets reply to email to be based on the domain' do it 'sets reply to email to be based on the domain' do
reply_to_email = "reply+#{message.conversation.uuid}@#{conversation.account.domain}" reply_to_email = "reply+#{message.conversation.uuid}@#{conversation.account.domain}"
reply_to = "#{message.sender.available_name} from #{conversation.inbox.name} <#{reply_to_email}>" reply_to = "#{message.sender.available_name} from #{conversation.inbox.sanitized_name} <#{reply_to_email}>"
expect(mail['REPLY-TO'].value).to eq(reply_to) expect(mail['REPLY-TO'].value).to eq(reply_to)
expect(mail.reply_to).to eq([reply_to_email]) expect(mail.reply_to).to eq([reply_to_email])
end end

View File

@ -0,0 +1,34 @@
require 'rails_helper'
RSpec.describe 'Conversation Audit', type: :model do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
before do
# Enable auditing for conversations
conversation.class.send(:include, Enterprise::Audit::Conversation) if defined?(Enterprise::Audit::Conversation)
end
describe 'audit logging on destroy' do
it 'creates an audit log when conversation is destroyed' do
skip 'Enterprise audit module not available' unless defined?(Enterprise::Audit::Conversation)
expect do
conversation.destroy!
end.to change(Audited::Audit, :count).by(1)
audit = Audited::Audit.last
expect(audit.auditable_type).to eq('Conversation')
expect(audit.action).to eq('destroy')
expect(audit.auditable_id).to eq(conversation.id)
end
it 'does not create audit log for other actions by default' do
skip 'Enterprise audit module not available' unless defined?(Enterprise::Audit::Conversation)
expect do
conversation.update!(priority: 'high')
end.not_to(change(Audited::Audit, :count))
end
end
end

View File

@ -164,31 +164,7 @@ RSpec.describe Inbox do
let(:inbox) { FactoryBot.create(:inbox) } let(:inbox) { FactoryBot.create(:inbox) }
context 'when validating inbox name' do context 'when validating inbox name' do
it 'does not allow any special character at the end' do it 'does not allow empty string' do
inbox.name = 'this is my inbox name-'
expect(inbox).not_to be_valid
expect(inbox.errors.full_messages).to eq(
['Name should not start or end with symbols, and it should not have < > / \\ @ characters.']
)
end
it 'does not allow any special character at the start' do
inbox.name = '-this is my inbox name'
expect(inbox).not_to be_valid
expect(inbox.errors.full_messages).to eq(
['Name should not start or end with symbols, and it should not have < > / \\ @ characters.']
)
end
it 'does not allow chacters like /\@<> in the entire string' do
inbox.name = 'inbox@name'
expect(inbox).not_to be_valid
expect(inbox.errors.full_messages).to eq(
['Name should not start or end with symbols, and it should not have < > / \\ @ characters.']
)
end
it 'does not empty string' do
inbox.name = '' inbox.name = ''
expect(inbox).not_to be_valid expect(inbox).not_to be_valid
expect(inbox.errors.full_messages[0]).to eq( expect(inbox.errors.full_messages[0]).to eq(
@ -282,4 +258,134 @@ RSpec.describe Inbox do
inbox.touch # rubocop:disable Rails/SkipsModelValidations inbox.touch # rubocop:disable Rails/SkipsModelValidations
end end
end end
describe '#sanitized_name' do
context 'when inbox name contains forbidden characters' do
it 'removes forbidden and spam-trigger characters' do
inbox = FactoryBot.build(:inbox, name: 'Test/Name\\With<Bad>@Characters"And\'Quotes!#$%')
expect(inbox.sanitized_name).to eq('Test/NameWithBadCharactersAnd\'Quotes')
end
end
context 'when inbox name has leading/trailing non-word characters' do
it 'removes leading and trailing symbols' do
inbox = FactoryBot.build(:inbox, name: '!!!Test Name***')
expect(inbox.sanitized_name).to eq('Test Name')
end
it 'handles mixed leading/trailing characters' do
inbox = FactoryBot.build(:inbox, name: '###@@@Test Inbox Name$$$%%')
expect(inbox.sanitized_name).to eq('Test Inbox Name')
end
end
context 'when inbox name has multiple spaces' do
it 'normalizes multiple spaces to single space' do
inbox = FactoryBot.build(:inbox, name: 'Test Multiple Spaces')
expect(inbox.sanitized_name).to eq('Test Multiple Spaces')
end
it 'handles tabs and other whitespace' do
inbox = FactoryBot.build(:inbox, name: "Test\t\nMultiple\r\nSpaces")
expect(inbox.sanitized_name).to eq('Test Multiple Spaces')
end
end
context 'when inbox name has leading/trailing whitespace' do
it 'strips whitespace' do
inbox = FactoryBot.build(:inbox, name: ' Test Name ')
expect(inbox.sanitized_name).to eq('Test Name')
end
end
context 'when inbox name becomes empty after sanitization' do
context 'with email channel' do
it 'falls back to email local part' do
email_channel = FactoryBot.build(:channel_email, email: 'support@example.com')
inbox = FactoryBot.build(:inbox, name: '\\<>@"', channel: email_channel)
expect(inbox.sanitized_name).to eq('Support')
end
it 'handles email with complex local part' do
email_channel = FactoryBot.build(:channel_email, email: 'help-desk_team@example.com')
inbox = FactoryBot.build(:inbox, name: '!!!@@@', channel: email_channel)
expect(inbox.sanitized_name).to eq('Help Desk Team')
end
end
context 'with non-email channel' do
it 'returns empty string when name becomes blank' do
web_widget_channel = FactoryBot.build(:channel_widget)
inbox = FactoryBot.build(:inbox, name: '\\<>@"', channel: web_widget_channel)
expect(inbox.sanitized_name).to eq('')
end
end
end
context 'when inbox name is blank initially' do
context 'with email channel' do
it 'uses email local part as fallback' do
email_channel = FactoryBot.build(:channel_email, email: 'customer-care@example.com')
inbox = FactoryBot.build(:inbox, name: '', channel: email_channel)
expect(inbox.sanitized_name).to eq('Customer Care')
end
end
context 'with non-email channel' do
it 'returns empty string' do
api_channel = FactoryBot.build(:channel_api)
inbox = FactoryBot.build(:inbox, name: '', channel: api_channel)
expect(inbox.sanitized_name).to eq('')
end
end
end
context 'when inbox name contains valid characters' do
it 'preserves valid characters like hyphens, underscores, and dots' do
inbox = FactoryBot.build(:inbox, name: 'Test-Name_With.Valid-Characters')
expect(inbox.sanitized_name).to eq('Test-Name_With.Valid-Characters')
end
it 'preserves alphanumeric characters and spaces' do
inbox = FactoryBot.build(:inbox, name: 'Customer Support 123')
expect(inbox.sanitized_name).to eq('Customer Support 123')
end
it 'preserves balanced safe characters but removes spam-trigger symbols' do
inbox = FactoryBot.build(:inbox, name: "Test!#$%&'*+/=?^_`{|}~-Name")
expect(inbox.sanitized_name).to eq("Test'/_-Name")
end
it 'keeps commonly used safe characters' do
inbox = FactoryBot.build(:inbox, name: "Support/Help's Team.Desk_2024-Main")
expect(inbox.sanitized_name).to eq("Support/Help's Team.Desk_2024-Main")
end
end
context 'when inbox name contains problematic characters for email headers' do
it 'preserves Unicode symbols (trademark, etc.)' do
inbox = FactoryBot.build(:inbox, name: 'Test™Name®With©Special™Characters')
expect(inbox.sanitized_name).to eq('Test™Name®With©Special™Characters')
end
end
context 'with edge cases' do
it 'handles nil name gracefully' do
inbox = FactoryBot.build(:inbox)
allow(inbox).to receive(:name).and_return(nil)
expect { inbox.sanitized_name }.not_to raise_error
end
it 'handles very long names' do
long_name = 'A' * 1000
inbox = FactoryBot.build(:inbox, name: long_name)
expect(inbox.sanitized_name).to eq(long_name)
end
it 'handles unicode characters and preserves emojis' do
inbox = FactoryBot.build(:inbox, name: 'Test Name with émojis 🎉')
expect(inbox.sanitized_name).to eq('Test Name with émojis 🎉')
end
end
end
end end

View File

@ -0,0 +1,34 @@
require 'rails_helper'
RSpec.describe ConversationPolicy, type: :policy do
subject { described_class }
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let(:administrator) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:administrator_context) { { user: administrator, account: account, account_user: administrator.account_users.first } }
let(:agent_context) { { user: agent, account: account, account_user: agent.account_users.first } }
permissions :destroy? do
context 'when user is an administrator' do
it 'allows destroy' do
expect(subject).to permit(administrator_context, conversation)
end
end
context 'when user is an agent' do
it 'denies destroy' do
expect(subject).not_to permit(agent_context, conversation)
end
end
end
permissions :index? do
context 'when user is authenticated' do
it 'allows index' do
expect(subject).to permit(agent_context, conversation)
end
end
end
end