Merge branch 'chatwoot:develop' into chatwoot/develop
This commit is contained in:
commit
53310d89c8
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)}`;
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 🌙"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -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:",
|
||||||
|
|||||||
@ -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."
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
{{
|
{{
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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] } });
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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',
|
||||||
|
|
||||||
|
|||||||
@ -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%;
|
||||||
|
|||||||
@ -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)">
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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?
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -2,4 +2,8 @@ class ConversationPolicy < ApplicationPolicy
|
|||||||
def index?
|
def index?
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def destroy?
|
||||||
|
@account_user&.administrator?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -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 || '' %>
|
||||||
|
|||||||
@ -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' %>
|
||||||
|
|||||||
@ -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 } %>
|
||||||
|
|
||||||
|
|||||||
54
app/views/super_admin/shared/_account_user_form.html.erb
Normal file
54
app/views/super_admin/shared/_account_user_form.html.erb
Normal 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>
|
||||||
@ -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} %>
|
||||||
|
|||||||
@ -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 %>,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
12
enterprise/app/jobs/messages/audio_transcription_job.rb
Normal file
12
enterprise/app/jobs/messages/audio_transcription_job.rb
Normal 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
|
||||||
7
enterprise/app/models/enterprise/audit/conversation.rb
Normal file
7
enterprise/app/models/enterprise/audit/conversation.rb
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module Enterprise::Audit::Conversation
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
audited only: [], on: [:destroy]
|
||||||
|
end
|
||||||
|
end
|
||||||
15
enterprise/app/models/enterprise/concerns/attachment.rb
Normal file
15
enterprise/app/models/enterprise/concerns/attachment.rb
Normal 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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
10
pnpm-lock.yaml
generated
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
34
spec/models/enterprise/audit/conversation_spec.rb
Normal file
34
spec/models/enterprise/audit/conversation_spec.rb
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
34
spec/policies/conversation_policy_spec.rb
Normal file
34
spec/policies/conversation_policy_spec.rb
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user