Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fc1f17757 | ||
|
|
a9468409fb | ||
|
|
d58b6a6cc4 | ||
|
|
81cb75b62f | ||
|
|
88ffa329eb | ||
|
|
899fce1c92 | ||
|
|
b6b856260f | ||
|
|
79b18e7009 |
+12
-12
@@ -170,7 +170,7 @@ GEM
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (4.1.2)
|
||||
bigdecimal (3.3.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.16.0)
|
||||
msgpack (~> 1.2)
|
||||
@@ -193,7 +193,7 @@ GEM
|
||||
climate_control (1.2.0)
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.23.10)
|
||||
concurrent-ruby (1.3.7)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.5)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
@@ -274,8 +274,8 @@ GEM
|
||||
dry-logic (~> 1.5)
|
||||
dry-types (~> 1.8)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-types (1.9.1)
|
||||
bigdecimal (>= 3.0)
|
||||
dry-types (1.8.3)
|
||||
bigdecimal (~> 3.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-core (~> 1.0)
|
||||
dry-inflector (~> 1.0)
|
||||
@@ -304,7 +304,7 @@ GEM
|
||||
railties (>= 5.0.0)
|
||||
faker (3.2.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.14.3)
|
||||
faraday (2.14.2)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
@@ -474,7 +474,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.19.9)
|
||||
json (2.19.8)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -598,14 +598,14 @@ GEM
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.4)
|
||||
nokogiri (1.19.3)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.4-arm64-darwin)
|
||||
nokogiri (1.19.3-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.4-x86_64-darwin)
|
||||
nokogiri (1.19.3-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.6)
|
||||
auth-sanitizer (~> 0.2, >= 0.2.1)
|
||||
@@ -627,7 +627,7 @@ GEM
|
||||
rack (>= 1.2, < 4)
|
||||
snaky_hash (~> 2.0, >= 2.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.11)
|
||||
oj (3.17.3)
|
||||
oj (3.16.10)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
omniauth (2.1.4)
|
||||
@@ -674,7 +674,7 @@ GEM
|
||||
opentelemetry-api (~> 1.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
ostruct (0.6.1)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.8.0)
|
||||
ast (~> 2.4.1)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.15.1
|
||||
4.15.0
|
||||
|
||||
@@ -6,11 +6,6 @@ class Messages::Messenger::MessageBuilder
|
||||
return if unsupported_file_type?(attachment['type'])
|
||||
|
||||
params = attachment_params(attachment)
|
||||
# During Meta's sticker webhook transition, a sticker message carries both an `image`
|
||||
# and a `sticker` attachment pointing to the same URL. Skip the redundant sticker so it
|
||||
# isn't attached twice, while still storing legitimate duplicate attachments of other types.
|
||||
return if duplicate_sticker?(attachment, params[:external_url])
|
||||
|
||||
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
|
||||
attachment_obj.save!
|
||||
if facebook_reel?(attachment)
|
||||
@@ -18,14 +13,10 @@ class Messages::Messenger::MessageBuilder
|
||||
elsif params[:remote_file_url]
|
||||
attach_file(attachment_obj, params[:remote_file_url])
|
||||
end
|
||||
fetch_attachment_links(attachment_obj)
|
||||
update_attachment_file_type(attachment_obj)
|
||||
end
|
||||
|
||||
def fetch_attachment_links(attachment_obj)
|
||||
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
||||
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
|
||||
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
||||
update_attachment_file_type(attachment_obj)
|
||||
end
|
||||
|
||||
def attach_file(attachment, file_url)
|
||||
@@ -120,20 +111,13 @@ class Messages::Messenger::MessageBuilder
|
||||
|
||||
# Facebook may send attachment types that don't directly match our file_type enum.
|
||||
# Map known aliases to their canonical enum values.
|
||||
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel, sticker: :image }.freeze
|
||||
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
|
||||
|
||||
def normalize_file_type(type)
|
||||
sym = type.to_sym
|
||||
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
|
||||
end
|
||||
|
||||
def duplicate_sticker?(attachment, url)
|
||||
return false unless attachment['type'].to_sym == :sticker
|
||||
return false if url.blank?
|
||||
|
||||
@message.attachments.any? { |existing| existing.external_url == url }
|
||||
end
|
||||
|
||||
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
|
||||
# direct video URLs. Downloading these yields HTML, not video content.
|
||||
def facebook_reel?(attachment)
|
||||
|
||||
@@ -21,11 +21,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
@assignable_agents = @inbox.assignable_agents
|
||||
end
|
||||
|
||||
def assignable_owners
|
||||
@assignable_agents = @inbox.assignable_agents.select(&:confirmed?)
|
||||
@agent_bots = AgentBot.accessible_to(Current.account)
|
||||
end
|
||||
|
||||
def campaigns
|
||||
@campaigns = @inbox.campaigns
|
||||
end
|
||||
|
||||
@@ -142,7 +142,7 @@ class ConversationFinder
|
||||
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
|
||||
@conversations = @conversations.where(id: conversation_ids)
|
||||
when 'participating'
|
||||
@conversations = current_user.participating_conversations.where(account_id: current_account.id)
|
||||
@conversations = @conversations.where(id: current_user.participating_conversations.where(account_id: current_account.id).select(:id))
|
||||
when 'unattended'
|
||||
@conversations = @conversations.unattended
|
||||
end
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class MessageReports extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/message_reports', { accountScoped: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default new MessageReports();
|
||||
@@ -16,7 +16,6 @@ class ArticlesAPI extends PortalsAPI {
|
||||
authorId,
|
||||
categorySlug,
|
||||
sort,
|
||||
query,
|
||||
}) {
|
||||
const url = getArticleSearchURL({
|
||||
pageNumber,
|
||||
@@ -26,7 +25,6 @@ class ArticlesAPI extends PortalsAPI {
|
||||
authorId,
|
||||
categorySlug,
|
||||
sort,
|
||||
query,
|
||||
host: this.url,
|
||||
});
|
||||
|
||||
|
||||
@@ -62,10 +62,9 @@ class ConversationApi extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
assignAgent({ conversationId, agentId, assigneeType }) {
|
||||
assignAgent({ conversationId, agentId }) {
|
||||
return axios.post(`${this.url}/${conversationId}/assignments`, {
|
||||
assignee_id: agentId,
|
||||
assignee_type: assigneeType,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,6 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
return axios.get(`${this.url}/${inboxId}/campaigns`);
|
||||
}
|
||||
|
||||
getAssignableOwners(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/assignable_owners`);
|
||||
}
|
||||
|
||||
deleteInboxAvatar(inboxId) {
|
||||
return axios.delete(`${this.url}/${inboxId}/avatar`);
|
||||
}
|
||||
|
||||
@@ -90,16 +90,11 @@ describe('#ConversationAPI', () => {
|
||||
});
|
||||
|
||||
it('#assignAgent', () => {
|
||||
conversationAPI.assignAgent({
|
||||
conversationId: 12,
|
||||
agentId: 34,
|
||||
assigneeType: 'AgentBot',
|
||||
});
|
||||
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
`/api/v1/conversations/12/assignments`,
|
||||
{
|
||||
assignee_id: 34,
|
||||
assignee_type: 'AgentBot',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ describe('#InboxesAPI', () => {
|
||||
expect(inboxesAPI).toHaveProperty('create');
|
||||
expect(inboxesAPI).toHaveProperty('update');
|
||||
expect(inboxesAPI).toHaveProperty('delete');
|
||||
expect(inboxesAPI).toHaveProperty('getAssignableOwners');
|
||||
expect(inboxesAPI).toHaveProperty('getCampaigns');
|
||||
expect(inboxesAPI).toHaveProperty('getAgentBot');
|
||||
expect(inboxesAPI).toHaveProperty('setAgentBot');
|
||||
@@ -38,13 +37,6 @@ describe('#InboxesAPI', () => {
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/inboxes/2/campaigns');
|
||||
});
|
||||
|
||||
it('#getAssignableOwners', () => {
|
||||
inboxesAPI.getAssignableOwners(2);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/inboxes/2/assignable_owners'
|
||||
);
|
||||
});
|
||||
|
||||
it('#deleteInboxAvatar', () => {
|
||||
inboxesAPI.deleteInboxAvatar(2);
|
||||
expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar');
|
||||
|
||||
@@ -210,9 +210,9 @@ const handleClick = id => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-full gap-2 sm:gap-4">
|
||||
<div class="flex items-center min-w-0 gap-2 sm:gap-4">
|
||||
<div class="flex items-center min-w-0 gap-1">
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<Avatar
|
||||
:name="authorName"
|
||||
:src="authorThumbnailSrc"
|
||||
@@ -223,17 +223,19 @@ const handleClick = id => {
|
||||
{{ authorName || '-' }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="flex items-center min-w-0 gap-1 text-sm text-n-slate-11">
|
||||
<span
|
||||
class="flex items-center gap-1 text-sm whitespace-nowrap text-n-slate-11"
|
||||
>
|
||||
<EmojiIcon
|
||||
v-if="category?.icon"
|
||||
:value="category.icon"
|
||||
:color="category.icon_color"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
<span class="truncate">{{ categoryName }}</span>
|
||||
{{ categoryName }}
|
||||
</span>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap shrink-0"
|
||||
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
|
||||
>
|
||||
<Icon icon="i-lucide-eye" class="size-4" />
|
||||
<span class="text-sm">
|
||||
@@ -245,7 +247,7 @@ const handleClick = id => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-11 line-clamp-1 shrink-0">
|
||||
<span class="text-sm text-n-slate-11 line-clamp-1">
|
||||
{{ lastUpdatedAt }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -67,11 +67,11 @@ const togglePortalSwitcher = () => {
|
||||
>
|
||||
<span
|
||||
v-if="activePortalName"
|
||||
class="min-w-0 text-xl font-medium truncate text-n-slate-12"
|
||||
class="text-xl font-medium text-n-slate-12"
|
||||
>
|
||||
{{ activePortalName }}
|
||||
</span>
|
||||
<div v-if="activePortalName" class="relative shrink-0 group">
|
||||
<div v-if="activePortalName" class="relative group">
|
||||
<OnClickOutside @trigger="showPortalSwitcher = false">
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
@@ -91,9 +91,6 @@ const togglePortalSwitcher = () => {
|
||||
</OnClickOutside>
|
||||
<CreatePortalDialog ref="createPortalDialogRef" />
|
||||
</div>
|
||||
<div class="flex justify-end min-w-0 grow">
|
||||
<slot name="title-actions" />
|
||||
</div>
|
||||
</div>
|
||||
<slot name="header-actions" />
|
||||
</div>
|
||||
|
||||
@@ -24,10 +24,6 @@ const props = defineProps({
|
||||
type: Set,
|
||||
default: () => new Set(),
|
||||
},
|
||||
isSearching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['translateArticle', 'toggleSelect']);
|
||||
@@ -45,7 +41,6 @@ const hoveredArticleId = ref(null);
|
||||
const dragEnabled = computed(() => {
|
||||
return (
|
||||
props.isCategoryArticles &&
|
||||
!props.isSearching &&
|
||||
localArticles.value?.length > 1 &&
|
||||
props.selectedArticleIds.size === 0
|
||||
);
|
||||
|
||||
+2
-32
@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
@@ -19,7 +18,6 @@ import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import BulkTranslateDialog from './BulkTranslateDialog.vue';
|
||||
@@ -51,12 +49,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'pageChange',
|
||||
'fetchPortal',
|
||||
'refreshArticles',
|
||||
'search',
|
||||
]);
|
||||
const emit = defineEmits(['pageChange', 'fetchPortal', 'refreshArticles']);
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -72,9 +65,6 @@ const isFeatureEnabledonAccount = useMapGetter(
|
||||
const selectedArticleIds = ref(new Set());
|
||||
const deleteConfirmDialogRef = ref(null);
|
||||
const isCategoryMenuOpen = ref(false);
|
||||
const searchQuery = ref(route.query.search || '');
|
||||
|
||||
const debouncedSearch = debounce(() => emit('search', searchQuery.value), 500);
|
||||
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
@@ -132,7 +122,6 @@ const updateRoute = newParams => {
|
||||
categorySlug: newParams.categorySlug ?? categorySlug,
|
||||
...newParams,
|
||||
},
|
||||
query: route.query,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -156,12 +145,7 @@ const showCategoryHeaderControls = computed(
|
||||
() => props.isCategoryArticles && !isSwitchingPortal.value
|
||||
);
|
||||
|
||||
const isSearching = computed(() => Boolean(searchQuery.value?.trim()));
|
||||
|
||||
const getEmptyStateText = type => {
|
||||
if (isSearching.value) {
|
||||
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.SEARCH.${type}`);
|
||||
}
|
||||
if (props.isCategoryArticles) {
|
||||
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.CATEGORY.${type}`);
|
||||
}
|
||||
@@ -307,19 +291,6 @@ watch(
|
||||
:show-pagination-footer="shouldShowPaginationFooter"
|
||||
@update:current-page="handlePageChange"
|
||||
>
|
||||
<template #title-actions>
|
||||
<Input
|
||||
v-if="!isSwitchingPortal"
|
||||
v-model="searchQuery"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
type="search"
|
||||
size="sm"
|
||||
class="w-full max-w-[16rem] min-w-0"
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<div class="flex items-end justify-between">
|
||||
<ArticleHeaderControls
|
||||
@@ -451,7 +422,6 @@ watch(
|
||||
<ArticleList
|
||||
:articles="articles"
|
||||
:is-category-articles="isCategoryArticles"
|
||||
:is-searching="isSearching"
|
||||
:selected-article-ids="selectedArticleIds"
|
||||
class="relative z-0"
|
||||
@translate-article="handleTranslateArticle"
|
||||
@@ -463,7 +433,7 @@ watch(
|
||||
class="pt-14"
|
||||
:title="getEmptyStateTitle"
|
||||
:subtitle="getEmptyStateSubtitle"
|
||||
:show-button="hasNoArticlesInPortal && !isSearching"
|
||||
:show-button="hasNoArticlesInPortal"
|
||||
:button-label="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.ALL.BUTTON_LABEL')
|
||||
"
|
||||
|
||||
+2
-21
@@ -37,21 +37,10 @@ const { t } = useI18n();
|
||||
|
||||
const editCategoryDialog = ref(null);
|
||||
const selectedCategory = ref(null);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
|
||||
|
||||
const filteredCategories = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase();
|
||||
if (!query) return props.categories;
|
||||
return props.categories.filter(category =>
|
||||
category.name?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const hasCategories = computed(() => filteredCategories.value?.length > 0);
|
||||
const isSearching = computed(() => searchQuery.value.trim().length > 0);
|
||||
const hasCategories = computed(() => props.categories?.length > 0);
|
||||
|
||||
const updateRoute = (newParams, routeName) => {
|
||||
const { accountId, portalSlug, locale } = route.params;
|
||||
@@ -126,7 +115,6 @@ const reorderCategories = async reorderedGroup => {
|
||||
<HelpCenterLayout :show-pagination-footer="false">
|
||||
<template #header-actions>
|
||||
<CategoryHeaderControls
|
||||
v-model:search-query="searchQuery"
|
||||
:categories="categories"
|
||||
:is-category-articles="false"
|
||||
:allowed-locales="allowedLocales"
|
||||
@@ -142,18 +130,11 @@ const reorderCategories = async reorderedGroup => {
|
||||
</div>
|
||||
<CategoryList
|
||||
v-else-if="hasCategories"
|
||||
:categories="filteredCategories"
|
||||
:disable-drag="isSearching"
|
||||
:categories="categories"
|
||||
@click="openCategoryArticles"
|
||||
@action="handleAction"
|
||||
@reorder="reorderCategories"
|
||||
/>
|
||||
<CategoryEmptyState
|
||||
v-else-if="isSearching"
|
||||
class="pt-14"
|
||||
:title="t('HELP_CENTER.CATEGORY_PAGE.SEARCH_EMPTY_STATE.TITLE')"
|
||||
:subtitle="t('HELP_CENTER.CATEGORY_PAGE.SEARCH_EMPTY_STATE.SUBTITLE')"
|
||||
/>
|
||||
<CategoryEmptyState
|
||||
v-else
|
||||
class="pt-14"
|
||||
|
||||
+17
-34
@@ -6,7 +6,6 @@ import { OnClickOutside } from '@vueuse/components';
|
||||
import { useStoreGetters } from 'dashboard/composables/store.js';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import CategoryDialog from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryDialog.vue';
|
||||
@@ -28,11 +27,6 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['localeChange', 'newArticle']);
|
||||
|
||||
const searchQuery = defineModel('searchQuery', {
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const getters = useStoreGetters();
|
||||
@@ -168,34 +162,23 @@ const handleBreadcrumbClick = () => {
|
||||
:items="breadcrumbItems"
|
||||
@click="handleBreadcrumbClick"
|
||||
/>
|
||||
<div v-if="!hasSelectedCategory" class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
type="search"
|
||||
size="sm"
|
||||
class="w-48"
|
||||
/>
|
||||
<div class="relative">
|
||||
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
|
||||
/>
|
||||
<CategoryDialog
|
||||
v-if="isCreateCategoryDialogOpen"
|
||||
mode="create"
|
||||
:portal-name="currentPortalName"
|
||||
:active-locale-name="activeLocaleName"
|
||||
:active-locale-code="activeLocaleCode"
|
||||
@close="isCreateCategoryDialogOpen = false"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
<div v-if="!hasSelectedCategory" class="relative">
|
||||
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
|
||||
/>
|
||||
<CategoryDialog
|
||||
v-if="isCreateCategoryDialogOpen"
|
||||
mode="create"
|
||||
:portal-name="currentPortalName"
|
||||
:active-locale-name="activeLocaleName"
|
||||
:active-locale-code="activeLocaleCode"
|
||||
@close="isCreateCategoryDialogOpen = false"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
<div v-else class="relative flex items-center gap-2">
|
||||
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
|
||||
|
||||
+1
-5
@@ -8,10 +8,6 @@ const props = defineProps({
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
disableDrag: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'action', 'reorder']);
|
||||
@@ -19,7 +15,7 @@ const emit = defineEmits(['click', 'action', 'reorder']);
|
||||
const localCategories = ref(props.categories);
|
||||
|
||||
const dragEnabled = computed(() => {
|
||||
return !props.disableDrag && localCategories.value?.length > 1;
|
||||
return localCategories.value?.length > 1;
|
||||
});
|
||||
|
||||
const handleClick = slug => {
|
||||
|
||||
+7
-42
@@ -2,12 +2,8 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue';
|
||||
import AddLocaleDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/AddLocaleDialog.vue';
|
||||
@@ -23,10 +19,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const addLocaleDialogRef = ref(null);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
|
||||
@@ -35,19 +28,6 @@ const openAddLocaleDialog = () => {
|
||||
};
|
||||
|
||||
const localeCount = computed(() => props.locales?.length);
|
||||
|
||||
const filteredLocales = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase();
|
||||
if (!query) return props.locales;
|
||||
return props.locales.filter(
|
||||
locale =>
|
||||
locale.name?.toLowerCase().includes(query) ||
|
||||
locale.code?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const isSearching = computed(() => searchQuery.value.trim().length > 0);
|
||||
const hasResults = computed(() => filteredLocales.value?.length > 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -59,21 +39,12 @@ const hasResults = computed(() => filteredLocales.value?.length > 0);
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('HELP_CENTER.LOCALES_PAGE.SEARCH_PLACEHOLDER')"
|
||||
type="search"
|
||||
size="sm"
|
||||
class="w-48"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="openAddLocaleDialog"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="openAddLocaleDialog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
@@ -83,13 +54,7 @@ const hasResults = computed(() => filteredLocales.value?.length > 0);
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<EmptyStateLayout
|
||||
v-else-if="isSearching && !hasResults"
|
||||
:title="t('HELP_CENTER.LOCALES_PAGE.SEARCH_EMPTY_STATE.TITLE')"
|
||||
:subtitle="t('HELP_CENTER.LOCALES_PAGE.SEARCH_EMPTY_STATE.SUBTITLE')"
|
||||
:show-backdrop="false"
|
||||
/>
|
||||
<LocaleList v-else :locales="filteredLocales" :portal="portal" />
|
||||
<LocaleList v-else :locales="locales" :portal="portal" />
|
||||
</template>
|
||||
<AddLocaleDialog ref="addLocaleDialogRef" :portal="portal" />
|
||||
</HelpCenterLayout>
|
||||
|
||||
@@ -147,14 +147,8 @@ const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
|
||||
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const isCaptainMessage = computed(() => {
|
||||
const senderType = props.sender?.type ?? props.senderType;
|
||||
return senderType === SENDER_TYPES.CAPTAIN_ASSISTANT;
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes the message variant based on props
|
||||
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
|
||||
@@ -396,10 +390,6 @@ const contextMenuEnabledOptions = computed(() => {
|
||||
!props.private &&
|
||||
props.inboxSupportsReplyTo.outgoing &&
|
||||
!isFailedOrProcessing,
|
||||
report:
|
||||
isOnChatwootCloud.value &&
|
||||
isCaptainMessage.value &&
|
||||
!isMessageDeleted.value,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ const props = defineProps({
|
||||
type: [Number, String],
|
||||
default: 0,
|
||||
},
|
||||
badgeTooltip: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const reauthorizationRequired = computed(() => {
|
||||
@@ -34,7 +38,7 @@ const reauthorizationRequired = computed(() => {
|
||||
<ChannelIcon :inbox="inbox" class="size-4" />
|
||||
</span>
|
||||
<div class="flex-1 truncate min-w-0">{{ label }}</div>
|
||||
<SidebarUnreadBadge :count="badgeCount" />
|
||||
<SidebarUnreadBadge :count="badgeCount" :tooltip="badgeTooltip" />
|
||||
<div
|
||||
v-if="reauthorizationRequired"
|
||||
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
|
||||
|
||||
@@ -199,6 +199,18 @@ const getLabelUnreadCount = useMapGetter(
|
||||
const getTeamUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getTeamUnreadCount'
|
||||
);
|
||||
const mentionsUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getMentionsUnreadCount'
|
||||
);
|
||||
const participatingUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getParticipatingUnreadCount'
|
||||
);
|
||||
const unattendedUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getUnattendedUnreadCount'
|
||||
);
|
||||
const getFolderUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getFolderUnreadCount'
|
||||
);
|
||||
const teams = useMapGetter('teams/getMyTeams');
|
||||
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
|
||||
const conversationCustomViews = useMapGetter(
|
||||
@@ -280,6 +292,19 @@ const sortedLabels = computed(() =>
|
||||
})
|
||||
);
|
||||
|
||||
const unreadBadgeTooltips = computed(() => ({
|
||||
ALL: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.ALL'),
|
||||
INBOX: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.INBOX'),
|
||||
LABEL: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.LABEL'),
|
||||
TEAM: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.TEAM'),
|
||||
FOLDER: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.FOLDER'),
|
||||
MENTIONS: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.MENTIONS'),
|
||||
PARTICIPATING: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.PARTICIPATING'),
|
||||
UNATTENDED: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.UNATTENDED'),
|
||||
}));
|
||||
|
||||
const unreadBadgeTooltip = type => unreadBadgeTooltips.value[type];
|
||||
|
||||
const closeMobileSidebar = () => {
|
||||
if (!props.isMobileSidebarOpen) return;
|
||||
emit('closeMobileSidebar');
|
||||
@@ -335,12 +360,15 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.ALL_CONVERSATIONS'),
|
||||
icon: 'i-lucide-inbox',
|
||||
badgeCount: allUnreadCount.value,
|
||||
badgeTooltip: unreadBadgeTooltip('ALL'),
|
||||
activeOn: ['inbox_conversation'],
|
||||
to: accountScopedRoute('home'),
|
||||
},
|
||||
{
|
||||
name: 'Mentions',
|
||||
label: t('SIDEBAR.MENTIONED_CONVERSATIONS'),
|
||||
badgeCount: mentionsUnreadCount.value,
|
||||
badgeTooltip: unreadBadgeTooltip('MENTIONS'),
|
||||
icon: 'i-lucide-at-sign',
|
||||
activeOn: ['conversation_through_mentions'],
|
||||
to: accountScopedRoute('conversation_mentions'),
|
||||
@@ -348,6 +376,8 @@ const menuItems = computed(() => {
|
||||
{
|
||||
name: 'Participating',
|
||||
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
|
||||
badgeCount: participatingUnreadCount.value,
|
||||
badgeTooltip: unreadBadgeTooltip('PARTICIPATING'),
|
||||
icon: 'i-lucide-user-round-check',
|
||||
activeOn: ['conversation_through_participating'],
|
||||
to: accountScopedRoute('conversation_participating'),
|
||||
@@ -355,6 +385,8 @@ const menuItems = computed(() => {
|
||||
{
|
||||
name: 'Unattended',
|
||||
activeOn: ['conversation_through_unattended'],
|
||||
badgeCount: unattendedUnreadCount.value,
|
||||
badgeTooltip: unreadBadgeTooltip('UNATTENDED'),
|
||||
label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'),
|
||||
icon: 'i-lucide-clock-alert',
|
||||
to: accountScopedRoute('conversation_unattended'),
|
||||
@@ -370,6 +402,8 @@ const menuItems = computed(() => {
|
||||
children: sortedFolders.value.map(view => ({
|
||||
name: `${view.name}-${view.id}`,
|
||||
label: view.name,
|
||||
badgeCount: getFolderUnreadCount.value(view.id),
|
||||
badgeTooltip: unreadBadgeTooltip('FOLDER'),
|
||||
to: accountScopedRoute('folder_conversations', { id: view.id }),
|
||||
})),
|
||||
},
|
||||
@@ -385,6 +419,7 @@ const menuItems = computed(() => {
|
||||
name: `${team.name}-${team.id}`,
|
||||
label: team.name,
|
||||
badgeCount: getTeamUnreadCount.value(team.id),
|
||||
badgeTooltip: unreadBadgeTooltip('TEAM'),
|
||||
to: accountScopedRoute('team_conversations', { teamId: team.id }),
|
||||
})),
|
||||
},
|
||||
@@ -400,6 +435,7 @@ const menuItems = computed(() => {
|
||||
name: `${inbox.name}-${inbox.id}`,
|
||||
label: inbox.name,
|
||||
badgeCount: getInboxUnreadCount.value(inbox.id),
|
||||
badgeTooltip: unreadBadgeTooltip('INBOX'),
|
||||
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
|
||||
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
|
||||
component: leafProps =>
|
||||
@@ -408,6 +444,7 @@ const menuItems = computed(() => {
|
||||
active: leafProps.active,
|
||||
inbox,
|
||||
badgeCount: leafProps.badgeCount,
|
||||
badgeTooltip: leafProps.badgeTooltip,
|
||||
}),
|
||||
})),
|
||||
},
|
||||
@@ -423,6 +460,7 @@ const menuItems = computed(() => {
|
||||
name: `${label.title}-${label.id}`,
|
||||
label: label.title,
|
||||
badgeCount: getLabelUnreadCount.value(label.id),
|
||||
badgeTooltip: unreadBadgeTooltip('LABEL'),
|
||||
icon: h('span', {
|
||||
class: `size-[8px] rounded-sm`,
|
||||
style: { backgroundColor: label.color },
|
||||
|
||||
@@ -197,7 +197,10 @@ onMounted(async () => {
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ subChild.label }}</span>
|
||||
<SidebarUnreadBadge :count="subChild.badgeCount" />
|
||||
<SidebarUnreadBadge
|
||||
:count="subChild.badgeCount"
|
||||
:tooltip="subChild.badgeTooltip"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -220,7 +223,10 @@ onMounted(async () => {
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ child.label }}</span>
|
||||
<SidebarUnreadBadge :count="child.badgeCount" />
|
||||
<SidebarUnreadBadge
|
||||
:count="child.badgeCount"
|
||||
:tooltip="child.badgeTooltip"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -12,6 +12,7 @@ const props = defineProps({
|
||||
active: { type: Boolean, default: false },
|
||||
component: { type: Function, default: null },
|
||||
badgeCount: { type: [Number, String], default: 0 },
|
||||
badgeTooltip: { type: String, default: '' },
|
||||
hideTreeLine: { type: Boolean, default: false },
|
||||
thinTreeLine: { type: Boolean, default: false },
|
||||
});
|
||||
@@ -52,14 +53,14 @@ const TREE_CONNECTOR =
|
||||
<component
|
||||
:is="component"
|
||||
v-if="shouldRenderComponent"
|
||||
v-bind="{ label, icon, active, badgeCount }"
|
||||
v-bind="{ label, icon, active, badgeCount, badgeTooltip }"
|
||||
/>
|
||||
<template v-else>
|
||||
<span v-if="icon" class="size-4 grid place-content-center rounded-full">
|
||||
<Icon :icon="icon" class="size-4 inline-block" />
|
||||
</span>
|
||||
<div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div>
|
||||
<SidebarUnreadBadge :count="badgeCount" />
|
||||
<SidebarUnreadBadge :count="badgeCount" :tooltip="badgeTooltip" />
|
||||
</template>
|
||||
</component>
|
||||
</Policy>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
count: { type: [Number, String], default: 0 },
|
||||
tooltip: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const normalizedCount = computed(() => {
|
||||
@@ -18,6 +19,7 @@ const displayCount = computed(() =>
|
||||
<template>
|
||||
<span
|
||||
v-if="normalizedCount > 0"
|
||||
v-tooltip.top="tooltip || null"
|
||||
data-test-id="sidebar-unread-badge"
|
||||
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-slate-4 px-1 text-xxs font-medium leading-3 text-n-slate-12 dark:bg-n-slate-5 flex-shrink-0"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ChannelLeaf from '../ChannelLeaf.vue';
|
||||
import SidebarUnreadBadge from '../SidebarUnreadBadge.vue';
|
||||
|
||||
const mountChannelLeaf = props =>
|
||||
mount(ChannelLeaf, {
|
||||
@@ -28,6 +29,17 @@ describe('ChannelLeaf', () => {
|
||||
expect(badge.text()).toBe('3');
|
||||
});
|
||||
|
||||
it('passes tooltip copy to unread badge', () => {
|
||||
const wrapper = mountChannelLeaf({
|
||||
badgeCount: 3,
|
||||
badgeTooltip: 'Total unread conversations in this inbox',
|
||||
});
|
||||
|
||||
expect(wrapper.findComponent(SidebarUnreadBadge).props('tooltip')).toBe(
|
||||
'Total unread conversations in this inbox'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not render unread badge when count is zero', () => {
|
||||
const wrapper = mountChannelLeaf({ badgeCount: 0 });
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { h } from 'vue';
|
||||
import SidebarGroupLeaf from '../SidebarGroupLeaf.vue';
|
||||
import SidebarUnreadBadge from '../SidebarUnreadBadge.vue';
|
||||
|
||||
vi.mock('../provider', () => ({
|
||||
useSidebarContext: () => ({
|
||||
@@ -44,6 +45,17 @@ describe('SidebarGroupLeaf', () => {
|
||||
expect(badge.text()).toBe('7');
|
||||
});
|
||||
|
||||
it('passes tooltip copy to unread badge', () => {
|
||||
const wrapper = mountLeaf({
|
||||
badgeCount: 7,
|
||||
badgeTooltip: 'Total unread conversations in this label',
|
||||
});
|
||||
|
||||
expect(wrapper.findComponent(SidebarUnreadBadge).props('tooltip')).toBe(
|
||||
'Total unread conversations in this label'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not render unread badge when count is zero', () => {
|
||||
const wrapper = mountLeaf({ badgeCount: 0 });
|
||||
|
||||
@@ -63,14 +75,17 @@ describe('SidebarGroupLeaf', () => {
|
||||
it('passes unread count to custom leaf components', () => {
|
||||
const wrapper = mountLeaf({
|
||||
badgeCount: 4,
|
||||
badgeTooltip: 'Total unread conversations in this inbox',
|
||||
component: leafProps =>
|
||||
h(
|
||||
'span',
|
||||
{ 'data-test-id': 'custom-leaf-count' },
|
||||
leafProps.badgeCount
|
||||
`${leafProps.badgeCount}:${leafProps.badgeTooltip}`
|
||||
),
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-test-id="custom-leaf-count"]').text()).toBe('4');
|
||||
expect(wrapper.find('[data-test-id="custom-leaf-count"]').text()).toBe(
|
||||
'4:Total unread conversations in this inbox'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,7 +31,6 @@ const mockUseMapGetter = (overrides = {}) => {
|
||||
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
|
||||
getCurrentAccountId: ref(1),
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
|
||||
'inboxAssignableAgents/getAssignableOwners': ref(() => []),
|
||||
};
|
||||
|
||||
const mergedGetters = { ...defaultGetters, ...overrides };
|
||||
@@ -76,32 +75,6 @@ describe('useAgentsList', () => {
|
||||
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
|
||||
});
|
||||
|
||||
it('includes agent bots when includeAgentBots is true', () => {
|
||||
mockUseMapGetter({
|
||||
'inboxAssignableAgents/getAssignableOwners': ref(() => [
|
||||
{ id: 2, name: 'Agent', assignee_type: 'User' },
|
||||
{
|
||||
id: 1,
|
||||
name: 'Bot',
|
||||
thumbnail: '',
|
||||
assignee_type: 'AgentBot',
|
||||
icon: 'i-lucide-bot',
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
const { agentsList } = useAgentsList(true, true);
|
||||
|
||||
expect(agentsList.value).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 1,
|
||||
name: 'Bot',
|
||||
assignee_type: 'AgentBot',
|
||||
icon: 'i-lucide-bot',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty assignable agents', () => {
|
||||
mockUseMapGetter({
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
|
||||
|
||||
@@ -10,21 +10,14 @@ import {
|
||||
* A composable function that provides a list of agents for assignment.
|
||||
*
|
||||
* @param {boolean} [includeNoneAgent=true] - Whether to include a 'None' agent option.
|
||||
* @param {boolean} [includeAgentBots=false] - Whether to include agent bots as assignment options.
|
||||
* @returns {Object} An object containing the agents list and assignable agents.
|
||||
*/
|
||||
export function useAgentsList(
|
||||
includeNoneAgent = true,
|
||||
includeAgentBots = false
|
||||
) {
|
||||
export function useAgentsList(includeNoneAgent = true) {
|
||||
const { t } = useI18n();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const assignable = useMapGetter('inboxAssignableAgents/getAssignableAgents');
|
||||
const assignableOwners = useMapGetter(
|
||||
'inboxAssignableAgents/getAssignableOwners'
|
||||
);
|
||||
|
||||
const inboxId = computed(() => currentChat.value?.inbox_id);
|
||||
const isAgentSelected = computed(() => currentChat.value?.meta?.assignee);
|
||||
@@ -49,22 +42,11 @@ export function useAgentsList(
|
||||
return inboxId.value ? assignable.value(inboxId.value) : [];
|
||||
});
|
||||
|
||||
const owners = computed(() => {
|
||||
return includeAgentBots && inboxId.value
|
||||
? assignableOwners.value(inboxId.value)
|
||||
: [];
|
||||
});
|
||||
|
||||
/**
|
||||
* @type {import('vue').ComputedRef<Array>}
|
||||
*/
|
||||
const agentsList = computed(() => {
|
||||
const agents = includeAgentBots
|
||||
? owners.value.filter(owner => owner.assignee_type === 'User')
|
||||
: assignableAgents.value || [];
|
||||
const bots = owners.value.filter(
|
||||
owner => owner.assignee_type === 'AgentBot'
|
||||
);
|
||||
const agents = assignableAgents.value || [];
|
||||
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
|
||||
agents,
|
||||
currentUser.value,
|
||||
@@ -78,7 +60,6 @@ export function useAgentsList(
|
||||
return [
|
||||
...(includeNoneAgent && isAgentSelected.value ? [createNoneAgent()] : []),
|
||||
...filteredAgentsByAvailability,
|
||||
...bots,
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -304,25 +304,6 @@
|
||||
"MESSAGE": "You cannot undo this action",
|
||||
"DELETE": "Delete",
|
||||
"CANCEL": "Cancel"
|
||||
},
|
||||
"REPORT_MESSAGE": {
|
||||
"LABEL": "Report message",
|
||||
"TITLE": "Report Captain message",
|
||||
"DESCRIPTION": "Found an issue with this AI response? Let us know what went wrong and our team will review it to help improve Captain's accuracy.",
|
||||
"PROBLEM_TYPE": "Problem type",
|
||||
"PROBLEM_TYPE_PLACEHOLDER": "Select a problem type",
|
||||
"DESCRIPTION_LABEL": "Description",
|
||||
"DESCRIPTION_PLACEHOLDER": "Describe the problem in detail",
|
||||
"SUBMIT": "Report",
|
||||
"SUCCESS": "Thanks for reporting. Our team will take a look.",
|
||||
"ERROR": "Could not report this message. Please try again.",
|
||||
"REASONS": {
|
||||
"incorrect_information": "Incorrect information",
|
||||
"inappropriate_response": "Inappropriate response",
|
||||
"incomplete_response": "Incomplete response",
|
||||
"outdated_information": "Outdated information",
|
||||
"other": "Other"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SIDEBAR": {
|
||||
|
||||
@@ -552,7 +552,6 @@
|
||||
"LOCALE": {
|
||||
"ALL": "All locales"
|
||||
},
|
||||
"SEARCH_PLACEHOLDER": "Search articles...",
|
||||
"NEW_ARTICLE": "New article"
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
@@ -580,10 +579,6 @@
|
||||
"CATEGORY": {
|
||||
"TITLE": "There are no articles in this category",
|
||||
"SUBTITLE": "Articles in this category will appear here"
|
||||
},
|
||||
"SEARCH": {
|
||||
"TITLE": "No matching articles",
|
||||
"SUBTITLE": "We couldn't find any articles matching your search. Try a different term."
|
||||
}
|
||||
},
|
||||
"BULK_TRANSLATE": {
|
||||
@@ -629,7 +624,6 @@
|
||||
"CATEGORY_HEADER": {
|
||||
"NEW_CATEGORY": "New category",
|
||||
"EDIT_CATEGORY": "Edit category",
|
||||
"SEARCH_PLACEHOLDER": "Search categories...",
|
||||
"CATEGORIES_COUNT": "{n} category | {n} categories",
|
||||
"BREADCRUMB": {
|
||||
"CATEGORY_LOCALE": "Categories ({localeCode})",
|
||||
@@ -640,10 +634,6 @@
|
||||
"TITLE": "No categories found",
|
||||
"SUBTITLE": "Categories will appear here. You can add a category by clicking the 'New Category' button."
|
||||
},
|
||||
"SEARCH_EMPTY_STATE": {
|
||||
"TITLE": "No matching categories",
|
||||
"SUBTITLE": "We couldn't find any categories matching your search. Try a different term."
|
||||
},
|
||||
"CATEGORY_CARD": {
|
||||
"ARTICLES_COUNT": "{count} article | {count} articles"
|
||||
},
|
||||
@@ -701,11 +691,6 @@
|
||||
"LOCALES_PAGE": {
|
||||
"LOCALES_COUNT": "No locales available | {n} locale | {n} locales",
|
||||
"NEW_LOCALE_BUTTON_TEXT": "New locale",
|
||||
"SEARCH_PLACEHOLDER": "Search locales...",
|
||||
"SEARCH_EMPTY_STATE": {
|
||||
"TITLE": "No matching locales",
|
||||
"SUBTITLE": "We couldn't find any locales matching your search. Try a different term."
|
||||
},
|
||||
"LOCALE_CARD": {
|
||||
"ARTICLES_COUNT": "{count} article | {count} articles",
|
||||
"CATEGORIES_COUNT": "{count} category | {count} categories",
|
||||
|
||||
@@ -65,8 +65,7 @@
|
||||
"CONNECT_YOUR_TIKTOK_PROFILE": "Connect your TikTok Profile",
|
||||
"HELP": "To add your TikTok profile as a channel, you need to authenticate your TikTok Profile by clicking on 'Continue with TikTok' ",
|
||||
"ERROR_MESSAGE": "There was an error connecting to TikTok, please try again",
|
||||
"ERROR_AUTH": "There was an error connecting to TikTok, please try again",
|
||||
"NORTH_AMERICA_WARNING": "TikTok connections for North American accounts are temporarily unavailable while we wait for an update from the TikTok team."
|
||||
"ERROR_AUTH": "There was an error connecting to TikTok, please try again"
|
||||
},
|
||||
"TWITTER": {
|
||||
"HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ",
|
||||
|
||||
@@ -316,6 +316,16 @@
|
||||
"MENTIONED_CONVERSATIONS": "Mentions",
|
||||
"PARTICIPATING_CONVERSATIONS": "Participating",
|
||||
"UNATTENDED_CONVERSATIONS": "Unattended",
|
||||
"UNREAD_COUNT_TOOLTIP": {
|
||||
"ALL": "Total unread conversations",
|
||||
"INBOX": "Total unread conversations in this inbox",
|
||||
"LABEL": "Total unread conversations in this label",
|
||||
"TEAM": "Total unread conversations in this team",
|
||||
"FOLDER": "Total unread conversations in this folder",
|
||||
"MENTIONS": "Unread conversations where you were mentioned",
|
||||
"PARTICIPATING": "Unread conversations you're participating in",
|
||||
"UNATTENDED": "Unread unattended conversations"
|
||||
},
|
||||
"REPORTS": "Reports",
|
||||
"SETTINGS": "Settings",
|
||||
"CONTACTS": "Contacts",
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import MenuItem from '../../../components/widgets/conversation/contextMenu/menuItem.vue';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import ReportCaptainMessageDialog from './ReportCaptainMessageDialog.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -22,7 +21,6 @@ export default {
|
||||
MenuItem,
|
||||
ContextMenu,
|
||||
NextButton,
|
||||
ReportCaptainMessageDialog,
|
||||
},
|
||||
props: {
|
||||
message: {
|
||||
@@ -154,10 +152,6 @@ export default {
|
||||
closeDeleteModal() {
|
||||
this.showDeleteModal = false;
|
||||
},
|
||||
openReportDialog() {
|
||||
this.handleClose();
|
||||
this.$refs.reportDialog?.open();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -249,16 +243,6 @@ export default {
|
||||
variant="icon"
|
||||
@click.stop="showCannedResponseModal"
|
||||
/>
|
||||
<hr v-if="enabledOptions['report']" />
|
||||
<MenuItem
|
||||
v-if="enabledOptions['report']"
|
||||
:option="{
|
||||
icon: 'warning',
|
||||
label: $t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.LABEL'),
|
||||
}"
|
||||
variant="icon"
|
||||
@click.stop="openReportDialog"
|
||||
/>
|
||||
<hr v-if="enabledOptions['delete']" />
|
||||
<MenuItem
|
||||
v-if="enabledOptions['delete']"
|
||||
@@ -271,11 +255,6 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
<ReportCaptainMessageDialog
|
||||
v-if="enabledOptions['report']"
|
||||
ref="reportDialog"
|
||||
:message-id="messageId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import MessageReportsAPI from 'dashboard/api/captain/messageReports';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Select from 'dashboard/components-next/select/Select.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
|
||||
const props = defineProps({
|
||||
messageId: { type: [Number, String], required: true },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const dialogRef = ref(null);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const REPORT_REASONS = [
|
||||
'incorrect_information',
|
||||
'inappropriate_response',
|
||||
'incomplete_response',
|
||||
'outdated_information',
|
||||
'other',
|
||||
];
|
||||
|
||||
const reasonOptions = computed(() =>
|
||||
REPORT_REASONS.map(value => ({
|
||||
value,
|
||||
label: t(`CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.REASONS.${value}`),
|
||||
}))
|
||||
);
|
||||
|
||||
const form = reactive({ reportReason: '', description: '' });
|
||||
|
||||
const isFormInvalid = computed(() => !form.reportReason);
|
||||
|
||||
const resetForm = () => {
|
||||
form.reportReason = '';
|
||||
form.description = '';
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
resetForm();
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (isFormInvalid.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await MessageReportsAPI.create({
|
||||
message_id: props.messageId,
|
||||
report_reason: form.reportReason,
|
||||
description: form.description.trim() || null,
|
||||
});
|
||||
useAlert(t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.SUCCESS'));
|
||||
close();
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.ERROR'));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.TITLE')"
|
||||
:description="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION')"
|
||||
:confirm-button-label="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.SUBMIT')"
|
||||
:is-loading="isLoading"
|
||||
:disable-confirm-button="isFormInvalid"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.PROBLEM_TYPE') }}
|
||||
</label>
|
||||
<Select
|
||||
v-model="form.reportReason"
|
||||
class="!w-full [&>select]:w-full"
|
||||
:options="reasonOptions"
|
||||
:placeholder="
|
||||
t(
|
||||
'CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.PROBLEM_TYPE_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION_LABEL') }}
|
||||
</label>
|
||||
<TextArea
|
||||
v-model="form.description"
|
||||
class="w-full"
|
||||
:placeholder="
|
||||
t(
|
||||
'CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:max-length="500"
|
||||
show-character-count
|
||||
auto-height
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { ref, watch, useTemplateRef } from 'vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import { ROLES } from 'dashboard/constants/permissions';
|
||||
@@ -30,6 +30,16 @@ const onSelectRecentSearch = query => {
|
||||
searchQuery.value = query;
|
||||
onSearch(query);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.initialQuery,
|
||||
newValue => {
|
||||
if (searchQuery.value !== newValue) {
|
||||
searchQuery.value = newValue;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -30,13 +30,10 @@ const debouncedEmit = debounce(
|
||||
500
|
||||
);
|
||||
|
||||
const onInput = e => {
|
||||
// Use the DOM value, not searchQuery.value: the defineModel ref updates a tick
|
||||
// later, so reading it back here lags one character behind.
|
||||
const value = e.target.value;
|
||||
debouncedEmit(value);
|
||||
const onInput = () => {
|
||||
debouncedEmit(searchQuery.value);
|
||||
|
||||
if (value.trim()) {
|
||||
if (searchQuery.value.trim()) {
|
||||
showRecentSearches.value = false;
|
||||
} else if (isInputFocused.value) {
|
||||
showRecentSearches.value = true;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { computed, watch, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
tabs: {
|
||||
@@ -15,6 +17,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['tabChange']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const activeTab = ref(props.selectedTab);
|
||||
|
||||
watch(
|
||||
@@ -47,5 +51,14 @@ const onTabChange = selectedTab => {
|
||||
:initial-active-tab="activeTab"
|
||||
@tab-changed="onTabChange"
|
||||
/>
|
||||
|
||||
<Button
|
||||
:label="t('SEARCH.SORT_BY.RELEVANCE')"
|
||||
sm
|
||||
link
|
||||
slate
|
||||
class="hover:!no-underline pointer-events-none lg:inline-flex hidden"
|
||||
icon="i-lucide-arrow-up-down"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { generateURLParams, parseURLParams } from '../helpers/searchHelper';
|
||||
@@ -29,7 +28,6 @@ import SearchResultArticlesList from './SearchResultArticlesList.vue';
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { currentAccount } = useAccount();
|
||||
const { t } = useI18n();
|
||||
|
||||
const PER_PAGE = 15; // Results per page
|
||||
@@ -340,25 +338,18 @@ const onTabChange = tab => {
|
||||
onMounted(() => {
|
||||
store.dispatch('conversationSearch/clearSearchResults');
|
||||
store.dispatch('agents/get');
|
||||
});
|
||||
|
||||
// Wait for the account before restoring URL filters: the ADVANCED_SEARCH flag
|
||||
// derives from account.features (loaded async), and reading it too early strips
|
||||
// the filter params from the URL. `immediate` covers the already-loaded case.
|
||||
watch(
|
||||
() => currentAccount.value?.id,
|
||||
id => {
|
||||
if (!id) return;
|
||||
filters.value = parseURLParams(
|
||||
route.query,
|
||||
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
|
||||
);
|
||||
if (route.query.q) {
|
||||
onSearch(route.query.q);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
const parsedFilters = parseURLParams(
|
||||
route.query,
|
||||
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
|
||||
);
|
||||
filters.value = parsedFilters;
|
||||
|
||||
// Auto-execute search if query parameter exists
|
||||
if (route.query.q) {
|
||||
onSearch(route.query.q);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
query.value = '';
|
||||
|
||||
@@ -25,7 +25,7 @@ export default {
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { agentsList } = useAgentsList(true, true);
|
||||
const { agentsList } = useAgentsList();
|
||||
return {
|
||||
agentsList,
|
||||
};
|
||||
@@ -81,27 +81,18 @@ export default {
|
||||
},
|
||||
assignedAgent: {
|
||||
get() {
|
||||
const assignee = this.currentChat.meta.assignee;
|
||||
if (!assignee) return assignee;
|
||||
|
||||
return {
|
||||
...assignee,
|
||||
assignee_type: this.currentChat.meta.assignee_type || 'User',
|
||||
};
|
||||
return this.currentChat.meta.assignee;
|
||||
},
|
||||
set(agent) {
|
||||
const agentId = agent ? agent.id : null;
|
||||
const assigneeType = agent ? agent.assignee_type || 'User' : null;
|
||||
this.$store.dispatch('setCurrentChatAssignee', {
|
||||
conversationId: this.currentChat.id,
|
||||
assignee: agent,
|
||||
assigneeType,
|
||||
});
|
||||
this.$store
|
||||
.dispatch('assignAgent', {
|
||||
conversationId: this.currentChat.id,
|
||||
agentId,
|
||||
assigneeType,
|
||||
})
|
||||
.then(() => {
|
||||
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
|
||||
@@ -167,12 +158,6 @@ export default {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch(
|
||||
'inboxAssignableAgents/fetchAssignableOwners',
|
||||
this.currentChat.inbox_id
|
||||
);
|
||||
},
|
||||
methods: {
|
||||
onSelfAssign() {
|
||||
const {
|
||||
@@ -189,7 +174,6 @@ export default {
|
||||
account_id,
|
||||
availability_status,
|
||||
available_name,
|
||||
assignee_type: 'User',
|
||||
email,
|
||||
id,
|
||||
name,
|
||||
@@ -199,14 +183,7 @@ export default {
|
||||
this.assignedAgent = selfAssign;
|
||||
},
|
||||
onClickAssignAgent(selectedItem) {
|
||||
const currentAssigneeType = this.assignedAgent?.assignee_type || 'User';
|
||||
const selectedAssigneeType = selectedItem.assignee_type || 'User';
|
||||
|
||||
if (
|
||||
this.assignedAgent &&
|
||||
this.assignedAgent.id === selectedItem.id &&
|
||||
currentAssigneeType === selectedAssigneeType
|
||||
) {
|
||||
if (this.assignedAgent && this.assignedAgent.id === selectedItem.id) {
|
||||
this.assignedAgent = null;
|
||||
} else {
|
||||
this.assignedAgent = selectedItem;
|
||||
|
||||
+1
-14
@@ -1,17 +1,15 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import allLocales from 'shared/constants/locales.js';
|
||||
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
|
||||
import ArticlesPage from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const pageNumber = ref(1);
|
||||
const searchQuery = ref(route.query.search || '');
|
||||
|
||||
const allArticles = useMapGetter('articles/allArticles');
|
||||
const articlesSortedByPosition = useMapGetter(
|
||||
@@ -76,7 +74,6 @@ const fetchArticles = ({ pageNumber: pageNumberParam } = {}) => {
|
||||
status: status.value,
|
||||
authorId: author.value,
|
||||
categorySlug: selectedCategorySlug.value,
|
||||
query: searchQuery.value || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -84,15 +81,6 @@ const onPageChange = pageNumberParam => {
|
||||
fetchArticles({ pageNumber: pageNumberParam });
|
||||
};
|
||||
|
||||
const onSearch = query => {
|
||||
searchQuery.value = query;
|
||||
pageNumber.value = 1;
|
||||
router.replace({
|
||||
query: { ...route.query, search: query || undefined },
|
||||
});
|
||||
fetchArticles({ pageNumber: 1 });
|
||||
};
|
||||
|
||||
const fetchPortalAndItsCategories = async locale => {
|
||||
await store.dispatch('portals/index');
|
||||
const selectedPortalParam = {
|
||||
@@ -130,7 +118,6 @@ watch(
|
||||
:portal-meta="portalMeta"
|
||||
:is-category-articles="isCategoryArticles"
|
||||
@page-change="onPageChange"
|
||||
@search="onSearch"
|
||||
@fetch-portal="fetchPortalAndItsCategories"
|
||||
@refresh-articles="fetchArticles"
|
||||
/>
|
||||
|
||||
@@ -3,12 +3,8 @@ import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import tiktokClient from 'dashboard/api/channel/tiktokClient';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const hasError = ref(false);
|
||||
const errorStateMessage = ref('');
|
||||
@@ -60,37 +56,23 @@ const requestAuthorization = async () => {
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center w-full px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
|
||||
class="flex flex-col items-center justify-center px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
|
||||
>
|
||||
<div class="flex flex-col items-center w-full max-w-2xl">
|
||||
<h6 class="text-2xl font-medium">
|
||||
{{ $t('INBOX_MGMT.ADD.TIKTOK.CONNECT_YOUR_TIKTOK_PROFILE') }}
|
||||
</h6>
|
||||
<p class="max-w-xl py-6 text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.TIKTOK.HELP') }}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#00f2ea] via-[#ff0050] to-[#000000]"
|
||||
lg
|
||||
icon="i-ri-tiktok-line"
|
||||
:disabled="isRequestingAuthorization"
|
||||
:is-loading="isRequestingAuthorization"
|
||||
:label="$t('INBOX_MGMT.ADD.TIKTOK.CONTINUE_WITH_TIKTOK')"
|
||||
@click="requestAuthorization()"
|
||||
/>
|
||||
<Banner v-if="isOnChatwootCloud" color="amber" class="w-full mt-6">
|
||||
<div class="flex items-start gap-3 text-left">
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="flex-shrink-0 size-4 mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{{ $t('INBOX_MGMT.ADD.TIKTOK.NORTH_AMERICA_WARNING') }}
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
</div>
|
||||
<h6 class="text-2xl font-medium">
|
||||
{{ $t('INBOX_MGMT.ADD.TIKTOK.CONNECT_YOUR_TIKTOK_PROFILE') }}
|
||||
</h6>
|
||||
<p class="py-6 text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.TIKTOK.HELP') }}
|
||||
</p>
|
||||
<Button
|
||||
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#00f2ea] via-[#ff0050] to-[#000000]"
|
||||
lg
|
||||
icon="i-ri-tiktok-line"
|
||||
:disabled="isRequestingAuthorization"
|
||||
:is-loading="isRequestingAuthorization"
|
||||
:label="$t('INBOX_MGMT.ADD.TIKTOK.CONTINUE_WITH_TIKTOK')"
|
||||
@click="requestAuthorization()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,10 @@ export const state = {
|
||||
inboxes: {},
|
||||
labels: {},
|
||||
teams: {},
|
||||
mentionsCount: 0,
|
||||
participatingCount: 0,
|
||||
unattendedCount: 0,
|
||||
folders: {},
|
||||
};
|
||||
|
||||
const normalizeCount = count => {
|
||||
@@ -37,6 +41,18 @@ export const getters = {
|
||||
getTeamUnreadCount: $state => teamId => {
|
||||
return $state.teams[String(teamId)] || 0;
|
||||
},
|
||||
getMentionsUnreadCount($state) {
|
||||
return $state.mentionsCount;
|
||||
},
|
||||
getParticipatingUnreadCount($state) {
|
||||
return $state.participatingCount;
|
||||
},
|
||||
getUnattendedUnreadCount($state) {
|
||||
return $state.unattendedCount;
|
||||
},
|
||||
getFolderUnreadCount: $state => folderId => {
|
||||
return $state.folders[String(folderId)] || 0;
|
||||
},
|
||||
getInboxUnreadCounts($state) {
|
||||
return $state.inboxes;
|
||||
},
|
||||
@@ -46,6 +62,9 @@ export const getters = {
|
||||
getTeamUnreadCounts($state) {
|
||||
return $state.teams;
|
||||
},
|
||||
getFolderUnreadCounts($state) {
|
||||
return $state.folders;
|
||||
},
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
@@ -68,6 +87,10 @@ export const mutations = {
|
||||
$state.inboxes = normalizeCounts(payload.inboxes);
|
||||
$state.labels = normalizeCounts(payload.labels);
|
||||
$state.teams = normalizeCounts(payload.teams);
|
||||
$state.mentionsCount = normalizeCount(payload.mentions_count);
|
||||
$state.participatingCount = normalizeCount(payload.participating_count);
|
||||
$state.unattendedCount = normalizeCount(payload.unattended_count);
|
||||
$state.folders = normalizeCounts(payload.folders);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -208,31 +208,23 @@ const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
assignAgent: async (
|
||||
{ dispatch },
|
||||
{ conversationId, agentId, assigneeType }
|
||||
) => {
|
||||
assignAgent: async ({ dispatch }, { conversationId, agentId }) => {
|
||||
try {
|
||||
const response = await ConversationApi.assignAgent({
|
||||
conversationId,
|
||||
agentId,
|
||||
assigneeType,
|
||||
});
|
||||
dispatch('setCurrentChatAssignee', {
|
||||
conversationId,
|
||||
assignee: response.data,
|
||||
assigneeType,
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
},
|
||||
|
||||
setCurrentChatAssignee(
|
||||
{ commit },
|
||||
{ conversationId, assignee, assigneeType }
|
||||
) {
|
||||
commit(types.ASSIGN_AGENT, { conversationId, assignee, assigneeType });
|
||||
setCurrentChatAssignee({ commit }, { conversationId, assignee }) {
|
||||
commit(types.ASSIGN_AGENT, { conversationId, assignee });
|
||||
},
|
||||
|
||||
assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
|
||||
|
||||
@@ -108,16 +108,10 @@ export const mutations = {
|
||||
}
|
||||
},
|
||||
|
||||
[types.ASSIGN_AGENT](_state, { conversationId, assignee, assigneeType }) {
|
||||
[types.ASSIGN_AGENT](_state, { conversationId, assignee }) {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (chat) {
|
||||
chat.meta.assignee = assignee;
|
||||
chat.meta.assignee_type = assigneeType;
|
||||
if (assigneeType === 'AgentBot') {
|
||||
chat.status = 'pending';
|
||||
} else if (assignee) {
|
||||
chat.status = 'open';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import types from '../../mutation-types';
|
||||
export const actions = {
|
||||
index: async (
|
||||
{ commit },
|
||||
{ pageNumber, portalSlug, locale, status, authorId, categorySlug, query }
|
||||
{ pageNumber, portalSlug, locale, status, authorId, categorySlug }
|
||||
) => {
|
||||
try {
|
||||
commit(types.SET_UI_FLAG, { isFetching: true });
|
||||
@@ -18,7 +18,6 @@ export const actions = {
|
||||
status,
|
||||
authorId,
|
||||
categorySlug,
|
||||
query,
|
||||
});
|
||||
const payload = camelcaseKeys(data.payload);
|
||||
const meta = camelcaseKeys(data.meta);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import AssignableAgentsAPI from '../../api/assignableAgents';
|
||||
import InboxesAPI from '../../api/inboxes';
|
||||
|
||||
const state = {
|
||||
records: {},
|
||||
ownerRecords: {},
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
},
|
||||
@@ -12,7 +10,6 @@ const state = {
|
||||
export const types = {
|
||||
SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG: 'SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG',
|
||||
SET_INBOX_ASSIGNABLE_AGENTS: 'SET_INBOX_ASSIGNABLE_AGENTS',
|
||||
SET_INBOX_ASSIGNABLE_OWNERS: 'SET_INBOX_ASSIGNABLE_OWNERS',
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
@@ -21,7 +18,6 @@ export const getters = {
|
||||
const verifiedAgents = allAgents.filter(record => record.confirmed);
|
||||
return verifiedAgents;
|
||||
},
|
||||
getAssignableOwners: $state => inboxId => $state.ownerRecords[inboxId] || [],
|
||||
getUIFlags($state) {
|
||||
return $state.uiFlags;
|
||||
},
|
||||
@@ -44,15 +40,6 @@ export const actions = {
|
||||
commit(types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: false });
|
||||
}
|
||||
},
|
||||
async fetchAssignableOwners({ commit }, inboxId) {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await InboxesAPI.getAssignableOwners(inboxId);
|
||||
commit(types.SET_INBOX_ASSIGNABLE_OWNERS, {
|
||||
inboxId,
|
||||
owners: payload,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
@@ -68,12 +55,6 @@ export const mutations = {
|
||||
[inboxId]: members,
|
||||
};
|
||||
},
|
||||
[types.SET_INBOX_ASSIGNABLE_OWNERS]: ($state, { inboxId, owners }) => {
|
||||
$state.ownerRecords = {
|
||||
...$state.ownerRecords,
|
||||
[inboxId]: owners,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -57,10 +57,32 @@ describe('#getters', () => {
|
||||
inboxes: { 1: 2 },
|
||||
labels: { 3: 4 },
|
||||
teams: { 5: 6 },
|
||||
folders: { 8: 9 },
|
||||
};
|
||||
|
||||
expect(getters.getInboxUnreadCounts(state)).toEqual({ 1: 2 });
|
||||
expect(getters.getLabelUnreadCounts(state)).toEqual({ 3: 4 });
|
||||
expect(getters.getTeamUnreadCounts(state)).toEqual({ 5: 6 });
|
||||
expect(getters.getFolderUnreadCounts(state)).toEqual({ 8: 9 });
|
||||
});
|
||||
|
||||
it('returns mentions, participating, unattended, and folder unread counts', () => {
|
||||
const state = {
|
||||
allCount: 0,
|
||||
inboxes: {},
|
||||
labels: {},
|
||||
teams: {},
|
||||
mentionsCount: 3,
|
||||
participatingCount: 4,
|
||||
unattendedCount: 5,
|
||||
folders: { 7: 8 },
|
||||
};
|
||||
|
||||
expect(getters.getMentionsUnreadCount(state)).toBe(3);
|
||||
expect(getters.getParticipatingUnreadCount(state)).toBe(4);
|
||||
expect(getters.getUnattendedUnreadCount(state)).toBe(5);
|
||||
expect(getters.getFolderUnreadCount(state)(7)).toBe(8);
|
||||
expect(getters.getFolderUnreadCount(state)('7')).toBe(8);
|
||||
expect(getters.getFolderUnreadCount(state)(8)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
+19
@@ -21,6 +21,13 @@ describe('#mutations', () => {
|
||||
6: '7',
|
||||
7: 0,
|
||||
},
|
||||
mentions_count: '8',
|
||||
participating_count: 9,
|
||||
unattended_count: '10',
|
||||
folders: {
|
||||
11: '12',
|
||||
12: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
@@ -28,6 +35,10 @@ describe('#mutations', () => {
|
||||
inboxes: { 1: 2 },
|
||||
labels: { 4: 5 },
|
||||
teams: { 6: 7 },
|
||||
mentionsCount: 8,
|
||||
participatingCount: 9,
|
||||
unattendedCount: 10,
|
||||
folders: { 11: 12 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +48,10 @@ describe('#mutations', () => {
|
||||
inboxes: { 1: 2 },
|
||||
labels: { 4: 5 },
|
||||
teams: { 6: 7 },
|
||||
mentionsCount: 8,
|
||||
participatingCount: 9,
|
||||
unattendedCount: 10,
|
||||
folders: { 10: 11 },
|
||||
};
|
||||
|
||||
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {});
|
||||
@@ -46,6 +61,10 @@ describe('#mutations', () => {
|
||||
inboxes: {},
|
||||
labels: {},
|
||||
teams: {},
|
||||
mentionsCount: 0,
|
||||
participatingCount: 0,
|
||||
unattendedCount: 0,
|
||||
folders: {},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -357,12 +357,11 @@ describe('#actions', () => {
|
||||
});
|
||||
await actions.assignAgent(
|
||||
{ dispatch },
|
||||
{ conversationId: 1, agentId: 1, assigneeType: 'AgentBot' }
|
||||
{ conversationId: 1, agentId: 1 }
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', {
|
||||
conversationId: 1,
|
||||
assignee: { id: 1, name: 'User' },
|
||||
assigneeType: 'AgentBot',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -372,7 +371,6 @@ describe('#actions', () => {
|
||||
const payload = {
|
||||
conversationId: 1,
|
||||
assignee: { id: 1, name: 'User' },
|
||||
assigneeType: 'AgentBot',
|
||||
};
|
||||
await actions.setCurrentChatAssignee({ commit }, payload);
|
||||
expect(commit).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -699,11 +699,11 @@ describe('#mutations', () => {
|
||||
});
|
||||
|
||||
describe('#ASSIGN_AGENT', () => {
|
||||
it('should assign agent bot to the correct conversation by ID', () => {
|
||||
it('should assign agent to the correct conversation by ID', () => {
|
||||
const assignee = { id: 1, name: 'Agent' };
|
||||
const state = {
|
||||
allConversations: [
|
||||
{ id: 1, meta: {}, status: 'open' },
|
||||
{ id: 1, meta: {} },
|
||||
{ id: 2, meta: {} },
|
||||
],
|
||||
selectedChatId: 2,
|
||||
@@ -712,28 +712,10 @@ describe('#mutations', () => {
|
||||
mutations[types.ASSIGN_AGENT](state, {
|
||||
conversationId: 1,
|
||||
assignee,
|
||||
assigneeType: 'AgentBot',
|
||||
});
|
||||
expect(state.allConversations[0].meta.assignee).toEqual(assignee);
|
||||
expect(state.allConversations[0].meta.assignee_type).toEqual('AgentBot');
|
||||
expect(state.allConversations[0].status).toEqual('pending');
|
||||
expect(state.allConversations[1].meta.assignee).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should open the conversation when assigning a user', () => {
|
||||
const assignee = { id: 1, name: 'Agent' };
|
||||
const state = {
|
||||
allConversations: [{ id: 1, meta: {}, status: 'pending' }],
|
||||
};
|
||||
|
||||
mutations[types.ASSIGN_AGENT](state, {
|
||||
conversationId: 1,
|
||||
assignee,
|
||||
assigneeType: 'User',
|
||||
});
|
||||
|
||||
expect(state.allConversations[0].status).toEqual('open');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#ASSIGN_PRIORITY', () => {
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import axios from 'axios';
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
import { actions, types } from '../../inboxAssignableAgents';
|
||||
import agentsData from './fixtures';
|
||||
|
||||
const commit = vi.fn();
|
||||
global.axios = axios;
|
||||
vi.mock('axios');
|
||||
vi.mock('dashboard/api/inboxes');
|
||||
|
||||
describe('#actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('#fetch', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.get.mockResolvedValue({
|
||||
@@ -39,20 +33,4 @@ describe('#actions', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#fetchAssignableOwners', () => {
|
||||
it('stores assignable owners for the inbox', async () => {
|
||||
InboxesAPI.getAssignableOwners.mockResolvedValue({
|
||||
data: { payload: agentsData },
|
||||
});
|
||||
|
||||
await actions.fetchAssignableOwners({ commit }, 1);
|
||||
|
||||
expect(InboxesAPI.getAssignableOwners).toHaveBeenCalledWith(1);
|
||||
expect(commit).toHaveBeenCalledWith(types.SET_INBOX_ASSIGNABLE_OWNERS, {
|
||||
inboxId: 1,
|
||||
owners: agentsData,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,16 +13,4 @@ describe('#mutations', () => {
|
||||
expect(state.records).toEqual({ 1: agentsData });
|
||||
});
|
||||
});
|
||||
|
||||
describe('#SET_INBOX_ASSIGNABLE_OWNERS', () => {
|
||||
it('adds inbox owners to ownerRecords', () => {
|
||||
const state = { ownerRecords: {} };
|
||||
mutations[types.SET_INBOX_ASSIGNABLE_OWNERS](state, {
|
||||
owners: [...agentsData],
|
||||
inboxId: 1,
|
||||
});
|
||||
|
||||
expect(state.ownerRecords).toEqual({ 1: agentsData });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,13 +67,7 @@ export default {
|
||||
this.$refs.searchbar.focus();
|
||||
},
|
||||
isActive(option) {
|
||||
return this.selectedItems.some(item => {
|
||||
if (!item || option.id !== item.id) return false;
|
||||
|
||||
return (
|
||||
(option.assignee_type || 'User') === (item.assignee_type || 'User')
|
||||
);
|
||||
});
|
||||
return this.selectedItems.some(item => item && option.id === item.id);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -94,10 +88,7 @@ export default {
|
||||
<div class="flex items-start justify-start flex-auto overflow-auto mt-2">
|
||||
<div class="w-full max-h-[10rem]">
|
||||
<WootDropdownMenu>
|
||||
<WootDropdownItem
|
||||
v-for="option in filteredOptions"
|
||||
:key="`${option.assignee_type || 'User'}-${option.id}`"
|
||||
>
|
||||
<WootDropdownItem v-for="option in filteredOptions" :key="option.id">
|
||||
<NextButton
|
||||
slate
|
||||
:variant="isActive(option) ? 'faded' : 'ghost'"
|
||||
|
||||
@@ -91,10 +91,10 @@ class ActionCableListener < BaseListener
|
||||
end
|
||||
|
||||
def conversation_unread_count_changed(event)
|
||||
account, inbox_members = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform
|
||||
account, inbox_members, include_admins = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform
|
||||
return if account.blank? || !account.feature_enabled?('conversation_unread_counts')
|
||||
|
||||
tokens = user_tokens(account, inbox_members)
|
||||
tokens = include_admins ? user_tokens(account, inbox_members) : inbox_members.pluck(:pubsub_token)
|
||||
|
||||
broadcast(account, tokens, CONVERSATION_UNREAD_COUNT_CHANGED, {})
|
||||
end
|
||||
|
||||
@@ -181,7 +181,7 @@ class Account < ApplicationRecord
|
||||
end
|
||||
|
||||
def clear_unread_conversation_counts_cache
|
||||
::Conversations::UnreadCounts::Store.clear_account!(id)
|
||||
::Conversations::UnreadCounts::Store.clear_all_account!(id)
|
||||
end
|
||||
|
||||
trigger.after(:insert).for_each(:row) do
|
||||
|
||||
@@ -39,6 +39,7 @@ class AccountUser < ApplicationRecord
|
||||
after_create_commit :notify_creation, :create_notification_setting
|
||||
after_destroy :notify_deletion, :remove_user_from_account
|
||||
after_save :update_presence_in_redis, if: :saved_change_to_availability?
|
||||
after_commit :notify_unread_filter_counts_changed, on: [:update, :destroy], if: :unread_filter_access_changed?
|
||||
|
||||
validates :user_id, uniqueness: { scope: :account_id }
|
||||
|
||||
@@ -79,6 +80,14 @@ class AccountUser < ApplicationRecord
|
||||
def update_presence_in_redis
|
||||
OnlineStatusTracker.set_status(account.id, user.id, availability)
|
||||
end
|
||||
|
||||
def unread_filter_access_changed?
|
||||
destroyed? || previous_changes.key?('role') || previous_changes.key?('custom_role_id')
|
||||
end
|
||||
|
||||
def notify_unread_filter_counts_changed
|
||||
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
|
||||
end
|
||||
end
|
||||
|
||||
AccountUser.prepend_mod_with('AccountUser')
|
||||
|
||||
@@ -30,7 +30,6 @@ module CacheKeys
|
||||
update_cache_key_for_account(id, model.name.underscore)
|
||||
end
|
||||
|
||||
::Conversations::UnreadCounts::Store.clear_account!(id)
|
||||
dispatch_cache_update_event
|
||||
end
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ class ConversationParticipant < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
before_validation :ensure_account_id
|
||||
after_commit :notify_unread_filter_counts_changed, on: [:create, :destroy]
|
||||
|
||||
private
|
||||
|
||||
@@ -38,4 +39,8 @@ class ConversationParticipant < ApplicationRecord
|
||||
def ensure_inbox_access
|
||||
errors.add(:user, 'must have inbox access') if conversation && conversation.inbox.assignable_agents.exclude?(user)
|
||||
end
|
||||
|
||||
def notify_unread_filter_counts_changed
|
||||
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,10 +22,19 @@ class CustomFilter < ApplicationRecord
|
||||
|
||||
enum filter_type: { conversation: 0, contact: 1, report: 2 }
|
||||
validate :validate_number_of_filters
|
||||
after_commit :notify_unread_filter_counts_changed, on: [:create, :update, :destroy]
|
||||
|
||||
def validate_number_of_filters
|
||||
return true if account.custom_filters.where(user_id: user_id).size < Limits::MAX_CUSTOM_FILTERS_PER_USER
|
||||
|
||||
errors.add :account_id, I18n.t('errors.custom_filters.number_of_records')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def notify_unread_filter_counts_changed
|
||||
return unless conversation?
|
||||
|
||||
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,7 +23,9 @@ class InboxMember < ApplicationRecord
|
||||
belongs_to :inbox
|
||||
|
||||
after_create :add_agent_to_round_robin
|
||||
before_destroy :cache_unread_filter_notification_context
|
||||
after_destroy :remove_agent_from_round_robin
|
||||
after_commit :notify_unread_filter_counts_changed, on: [:create, :destroy]
|
||||
|
||||
private
|
||||
|
||||
@@ -34,6 +36,16 @@ class InboxMember < ApplicationRecord
|
||||
def remove_agent_from_round_robin
|
||||
::AutoAssignment::InboxRoundRobinService.new(inbox: inbox).remove_agent_from_queue(user_id) if inbox.present?
|
||||
end
|
||||
|
||||
def cache_unread_filter_notification_context
|
||||
@unread_filter_account = inbox&.account
|
||||
@unread_filter_user = user
|
||||
end
|
||||
|
||||
def notify_unread_filter_counts_changed
|
||||
account = @unread_filter_account || inbox&.account
|
||||
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: @unread_filter_user || user).perform
|
||||
end
|
||||
end
|
||||
|
||||
InboxMember.include_mod_with('Audit::InboxMember')
|
||||
|
||||
@@ -32,6 +32,7 @@ class Mention < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
after_commit :notify_mentioned_user
|
||||
after_commit :notify_unread_filter_counts_changed, on: [:create, :destroy]
|
||||
|
||||
scope :latest, -> { order(mentioned_at: :desc) }
|
||||
|
||||
@@ -55,4 +56,8 @@ class Mention < ApplicationRecord
|
||||
def notify_mentioned_user
|
||||
Rails.configuration.dispatcher.dispatch(CONVERSATION_MENTIONED, Time.zone.now, user: user, conversation: conversation)
|
||||
end
|
||||
|
||||
def notify_unread_filter_counts_changed
|
||||
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,10 +30,6 @@ class InboxPolicy < ApplicationPolicy
|
||||
true
|
||||
end
|
||||
|
||||
def assignable_owners?
|
||||
true
|
||||
end
|
||||
|
||||
def agent_bot?
|
||||
true
|
||||
end
|
||||
|
||||
@@ -16,7 +16,6 @@ class Conversations::AssignmentService
|
||||
def assign_agent
|
||||
conversation.assignee = assignee
|
||||
conversation.assignee_agent_bot = nil
|
||||
conversation.status = :open if assignee
|
||||
conversation.save!
|
||||
assignee
|
||||
end
|
||||
@@ -26,7 +25,6 @@ class Conversations::AssignmentService
|
||||
|
||||
conversation.assignee = nil
|
||||
conversation.assignee_agent_bot = agent_bot
|
||||
conversation.status = :pending
|
||||
conversation.save!
|
||||
agent_bot
|
||||
end
|
||||
|
||||
@@ -7,8 +7,7 @@ class Conversations::FilterService < FilterService
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_query_operator
|
||||
@conversations = query_builder(@filters['conversations'])
|
||||
@conversations = filtered_relation
|
||||
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
|
||||
assigned_count = all_count - unassigned_count
|
||||
|
||||
@@ -23,6 +22,13 @@ class Conversations::FilterService < FilterService
|
||||
}
|
||||
end
|
||||
|
||||
def filtered_relation
|
||||
validate_query_operator
|
||||
return base_relation if @params[:payload].blank?
|
||||
|
||||
query_builder(@filters['conversations'])
|
||||
end
|
||||
|
||||
def base_relation
|
||||
conversations = @account.conversations.includes(
|
||||
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
|
||||
|
||||
@@ -6,13 +6,23 @@ class Conversations::UnreadCounts::BroadcastScope
|
||||
end
|
||||
|
||||
def perform
|
||||
return [conversation.account, conversation.inbox.members] if conversation.present?
|
||||
return user_scope if user.present?
|
||||
return [conversation.account, conversation.inbox.members, true] if conversation.present?
|
||||
|
||||
deleted_conversation_scope
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user
|
||||
event.data[:user]
|
||||
end
|
||||
|
||||
def user_scope
|
||||
account = event.data[:account] || user.account
|
||||
[account, [user], false]
|
||||
end
|
||||
|
||||
def conversation
|
||||
event.data[:conversation]
|
||||
end
|
||||
@@ -24,7 +34,7 @@ class Conversations::UnreadCounts::BroadcastScope
|
||||
account = Account.find_by(id: conversation_data[:account_id])
|
||||
return if account.blank?
|
||||
|
||||
[account, inbox_members_for(account, conversation_data[:inbox_id])]
|
||||
[account, inbox_members_for(account, conversation_data[:inbox_id]), true]
|
||||
end
|
||||
|
||||
def inbox_members_for(account, inbox_id)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
module Conversations::UnreadCounts::BuildLockKeys
|
||||
private
|
||||
|
||||
def base_build_lock_key
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_BASE_BUILD_LOCK, account_id: account.id)
|
||||
end
|
||||
|
||||
def assignment_build_lock_key
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_ASSIGNMENT_BUILD_LOCK, account_id: account.id)
|
||||
end
|
||||
|
||||
def filters_build_lock_key
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_BUILD_LOCK, account_id: account.id, user_id: user.id)
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,14 @@
|
||||
class Conversations::UnreadCounts::Builder
|
||||
PARTICIPATING_PERMISSION = 'conversation_participating_manage'.freeze
|
||||
RELATIVE_DATE_FILTER_OPERATOR = 'days_before'.freeze
|
||||
BATCH_SIZE = 1000
|
||||
FILTER_ERRORS = [
|
||||
ActiveRecord::StatementInvalid,
|
||||
CustomExceptions::CustomFilter::InvalidAttribute,
|
||||
CustomExceptions::CustomFilter::InvalidOperator,
|
||||
CustomExceptions::CustomFilter::InvalidQueryOperator,
|
||||
CustomExceptions::CustomFilter::InvalidValue
|
||||
].freeze
|
||||
|
||||
attr_reader :account
|
||||
|
||||
@@ -24,10 +33,37 @@ class Conversations::UnreadCounts::Builder
|
||||
build_assignment!
|
||||
end
|
||||
|
||||
def build_filters_for!(user)
|
||||
store.clear_user_filters!(account.id, user.id)
|
||||
version_snapshot = store.filter_version_snapshot(account.id, user.id)
|
||||
custom_filters = conversation_custom_filters(user).to_a
|
||||
|
||||
store.add_filter_memberships(
|
||||
account_id: account.id,
|
||||
user_id: user.id,
|
||||
filters: {
|
||||
mentions: mentioned_unread_conversation_ids(user),
|
||||
participating: participating_unread_conversation_ids(user),
|
||||
unattended: unattended_unread_conversation_ids(user)
|
||||
},
|
||||
folders: folder_unread_conversation_ids(custom_filters, user)
|
||||
)
|
||||
mark_filters_ready_if_current(user, custom_filters, version_snapshot)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mark_filters_ready_if_current(user, custom_filters, version_snapshot)
|
||||
store.mark_filters_ready_if_current!(
|
||||
account.id,
|
||||
user.id,
|
||||
version_snapshot: version_snapshot,
|
||||
expires_in: filters_ready_ttl(custom_filters)
|
||||
)
|
||||
end
|
||||
|
||||
def write_memberships(assignment:)
|
||||
unread_conversations.in_batches(of: BATCH_SIZE) do |relation|
|
||||
unread_conversations(open_only: true).in_batches(of: BATCH_SIZE) do |relation|
|
||||
columns = %i[id inbox_id assignee_id cached_label_list team_id]
|
||||
memberships = relation.pluck(*columns).map do |id, inbox_id, assignee_id, cached_label_list, team_id|
|
||||
{
|
||||
@@ -43,14 +79,99 @@ class Conversations::UnreadCounts::Builder
|
||||
end
|
||||
end
|
||||
|
||||
def unread_conversations
|
||||
account.conversations
|
||||
.open
|
||||
.joins(:messages)
|
||||
.merge(Message.incoming.reorder(nil))
|
||||
.where(messages: { account_id: account.id })
|
||||
.where(unread_since_last_seen_condition)
|
||||
.distinct
|
||||
def mentioned_unread_conversation_ids(user)
|
||||
visible_unread_conversations(user, open_only: true)
|
||||
.joins(:mentions)
|
||||
.where(mentions: { account_id: account.id, user_id: user.id })
|
||||
.pluck(:id)
|
||||
end
|
||||
|
||||
def participating_unread_conversation_ids(user)
|
||||
participating_visible_unread_conversations(user, open_only: true)
|
||||
.where(id: user.participating_conversations.where(account_id: account.id).select(:id))
|
||||
.pluck(:id)
|
||||
end
|
||||
|
||||
def unattended_unread_conversation_ids(user)
|
||||
visible_unread_conversations(user, open_only: true)
|
||||
.unattended
|
||||
.pluck(:id)
|
||||
end
|
||||
|
||||
def folder_unread_conversation_ids(custom_filters, user)
|
||||
custom_filters.each_with_object({}) do |custom_filter, result|
|
||||
result[custom_filter.id] = unread_ids_for_filter(custom_filter, user)
|
||||
rescue *FILTER_ERRORS
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
def conversation_custom_filters(user)
|
||||
account.custom_filters.where(user: user, filter_type: :conversation)
|
||||
end
|
||||
|
||||
def filters_ready_ttl(custom_filters)
|
||||
return Conversations::UnreadCounts::READY_TTL unless relative_date_filter?(custom_filters)
|
||||
|
||||
seconds_until_next_day
|
||||
end
|
||||
|
||||
def relative_date_filter?(custom_filters)
|
||||
custom_filters.any? do |custom_filter|
|
||||
Array(custom_filter.query.with_indifferent_access[:payload]).any? do |condition|
|
||||
condition[:filter_operator] == RELATIVE_DATE_FILTER_OPERATOR
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def seconds_until_next_day
|
||||
[(Time.zone.tomorrow.beginning_of_day - Time.current).ceil, 1].max
|
||||
end
|
||||
|
||||
def unread_ids_for_filter(custom_filter, user)
|
||||
filter_relation = ::Conversations::FilterService.new(custom_filter.query.with_indifferent_access, user, account).filtered_relation
|
||||
filter_relation
|
||||
.where(id: unread_conversations(open_only: false).select(:id))
|
||||
.reorder(nil)
|
||||
.distinct
|
||||
.pluck(:id)
|
||||
end
|
||||
|
||||
def unread_conversations(open_only:)
|
||||
conversations = account.conversations
|
||||
conversations = conversations.open if open_only
|
||||
|
||||
conversations.joins(:messages)
|
||||
.merge(Message.incoming.reorder(nil))
|
||||
.where(messages: { account_id: account.id })
|
||||
.where(unread_since_last_seen_condition)
|
||||
.distinct
|
||||
end
|
||||
|
||||
def visible_unread_conversations(user, open_only:)
|
||||
::Conversations::PermissionFilterService.new(unread_conversations(open_only: open_only), user, account).perform
|
||||
end
|
||||
|
||||
def participating_visible_unread_conversations(user, open_only:)
|
||||
return inbox_visible_unread_conversations(user, open_only: open_only) if custom_role_participating_permission?(user)
|
||||
|
||||
visible_unread_conversations(user, open_only: open_only)
|
||||
end
|
||||
|
||||
def inbox_visible_unread_conversations(user, open_only:)
|
||||
conversations = unread_conversations(open_only: open_only)
|
||||
return conversations if account_user_for(user)&.administrator?
|
||||
|
||||
conversations.where(inbox: user.inboxes.where(account_id: account.id))
|
||||
end
|
||||
|
||||
def custom_role_participating_permission?(user)
|
||||
account_user = account_user_for(user)
|
||||
account_user&.agent? && account_user.custom_role_id.present? && account_user.permissions.include?(PARTICIPATING_PERMISSION)
|
||||
end
|
||||
|
||||
def account_user_for(user)
|
||||
account.account_users.find_by(user_id: user.id)
|
||||
end
|
||||
|
||||
def unread_since_last_seen_condition
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
class Conversations::UnreadCounts::Counter
|
||||
include ::Conversations::UnreadCounts::BuildLockKeys
|
||||
include ::Conversations::UnreadCounts::FilterCounter
|
||||
|
||||
MANAGE_ALL_PERMISSION = 'conversation_manage'.freeze
|
||||
UNASSIGNED_PERMISSION = 'conversation_unassigned_manage'.freeze
|
||||
PARTICIPATING_PERMISSION = 'conversation_participating_manage'.freeze
|
||||
@@ -18,31 +21,38 @@ class Conversations::UnreadCounts::Counter
|
||||
|
||||
ensure_base_cache!
|
||||
ensure_assignment_cache! if assignment_mode?
|
||||
ensure_filters_cache!
|
||||
|
||||
inbox_counts = unread_inbox_counts
|
||||
filter_counts = unread_filter_counts
|
||||
|
||||
{
|
||||
all_count: inbox_counts.values.sum,
|
||||
inboxes: inbox_counts,
|
||||
labels: unread_label_counts,
|
||||
teams: unread_team_counts
|
||||
teams: unread_team_counts,
|
||||
mentions_count: filter_counts[:mentions_count],
|
||||
participating_count: filter_counts[:participating_count],
|
||||
unattended_count: filter_counts[:unattended_count],
|
||||
folders: filter_counts[:folders]
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_base_cache!
|
||||
ensure_cache_ready!(
|
||||
ready: -> { store.base_ready?(account.id) },
|
||||
lock_key: base_build_lock_key
|
||||
) { ::Conversations::UnreadCounts::Builder.new(account).build_base! }
|
||||
ensure_cache_ready!(ready: -> { store.base_ready?(account.id) }, lock_key: base_build_lock_key) { builder.build_base! }
|
||||
end
|
||||
|
||||
def ensure_assignment_cache!
|
||||
ensure_cache_ready!(ready: -> { store.assignment_ready?(account.id) }, lock_key: assignment_build_lock_key) { builder.build_assignment! }
|
||||
end
|
||||
|
||||
def ensure_filters_cache!
|
||||
ensure_cache_ready!(
|
||||
ready: -> { store.assignment_ready?(account.id) },
|
||||
lock_key: assignment_build_lock_key
|
||||
) { ::Conversations::UnreadCounts::Builder.new(account).build_assignment! }
|
||||
ready: -> { store.filters_ready?(account.id, user.id) },
|
||||
lock_key: filters_build_lock_key
|
||||
) { builder.build_filters_for!(user) }
|
||||
end
|
||||
|
||||
def ensure_cache_ready!(ready:, lock_key:)
|
||||
@@ -51,9 +61,10 @@ class Conversations::UnreadCounts::Counter
|
||||
loop do
|
||||
return if ready.call
|
||||
|
||||
return if lock_manager.with_lock(lock_key, BUILD_LOCK_TTL) { yield unless ready.call }
|
||||
lock_acquired = lock_manager.with_lock(lock_key, BUILD_LOCK_TTL) { yield unless ready.call }
|
||||
return if ready.call
|
||||
|
||||
wait_for_cache_ready(ready)
|
||||
wait_for_cache_ready(ready) unless lock_acquired
|
||||
end
|
||||
end
|
||||
|
||||
@@ -62,14 +73,6 @@ class Conversations::UnreadCounts::Counter
|
||||
sleep BUILD_WAIT_INTERVAL until ready.call || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
||||
end
|
||||
|
||||
def base_build_lock_key
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_BASE_BUILD_LOCK, account_id: account.id)
|
||||
end
|
||||
|
||||
def assignment_build_lock_key
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_ASSIGNMENT_BUILD_LOCK, account_id: account.id)
|
||||
end
|
||||
|
||||
def unread_inbox_counts
|
||||
counts_for_grouped_keys(visible_inbox_ids.index_with { |inbox_id| inbox_keys_for_mode(inbox_id) })
|
||||
end
|
||||
@@ -194,10 +197,14 @@ class Conversations::UnreadCounts::Counter
|
||||
end
|
||||
|
||||
def empty_counts
|
||||
{ all_count: 0, inboxes: {}, labels: {}, teams: {} }
|
||||
{ all_count: 0, inboxes: {}, labels: {}, teams: {}, mentions_count: 0, participating_count: 0, unattended_count: 0, folders: {} }
|
||||
end
|
||||
|
||||
def store
|
||||
::Conversations::UnreadCounts::Store
|
||||
end
|
||||
|
||||
def builder
|
||||
@builder ||= ::Conversations::UnreadCounts::Builder.new(account)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
module Conversations::UnreadCounts::FilterCounter
|
||||
private
|
||||
|
||||
def unread_filter_counts
|
||||
keys = user_filter_keys
|
||||
counts_by_key = store.counts_for_keys(keys.values + folder_keys.values)
|
||||
|
||||
{
|
||||
mentions_count: counts_by_key[keys[:mentions]].to_i,
|
||||
participating_count: counts_by_key[keys[:participating]].to_i,
|
||||
unattended_count: counts_by_key[keys[:unattended]].to_i,
|
||||
folders: folder_counts(counts_by_key)
|
||||
}
|
||||
end
|
||||
|
||||
def user_filter_keys
|
||||
{
|
||||
mentions: store.user_mentions_key(account.id, user.id),
|
||||
participating: store.user_participating_key(account.id, user.id),
|
||||
unattended: store.user_unattended_key(account.id, user.id)
|
||||
}
|
||||
end
|
||||
|
||||
def conversation_folder_ids
|
||||
@conversation_folder_ids ||= account.custom_filters.where(user: user, filter_type: :conversation).pluck(:id)
|
||||
end
|
||||
|
||||
def folder_keys
|
||||
@folder_keys ||= conversation_folder_ids.index_with do |custom_filter_id|
|
||||
store.user_folder_key(account.id, user.id, custom_filter_id)
|
||||
end
|
||||
end
|
||||
|
||||
def folder_counts(counts_by_key)
|
||||
folder_keys.each_with_object({}) do |(custom_filter_id, key), result|
|
||||
count = counts_by_key[key].to_i
|
||||
result[custom_filter_id.to_s] = count if count.positive?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,10 +3,13 @@ class Conversations::UnreadCounts::Listener < BaseListener
|
||||
|
||||
def message_created(event)
|
||||
message, = extract_message_and_account(event)
|
||||
return unless message.incoming?
|
||||
return unless message.account.feature_enabled?('conversation_unread_counts')
|
||||
|
||||
refresh(message.conversation)
|
||||
if message.incoming?
|
||||
refresh(message.conversation)
|
||||
else
|
||||
notify_filter_counts_changed(message.conversation)
|
||||
end
|
||||
end
|
||||
|
||||
def conversation_status_changed(event)
|
||||
@@ -15,10 +18,21 @@ class Conversations::UnreadCounts::Listener < BaseListener
|
||||
end
|
||||
|
||||
def conversation_updated(event)
|
||||
return unless label_changed?(event.data[:changed_attributes])
|
||||
|
||||
conversation, = extract_conversation_and_account(event)
|
||||
refresh(conversation, event.data[:changed_attributes])
|
||||
return unless conversation.account.feature_enabled?('conversation_unread_counts')
|
||||
|
||||
if label_changed?(event.data[:changed_attributes])
|
||||
refresh(conversation, event.data[:changed_attributes])
|
||||
elsif folder_filter_changed?(event.data[:changed_attributes])
|
||||
notify_filter_counts_changed(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
def conversation_contact_changed(event)
|
||||
conversation, = extract_conversation_and_account(event)
|
||||
return unless conversation.account.feature_enabled?('conversation_unread_counts')
|
||||
|
||||
notify_filter_counts_changed(conversation)
|
||||
end
|
||||
|
||||
def assignee_changed(event)
|
||||
@@ -37,7 +51,10 @@ class Conversations::UnreadCounts::Listener < BaseListener
|
||||
|
||||
account = Account.find_by(id: conversation_data[:account_id])
|
||||
return unless account&.feature_enabled?('conversation_unread_counts')
|
||||
return unless remove_deleted_conversation(account, conversation_data)
|
||||
|
||||
filters_cleared = store.clear_filter_caches!(account.id)
|
||||
memberships_removed = remove_deleted_conversation(account, conversation_data)
|
||||
return unless memberships_removed || filters_cleared
|
||||
|
||||
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation_data: conversation_data.to_h)
|
||||
end
|
||||
@@ -90,6 +107,16 @@ class Conversations::UnreadCounts::Listener < BaseListener
|
||||
changed_attributes.key?('cached_label_list') || changed_attributes.key?(:cached_label_list)
|
||||
end
|
||||
|
||||
def folder_filter_changed?(changed_attributes)
|
||||
changed_attributes.present?
|
||||
end
|
||||
|
||||
def notify_filter_counts_changed(conversation)
|
||||
return unless store.clear_filter_caches!(conversation.account_id)
|
||||
|
||||
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation)
|
||||
end
|
||||
|
||||
def store
|
||||
::Conversations::UnreadCounts::Store
|
||||
end
|
||||
|
||||
@@ -11,7 +11,9 @@ class Conversations::UnreadCounts::Notifier
|
||||
def perform
|
||||
return false unless conversation.account.feature_enabled?('conversation_unread_counts')
|
||||
|
||||
return false unless ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform
|
||||
filters_cleared = ::Conversations::UnreadCounts::Store.clear_filter_caches!(conversation.account_id)
|
||||
memberships_refreshed = ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform
|
||||
return false unless memberships_refreshed || filters_cleared
|
||||
|
||||
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation)
|
||||
true
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Conversations::UnreadCounts::Store
|
||||
extend ::Conversations::UnreadCounts::StoreKeys
|
||||
extend ::Conversations::UnreadCounts::UserFilterStore
|
||||
|
||||
class << self
|
||||
def base_ready?(account_id)
|
||||
@@ -19,6 +20,10 @@ class Conversations::UnreadCounts::Store
|
||||
end
|
||||
|
||||
def clear_account!(account_id)
|
||||
account_key_patterns(account_id).each { |pattern| delete_matching(pattern) }
|
||||
end
|
||||
|
||||
def clear_all_account!(account_id)
|
||||
delete_matching("#{account_prefix(account_id)}::*")
|
||||
end
|
||||
|
||||
@@ -178,9 +183,11 @@ class Conversations::UnreadCounts::Store
|
||||
end
|
||||
|
||||
def delete_matching(pattern)
|
||||
deleted = 0
|
||||
Redis::Alfred.scan_each(match: pattern, count: 1000) do |key|
|
||||
Redis::Alfred.delete(key)
|
||||
deleted += 1 if Redis::Alfred.delete(key)
|
||||
end
|
||||
deleted.positive?
|
||||
end
|
||||
|
||||
def assignment_key_patterns(account_id)
|
||||
@@ -195,5 +202,16 @@ class Conversations::UnreadCounts::Store
|
||||
"#{prefix}::TEAM::*::INBOX::*::ASSIGNEE::*"
|
||||
]
|
||||
end
|
||||
|
||||
def account_key_patterns(account_id)
|
||||
prefix = account_prefix(account_id)
|
||||
[
|
||||
base_ready_key(account_id),
|
||||
assignment_ready_key(account_id),
|
||||
"#{prefix}::INBOX::*",
|
||||
"#{prefix}::LABEL::*::INBOX::*",
|
||||
"#{prefix}::TEAM::*::INBOX::*"
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -40,4 +40,20 @@ module Conversations::UnreadCounts::StoreKeys
|
||||
def team_inbox_assignee_key(account_id, team_id, inbox_id, user_id)
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_TEAM_INBOX_ASSIGNEE, account_id: account_id, team_id: team_id, inbox_id: inbox_id, user_id: user_id)
|
||||
end
|
||||
|
||||
def user_mentions_key(account_id, user_id)
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_MENTIONS, account_id: account_id, user_id: user_id)
|
||||
end
|
||||
|
||||
def user_participating_key(account_id, user_id)
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_PARTICIPATING, account_id: account_id, user_id: user_id)
|
||||
end
|
||||
|
||||
def user_unattended_key(account_id, user_id)
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_UNATTENDED, account_id: account_id, user_id: user_id)
|
||||
end
|
||||
|
||||
def user_folder_key(account_id, user_id, custom_filter_id)
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FOLDER, account_id: account_id, user_id: user_id, custom_filter_id: custom_filter_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
class Conversations::UnreadCounts::UserFilterNotifier
|
||||
include Events::Types
|
||||
|
||||
attr_reader :account, :user
|
||||
|
||||
def initialize(account:, user:)
|
||||
@account = account
|
||||
@user = user
|
||||
end
|
||||
|
||||
def perform
|
||||
return false if account.blank? || user.blank?
|
||||
return false unless account.feature_enabled?('conversation_unread_counts')
|
||||
|
||||
::Conversations::UnreadCounts::Store.clear_user_filters!(account.id, user.id)
|
||||
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, account: account, user: user)
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,100 @@
|
||||
module Conversations::UnreadCounts::UserFilterStore
|
||||
USER_FILTER_KEY_SUFFIXES = [
|
||||
'READY::FILTERS',
|
||||
'MENTIONS',
|
||||
'PARTICIPATING',
|
||||
'UNATTENDED',
|
||||
'FOLDER::*'
|
||||
].freeze
|
||||
|
||||
def filters_ready?(account_id, user_id)
|
||||
Redis::Alfred.exists?(filters_ready_key(account_id, user_id))
|
||||
end
|
||||
|
||||
def mark_filters_ready!(account_id, user_id, expires_in: Conversations::UnreadCounts::READY_TTL)
|
||||
Redis::Alfred.set(filters_ready_key(account_id, user_id), Time.current.to_i, ex: expires_in)
|
||||
end
|
||||
|
||||
def mark_filters_ready_if_current!(account_id, user_id, version_snapshot:, expires_in: Conversations::UnreadCounts::READY_TTL)
|
||||
return false unless filter_version_snapshot(account_id, user_id) == version_snapshot
|
||||
|
||||
mark_filters_ready!(account_id, user_id, expires_in: expires_in)
|
||||
end
|
||||
|
||||
def filter_version_snapshot(account_id, user_id)
|
||||
{
|
||||
account: filter_version(account_filter_version_key(account_id)),
|
||||
user: filter_version(user_filter_version_key(account_id, user_id))
|
||||
}
|
||||
end
|
||||
|
||||
def clear_filter_caches!(account_id)
|
||||
bump_filter_version(account_filter_version_key(account_id))
|
||||
delete_user_filter_patterns("#{account_prefix(account_id)}::USER::*")
|
||||
end
|
||||
|
||||
def clear_user_filters!(account_id, user_id)
|
||||
bump_filter_version(user_filter_version_key(account_id, user_id))
|
||||
delete_user_filter_patterns(user_filter_prefix(account_id, user_id))
|
||||
end
|
||||
|
||||
def add_filter_memberships(account_id:, user_id:, filters:, folders:)
|
||||
memberships = {
|
||||
user_mentions_key(account_id, user_id) => filters[:mentions],
|
||||
user_participating_key(account_id, user_id) => filters[:participating],
|
||||
user_unattended_key(account_id, user_id) => filters[:unattended]
|
||||
}
|
||||
folders.each do |custom_filter_id, conversation_ids|
|
||||
memberships[user_folder_key(account_id, user_id, custom_filter_id)] = conversation_ids
|
||||
end
|
||||
|
||||
write_membership_sets(memberships)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filters_ready_key(account_id, user_id)
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_READY, account_id: account_id, user_id: user_id)
|
||||
end
|
||||
|
||||
def account_filter_version_key(account_id)
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_FILTERS_VERSION, account_id: account_id)
|
||||
end
|
||||
|
||||
def user_filter_version_key(account_id, user_id)
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_VERSION, account_id: account_id, user_id: user_id)
|
||||
end
|
||||
|
||||
def user_filter_prefix(account_id, user_id)
|
||||
"#{account_prefix(account_id)}::USER::#{user_id}"
|
||||
end
|
||||
|
||||
def filter_version(key)
|
||||
Redis::Alfred.get(key).to_i
|
||||
end
|
||||
|
||||
def bump_filter_version(key)
|
||||
Redis::Alfred.incr(key).tap { Redis::Alfred.expire(key, Conversations::UnreadCounts::SET_TTL) }
|
||||
end
|
||||
|
||||
def delete_user_filter_patterns(prefix)
|
||||
deleted = false
|
||||
USER_FILTER_KEY_SUFFIXES.each do |suffix|
|
||||
deleted = delete_matching("#{prefix}::#{suffix}") || deleted
|
||||
end
|
||||
deleted
|
||||
end
|
||||
|
||||
def write_membership_sets(memberships)
|
||||
memberships = memberships.transform_values { |conversation_ids| Array(conversation_ids).compact_blank }
|
||||
memberships = memberships.select { |_key, conversation_ids| conversation_ids.present? }
|
||||
return if memberships.blank?
|
||||
|
||||
Redis::Alfred.pipelined do |pipeline|
|
||||
memberships.each do |key, conversation_ids|
|
||||
conversation_ids.each { |conversation_id| pipeline.sadd(key, conversation_id) }
|
||||
pipeline.expire(key, Conversations::UnreadCounts::SET_TTL)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -11,7 +11,7 @@ class FilterService
|
||||
}.with_indifferent_access
|
||||
|
||||
def initialize(params, user)
|
||||
@params = params
|
||||
@params = normalize_params(params)
|
||||
@user = user
|
||||
file = File.read('./lib/filters/filter_keys.yml')
|
||||
@filters = YAML.safe_load(file)
|
||||
@@ -140,6 +140,16 @@ class FilterService
|
||||
|
||||
private
|
||||
|
||||
def normalize_params(params)
|
||||
return params unless params.respond_to?(:with_indifferent_access)
|
||||
|
||||
normalized_params = params.with_indifferent_access
|
||||
normalized_params[:payload] = Array(normalized_params[:payload]).map do |condition|
|
||||
condition.respond_to?(:with_indifferent_access) ? condition.with_indifferent_access : condition
|
||||
end
|
||||
normalized_params
|
||||
end
|
||||
|
||||
def standard_attribute_data_type(attribute_key)
|
||||
@filters.each_value do |section|
|
||||
return section.dig(attribute_key, 'data_type') if section.is_a?(Hash) && section.key?(attribute_key)
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
owners = @assignable_agents.map { |agent| { type: 'User', resource: agent } }
|
||||
owners += @agent_bots.map { |agent_bot| { type: 'AgentBot', resource: agent_bot } }
|
||||
|
||||
json.payload do
|
||||
json.array! owners do |owner|
|
||||
if owner[:type] == 'User'
|
||||
json.partial! 'api/v1/models/agent', formats: [:json], resource: owner[:resource]
|
||||
json.assignee_type 'User'
|
||||
else
|
||||
json.partial! 'api/v1/models/agent_bot_slim', formats: [:json], resource: owner[:resource]
|
||||
json.assignee_type 'AgentBot'
|
||||
json.icon 'i-lucide-bot'
|
||||
end
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.15.1'
|
||||
version: '4.15.0'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -203,15 +203,6 @@ class Rack::Attack
|
||||
match_data[:account_id] if match_data.present?
|
||||
end
|
||||
|
||||
## Prevent abuse of conversation delete API (per account)
|
||||
throttle('/api/v1/accounts/:account_id/conversations/:id DELETE',
|
||||
limit: ENV.fetch('RATE_LIMIT_CONVERSATION_DELETE', '60').to_i, period: 1.minute) do |req|
|
||||
next unless req.delete?
|
||||
|
||||
match_data = %r{\A/api/v1/accounts/(?<account_id>\d+)/conversations/(?<id>\d+)/?\z}.match(req.path_without_extensions)
|
||||
match_data[:account_id] if match_data.present?
|
||||
end
|
||||
|
||||
## Prevent Abuse of attachment upload APIs ##
|
||||
throttle('/api/v1/accounts/:account_id/upload', limit: 60, period: 1.hour) do |req|
|
||||
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/upload}.match(req.path)
|
||||
|
||||
@@ -74,7 +74,6 @@ Rails.application.routes.draw do
|
||||
resources :scenarios
|
||||
end
|
||||
resources :assistant_responses
|
||||
resources :message_reports, only: [:create]
|
||||
resources :bulk_actions, only: [:create]
|
||||
resources :copilot_threads, only: [:index, :create] do
|
||||
resources :copilot_messages, only: [:index, :create]
|
||||
@@ -254,7 +253,6 @@ Rails.application.routes.draw do
|
||||
resources :custom_filters, only: [:index, :show, :create, :update, :destroy]
|
||||
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
|
||||
get :assignable_agents, on: :member
|
||||
get :assignable_owners, on: :member
|
||||
get :campaigns, on: :member
|
||||
get :agent_bot, on: :member
|
||||
post :set_agent_bot, on: :member
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
class CreateCaptainMessageReports < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :captain_message_reports do |t|
|
||||
t.references :account, null: false
|
||||
t.references :conversation, null: false
|
||||
t.references :message, null: false
|
||||
t.references :user, null: false
|
||||
t.string :report_reason, null: false
|
||||
t.text :description
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
+1
-16
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_06_11_184600) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -399,21 +399,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
|
||||
t.index ["inbox_id"], name: "index_captain_inboxes_on_inbox_id"
|
||||
end
|
||||
|
||||
create_table "captain_message_reports", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
t.bigint "message_id", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.string "report_reason", null: false
|
||||
t.text "description"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_captain_message_reports_on_account_id"
|
||||
t.index ["conversation_id"], name: "index_captain_message_reports_on_conversation_id"
|
||||
t.index ["message_id"], name: "index_captain_message_reports_on_message_id"
|
||||
t.index ["user_id"], name: "index_captain_message_reports_on_user_id"
|
||||
end
|
||||
|
||||
create_table "captain_scenarios", force: :cascade do |t|
|
||||
t.string "title"
|
||||
t.text "description"
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
class Api::V1::Accounts::Captain::MessageReportsController < Api::V1::Accounts::BaseController
|
||||
before_action :ensure_cloud_installation
|
||||
before_action :set_message
|
||||
before_action :authorize_conversation
|
||||
before_action :ensure_captain_message
|
||||
|
||||
def create
|
||||
@message_report = @message.message_reports.create!(
|
||||
user: Current.user,
|
||||
report_reason: permitted_params[:report_reason],
|
||||
description: permitted_params[:description]
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_cloud_installation
|
||||
render json: { error: 'Not available' }, status: :not_found unless ChatwootApp.chatwoot_cloud?
|
||||
end
|
||||
|
||||
def set_message
|
||||
@message = Current.account.messages.find(permitted_params[:message_id])
|
||||
end
|
||||
|
||||
def authorize_conversation
|
||||
authorize @message.conversation, :show?
|
||||
end
|
||||
|
||||
def ensure_captain_message
|
||||
return if @message.sender_type == 'Captain::Assistant'
|
||||
|
||||
render json: { error: 'Only Captain messages can be reported' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:message_id, :report_reason, :description)
|
||||
end
|
||||
end
|
||||
@@ -49,7 +49,7 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAcco
|
||||
end
|
||||
|
||||
def destroy
|
||||
Companies::DeleteJob.perform_later(company_id: @company.id)
|
||||
@company.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
module Enterprise::Api::V1::AccountsSettings
|
||||
def create
|
||||
super
|
||||
record_marketing_attribution
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def record_marketing_attribution
|
||||
return if current_user.present?
|
||||
return if @account.blank?
|
||||
|
||||
Internal::Accounts::MarketingAttributionService.new(account: @account, cookies: cookies).perform
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
end
|
||||
|
||||
def permitted_settings_attributes
|
||||
super + [{ conversation_required_attributes: [] }]
|
||||
end
|
||||
|
||||
-13
@@ -29,19 +29,6 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
||||
|
||||
private
|
||||
|
||||
def create_account_for_user
|
||||
super
|
||||
record_marketing_attribution
|
||||
end
|
||||
|
||||
def record_marketing_attribution
|
||||
return if @account.blank?
|
||||
|
||||
Internal::Accounts::MarketingAttributionService.new(account: @account, cookies: cookies).perform
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
end
|
||||
|
||||
def handle_saml_auth
|
||||
account_id = extract_saml_account_id
|
||||
relay_state = saml_relay_state
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
module Enterprise::ConversationFinder
|
||||
def filter_by_conversation_type
|
||||
return super unless params[:conversation_type] == 'participating' && custom_role_participating_permission?
|
||||
|
||||
@conversations = participating_visible_conversations.where(
|
||||
id: current_user.participating_conversations.where(account_id: current_account.id).select(:id)
|
||||
)
|
||||
end
|
||||
|
||||
def conversations_base_query
|
||||
current_account.feature_enabled?('sla') ? super.includes(:applied_sla, :sla_events) : super
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def participating_visible_conversations
|
||||
conversations = current_account.conversations
|
||||
conversations = conversations.where(inbox_id: @inbox_ids) if params[:inbox_id]
|
||||
return conversations if account_user&.administrator?
|
||||
|
||||
conversations.where(inbox: current_user.inboxes.where(account_id: current_account.id))
|
||||
end
|
||||
|
||||
def custom_role_participating_permission?
|
||||
account_user&.agent? && account_user.custom_role_id.present? && account_user.permissions.include?('conversation_participating_manage')
|
||||
end
|
||||
|
||||
def account_user
|
||||
@account_user ||= current_account.account_users.find_by(user_id: current_user.id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
class Companies::DeleteJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
BATCH_SIZE = 1000
|
||||
CONTACT_COMPANY_CLEAR_SQL = <<~SQL.squish.freeze
|
||||
company_id = NULL,
|
||||
additional_attributes = COALESCE(additional_attributes, '{}'::jsonb) - 'company_name'
|
||||
SQL
|
||||
|
||||
def perform(company_id:)
|
||||
company = Company.find_by(id: company_id)
|
||||
return if company.blank?
|
||||
|
||||
clear_contact_company_names(company)
|
||||
company.destroy!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Avoid contact callbacks so this cleanup does not dispatch contact automations/webhooks.
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
def clear_contact_company_names(company)
|
||||
company.contacts.in_batches(of: BATCH_SIZE) do |contacts|
|
||||
contacts.update_all(CONTACT_COMPANY_CLEAR_SQL)
|
||||
end
|
||||
end
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
@@ -1,33 +0,0 @@
|
||||
class Companies::SyncContactNamesJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
BATCH_SIZE = 1000
|
||||
CONTACT_COMPANY_NAME_UPDATE_SQL = <<~SQL.squish.freeze
|
||||
additional_attributes = jsonb_set(
|
||||
COALESCE(additional_attributes, '{}'::jsonb),
|
||||
'{company_name}',
|
||||
?::jsonb,
|
||||
true
|
||||
)
|
||||
SQL
|
||||
|
||||
def perform(company_id:)
|
||||
return if company_id.blank?
|
||||
|
||||
company = Company.find_by(id: company_id)
|
||||
return if company.blank?
|
||||
|
||||
sync_company_name(company)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Denormalized display field sync; avoid contact validations, callbacks, and webhook/automation side effects.
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
def sync_company_name(company)
|
||||
company.contacts.in_batches(of: BATCH_SIZE) do |contacts|
|
||||
contacts.update_all([CONTACT_COMPANY_NAME_UPDATE_SQL, company.name.to_json])
|
||||
end
|
||||
end
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
@@ -1,46 +0,0 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: captain_message_reports
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# description :text
|
||||
# report_reason :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# message_id :bigint not null
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_captain_message_reports_on_account_id (account_id)
|
||||
# index_captain_message_reports_on_conversation_id (conversation_id)
|
||||
# index_captain_message_reports_on_message_id (message_id)
|
||||
# index_captain_message_reports_on_user_id (user_id)
|
||||
#
|
||||
class Captain::MessageReport < ApplicationRecord
|
||||
self.table_name = 'captain_message_reports'
|
||||
|
||||
REPORT_REASONS = %w[incorrect_information inappropriate_response incomplete_response outdated_information other].freeze
|
||||
|
||||
belongs_to :account
|
||||
# `Captain::Conversation` exists as a job namespace, so the association would
|
||||
# resolve to that module instead of the top-level model without this override.
|
||||
belongs_to :conversation, class_name: '::Conversation'
|
||||
belongs_to :message
|
||||
belongs_to :user
|
||||
|
||||
validates :report_reason, presence: true, inclusion: { in: REPORT_REASONS }
|
||||
|
||||
before_validation :ensure_account_and_conversation
|
||||
|
||||
private
|
||||
|
||||
def ensure_account_and_conversation
|
||||
return if message.blank?
|
||||
|
||||
self.account ||= message.account
|
||||
self.conversation ||= message.conversation
|
||||
end
|
||||
end
|
||||
@@ -39,7 +39,6 @@ class Company < ApplicationRecord
|
||||
has_many :contacts, dependent: :nullify
|
||||
before_validation :prepare_jsonb_attributes
|
||||
after_create_commit :fetch_favicon, if: -> { domain.present? }
|
||||
after_update_commit :enqueue_contact_company_name_sync, if: :saved_change_to_name?
|
||||
|
||||
scope :ordered_by_name, -> { order(:name) }
|
||||
scope :search_by_name_or_domain, lambda { |query|
|
||||
@@ -77,8 +76,4 @@ class Company < ApplicationRecord
|
||||
def fetch_favicon
|
||||
Avatar::AvatarFromFaviconJob.set(wait: 5.seconds).perform_later(self)
|
||||
end
|
||||
|
||||
def enqueue_contact_company_name_sync
|
||||
Companies::SyncContactNamesJob.perform_later(company_id: id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -28,6 +28,9 @@ class CustomRole < ApplicationRecord
|
||||
belongs_to :account
|
||||
has_many :account_users, dependent: :nullify
|
||||
|
||||
before_destroy :cache_users_for_unread_filter_notification, prepend: true
|
||||
after_commit :notify_unread_filter_counts_changed, on: [:update, :destroy], if: :unread_filter_access_changed?
|
||||
|
||||
PERMISSIONS = %w[
|
||||
conversation_manage
|
||||
conversation_unassigned_manage
|
||||
@@ -39,4 +42,24 @@ class CustomRole < ApplicationRecord
|
||||
|
||||
validates :name, presence: true
|
||||
validates :permissions, inclusion: { in: PERMISSIONS }
|
||||
|
||||
private
|
||||
|
||||
def unread_filter_access_changed?
|
||||
destroyed? || previous_changes.key?('permissions')
|
||||
end
|
||||
|
||||
def cache_users_for_unread_filter_notification
|
||||
@users_for_unread_filter_notification = account_users.includes(:user).map(&:user)
|
||||
end
|
||||
|
||||
def users_for_unread_filter_notification
|
||||
@users_for_unread_filter_notification || account_users.includes(:user).map(&:user)
|
||||
end
|
||||
|
||||
def notify_unread_filter_counts_changed
|
||||
users_for_unread_filter_notification.each do |user|
|
||||
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,5 @@ module Enterprise::Concerns::Message
|
||||
|
||||
included do
|
||||
has_one :call, dependent: :nullify
|
||||
has_many :message_reports, class_name: 'Captain::MessageReport', dependent: :destroy_async
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,7 +13,6 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
|
||||
channel_instagram
|
||||
channel_tiktok
|
||||
captain_integration
|
||||
captain_document_auto_sync
|
||||
advanced_search_indexing
|
||||
advanced_search
|
||||
linear_integration
|
||||
|
||||
@@ -4,7 +4,7 @@ class Internal::Accounts::InternalAttributesService
|
||||
# List of keys that can be managed through this service
|
||||
# TODO: Add account_notes field in future
|
||||
# This field can be used to store notes about account on Chatwoot cloud
|
||||
VALID_KEYS = %w[manually_managed_features marketing_attribution].freeze
|
||||
VALID_KEYS = %w[manually_managed_features].freeze
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'base64'
|
||||
|
||||
class Internal::Accounts::MarketingAttributionService
|
||||
FIRST_TOUCH_COOKIE = 'cw_first_touch_attribution'
|
||||
LAST_TOUCH_COOKIE = 'cw_last_touch_attribution'
|
||||
FIELD_MAX_LENGTH = 500
|
||||
ALLOWED_FIELDS = %w[
|
||||
utm_source
|
||||
utm_medium
|
||||
utm_campaign
|
||||
utm_term
|
||||
utm_content
|
||||
utm_id
|
||||
gclid
|
||||
gbraid
|
||||
wbraid
|
||||
dclid
|
||||
fbclid
|
||||
msclkid
|
||||
ttclid
|
||||
li_fat_id
|
||||
twclid
|
||||
rdt_cid
|
||||
referrer
|
||||
referrer_path
|
||||
landing_page
|
||||
source
|
||||
source_type
|
||||
captured_at
|
||||
].freeze
|
||||
|
||||
pattr_initialize [:account!, :cookies!]
|
||||
|
||||
def perform
|
||||
return unless ChatwootApp.chatwoot_cloud?
|
||||
|
||||
first_touch = attribution_cookie(FIRST_TOUCH_COOKIE)
|
||||
last_touch = attribution_cookie(LAST_TOUCH_COOKIE)
|
||||
return unless first_touch || last_touch
|
||||
|
||||
existing_attribution = internal_attributes_service.get('marketing_attribution') || {}
|
||||
internal_attributes_service.set(
|
||||
'marketing_attribution',
|
||||
{
|
||||
'first_touch' => first_touch || existing_attribution['first_touch'],
|
||||
'last_touch' => last_touch || existing_attribution['last_touch'],
|
||||
'captured_from' => 'cookie',
|
||||
'stored_at' => Time.current.iso8601
|
||||
}.compact
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def attribution_cookie(cookie_name)
|
||||
return if cookies[cookie_name].blank?
|
||||
|
||||
parse_cookie(cookies[cookie_name].to_s)
|
||||
end
|
||||
|
||||
def parse_cookie(cookie_value)
|
||||
validate_payload(JSON.parse(Base64.urlsafe_decode64(cookie_value)))
|
||||
rescue JSON::ParserError, ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
def validate_payload(payload)
|
||||
return unless payload.is_a?(Hash)
|
||||
|
||||
payload.slice(*ALLOWED_FIELDS).filter_map do |key, value|
|
||||
next if value.blank? || value.is_a?(Array) || value.is_a?(Hash)
|
||||
|
||||
[key, value.to_s.first(FIELD_MAX_LENGTH)]
|
||||
end.to_h.presence
|
||||
end
|
||||
|
||||
def internal_attributes_service
|
||||
@internal_attributes_service ||= Internal::Accounts::InternalAttributesService.new(account)
|
||||
end
|
||||
end
|
||||
@@ -1,7 +0,0 @@
|
||||
json.id @message_report.id
|
||||
json.message_id @message_report.message_id
|
||||
json.conversation_id @message_report.conversation_id
|
||||
json.user_id @message_report.user_id
|
||||
json.report_reason @message_report.report_reason
|
||||
json.description @message_report.description
|
||||
json.created_at @message_report.created_at.to_i
|
||||
@@ -4,6 +4,5 @@
|
||||
- sla
|
||||
- custom_roles
|
||||
- captain_integration
|
||||
- captain_document_auto_sync
|
||||
- csat_review_notes
|
||||
- conversation_required_attributes
|
||||
|
||||
@@ -14,8 +14,15 @@ module Redis::RedisKeys
|
||||
UNREAD_CONVERSATIONS_ACCOUNT_PREFIX = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d'.freeze
|
||||
UNREAD_CONVERSATIONS_BASE_READY = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::READY::BASE'.freeze
|
||||
UNREAD_CONVERSATIONS_ASSIGNMENT_READY = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::READY::ASSIGNMENT'.freeze
|
||||
UNREAD_CONVERSATIONS_USER_FILTERS_READY =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::READY::FILTERS'.freeze
|
||||
UNREAD_CONVERSATIONS_FILTERS_VERSION = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::VERSION::FILTERS'.freeze
|
||||
UNREAD_CONVERSATIONS_USER_FILTERS_VERSION =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::VERSION::FILTERS'.freeze
|
||||
UNREAD_CONVERSATIONS_BASE_BUILD_LOCK = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::BUILD_LOCK::BASE'.freeze
|
||||
UNREAD_CONVERSATIONS_ASSIGNMENT_BUILD_LOCK = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::BUILD_LOCK::ASSIGNMENT'.freeze
|
||||
UNREAD_CONVERSATIONS_USER_FILTERS_BUILD_LOCK =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::BUILD_LOCK::FILTERS'.freeze
|
||||
UNREAD_CONVERSATIONS_INBOX = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::INBOX::%<inbox_id>d'.freeze
|
||||
UNREAD_CONVERSATIONS_LABEL_INBOX =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::LABEL::%<label_id>d::INBOX::%<inbox_id>d'.freeze
|
||||
@@ -33,6 +40,14 @@ module Redis::RedisKeys
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::TEAM::%<team_id>d::INBOX::%<inbox_id>d::UNASSIGNED'.freeze
|
||||
UNREAD_CONVERSATIONS_TEAM_INBOX_ASSIGNEE =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::TEAM::%<team_id>d::INBOX::%<inbox_id>d::ASSIGNEE::%<user_id>d'.freeze
|
||||
UNREAD_CONVERSATIONS_USER_MENTIONS =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::MENTIONS'.freeze
|
||||
UNREAD_CONVERSATIONS_USER_PARTICIPATING =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::PARTICIPATING'.freeze
|
||||
UNREAD_CONVERSATIONS_USER_UNATTENDED =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::UNATTENDED'.freeze
|
||||
UNREAD_CONVERSATIONS_USER_FOLDER =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::FOLDER::%<custom_filter_id>d'.freeze
|
||||
|
||||
## User Keys
|
||||
# SSO Auth Tokens
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user