Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97bb8ecd32 | ||
|
|
3fc1f17757 | ||
|
|
a9468409fb | ||
|
|
d58b6a6cc4 | ||
|
|
81cb75b62f | ||
|
|
88ffa329eb | ||
|
|
899fce1c92 | ||
|
|
b6b856260f | ||
|
|
79b18e7009 |
+12
-12
@@ -170,7 +170,7 @@ GEM
|
|||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcrypt (3.1.22)
|
bcrypt (3.1.22)
|
||||||
benchmark (0.4.1)
|
benchmark (0.4.1)
|
||||||
bigdecimal (4.1.2)
|
bigdecimal (3.3.1)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
bootsnap (1.16.0)
|
bootsnap (1.16.0)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
@@ -193,7 +193,7 @@ GEM
|
|||||||
climate_control (1.2.0)
|
climate_control (1.2.0)
|
||||||
coderay (1.1.3)
|
coderay (1.1.3)
|
||||||
commonmarker (0.23.10)
|
commonmarker (0.23.10)
|
||||||
concurrent-ruby (1.3.7)
|
concurrent-ruby (1.3.5)
|
||||||
connection_pool (2.5.5)
|
connection_pool (2.5.5)
|
||||||
crack (1.0.0)
|
crack (1.0.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
@@ -274,8 +274,8 @@ GEM
|
|||||||
dry-logic (~> 1.5)
|
dry-logic (~> 1.5)
|
||||||
dry-types (~> 1.8)
|
dry-types (~> 1.8)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
dry-types (1.9.1)
|
dry-types (1.8.3)
|
||||||
bigdecimal (>= 3.0)
|
bigdecimal (~> 3.0)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
dry-core (~> 1.0)
|
dry-core (~> 1.0)
|
||||||
dry-inflector (~> 1.0)
|
dry-inflector (~> 1.0)
|
||||||
@@ -304,7 +304,7 @@ GEM
|
|||||||
railties (>= 5.0.0)
|
railties (>= 5.0.0)
|
||||||
faker (3.2.0)
|
faker (3.2.0)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
faraday (2.14.3)
|
faraday (2.14.2)
|
||||||
faraday-net_http (>= 2.0, < 3.5)
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
json
|
json
|
||||||
logger
|
logger
|
||||||
@@ -474,7 +474,7 @@ GEM
|
|||||||
rails-dom-testing (>= 1, < 3)
|
rails-dom-testing (>= 1, < 3)
|
||||||
railties (>= 4.2.0)
|
railties (>= 4.2.0)
|
||||||
thor (>= 0.14, < 2.0)
|
thor (>= 0.14, < 2.0)
|
||||||
json (2.19.9)
|
json (2.19.8)
|
||||||
json_refs (0.1.8)
|
json_refs (0.1.8)
|
||||||
hana
|
hana
|
||||||
json_schemer (0.2.24)
|
json_schemer (0.2.24)
|
||||||
@@ -598,14 +598,14 @@ GEM
|
|||||||
newrelic_rpm (9.6.0)
|
newrelic_rpm (9.6.0)
|
||||||
base64
|
base64
|
||||||
nio4r (2.7.5)
|
nio4r (2.7.5)
|
||||||
nokogiri (1.19.4)
|
nokogiri (1.19.3)
|
||||||
mini_portile2 (~> 2.8.2)
|
mini_portile2 (~> 2.8.2)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.4-arm64-darwin)
|
nokogiri (1.19.3-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.4-x86_64-darwin)
|
nokogiri (1.19.3-x86_64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.4-x86_64-linux-gnu)
|
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
oauth (1.1.6)
|
oauth (1.1.6)
|
||||||
auth-sanitizer (~> 0.2, >= 0.2.1)
|
auth-sanitizer (~> 0.2, >= 0.2.1)
|
||||||
@@ -627,7 +627,7 @@ GEM
|
|||||||
rack (>= 1.2, < 4)
|
rack (>= 1.2, < 4)
|
||||||
snaky_hash (~> 2.0, >= 2.0.5)
|
snaky_hash (~> 2.0, >= 2.0.5)
|
||||||
version_gem (~> 1.1, >= 1.1.11)
|
version_gem (~> 1.1, >= 1.1.11)
|
||||||
oj (3.17.3)
|
oj (3.16.10)
|
||||||
bigdecimal (>= 3.0)
|
bigdecimal (>= 3.0)
|
||||||
ostruct (>= 0.2)
|
ostruct (>= 0.2)
|
||||||
omniauth (2.1.4)
|
omniauth (2.1.4)
|
||||||
@@ -674,7 +674,7 @@ GEM
|
|||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.1)
|
||||||
parallel (1.27.0)
|
parallel (1.27.0)
|
||||||
parser (3.3.8.0)
|
parser (3.3.8.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
|
|||||||
@@ -6,11 +6,6 @@ class Messages::Messenger::MessageBuilder
|
|||||||
return if unsupported_file_type?(attachment['type'])
|
return if unsupported_file_type?(attachment['type'])
|
||||||
|
|
||||||
params = attachment_params(attachment)
|
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 = @message.attachments.new(params.except(:remote_file_url))
|
||||||
attachment_obj.save!
|
attachment_obj.save!
|
||||||
if facebook_reel?(attachment)
|
if facebook_reel?(attachment)
|
||||||
@@ -18,14 +13,10 @@ class Messages::Messenger::MessageBuilder
|
|||||||
elsif params[:remote_file_url]
|
elsif params[:remote_file_url]
|
||||||
attach_file(attachment_obj, params[:remote_file_url])
|
attach_file(attachment_obj, params[:remote_file_url])
|
||||||
end
|
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_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_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
|
||||||
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
||||||
|
update_attachment_file_type(attachment_obj)
|
||||||
end
|
end
|
||||||
|
|
||||||
def attach_file(attachment, file_url)
|
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.
|
# Facebook may send attachment types that don't directly match our file_type enum.
|
||||||
# Map known aliases to their canonical enum values.
|
# 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)
|
def normalize_file_type(type)
|
||||||
sym = type.to_sym
|
sym = type.to_sym
|
||||||
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
|
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
|
||||||
end
|
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
|
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
|
||||||
# direct video URLs. Downloading these yields HTML, not video content.
|
# direct video URLs. Downloading these yields HTML, not video content.
|
||||||
def facebook_reel?(attachment)
|
def facebook_reel?(attachment)
|
||||||
|
|||||||
@@ -21,11 +21,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
@assignable_agents = @inbox.assignable_agents
|
@assignable_agents = @inbox.assignable_agents
|
||||||
end
|
end
|
||||||
|
|
||||||
def assignable_owners
|
|
||||||
@assignable_agents = @inbox.assignable_agents.select(&:confirmed?)
|
|
||||||
@agent_bots = AgentBot.accessible_to(Current.account)
|
|
||||||
end
|
|
||||||
|
|
||||||
def campaigns
|
def campaigns
|
||||||
@campaigns = @inbox.campaigns
|
@campaigns = @inbox.campaigns
|
||||||
end
|
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,
|
authorId,
|
||||||
categorySlug,
|
categorySlug,
|
||||||
sort,
|
sort,
|
||||||
query,
|
|
||||||
}) {
|
}) {
|
||||||
const url = getArticleSearchURL({
|
const url = getArticleSearchURL({
|
||||||
pageNumber,
|
pageNumber,
|
||||||
@@ -26,7 +25,6 @@ class ArticlesAPI extends PortalsAPI {
|
|||||||
authorId,
|
authorId,
|
||||||
categorySlug,
|
categorySlug,
|
||||||
sort,
|
sort,
|
||||||
query,
|
|
||||||
host: this.url,
|
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`, {
|
return axios.post(`${this.url}/${conversationId}/assignments`, {
|
||||||
assignee_id: agentId,
|
assignee_id: agentId,
|
||||||
assignee_type: assigneeType,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,6 @@ class Inboxes extends CacheEnabledApiClient {
|
|||||||
return axios.get(`${this.url}/${inboxId}/campaigns`);
|
return axios.get(`${this.url}/${inboxId}/campaigns`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAssignableOwners(inboxId) {
|
|
||||||
return axios.get(`${this.url}/${inboxId}/assignable_owners`);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteInboxAvatar(inboxId) {
|
deleteInboxAvatar(inboxId) {
|
||||||
return axios.delete(`${this.url}/${inboxId}/avatar`);
|
return axios.delete(`${this.url}/${inboxId}/avatar`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,16 +90,11 @@ describe('#ConversationAPI', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('#assignAgent', () => {
|
it('#assignAgent', () => {
|
||||||
conversationAPI.assignAgent({
|
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
|
||||||
conversationId: 12,
|
|
||||||
agentId: 34,
|
|
||||||
assigneeType: 'AgentBot',
|
|
||||||
});
|
|
||||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||||
`/api/v1/conversations/12/assignments`,
|
`/api/v1/conversations/12/assignments`,
|
||||||
{
|
{
|
||||||
assignee_id: 34,
|
assignee_id: 34,
|
||||||
assignee_type: 'AgentBot',
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ describe('#InboxesAPI', () => {
|
|||||||
expect(inboxesAPI).toHaveProperty('create');
|
expect(inboxesAPI).toHaveProperty('create');
|
||||||
expect(inboxesAPI).toHaveProperty('update');
|
expect(inboxesAPI).toHaveProperty('update');
|
||||||
expect(inboxesAPI).toHaveProperty('delete');
|
expect(inboxesAPI).toHaveProperty('delete');
|
||||||
expect(inboxesAPI).toHaveProperty('getAssignableOwners');
|
|
||||||
expect(inboxesAPI).toHaveProperty('getCampaigns');
|
expect(inboxesAPI).toHaveProperty('getCampaigns');
|
||||||
expect(inboxesAPI).toHaveProperty('getAgentBot');
|
expect(inboxesAPI).toHaveProperty('getAgentBot');
|
||||||
expect(inboxesAPI).toHaveProperty('setAgentBot');
|
expect(inboxesAPI).toHaveProperty('setAgentBot');
|
||||||
@@ -38,13 +37,6 @@ describe('#InboxesAPI', () => {
|
|||||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/inboxes/2/campaigns');
|
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', () => {
|
it('#deleteInboxAvatar', () => {
|
||||||
inboxesAPI.deleteInboxAvatar(2);
|
inboxesAPI.deleteInboxAvatar(2);
|
||||||
expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar');
|
expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar');
|
||||||
|
|||||||
@@ -210,9 +210,9 @@ const handleClick = id => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between w-full gap-2 sm:gap-4">
|
<div class="flex items-center justify-between w-full gap-4">
|
||||||
<div class="flex items-center min-w-0 gap-2 sm:gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="flex items-center min-w-0 gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<Avatar
|
<Avatar
|
||||||
:name="authorName"
|
:name="authorName"
|
||||||
:src="authorThumbnailSrc"
|
:src="authorThumbnailSrc"
|
||||||
@@ -223,17 +223,19 @@ const handleClick = id => {
|
|||||||
{{ authorName || '-' }}
|
{{ authorName || '-' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<EmojiIcon
|
||||||
v-if="category?.icon"
|
v-if="category?.icon"
|
||||||
:value="category.icon"
|
:value="category.icon"
|
||||||
:color="category.icon_color"
|
:color="category.icon_color"
|
||||||
class="flex-shrink-0 size-4"
|
class="flex-shrink-0 size-4"
|
||||||
/>
|
/>
|
||||||
<span class="truncate">{{ categoryName }}</span>
|
{{ categoryName }}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<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" />
|
<Icon icon="i-lucide-eye" class="size-4" />
|
||||||
<span class="text-sm">
|
<span class="text-sm">
|
||||||
@@ -245,7 +247,7 @@ const handleClick = id => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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 }}
|
{{ lastUpdatedAt }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,11 +67,11 @@ const togglePortalSwitcher = () => {
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-if="activePortalName"
|
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 }}
|
{{ activePortalName }}
|
||||||
</span>
|
</span>
|
||||||
<div v-if="activePortalName" class="relative shrink-0 group">
|
<div v-if="activePortalName" class="relative group">
|
||||||
<OnClickOutside @trigger="showPortalSwitcher = false">
|
<OnClickOutside @trigger="showPortalSwitcher = false">
|
||||||
<Button
|
<Button
|
||||||
icon="i-lucide-chevron-down"
|
icon="i-lucide-chevron-down"
|
||||||
@@ -91,9 +91,6 @@ const togglePortalSwitcher = () => {
|
|||||||
</OnClickOutside>
|
</OnClickOutside>
|
||||||
<CreatePortalDialog ref="createPortalDialogRef" />
|
<CreatePortalDialog ref="createPortalDialogRef" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end min-w-0 grow">
|
|
||||||
<slot name="title-actions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<slot name="header-actions" />
|
<slot name="header-actions" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,10 +24,6 @@ const props = defineProps({
|
|||||||
type: Set,
|
type: Set,
|
||||||
default: () => new Set(),
|
default: () => new Set(),
|
||||||
},
|
},
|
||||||
isSearching: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['translateArticle', 'toggleSelect']);
|
const emit = defineEmits(['translateArticle', 'toggleSelect']);
|
||||||
@@ -45,7 +41,6 @@ const hoveredArticleId = ref(null);
|
|||||||
const dragEnabled = computed(() => {
|
const dragEnabled = computed(() => {
|
||||||
return (
|
return (
|
||||||
props.isCategoryArticles &&
|
props.isCategoryArticles &&
|
||||||
!props.isSearching &&
|
|
||||||
localArticles.value?.length > 1 &&
|
localArticles.value?.length > 1 &&
|
||||||
props.selectedArticleIds.size === 0
|
props.selectedArticleIds.size === 0
|
||||||
);
|
);
|
||||||
|
|||||||
+2
-32
@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { OnClickOutside } from '@vueuse/components';
|
import { OnClickOutside } from '@vueuse/components';
|
||||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
import { useConfig } from 'dashboard/composables/useConfig';
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
import { debounce } from '@chatwoot/utils';
|
|
||||||
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
import { useAlert } from 'dashboard/composables';
|
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 ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
|
||||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.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 Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
import BulkTranslateDialog from './BulkTranslateDialog.vue';
|
import BulkTranslateDialog from './BulkTranslateDialog.vue';
|
||||||
@@ -51,12 +49,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits(['pageChange', 'fetchPortal', 'refreshArticles']);
|
||||||
'pageChange',
|
|
||||||
'fetchPortal',
|
|
||||||
'refreshArticles',
|
|
||||||
'search',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -72,9 +65,6 @@ const isFeatureEnabledonAccount = useMapGetter(
|
|||||||
const selectedArticleIds = ref(new Set());
|
const selectedArticleIds = ref(new Set());
|
||||||
const deleteConfirmDialogRef = ref(null);
|
const deleteConfirmDialogRef = ref(null);
|
||||||
const isCategoryMenuOpen = ref(false);
|
const isCategoryMenuOpen = ref(false);
|
||||||
const searchQuery = ref(route.query.search || '');
|
|
||||||
|
|
||||||
const debouncedSearch = debounce(() => emit('search', searchQuery.value), 500);
|
|
||||||
|
|
||||||
const { isEnterprise } = useConfig();
|
const { isEnterprise } = useConfig();
|
||||||
|
|
||||||
@@ -132,7 +122,6 @@ const updateRoute = newParams => {
|
|||||||
categorySlug: newParams.categorySlug ?? categorySlug,
|
categorySlug: newParams.categorySlug ?? categorySlug,
|
||||||
...newParams,
|
...newParams,
|
||||||
},
|
},
|
||||||
query: route.query,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -156,12 +145,7 @@ const showCategoryHeaderControls = computed(
|
|||||||
() => props.isCategoryArticles && !isSwitchingPortal.value
|
() => props.isCategoryArticles && !isSwitchingPortal.value
|
||||||
);
|
);
|
||||||
|
|
||||||
const isSearching = computed(() => Boolean(searchQuery.value?.trim()));
|
|
||||||
|
|
||||||
const getEmptyStateText = type => {
|
const getEmptyStateText = type => {
|
||||||
if (isSearching.value) {
|
|
||||||
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.SEARCH.${type}`);
|
|
||||||
}
|
|
||||||
if (props.isCategoryArticles) {
|
if (props.isCategoryArticles) {
|
||||||
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.CATEGORY.${type}`);
|
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.CATEGORY.${type}`);
|
||||||
}
|
}
|
||||||
@@ -307,19 +291,6 @@ watch(
|
|||||||
:show-pagination-footer="shouldShowPaginationFooter"
|
:show-pagination-footer="shouldShowPaginationFooter"
|
||||||
@update:current-page="handlePageChange"
|
@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>
|
<template #header-actions>
|
||||||
<div class="flex items-end justify-between">
|
<div class="flex items-end justify-between">
|
||||||
<ArticleHeaderControls
|
<ArticleHeaderControls
|
||||||
@@ -451,7 +422,6 @@ watch(
|
|||||||
<ArticleList
|
<ArticleList
|
||||||
:articles="articles"
|
:articles="articles"
|
||||||
:is-category-articles="isCategoryArticles"
|
:is-category-articles="isCategoryArticles"
|
||||||
:is-searching="isSearching"
|
|
||||||
:selected-article-ids="selectedArticleIds"
|
:selected-article-ids="selectedArticleIds"
|
||||||
class="relative z-0"
|
class="relative z-0"
|
||||||
@translate-article="handleTranslateArticle"
|
@translate-article="handleTranslateArticle"
|
||||||
@@ -463,7 +433,7 @@ watch(
|
|||||||
class="pt-14"
|
class="pt-14"
|
||||||
:title="getEmptyStateTitle"
|
:title="getEmptyStateTitle"
|
||||||
:subtitle="getEmptyStateSubtitle"
|
:subtitle="getEmptyStateSubtitle"
|
||||||
:show-button="hasNoArticlesInPortal && !isSearching"
|
:show-button="hasNoArticlesInPortal"
|
||||||
:button-label="
|
:button-label="
|
||||||
t('HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.ALL.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 editCategoryDialog = ref(null);
|
||||||
const selectedCategory = ref(null);
|
const selectedCategory = ref(null);
|
||||||
const searchQuery = ref('');
|
|
||||||
|
|
||||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||||
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
|
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
|
||||||
|
const hasCategories = computed(() => props.categories?.length > 0);
|
||||||
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 updateRoute = (newParams, routeName) => {
|
const updateRoute = (newParams, routeName) => {
|
||||||
const { accountId, portalSlug, locale } = route.params;
|
const { accountId, portalSlug, locale } = route.params;
|
||||||
@@ -126,7 +115,6 @@ const reorderCategories = async reorderedGroup => {
|
|||||||
<HelpCenterLayout :show-pagination-footer="false">
|
<HelpCenterLayout :show-pagination-footer="false">
|
||||||
<template #header-actions>
|
<template #header-actions>
|
||||||
<CategoryHeaderControls
|
<CategoryHeaderControls
|
||||||
v-model:search-query="searchQuery"
|
|
||||||
:categories="categories"
|
:categories="categories"
|
||||||
:is-category-articles="false"
|
:is-category-articles="false"
|
||||||
:allowed-locales="allowedLocales"
|
:allowed-locales="allowedLocales"
|
||||||
@@ -142,18 +130,11 @@ const reorderCategories = async reorderedGroup => {
|
|||||||
</div>
|
</div>
|
||||||
<CategoryList
|
<CategoryList
|
||||||
v-else-if="hasCategories"
|
v-else-if="hasCategories"
|
||||||
:categories="filteredCategories"
|
:categories="categories"
|
||||||
:disable-drag="isSearching"
|
|
||||||
@click="openCategoryArticles"
|
@click="openCategoryArticles"
|
||||||
@action="handleAction"
|
@action="handleAction"
|
||||||
@reorder="reorderCategories"
|
@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
|
<CategoryEmptyState
|
||||||
v-else
|
v-else
|
||||||
class="pt-14"
|
class="pt-14"
|
||||||
|
|||||||
+17
-34
@@ -6,7 +6,6 @@ import { OnClickOutside } from '@vueuse/components';
|
|||||||
import { useStoreGetters } from 'dashboard/composables/store.js';
|
import { useStoreGetters } from 'dashboard/composables/store.js';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
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 Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
import CategoryDialog from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryDialog.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 emit = defineEmits(['localeChange', 'newArticle']);
|
||||||
|
|
||||||
const searchQuery = defineModel('searchQuery', {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const getters = useStoreGetters();
|
const getters = useStoreGetters();
|
||||||
@@ -168,34 +162,23 @@ const handleBreadcrumbClick = () => {
|
|||||||
:items="breadcrumbItems"
|
:items="breadcrumbItems"
|
||||||
@click="handleBreadcrumbClick"
|
@click="handleBreadcrumbClick"
|
||||||
/>
|
/>
|
||||||
<div v-if="!hasSelectedCategory" class="flex items-center gap-2">
|
<div v-if="!hasSelectedCategory" class="relative">
|
||||||
<Input
|
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
|
||||||
v-model="searchQuery"
|
<Button
|
||||||
:placeholder="
|
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
|
||||||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.SEARCH_PLACEHOLDER')
|
icon="i-lucide-plus"
|
||||||
"
|
size="sm"
|
||||||
type="search"
|
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
|
||||||
size="sm"
|
/>
|
||||||
class="w-48"
|
<CategoryDialog
|
||||||
/>
|
v-if="isCreateCategoryDialogOpen"
|
||||||
<div class="relative">
|
mode="create"
|
||||||
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
|
:portal-name="currentPortalName"
|
||||||
<Button
|
:active-locale-name="activeLocaleName"
|
||||||
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
|
:active-locale-code="activeLocaleCode"
|
||||||
icon="i-lucide-plus"
|
@close="isCreateCategoryDialogOpen = false"
|
||||||
size="sm"
|
/>
|
||||||
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
|
</OnClickOutside>
|
||||||
/>
|
|
||||||
<CategoryDialog
|
|
||||||
v-if="isCreateCategoryDialogOpen"
|
|
||||||
mode="create"
|
|
||||||
:portal-name="currentPortalName"
|
|
||||||
:active-locale-name="activeLocaleName"
|
|
||||||
:active-locale-code="activeLocaleCode"
|
|
||||||
@close="isCreateCategoryDialogOpen = false"
|
|
||||||
/>
|
|
||||||
</OnClickOutside>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="relative flex items-center gap-2">
|
<div v-else class="relative flex items-center gap-2">
|
||||||
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
|
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
|
||||||
|
|||||||
+1
-5
@@ -8,10 +8,6 @@ const props = defineProps({
|
|||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
disableDrag: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['click', 'action', 'reorder']);
|
const emit = defineEmits(['click', 'action', 'reorder']);
|
||||||
@@ -19,7 +15,7 @@ const emit = defineEmits(['click', 'action', 'reorder']);
|
|||||||
const localCategories = ref(props.categories);
|
const localCategories = ref(props.categories);
|
||||||
|
|
||||||
const dragEnabled = computed(() => {
|
const dragEnabled = computed(() => {
|
||||||
return !props.disableDrag && localCategories.value?.length > 1;
|
return localCategories.value?.length > 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleClick = slug => {
|
const handleClick = slug => {
|
||||||
|
|||||||
+7
-42
@@ -2,12 +2,8 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.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 Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue';
|
import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue';
|
||||||
import AddLocaleDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/AddLocaleDialog.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 addLocaleDialogRef = ref(null);
|
||||||
const searchQuery = ref('');
|
|
||||||
|
|
||||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||||
|
|
||||||
@@ -35,19 +28,6 @@ const openAddLocaleDialog = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const localeCount = computed(() => props.locales?.length);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -59,21 +39,12 @@ const hasResults = computed(() => filteredLocales.value?.length > 0);
|
|||||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
|
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<Button
|
||||||
<Input
|
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
|
||||||
v-model="searchQuery"
|
icon="i-lucide-plus"
|
||||||
:placeholder="$t('HELP_CENTER.LOCALES_PAGE.SEARCH_PLACEHOLDER')"
|
size="sm"
|
||||||
type="search"
|
@click="openAddLocaleDialog"
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
@@ -83,13 +54,7 @@ const hasResults = computed(() => filteredLocales.value?.length > 0);
|
|||||||
>
|
>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
<EmptyStateLayout
|
<LocaleList v-else :locales="locales" :portal="portal" />
|
||||||
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" />
|
|
||||||
</template>
|
</template>
|
||||||
<AddLocaleDialog ref="addLocaleDialogRef" :portal="portal" />
|
<AddLocaleDialog ref="addLocaleDialogRef" :portal="portal" />
|
||||||
</HelpCenterLayout>
|
</HelpCenterLayout>
|
||||||
|
|||||||
@@ -147,14 +147,8 @@ const { t } = useI18n();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||||
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
|
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
|
||||||
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
|
|
||||||
const { replaceInstallationName } = useBranding();
|
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
|
* Computes the message variant based on props
|
||||||
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
|
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
|
||||||
@@ -396,10 +390,6 @@ const contextMenuEnabledOptions = computed(() => {
|
|||||||
!props.private &&
|
!props.private &&
|
||||||
props.inboxSupportsReplyTo.outgoing &&
|
props.inboxSupportsReplyTo.outgoing &&
|
||||||
!isFailedOrProcessing,
|
!isFailedOrProcessing,
|
||||||
report:
|
|
||||||
isOnChatwootCloud.value &&
|
|
||||||
isCaptainMessage.value &&
|
|
||||||
!isMessageDeleted.value,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ const mockUseMapGetter = (overrides = {}) => {
|
|||||||
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
|
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
|
||||||
getCurrentAccountId: ref(1),
|
getCurrentAccountId: ref(1),
|
||||||
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
|
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
|
||||||
'inboxAssignableAgents/getAssignableOwners': ref(() => []),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mergedGetters = { ...defaultGetters, ...overrides };
|
const mergedGetters = { ...defaultGetters, ...overrides };
|
||||||
@@ -76,32 +75,6 @@ describe('useAgentsList', () => {
|
|||||||
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
|
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', () => {
|
it('handles empty assignable agents', () => {
|
||||||
mockUseMapGetter({
|
mockUseMapGetter({
|
||||||
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
|
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
|
||||||
|
|||||||
@@ -10,21 +10,14 @@ import {
|
|||||||
* A composable function that provides a list of agents for assignment.
|
* A composable function that provides a list of agents for assignment.
|
||||||
*
|
*
|
||||||
* @param {boolean} [includeNoneAgent=true] - Whether to include a 'None' agent option.
|
* @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.
|
* @returns {Object} An object containing the agents list and assignable agents.
|
||||||
*/
|
*/
|
||||||
export function useAgentsList(
|
export function useAgentsList(includeNoneAgent = true) {
|
||||||
includeNoneAgent = true,
|
|
||||||
includeAgentBots = false
|
|
||||||
) {
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const currentUser = useMapGetter('getCurrentUser');
|
const currentUser = useMapGetter('getCurrentUser');
|
||||||
const currentChat = useMapGetter('getSelectedChat');
|
const currentChat = useMapGetter('getSelectedChat');
|
||||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||||
const assignable = useMapGetter('inboxAssignableAgents/getAssignableAgents');
|
const assignable = useMapGetter('inboxAssignableAgents/getAssignableAgents');
|
||||||
const assignableOwners = useMapGetter(
|
|
||||||
'inboxAssignableAgents/getAssignableOwners'
|
|
||||||
);
|
|
||||||
|
|
||||||
const inboxId = computed(() => currentChat.value?.inbox_id);
|
const inboxId = computed(() => currentChat.value?.inbox_id);
|
||||||
const isAgentSelected = computed(() => currentChat.value?.meta?.assignee);
|
const isAgentSelected = computed(() => currentChat.value?.meta?.assignee);
|
||||||
@@ -49,22 +42,11 @@ export function useAgentsList(
|
|||||||
return inboxId.value ? assignable.value(inboxId.value) : [];
|
return inboxId.value ? assignable.value(inboxId.value) : [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const owners = computed(() => {
|
|
||||||
return includeAgentBots && inboxId.value
|
|
||||||
? assignableOwners.value(inboxId.value)
|
|
||||||
: [];
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {import('vue').ComputedRef<Array>}
|
* @type {import('vue').ComputedRef<Array>}
|
||||||
*/
|
*/
|
||||||
const agentsList = computed(() => {
|
const agentsList = computed(() => {
|
||||||
const agents = includeAgentBots
|
const agents = assignableAgents.value || [];
|
||||||
? owners.value.filter(owner => owner.assignee_type === 'User')
|
|
||||||
: assignableAgents.value || [];
|
|
||||||
const bots = owners.value.filter(
|
|
||||||
owner => owner.assignee_type === 'AgentBot'
|
|
||||||
);
|
|
||||||
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
|
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
|
||||||
agents,
|
agents,
|
||||||
currentUser.value,
|
currentUser.value,
|
||||||
@@ -78,7 +60,6 @@ export function useAgentsList(
|
|||||||
return [
|
return [
|
||||||
...(includeNoneAgent && isAgentSelected.value ? [createNoneAgent()] : []),
|
...(includeNoneAgent && isAgentSelected.value ? [createNoneAgent()] : []),
|
||||||
...filteredAgentsByAvailability,
|
...filteredAgentsByAvailability,
|
||||||
...bots,
|
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -304,25 +304,6 @@
|
|||||||
"MESSAGE": "You cannot undo this action",
|
"MESSAGE": "You cannot undo this action",
|
||||||
"DELETE": "Delete",
|
"DELETE": "Delete",
|
||||||
"CANCEL": "Cancel"
|
"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": {
|
"SIDEBAR": {
|
||||||
|
|||||||
@@ -552,7 +552,6 @@
|
|||||||
"LOCALE": {
|
"LOCALE": {
|
||||||
"ALL": "All locales"
|
"ALL": "All locales"
|
||||||
},
|
},
|
||||||
"SEARCH_PLACEHOLDER": "Search articles...",
|
|
||||||
"NEW_ARTICLE": "New article"
|
"NEW_ARTICLE": "New article"
|
||||||
},
|
},
|
||||||
"EMPTY_STATE": {
|
"EMPTY_STATE": {
|
||||||
@@ -580,10 +579,6 @@
|
|||||||
"CATEGORY": {
|
"CATEGORY": {
|
||||||
"TITLE": "There are no articles in this category",
|
"TITLE": "There are no articles in this category",
|
||||||
"SUBTITLE": "Articles in this category will appear here"
|
"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": {
|
"BULK_TRANSLATE": {
|
||||||
@@ -629,7 +624,6 @@
|
|||||||
"CATEGORY_HEADER": {
|
"CATEGORY_HEADER": {
|
||||||
"NEW_CATEGORY": "New category",
|
"NEW_CATEGORY": "New category",
|
||||||
"EDIT_CATEGORY": "Edit category",
|
"EDIT_CATEGORY": "Edit category",
|
||||||
"SEARCH_PLACEHOLDER": "Search categories...",
|
|
||||||
"CATEGORIES_COUNT": "{n} category | {n} categories",
|
"CATEGORIES_COUNT": "{n} category | {n} categories",
|
||||||
"BREADCRUMB": {
|
"BREADCRUMB": {
|
||||||
"CATEGORY_LOCALE": "Categories ({localeCode})",
|
"CATEGORY_LOCALE": "Categories ({localeCode})",
|
||||||
@@ -640,10 +634,6 @@
|
|||||||
"TITLE": "No categories found",
|
"TITLE": "No categories found",
|
||||||
"SUBTITLE": "Categories will appear here. You can add a category by clicking the 'New Category' button."
|
"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": {
|
"CATEGORY_CARD": {
|
||||||
"ARTICLES_COUNT": "{count} article | {count} articles"
|
"ARTICLES_COUNT": "{count} article | {count} articles"
|
||||||
},
|
},
|
||||||
@@ -701,11 +691,6 @@
|
|||||||
"LOCALES_PAGE": {
|
"LOCALES_PAGE": {
|
||||||
"LOCALES_COUNT": "No locales available | {n} locale | {n} locales",
|
"LOCALES_COUNT": "No locales available | {n} locale | {n} locales",
|
||||||
"NEW_LOCALE_BUTTON_TEXT": "New locale",
|
"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": {
|
"LOCALE_CARD": {
|
||||||
"ARTICLES_COUNT": "{count} article | {count} articles",
|
"ARTICLES_COUNT": "{count} article | {count} articles",
|
||||||
"CATEGORIES_COUNT": "{count} category | {count} categories",
|
"CATEGORIES_COUNT": "{count} category | {count} categories",
|
||||||
|
|||||||
@@ -65,8 +65,7 @@
|
|||||||
"CONNECT_YOUR_TIKTOK_PROFILE": "Connect your TikTok Profile",
|
"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' ",
|
"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_MESSAGE": "There was an error connecting to TikTok, please try again",
|
||||||
"ERROR_AUTH": "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."
|
|
||||||
},
|
},
|
||||||
"TWITTER": {
|
"TWITTER": {
|
||||||
"HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ",
|
"HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
import MenuItem from '../../../components/widgets/conversation/contextMenu/menuItem.vue';
|
import MenuItem from '../../../components/widgets/conversation/contextMenu/menuItem.vue';
|
||||||
import { useTrack } from 'dashboard/composables';
|
import { useTrack } from 'dashboard/composables';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
import ReportCaptainMessageDialog from './ReportCaptainMessageDialog.vue';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@@ -22,7 +21,6 @@ export default {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
NextButton,
|
NextButton,
|
||||||
ReportCaptainMessageDialog,
|
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
message: {
|
message: {
|
||||||
@@ -154,10 +152,6 @@ export default {
|
|||||||
closeDeleteModal() {
|
closeDeleteModal() {
|
||||||
this.showDeleteModal = false;
|
this.showDeleteModal = false;
|
||||||
},
|
},
|
||||||
openReportDialog() {
|
|
||||||
this.handleClose();
|
|
||||||
this.$refs.reportDialog?.open();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -249,16 +243,6 @@ export default {
|
|||||||
variant="icon"
|
variant="icon"
|
||||||
@click.stop="showCannedResponseModal"
|
@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']" />
|
<hr v-if="enabledOptions['delete']" />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
v-if="enabledOptions['delete']"
|
v-if="enabledOptions['delete']"
|
||||||
@@ -271,11 +255,6 @@ export default {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
<ReportCaptainMessageDialog
|
|
||||||
v-if="enabledOptions['report']"
|
|
||||||
ref="reportDialog"
|
|
||||||
:message-id="messageId"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<script setup>
|
||||||
import { ref, useTemplateRef } from 'vue';
|
import { ref, watch, useTemplateRef } from 'vue';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||||
import { ROLES } from 'dashboard/constants/permissions';
|
import { ROLES } from 'dashboard/constants/permissions';
|
||||||
@@ -30,6 +30,16 @@ const onSelectRecentSearch = query => {
|
|||||||
searchQuery.value = query;
|
searchQuery.value = query;
|
||||||
onSearch(query);
|
onSearch(query);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.initialQuery,
|
||||||
|
newValue => {
|
||||||
|
if (searchQuery.value !== newValue) {
|
||||||
|
searchQuery.value = newValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -30,13 +30,10 @@ const debouncedEmit = debounce(
|
|||||||
500
|
500
|
||||||
);
|
);
|
||||||
|
|
||||||
const onInput = e => {
|
const onInput = () => {
|
||||||
// Use the DOM value, not searchQuery.value: the defineModel ref updates a tick
|
debouncedEmit(searchQuery.value);
|
||||||
// later, so reading it back here lags one character behind.
|
|
||||||
const value = e.target.value;
|
|
||||||
debouncedEmit(value);
|
|
||||||
|
|
||||||
if (value.trim()) {
|
if (searchQuery.value.trim()) {
|
||||||
showRecentSearches.value = false;
|
showRecentSearches.value = false;
|
||||||
} else if (isInputFocused.value) {
|
} else if (isInputFocused.value) {
|
||||||
showRecentSearches.value = true;
|
showRecentSearches.value = true;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, watch, ref } from 'vue';
|
import { computed, watch, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
tabs: {
|
tabs: {
|
||||||
@@ -15,6 +17,8 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['tabChange']);
|
const emit = defineEmits(['tabChange']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const activeTab = ref(props.selectedTab);
|
const activeTab = ref(props.selectedTab);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -47,5 +51,14 @@ const onTabChange = selectedTab => {
|
|||||||
:initial-active-tab="activeTab"
|
:initial-active-tab="activeTab"
|
||||||
@tab-changed="onTabChange"
|
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<script setup>
|
<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 { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import { useTrack } from 'dashboard/composables';
|
import { useTrack } from 'dashboard/composables';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||||
import { generateURLParams, parseURLParams } from '../helpers/searchHelper';
|
import { generateURLParams, parseURLParams } from '../helpers/searchHelper';
|
||||||
@@ -29,7 +28,6 @@ import SearchResultArticlesList from './SearchResultArticlesList.vue';
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { currentAccount } = useAccount();
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const PER_PAGE = 15; // Results per page
|
const PER_PAGE = 15; // Results per page
|
||||||
@@ -340,25 +338,18 @@ const onTabChange = tab => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.dispatch('conversationSearch/clearSearchResults');
|
store.dispatch('conversationSearch/clearSearchResults');
|
||||||
store.dispatch('agents/get');
|
store.dispatch('agents/get');
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for the account before restoring URL filters: the ADVANCED_SEARCH flag
|
const parsedFilters = parseURLParams(
|
||||||
// derives from account.features (loaded async), and reading it too early strips
|
route.query,
|
||||||
// the filter params from the URL. `immediate` covers the already-loaded case.
|
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
|
||||||
watch(
|
);
|
||||||
() => currentAccount.value?.id,
|
filters.value = parsedFilters;
|
||||||
id => {
|
|
||||||
if (!id) return;
|
// Auto-execute search if query parameter exists
|
||||||
filters.value = parseURLParams(
|
if (route.query.q) {
|
||||||
route.query,
|
onSearch(route.query.q);
|
||||||
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
|
}
|
||||||
);
|
});
|
||||||
if (route.query.q) {
|
|
||||||
onSearch(route.query.q);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
query.value = '';
|
query.value = '';
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { agentsList } = useAgentsList(true, true);
|
const { agentsList } = useAgentsList();
|
||||||
return {
|
return {
|
||||||
agentsList,
|
agentsList,
|
||||||
};
|
};
|
||||||
@@ -81,27 +81,18 @@ export default {
|
|||||||
},
|
},
|
||||||
assignedAgent: {
|
assignedAgent: {
|
||||||
get() {
|
get() {
|
||||||
const assignee = this.currentChat.meta.assignee;
|
return this.currentChat.meta.assignee;
|
||||||
if (!assignee) return assignee;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...assignee,
|
|
||||||
assignee_type: this.currentChat.meta.assignee_type || 'User',
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
set(agent) {
|
set(agent) {
|
||||||
const agentId = agent ? agent.id : null;
|
const agentId = agent ? agent.id : null;
|
||||||
const assigneeType = agent ? agent.assignee_type || 'User' : null;
|
|
||||||
this.$store.dispatch('setCurrentChatAssignee', {
|
this.$store.dispatch('setCurrentChatAssignee', {
|
||||||
conversationId: this.currentChat.id,
|
conversationId: this.currentChat.id,
|
||||||
assignee: agent,
|
assignee: agent,
|
||||||
assigneeType,
|
|
||||||
});
|
});
|
||||||
this.$store
|
this.$store
|
||||||
.dispatch('assignAgent', {
|
.dispatch('assignAgent', {
|
||||||
conversationId: this.currentChat.id,
|
conversationId: this.currentChat.id,
|
||||||
agentId,
|
agentId,
|
||||||
assigneeType,
|
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
|
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
|
||||||
@@ -167,12 +158,6 @@ export default {
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
|
||||||
this.$store.dispatch(
|
|
||||||
'inboxAssignableAgents/fetchAssignableOwners',
|
|
||||||
this.currentChat.inbox_id
|
|
||||||
);
|
|
||||||
},
|
|
||||||
methods: {
|
methods: {
|
||||||
onSelfAssign() {
|
onSelfAssign() {
|
||||||
const {
|
const {
|
||||||
@@ -189,7 +174,6 @@ export default {
|
|||||||
account_id,
|
account_id,
|
||||||
availability_status,
|
availability_status,
|
||||||
available_name,
|
available_name,
|
||||||
assignee_type: 'User',
|
|
||||||
email,
|
email,
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
@@ -199,14 +183,7 @@ export default {
|
|||||||
this.assignedAgent = selfAssign;
|
this.assignedAgent = selfAssign;
|
||||||
},
|
},
|
||||||
onClickAssignAgent(selectedItem) {
|
onClickAssignAgent(selectedItem) {
|
||||||
const currentAssigneeType = this.assignedAgent?.assignee_type || 'User';
|
if (this.assignedAgent && this.assignedAgent.id === selectedItem.id) {
|
||||||
const selectedAssigneeType = selectedItem.assignee_type || 'User';
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.assignedAgent &&
|
|
||||||
this.assignedAgent.id === selectedItem.id &&
|
|
||||||
currentAssigneeType === selectedAssigneeType
|
|
||||||
) {
|
|
||||||
this.assignedAgent = null;
|
this.assignedAgent = null;
|
||||||
} else {
|
} else {
|
||||||
this.assignedAgent = selectedItem;
|
this.assignedAgent = selectedItem;
|
||||||
|
|||||||
+1
-14
@@ -1,17 +1,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, onMounted, watch } from 'vue';
|
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 { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||||
import allLocales from 'shared/constants/locales.js';
|
import allLocales from 'shared/constants/locales.js';
|
||||||
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
|
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
|
||||||
import ArticlesPage from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue';
|
import ArticlesPage from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
|
||||||
const pageNumber = ref(1);
|
const pageNumber = ref(1);
|
||||||
const searchQuery = ref(route.query.search || '');
|
|
||||||
|
|
||||||
const allArticles = useMapGetter('articles/allArticles');
|
const allArticles = useMapGetter('articles/allArticles');
|
||||||
const articlesSortedByPosition = useMapGetter(
|
const articlesSortedByPosition = useMapGetter(
|
||||||
@@ -76,7 +74,6 @@ const fetchArticles = ({ pageNumber: pageNumberParam } = {}) => {
|
|||||||
status: status.value,
|
status: status.value,
|
||||||
authorId: author.value,
|
authorId: author.value,
|
||||||
categorySlug: selectedCategorySlug.value,
|
categorySlug: selectedCategorySlug.value,
|
||||||
query: searchQuery.value || undefined,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,15 +81,6 @@ const onPageChange = pageNumberParam => {
|
|||||||
fetchArticles({ pageNumber: 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 => {
|
const fetchPortalAndItsCategories = async locale => {
|
||||||
await store.dispatch('portals/index');
|
await store.dispatch('portals/index');
|
||||||
const selectedPortalParam = {
|
const selectedPortalParam = {
|
||||||
@@ -130,7 +118,6 @@ watch(
|
|||||||
:portal-meta="portalMeta"
|
:portal-meta="portalMeta"
|
||||||
:is-category-articles="isCategoryArticles"
|
:is-category-articles="isCategoryArticles"
|
||||||
@page-change="onPageChange"
|
@page-change="onPageChange"
|
||||||
@search="onSearch"
|
|
||||||
@fetch-portal="fetchPortalAndItsCategories"
|
@fetch-portal="fetchPortalAndItsCategories"
|
||||||
@refresh-articles="fetchArticles"
|
@refresh-articles="fetchArticles"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,12 +3,8 @@ import { ref, onMounted } from 'vue';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import tiktokClient from 'dashboard/api/channel/tiktokClient';
|
import tiktokClient from 'dashboard/api/channel/tiktokClient';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
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 { t } = useI18n();
|
||||||
const { isOnChatwootCloud } = useAccount();
|
|
||||||
|
|
||||||
const hasError = ref(false);
|
const hasError = ref(false);
|
||||||
const errorStateMessage = ref('');
|
const errorStateMessage = ref('');
|
||||||
@@ -60,37 +56,23 @@ const requestAuthorization = async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
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">
|
||||||
<h6 class="text-2xl font-medium">
|
{{ $t('INBOX_MGMT.ADD.TIKTOK.CONNECT_YOUR_TIKTOK_PROFILE') }}
|
||||||
{{ $t('INBOX_MGMT.ADD.TIKTOK.CONNECT_YOUR_TIKTOK_PROFILE') }}
|
</h6>
|
||||||
</h6>
|
<p class="py-6 text-sm text-n-slate-11">
|
||||||
<p class="max-w-xl py-6 text-sm text-n-slate-11">
|
{{ $t('INBOX_MGMT.ADD.TIKTOK.HELP') }}
|
||||||
{{ $t('INBOX_MGMT.ADD.TIKTOK.HELP') }}
|
</p>
|
||||||
</p>
|
<Button
|
||||||
|
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#00f2ea] via-[#ff0050] to-[#000000]"
|
||||||
<Button
|
lg
|
||||||
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#00f2ea] via-[#ff0050] to-[#000000]"
|
icon="i-ri-tiktok-line"
|
||||||
lg
|
:disabled="isRequestingAuthorization"
|
||||||
icon="i-ri-tiktok-line"
|
:is-loading="isRequestingAuthorization"
|
||||||
:disabled="isRequestingAuthorization"
|
:label="$t('INBOX_MGMT.ADD.TIKTOK.CONTINUE_WITH_TIKTOK')"
|
||||||
:is-loading="isRequestingAuthorization"
|
@click="requestAuthorization()"
|
||||||
: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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -208,31 +208,23 @@ const actions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
assignAgent: async (
|
assignAgent: async ({ dispatch }, { conversationId, agentId }) => {
|
||||||
{ dispatch },
|
|
||||||
{ conversationId, agentId, assigneeType }
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
const response = await ConversationApi.assignAgent({
|
const response = await ConversationApi.assignAgent({
|
||||||
conversationId,
|
conversationId,
|
||||||
agentId,
|
agentId,
|
||||||
assigneeType,
|
|
||||||
});
|
});
|
||||||
dispatch('setCurrentChatAssignee', {
|
dispatch('setCurrentChatAssignee', {
|
||||||
conversationId,
|
conversationId,
|
||||||
assignee: response.data,
|
assignee: response.data,
|
||||||
assigneeType,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle error
|
// Handle error
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setCurrentChatAssignee(
|
setCurrentChatAssignee({ commit }, { conversationId, assignee }) {
|
||||||
{ commit },
|
commit(types.ASSIGN_AGENT, { conversationId, assignee });
|
||||||
{ conversationId, assignee, assigneeType }
|
|
||||||
) {
|
|
||||||
commit(types.ASSIGN_AGENT, { conversationId, assignee, assigneeType });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
|
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);
|
const chat = getConversationById(_state)(conversationId);
|
||||||
if (chat) {
|
if (chat) {
|
||||||
chat.meta.assignee = assignee;
|
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 = {
|
export const actions = {
|
||||||
index: async (
|
index: async (
|
||||||
{ commit },
|
{ commit },
|
||||||
{ pageNumber, portalSlug, locale, status, authorId, categorySlug, query }
|
{ pageNumber, portalSlug, locale, status, authorId, categorySlug }
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
commit(types.SET_UI_FLAG, { isFetching: true });
|
commit(types.SET_UI_FLAG, { isFetching: true });
|
||||||
@@ -18,7 +18,6 @@ export const actions = {
|
|||||||
status,
|
status,
|
||||||
authorId,
|
authorId,
|
||||||
categorySlug,
|
categorySlug,
|
||||||
query,
|
|
||||||
});
|
});
|
||||||
const payload = camelcaseKeys(data.payload);
|
const payload = camelcaseKeys(data.payload);
|
||||||
const meta = camelcaseKeys(data.meta);
|
const meta = camelcaseKeys(data.meta);
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import AssignableAgentsAPI from '../../api/assignableAgents';
|
import AssignableAgentsAPI from '../../api/assignableAgents';
|
||||||
import InboxesAPI from '../../api/inboxes';
|
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
records: {},
|
records: {},
|
||||||
ownerRecords: {},
|
|
||||||
uiFlags: {
|
uiFlags: {
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
},
|
},
|
||||||
@@ -12,7 +10,6 @@ const state = {
|
|||||||
export const types = {
|
export const types = {
|
||||||
SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG: 'SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG',
|
SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG: 'SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG',
|
||||||
SET_INBOX_ASSIGNABLE_AGENTS: 'SET_INBOX_ASSIGNABLE_AGENTS',
|
SET_INBOX_ASSIGNABLE_AGENTS: 'SET_INBOX_ASSIGNABLE_AGENTS',
|
||||||
SET_INBOX_ASSIGNABLE_OWNERS: 'SET_INBOX_ASSIGNABLE_OWNERS',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getters = {
|
export const getters = {
|
||||||
@@ -21,7 +18,6 @@ export const getters = {
|
|||||||
const verifiedAgents = allAgents.filter(record => record.confirmed);
|
const verifiedAgents = allAgents.filter(record => record.confirmed);
|
||||||
return verifiedAgents;
|
return verifiedAgents;
|
||||||
},
|
},
|
||||||
getAssignableOwners: $state => inboxId => $state.ownerRecords[inboxId] || [],
|
|
||||||
getUIFlags($state) {
|
getUIFlags($state) {
|
||||||
return $state.uiFlags;
|
return $state.uiFlags;
|
||||||
},
|
},
|
||||||
@@ -44,15 +40,6 @@ export const actions = {
|
|||||||
commit(types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: false });
|
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 = {
|
export const mutations = {
|
||||||
@@ -68,12 +55,6 @@ export const mutations = {
|
|||||||
[inboxId]: members,
|
[inboxId]: members,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[types.SET_INBOX_ASSIGNABLE_OWNERS]: ($state, { inboxId, owners }) => {
|
|
||||||
$state.ownerRecords = {
|
|
||||||
...$state.ownerRecords,
|
|
||||||
[inboxId]: owners,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -357,12 +357,11 @@ describe('#actions', () => {
|
|||||||
});
|
});
|
||||||
await actions.assignAgent(
|
await actions.assignAgent(
|
||||||
{ dispatch },
|
{ dispatch },
|
||||||
{ conversationId: 1, agentId: 1, assigneeType: 'AgentBot' }
|
{ conversationId: 1, agentId: 1 }
|
||||||
);
|
);
|
||||||
expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', {
|
expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', {
|
||||||
conversationId: 1,
|
conversationId: 1,
|
||||||
assignee: { id: 1, name: 'User' },
|
assignee: { id: 1, name: 'User' },
|
||||||
assigneeType: 'AgentBot',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -372,7 +371,6 @@ describe('#actions', () => {
|
|||||||
const payload = {
|
const payload = {
|
||||||
conversationId: 1,
|
conversationId: 1,
|
||||||
assignee: { id: 1, name: 'User' },
|
assignee: { id: 1, name: 'User' },
|
||||||
assigneeType: 'AgentBot',
|
|
||||||
};
|
};
|
||||||
await actions.setCurrentChatAssignee({ commit }, payload);
|
await actions.setCurrentChatAssignee({ commit }, payload);
|
||||||
expect(commit).toHaveBeenCalledTimes(1);
|
expect(commit).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -699,11 +699,11 @@ describe('#mutations', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('#ASSIGN_AGENT', () => {
|
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 assignee = { id: 1, name: 'Agent' };
|
||||||
const state = {
|
const state = {
|
||||||
allConversations: [
|
allConversations: [
|
||||||
{ id: 1, meta: {}, status: 'open' },
|
{ id: 1, meta: {} },
|
||||||
{ id: 2, meta: {} },
|
{ id: 2, meta: {} },
|
||||||
],
|
],
|
||||||
selectedChatId: 2,
|
selectedChatId: 2,
|
||||||
@@ -712,28 +712,10 @@ describe('#mutations', () => {
|
|||||||
mutations[types.ASSIGN_AGENT](state, {
|
mutations[types.ASSIGN_AGENT](state, {
|
||||||
conversationId: 1,
|
conversationId: 1,
|
||||||
assignee,
|
assignee,
|
||||||
assigneeType: 'AgentBot',
|
|
||||||
});
|
});
|
||||||
expect(state.allConversations[0].meta.assignee).toEqual(assignee);
|
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();
|
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', () => {
|
describe('#ASSIGN_PRIORITY', () => {
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import InboxesAPI from 'dashboard/api/inboxes';
|
|
||||||
import { actions, types } from '../../inboxAssignableAgents';
|
import { actions, types } from '../../inboxAssignableAgents';
|
||||||
import agentsData from './fixtures';
|
import agentsData from './fixtures';
|
||||||
|
|
||||||
const commit = vi.fn();
|
const commit = vi.fn();
|
||||||
global.axios = axios;
|
global.axios = axios;
|
||||||
vi.mock('axios');
|
vi.mock('axios');
|
||||||
vi.mock('dashboard/api/inboxes');
|
|
||||||
|
|
||||||
describe('#actions', () => {
|
describe('#actions', () => {
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#fetch', () => {
|
describe('#fetch', () => {
|
||||||
it('sends correct actions if API is success', async () => {
|
it('sends correct actions if API is success', async () => {
|
||||||
axios.get.mockResolvedValue({
|
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 });
|
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();
|
this.$refs.searchbar.focus();
|
||||||
},
|
},
|
||||||
isActive(option) {
|
isActive(option) {
|
||||||
return this.selectedItems.some(item => {
|
return this.selectedItems.some(item => item && option.id === item.id);
|
||||||
if (!item || option.id !== item.id) return false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
(option.assignee_type || 'User') === (item.assignee_type || 'User')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -94,10 +88,7 @@ export default {
|
|||||||
<div class="flex items-start justify-start flex-auto overflow-auto mt-2">
|
<div class="flex items-start justify-start flex-auto overflow-auto mt-2">
|
||||||
<div class="w-full max-h-[10rem]">
|
<div class="w-full max-h-[10rem]">
|
||||||
<WootDropdownMenu>
|
<WootDropdownMenu>
|
||||||
<WootDropdownItem
|
<WootDropdownItem v-for="option in filteredOptions" :key="option.id">
|
||||||
v-for="option in filteredOptions"
|
|
||||||
:key="`${option.assignee_type || 'User'}-${option.id}`"
|
|
||||||
>
|
|
||||||
<NextButton
|
<NextButton
|
||||||
slate
|
slate
|
||||||
:variant="isActive(option) ? 'faded' : 'ghost'"
|
:variant="isActive(option) ? 'faded' : 'ghost'"
|
||||||
|
|||||||
@@ -30,10 +30,6 @@ class InboxPolicy < ApplicationPolicy
|
|||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
def assignable_owners?
|
|
||||||
true
|
|
||||||
end
|
|
||||||
|
|
||||||
def agent_bot?
|
def agent_bot?
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ class Conversations::AssignmentService
|
|||||||
def assign_agent
|
def assign_agent
|
||||||
conversation.assignee = assignee
|
conversation.assignee = assignee
|
||||||
conversation.assignee_agent_bot = nil
|
conversation.assignee_agent_bot = nil
|
||||||
conversation.status = :open if assignee
|
|
||||||
conversation.save!
|
conversation.save!
|
||||||
assignee
|
assignee
|
||||||
end
|
end
|
||||||
@@ -26,7 +25,6 @@ class Conversations::AssignmentService
|
|||||||
|
|
||||||
conversation.assignee = nil
|
conversation.assignee = nil
|
||||||
conversation.assignee_agent_bot = agent_bot
|
conversation.assignee_agent_bot = agent_bot
|
||||||
conversation.status = :pending
|
|
||||||
conversation.save!
|
conversation.save!
|
||||||
agent_bot
|
agent_bot
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -203,15 +203,6 @@ class Rack::Attack
|
|||||||
match_data[:account_id] if match_data.present?
|
match_data[:account_id] if match_data.present?
|
||||||
end
|
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 ##
|
## Prevent Abuse of attachment upload APIs ##
|
||||||
throttle('/api/v1/accounts/:account_id/upload', limit: 60, period: 1.hour) do |req|
|
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)
|
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/upload}.match(req.path)
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ Rails.application.routes.draw do
|
|||||||
resources :scenarios
|
resources :scenarios
|
||||||
end
|
end
|
||||||
resources :assistant_responses
|
resources :assistant_responses
|
||||||
resources :message_reports, only: [:create]
|
|
||||||
resources :bulk_actions, only: [:create]
|
resources :bulk_actions, only: [:create]
|
||||||
resources :copilot_threads, only: [:index, :create] do
|
resources :copilot_threads, only: [:index, :create] do
|
||||||
resources :copilot_messages, only: [:index, :create]
|
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 :custom_filters, only: [:index, :show, :create, :update, :destroy]
|
||||||
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
|
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
|
||||||
get :assignable_agents, on: :member
|
get :assignable_agents, on: :member
|
||||||
get :assignable_owners, on: :member
|
|
||||||
get :campaigns, on: :member
|
get :campaigns, on: :member
|
||||||
get :agent_bot, on: :member
|
get :agent_bot, on: :member
|
||||||
post :set_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.
|
# 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
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
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"
|
t.index ["inbox_id"], name: "index_captain_inboxes_on_inbox_id"
|
||||||
end
|
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|
|
create_table "captain_scenarios", force: :cascade do |t|
|
||||||
t.string "title"
|
t.string "title"
|
||||||
t.text "description"
|
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
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
Companies::DeleteJob.perform_later(company_id: @company.id)
|
@company.destroy!
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,6 @@
|
|||||||
module Enterprise::Api::V1::AccountsSettings
|
module Enterprise::Api::V1::AccountsSettings
|
||||||
def create
|
|
||||||
super
|
|
||||||
record_marketing_attribution
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
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
|
def permitted_settings_attributes
|
||||||
super + [{ conversation_required_attributes: [] }]
|
super + [{ conversation_required_attributes: [] }]
|
||||||
end
|
end
|
||||||
|
|||||||
-13
@@ -29,19 +29,6 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
|||||||
|
|
||||||
private
|
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
|
def handle_saml_auth
|
||||||
account_id = extract_saml_account_id
|
account_id = extract_saml_account_id
|
||||||
relay_state = saml_relay_state
|
relay_state = saml_relay_state
|
||||||
|
|||||||
@@ -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
|
has_many :contacts, dependent: :nullify
|
||||||
before_validation :prepare_jsonb_attributes
|
before_validation :prepare_jsonb_attributes
|
||||||
after_create_commit :fetch_favicon, if: -> { domain.present? }
|
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 :ordered_by_name, -> { order(:name) }
|
||||||
scope :search_by_name_or_domain, lambda { |query|
|
scope :search_by_name_or_domain, lambda { |query|
|
||||||
@@ -77,8 +76,4 @@ class Company < ApplicationRecord
|
|||||||
def fetch_favicon
|
def fetch_favicon
|
||||||
Avatar::AvatarFromFaviconJob.set(wait: 5.seconds).perform_later(self)
|
Avatar::AvatarFromFaviconJob.set(wait: 5.seconds).perform_later(self)
|
||||||
end
|
end
|
||||||
|
|
||||||
def enqueue_contact_company_name_sync
|
|
||||||
Companies::SyncContactNamesJob.perform_later(company_id: id)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,6 +3,5 @@ module Enterprise::Concerns::Message
|
|||||||
|
|
||||||
included do
|
included do
|
||||||
has_one :call, dependent: :nullify
|
has_one :call, dependent: :nullify
|
||||||
has_many :message_reports, class_name: 'Captain::MessageReport', dependent: :destroy_async
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
|
|||||||
channel_instagram
|
channel_instagram
|
||||||
channel_tiktok
|
channel_tiktok
|
||||||
captain_integration
|
captain_integration
|
||||||
captain_document_auto_sync
|
|
||||||
advanced_search_indexing
|
advanced_search_indexing
|
||||||
advanced_search
|
advanced_search
|
||||||
linear_integration
|
linear_integration
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ class Internal::Accounts::InternalAttributesService
|
|||||||
# List of keys that can be managed through this service
|
# List of keys that can be managed through this service
|
||||||
# TODO: Add account_notes field in future
|
# TODO: Add account_notes field in future
|
||||||
# This field can be used to store notes about account on Chatwoot cloud
|
# 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)
|
def initialize(account)
|
||||||
@account = 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
|
- sla
|
||||||
- custom_roles
|
- custom_roles
|
||||||
- captain_integration
|
- captain_integration
|
||||||
- captain_document_auto_sync
|
|
||||||
- csat_review_notes
|
- csat_review_notes
|
||||||
- conversation_required_attributes
|
- conversation_required_attributes
|
||||||
|
|||||||
+1
-1
@@ -69,7 +69,7 @@
|
|||||||
"countries-and-timezones": "^3.6.0",
|
"countries-and-timezones": "^3.6.0",
|
||||||
"date-fns": "2.21.1",
|
"date-fns": "2.21.1",
|
||||||
"date-fns-tz": "^1.3.3",
|
"date-fns-tz": "^1.3.3",
|
||||||
"dompurify": "3.4.11",
|
"dompurify": "3.4.0",
|
||||||
"flag-icons": "^7.2.3",
|
"flag-icons": "^7.2.3",
|
||||||
"floating-vue": "^5.2.2",
|
"floating-vue": "^5.2.2",
|
||||||
"highlight.js": "^11.10.0",
|
"highlight.js": "^11.10.0",
|
||||||
|
|||||||
Generated
+7
-15
@@ -130,8 +130,8 @@ importers:
|
|||||||
specifier: ^1.3.3
|
specifier: ^1.3.3
|
||||||
version: 1.3.8(date-fns@2.21.1)
|
version: 1.3.8(date-fns@2.21.1)
|
||||||
dompurify:
|
dompurify:
|
||||||
specifier: 3.4.11
|
specifier: 3.4.0
|
||||||
version: 3.4.11
|
version: 3.4.0
|
||||||
flag-icons:
|
flag-icons:
|
||||||
specifier: ^7.2.3
|
specifier: ^7.2.3
|
||||||
version: 7.2.3
|
version: 7.2.3
|
||||||
@@ -1635,11 +1635,6 @@ packages:
|
|||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
acorn@8.17.0:
|
|
||||||
resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
|
|
||||||
engines: {node: '>=0.4.0'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
activestorage@5.2.8:
|
activestorage@5.2.8:
|
||||||
resolution: {integrity: sha512-bueFOxBGIAUdrjbLyBZ8Xlkcecy8vr05sCk5VV37BbFi+RehPoEjfvKX3iYYPY7RFVhl+L43W9/ZbN3xNNLPtQ==}
|
resolution: {integrity: sha512-bueFOxBGIAUdrjbLyBZ8Xlkcecy8vr05sCk5VV37BbFi+RehPoEjfvKX3iYYPY7RFVhl+L43W9/ZbN3xNNLPtQ==}
|
||||||
|
|
||||||
@@ -2223,8 +2218,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
dompurify@3.4.11:
|
dompurify@3.4.0:
|
||||||
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
|
resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==}
|
||||||
|
|
||||||
domutils@3.1.0:
|
domutils@3.1.0:
|
||||||
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
|
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
|
||||||
@@ -6385,9 +6380,6 @@ snapshots:
|
|||||||
|
|
||||||
acorn@8.16.0: {}
|
acorn@8.16.0: {}
|
||||||
|
|
||||||
acorn@8.17.0:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
activestorage@5.2.8:
|
activestorage@5.2.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
spark-md5: 3.0.2
|
spark-md5: 3.0.2
|
||||||
@@ -7007,7 +6999,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
|
|
||||||
dompurify@3.4.11:
|
dompurify@3.4.0:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/trusted-types': 2.0.7
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
||||||
@@ -9519,7 +9511,7 @@ snapshots:
|
|||||||
terser@5.33.0:
|
terser@5.33.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/source-map': 0.3.11
|
'@jridgewell/source-map': 0.3.11
|
||||||
acorn: 8.17.0
|
acorn: 8.16.0
|
||||||
commander: 2.20.3
|
commander: 2.20.3
|
||||||
source-map-support: 0.5.21
|
source-map-support: 0.5.21
|
||||||
optional: true
|
optional: true
|
||||||
@@ -9906,7 +9898,7 @@ snapshots:
|
|||||||
|
|
||||||
vue-dompurify-html@5.3.0(vue@3.5.12(typescript@5.6.2)):
|
vue-dompurify-html@5.3.0(vue@3.5.12(typescript@5.6.2)):
|
||||||
dependencies:
|
dependencies:
|
||||||
dompurify: 3.4.11
|
dompurify: 3.4.0
|
||||||
vue: 3.5.12(typescript@5.6.2)
|
vue: 3.5.12(typescript@5.6.2)
|
||||||
|
|
||||||
vue-eslint-parser@9.4.3(eslint@8.57.0):
|
vue-eslint-parser@9.4.3(eslint@8.57.0):
|
||||||
|
|||||||
@@ -140,66 +140,6 @@ describe Messages::Facebook::MessageBuilder do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when message contains a sticker attachment' do
|
|
||||||
let(:sticker_url) { 'https://scontent.xx.fbcdn.net/sticker.png' }
|
|
||||||
let(:sticker_message_object) do
|
|
||||||
{
|
|
||||||
messaging: {
|
|
||||||
sender: { id: '3383290475046708' },
|
|
||||||
recipient: { id: facebook_channel.page_id },
|
|
||||||
timestamp: 1_772_452_164_516,
|
|
||||||
message: {
|
|
||||||
mid: 'm_sticker_test',
|
|
||||||
attachments: [
|
|
||||||
{ type: 'image', payload: { url: sticker_url } },
|
|
||||||
{ type: 'sticker', payload: { url: sticker_url } }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
let(:sticker_message) { Integrations::Facebook::MessageParser.new(sticker_message_object) }
|
|
||||||
|
|
||||||
before do
|
|
||||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
|
||||||
allow(fb_object).to receive(:get_object).and_return(
|
|
||||||
{ first_name: 'Jane', last_name: 'Dae', profile_pic: 'https://chatwoot-assets.local/sample.png' }.with_indifferent_access
|
|
||||||
)
|
|
||||||
stub_request(:get, sticker_url).to_return(status: 200, body: 'sticker_data', headers: { 'Content-Type' => 'image/png' })
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'stores the sticker as a single image attachment' do
|
|
||||||
described_class.new(sticker_message, facebook_channel.inbox).perform
|
|
||||||
|
|
||||||
message = facebook_channel.inbox.messages.find_by(source_id: 'm_sticker_test')
|
|
||||||
expect(message.attachments.count).to eq(1)
|
|
||||||
expect(message.attachments.first.file_type).to eq('image')
|
|
||||||
expect(message.attachments.first.external_url).to eq(sticker_url)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'keeps duplicate non-sticker attachments that share a URL' do
|
|
||||||
duplicate_image_object = {
|
|
||||||
messaging: {
|
|
||||||
sender: { id: '3383290475046708' },
|
|
||||||
recipient: { id: facebook_channel.page_id },
|
|
||||||
message: {
|
|
||||||
mid: 'm_duplicate_image_test',
|
|
||||||
attachments: [
|
|
||||||
{ type: 'image', payload: { url: sticker_url } },
|
|
||||||
{ type: 'image', payload: { url: sticker_url } }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.to_json
|
|
||||||
duplicate_image_message = Integrations::Facebook::MessageParser.new(duplicate_image_object)
|
|
||||||
|
|
||||||
described_class.new(duplicate_image_message, facebook_channel.inbox).perform
|
|
||||||
|
|
||||||
message = facebook_channel.inbox.messages.find_by(source_id: 'm_duplicate_image_test')
|
|
||||||
expect(message.attachments.count).to eq(2)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
source_id: 'm_fallback_test',
|
source_id: 'm_fallback_test',
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ RSpec.describe 'Conversation Assignment API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'assigns a user to the conversation' do
|
it 'assigns a user to the conversation' do
|
||||||
conversation.update!(status: :pending)
|
|
||||||
params = { assignee_id: agent.id }
|
params = { assignee_id: agent.id }
|
||||||
|
|
||||||
post api_v1_account_conversation_assignments_url(account_id: account.id, conversation_id: conversation.display_id),
|
post api_v1_account_conversation_assignments_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||||
@@ -53,13 +52,10 @@ RSpec.describe 'Conversation Assignment API', type: :request do
|
|||||||
as: :json
|
as: :json
|
||||||
|
|
||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
conversation.reload
|
expect(conversation.reload.assignee).to eq(agent)
|
||||||
expect(conversation.assignee).to eq(agent)
|
|
||||||
expect(conversation.status).to eq('open')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'assigns an agent bot to the conversation' do
|
it 'assigns an agent bot to the conversation' do
|
||||||
conversation.update!(status: :open)
|
|
||||||
params = { assignee_id: agent_bot.id, assignee_type: 'AgentBot' }
|
params = { assignee_id: agent_bot.id, assignee_type: 'AgentBot' }
|
||||||
|
|
||||||
expect(Conversations::AssignmentService).to receive(:new)
|
expect(Conversations::AssignmentService).to receive(:new)
|
||||||
@@ -76,7 +72,6 @@ RSpec.describe 'Conversation Assignment API', type: :request do
|
|||||||
conversation.reload
|
conversation.reload
|
||||||
expect(conversation.assignee_agent_bot).to eq(agent_bot)
|
expect(conversation.assignee_agent_bot).to eq(agent_bot)
|
||||||
expect(conversation.assignee).to be_nil
|
expect(conversation.assignee).to be_nil
|
||||||
expect(conversation.status).to eq('pending')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'assigns a team to the conversation' do
|
it 'assigns a team to the conversation' do
|
||||||
|
|||||||
@@ -251,28 +251,6 @@ RSpec.describe 'Inboxes API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/assignable_owners' do
|
|
||||||
let(:inbox) { create(:inbox, account: account) }
|
|
||||||
let!(:account_bot) { create(:agent_bot, account: account, name: 'Account bot') }
|
|
||||||
let!(:global_bot) { create(:agent_bot, account: nil, name: 'Global bot') }
|
|
||||||
|
|
||||||
before do
|
|
||||||
create(:inbox_member, user: agent, inbox: inbox)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns assignable agents and accessible agent bots' do
|
|
||||||
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignable_owners",
|
|
||||||
headers: admin.create_new_auth_token,
|
|
||||||
as: :json
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:success)
|
|
||||||
|
|
||||||
response_data = response.parsed_body['payload']
|
|
||||||
expect(response_data.pluck('assignee_type')).to include('User', 'AgentBot')
|
|
||||||
expect(response_data.pluck('name')).to include(agent.name, admin.name, account_bot.name, global_bot.name)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/campaigns' do
|
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/campaigns' do
|
||||||
let(:inbox) { create(:inbox, account: account) }
|
let(:inbox) { create(:inbox, account: account) }
|
||||||
|
|
||||||
|
|||||||
-117
@@ -1,117 +0,0 @@
|
|||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe 'Api::V1::Accounts::Captain::MessageReports', type: :request do
|
|
||||||
let(:account) { create(:account) }
|
|
||||||
let(:agent) { create(:user, account: account, role: :agent) }
|
|
||||||
let(:inbox) { create(:inbox, account: account) }
|
|
||||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
|
||||||
let(:assistant) { create(:captain_assistant, account: account) }
|
|
||||||
let(:message) do
|
|
||||||
create(:message, account: account, conversation: conversation, message_type: :outgoing, sender: assistant)
|
|
||||||
end
|
|
||||||
|
|
||||||
before { create(:inbox_member, user: agent, inbox: inbox) }
|
|
||||||
|
|
||||||
def json_response
|
|
||||||
JSON.parse(response.body, symbolize_names: true)
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'POST /api/v1/accounts/:account_id/captain/message_reports' do
|
|
||||||
let(:valid_params) do
|
|
||||||
{
|
|
||||||
message_id: message.id,
|
|
||||||
report_reason: 'incorrect_information',
|
|
||||||
description: 'The generated citation is wrong.'
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when it is an unauthenticated user' do
|
|
||||||
it 'returns unauthorized' do
|
|
||||||
post "/api/v1/accounts/#{account.id}/captain/message_reports", params: valid_params, as: :json
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unauthorized)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when the installation is not on Chatwoot cloud' do
|
|
||||||
before { InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'self_hosted') }
|
|
||||||
|
|
||||||
it 'returns not found' do
|
|
||||||
post "/api/v1/accounts/#{account.id}/captain/message_reports",
|
|
||||||
params: valid_params, headers: agent.create_new_auth_token, as: :json
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:not_found)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not create a report' do
|
|
||||||
expect do
|
|
||||||
post "/api/v1/accounts/#{account.id}/captain/message_reports",
|
|
||||||
params: valid_params, headers: agent.create_new_auth_token, as: :json
|
|
||||||
end.not_to change(Captain::MessageReport, :count)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when on Chatwoot cloud' do
|
|
||||||
before { InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'cloud') }
|
|
||||||
|
|
||||||
it 'creates a message report for the reporting agent' do
|
|
||||||
expect do
|
|
||||||
post "/api/v1/accounts/#{account.id}/captain/message_reports",
|
|
||||||
params: valid_params, headers: agent.create_new_auth_token, as: :json
|
|
||||||
end.to change(Captain::MessageReport, :count).by(1)
|
|
||||||
|
|
||||||
report = Captain::MessageReport.last
|
|
||||||
aggregate_failures do
|
|
||||||
expect(response).to have_http_status(:success)
|
|
||||||
expect(report.message_id).to eq(message.id)
|
|
||||||
expect(report.conversation_id).to eq(conversation.id)
|
|
||||||
expect(report.user_id).to eq(agent.id)
|
|
||||||
expect(report.report_reason).to eq('incorrect_information')
|
|
||||||
expect(report.description).to eq('The generated citation is wrong.')
|
|
||||||
expect(json_response[:report_reason]).to eq('incorrect_information')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns not found when the message does not belong to the account' do
|
|
||||||
other_message = create(:message)
|
|
||||||
|
|
||||||
post "/api/v1/accounts/#{account.id}/captain/message_reports",
|
|
||||||
params: valid_params.merge(message_id: other_message.id),
|
|
||||||
headers: agent.create_new_auth_token, as: :json
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:not_found)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns unprocessable entity for an invalid report reason' do
|
|
||||||
post "/api/v1/accounts/#{account.id}/captain/message_reports",
|
|
||||||
params: valid_params.merge(report_reason: 'invalid_reason'),
|
|
||||||
headers: agent.create_new_auth_token, as: :json
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not allow an agent without access to the conversation to report' do
|
|
||||||
other_agent = create(:user, account: account, role: :agent)
|
|
||||||
|
|
||||||
expect do
|
|
||||||
post "/api/v1/accounts/#{account.id}/captain/message_reports",
|
|
||||||
params: valid_params, headers: other_agent.create_new_auth_token, as: :json
|
|
||||||
end.not_to change(Captain::MessageReport, :count)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unauthorized)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'rejects messages that were not sent by a Captain assistant' do
|
|
||||||
non_captain_message = create(:message, account: account, conversation: conversation)
|
|
||||||
|
|
||||||
expect do
|
|
||||||
post "/api/v1/accounts/#{account.id}/captain/message_reports",
|
|
||||||
params: valid_params.merge(message_id: non_captain_message.id),
|
|
||||||
headers: agent.create_new_auth_token, as: :json
|
|
||||||
end.not_to change(Captain::MessageReport, :count)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -385,13 +385,13 @@ RSpec.describe 'Companies API', type: :request do
|
|||||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||||
let(:company) { create(:company, account: account) }
|
let(:company) { create(:company, account: account) }
|
||||||
|
|
||||||
it 'enqueues company deletion' do
|
it 'deletes the company' do
|
||||||
|
company
|
||||||
expect do
|
expect do
|
||||||
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
|
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
|
||||||
headers: admin.create_new_auth_token,
|
headers: admin.create_new_auth_token,
|
||||||
as: :json
|
as: :json
|
||||||
end.to have_enqueued_job(Companies::DeleteJob).with(company_id: company.id)
|
end.to change(Company, :count).by(-1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
require 'rails_helper'
|
|
||||||
require 'base64'
|
|
||||||
|
|
||||||
RSpec.describe 'Enterprise Accounts API', type: :request do
|
|
||||||
describe 'POST /api/v1/accounts' do
|
|
||||||
let(:email) { Faker::Internet.email }
|
|
||||||
let(:user_full_name) { Faker::Name.name_with_middle }
|
|
||||||
let(:first_touch_cookie) { Base64.urlsafe_encode64({ source: 'reddit', source_type: 'paid_social' }.to_json, padding: false) }
|
|
||||||
let(:last_touch_cookie) { Base64.urlsafe_encode64({ source: 'github', source_type: 'referral' }.to_json, padding: false) }
|
|
||||||
let(:attribution_cookie_header) do
|
|
||||||
{
|
|
||||||
'Cookie' => [
|
|
||||||
"#{Internal::Accounts::MarketingAttributionService::FIRST_TOUCH_COOKIE}=#{first_touch_cookie}",
|
|
||||||
"#{Internal::Accounts::MarketingAttributionService::LAST_TOUCH_COOKIE}=#{last_touch_cookie}"
|
|
||||||
].join('; ')
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
before do
|
|
||||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'records marketing attribution for unauthenticated signup requests' do
|
|
||||||
account_builder = double
|
|
||||||
account = create(:account)
|
|
||||||
user = create(:user, email: email, account: account, name: user_full_name)
|
|
||||||
|
|
||||||
allow(AccountBuilder).to receive(:new).and_return(account_builder)
|
|
||||||
allow(account_builder).to receive(:perform).and_return([user, account])
|
|
||||||
|
|
||||||
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
|
|
||||||
post api_v1_accounts_url,
|
|
||||||
params: {
|
|
||||||
account_name: 'test',
|
|
||||||
email: email,
|
|
||||||
user: nil,
|
|
||||||
locale: nil,
|
|
||||||
user_full_name: user_full_name,
|
|
||||||
password: 'Password1!'
|
|
||||||
},
|
|
||||||
headers: attribution_cookie_header,
|
|
||||||
as: :json
|
|
||||||
end
|
|
||||||
|
|
||||||
attribution = account.reload.internal_attributes['marketing_attribution']
|
|
||||||
expect(attribution['captured_from']).to eq('cookie')
|
|
||||||
expect(attribution['first_touch']).to include('source' => 'reddit', 'source_type' => 'paid_social')
|
|
||||||
expect(attribution['last_touch']).to include('source' => 'github', 'source_type' => 'referral')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not record marketing attribution for authenticated add-workspace requests' do
|
|
||||||
existing_user = create(:user, password: 'Password1!')
|
|
||||||
|
|
||||||
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
|
|
||||||
post api_v1_accounts_url,
|
|
||||||
params: { account_name: 'Second Account', email: existing_user.email,
|
|
||||||
user_full_name: existing_user.name, password: 'Password1!' },
|
|
||||||
headers: existing_user.create_new_auth_token.merge(attribution_cookie_header),
|
|
||||||
as: :json
|
|
||||||
end
|
|
||||||
|
|
||||||
account = Account.find(response.parsed_body.dig('data', 'account_id'))
|
|
||||||
expect(account.internal_attributes).not_to include('marketing_attribution')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
-53
@@ -1,53 +0,0 @@
|
|||||||
require 'rails_helper'
|
|
||||||
require 'base64'
|
|
||||||
|
|
||||||
RSpec.describe 'Enterprise Google OAuth attribution', type: :request do
|
|
||||||
let(:email_validation_service) { instance_double(Account::SignUpEmailValidationService) }
|
|
||||||
let(:email) { 'oauth-attribution@example.com' }
|
|
||||||
let(:account_builder) { double }
|
|
||||||
let(:account) { create(:account) }
|
|
||||||
let(:first_touch_cookie) { encoded_cookie('source' => 'reddit', 'source_type' => 'paid_social') }
|
|
||||||
let(:last_touch_cookie) { encoded_cookie('source' => 'github', 'source_type' => 'referral') }
|
|
||||||
|
|
||||||
before do
|
|
||||||
allow(ChatwootApp).to receive(:enterprise?).and_return(true)
|
|
||||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
|
||||||
allow(Account::SignUpEmailValidationService).to receive(:new).and_return(email_validation_service)
|
|
||||||
allow(email_validation_service).to receive(:perform).and_return(true)
|
|
||||||
allow(AccountBuilder).to receive(:new).and_return(account_builder)
|
|
||||||
allow(account_builder).to receive(:perform) do
|
|
||||||
[create(:user, email: email, account: account), account]
|
|
||||||
end
|
|
||||||
|
|
||||||
OmniAuth.config.test_mode = true
|
|
||||||
OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new(
|
|
||||||
provider: 'google',
|
|
||||||
uid: '123545',
|
|
||||||
info: {
|
|
||||||
name: 'OAuth Attribution',
|
|
||||||
email: email,
|
|
||||||
image: 'https://example.com/image.jpg'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'records marketing attribution for Google OAuth signups' do
|
|
||||||
cookies[Internal::Accounts::MarketingAttributionService::FIRST_TOUCH_COOKIE] = first_touch_cookie
|
|
||||||
cookies[Internal::Accounts::MarketingAttributionService::LAST_TOUCH_COOKIE] = last_touch_cookie
|
|
||||||
|
|
||||||
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', FRONTEND_URL: 'http://www.example.com' do
|
|
||||||
get '/omniauth/google_oauth2/callback'
|
|
||||||
follow_redirect!
|
|
||||||
end
|
|
||||||
|
|
||||||
attribution = account.reload.internal_attributes['marketing_attribution']
|
|
||||||
|
|
||||||
expect(attribution['captured_from']).to eq('cookie')
|
|
||||||
expect(attribution['first_touch']).to include('source' => 'reddit', 'source_type' => 'paid_social')
|
|
||||||
expect(attribution['last_touch']).to include('source' => 'github', 'source_type' => 'referral')
|
|
||||||
end
|
|
||||||
|
|
||||||
def encoded_cookie(payload)
|
|
||||||
Base64.urlsafe_encode64(payload.to_json, padding: false)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe Companies::DeleteJob, type: :job do
|
|
||||||
describe '#perform' do
|
|
||||||
it 'unlinks contacts, clears company names, and deletes the company' do
|
|
||||||
account = create(:account)
|
|
||||||
company = create(:company, account: account, name: 'Acme')
|
|
||||||
contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme', 'city' => 'Berlin' })
|
|
||||||
other_contact = create(:contact, account: account, additional_attributes: { 'company_name' => 'Acme' })
|
|
||||||
|
|
||||||
described_class.perform_now(company_id: company.id)
|
|
||||||
|
|
||||||
expect { company.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
|
||||||
expect(contact.reload.company_id).to be_nil
|
|
||||||
expect(contact.additional_attributes).to eq('city' => 'Berlin')
|
|
||||||
expect(other_contact.reload.additional_attributes).to eq('company_name' => 'Acme')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe Companies::SyncContactNamesJob, type: :job do
|
|
||||||
let(:account) { create(:account) }
|
|
||||||
let(:company) { create(:company, account: account, name: 'Acme') }
|
|
||||||
|
|
||||||
describe '#perform' do
|
|
||||||
it 'updates linked contact company names' do
|
|
||||||
contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme', 'city' => 'Berlin' })
|
|
||||||
|
|
||||||
company.update!(name: 'Acme Labs')
|
|
||||||
|
|
||||||
described_class.perform_now(company_id: company.id)
|
|
||||||
|
|
||||||
expect(contact.reload.additional_attributes).to eq('company_name' => 'Acme Labs', 'city' => 'Berlin')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'uses the current company name when a stale rename job runs' do
|
|
||||||
contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme' })
|
|
||||||
company.update!(name: 'Acme Labs')
|
|
||||||
|
|
||||||
described_class.perform_now(company_id: company.id)
|
|
||||||
|
|
||||||
expect(contact.reload.additional_attributes).to eq('company_name' => 'Acme Labs')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not save contacts while syncing the denormalized company name' do
|
|
||||||
contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme' })
|
|
||||||
original_updated_at = contact.reload.updated_at
|
|
||||||
|
|
||||||
company.update!(name: 'Acme Labs')
|
|
||||||
|
|
||||||
described_class.perform_now(company_id: company.id)
|
|
||||||
|
|
||||||
expect(contact.reload.updated_at).to eq(original_updated_at)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe Captain::MessageReport, type: :model do
|
|
||||||
describe 'associations' do
|
|
||||||
it { is_expected.to belong_to(:account) }
|
|
||||||
it { is_expected.to belong_to(:conversation) }
|
|
||||||
it { is_expected.to belong_to(:message) }
|
|
||||||
it { is_expected.to belong_to(:user) }
|
|
||||||
|
|
||||||
it 'resolves the conversation association to the top-level Conversation model' do
|
|
||||||
# `Captain::Conversation` exists as a job namespace, so without an explicit
|
|
||||||
# class_name the association would resolve to that module instead.
|
|
||||||
expect(described_class.reflect_on_association(:conversation).klass).to eq(Conversation)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'validations' do
|
|
||||||
it { is_expected.to validate_presence_of(:report_reason) }
|
|
||||||
it { is_expected.to validate_inclusion_of(:report_reason).in_array(described_class::REPORT_REASONS) }
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'callbacks' do
|
|
||||||
let(:account) { create(:account) }
|
|
||||||
let(:conversation) { create(:conversation, account: account) }
|
|
||||||
let(:message) { create(:message, account: account, conversation: conversation) }
|
|
||||||
|
|
||||||
it 'derives the account and conversation from the message' do
|
|
||||||
report = described_class.create!(message: message, user: create(:user, account: account), report_reason: 'other')
|
|
||||||
|
|
||||||
expect(report.account).to eq(account)
|
|
||||||
expect(report.conversation).to eq(conversation)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'factory' do
|
|
||||||
it 'creates a valid message report' do
|
|
||||||
expect(build(:captain_message_report)).to be_valid
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -46,15 +46,4 @@ RSpec.describe Company, type: :model do
|
|||||||
expect(company.reload.last_activity_at).to be_within(1.second).of(original_activity_at)
|
expect(company.reload.last_activity_at).to be_within(1.second).of(original_activity_at)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'contact company name sync' do
|
|
||||||
let(:account) { create(:account) }
|
|
||||||
let(:company) { create(:company, account: account, name: 'Acme') }
|
|
||||||
|
|
||||||
it 'enqueues contact company name sync when the company name changes' do
|
|
||||||
expect do
|
|
||||||
company.update!(name: 'Acme Labs')
|
|
||||||
end.to have_enqueued_job(Companies::SyncContactNamesJob).with(company_id: company.id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require 'rails_helper'
|
|
||||||
require 'base64'
|
|
||||||
|
|
||||||
RSpec.describe Internal::Accounts::MarketingAttributionService do
|
|
||||||
let(:account) { create(:account) }
|
|
||||||
let(:cookies) { {} }
|
|
||||||
|
|
||||||
before do
|
|
||||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'stores website attribution cookies on the account' do
|
|
||||||
cookies[described_class::FIRST_TOUCH_COOKIE] = encoded_cookie(
|
|
||||||
'source' => 'reddit',
|
|
||||||
'source_type' => 'paid_social',
|
|
||||||
'referrer' => 'https://reddit.com',
|
|
||||||
'referrer_path' => '/r/selfhosted/comments/123/chatwoot'
|
|
||||||
)
|
|
||||||
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
|
|
||||||
'source' => 'github',
|
|
||||||
'source_type' => 'referral'
|
|
||||||
)
|
|
||||||
|
|
||||||
described_class.new(account: account, cookies: cookies).perform
|
|
||||||
|
|
||||||
attribution = account.reload.internal_attributes['marketing_attribution']
|
|
||||||
expect(attribution['captured_from']).to eq('cookie')
|
|
||||||
expect(attribution['first_touch']['source']).to eq('reddit')
|
|
||||||
expect(attribution['first_touch']['referrer_path']).to eq('/r/selfhosted/comments/123/chatwoot')
|
|
||||||
expect(attribution['last_touch']['source']).to eq('github')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not store attribution outside Chatwoot Cloud' do
|
|
||||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
|
|
||||||
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie('source' => 'reddit')
|
|
||||||
|
|
||||||
described_class.new(account: account, cookies: cookies).perform
|
|
||||||
|
|
||||||
expect(account.reload.internal_attributes).not_to include('marketing_attribution')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'decodes base64url cookie values and preserves plus signs' do
|
|
||||||
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
|
|
||||||
'source' => 'google',
|
|
||||||
'utm_campaign' => 'C++ launch'
|
|
||||||
)
|
|
||||||
|
|
||||||
described_class.new(account: account, cookies: cookies).perform
|
|
||||||
|
|
||||||
attribution = account.reload.internal_attributes['marketing_attribution']
|
|
||||||
expect(attribution['last_touch']['utm_campaign']).to eq('C++ launch')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'preserves an existing touch when the matching cookie is absent' do
|
|
||||||
account.update!(
|
|
||||||
internal_attributes: {
|
|
||||||
'marketing_attribution' => {
|
|
||||||
'first_touch' => { 'source' => 'reddit' },
|
|
||||||
'last_touch' => { 'source' => 'github' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie('source' => 'google')
|
|
||||||
|
|
||||||
described_class.new(account: account, cookies: cookies).perform
|
|
||||||
|
|
||||||
attribution = account.reload.internal_attributes['marketing_attribution']
|
|
||||||
expect(attribution['first_touch']['source']).to eq('reddit')
|
|
||||||
expect(attribution['last_touch']['source']).to eq('google')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'preserves other internal attributes' do
|
|
||||||
account.update!(internal_attributes: { 'manually_managed_features' => ['inbound_emails'] })
|
|
||||||
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie('source' => 'google')
|
|
||||||
|
|
||||||
described_class.new(account: account, cookies: cookies).perform
|
|
||||||
|
|
||||||
expect(account.reload.internal_attributes['manually_managed_features']).to eq(['inbound_emails'])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'ignores parsed cookies that are not populated attribution objects' do
|
|
||||||
account.update!(
|
|
||||||
internal_attributes: {
|
|
||||||
'marketing_attribution' => {
|
|
||||||
'first_touch' => { 'source' => 'reddit' },
|
|
||||||
'last_touch' => { 'source' => 'github' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
cookies[described_class::FIRST_TOUCH_COOKIE] = {}.to_json
|
|
||||||
cookies[described_class::LAST_TOUCH_COOKIE] = [].to_json
|
|
||||||
|
|
||||||
described_class.new(account: account, cookies: cookies).perform
|
|
||||||
|
|
||||||
attribution = account.reload.internal_attributes['marketing_attribution']
|
|
||||||
expect(attribution['first_touch']['source']).to eq('reddit')
|
|
||||||
expect(attribution['last_touch']['source']).to eq('github')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'stores only allowlisted scalar attribution fields' do
|
|
||||||
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
|
|
||||||
'source' => 'google',
|
|
||||||
'source_type' => 'paid_search',
|
|
||||||
'utm_campaign' => 'spring',
|
|
||||||
'unknown_field' => 'ignore me',
|
|
||||||
'nested' => { 'value' => 'ignore me' },
|
|
||||||
'array' => ['ignore me']
|
|
||||||
)
|
|
||||||
|
|
||||||
described_class.new(account: account, cookies: cookies).perform
|
|
||||||
|
|
||||||
attribution = account.reload.internal_attributes['marketing_attribution']
|
|
||||||
expect(attribution['last_touch']).to eq(
|
|
||||||
'source' => 'google',
|
|
||||||
'source_type' => 'paid_search',
|
|
||||||
'utm_campaign' => 'spring'
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'truncates oversized attribution values' do
|
|
||||||
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
|
|
||||||
'source' => 'google',
|
|
||||||
'utm_campaign' => 'a' * 600
|
|
||||||
)
|
|
||||||
|
|
||||||
described_class.new(account: account, cookies: cookies).perform
|
|
||||||
|
|
||||||
attribution = account.reload.internal_attributes['marketing_attribution']
|
|
||||||
expect(attribution['last_touch']['utm_campaign'].length).to eq(described_class::FIELD_MAX_LENGTH)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'stores raw attribution values without escaping them' do
|
|
||||||
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
|
|
||||||
'source' => '<script>alert(1)</script>',
|
|
||||||
'utm_campaign' => 'launch & learn'
|
|
||||||
)
|
|
||||||
|
|
||||||
described_class.new(account: account, cookies: cookies).perform
|
|
||||||
|
|
||||||
attribution = account.reload.internal_attributes['marketing_attribution']
|
|
||||||
expect(attribution['last_touch']['source']).to eq('<script>alert(1)</script>')
|
|
||||||
expect(attribution['last_touch']['utm_campaign']).to eq('launch & learn')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'caps raw attribution values' do
|
|
||||||
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
|
|
||||||
'source' => 'google',
|
|
||||||
'utm_campaign' => '&' * 600
|
|
||||||
)
|
|
||||||
|
|
||||||
described_class.new(account: account, cookies: cookies).perform
|
|
||||||
|
|
||||||
attribution = account.reload.internal_attributes['marketing_attribution']
|
|
||||||
expect(attribution['last_touch']['utm_campaign'].length).to eq(described_class::FIELD_MAX_LENGTH)
|
|
||||||
end
|
|
||||||
|
|
||||||
def encoded_cookie(payload)
|
|
||||||
Base64.urlsafe_encode64(payload.to_json, padding: false)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
FactoryBot.define do
|
|
||||||
factory :captain_message_report, class: 'Captain::MessageReport' do
|
|
||||||
report_reason { 'incorrect_information' }
|
|
||||||
description { 'The generated citation is wrong.' }
|
|
||||||
association :message
|
|
||||||
association :user
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -23,17 +23,16 @@ describe Conversations::AssignmentService do
|
|||||||
|
|
||||||
context 'when assigning a user' do
|
context 'when assigning a user' do
|
||||||
before do
|
before do
|
||||||
conversation.update!(assignee_agent_bot: agent_bot, assignee: nil, status: :pending)
|
conversation.update!(assignee_agent_bot: agent_bot, assignee: nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'sets the agent, clears agent bot and opens the conversation' do
|
it 'sets the agent and clears agent bot' do
|
||||||
result = described_class.new(conversation: conversation, assignee_id: agent.id).perform
|
result = described_class.new(conversation: conversation, assignee_id: agent.id).perform
|
||||||
|
|
||||||
conversation.reload
|
conversation.reload
|
||||||
expect(result).to eq(agent)
|
expect(result).to eq(agent)
|
||||||
expect(conversation.assignee_id).to eq(agent.id)
|
expect(conversation.assignee_id).to eq(agent.id)
|
||||||
expect(conversation.assignee_agent_bot_id).to be_nil
|
expect(conversation.assignee_agent_bot_id).to be_nil
|
||||||
expect(conversation.status).to eq('open')
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -46,8 +45,8 @@ describe Conversations::AssignmentService do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'sets the agent bot, clears human assignee and marks the conversation pending' do
|
it 'sets the agent bot and clears human assignee' do
|
||||||
conversation.update!(assignee: agent, assignee_agent_bot: nil, status: :open)
|
conversation.update!(assignee: agent, assignee_agent_bot: nil)
|
||||||
|
|
||||||
result = service.perform
|
result = service.perform
|
||||||
|
|
||||||
@@ -55,7 +54,6 @@ describe Conversations::AssignmentService do
|
|||||||
expect(result).to eq(agent_bot)
|
expect(result).to eq(agent_bot)
|
||||||
expect(conversation.assignee_agent_bot_id).to eq(agent_bot.id)
|
expect(conversation.assignee_agent_bot_id).to eq(agent_bot.id)
|
||||||
expect(conversation.assignee_id).to be_nil
|
expect(conversation.assignee_id).to be_nil
|
||||||
expect(conversation.status).to eq('pending')
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user