Merge remote-tracking branch 'origin/develop' into feat/whatsapp-call-ui
# Conflicts: # spec/enterprise/services/voice/inbound_call_builder_spec.rb
This commit is contained in:
@@ -195,10 +195,10 @@ gem 'reverse_markdown'
|
||||
|
||||
gem 'iso-639'
|
||||
gem 'ruby-openai'
|
||||
gem 'ai-agents', '>= 0.9.1'
|
||||
gem 'ai-agents', '>= 0.10.0'
|
||||
|
||||
# TODO: Move this gem as a dependency of ai-agents
|
||||
gem 'ruby_llm', '>= 1.8.2'
|
||||
gem 'ruby_llm', '>= 1.14.1'
|
||||
gem 'ruby_llm-schema'
|
||||
|
||||
gem 'cld3', '~> 3.7'
|
||||
|
||||
+16
-16
@@ -126,8 +126,8 @@ GEM
|
||||
jbuilder (~> 2)
|
||||
rails (>= 4.2, < 7.2)
|
||||
selectize-rails (~> 0.6)
|
||||
ai-agents (0.9.1)
|
||||
ruby_llm (~> 1.9.1)
|
||||
ai-agents (0.10.0)
|
||||
ruby_llm (~> 1.14)
|
||||
annotaterb (4.20.0)
|
||||
activerecord (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
@@ -307,8 +307,8 @@ GEM
|
||||
faraday-mashify (1.0.0)
|
||||
faraday (~> 2.0)
|
||||
hashie
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.2)
|
||||
net-http (~> 0.5)
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
@@ -466,7 +466,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.19.2)
|
||||
json (2.19.5)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -590,14 +590,14 @@ GEM
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.19.1)
|
||||
nokogiri (1.19.3)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-arm64-darwin)
|
||||
nokogiri (1.19.3-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-x86_64-darwin)
|
||||
nokogiri (1.19.3-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-x86_64-linux-gnu)
|
||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.0)
|
||||
oauth-tty (~> 1.0, >= 1.0.1)
|
||||
@@ -835,17 +835,17 @@ GEM
|
||||
ruby2ruby (2.5.0)
|
||||
ruby_parser (~> 3.1)
|
||||
sexp_processor (~> 4.6)
|
||||
ruby_llm (1.9.2)
|
||||
ruby_llm (1.15.0)
|
||||
base64
|
||||
event_stream_parser (~> 1)
|
||||
faraday (>= 1.10.0)
|
||||
faraday-multipart (>= 1)
|
||||
faraday-net_http (>= 1)
|
||||
faraday-retry (>= 1)
|
||||
marcel (~> 1.0)
|
||||
ruby_llm-schema (~> 0.2.1)
|
||||
marcel (~> 1)
|
||||
ruby_llm-schema (~> 0)
|
||||
zeitwerk (~> 2)
|
||||
ruby_llm-schema (0.2.5)
|
||||
ruby_llm-schema (0.3.0)
|
||||
ruby_parser (3.20.0)
|
||||
sexp_processor (~> 4.16)
|
||||
sass (3.7.4)
|
||||
@@ -1019,7 +1019,7 @@ GEM
|
||||
working_hours (1.4.1)
|
||||
activesupport (>= 3.2)
|
||||
tzinfo
|
||||
zeitwerk (2.7.4)
|
||||
zeitwerk (2.7.5)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-20
|
||||
@@ -1039,7 +1039,7 @@ DEPENDENCIES
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.3)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
ai-agents (>= 0.9.1)
|
||||
ai-agents (>= 0.10.0)
|
||||
annotaterb
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
@@ -1144,7 +1144,7 @@ DEPENDENCIES
|
||||
rubocop-rails
|
||||
rubocop-rspec
|
||||
ruby-openai
|
||||
ruby_llm (>= 1.8.2)
|
||||
ruby_llm (>= 1.14.1)
|
||||
ruby_llm-schema
|
||||
scout_apm
|
||||
scss_lint
|
||||
|
||||
@@ -27,6 +27,8 @@ class NotificationBuilder
|
||||
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
|
||||
# skip notifications for blocked conversations except for user mentions
|
||||
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention'
|
||||
# respect conversation access (inbox/team membership and custom-role permissions)
|
||||
return unless user_can_access_conversation?
|
||||
|
||||
user.notifications.create!(
|
||||
notification_type: notification_type,
|
||||
@@ -36,4 +38,17 @@ class NotificationBuilder
|
||||
secondary_actor: secondary_actor || current_user
|
||||
)
|
||||
end
|
||||
|
||||
def user_can_access_conversation?
|
||||
conversation = primary_actor.is_a?(Conversation) ? primary_actor : primary_actor.try(:conversation)
|
||||
return true if conversation.blank?
|
||||
|
||||
account_user = AccountUser.find_by(account_id: account.id, user_id: user.id)
|
||||
return false if account_user.blank?
|
||||
|
||||
ConversationPolicy.new(
|
||||
{ user: user, account: account, account_user: account_user },
|
||||
conversation
|
||||
).show?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_custom_attributes_definitions, except: [:create]
|
||||
before_action :fetch_custom_attribute_definition, only: [:show, :update, :destroy]
|
||||
before_action :check_authorization
|
||||
DEFAULT_ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
|
||||
|
||||
def index; end
|
||||
|
||||
@@ -18,7 +18,16 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def destroy
|
||||
label_title = @label.title
|
||||
account_id = Current.account.id
|
||||
label_deleted_at = Time.current
|
||||
|
||||
@label.destroy!
|
||||
Labels::RemoveAssociationsJob.perform_later(
|
||||
label_title: label_title,
|
||||
account_id: account_id,
|
||||
label_deleted_at: label_deleted_at
|
||||
)
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -17,15 +17,12 @@ module Api::V1::InboxesHelper
|
||||
def validate_imap(channel_data)
|
||||
return unless channel_data.key?('imap_enabled') && channel_data[:imap_enabled]
|
||||
|
||||
Mail.defaults do
|
||||
retriever_method :imap, { address: channel_data[:imap_address],
|
||||
port: channel_data[:imap_port],
|
||||
user_name: channel_data[:imap_login],
|
||||
password: channel_data[:imap_password],
|
||||
enable_ssl: channel_data[:imap_enable_ssl] }
|
||||
end
|
||||
# Validate the user-selected auth mechanism before opening the connection.
|
||||
authentication = Imap::Authentication.validate_user_configurable!(channel_data[:imap_authentication])
|
||||
|
||||
check_imap_connection(channel_data)
|
||||
# Use the same auth adapter as the fetch service so LOGIN uses the IMAP LOGIN command,
|
||||
# not SASL AUTH=LOGIN.
|
||||
check_imap_connection(channel_data, authentication)
|
||||
end
|
||||
|
||||
def validate_smtp(channel_data)
|
||||
@@ -37,8 +34,8 @@ module Api::V1::InboxesHelper
|
||||
check_smtp_connection(channel_data, smtp)
|
||||
end
|
||||
|
||||
def check_imap_connection(channel_data)
|
||||
Mail.connection {} # rubocop:disable:block
|
||||
def check_imap_connection(channel_data, authentication)
|
||||
imap = open_imap_connection(channel_data, authentication)
|
||||
rescue SocketError => e
|
||||
raise StandardError, I18n.t('errors.inboxes.imap.socket_error')
|
||||
rescue Net::IMAP::NoResponseError => e
|
||||
@@ -53,9 +50,20 @@ module Api::V1::InboxesHelper
|
||||
rescue StandardError => e
|
||||
raise StandardError, e.message
|
||||
ensure
|
||||
imap.disconnect if imap.present? && !imap.disconnected?
|
||||
Rails.logger.error "[Api::V1::InboxesHelper] check_imap_connection failed with #{e.message}" if e.present?
|
||||
end
|
||||
|
||||
def open_imap_connection(channel_data, authentication)
|
||||
imap = build_imap_connection(channel_data)
|
||||
Imap::Authentication.authenticate!(imap, authentication, channel_data[:imap_login], channel_data[:imap_password])
|
||||
imap
|
||||
end
|
||||
|
||||
def build_imap_connection(channel_data)
|
||||
Net::IMAP.new(channel_data[:imap_address], port: channel_data[:imap_port], ssl: channel_data[:imap_enable_ssl])
|
||||
end
|
||||
|
||||
def check_smtp_connection(channel_data, smtp)
|
||||
smtp.open_timeout = 10
|
||||
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
|
||||
|
||||
@@ -6,15 +6,22 @@ class CaptainDocument extends ApiClient {
|
||||
super('captain/documents', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ page = 1, searchKey, assistantId } = {}) {
|
||||
get({ page = 1, searchKey, assistantId, filter, source, sort } = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
page,
|
||||
searchKey,
|
||||
search_key: searchKey,
|
||||
assistant_id: assistantId,
|
||||
filter,
|
||||
source,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
sync(id) {
|
||||
return axios.post(`${this.url}/${id}/sync`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainDocument();
|
||||
|
||||
@@ -28,6 +28,14 @@ class CompanyAPI extends ApiClient {
|
||||
return axios.get(`${this.url}/${id}/contacts?${buildParams({ page })}`);
|
||||
}
|
||||
|
||||
listNotes(id) {
|
||||
return axios.get(`${this.url}/${id}/notes`);
|
||||
}
|
||||
|
||||
listConversations(id) {
|
||||
return axios.get(`${this.url}/${id}/conversations`);
|
||||
}
|
||||
|
||||
searchContacts(id, query = '', page = 1) {
|
||||
const requestURL = `${this.url}/${id}/contacts/search?${buildParams({ q: query, page })}`;
|
||||
return axios.get(requestURL);
|
||||
|
||||
@@ -56,9 +56,14 @@ const closeMobileSidebar = () => {
|
||||
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
<div class="shrink-0">
|
||||
<slot name="sidebarHeader" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto pb-6 pt-3">
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -105,9 +110,14 @@ const closeMobileSidebar = () => {
|
||||
<div
|
||||
v-if="isSidebarOpen"
|
||||
id="details-sidebar-content"
|
||||
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
|
||||
class="order-2 w-[85%] sm:w-[50%] flex flex-col bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak shadow-lg"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
<div class="shrink-0">
|
||||
<slot name="sidebarHeader" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto pb-6 pt-3">
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
+3
-1
@@ -2,6 +2,7 @@
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import CompanySortMenu from './components/CompanySortMenu.vue';
|
||||
import CompanyMoreActions from './components/CompanyMoreActions.vue';
|
||||
|
||||
defineProps({
|
||||
showSearch: { type: Boolean, default: true },
|
||||
@@ -11,7 +12,7 @@ defineProps({
|
||||
activeOrdering: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['search', 'update:sort']);
|
||||
const emit = defineEmits(['search', 'update:sort', 'create']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -48,6 +49,7 @@ const emit = defineEmits(['search', 'update:sort']);
|
||||
:active-ordering="activeOrdering"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
/>
|
||||
<CompanyMoreActions @create="emit('create')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const emit = defineEmits(['create']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const showActionsDropdown = ref(false);
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: t('COMPANIES.ACTIONS.CREATE'),
|
||||
action: 'create',
|
||||
value: 'create',
|
||||
icon: 'i-lucide-plus',
|
||||
},
|
||||
];
|
||||
|
||||
const handleAction = ({ action }) => {
|
||||
if (action === 'create') emit('create');
|
||||
showActionsDropdown.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="() => (showActionsDropdown = false)" class="relative">
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="showActionsDropdown ? 'bg-n-alpha-2' : ''"
|
||||
@click="showActionsDropdown = !showActionsDropdown"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showActionsDropdown"
|
||||
:menu-items="menuItems"
|
||||
class="ltr:right-0 rtl:left-0 mt-1 w-52 top-full"
|
||||
@action="handleAction($event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -12,7 +12,12 @@ defineProps({
|
||||
showPaginationFooter: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:currentPage', 'update:sort', 'search']);
|
||||
const emit = defineEmits([
|
||||
'update:currentPage',
|
||||
'update:sort',
|
||||
'search',
|
||||
'create',
|
||||
]);
|
||||
|
||||
const updateCurrentPage = page => {
|
||||
emit('update:currentPage', page);
|
||||
@@ -31,6 +36,7 @@ const updateCurrentPage = page => {
|
||||
:active-ordering="activeOrdering"
|
||||
@search="emit('search', $event)"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
@create="emit('create')"
|
||||
/>
|
||||
<main class="flex-1 px-6 overflow-y-auto">
|
||||
<div class="w-full mx-auto max-w-5xl py-4">
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
|
||||
defineProps({
|
||||
isLoading: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['create']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const form = reactive({ name: '', domain: '', description: '' });
|
||||
|
||||
const isFormInvalid = computed(() => !form.name.trim());
|
||||
|
||||
const resetForm = () => {
|
||||
form.name = '';
|
||||
form.domain = '';
|
||||
form.description = '';
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (isFormInvalid.value) return;
|
||||
|
||||
emit('create', {
|
||||
name: form.name.trim(),
|
||||
domain: form.domain.trim() || null,
|
||||
description: form.description.trim() || null,
|
||||
});
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const onSuccess = () => {
|
||||
resetForm();
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef, onSuccess });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
width="3xl"
|
||||
overflow-y-auto
|
||||
@confirm="handleConfirm"
|
||||
@close="resetForm"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<span class="py-1 text-sm font-medium text-n-slate-12">
|
||||
{{ t('COMPANIES.CREATE.TITLE') }}
|
||||
</span>
|
||||
<div class="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Input
|
||||
v-model="form.name"
|
||||
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.NAME')"
|
||||
:disabled="isLoading"
|
||||
custom-input-class="h-8 !pt-1 !pb-1 [&:not(.error,.focus)]:!outline-transparent"
|
||||
autofocus
|
||||
/>
|
||||
<Input
|
||||
v-model="form.domain"
|
||||
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.DOMAIN')"
|
||||
:disabled="isLoading"
|
||||
custom-input-class="h-8 !pt-1 !pb-1 [&:not(.error,.focus)]:!outline-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TextArea
|
||||
v-model="form.description"
|
||||
:placeholder="t('COMPANIES.DETAIL.PROFILE.DESCRIPTION_PLACEHOLDER')"
|
||||
:disabled="isLoading"
|
||||
:max-length="280"
|
||||
class="w-full"
|
||||
show-character-count
|
||||
auto-height
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
:label="t('DIALOG.BUTTONS.CANCEL')"
|
||||
variant="link"
|
||||
type="reset"
|
||||
class="h-10 hover:!no-underline hover:text-n-brand"
|
||||
@click="closeDialog"
|
||||
/>
|
||||
<Button
|
||||
:label="t('COMPANIES.CREATE.ACTIONS.SAVE')"
|
||||
color="blue"
|
||||
type="submit"
|
||||
:disabled="isFormInvalid || isLoading"
|
||||
:is-loading="isLoading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
+3
-3
@@ -169,7 +169,7 @@ const handleContactSelect = contactId => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 px-6 pb-6 pt-1">
|
||||
<div class="flex flex-col gap-6 px-6 pb-8">
|
||||
<div v-if="!selectedContact" class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-base text-n-slate-12">
|
||||
@@ -288,7 +288,7 @@ const handleContactSelect = contactId => {
|
||||
|
||||
<div
|
||||
v-else-if="!hasContacts"
|
||||
class="py-8 text-sm text-center rounded-xl border border-dashed border-n-weak text-n-slate-11"
|
||||
class="py-8 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
|
||||
>
|
||||
{{ t('COMPANIES.DETAIL.CONTACTS.EMPTY') }}
|
||||
</div>
|
||||
@@ -346,7 +346,7 @@ const handleContactSelect = contactId => {
|
||||
:current-page="currentPage"
|
||||
:total-items="totalContacts"
|
||||
:items-per-page="15"
|
||||
class="px-0 before:hidden"
|
||||
class="!px-0 before:hidden bg-transparent"
|
||||
@update:current-page="emit('update:currentPage', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
defineProps({
|
||||
conversations: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const contactsById = useMapGetter('contacts/getContactById');
|
||||
const stateInbox = useMapGetter('inboxes/getInboxById');
|
||||
const accountLabels = useMapGetter('labels/getLabels');
|
||||
|
||||
const accountLabelsValue = computed(() => accountLabels.value);
|
||||
const conversationContact = conversation => {
|
||||
const sender = conversation.meta?.sender || {};
|
||||
const contact = contactsById.value(sender.id);
|
||||
return contact.id ? contact : sender;
|
||||
};
|
||||
const conversationInbox = conversation =>
|
||||
stateInbox.value(conversation.inboxId) || {
|
||||
name: '',
|
||||
channelType: conversation.meta?.channel,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="conversations.length > 0"
|
||||
class="px-6 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
|
||||
>
|
||||
<ConversationCard
|
||||
v-for="conversation in conversations"
|
||||
:key="conversation.id"
|
||||
:conversation="conversation"
|
||||
:contact="conversationContact(conversation)"
|
||||
:state-inbox="conversationInbox(conversation)"
|
||||
:account-labels="accountLabelsValue"
|
||||
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-else
|
||||
class="py-8 px-4 mx-6 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
|
||||
>
|
||||
{{ t('COMPANIES.DETAIL.HISTORY.EMPTY') }}
|
||||
</p>
|
||||
</template>
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
notes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const hasNotes = computed(() => props.notes.length > 0);
|
||||
|
||||
const contactName = contact =>
|
||||
contact?.name || t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
|
||||
|
||||
const getWrittenBy = note => {
|
||||
const isCurrentUser = note?.user?.id === currentUser.value.id;
|
||||
return isCurrentUser
|
||||
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
|
||||
: note?.user?.name || 'Bot';
|
||||
};
|
||||
|
||||
const openContact = contactId => {
|
||||
router.push({
|
||||
name: 'contacts_edit',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
contactId,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasNotes" class="flex flex-col px-6">
|
||||
<div class="flex flex-col divide-y divide-n-strong">
|
||||
<div
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
class="flex flex-col gap-2 py-4 group/note"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<Avatar
|
||||
:name="contactName(note.contact)"
|
||||
:src="note.contact?.thumbnail"
|
||||
:size="16"
|
||||
rounded-full
|
||||
hide-offline-status
|
||||
/>
|
||||
<div
|
||||
class="flex items-center justify-between min-w-0 gap-1 w-full text-sm text-n-slate-11"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 font-medium truncate text-start text-n-slate-12 hover:text-n-blue-11 p-0"
|
||||
@click="openContact(note.contact.id)"
|
||||
>
|
||||
{{ contactName(note.contact) }}
|
||||
</button>
|
||||
<div class="min-w-0 truncate">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-sm text-n-slate-10"
|
||||
>
|
||||
<span class="font-medium text-n-slate-11">
|
||||
{{ getWrittenBy(note) }}
|
||||
</span>
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
|
||||
<span class="font-medium text-n-slate-11">
|
||||
{{ dynamicTime(note.createdAt) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-dompurify-html="formatMessage(note.content || '')"
|
||||
class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="isLoading"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-else
|
||||
class="py-8 mx-6 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
|
||||
>
|
||||
{{ t('COMPANIES.DETAIL.NOTES.EMPTY') }}
|
||||
</p>
|
||||
</template>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedIds: { type: Set, default: () => new Set() },
|
||||
documents: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:selectedIds',
|
||||
'bulkSyncQueued',
|
||||
'bulkDeleteSucceeded',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const bulkDeleteDialog = ref(null);
|
||||
|
||||
const isSyncableDocument = doc =>
|
||||
!doc.pdf_document && doc.status === 'available' && !doc.sync_in_progress;
|
||||
|
||||
const syncableSelectedIds = computed(() => {
|
||||
if (!props.selectedIds.size) return [];
|
||||
return props.documents
|
||||
.filter(doc => props.selectedIds.has(doc.id) && isSyncableDocument(doc))
|
||||
.map(doc => doc.id);
|
||||
});
|
||||
|
||||
const hasSyncableSelection = computed(
|
||||
() => syncableSelectedIds.value.length > 0
|
||||
);
|
||||
|
||||
const selectAllLabel = computed(() => {
|
||||
const count = props.documents.length;
|
||||
const isAllSelected = props.selectedIds.size === count && count > 0;
|
||||
return isAllSelected
|
||||
? t('CAPTAIN.DOCUMENTS.UNSELECT_ALL', { count })
|
||||
: t('CAPTAIN.DOCUMENTS.SELECT_ALL', { count });
|
||||
});
|
||||
|
||||
const selectedCountLabel = computed(() =>
|
||||
t('CAPTAIN.DOCUMENTS.SELECTED', { count: props.selectedIds.size })
|
||||
);
|
||||
|
||||
const handleBulkSync = async () => {
|
||||
const ids = syncableSelectedIds.value;
|
||||
if (!ids.length) return;
|
||||
|
||||
try {
|
||||
const response = await store.dispatch('captainBulkActions/handleBulkSync', {
|
||||
ids,
|
||||
});
|
||||
const queuedCount = response?.count ?? response?.ids?.length ?? 0;
|
||||
let message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.ZERO_MESSAGE');
|
||||
|
||||
if (queuedCount === 1) {
|
||||
message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.SUCCESS_MESSAGE_ONE');
|
||||
} else if (queuedCount > 1) {
|
||||
message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.SUCCESS_MESSAGE', {
|
||||
count: queuedCount,
|
||||
});
|
||||
}
|
||||
|
||||
useAlert(message);
|
||||
emit('update:selectedIds', new Set());
|
||||
if (queuedCount > 0) emit('bulkSyncQueued');
|
||||
} catch (error) {
|
||||
useAlert(t('CAPTAIN.DOCUMENTS.BULK_SYNC.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BulkSelectBar
|
||||
:model-value="selectedIds"
|
||||
:all-items="documents"
|
||||
:select-all-label="selectAllLabel"
|
||||
:selected-count-label="selectedCountLabel"
|
||||
:delete-label="$t('CAPTAIN.DOCUMENTS.BULK_DELETE_BUTTON')"
|
||||
class="w-fit"
|
||||
:class="{ 'mb-2': selectedIds.size > 0 }"
|
||||
@update:model-value="emit('update:selectedIds', $event)"
|
||||
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
|
||||
>
|
||||
<template v-if="hasSyncableSelection" #secondaryActions>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.DOCUMENTS.BULK_SYNC_BUTTON')"
|
||||
sm
|
||||
slate
|
||||
ghost
|
||||
icon="i-lucide-refresh-cw"
|
||||
class="!px-1.5"
|
||||
@click="handleBulkSync"
|
||||
/>
|
||||
</template>
|
||||
</BulkSelectBar>
|
||||
<BulkDeleteDialog
|
||||
ref="bulkDeleteDialog"
|
||||
:bulk-ids="selectedIds"
|
||||
type="AssistantDocument"
|
||||
@delete-success="emit('bulkDeleteSucceeded')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,14 +5,17 @@ import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import {
|
||||
isPdfDocument,
|
||||
isSafeHttpLink,
|
||||
formatDocumentLink,
|
||||
getDocumentDisplayPath,
|
||||
} from 'shared/helpers/documentHelper';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import DocumentSyncStatus from 'dashboard/components-next/captain/assistant/DocumentSyncStatus.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -31,10 +34,38 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
pdfDocument: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
createdAt: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
syncStatus: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
lastSyncedAt: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
lastSyncErrorCode: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
syncInProgress: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
syncStaleAfterHours: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -64,6 +95,20 @@ const modelValue = computed({
|
||||
set: () => emit('select', props.id),
|
||||
});
|
||||
|
||||
const isPdf = computed(() => props.pdfDocument);
|
||||
const hasSafeLink = computed(() => isSafeHttpLink(props.externalLink));
|
||||
const canManage = computed(() => checkPermissions(['administrator']));
|
||||
const isAvailable = computed(() => props.status === 'available');
|
||||
const canSync = computed(
|
||||
() => canManage.value && !isPdf.value && isAvailable.value
|
||||
);
|
||||
const isSyncing = computed(() => props.syncStatus === 'syncing');
|
||||
const isFailed = computed(() => props.syncStatus === 'failed');
|
||||
const isRetryableSync = computed(
|
||||
() => isFailed.value || (isSyncing.value && !props.syncInProgress)
|
||||
);
|
||||
const showSyncStatus = computed(() => !isPdf.value);
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const allOptions = [
|
||||
{
|
||||
@@ -74,7 +119,19 @@ const menuItems = computed(() => {
|
||||
},
|
||||
];
|
||||
|
||||
if (checkPermissions(['administrator'])) {
|
||||
if (canSync.value) {
|
||||
allOptions.push({
|
||||
label: isRetryableSync.value
|
||||
? t('CAPTAIN.DOCUMENTS.OPTIONS.RETRY_SYNC')
|
||||
: t('CAPTAIN.DOCUMENTS.OPTIONS.SYNC_NOW'),
|
||||
value: 'sync',
|
||||
action: 'sync',
|
||||
icon: 'i-lucide-refresh-cw',
|
||||
disabled: props.syncInProgress,
|
||||
});
|
||||
}
|
||||
|
||||
if (canManage.value) {
|
||||
allOptions.push({
|
||||
label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'),
|
||||
value: 'delete',
|
||||
@@ -86,17 +143,25 @@ const menuItems = computed(() => {
|
||||
return allOptions;
|
||||
});
|
||||
|
||||
const createdAt = computed(() => dynamicTime(props.createdAt));
|
||||
const createdAtLabel = computed(() => dynamicTime(props.createdAt));
|
||||
|
||||
const displayLink = computed(() => formatDocumentLink(props.externalLink));
|
||||
const displayLink = computed(() =>
|
||||
isPdf.value
|
||||
? formatDocumentLink(props.externalLink)
|
||||
: getDocumentDisplayPath(props.externalLink)
|
||||
);
|
||||
const linkIcon = computed(() =>
|
||||
isPdfDocument(props.externalLink) ? 'i-ph-file-pdf' : 'i-ph-link-simple'
|
||||
isPdf.value ? 'i-ph-file-pdf' : 'i-ph-link-simple'
|
||||
);
|
||||
|
||||
const handleAction = ({ action, value }) => {
|
||||
toggleDropdown(false);
|
||||
emit('action', { action, value, id: props.id });
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
emit('action', { action: 'sync', id: props.id });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -141,17 +206,41 @@ const handleAction = ({ action, value }) => {
|
||||
<span
|
||||
class="flex gap-1 items-center text-sm truncate shrink-0 text-n-slate-11"
|
||||
>
|
||||
<i class="i-woot-captain" />
|
||||
<Icon icon="i-woot-captain" />
|
||||
{{ assistant?.name || '' }}
|
||||
</span>
|
||||
<a
|
||||
v-if="!isPdf && hasSafeLink"
|
||||
:href="externalLink"
|
||||
:title="externalLink"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex flex-1 gap-1 justify-start items-center text-sm truncate text-n-slate-11 hover:text-n-slate-12 hover:underline"
|
||||
@click.stop
|
||||
>
|
||||
<Icon :icon="linkIcon" class="shrink-0" />
|
||||
<span class="truncate">{{ displayLink }}</span>
|
||||
<Icon icon="i-lucide-external-link size-3 shrink-0 opacity-70" />
|
||||
</a>
|
||||
<span
|
||||
v-else
|
||||
class="flex flex-1 gap-1 justify-start items-center text-sm truncate text-n-slate-11"
|
||||
>
|
||||
<i :class="linkIcon" class="shrink-0" />
|
||||
<Icon :icon="linkIcon" class="shrink-0" />
|
||||
<span class="truncate">{{ displayLink }}</span>
|
||||
</span>
|
||||
<div class="text-sm shrink-0 text-n-slate-11 line-clamp-1">
|
||||
{{ createdAt }}
|
||||
<DocumentSyncStatus
|
||||
v-if="showSyncStatus"
|
||||
:status="syncStatus"
|
||||
:last-synced-at="lastSyncedAt"
|
||||
:error-code="lastSyncErrorCode"
|
||||
:sync-in-progress="syncInProgress"
|
||||
:stale-after-hours="syncStaleAfterHours"
|
||||
:show-retry="canSync && isRetryableSync"
|
||||
@retry="handleRetry"
|
||||
/>
|
||||
<div v-else class="text-sm shrink-0 text-n-slate-11 line-clamp-1">
|
||||
{{ createdAtLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</CardLayout>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import DocumentFiltersBar from 'dashboard/components-next/captain/assistant/DocumentFiltersBar.vue';
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
|
||||
const source = ref('all');
|
||||
const status = ref(null);
|
||||
const sort = ref('recently_updated');
|
||||
|
||||
const hasActiveFilters = computed(
|
||||
() => source.value !== 'all' || Boolean(status.value)
|
||||
);
|
||||
|
||||
const buildParams = (page = 1) => {
|
||||
const params = { page };
|
||||
if (source.value !== 'all') params.source = source.value;
|
||||
if (status.value) params.filter = status.value;
|
||||
if (sort.value) params.sort = sort.value;
|
||||
return params;
|
||||
};
|
||||
|
||||
const emitChange = () => emit('change');
|
||||
|
||||
const handleSourceSelect = sourceKey => {
|
||||
source.value = sourceKey;
|
||||
if (sourceKey !== 'web') status.value = null;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const handleStatusSelect = statusKey => {
|
||||
status.value = statusKey;
|
||||
if (statusKey) source.value = 'web';
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const handleSortSelect = sortKey => {
|
||||
sort.value = sortKey;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
source.value = 'all';
|
||||
status.value = null;
|
||||
sort.value = 'recently_updated';
|
||||
};
|
||||
|
||||
defineExpose({ buildParams, reset, hasActiveFilters });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DocumentFiltersBar
|
||||
:active-source-filter="source"
|
||||
:active-status-filter="status"
|
||||
:active-sort="sort"
|
||||
@select-source="handleSourceSelect"
|
||||
@select-status="handleStatusSelect"
|
||||
@select-sort="handleSortSelect"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeSourceFilter: { type: String, default: 'all' },
|
||||
activeStatusFilter: { type: String, default: null },
|
||||
activeSort: { type: String, default: 'recently_updated' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectSource', 'selectStatus', 'selectSort']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const openMenu = ref(null);
|
||||
|
||||
const MENU_CONFIG = [
|
||||
{
|
||||
key: 'source',
|
||||
activeKey: 'activeSourceFilter',
|
||||
dropdownClass: 'min-w-48',
|
||||
options: [
|
||||
{ labelKey: 'SOURCE.ALL', value: 'all', icon: 'i-lucide-files' },
|
||||
{ labelKey: 'SOURCE.WEB', value: 'web', icon: 'i-lucide-link' },
|
||||
{ labelKey: 'SOURCE.PDF', value: 'pdf', icon: 'i-lucide-file-text' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
activeKey: 'activeStatusFilter',
|
||||
dropdownClass: 'min-w-52',
|
||||
options: [
|
||||
{ labelKey: 'STATUS.ANY', value: null, icon: 'i-lucide-circle-dashed' },
|
||||
{
|
||||
labelKey: 'STATUS.UPDATED',
|
||||
value: 'synced',
|
||||
icon: 'i-lucide-check-circle',
|
||||
},
|
||||
{
|
||||
labelKey: 'STATUS.NEEDS_UPDATE',
|
||||
value: 'stale',
|
||||
icon: 'i-lucide-clock',
|
||||
},
|
||||
{
|
||||
labelKey: 'STATUS.UPDATING',
|
||||
value: 'syncing',
|
||||
icon: 'i-lucide-refresh-cw',
|
||||
},
|
||||
{
|
||||
labelKey: 'STATUS.FAILED',
|
||||
value: 'failed',
|
||||
icon: 'i-lucide-circle-x',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sort',
|
||||
activeKey: 'activeSort',
|
||||
dropdownClass: 'min-w-56',
|
||||
options: [
|
||||
{
|
||||
labelKey: 'SORT.RECENTLY_UPDATED',
|
||||
value: 'recently_updated',
|
||||
icon: 'i-lucide-arrow-down-up',
|
||||
},
|
||||
{
|
||||
labelKey: 'SORT.RECENTLY_CREATED',
|
||||
value: 'recently_created',
|
||||
icon: 'i-lucide-clock',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const filterMenus = computed(() =>
|
||||
MENU_CONFIG.filter(
|
||||
menu => !(menu.key === 'status' && props.activeSourceFilter === 'pdf')
|
||||
).map(menu => {
|
||||
const active = props[menu.activeKey];
|
||||
const items = menu.options.map(opt => ({
|
||||
label: t(`CAPTAIN.DOCUMENTS.FILTERS.${opt.labelKey}`),
|
||||
value: opt.value,
|
||||
icon: opt.icon,
|
||||
action: menu.key,
|
||||
isSelected: opt.value === active,
|
||||
}));
|
||||
return {
|
||||
...menu,
|
||||
items,
|
||||
selected: items.find(item => item.isSelected) || items[0],
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const closeMenu = () => {
|
||||
openMenu.value = null;
|
||||
};
|
||||
|
||||
const toggleMenu = menu => {
|
||||
openMenu.value = openMenu.value === menu ? null : menu;
|
||||
};
|
||||
|
||||
const handleMenuAction = ({ action, value }) => {
|
||||
closeMenu();
|
||||
if (action === 'source') emit('selectSource', value);
|
||||
else if (action === 'status') emit('selectStatus', value);
|
||||
else if (action === 'sort') emit('selectSort', value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="closeMenu"
|
||||
class="inline-flex flex-wrap items-center gap-2 pt-2 w-fit"
|
||||
>
|
||||
<div v-for="menu in filterMenus" :key="menu.key" class="relative">
|
||||
<Button
|
||||
:icon="menu.selected.icon"
|
||||
slate
|
||||
size="sm"
|
||||
:class="{ 'bg-n-slate-9/10': openMenu === menu.key }"
|
||||
@click="toggleMenu(menu.key)"
|
||||
>
|
||||
<span class="min-w-0 truncate">{{ menu.selected.label }}</span>
|
||||
<Icon icon="i-lucide-chevron-down" class="shrink-0 size-4" />
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
v-if="openMenu === menu.key"
|
||||
:menu-items="menu.items"
|
||||
:class="menu.dropdownClass"
|
||||
class="top-full mt-2 ltr:left-0 rtl:right-0"
|
||||
@action="handleMenuAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
lastSyncedAt: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
errorCode: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
syncInProgress: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
staleAfterHours: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
showRetry: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['retry']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const SECONDS_PER_HOUR = 3600;
|
||||
|
||||
const SYNCING = 'syncing';
|
||||
const FAILED = 'failed';
|
||||
|
||||
const ERROR_CODE_LABELS = {
|
||||
not_found: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.NOT_FOUND',
|
||||
access_denied: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.ACCESS_DENIED',
|
||||
timeout: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.TIMEOUT',
|
||||
content_empty: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.CONTENT_EMPTY',
|
||||
fetch_failed: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.FETCH_FAILED',
|
||||
sync_error: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.SYNC_ERROR',
|
||||
};
|
||||
const DEFAULT_ERROR_LABEL = 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.DEFAULT';
|
||||
|
||||
const hasSyncingStatus = computed(() => props.status === SYNCING);
|
||||
const isSyncing = computed(
|
||||
() => hasSyncingStatus.value && props.syncInProgress
|
||||
);
|
||||
const isStaleSync = computed(
|
||||
() => hasSyncingStatus.value && !props.syncInProgress
|
||||
);
|
||||
const isFailed = computed(() => props.status === FAILED);
|
||||
const canRetry = computed(() => isFailed.value || isStaleSync.value);
|
||||
const hasBeenSynced = computed(() => Boolean(props.lastSyncedAt));
|
||||
|
||||
const ageInHours = computed(() => {
|
||||
if (!props.lastSyncedAt) return null;
|
||||
const nowSeconds = Date.now() / 1000;
|
||||
return (nowSeconds - props.lastSyncedAt) / SECONDS_PER_HOUR;
|
||||
});
|
||||
|
||||
const staleAfterHours = computed(() => Number(props.staleAfterHours));
|
||||
const hasStaleThreshold = computed(
|
||||
() => Number.isFinite(staleAfterHours.value) && staleAfterHours.value > 0
|
||||
);
|
||||
const isStale = computed(
|
||||
() =>
|
||||
hasStaleThreshold.value &&
|
||||
ageInHours.value !== null &&
|
||||
ageInHours.value >= staleAfterHours.value
|
||||
);
|
||||
|
||||
const errorLabel = computed(() =>
|
||||
t(ERROR_CODE_LABELS[props.errorCode] || DEFAULT_ERROR_LABEL)
|
||||
);
|
||||
|
||||
const label = computed(() => {
|
||||
if (isSyncing.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCING');
|
||||
if (isStaleSync.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.STALE_SYNC');
|
||||
if (isFailed.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.FAILED');
|
||||
if (hasBeenSynced.value)
|
||||
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCED', {
|
||||
time: dynamicTime(props.lastSyncedAt),
|
||||
});
|
||||
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.NEVER_SYNCED');
|
||||
});
|
||||
|
||||
const fullLabel = computed(() => {
|
||||
if (isSyncing.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCING');
|
||||
if (isStaleSync.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.STALE_SYNC');
|
||||
if (isFailed.value)
|
||||
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.FAILED', {
|
||||
error: errorLabel.value,
|
||||
});
|
||||
if (hasBeenSynced.value)
|
||||
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCED', {
|
||||
time: dynamicTime(props.lastSyncedAt),
|
||||
});
|
||||
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.NEVER_SYNCED');
|
||||
});
|
||||
|
||||
const tone = computed(() => {
|
||||
if (isSyncing.value) return 'amber';
|
||||
if (isStaleSync.value) return 'amber';
|
||||
if (isFailed.value) return 'ruby';
|
||||
if (isStale.value) return 'amber';
|
||||
return 'slate';
|
||||
});
|
||||
|
||||
const textClass = computed(() => {
|
||||
if (tone.value === 'amber') return 'text-n-amber-11';
|
||||
if (tone.value === 'ruby') return 'text-n-ruby-11';
|
||||
return 'text-n-slate-11';
|
||||
});
|
||||
|
||||
const statusIcon = computed(() => {
|
||||
if (isFailed.value || isStale.value || isStaleSync.value) {
|
||||
return 'i-lucide-circle-alert';
|
||||
}
|
||||
return 'i-lucide-refresh-cw';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="flex gap-1.5 items-center text-sm truncate shrink-0 tabular-nums"
|
||||
:class="textClass"
|
||||
:title="fullLabel"
|
||||
>
|
||||
<Spinner v-if="isSyncing" class="text-n-amber-11 size-3" />
|
||||
<Icon v-else :icon="statusIcon" class="shrink-0 size-3.5" />
|
||||
<span class="truncate">{{ label }}</span>
|
||||
<Button
|
||||
v-if="showRetry && canRetry"
|
||||
:label="t('CAPTAIN.DOCUMENTS.OPTIONS.RETRY_SYNC')"
|
||||
xs
|
||||
link
|
||||
ruby
|
||||
icon="i-lucide-refresh-cw"
|
||||
class="hover:!no-underline !gap-1 ms-1"
|
||||
@click.stop="emit('retry')"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
+2
-1
@@ -15,7 +15,7 @@ defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const emit = defineEmits(['close', 'createSuccess']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
@@ -26,6 +26,7 @@ const i18nKey = 'CAPTAIN.DOCUMENTS.CREATE';
|
||||
const handleSubmit = async newDocument => {
|
||||
try {
|
||||
await store.dispatch('captainDocuments/create', newDocument);
|
||||
emit('createSuccess');
|
||||
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
|
||||
dialogRef.value.close();
|
||||
} catch (error) {
|
||||
|
||||
+1
-7
@@ -1,9 +1,8 @@
|
||||
<script setup>
|
||||
import { useTemplateRef, computed, ref, onMounted } from 'vue';
|
||||
import { useTemplateRef, computed, ref } from 'vue';
|
||||
import { useI18n, I18nT } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -19,7 +18,6 @@ const props = defineProps({
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const containerRef = useTemplateRef('containerRef');
|
||||
const [showDropdown, toggleDropdown] = useToggle(false);
|
||||
const selectedTeam = ref(null);
|
||||
@@ -77,10 +75,6 @@ const handleDismiss = () => {
|
||||
selectedTeam.value = null;
|
||||
toggleDropdown(false);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('teams/get');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -41,6 +41,7 @@ export const FEATURE_FLAGS = {
|
||||
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
CAPTAIN_TASKS: 'captain_tasks',
|
||||
CAPTAIN_DOCUMENT_AUTO_SYNC: 'captain_document_auto_sync',
|
||||
SAML: 'saml',
|
||||
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
|
||||
COMPANIES: 'companies',
|
||||
|
||||
@@ -22,6 +22,19 @@
|
||||
"LOADING": "Loading companies...",
|
||||
"UNNAMED": "Unnamed Company",
|
||||
"CONTACTS_COUNT": "{n} contact | {n} contacts",
|
||||
"ACTIONS": {
|
||||
"CREATE": "Add company"
|
||||
},
|
||||
"CREATE": {
|
||||
"TITLE": "Add company details",
|
||||
"ACTIONS": {
|
||||
"SAVE": "Add company"
|
||||
},
|
||||
"MESSAGES": {
|
||||
"SUCCESS": "Company created.",
|
||||
"ERROR": "Could not create the company."
|
||||
}
|
||||
},
|
||||
"DETAIL": {
|
||||
"LOADING": "Loading company details...",
|
||||
"EMPTY_STATE": {
|
||||
@@ -31,9 +44,17 @@
|
||||
"SIDEBAR": {
|
||||
"TABS": {
|
||||
"ATTRIBUTES": "Attributes",
|
||||
"CONTACTS": "Contacts"
|
||||
"CONTACTS": "Contacts",
|
||||
"HISTORY": "History",
|
||||
"NOTES": "Notes"
|
||||
}
|
||||
},
|
||||
"HISTORY": {
|
||||
"EMPTY": "No conversations found for this company's contacts yet."
|
||||
},
|
||||
"NOTES": {
|
||||
"EMPTY": "No notes found for this company's contacts yet."
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"SEARCH_PLACEHOLDER": "Search attributes...",
|
||||
"EMPTY_STATE": "There are no company custom attributes configured yet.",
|
||||
|
||||
@@ -1035,7 +1035,8 @@
|
||||
"LABEL": "Password",
|
||||
"PLACE_HOLDER": "Password"
|
||||
},
|
||||
"ENABLE_SSL": "Enable SSL"
|
||||
"ENABLE_SSL": "Enable SSL",
|
||||
"AUTH_MECHANISM": "Authentication"
|
||||
},
|
||||
"MICROSOFT": {
|
||||
"TITLE": "Microsoft",
|
||||
|
||||
@@ -742,6 +742,7 @@
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"UNSELECT_ALL": "Unselect all ({count})",
|
||||
"BULK_DELETE_BUTTON": "Delete",
|
||||
"BULK_SYNC_BUTTON": "Refresh",
|
||||
"BULK_DELETE": {
|
||||
"TITLE": "Delete documents?",
|
||||
"DESCRIPTION": "Are you sure you want to delete the selected documents? This action cannot be undone.",
|
||||
@@ -749,6 +750,51 @@
|
||||
"SUCCESS_MESSAGE": "Documents deleted successfully",
|
||||
"ERROR_MESSAGE": "There was an error deleting the documents, please try again."
|
||||
},
|
||||
"BULK_SYNC": {
|
||||
"SUCCESS_MESSAGE_ONE": "Refresh queued for 1 document",
|
||||
"SUCCESS_MESSAGE": "Refresh queued for {count} documents",
|
||||
"ZERO_MESSAGE": "No documents marked for refresh.",
|
||||
"ERROR_MESSAGE": "There was an error queuing the refresh, please try again."
|
||||
},
|
||||
"SYNC": {
|
||||
"QUEUED_MESSAGE": "Refresh queued. We'll update the document shortly.",
|
||||
"ERROR_MESSAGE": "Could not queue refresh, please try again."
|
||||
},
|
||||
"FILTERS": {
|
||||
"SOURCE": {
|
||||
"ALL": "All sources",
|
||||
"WEB": "Web pages",
|
||||
"PDF": "PDFs"
|
||||
},
|
||||
"STATUS": {
|
||||
"ANY": "Any status",
|
||||
"UPDATED": "Updated",
|
||||
"NEEDS_UPDATE": "Needs update",
|
||||
"UPDATING": "Updating",
|
||||
"FAILED": "Failed"
|
||||
},
|
||||
"SORT": {
|
||||
"RECENTLY_UPDATED": "Recently updated",
|
||||
"RECENTLY_CREATED": "Recently created"
|
||||
},
|
||||
"SEARCH_PLACEHOLDER": "Search..."
|
||||
},
|
||||
"SYNC_STATUS": {
|
||||
"SYNCED": "last updated {time}",
|
||||
"SYNCING": "updating...",
|
||||
"STALE_SYNC": "update stalled",
|
||||
"FAILED": "Failed to sync",
|
||||
"NEVER_SYNCED": "not updated yet"
|
||||
},
|
||||
"SYNC_ERRORS": {
|
||||
"NOT_FOUND": "Page not found",
|
||||
"ACCESS_DENIED": "Access denied",
|
||||
"TIMEOUT": "Page took too long to respond",
|
||||
"CONTENT_EMPTY": "Page returned empty content",
|
||||
"FETCH_FAILED": "Could not fetch page",
|
||||
"SYNC_ERROR": "Unexpected error",
|
||||
"DEFAULT": "Sync error"
|
||||
},
|
||||
"RELATED_RESPONSES": {
|
||||
"TITLE": "Related FAQs",
|
||||
"DESCRIPTION": "These FAQs are generated directly from the document."
|
||||
@@ -793,11 +839,15 @@
|
||||
|
||||
"OPTIONS": {
|
||||
"VIEW_RELATED_RESPONSES": "View Related Responses",
|
||||
"SYNC_NOW": "Refresh now",
|
||||
"RETRY_SYNC": "Retry refresh",
|
||||
"DELETE_DOCUMENT": "Delete Document"
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No documents available",
|
||||
"SUBTITLE": "Documents are used by your assistant to generate FAQs. You can import documents to provide context for your assistant.",
|
||||
"FILTERED_TITLE": "No matching documents",
|
||||
"FILTERED_SUBTITLE": "Try changing the source, status, or search term.",
|
||||
"FEATURE_SPOTLIGHT": {
|
||||
"TITLE": "Captain Document",
|
||||
"NOTE": "A document in Captain serves as a knowledge resource for the assistant. By connecting your help center or guides, Captain can analyze the content and provide accurate responses for customer inquiries."
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { computed, onUnmounted, ref, nextTick, watch } from 'vue';
|
||||
import { useTimeoutPoll } from '@vueuse/core';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
|
||||
import DocumentFilter from 'dashboard/components-next/captain/assistant/DocumentFilter.vue';
|
||||
import DocumentBulkActions from 'dashboard/components-next/captain/assistant/DocumentBulkActions.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
@@ -18,6 +22,7 @@ import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponen
|
||||
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
|
||||
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
|
||||
import LimitBanner from 'dashboard/components-next/captain/pageComponents/document/LimitBanner.vue';
|
||||
import CaptainDocumentAPI from 'dashboard/api/captain/document';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const route = useRoute();
|
||||
@@ -25,6 +30,9 @@ const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { checkPermissions } = usePolicy();
|
||||
|
||||
const SYNC_POLL_INTERVAL_MS = 5000;
|
||||
const SYNC_POLL_MAX_DURATION_MS = 15 * 60 * 1000;
|
||||
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
const uiFlags = useMapGetter('captainDocuments/getUIFlags');
|
||||
const documents = useMapGetter('captainDocuments/getRecords');
|
||||
@@ -36,7 +44,6 @@ const canManageDocuments = computed(() => checkPermissions(['administrator']));
|
||||
|
||||
const selectedDocument = ref(null);
|
||||
const deleteDocumentDialog = ref(null);
|
||||
const bulkDeleteDialog = ref(null);
|
||||
const bulkSelectedIds = ref(new Set());
|
||||
const hoveredCard = ref(null);
|
||||
|
||||
@@ -66,6 +73,160 @@ const handleCreateDialogClose = () => {
|
||||
showCreateDialog.value = false;
|
||||
};
|
||||
|
||||
const documentFilter = ref(null);
|
||||
const syncIntervalHours = ref(null);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const currentAssistantId = () =>
|
||||
Number.isFinite(selectedAssistantId.value) ? selectedAssistantId.value : null;
|
||||
|
||||
const buildDocumentFilterParams = (page = 1) => {
|
||||
const filterParams = documentFilter.value?.buildParams(page) ?? {
|
||||
page,
|
||||
sort: 'recently_updated',
|
||||
};
|
||||
const assistantId = currentAssistantId();
|
||||
if (assistantId) filterParams.assistantId = assistantId;
|
||||
const trimmedQuery = searchQuery.value.trim();
|
||||
if (trimmedQuery) filterParams.searchKey = trimmedQuery;
|
||||
return filterParams;
|
||||
};
|
||||
|
||||
let documentsRequestId = 0;
|
||||
let fetchingListRequestId = null;
|
||||
|
||||
const isCurrentDocumentRequest = (requestId, filterParams) =>
|
||||
requestId === documentsRequestId &&
|
||||
(filterParams.assistantId || null) === currentAssistantId();
|
||||
|
||||
const pruneSelectionToDocuments = nextDocuments => {
|
||||
if (!bulkSelectedIds.value.size) return;
|
||||
|
||||
const visibleDocumentIds = new Set(nextDocuments.map(doc => doc.id));
|
||||
const selectedIds = new Set(
|
||||
[...bulkSelectedIds.value].filter(id => visibleDocumentIds.has(id))
|
||||
);
|
||||
|
||||
if (selectedIds.size !== bulkSelectedIds.value.size) {
|
||||
bulkSelectedIds.value = selectedIds;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDocuments = async (page = 1, { showLoader = true } = {}) => {
|
||||
documentsRequestId += 1;
|
||||
const requestId = documentsRequestId;
|
||||
const filterParams = buildDocumentFilterParams(page);
|
||||
|
||||
if (showLoader) {
|
||||
fetchingListRequestId = requestId;
|
||||
store.dispatch('captainDocuments/setFetchingList', true);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await CaptainDocumentAPI.get(filterParams);
|
||||
|
||||
if (!isCurrentDocumentRequest(requestId, filterParams)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { payload, meta } = response.data;
|
||||
store.dispatch('captainDocuments/setRecords', { records: payload, meta });
|
||||
pruneSelectionToDocuments(payload);
|
||||
syncIntervalHours.value = Number(meta?.sync_interval_hours) || null;
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (isCurrentDocumentRequest(requestId, filterParams)) {
|
||||
throw error;
|
||||
}
|
||||
return [];
|
||||
} finally {
|
||||
if (showLoader && fetchingListRequestId === requestId) {
|
||||
fetchingListRequestId = null;
|
||||
store.dispatch('captainDocuments/setFetchingList', false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const refreshDocumentsPage = (
|
||||
page = documentsMeta.value?.page || 1,
|
||||
{ showLoader = false } = {}
|
||||
) => {
|
||||
return fetchDocuments(page, { showLoader }).catch(() => {});
|
||||
};
|
||||
|
||||
const onFiltersChanged = () => {
|
||||
bulkSelectedIds.value = new Set();
|
||||
fetchDocuments(1);
|
||||
};
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
bulkSelectedIds.value = new Set();
|
||||
fetchDocuments(1);
|
||||
}, 300);
|
||||
|
||||
const syncPollStartedAt = ref(null);
|
||||
|
||||
const hasDocumentsSyncing = computed(() =>
|
||||
(documents.value || []).some(doc => doc.sync_in_progress)
|
||||
);
|
||||
|
||||
const hasSyncingDocuments = computed(() => hasDocumentsSyncing.value);
|
||||
|
||||
const isWithinSyncPollWindow = () =>
|
||||
syncPollStartedAt.value &&
|
||||
Date.now() - syncPollStartedAt.value < SYNC_POLL_MAX_DURATION_MS;
|
||||
|
||||
const shouldContinueSyncPolling = () =>
|
||||
hasSyncingDocuments.value && isWithinSyncPollWindow();
|
||||
|
||||
let syncPollingControls;
|
||||
|
||||
function stopSyncPolling() {
|
||||
syncPollingControls.pause();
|
||||
syncPollStartedAt.value = null;
|
||||
}
|
||||
|
||||
async function pollSyncDocuments() {
|
||||
try {
|
||||
await refreshDocumentsPage();
|
||||
} catch (error) {
|
||||
// Keep the existing polling decision based on the last known sync state.
|
||||
}
|
||||
|
||||
if (!shouldContinueSyncPolling()) {
|
||||
stopSyncPolling();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSyncPoll({ extendWindow = false } = {}) {
|
||||
if (extendWindow || !syncPollStartedAt.value) {
|
||||
syncPollStartedAt.value = Date.now();
|
||||
}
|
||||
|
||||
if (syncPollingControls.isActive.value) return;
|
||||
syncPollingControls.resume();
|
||||
}
|
||||
|
||||
syncPollingControls = useTimeoutPoll(pollSyncDocuments, SYNC_POLL_INTERVAL_MS, {
|
||||
immediate: false,
|
||||
});
|
||||
|
||||
watch(hasSyncingDocuments, isSyncing => {
|
||||
if (isSyncing) {
|
||||
scheduleSyncPoll();
|
||||
}
|
||||
});
|
||||
|
||||
const handleSync = async id => {
|
||||
try {
|
||||
await store.dispatch('captainDocuments/sync', id);
|
||||
useAlert(t('CAPTAIN.DOCUMENTS.SYNC.QUEUED_MESSAGE'));
|
||||
scheduleSyncPoll({ extendWindow: true });
|
||||
} catch (error) {
|
||||
useAlert(t('CAPTAIN.DOCUMENTS.SYNC.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = ({ action, id }) => {
|
||||
selectedDocument.value = documents.value.find(
|
||||
captainDocument => id === captainDocument.id
|
||||
@@ -76,19 +237,12 @@ const handleAction = ({ action, id }) => {
|
||||
handleDelete();
|
||||
} else if (action === 'viewRelatedQuestions') {
|
||||
handleShowRelatedDocument();
|
||||
} else if (action === 'sync') {
|
||||
handleSync(id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const fetchDocuments = (page = 1) => {
|
||||
const filterParams = { page };
|
||||
|
||||
if (selectedAssistantId.value) {
|
||||
filterParams.assistantId = selectedAssistantId.value;
|
||||
}
|
||||
store.dispatch('captainDocuments/get', filterParams);
|
||||
};
|
||||
|
||||
const onPageChange = page => {
|
||||
const hadSelection = bulkSelectedIds.value.size > 0;
|
||||
fetchDocuments(page);
|
||||
@@ -101,31 +255,14 @@ const onPageChange = page => {
|
||||
const onDeleteSuccess = () => {
|
||||
if (documents.value?.length === 0 && documentsMeta.value?.page > 1) {
|
||||
onPageChange(documentsMeta.value.page - 1);
|
||||
} else {
|
||||
refreshDocumentsPage();
|
||||
}
|
||||
};
|
||||
|
||||
const buildSelectedCountLabel = computed(() => {
|
||||
const count = documents.value?.length || 0;
|
||||
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
|
||||
return isAllSelected
|
||||
? t('CAPTAIN.DOCUMENTS.UNSELECT_ALL', { count })
|
||||
: t('CAPTAIN.DOCUMENTS.SELECT_ALL', { count });
|
||||
});
|
||||
|
||||
const selectedCountLabel = computed(() => {
|
||||
return t('CAPTAIN.DOCUMENTS.SELECTED', {
|
||||
count: bulkSelectedIds.value.size,
|
||||
});
|
||||
});
|
||||
|
||||
const hasBulkSelection = computed(() => bulkSelectedIds.value.size > 0);
|
||||
|
||||
const shouldShowSelectionControl = docId => {
|
||||
return (
|
||||
canManageDocuments.value &&
|
||||
(hoveredCard.value === docId || hasBulkSelection.value)
|
||||
);
|
||||
};
|
||||
const shouldShowSelectionControl = docId =>
|
||||
canManageDocuments.value &&
|
||||
(hoveredCard.value === docId || bulkSelectedIds.value.size > 0);
|
||||
|
||||
const handleCardHover = (isHovered, id) => {
|
||||
hoveredCard.value = isHovered ? id : null;
|
||||
@@ -152,12 +289,33 @@ const fetchDocumentsAfterBulkAction = () => {
|
||||
bulkSelectedIds.value = new Set();
|
||||
};
|
||||
|
||||
const onBulkDeleteSuccess = () => {
|
||||
fetchDocumentsAfterBulkAction();
|
||||
const onCreateSuccess = () => {
|
||||
refreshDocumentsPage(1);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchDocuments();
|
||||
const hasActiveDocumentFilters = computed(
|
||||
() =>
|
||||
(documentFilter.value?.hasActiveFilters ?? false) ||
|
||||
Boolean(searchQuery.value.trim())
|
||||
);
|
||||
|
||||
watch(
|
||||
selectedAssistantId,
|
||||
async () => {
|
||||
documentFilter.value?.reset();
|
||||
searchQuery.value = '';
|
||||
bulkSelectedIds.value = new Set();
|
||||
syncIntervalHours.value = null;
|
||||
stopSyncPolling();
|
||||
await fetchDocuments(1);
|
||||
if (hasSyncingDocuments.value) scheduleSyncPoll();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
stopSyncPolling();
|
||||
documentsRequestId += 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -170,27 +328,43 @@ onMounted(() => {
|
||||
:current-page="documentsMeta.page"
|
||||
:show-pagination-footer="!isFetching && !!documents.length"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!documents.length"
|
||||
:show-know-more="false"
|
||||
:is-empty="!documents.length && !hasActiveDocumentFilters"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
@update:current-page="onPageChange"
|
||||
@click="handleCreateDocument"
|
||||
>
|
||||
<template #subHeader>
|
||||
<Policy :permissions="['administrator']">
|
||||
<BulkSelectBar
|
||||
v-model="bulkSelectedIds"
|
||||
:all-items="documents"
|
||||
:select-all-label="buildSelectedCountLabel"
|
||||
:selected-count-label="selectedCountLabel"
|
||||
:delete-label="$t('CAPTAIN.DOCUMENTS.BULK_DELETE_BUTTON')"
|
||||
class="w-fit"
|
||||
:class="{ 'mb-2': bulkSelectedIds.size > 0 }"
|
||||
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
|
||||
<template #search>
|
||||
<div
|
||||
v-if="bulkSelectedIds.size === 0"
|
||||
class="flex gap-3 justify-between w-full items-center"
|
||||
>
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('CAPTAIN.DOCUMENTS.FILTERS.SEARCH_PLACEHOLDER')"
|
||||
class="max-w-64 min-w-0 w-full"
|
||||
size="sm"
|
||||
type="search"
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</Policy>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #subHeader>
|
||||
<Policy :permissions="['administrator']">
|
||||
<DocumentBulkActions
|
||||
v-model:selected-ids="bulkSelectedIds"
|
||||
:documents="documents"
|
||||
@bulk-sync-queued="scheduleSyncPoll({ extendWindow: true })"
|
||||
@bulk-delete-succeeded="fetchDocumentsAfterBulkAction"
|
||||
/>
|
||||
</Policy>
|
||||
<DocumentFilter
|
||||
v-show="!bulkSelectedIds.size"
|
||||
ref="documentFilter"
|
||||
class="mb-2"
|
||||
@change="onFiltersChanged"
|
||||
/>
|
||||
</template>
|
||||
<template #knowMore>
|
||||
<FeatureSpotlightPopover
|
||||
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
|
||||
@@ -214,15 +388,34 @@ onMounted(() => {
|
||||
<template #body>
|
||||
<LimitBanner class="mb-5" />
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-if="!documents.length && hasActiveDocumentFilters"
|
||||
class="flex flex-col items-center justify-center min-h-80 gap-2 text-center"
|
||||
>
|
||||
<span class="text-base font-medium text-n-slate-12">
|
||||
{{ $t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FILTERED_TITLE') }}
|
||||
</span>
|
||||
<span class="max-w-md text-sm text-n-slate-11">
|
||||
{{ $t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FILTERED_SUBTITLE') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<DocumentCard
|
||||
v-for="doc in documents"
|
||||
:id="doc.id"
|
||||
:key="doc.id"
|
||||
:name="doc.name || doc.external_link"
|
||||
:external-link="doc.external_link"
|
||||
:pdf-document="doc.pdf_document"
|
||||
:assistant="doc.assistant"
|
||||
:created-at="doc.created_at"
|
||||
:status="doc.status"
|
||||
:sync-status="doc.sync_status"
|
||||
:last-synced-at="doc.last_synced_at"
|
||||
:last-sync-error-code="doc.last_sync_error_code"
|
||||
:sync-in-progress="doc.sync_in_progress"
|
||||
:sync-stale-after-hours="syncIntervalHours"
|
||||
:is-selected="canManageDocuments && bulkSelectedIds.has(doc.id)"
|
||||
:selectable="canManageDocuments"
|
||||
:show-selection-control="shouldShowSelectionControl(doc.id)"
|
||||
@@ -244,6 +437,7 @@ onMounted(() => {
|
||||
v-if="showCreateDialog"
|
||||
ref="createDocumentDialog"
|
||||
:assistant-id="selectedAssistantId"
|
||||
@create-success="onCreateSuccess"
|
||||
@close="handleCreateDialogClose"
|
||||
/>
|
||||
<DeleteDialog
|
||||
@@ -253,12 +447,5 @@ onMounted(() => {
|
||||
type="Documents"
|
||||
@delete-success="onDeleteSuccess"
|
||||
/>
|
||||
<BulkDeleteDialog
|
||||
v-if="bulkSelectedIds"
|
||||
ref="bulkDeleteDialog"
|
||||
:bulk-ids="bulkSelectedIds"
|
||||
type="AssistantDocument"
|
||||
@delete-success="onBulkDeleteSuccess"
|
||||
/>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
@@ -3,11 +3,13 @@ import { ref, computed, onMounted, reactive } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useCompaniesStore } from 'dashboard/stores/companies';
|
||||
|
||||
import CompaniesListLayout from 'dashboard/components-next/Companies/CompaniesListLayout.vue';
|
||||
import CompaniesCard from 'dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue';
|
||||
import CompanyCreateDialog from 'dashboard/components-next/Companies/CompanyCreateDialog.vue';
|
||||
|
||||
const DEFAULT_SORT_FIELD = 'name';
|
||||
const DEBOUNCE_DELAY = 300;
|
||||
@@ -26,6 +28,7 @@ const uiFlags = computed(() => companiesStore.getUIFlags);
|
||||
|
||||
const searchQuery = computed(() => route.query?.search || '');
|
||||
const searchValue = ref(searchQuery.value);
|
||||
const createCompanyDialogRef = ref(null);
|
||||
const pageNumber = computed(() => Number(route.query?.page) || 1);
|
||||
|
||||
const parseSortSettings = (sortString = '') => {
|
||||
@@ -51,6 +54,7 @@ const activeSort = computed(() => sortState.activeSort);
|
||||
const activeOrdering = computed(() => sortState.activeOrdering);
|
||||
|
||||
const isFetchingList = computed(() => uiFlags.value.fetchingList);
|
||||
const isCreatingCompany = computed(() => uiFlags.value.creatingItem);
|
||||
|
||||
const buildSortAttr = () =>
|
||||
`${sortState.activeOrdering}${sortState.activeSort}`;
|
||||
@@ -121,6 +125,21 @@ const showCompany = companyId => {
|
||||
});
|
||||
};
|
||||
|
||||
const openCreateCompanyDialog = () => {
|
||||
createCompanyDialogRef.value?.dialogRef.open();
|
||||
};
|
||||
|
||||
const createCompany = async company => {
|
||||
try {
|
||||
const newCompany = await companiesStore.create(company);
|
||||
createCompanyDialogRef.value?.onSuccess();
|
||||
useAlert(t('COMPANIES.CREATE.MESSAGES.SUCCESS'));
|
||||
showCompany(newCompany.id);
|
||||
} catch {
|
||||
useAlert(t('COMPANIES.CREATE.MESSAGES.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = async ({ sort, order }) => {
|
||||
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
|
||||
|
||||
@@ -155,6 +174,7 @@ onMounted(() => {
|
||||
@update:current-page="onPageChange"
|
||||
@update:sort="handleSort"
|
||||
@search="onSearch"
|
||||
@create="openCreateCompanyDialog"
|
||||
>
|
||||
<div v-if="isFetchingList" class="flex items-center justify-center p-8">
|
||||
<span class="text-n-slate-11 text-base">{{
|
||||
@@ -182,5 +202,10 @@ onMounted(() => {
|
||||
@show-company="showCompany"
|
||||
/>
|
||||
</div>
|
||||
<CompanyCreateDialog
|
||||
ref="createCompanyDialogRef"
|
||||
:is-loading="isCreatingCompany"
|
||||
@create="createCompany"
|
||||
/>
|
||||
</CompaniesListLayout>
|
||||
</template>
|
||||
|
||||
@@ -8,7 +8,10 @@ import Policy from 'dashboard/components/policy.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CompaniesDetailsLayout from 'dashboard/components-next/Companies/CompaniesDetailsLayout.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import CompanyContactsSidebar from 'dashboard/components-next/Companies/CompanyDetail/CompanyContactsSidebar.vue';
|
||||
import CompanyHistorySidebar from 'dashboard/components-next/Companies/CompanyDetail/CompanyHistorySidebar.vue';
|
||||
import CompanyNotesSidebar from 'dashboard/components-next/Companies/CompanyDetail/CompanyNotesSidebar.vue';
|
||||
import CompanyProfileCard from 'dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue';
|
||||
import ConfirmCompanyDeleteDialog from 'dashboard/components-next/Companies/CompanyDetail/ConfirmCompanyDeleteDialog.vue';
|
||||
import { useCompaniesStore } from 'dashboard/stores/companies';
|
||||
@@ -20,11 +23,16 @@ const { t } = useI18n();
|
||||
|
||||
const confirmDeleteDialogRef = ref(null);
|
||||
const selectedCandidate = ref(null);
|
||||
const activeSidebarTab = ref('history');
|
||||
|
||||
const companyId = computed(() => Number(route.params.companyId));
|
||||
const company = computed(() => companiesStore.getRecord(companyId.value));
|
||||
const companyContacts = computed(() => companiesStore.companyContacts);
|
||||
const companyContactsMeta = computed(() => companiesStore.companyContactsMeta);
|
||||
const companyConversations = computed(
|
||||
() => companiesStore.companyConversations || []
|
||||
);
|
||||
const companyNotes = computed(() => companiesStore.companyNotes || []);
|
||||
const contactSearchResults = computed(
|
||||
() => companiesStore.contactSearchResults
|
||||
);
|
||||
@@ -32,6 +40,10 @@ const uiFlags = computed(() => companiesStore.getUIFlags);
|
||||
|
||||
const isFetchingCompany = computed(() => uiFlags.value.fetchingItem);
|
||||
const isFetchingContacts = computed(() => uiFlags.value.fetchingContacts);
|
||||
const isFetchingConversations = computed(
|
||||
() => uiFlags.value.fetchingConversations
|
||||
);
|
||||
const isFetchingNotes = computed(() => uiFlags.value.fetchingNotes);
|
||||
const isSearchingContacts = computed(() => uiFlags.value.searchingContacts);
|
||||
const isManagingContacts = computed(
|
||||
() => uiFlags.value.creatingContact || uiFlags.value.removingContact
|
||||
@@ -50,6 +62,27 @@ const breadcrumbItems = computed(() => [
|
||||
: []),
|
||||
]);
|
||||
|
||||
const SIDEBAR_TABS_OPTIONS = [
|
||||
{ key: 'HISTORY', value: 'history' },
|
||||
{ key: 'NOTES', value: 'notes' },
|
||||
{ key: 'CONTACTS', value: 'contacts' },
|
||||
];
|
||||
|
||||
const sidebarTabs = computed(() =>
|
||||
SIDEBAR_TABS_OPTIONS.map(tab => ({
|
||||
label: {
|
||||
notes: t('COMPANIES.DETAIL.SIDEBAR.TABS.NOTES'),
|
||||
history: t('COMPANIES.DETAIL.SIDEBAR.TABS.HISTORY'),
|
||||
contacts: `${t('COMPANIES.DETAIL.SIDEBAR.TABS.CONTACTS')} (${Number(companyContactsMeta.value.totalCount || 0)})`,
|
||||
}[tab.value],
|
||||
value: tab.value,
|
||||
}))
|
||||
);
|
||||
|
||||
const activeSidebarTabIndex = computed(() =>
|
||||
SIDEBAR_TABS_OPTIONS.findIndex(tab => tab.value === activeSidebarTab.value)
|
||||
);
|
||||
|
||||
const goToCompaniesIndex = () => {
|
||||
router.push({
|
||||
name: 'companies_dashboard_index',
|
||||
@@ -79,6 +112,19 @@ const clearSelectedCandidate = () => {
|
||||
selectedCandidate.value = null;
|
||||
};
|
||||
|
||||
const loadSidebarTab = tab => {
|
||||
if (!companyId.value) return;
|
||||
if (tab === 'notes') companiesStore.getCompanyNotes(companyId.value);
|
||||
if (tab === 'history') {
|
||||
companiesStore.getCompanyConversations(companyId.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSidebarTabChange = tab => {
|
||||
activeSidebarTab.value = tab.value;
|
||||
loadSidebarTab(tab.value);
|
||||
};
|
||||
|
||||
const handleContactSearch = async query => {
|
||||
await companiesStore.searchCompanyContactCandidates({
|
||||
companyId: companyId.value,
|
||||
@@ -143,10 +189,12 @@ watch(
|
||||
async id => {
|
||||
companiesStore.resetCompanyDetailState();
|
||||
clearSelectedCandidate();
|
||||
activeSidebarTab.value = 'history';
|
||||
if (!id) return;
|
||||
await Promise.allSettled([
|
||||
companiesStore.show(id),
|
||||
companiesStore.getCompanyContacts(id),
|
||||
companiesStore.getCompanyConversations(id),
|
||||
]);
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -207,8 +255,29 @@ onBeforeUnmount(() => {
|
||||
</Policy>
|
||||
</div>
|
||||
|
||||
<template #sidebarHeader>
|
||||
<div class="px-6 pt-6 pb-3">
|
||||
<TabBar
|
||||
:tabs="sidebarTabs"
|
||||
:initial-active-tab="activeSidebarTabIndex"
|
||||
class="w-full [&>button]:w-full bg-n-alpha-black2"
|
||||
@tab-changed="handleSidebarTabChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="hasCompany" #sidebar>
|
||||
<CompanyNotesSidebar
|
||||
v-if="activeSidebarTab === 'notes'"
|
||||
:notes="companyNotes"
|
||||
:is-loading="isFetchingNotes"
|
||||
/>
|
||||
<CompanyHistorySidebar
|
||||
v-if="activeSidebarTab === 'history'"
|
||||
:conversations="companyConversations"
|
||||
:is-loading="isFetchingConversations"
|
||||
/>
|
||||
<CompanyContactsSidebar
|
||||
v-if="activeSidebarTab === 'contacts'"
|
||||
:company="company"
|
||||
:contacts="companyContacts"
|
||||
:meta="companyContactsMeta"
|
||||
|
||||
@@ -5,11 +5,13 @@ import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFie
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import SingleSelectDropdown from './components/SingleSelectDropdown.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SettingsFieldSection,
|
||||
NextButton,
|
||||
SingleSelectDropdown,
|
||||
},
|
||||
props: {
|
||||
inbox: {
|
||||
@@ -28,6 +30,12 @@ export default {
|
||||
login: '',
|
||||
password: '',
|
||||
isSSLEnabled: true,
|
||||
authMechanism: 'plain',
|
||||
authMechanisms: [
|
||||
{ key: 1, value: 'plain' },
|
||||
{ key: 2, value: 'login' },
|
||||
{ key: 3, value: 'cram-md5' },
|
||||
],
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
@@ -56,6 +64,7 @@ export default {
|
||||
imap_login,
|
||||
imap_password,
|
||||
imap_enable_ssl,
|
||||
imap_authentication,
|
||||
} = this.inbox;
|
||||
this.isIMAPEnabled = imap_enabled;
|
||||
this.address = imap_address;
|
||||
@@ -63,6 +72,7 @@ export default {
|
||||
this.login = imap_login;
|
||||
this.password = imap_password;
|
||||
this.isSSLEnabled = imap_enable_ssl;
|
||||
this.authMechanism = imap_authentication || 'plain';
|
||||
},
|
||||
async updateInbox() {
|
||||
try {
|
||||
@@ -77,6 +87,7 @@ export default {
|
||||
imap_login: this.login,
|
||||
imap_password: this.password,
|
||||
imap_enable_ssl: this.isSSLEnabled,
|
||||
imap_authentication: this.authMechanism,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -90,6 +101,9 @@ export default {
|
||||
useAlert(error.message);
|
||||
}
|
||||
},
|
||||
handleAuthMechanismChange(mode) {
|
||||
this.authMechanism = mode;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -155,6 +169,13 @@ export default {
|
||||
/>
|
||||
{{ $t('INBOX_MGMT.IMAP.ENABLE_SSL') }}
|
||||
</label>
|
||||
<SingleSelectDropdown
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.IMAP.AUTH_MECHANISM')"
|
||||
:selected="authMechanism"
|
||||
:options="authMechanisms"
|
||||
:action="handleAuthMechanismChange"
|
||||
/>
|
||||
</div>
|
||||
<NextButton
|
||||
type="submit"
|
||||
|
||||
@@ -61,5 +61,18 @@ export default createStore({
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
handleBulkSync: async function handleBulkSync({ dispatch }, { ids }) {
|
||||
const response = await dispatch('processBulkAction', {
|
||||
type: 'AssistantDocument',
|
||||
actionType: 'sync',
|
||||
ids,
|
||||
});
|
||||
|
||||
await dispatch('captainDocuments/markSyncing', response.ids || [], {
|
||||
root: true,
|
||||
});
|
||||
return response;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,15 +1,55 @@
|
||||
import CaptainDocumentAPI from 'dashboard/api/captain/document';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
import { createStore } from '../storeFactory';
|
||||
|
||||
const SYNCING_STATE = 'syncing';
|
||||
|
||||
const markRecordsSyncing = (records, ids) => {
|
||||
const idSet = new Set(ids);
|
||||
return records.map(record =>
|
||||
idSet.has(record.id)
|
||||
? {
|
||||
...record,
|
||||
sync_status: SYNCING_STATE,
|
||||
sync_in_progress: true,
|
||||
last_sync_attempted_at: Math.floor(Date.now() / 1000),
|
||||
last_sync_error_code: null,
|
||||
}
|
||||
: record
|
||||
);
|
||||
};
|
||||
|
||||
export default createStore({
|
||||
name: 'CaptainDocument',
|
||||
API: CaptainDocumentAPI,
|
||||
getters: {
|
||||
getRecords: state => state.records,
|
||||
},
|
||||
actions: mutations => ({
|
||||
setFetchingList({ commit }, isFetching) {
|
||||
commit(mutations.SET_UI_FLAG, { fetchingList: isFetching });
|
||||
},
|
||||
setRecords({ commit }, { records, meta }) {
|
||||
commit(mutations.SET, records);
|
||||
commit(mutations.SET_META, meta);
|
||||
},
|
||||
removeBulkRecords({ commit, getters }, ids) {
|
||||
const records = getters.getRecords.filter(
|
||||
record => !ids.includes(record.id)
|
||||
);
|
||||
commit(mutations.SET, records);
|
||||
},
|
||||
markSyncing({ commit, getters }, ids) {
|
||||
commit(mutations.SET, markRecordsSyncing(getters.getRecords, ids));
|
||||
},
|
||||
async sync({ dispatch }, id) {
|
||||
try {
|
||||
await CaptainDocumentAPI.sync(id);
|
||||
dispatch('markSyncing', [id]);
|
||||
return id;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -134,10 +134,13 @@ export const createVuexStore = options => {
|
||||
* @returns {Function} Pinia store composable
|
||||
*/
|
||||
export const createPiniaStore = options => {
|
||||
const { name, API, actions, getters } = options;
|
||||
const { name, API, actions, getters, state } = options;
|
||||
|
||||
return defineStore(name.toLowerCase(), {
|
||||
state: createInitialState,
|
||||
state: () => ({
|
||||
...createInitialState(),
|
||||
...(state ? state() : {}),
|
||||
}),
|
||||
|
||||
getters: {
|
||||
...createGetters(),
|
||||
|
||||
@@ -8,10 +8,13 @@ const createInitialUIFlags = () => ({
|
||||
fetchingList: false,
|
||||
fetchingItem: false,
|
||||
updatingItem: false,
|
||||
creatingItem: false,
|
||||
deletingItem: false,
|
||||
deletingAvatar: false,
|
||||
deletingCustomAttributes: false,
|
||||
fetchingContacts: false,
|
||||
fetchingConversations: false,
|
||||
fetchingNotes: false,
|
||||
searchingContacts: false,
|
||||
creatingContact: false,
|
||||
removingContact: false,
|
||||
@@ -66,6 +69,16 @@ export const useCompaniesStore = createStore({
|
||||
name: 'companies',
|
||||
type: 'pinia',
|
||||
API: CompanyAPI,
|
||||
state: () => ({
|
||||
activeCompanyId: null,
|
||||
companyContacts: [],
|
||||
companyContactsMeta: {},
|
||||
companyConversations: [],
|
||||
companyNotes: [],
|
||||
contactSearchResults: [],
|
||||
contactSearchMeta: {},
|
||||
activeContactSearchQuery: '',
|
||||
}),
|
||||
|
||||
getters: {
|
||||
getCompaniesList: state => state.records,
|
||||
@@ -172,6 +185,22 @@ export const useCompaniesStore = createStore({
|
||||
}
|
||||
},
|
||||
|
||||
async create(companyAttrs) {
|
||||
this.setUIFlag({ creatingItem: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.create(buildCompanyRequestPayload(companyAttrs));
|
||||
const company = camelizeCompany(payload);
|
||||
this.upsertCompanyRecord(company);
|
||||
return company;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ creatingItem: false });
|
||||
}
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
this.setUIFlag({ deletingItem: true });
|
||||
try {
|
||||
@@ -253,6 +282,74 @@ export const useCompaniesStore = createStore({
|
||||
}
|
||||
},
|
||||
|
||||
async getCompanyNotes(companyId) {
|
||||
this.setUIFlag({ fetchingNotes: true });
|
||||
this.ensureActiveCompanyContext(companyId);
|
||||
const activeCompanyId = Number(companyId);
|
||||
const requestToken = (this.companyNotesRequestToken || 0) + 1;
|
||||
this.companyNotesRequestToken = requestToken;
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.listNotes(companyId);
|
||||
const notes = camelcaseKeys(payload || [], { deep: true });
|
||||
|
||||
if (
|
||||
this.companyNotesRequestToken !== requestToken ||
|
||||
this.activeCompanyId !== activeCompanyId
|
||||
) {
|
||||
return notes;
|
||||
}
|
||||
|
||||
this.companyNotes = notes;
|
||||
return notes;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
if (
|
||||
this.companyNotesRequestToken === requestToken &&
|
||||
this.activeCompanyId === activeCompanyId
|
||||
) {
|
||||
this.setUIFlag({ fetchingNotes: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async getCompanyConversations(companyId) {
|
||||
this.setUIFlag({ fetchingConversations: true });
|
||||
this.ensureActiveCompanyContext(companyId);
|
||||
const activeCompanyId = Number(companyId);
|
||||
const requestToken = (this.companyConversationsRequestToken || 0) + 1;
|
||||
this.companyConversationsRequestToken = requestToken;
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.listConversations(companyId);
|
||||
const conversations = camelcaseKeys(payload || [], { deep: true });
|
||||
|
||||
if (
|
||||
this.companyConversationsRequestToken !== requestToken ||
|
||||
this.activeCompanyId !== activeCompanyId
|
||||
) {
|
||||
return conversations;
|
||||
}
|
||||
|
||||
this.companyConversations = conversations;
|
||||
return conversations;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
if (
|
||||
this.companyConversationsRequestToken === requestToken &&
|
||||
this.activeCompanyId === activeCompanyId
|
||||
) {
|
||||
this.setUIFlag({ fetchingConversations: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async searchCompanyContactCandidates({ companyId, search, page = 1 }) {
|
||||
const query = search?.trim() || '';
|
||||
if (!query) {
|
||||
@@ -362,10 +459,15 @@ export const useCompaniesStore = createStore({
|
||||
(this.companyDetailRequestToken || 0) + 1;
|
||||
this.companyContactsRequestToken =
|
||||
(this.companyContactsRequestToken || 0) + 1;
|
||||
this.companyConversationsRequestToken =
|
||||
(this.companyConversationsRequestToken || 0) + 1;
|
||||
this.companyNotesRequestToken = (this.companyNotesRequestToken || 0) + 1;
|
||||
this.contactSearchRequestToken =
|
||||
(this.contactSearchRequestToken || 0) + 1;
|
||||
this.companyContacts = [];
|
||||
this.companyContactsMeta = {};
|
||||
this.companyConversations = [];
|
||||
this.companyNotes = [];
|
||||
this.contactSearchResults = [];
|
||||
this.contactSearchMeta = {};
|
||||
this.activeContactSearchQuery = '';
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Constants for document processing
|
||||
const PDF_PREFIX = 'PDF:';
|
||||
const TIMESTAMP_PATTERN = /_\d{14}(?=\.pdf$)/; // Format: _YYYYMMDDHHMMSS before .pdf extension
|
||||
const URL_DISPLAY_PREFIX_PATTERN = /^https?:\/\/(www\.)?/i;
|
||||
|
||||
/**
|
||||
* Checks if a document is a PDF based on its external link
|
||||
@@ -16,10 +17,26 @@ export const isPdfDocument = externalLink => {
|
||||
return externalLink.startsWith(PDF_PREFIX);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a link is safe to bind to an href attribute (http/https only).
|
||||
* Guards against schemes like `javascript:` that would execute on click.
|
||||
* @param {string} externalLink - The external link string
|
||||
* @returns {boolean} True if the link uses http or https
|
||||
*/
|
||||
export const isSafeHttpLink = externalLink => {
|
||||
if (!externalLink) return false;
|
||||
try {
|
||||
const { protocol } = new URL(externalLink);
|
||||
return protocol === 'http:' || protocol === 'https:';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats the display link for documents
|
||||
* For PDF documents: removes 'PDF:' prefix and timestamp suffix
|
||||
* For regular URLs: returns as-is
|
||||
* For regular URLs: strips http(s):// and www. for a denser list view
|
||||
*
|
||||
* @param {string} externalLink - The external link string
|
||||
* @returns {string} Formatted display link
|
||||
@@ -34,5 +51,28 @@ export const formatDocumentLink = externalLink => {
|
||||
return fullName.replace(TIMESTAMP_PATTERN, '');
|
||||
}
|
||||
|
||||
return externalLink;
|
||||
return externalLink.replace(URL_DISPLAY_PREFIX_PATTERN, '');
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the path of a URL for compact display in document lists. This avoids
|
||||
* repeating the domain while preserving enough context to distinguish pages.
|
||||
* Falls back to the bare hostname for root URLs and formatDocumentLink for
|
||||
* malformed URLs and PDFs.
|
||||
*/
|
||||
export const getDocumentDisplayPath = externalLink => {
|
||||
if (!externalLink) return '';
|
||||
if (isPdfDocument(externalLink)) return formatDocumentLink(externalLink);
|
||||
try {
|
||||
const { pathname, hostname } = new URL(externalLink);
|
||||
const path = pathname.replace(/^\/+/, '');
|
||||
if (!path) return hostname.replace(/^www\./i, '');
|
||||
try {
|
||||
return decodeURIComponent(path);
|
||||
} catch (e) {
|
||||
return path;
|
||||
}
|
||||
} catch (e) {
|
||||
return formatDocumentLink(externalLink);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
isPdfDocument,
|
||||
isSafeHttpLink,
|
||||
formatDocumentLink,
|
||||
} from 'shared/helpers/documentHelper';
|
||||
|
||||
@@ -31,6 +32,35 @@ describe('documentHelper', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isSafeHttpLink', () => {
|
||||
it('returns true for http and https URLs', () => {
|
||||
expect(isSafeHttpLink('http://example.com')).toBe(true);
|
||||
expect(isSafeHttpLink('https://example.com/path?q=1#x')).toBe(true);
|
||||
expect(isSafeHttpLink('HTTPS://EXAMPLE.COM')).toBe(true);
|
||||
});
|
||||
|
||||
/* eslint-disable no-script-url */
|
||||
it('returns false for javascript: and other dangerous schemes', () => {
|
||||
expect(isSafeHttpLink('javascript:alert(1)')).toBe(false);
|
||||
expect(isSafeHttpLink('JavaScript:alert(1)')).toBe(false);
|
||||
expect(isSafeHttpLink('data:text/html,<script>alert(1)</script>')).toBe(
|
||||
false
|
||||
);
|
||||
expect(isSafeHttpLink('vbscript:msgbox(1)')).toBe(false);
|
||||
expect(isSafeHttpLink('file:///etc/passwd')).toBe(false);
|
||||
expect(isSafeHttpLink('ftp://files.example.com/doc.pdf')).toBe(false);
|
||||
});
|
||||
/* eslint-enable no-script-url */
|
||||
|
||||
it('returns false for invalid or empty values', () => {
|
||||
expect(isSafeHttpLink('')).toBe(false);
|
||||
expect(isSafeHttpLink(null)).toBe(false);
|
||||
expect(isSafeHttpLink(undefined)).toBe(false);
|
||||
expect(isSafeHttpLink('not a url')).toBe(false);
|
||||
expect(isSafeHttpLink('//example.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#formatDocumentLink', () => {
|
||||
describe('PDF documents', () => {
|
||||
it('removes PDF: prefix from PDF documents', () => {
|
||||
@@ -78,32 +108,30 @@ describe('documentHelper', () => {
|
||||
});
|
||||
|
||||
describe('Regular URLs', () => {
|
||||
it('returns regular URLs unchanged', () => {
|
||||
expect(formatDocumentLink('https://example.com')).toBe(
|
||||
'https://example.com'
|
||||
);
|
||||
it('removes http(s) and www prefixes for compact display', () => {
|
||||
expect(formatDocumentLink('https://example.com')).toBe('example.com');
|
||||
expect(formatDocumentLink('http://docs.example.com/api')).toBe(
|
||||
'http://docs.example.com/api'
|
||||
'docs.example.com/api'
|
||||
);
|
||||
expect(formatDocumentLink('https://github.com/user/repo')).toBe(
|
||||
'https://github.com/user/repo'
|
||||
expect(formatDocumentLink('https://www.github.com/user/repo')).toBe(
|
||||
'github.com/user/repo'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles URLs with query parameters', () => {
|
||||
expect(formatDocumentLink('https://example.com?param=value')).toBe(
|
||||
'https://example.com?param=value'
|
||||
'example.com?param=value'
|
||||
);
|
||||
expect(
|
||||
formatDocumentLink(
|
||||
'https://api.example.com/docs?version=v1&format=json'
|
||||
)
|
||||
).toBe('https://api.example.com/docs?version=v1&format=json');
|
||||
).toBe('api.example.com/docs?version=v1&format=json');
|
||||
});
|
||||
|
||||
it('handles URLs with fragments', () => {
|
||||
expect(formatDocumentLink('https://example.com/docs#section1')).toBe(
|
||||
'https://example.com/docs#section1'
|
||||
'example.com/docs#section1'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,10 @@ class Account::BrandingEnrichmentJob < ApplicationJob
|
||||
|
||||
def perform(account_id, email)
|
||||
result = WebsiteBrandingService.new(email).perform
|
||||
return if result.blank?
|
||||
if result.blank?
|
||||
Rails.logger.info "[BrandingEnrichment] Enrichment failed for account=#{account_id} email=#{email}"
|
||||
return
|
||||
end
|
||||
|
||||
account = Account.find(account_id)
|
||||
account.name = result[:title] if result[:title].present?
|
||||
|
||||
+17
-10
@@ -3,19 +3,19 @@ class HookJob < MutexApplicationJob
|
||||
|
||||
queue_as :medium
|
||||
|
||||
INTEGRATION_PROCESSORS = {
|
||||
'slack' => :process_slack_integration,
|
||||
'dialogflow' => :process_dialogflow_integration,
|
||||
'google_translate' => :google_translate_integration,
|
||||
'leadsquared' => :process_leadsquared_integration_with_lock,
|
||||
'linear' => :process_linear_integration
|
||||
}.freeze
|
||||
|
||||
def perform(hook, event_name, event_data = {})
|
||||
return if hook.disabled?
|
||||
|
||||
case hook.app_id
|
||||
when 'slack'
|
||||
process_slack_integration(hook, event_name, event_data)
|
||||
when 'dialogflow'
|
||||
process_dialogflow_integration(hook, event_name, event_data)
|
||||
when 'google_translate'
|
||||
google_translate_integration(hook, event_name, event_data)
|
||||
when 'leadsquared'
|
||||
process_leadsquared_integration_with_lock(hook, event_name, event_data)
|
||||
end
|
||||
processor = INTEGRATION_PROCESSORS[hook.app_id]
|
||||
send(processor, hook, event_name, event_data) if processor
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
end
|
||||
@@ -57,6 +57,13 @@ class HookJob < MutexApplicationJob
|
||||
Integrations::GoogleTranslate::DetectLanguageService.new(hook: hook, message: message).perform
|
||||
end
|
||||
|
||||
def process_linear_integration(hook, event_name, event_data)
|
||||
return unless event_name == 'message.created'
|
||||
|
||||
message = event_data[:message]
|
||||
Integrations::Linear::AutoLinkService.new(account: hook.account, message: message).perform
|
||||
end
|
||||
|
||||
def process_leadsquared_integration_with_lock(hook, event_name, event_data)
|
||||
# Why do we need a mutex here? glad you asked
|
||||
# When a new conversation is created. We get a contact created event, immediately followed by
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
class Labels::RemoveAssociationsJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(label_title:, account_id:, label_deleted_at:)
|
||||
Labels::DestroyService.new(
|
||||
label_title: label_title,
|
||||
account_id: account_id,
|
||||
label_deleted_at: label_deleted_at
|
||||
).perform
|
||||
end
|
||||
end
|
||||
@@ -62,7 +62,8 @@ class HookListener < BaseListener
|
||||
'slack' => ['message.created', 'message.updated'],
|
||||
'dialogflow' => ['message.created', 'message.updated'],
|
||||
'google_translate' => ['message.created'],
|
||||
'leadsquared' => ['contact.updated', 'conversation.created', 'conversation.resolved']
|
||||
'leadsquared' => ['contact.updated', 'conversation.created', 'conversation.resolved'],
|
||||
'linear' => ['message.created']
|
||||
}
|
||||
|
||||
return false unless supported_events_map.key?(hook.app_id)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
# email :string not null
|
||||
# forward_to_email :string not null
|
||||
# imap_address :string default("")
|
||||
# imap_authentication :string default("plain")
|
||||
# imap_enable_ssl :boolean default(TRUE)
|
||||
# imap_enabled :boolean default(FALSE)
|
||||
# imap_login :string default("")
|
||||
@@ -47,7 +48,7 @@ class Channel::Email < ApplicationRecord
|
||||
end
|
||||
|
||||
self.table_name = 'channel_email'
|
||||
EDITABLE_ATTRS = [:email, :imap_enabled, :imap_login, :imap_password, :imap_address, :imap_port, :imap_enable_ssl,
|
||||
EDITABLE_ATTRS = [:email, :imap_enabled, :imap_login, :imap_password, :imap_address, :imap_port, :imap_enable_ssl, :imap_authentication,
|
||||
:smtp_enabled, :smtp_login, :smtp_password, :smtp_address, :smtp_port, :smtp_domain, :smtp_enable_starttls_auto,
|
||||
:smtp_enable_ssl_tls, :smtp_openssl_verify_mode, :smtp_authentication, :provider, :verified_for_sending].freeze
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
class CustomAttributeDefinitionPolicy < ApplicationPolicy
|
||||
def index?
|
||||
@account_user.administrator? || @account_user.agent?
|
||||
end
|
||||
|
||||
def show?
|
||||
@account_user.administrator? || @account_user.agent?
|
||||
end
|
||||
|
||||
def create?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def update?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
@@ -89,11 +89,19 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
|
||||
metadata[metadata_key] = activity_id
|
||||
store_conversation_metadata(conversation, metadata)
|
||||
rescue Crm::Leadsquared::Api::BaseClient::ApiError => e
|
||||
ChatwootExceptionTracker.new(e, account: @account).capture_exception
|
||||
Rails.logger.error "LeadSquared API error in #{activity_type} activity: #{e.message}"
|
||||
log_activity_error(e, activity_type, conversation, payload: { lead_id: lead_id, activity_code: activity_code, activity_note: activity_note })
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @account).capture_exception
|
||||
Rails.logger.error "Error creating #{activity_type} activity in LeadSquared: #{e.message}"
|
||||
log_activity_error(e, activity_type, conversation)
|
||||
end
|
||||
|
||||
def log_activity_error(error, activity_type, conversation, payload: nil)
|
||||
ChatwootExceptionTracker.new(error, account: @account).capture_exception
|
||||
context = "account_id=#{conversation.account_id}, conversation_display_id=#{conversation.display_id}"
|
||||
if payload
|
||||
context += ", http_status=#{error.code}, prospect_id=#{payload[:lead_id]}, " \
|
||||
"activity_event=#{payload[:activity_code]}, note_bytes=#{payload[:activity_note].to_s.bytesize}"
|
||||
end
|
||||
Rails.logger.error("LeadSquared #{activity_type} activity failed: #{error.message} (#{context})")
|
||||
end
|
||||
|
||||
def get_activity_code(key)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
module Imap::Authentication
|
||||
DEFAULT_MECHANISM = 'plain'.freeze
|
||||
USER_CONFIGURABLE_MECHANISMS = %w[plain login cram-md5].freeze
|
||||
|
||||
module_function
|
||||
|
||||
def normalize(mechanism)
|
||||
mechanism.presence || DEFAULT_MECHANISM
|
||||
end
|
||||
|
||||
def validate_user_configurable!(mechanism)
|
||||
normalized_mechanism = normalize(mechanism).to_s.downcase
|
||||
return normalized_mechanism if USER_CONFIGURABLE_MECHANISMS.include?(normalized_mechanism)
|
||||
|
||||
allowed_values = USER_CONFIGURABLE_MECHANISMS.join(', ')
|
||||
raise StandardError, "Invalid IMAP authentication mechanism. Allowed values: #{allowed_values}"
|
||||
end
|
||||
|
||||
def authenticate!(imap, mechanism, username, password)
|
||||
normalized_mechanism = normalize(mechanism).to_s.downcase
|
||||
|
||||
case normalized_mechanism
|
||||
when 'cram-md5'
|
||||
imap.authenticate('CRAM-MD5', username, password)
|
||||
when 'login'
|
||||
imap.login(username, password)
|
||||
else
|
||||
imap.authenticate(normalize(mechanism), username, password)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -130,8 +130,9 @@ class Imap::BaseFetchEmailService
|
||||
end
|
||||
|
||||
def build_imap_client
|
||||
imap = Net::IMAP.new(channel.imap_address, port: channel.imap_port, ssl: true)
|
||||
imap.authenticate(authentication_type, channel.imap_login, imap_password)
|
||||
imap = Net::IMAP.new(channel.imap_address, port: channel.imap_port, ssl: channel.imap_enable_ssl)
|
||||
Imap::Authentication.authenticate!(imap, authentication_type, channel.imap_login, imap_password)
|
||||
|
||||
imap.select('INBOX')
|
||||
imap
|
||||
end
|
||||
|
||||
@@ -6,7 +6,7 @@ class Imap::FetchEmailService < Imap::BaseFetchEmailService
|
||||
private
|
||||
|
||||
def authentication_type
|
||||
'PLAIN'
|
||||
channel.imap_authentication || 'plain'
|
||||
end
|
||||
|
||||
def imap_password
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
class Labels::DestroyService
|
||||
pattr_initialize [:label_title!, :account_id!, :label_deleted_at!]
|
||||
|
||||
def perform
|
||||
remove_conversation_labels
|
||||
remove_contact_labels
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def remove_conversation_labels
|
||||
tagged_conversations.find_in_batches do |conversation_batch|
|
||||
conversation_batch.each do |conversation|
|
||||
update_conversation_cached_labels(conversation)
|
||||
end
|
||||
delete_label_taggings('Conversation', conversation_batch.map(&:id))
|
||||
end
|
||||
end
|
||||
|
||||
def remove_contact_labels
|
||||
contact_label_taggings.in_batches do |tagging_batch|
|
||||
ActsAsTaggableOn::Tagging.where(id: tagging_batch.select(:id)).delete_all
|
||||
end
|
||||
end
|
||||
|
||||
def update_conversation_cached_labels(conversation)
|
||||
label_list = conversation.label_list.dup
|
||||
label_list.remove(label_title)
|
||||
|
||||
# We only want the acts-as-taggable-on cache effect here, not Conversation callbacks/events.
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
conversation.update_column(:cached_label_list, label_list.join("#{ActsAsTaggableOn.delimiter} "))
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
def tagged_conversations
|
||||
account.conversations.where(id: label_taggings_for('Conversation').select(:taggable_id))
|
||||
end
|
||||
|
||||
def contact_label_taggings
|
||||
label_taggings_for('Contact').where(taggable_id: account.contacts.select(:id))
|
||||
end
|
||||
|
||||
def delete_label_taggings(taggable_type, taggable_ids)
|
||||
ActsAsTaggableOn::Tagging
|
||||
.where(id: label_taggings_for(taggable_type).where(taggable_id: taggable_ids).select(:id))
|
||||
.delete_all
|
||||
end
|
||||
|
||||
def label_taggings_for(taggable_type)
|
||||
ActsAsTaggableOn::Tagging
|
||||
.joins(:tag)
|
||||
.where(context: 'labels', taggable_type: taggable_type, tags: { name: label_title })
|
||||
.where('taggings.created_at <= ?', label_deleted_at)
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= Account.find(account_id)
|
||||
end
|
||||
end
|
||||
@@ -8,8 +8,8 @@ class Messages::MentionService
|
||||
return if validated_mentioned_ids.blank?
|
||||
|
||||
Conversations::UserMentionJob.perform_later(validated_mentioned_ids, message.conversation.id, message.account.id)
|
||||
generate_notifications_for_mentions(validated_mentioned_ids)
|
||||
add_mentioned_users_as_participants(validated_mentioned_ids)
|
||||
generate_notifications_for_mentions(validated_mentioned_ids)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
class Onboarding::WebWidgetCreationService
|
||||
DEFAULT_WIDGET_COLOR = '#1f93ff'.freeze
|
||||
# context.dev descriptions and LLM completions are unbounded; bound the
|
||||
# stored tagline so a long string doesn't render as a wall of text in the
|
||||
# widget UI (and so backends that enforce varchar limits don't raise).
|
||||
WELCOME_TAGLINE_MAX_LENGTH = 255
|
||||
|
||||
def initialize(account, user)
|
||||
@account = account
|
||||
@user = user
|
||||
end
|
||||
|
||||
def perform
|
||||
existing = existing_web_widget_inbox
|
||||
if existing
|
||||
Rails.logger.info "[WidgetCreation] Reusing existing web widget inbox #{existing.id} for account #{@account.id}"
|
||||
return existing
|
||||
end
|
||||
|
||||
if website_url.blank?
|
||||
Rails.logger.info "[WidgetCreation] Skipping for account #{@account.id}: no website_url available"
|
||||
return nil
|
||||
end
|
||||
|
||||
attrs = channel_attributes
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
channel = @account.web_widgets.create!(attrs)
|
||||
inbox = @account.inboxes.create!(name: @account.name, channel: channel)
|
||||
InboxMember.find_or_create_by!(inbox: inbox, user: @user)
|
||||
inbox
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WidgetCreation] #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def existing_web_widget_inbox
|
||||
@account.inboxes.find_by(channel_type: 'Channel::WebWidget')
|
||||
end
|
||||
|
||||
def channel_attributes
|
||||
{
|
||||
website_url: website_url,
|
||||
widget_color: widget_color,
|
||||
welcome_title: welcome_title,
|
||||
welcome_tagline: welcome_tagline_text&.truncate(WELCOME_TAGLINE_MAX_LENGTH)
|
||||
}
|
||||
end
|
||||
|
||||
def brand_info
|
||||
@brand_info ||= (@account.custom_attributes['brand_info'] || {}).deep_symbolize_keys
|
||||
end
|
||||
|
||||
def website_url
|
||||
@account.domain.presence || brand_info[:domain].presence
|
||||
end
|
||||
|
||||
def widget_color
|
||||
hex = brand_info[:colors]&.first&.dig(:hex)
|
||||
hex.to_s.match?(/\A#\h{6}\z/) ? hex : DEFAULT_WIDGET_COLOR
|
||||
end
|
||||
|
||||
def welcome_title
|
||||
brand_info[:title].presence || @account.name
|
||||
end
|
||||
|
||||
def welcome_tagline_text
|
||||
brand_info[:slogan].presence || brand_info[:description].presence
|
||||
end
|
||||
end
|
||||
|
||||
Onboarding::WebWidgetCreationService.prepend_mod_with('Onboarding::WebWidgetCreationService')
|
||||
@@ -90,6 +90,7 @@ if resource.email?
|
||||
json.imap_port resource.channel.try(:imap_port)
|
||||
json.imap_enabled resource.channel.try(:imap_enabled)
|
||||
json.imap_enable_ssl resource.channel.try(:imap_enable_ssl)
|
||||
json.imap_authentication resource.channel.try(:imap_authentication)
|
||||
|
||||
if resource.channel.try(:microsoft?) || resource.channel.try(:google?) || resource.channel.try(:legacy_google?)
|
||||
json.reauthorization_required resource.channel.try(:provider_config).empty? || resource.channel.try(:reauthorization_required?)
|
||||
|
||||
+39788
-2540
File diff suppressed because it is too large
Load Diff
@@ -367,6 +367,7 @@ en:
|
||||
name: 'Linear'
|
||||
short_description: 'Create and link Linear issues directly from conversations.'
|
||||
description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
|
||||
attachment_link_title: 'Conversation (#%{conversation_id}) with %{name}'
|
||||
notion:
|
||||
name: 'Notion'
|
||||
short_description: 'Integrate databases, documents and pages directly with Captain.'
|
||||
|
||||
@@ -187,6 +187,8 @@ Rails.application.routes.draw do
|
||||
get :search
|
||||
end
|
||||
end
|
||||
resources :conversations, only: [:index]
|
||||
resources :notes, only: [:index]
|
||||
end
|
||||
end
|
||||
resources :contacts, only: [:index, :show, :update, :create, :destroy] do
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
class AddSyncStatsIndexToCaptainDocuments < ActiveRecord::Migration[7.0]
|
||||
def up
|
||||
add_index :captain_documents,
|
||||
[:account_id, :assistant_id, :sync_status, :last_synced_at],
|
||||
name: 'idx_captain_documents_on_account_assistant_sync_stats',
|
||||
if_not_exists: true
|
||||
end
|
||||
|
||||
def down
|
||||
remove_index :captain_documents, name: 'idx_captain_documents_on_account_assistant_sync_stats', if_exists: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddImapAuthenticationToChannelEmail < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :channel_email, :imap_authentication, :string, default: 'plain'
|
||||
end
|
||||
end
|
||||
+4
-2
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_04_30_114500) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_05_07_000000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -381,11 +381,12 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_30_114500) do
|
||||
t.integer "sync_status"
|
||||
t.datetime "last_synced_at"
|
||||
t.datetime "last_sync_attempted_at"
|
||||
t.index ["account_id", "assistant_id", "sync_status", "last_synced_at"], name: "idx_captain_documents_on_account_assistant_sync_stats"
|
||||
t.index ["account_id", "sync_status"], name: "index_captain_documents_on_account_id_and_sync_status"
|
||||
t.index ["account_id"], name: "index_captain_documents_on_account_id"
|
||||
t.index ["assistant_id", "external_link"], name: "index_captain_documents_on_assistant_id_and_external_link", unique: true
|
||||
t.index ["assistant_id"], name: "index_captain_documents_on_assistant_id"
|
||||
t.index ["status"], name: "index_captain_documents_on_status"
|
||||
t.index ["account_id", "sync_status"], name: "index_captain_documents_on_account_id_and_sync_status"
|
||||
end
|
||||
|
||||
create_table "captain_inboxes", force: :cascade do |t|
|
||||
@@ -472,6 +473,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_30_114500) do
|
||||
t.boolean "smtp_enable_ssl_tls", default: false
|
||||
t.jsonb "provider_config", default: {}
|
||||
t.string "provider"
|
||||
t.string "imap_authentication", default: "plain"
|
||||
t.boolean "verified_for_sending", default: false, null: false
|
||||
t.index ["email"], name: "index_channel_email_on_email", unique: true
|
||||
t.index ["forward_to_email"], name: "index_channel_email_on_forward_to_email", unique: true
|
||||
|
||||
@@ -77,7 +77,12 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
|
||||
next unless document.available?
|
||||
next if document.sync_in_progress?
|
||||
|
||||
document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
|
||||
document.update!(
|
||||
sync_status: :syncing,
|
||||
sync_step: nil,
|
||||
last_sync_error_code: nil,
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
Captain::Documents::PerformSyncJob.perform_later(document)
|
||||
synced_document_ids << document.id
|
||||
end
|
||||
|
||||
@@ -11,8 +11,13 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
def index
|
||||
base_query = @documents
|
||||
base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
|
||||
base_query = apply_source_filter(base_query, permitted_params[:source])
|
||||
base_query = apply_filter(base_query, permitted_params[:filter])
|
||||
base_query = apply_search(base_query, permitted_params[:search_key])
|
||||
base_query = apply_sort(base_query, permitted_params[:sort])
|
||||
|
||||
@documents_count = base_query.count
|
||||
@sync_interval_hours = current_sync_interval&.in_hours&.to_i
|
||||
@documents = base_query.page(@current_page).per(RESULTS_PER_PAGE)
|
||||
end
|
||||
|
||||
@@ -34,7 +39,12 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
return render_could_not_create_error(I18n.t('captain.documents.sync_only_available_documents')) unless @document.available?
|
||||
return render_could_not_create_error(I18n.t('captain.documents.sync_already_in_progress')) if @document.sync_in_progress?
|
||||
|
||||
@document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
|
||||
@document.update!(
|
||||
sync_status: :syncing,
|
||||
sync_step: nil,
|
||||
last_sync_error_code: nil,
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
Captain::Documents::PerformSyncJob.perform_later(@document)
|
||||
head :accepted
|
||||
end
|
||||
@@ -47,7 +57,7 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
private
|
||||
|
||||
def set_documents
|
||||
@documents = Current.account.captain_documents.includes(:assistant).ordered
|
||||
@documents = Current.account.captain_documents.with_attached_pdf_file.includes(:assistant)
|
||||
end
|
||||
|
||||
def set_document
|
||||
@@ -63,7 +73,58 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:assistant_id, :page, :id, :account_id)
|
||||
params.permit(:assistant_id, :page, :id, :account_id, :filter, :source, :sort, :search_key)
|
||||
end
|
||||
|
||||
def apply_source_filter(scope, source)
|
||||
case source
|
||||
when 'web' then scope.syncable
|
||||
when 'pdf' then scope.pdf_documents
|
||||
else scope
|
||||
end
|
||||
end
|
||||
|
||||
def apply_filter(scope, filter)
|
||||
case filter
|
||||
when 'stale' then stale_documents(scope.syncable)
|
||||
when 'synced' then up_to_date_documents(scope.syncable)
|
||||
when 'syncing' then scope.syncable.sync_in_progress
|
||||
when 'failed' then scope.syncable.sync_failed
|
||||
else scope
|
||||
end
|
||||
end
|
||||
|
||||
def apply_search(scope, search_key)
|
||||
return scope if search_key.blank?
|
||||
|
||||
query = "%#{ActiveRecord::Base.sanitize_sql_like(search_key)}%"
|
||||
scope.where('captain_documents.name ILIKE :query OR captain_documents.external_link ILIKE :query', query: query)
|
||||
end
|
||||
|
||||
def apply_sort(scope, sort)
|
||||
case sort
|
||||
when 'recently_created' then scope.order(created_at: :desc)
|
||||
else scope.order(updated_at: :desc)
|
||||
end
|
||||
end
|
||||
|
||||
def stale_documents(scope)
|
||||
return scope.none unless current_sync_interval
|
||||
|
||||
scope.sync_synced.where(Captain::Document.arel_table[:last_synced_at].lt(current_sync_interval.ago))
|
||||
end
|
||||
|
||||
def up_to_date_documents(scope)
|
||||
documents = scope.sync_synced
|
||||
return documents unless current_sync_interval
|
||||
|
||||
documents.where(Captain::Document.arel_table[:last_synced_at].gteq(current_sync_interval.ago))
|
||||
end
|
||||
|
||||
def current_sync_interval
|
||||
return @current_sync_interval if defined?(@current_sync_interval)
|
||||
|
||||
@current_sync_interval = Current.account.captain_document_sync_interval
|
||||
end
|
||||
|
||||
def document_params
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
class Api::V1::Accounts::Companies::BaseController < Api::V1::Accounts::EnterpriseAccountsController
|
||||
before_action :ensure_companies_enabled!
|
||||
before_action :fetch_company
|
||||
|
||||
private
|
||||
|
||||
def ensure_companies_enabled!
|
||||
return if Current.account.feature_enabled?('companies')
|
||||
|
||||
render json: { error: 'Companies are not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
|
||||
def fetch_company
|
||||
@company = Current.account.companies.find(params[:company_id])
|
||||
end
|
||||
|
||||
def authorize_company_read!
|
||||
authorize(@company, :show?)
|
||||
end
|
||||
|
||||
def authorize_company_update!
|
||||
authorize(@company, :update?)
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class Api::V1::Accounts::Companies::ContactsController < Api::V1::Accounts::EnterpriseAccountsController
|
||||
class Api::V1::Accounts::Companies::ContactsController < Api::V1::Accounts::Companies::BaseController
|
||||
RESULTS_PER_PAGE = 15
|
||||
CONTACT_SEARCH_QUERY = [
|
||||
'contacts.name ILIKE :search',
|
||||
@@ -7,8 +7,6 @@ class Api::V1::Accounts::Companies::ContactsController < Api::V1::Accounts::Ente
|
||||
'contacts.identifier ILIKE :search'
|
||||
].join(' OR ')
|
||||
|
||||
before_action :ensure_companies_enabled!
|
||||
before_action :fetch_company
|
||||
before_action :authorize_company_read!, only: [:index, :search]
|
||||
before_action :authorize_company_update!, only: [:create, :destroy]
|
||||
before_action :set_current_page, only: [:index, :search]
|
||||
@@ -45,10 +43,6 @@ class Api::V1::Accounts::Companies::ContactsController < Api::V1::Accounts::Ente
|
||||
@current_page = params[:page] || 1
|
||||
end
|
||||
|
||||
def fetch_company
|
||||
@company = Current.account.companies.find(params[:company_id])
|
||||
end
|
||||
|
||||
def fetch_contact
|
||||
@contact = @company.contacts.find(params[:id])
|
||||
end
|
||||
@@ -70,18 +64,4 @@ class Api::V1::Accounts::Companies::ContactsController < Api::V1::Accounts::Ente
|
||||
def membership_service
|
||||
@membership_service ||= Companies::ContactMembershipService.new(company: @company)
|
||||
end
|
||||
|
||||
def ensure_companies_enabled!
|
||||
return if Current.account.feature_enabled?('companies')
|
||||
|
||||
render json: { error: 'Companies are not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
|
||||
def authorize_company_read!
|
||||
authorize(@company, :show?)
|
||||
end
|
||||
|
||||
def authorize_company_update!
|
||||
authorize(@company, :update?)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
class Api::V1::Accounts::Companies::ConversationsController < Api::V1::Accounts::Companies::BaseController
|
||||
before_action :authorize_company_read!
|
||||
|
||||
def index
|
||||
conversations = Current.account.conversations.includes(
|
||||
:assignee, :contact, :inbox, :taggings
|
||||
).where(contact_id: @company.contacts.select(:id))
|
||||
|
||||
@conversations = Conversations::PermissionFilterService.new(
|
||||
conversations,
|
||||
Current.user,
|
||||
Current.account
|
||||
).perform.order(last_activity_at: :desc).limit(20)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
class Api::V1::Accounts::Companies::NotesController < Api::V1::Accounts::Companies::BaseController
|
||||
before_action :authorize_company_read!
|
||||
|
||||
def index
|
||||
@notes = Current.account.notes
|
||||
.where(contact_id: @company.contacts.select(:id))
|
||||
.latest
|
||||
.includes(:contact, :user)
|
||||
.limit(20)
|
||||
end
|
||||
end
|
||||
@@ -20,13 +20,13 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
|
||||
exception_class: error.class.name)
|
||||
end
|
||||
|
||||
# Permanent errors (404, 403, empty content) — no point retrying, discard immediately.
|
||||
# Permanent errors (404, 403, empty content) - no point retrying, discard immediately.
|
||||
# Document is already marked failed by SyncService before the exception reaches here.
|
||||
discard_on(Captain::Documents::SyncService::PermanentSyncError)
|
||||
|
||||
# TransientSyncError is raised by SyncService when the customer's site is unreachable —
|
||||
# TransientSyncError is raised by SyncService when the customer's site is unreachable -
|
||||
# timeouts, TLS errors, 5xx, connection drops. Four attempts with backoff gives the site
|
||||
# a chance to recover before we give up.
|
||||
# a chance to recover before we mark the document failed.
|
||||
#
|
||||
# The exhaustion block absorbs the exception so it doesn't propagate to Sentry —
|
||||
# site flakiness isn't an application bug.
|
||||
@@ -36,6 +36,7 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
|
||||
attempts: 4
|
||||
) do |job, error|
|
||||
document = job.arguments.first
|
||||
job.send(:mark_sync_failed, document, error.message)
|
||||
job.send(:log_sync_outcome, document, result: :transient_retry_exhausted, error_code: error.message)
|
||||
end
|
||||
|
||||
@@ -47,7 +48,7 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
|
||||
return if document.pdf_document?
|
||||
|
||||
with_lock(lock_key(document), LOCK_TIMEOUT) do
|
||||
document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
|
||||
mark_sync_started(document)
|
||||
result = Captain::Documents::SyncService.new(document.reload).perform
|
||||
log_sync_outcome(document, result: result, duration_ms: duration_ms_since(start_time))
|
||||
end
|
||||
@@ -78,13 +79,26 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
|
||||
raise error
|
||||
end
|
||||
|
||||
def handle_unexpected_failure(document, error, start_time)
|
||||
def mark_sync_failed(document, error_code)
|
||||
document.update!(
|
||||
sync_status: :failed,
|
||||
sync_step: nil,
|
||||
last_sync_error_code: 'sync_error',
|
||||
last_sync_error_code: error_code,
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
def mark_sync_started(document)
|
||||
document.update!(
|
||||
sync_status: :syncing,
|
||||
sync_step: nil,
|
||||
last_sync_error_code: nil,
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
def handle_unexpected_failure(document, error, start_time)
|
||||
mark_sync_failed(document, 'sync_error')
|
||||
log_sync_outcome(document, result: :unexpected_failure, error_code: 'sync_error',
|
||||
exception_class: error.class.name,
|
||||
duration_ms: duration_ms_since(start_time))
|
||||
|
||||
@@ -82,7 +82,7 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
|
||||
end
|
||||
|
||||
def reserve_sync_slot(document)
|
||||
document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
|
||||
mark_sync_started(document)
|
||||
true
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
log_document_skip(document, e)
|
||||
@@ -112,4 +112,13 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
|
||||
|
||||
Rails.logger.info("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}")
|
||||
end
|
||||
|
||||
def mark_sync_started(document)
|
||||
document.update!(
|
||||
sync_status: :syncing,
|
||||
sync_step: nil,
|
||||
last_sync_error_code: nil,
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# content :text
|
||||
# content_fingerprint :string
|
||||
# external_link :string not null
|
||||
# last_sync_attempted_at :datetime
|
||||
# last_sync_error_code :string
|
||||
# last_synced_at :datetime
|
||||
# metadata :jsonb
|
||||
# name :string
|
||||
@@ -18,6 +20,7 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_captain_documents_on_account_assistant_sync_stats (account_id,assistant_id,sync_status,last_synced_at)
|
||||
# index_captain_documents_on_account_id (account_id)
|
||||
# index_captain_documents_on_account_id_and_sync_status (account_id,sync_status)
|
||||
# index_captain_documents_on_assistant_id (assistant_id)
|
||||
@@ -62,6 +65,14 @@ class Captain::Document < ApplicationRecord
|
||||
scope :for_account, ->(account_id) { where(account_id: account_id) }
|
||||
scope :for_assistant, ->(assistant_id) { where(assistant_id: assistant_id) }
|
||||
scope :syncable, -> { where("external_link NOT LIKE 'PDF:%' AND external_link NOT LIKE '%.pdf'") }
|
||||
scope :pdf_documents, -> { where("external_link LIKE 'PDF:%' OR external_link LIKE '%.pdf'") }
|
||||
scope :sync_in_progress, -> { sync_syncing.where(arel_table[:last_sync_attempted_at].gteq(SYNC_STALE_TIMEOUT.ago)) }
|
||||
scope :stale, lambda { |stale_before|
|
||||
sync_failed.or(sync_synced.where(arel_table[:last_synced_at].lt(stale_before)))
|
||||
}
|
||||
scope :synced_since, lambda { |time|
|
||||
sync_synced.where(arel_table[:last_synced_at].gteq(time))
|
||||
}
|
||||
|
||||
def pdf_document?
|
||||
return true if pdf_file.attached? && pdf_file.blob.content_type == 'application/pdf'
|
||||
|
||||
@@ -7,6 +7,10 @@ class Captain::AssistantPolicy < ApplicationPolicy
|
||||
true
|
||||
end
|
||||
|
||||
def stats?
|
||||
true
|
||||
end
|
||||
|
||||
def tools?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
@@ -15,10 +15,7 @@ class Captain::Documents::SyncService
|
||||
@document.update!(sync_step: 'fetching')
|
||||
result = Captain::Documents::SinglePageFetcher.new(@document.external_link).fetch
|
||||
|
||||
unless result.success
|
||||
mark_failed(result.error_code)
|
||||
raise_for_error_code(result.error_code)
|
||||
end
|
||||
handle_fetch_error(result.error_code) unless result.success
|
||||
|
||||
@document.update!(sync_step: 'comparing')
|
||||
new_fingerprint = compute_fingerprint(result.content)
|
||||
@@ -75,8 +72,11 @@ class Captain::Documents::SyncService
|
||||
)
|
||||
end
|
||||
|
||||
def raise_for_error_code(error_code)
|
||||
raise PermanentSyncError, error_code if PERMANENT_ERROR_CODES.include?(error_code)
|
||||
def handle_fetch_error(error_code)
|
||||
if PERMANENT_ERROR_CODES.include?(error_code)
|
||||
mark_failed(error_code)
|
||||
raise PermanentSyncError, error_code
|
||||
end
|
||||
|
||||
raise TransientSyncError, error_code
|
||||
end
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class Captain::Llm::WidgetTaglineSchema < RubyLLM::Schema
|
||||
string :tagline,
|
||||
description: 'Short marketing tagline for a customer-support chat widget. Plain text, no quotes, no emoji, no trailing punctuation.',
|
||||
max_length: 60
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
class Captain::Llm::WidgetTaglineService < Captain::BaseTaskService
|
||||
RESPONSE_SCHEMA = Captain::Llm::WidgetTaglineSchema
|
||||
|
||||
pattr_initialize [:account!]
|
||||
|
||||
def perform
|
||||
response = make_api_call(model: tagline_model, messages: messages, schema: RESPONSE_SCHEMA)
|
||||
return response if response[:error]
|
||||
|
||||
response.merge(message: extract_tagline(response[:message]))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_tagline(message)
|
||||
tagline = message.is_a?(Hash) ? (message['tagline'] || message[:tagline]) : message
|
||||
tagline.to_s.strip
|
||||
end
|
||||
|
||||
def messages
|
||||
[
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: user_prompt }
|
||||
]
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
<<~PROMPT
|
||||
You write a short marketing tagline for a company's customer-support chat widget.
|
||||
Use the provided company context to make the tagline specific and on-brand.
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def user_prompt
|
||||
parts = [
|
||||
"Company: #{account.name}",
|
||||
("Title: #{brand_info[:title]}" if brand_info[:title].present?),
|
||||
("Description: #{brand_info[:description]}" if brand_info[:description].present?),
|
||||
("Slogan: #{brand_info[:slogan]}" if brand_info[:slogan].present?),
|
||||
("Industries: #{industries_text}" if industries_text.present?)
|
||||
].compact
|
||||
parts.join("\n")
|
||||
end
|
||||
|
||||
def brand_info
|
||||
@brand_info ||= (account.custom_attributes['brand_info'] || {}).deep_symbolize_keys
|
||||
end
|
||||
|
||||
def industries_text
|
||||
Array(brand_info[:industries]).filter_map { |i| i.is_a?(Hash) ? i[:industry] : i }.join(', ').presence
|
||||
end
|
||||
|
||||
def event_name
|
||||
'widget_tagline'
|
||||
end
|
||||
|
||||
def llm_credential
|
||||
@llm_credential ||= system_llm_credential
|
||||
end
|
||||
|
||||
def captain_tasks_enabled?
|
||||
true
|
||||
end
|
||||
|
||||
# Tagline generation runs on the operator's OpenAI key during onboarding;
|
||||
# the customer should not have captain_responses quota deducted for it.
|
||||
def counts_toward_usage?
|
||||
false
|
||||
end
|
||||
|
||||
def tagline_model
|
||||
@tagline_model ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || GPT_MODEL
|
||||
end
|
||||
|
||||
def build_follow_up_context?
|
||||
false
|
||||
end
|
||||
end
|
||||
@@ -4,15 +4,17 @@ class Enterprise::Billing::CreateStripeCustomerService
|
||||
DEFAULT_QUANTITY = 2
|
||||
|
||||
def perform
|
||||
return if existing_subscription?
|
||||
active_sub = active_subscription
|
||||
return false if active_sub && !default_plan_subscription?(active_sub)
|
||||
|
||||
customer_id = prepare_customer_id
|
||||
subscription = Stripe::Subscription.create(customer: customer_id, items: [{ price: price_id, quantity: default_quantity }])
|
||||
subscription = active_sub || Stripe::Subscription.create(customer: customer_id, items: [{ price: price_id, quantity: default_quantity }])
|
||||
custom_attributes = build_custom_attributes(customer_id, subscription)
|
||||
custom_attributes.except!('is_creating_customer')
|
||||
|
||||
account.update!(custom_attributes: custom_attributes)
|
||||
Enterprise::Billing::ReconcilePlanFeaturesService.new(account: account).perform
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
@@ -44,18 +46,21 @@ class Enterprise::Billing::CreateStripeCustomerService
|
||||
price_ids.first
|
||||
end
|
||||
|
||||
def existing_subscription?
|
||||
def active_subscription
|
||||
stripe_customer_id = account.custom_attributes['stripe_customer_id']
|
||||
return false if stripe_customer_id.blank?
|
||||
return nil if stripe_customer_id.blank?
|
||||
|
||||
subscriptions = Stripe::Subscription.list(
|
||||
Stripe::Subscription.list(
|
||||
{
|
||||
customer: stripe_customer_id,
|
||||
status: 'active',
|
||||
limit: 1
|
||||
}
|
||||
)
|
||||
subscriptions.data.present?
|
||||
).data.first
|
||||
end
|
||||
|
||||
def default_plan_subscription?(subscription)
|
||||
default_plan['price_ids'].include?(subscription['plan']['id'])
|
||||
end
|
||||
|
||||
def build_custom_attributes(customer_id, subscription)
|
||||
|
||||
@@ -47,9 +47,8 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
|
||||
def current_plan_credits
|
||||
plan_name = account.custom_attributes['plan_name']
|
||||
return { responses: 0, documents: 0 } if plan_name.blank?
|
||||
|
||||
get_plan_credits(plan_name)
|
||||
plan_credits = get_plan_credits(plan_name) if plan_name.present?
|
||||
plan_credits || { responses: 0, documents: 0 }
|
||||
end
|
||||
|
||||
def update_account_attributes(subscription, plan)
|
||||
@@ -71,19 +70,28 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
# skipping self hosted plan events
|
||||
return if account.blank?
|
||||
|
||||
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
|
||||
previous_monthly_credits = current_plan_credits[:responses]
|
||||
return unless Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
|
||||
|
||||
account.with_lock do
|
||||
previous_usage = { responses: account.custom_attributes['captain_responses_usage'].to_i, monthly: previous_monthly_credits }
|
||||
adjust_captain_credits(previous_usage, new_plan_credits: 0)
|
||||
account.reset_response_usage
|
||||
end
|
||||
end
|
||||
|
||||
def handle_subscription_credits(plan, previous_usage)
|
||||
current_limits = account.limits || {}
|
||||
adjust_captain_credits(previous_usage, new_plan_credits: get_plan_credits(plan['name'])[:responses])
|
||||
end
|
||||
|
||||
def adjust_captain_credits(previous_usage, new_plan_credits:)
|
||||
current_limits = account.limits || {}
|
||||
current_credits = current_limits['captain_responses'].to_i
|
||||
new_plan_credits = get_plan_credits(plan['name'])[:responses]
|
||||
|
||||
consumed_topup_credits = [previous_usage[:responses] - previous_usage[:monthly], 0].max
|
||||
updated_credits = current_credits - consumed_topup_credits - previous_usage[:monthly] + new_plan_credits
|
||||
updated_credits = [current_credits - consumed_topup_credits - previous_usage[:monthly] + new_plan_credits, 0].max
|
||||
|
||||
Rails.logger.info("Updating subscription credits for account #{account.id}: #{current_credits} -> #{updated_credits}")
|
||||
Rails.logger.info("Updating captain credits for account #{account.id}: #{current_credits} -> #{updated_credits}")
|
||||
account.update!(limits: current_limits.merge('captain_responses' => updated_credits))
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
module Enterprise::Onboarding::WebWidgetCreationService
|
||||
private
|
||||
|
||||
def welcome_tagline_text
|
||||
response = Captain::Llm::WidgetTaglineService.new(account: @account).perform
|
||||
response&.dig(:message).to_s.strip.presence || super
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WidgetCreation] LLM tagline failed: #{e.message}"
|
||||
super
|
||||
end
|
||||
end
|
||||
@@ -34,7 +34,10 @@ module Enterprise::WebsiteBrandingService
|
||||
|
||||
def process_response(response)
|
||||
@http_status = response.code
|
||||
raise "API Error: #{response.message} (Status: #{response.code})" unless response.success?
|
||||
unless response.success?
|
||||
Rails.logger.warn "[WebsiteBranding] Context.dev returned #{response.code}: #{response.parsed_response}"
|
||||
return nil
|
||||
end
|
||||
|
||||
brand = response.parsed_response&.dig('brand')
|
||||
return nil if brand.blank?
|
||||
|
||||
@@ -7,4 +7,5 @@ end
|
||||
json.meta do
|
||||
json.total_count @documents_count
|
||||
json.page @current_page
|
||||
json.sync_interval_hours @sync_interval_hours if @sync_interval_hours.present?
|
||||
end
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
json.payload do
|
||||
json.array! @conversations do |conversation|
|
||||
json.partial! 'api/v1/conversations/partials/conversation', formats: [:json], conversation: conversation
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
json.payload do
|
||||
json.array! @notes do |note|
|
||||
json.partial! 'api/v1/models/note', formats: [:json], resource: note
|
||||
json.contact do
|
||||
json.partial! 'api/v1/models/contact', formats: [:json], resource: note.contact
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -8,10 +8,12 @@ json.created_at resource.created_at.to_i
|
||||
json.external_link resource.external_link
|
||||
json.display_url resource.display_url
|
||||
json.file_size resource.file_size
|
||||
json.pdf_document resource.pdf_document?
|
||||
json.id resource.id
|
||||
json.name resource.name
|
||||
json.status resource.status
|
||||
json.sync_status resource.sync_status
|
||||
json.sync_in_progress resource.sync_in_progress?
|
||||
json.last_synced_at resource.last_synced_at&.to_i
|
||||
json.last_sync_attempted_at resource.last_sync_attempted_at&.to_i
|
||||
json.last_sync_error_code resource.last_sync_error_code
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module Enterprise::Captain::BaseTaskService
|
||||
def perform
|
||||
return { error: I18n.t('captain.copilot_limit'), error_code: 429 } unless responses_available?
|
||||
return { error: I18n.t('captain.copilot_limit'), error_code: 429 } if counts_toward_usage? && !responses_available?
|
||||
|
||||
unless captain_tasks_enabled?
|
||||
return { error: I18n.t('captain.upgrade') } if ChatwootApp.chatwoot_cloud?
|
||||
@@ -9,7 +9,7 @@ module Enterprise::Captain::BaseTaskService
|
||||
end
|
||||
|
||||
result = super
|
||||
increment_usage if successful_result?(result)
|
||||
increment_usage if counts_toward_usage? && successful_result?(result)
|
||||
result
|
||||
end
|
||||
|
||||
|
||||
@@ -149,6 +149,16 @@ class Captain::BaseTaskService
|
||||
account.feature_enabled?('captain_tasks')
|
||||
end
|
||||
|
||||
# Extension point consulted by the Enterprise quota wrapper. Subclasses
|
||||
# whose calls run on the operator's key (e.g. internal/onboarding tasks)
|
||||
# should override this to return false. When false, the wrapper neither
|
||||
# blocks the call on an exhausted captain_responses quota nor decrements
|
||||
# it on success — the call participates in the quota system in neither
|
||||
# direction.
|
||||
def counts_toward_usage?
|
||||
true
|
||||
end
|
||||
|
||||
def api_key_configured?
|
||||
llm_credential.present?
|
||||
end
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
class Integrations::Linear::AutoLinkService
|
||||
pattr_initialize [:account!, :message!]
|
||||
|
||||
LINEAR_URL_REGEX = %r{https?://linear\.app/[^/\s]+/issue/[A-Z][A-Z0-9_]+-\d+(?:/[^\s)]*)?}
|
||||
IDENTIFIER_REGEX = %r{/issue/([A-Z][A-Z0-9_]+-\d+)}i
|
||||
WORKSPACE_REGEX = %r{//linear\.app/([^/\s]+)/}i
|
||||
|
||||
def perform
|
||||
return unless valid_message?
|
||||
|
||||
attempt_link
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_message?
|
||||
message.private? && message.content.present? && message.sender.is_a?(User)
|
||||
end
|
||||
|
||||
def attempt_link
|
||||
linear_url = message.content[LINEAR_URL_REGEX]
|
||||
return if linear_url.blank?
|
||||
|
||||
identifier = linear_url[IDENTIFIER_REGEX, 1]&.upcase
|
||||
workspace = linear_url[WORKSPACE_REGEX, 1]&.downcase
|
||||
return if identifier.blank? || workspace.blank? || already_linked?(identifier)
|
||||
|
||||
finalize_link(workspace, identifier)
|
||||
end
|
||||
|
||||
def finalize_link(workspace, identifier)
|
||||
node_id = resolve_node_id(workspace, identifier)
|
||||
return if node_id.blank?
|
||||
return unless link_to_linear(node_id, identifier)
|
||||
|
||||
post_activity_message(identifier)
|
||||
end
|
||||
|
||||
def already_linked?(identifier)
|
||||
response = processor.linked_issues(conversation_link)
|
||||
return false if response[:error]
|
||||
|
||||
response[:data].any? { |attachment| attachment.dig('issue', 'identifier') == identifier }
|
||||
end
|
||||
|
||||
def resolve_node_id(workspace, identifier)
|
||||
response = processor.search_issue(identifier)
|
||||
return if response[:error]
|
||||
|
||||
node = response[:data].find do |issue|
|
||||
issue['identifier'] == identifier && node_workspace(issue) == workspace
|
||||
end
|
||||
node && node['id']
|
||||
end
|
||||
|
||||
def node_workspace(node)
|
||||
node['url']&.match(WORKSPACE_REGEX)&.[](1)&.downcase
|
||||
end
|
||||
|
||||
def link_to_linear(node_id, identifier)
|
||||
response = processor.link_issue(conversation_link, node_id, attachment_title, message.sender)
|
||||
if response[:error].present?
|
||||
Rails.logger.warn("[Linear::AutoLinkService] link_issue failed for #{identifier}: #{response[:error]}")
|
||||
return false
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
def attachment_title
|
||||
I18n.t(
|
||||
'integration_apps.linear.attachment_link_title',
|
||||
conversation_id: message.conversation.display_id,
|
||||
name: message.conversation.contact&.name
|
||||
)
|
||||
end
|
||||
|
||||
def post_activity_message(identifier)
|
||||
Linear::ActivityMessageService.new(
|
||||
conversation: message.conversation,
|
||||
action_type: :issue_linked,
|
||||
user: message.sender,
|
||||
issue_data: { id: identifier }
|
||||
).perform
|
||||
end
|
||||
|
||||
def conversation_link
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{message.account_id}/conversations/#{message.conversation.display_id}"
|
||||
end
|
||||
|
||||
def processor
|
||||
@processor ||= Integrations::Linear::ProcessorService.new(account: account)
|
||||
end
|
||||
end
|
||||
@@ -54,6 +54,7 @@ module Linear::Queries
|
||||
title
|
||||
description
|
||||
identifier
|
||||
url
|
||||
state {
|
||||
name
|
||||
color
|
||||
|
||||
+1
-6
@@ -22,16 +22,11 @@ module SafeFetch
|
||||
class FileTooLargeError < Error; end
|
||||
class UnsupportedContentTypeError < Error; end
|
||||
class UnsupportedMethodError < Error; end
|
||||
end
|
||||
|
||||
require_relative 'safe_fetch/request_options'
|
||||
require_relative 'safe_fetch/fetcher'
|
||||
|
||||
module SafeFetch
|
||||
def self.fetch(url, **, &)
|
||||
raise ArgumentError, 'block required' unless block_given?
|
||||
|
||||
Fetcher.new(RequestOptions.new(url: url, **)).fetch(&)
|
||||
SafeFetch::Fetcher.new(SafeFetch::RequestOptions.new(url: url, **)).fetch(&)
|
||||
rescue SsrfFilter::InvalidUriScheme, URI::InvalidURIError => e
|
||||
raise InvalidUrlError, e.message
|
||||
rescue SsrfFilter::Error, Resolv::ResolvError => e
|
||||
|
||||
@@ -6,9 +6,11 @@ describe NotificationBuilder do
|
||||
describe '#perform' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user) { create(:user, account: account) }
|
||||
let!(:primary_actor) { create(:conversation, account: account) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
let!(:primary_actor) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: user, inbox: inbox)
|
||||
notification_setting = user.notification_settings.find_by(account_id: account.id)
|
||||
notification_setting.selected_email_flags = [:email_conversation_creation]
|
||||
notification_setting.selected_push_flags = [:push_conversation_creation]
|
||||
@@ -97,5 +99,66 @@ describe NotificationBuilder do
|
||||
).perform
|
||||
end.to change { user.notifications.count }.by(1)
|
||||
end
|
||||
|
||||
context 'when the user does not have access to the conversation' do
|
||||
let!(:outsider) { create(:user, account: account) }
|
||||
|
||||
it 'does not create a notification for an agent without inbox or team access' do
|
||||
expect do
|
||||
described_class.new(
|
||||
notification_type: 'conversation_creation',
|
||||
user: outsider,
|
||||
account: account,
|
||||
primary_actor: primary_actor
|
||||
).perform
|
||||
end.not_to(change { outsider.notifications.count })
|
||||
end
|
||||
|
||||
it 'still creates a notification for administrators regardless of inbox membership' do
|
||||
admin = create(:user, account: account, role: :administrator)
|
||||
admin_setting = admin.notification_settings.find_by(account_id: account.id)
|
||||
admin_setting.selected_email_flags = [:email_conversation_creation]
|
||||
admin_setting.selected_push_flags = [:push_conversation_creation]
|
||||
admin_setting.save!
|
||||
|
||||
expect do
|
||||
described_class.new(
|
||||
notification_type: 'conversation_creation',
|
||||
user: admin,
|
||||
account: account,
|
||||
primary_actor: primary_actor
|
||||
).perform
|
||||
end.to change { admin.notifications.count }.by(1)
|
||||
end
|
||||
|
||||
it 'does not create a notification when the user is not part of the account' do
|
||||
unrelated_user = create(:user)
|
||||
|
||||
expect do
|
||||
described_class.new(
|
||||
notification_type: 'conversation_creation',
|
||||
user: unrelated_user,
|
||||
account: account,
|
||||
primary_actor: primary_actor
|
||||
).perform
|
||||
end.not_to(change { unrelated_user.notifications.count })
|
||||
end
|
||||
|
||||
it 'derives the conversation from a message primary_actor' do
|
||||
outsider_inbox = create(:inbox, account: account)
|
||||
message = create(:message, account: account, inbox: outsider_inbox,
|
||||
conversation: create(:conversation, account: account, inbox: outsider_inbox))
|
||||
|
||||
expect do
|
||||
described_class.new(
|
||||
notification_type: 'conversation_mention',
|
||||
user: outsider,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
).perform
|
||||
end.not_to(change { outsider.notifications.count })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,8 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Custom Attribute Definitions API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/custom_attribute_definitions' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
@@ -19,7 +20,7 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
|
||||
create(:custom_attribute_definition, attribute_model: 'contact_attribute', account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/custom_attribute_definitions",
|
||||
headers: user.create_new_auth_token,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
@@ -45,7 +46,7 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'shows the custom attribute definition' do
|
||||
get "/api/v1/accounts/#{account.id}/custom_attribute_definitions/#{custom_attribute_definition.id}",
|
||||
headers: user.create_new_auth_token,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
@@ -81,7 +82,7 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'creates the filter' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/custom_attribute_definitions", headers: user.create_new_auth_token,
|
||||
post "/api/v1/accounts/#{account.id}/custom_attribute_definitions", headers: admin.create_new_auth_token,
|
||||
params: payload
|
||||
end.to change(CustomAttributeDefinition, :count).by(1)
|
||||
|
||||
@@ -90,6 +91,18 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
|
||||
expect(json_response['attribute_key']).to eq 'developer_id'
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns forbidden and does not create the custom attribute' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/custom_attribute_definitions",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: payload
|
||||
end.not_to change(CustomAttributeDefinition, :count)
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when creating with a conflicting attribute_key' do
|
||||
let(:standard_key) { CustomAttributeDefinition::STANDARD_ATTRIBUTES[:conversation].first }
|
||||
let(:conflicting_payload) do
|
||||
@@ -105,7 +118,7 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
|
||||
|
||||
it 'returns error for conflicting key' do
|
||||
post "/api/v1/accounts/#{account.id}/custom_attribute_definitions",
|
||||
headers: user.create_new_auth_token,
|
||||
headers: admin.create_new_auth_token,
|
||||
params: conflicting_payload
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
@@ -132,7 +145,7 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'updates the custom attribute definition' do
|
||||
patch "/api/v1/accounts/#{account.id}/custom_attribute_definitions/#{custom_attribute_definition.id}",
|
||||
headers: user.create_new_auth_token,
|
||||
headers: admin.create_new_auth_token,
|
||||
params: payload,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
@@ -141,6 +154,19 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
|
||||
expect(custom_attribute_definition.reload.attribute_model).to eq('conversation_attribute')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns forbidden and does not update the custom attribute' do
|
||||
original_name = custom_attribute_definition.attribute_display_name
|
||||
patch "/api/v1/accounts/#{account.id}/custom_attribute_definitions/#{custom_attribute_definition.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: payload,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(custom_attribute_definition.reload.attribute_display_name).to eq(original_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/custom_attribute_definitions/:id' do
|
||||
@@ -156,11 +182,22 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
|
||||
context 'when it is an authenticated admin user' do
|
||||
it 'deletes custom attribute' do
|
||||
delete "/api/v1/accounts/#{account.id}/custom_attribute_definitions/#{custom_attribute_definition.id}",
|
||||
headers: user.create_new_auth_token,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:no_content)
|
||||
expect(account.custom_attribute_definitions.count).to be 0
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns forbidden and does not delete the custom attribute' do
|
||||
delete "/api/v1/accounts/#{account.id}/custom_attribute_definitions/#{custom_attribute_definition.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(account.custom_attribute_definitions.count).to be 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -568,8 +568,10 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
email_channel = create(:channel_email, account: account)
|
||||
email_inbox = create(:inbox, channel: email_channel, account: account)
|
||||
|
||||
imap_connection = double
|
||||
allow(Mail).to receive(:connection).and_return(imap_connection)
|
||||
imap_connection = instance_double(Net::IMAP, disconnected?: false)
|
||||
allow(Net::IMAP).to receive(:new).and_return(imap_connection)
|
||||
allow(imap_connection).to receive(:login)
|
||||
allow(imap_connection).to receive(:disconnect)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
@@ -578,7 +580,8 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
imap_enabled: true,
|
||||
imap_address: 'imap.gmail.com',
|
||||
imap_port: 993,
|
||||
imap_login: 'imaptest@gmail.com'
|
||||
imap_login: 'imaptest@gmail.com',
|
||||
imap_authentication: 'login'
|
||||
}
|
||||
},
|
||||
as: :json
|
||||
@@ -587,6 +590,7 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
expect(email_channel.reload.imap_enabled).to be true
|
||||
expect(email_channel.reload.imap_address).to eq('imap.gmail.com')
|
||||
expect(email_channel.reload.imap_port).to eq(993)
|
||||
expect(email_channel.reload.imap_authentication).to eq('login')
|
||||
end
|
||||
|
||||
it 'updates avatar when administrator' do
|
||||
|
||||
@@ -3,6 +3,7 @@ require 'rails_helper'
|
||||
RSpec.describe 'Label API', type: :request do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:label) { create(:label, account: account) }
|
||||
let!(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/labels' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
@@ -101,4 +102,39 @@ RSpec.describe 'Label API', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/labels/:id' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/labels/#{label.id}"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'deletes the label and enqueues label cleanup' do
|
||||
label_deleted_at = Time.zone.parse('2026-05-07 10:00:00 UTC')
|
||||
conversation.label_list.add(label.title)
|
||||
conversation.save!
|
||||
|
||||
clear_enqueued_jobs
|
||||
|
||||
travel_to(label_deleted_at) do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/labels/#{label.id}", headers: admin.create_new_auth_token, as: :json
|
||||
end.to have_enqueued_job(Labels::RemoveAssociationsJob).with(
|
||||
label_title: label.title,
|
||||
account_id: account.id,
|
||||
label_deleted_at: label_deleted_at
|
||||
)
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(Label.exists?(label.id)).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe NotificationBuilder do
|
||||
describe '#perform with custom role permissions' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:agent) { create(:user, account: account, role: :agent) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
let!(:account_user) { agent.account_users.find_by(account: account) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
notification_setting = agent.notification_settings.find_by(account_id: account.id)
|
||||
notification_setting.selected_email_flags = [:email_conversation_creation]
|
||||
notification_setting.selected_push_flags = [:push_conversation_creation]
|
||||
notification_setting.save!
|
||||
end
|
||||
|
||||
def build_notification(conversation, type: 'conversation_creation')
|
||||
described_class.new(
|
||||
notification_type: type,
|
||||
user: agent,
|
||||
account: account,
|
||||
primary_actor: conversation
|
||||
).perform
|
||||
end
|
||||
|
||||
context 'when the agent has conversation_manage permission' do
|
||||
before do
|
||||
custom_role = create(:custom_role, account: account, permissions: ['conversation_manage'])
|
||||
account_user.update!(custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'creates a notification for any inbox conversation' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
|
||||
expect { build_notification(conversation) }.to change { agent.notifications.count }.by(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the agent has conversation_unassigned_manage permission' do
|
||||
before do
|
||||
custom_role = create(:custom_role, account: account, permissions: ['conversation_unassigned_manage'])
|
||||
account_user.update!(custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'creates a notification for unassigned conversations' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox, assignee: nil)
|
||||
|
||||
expect { build_notification(conversation) }.to change { agent.notifications.count }.by(1)
|
||||
end
|
||||
|
||||
it 'creates a notification for conversations assigned to the agent' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox, assignee: agent)
|
||||
|
||||
expect { build_notification(conversation) }.to change { agent.notifications.count }.by(1)
|
||||
end
|
||||
|
||||
it 'does not create a notification for conversations assigned to someone else' do
|
||||
other_agent = create(:user, account: account, role: :agent)
|
||||
create(:inbox_member, user: other_agent, inbox: inbox)
|
||||
conversation = create(:conversation, account: account, inbox: inbox, assignee: other_agent)
|
||||
|
||||
expect { build_notification(conversation) }.not_to(change { agent.notifications.count })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the agent has conversation_participating_manage permission' do
|
||||
before do
|
||||
custom_role = create(:custom_role, account: account, permissions: ['conversation_participating_manage'])
|
||||
account_user.update!(custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'creates a notification for conversations assigned to the agent' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox, assignee: agent)
|
||||
|
||||
expect { build_notification(conversation) }.to change { agent.notifications.count }.by(1)
|
||||
end
|
||||
|
||||
it 'creates a notification for conversations the agent participates in' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox, assignee: nil)
|
||||
create(:conversation_participant, conversation: conversation, account: account, user: agent)
|
||||
|
||||
expect { build_notification(conversation) }.to change { agent.notifications.count }.by(1)
|
||||
end
|
||||
|
||||
it 'does not create a notification for unassigned conversations the agent does not participate in' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox, assignee: nil)
|
||||
|
||||
expect { build_notification(conversation) }.not_to(change { agent.notifications.count })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the custom role grants no conversation permissions' do
|
||||
before do
|
||||
custom_role = create(:custom_role, account: account, permissions: ['contact_manage'])
|
||||
account_user.update!(custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'does not create a notification' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox, assignee: agent)
|
||||
|
||||
expect { build_notification(conversation) }.not_to(change { agent.notifications.count })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,209 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'WhatsApp Calls API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:channel) do
|
||||
create(:channel_whatsapp, provider: 'whatsapp_cloud', account: account,
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
end
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:call) do
|
||||
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
|
||||
provider: :whatsapp, direction: :incoming, status: 'ringing', provider_call_id: 'wacid_abc')
|
||||
end
|
||||
let(:provider_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) }
|
||||
|
||||
before do
|
||||
account.enable_features!('channel_voice')
|
||||
channel.provider_config = channel.provider_config.merge('source' => 'embedded_signup', 'calling_enabled' => true)
|
||||
channel.save!
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(provider_service)
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/whatsapp_calls/:id' do
|
||||
it 'returns the call payload' do
|
||||
get "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}", headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
body = response.parsed_body
|
||||
expect(body['id']).to eq(call.id)
|
||||
expect(body['call_id']).to eq('wacid_abc')
|
||||
expect(body['provider']).to eq('whatsapp')
|
||||
end
|
||||
|
||||
it 'returns 401 when unauthenticated' do
|
||||
get "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/accept' do
|
||||
it 'forwards SDP and returns the updated call payload' do
|
||||
allow(provider_service).to receive(:pre_accept_call).and_return(true)
|
||||
allow(provider_service).to receive(:accept_call).and_return(true)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/accept",
|
||||
params: { sdp_answer: 'sdp_answer' }, headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(call.reload.status).to eq('in_progress')
|
||||
end
|
||||
|
||||
it 'returns 422 when sdp_answer is missing' do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/accept",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/reject' do
|
||||
it 'rejects the call via Meta and returns its new status' do
|
||||
allow(provider_service).to receive(:reject_call).and_return(true)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/reject",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(call.reload.status).to eq('failed')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/terminate' do
|
||||
it 'terminates the call via Meta and returns its new status' do
|
||||
call.update!(status: 'in_progress')
|
||||
allow(provider_service).to receive(:terminate_call).and_return(true)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/terminate",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(call.reload.status).to eq('completed')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/initiate' do
|
||||
let(:contact) { create(:contact, account: account, phone_number: '+15551234567') }
|
||||
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: '15551234567') }
|
||||
let(:initiate_conversation) do
|
||||
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox)
|
||||
end
|
||||
|
||||
it 'creates an outbound Call and returns calling status' do
|
||||
allow(provider_service).to receive(:initiate_call).and_return({ 'calls' => [{ 'id' => 'wacid_outbound' }] })
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body).to include('status' => 'calling', 'call_id' => 'wacid_outbound')
|
||||
expect(Call.find_by(provider_call_id: 'wacid_outbound')).to have_attributes(direction: 'outgoing', status: 'ringing')
|
||||
end
|
||||
|
||||
it 'sends a permission request and records the wamid when Meta returns NoCallPermission' do
|
||||
allow(provider_service).to receive(:initiate_call).and_raise(Voice::CallErrors::NoCallPermission)
|
||||
allow(provider_service).to receive(:send_call_permission_request).and_return({ 'messages' => [{ 'id' => 'wamid.req_xyz' }] })
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
# Controller deliberately returns 422 so clients can't mistake the permission-template path for a successful dial.
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['status']).to eq('permission_requested')
|
||||
attrs = initiate_conversation.reload.additional_attributes
|
||||
expect(attrs['call_permission_requested_at']).to be_present
|
||||
expect(attrs['call_permission_request_message_id']).to eq('wamid.req_xyz')
|
||||
end
|
||||
|
||||
it 'returns permission_request_failed when send_call_permission_request raises a transport error' do
|
||||
allow(provider_service).to receive(:initiate_call).and_raise(Voice::CallErrors::NoCallPermission)
|
||||
allow(provider_service).to receive(:send_call_permission_request).and_raise(Faraday::TimeoutError)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('errors.whatsapp.calls.permission_request_failed'))
|
||||
end
|
||||
|
||||
it 'returns 422 when sdp_offer is missing' do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: initiate_conversation.display_id },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it 'returns 422 when Meta raises CallFailed for non-permission errors' do
|
||||
allow(provider_service).to receive(:initiate_call).and_raise(Voice::CallErrors::CallFailed, 'Meta error')
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Meta error')
|
||||
end
|
||||
|
||||
it 'returns 422 when the conversation contact has no phone number' do
|
||||
contact.update!(phone_number: nil)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('errors.whatsapp.calls.contact_phone_required'))
|
||||
end
|
||||
|
||||
it 'returns 422 when the conversation belongs to a non-WhatsApp inbox' do
|
||||
twilio_channel = create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551239998')
|
||||
create(:inbox_member, user: agent, inbox: twilio_channel.inbox)
|
||||
twilio_conversation = create(:conversation, account: account, inbox: twilio_channel.inbox)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: twilio_conversation.display_id, sdp_offer: 'sdp_offer' },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('errors.whatsapp.calls.not_enabled'))
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/upload_recording' do
|
||||
before do
|
||||
message = create(:message, conversation: conversation, account: account, inbox: inbox,
|
||||
content_type: 'voice_call', message_type: 'incoming')
|
||||
call.update!(message_id: message.id)
|
||||
end
|
||||
|
||||
it 'attaches the recording to the call message' do
|
||||
file = fixture_file_upload(Rails.root.join('spec/assets/sample.mp3'), 'audio/mpeg')
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/upload_recording",
|
||||
params: { recording: file }, headers: agent.create_new_auth_token
|
||||
end.to change { call.message.attachments.count }.by(1)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body['status']).to eq('uploaded')
|
||||
end
|
||||
|
||||
it 'is idempotent: returns already_uploaded if an audio attachment exists' do
|
||||
call.message.attachments.create!(account_id: account.id, file_type: :audio,
|
||||
file: fixture_file_upload(Rails.root.join('spec/assets/sample.mp3'), 'audio/mpeg'))
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/upload_recording",
|
||||
params: { recording: fixture_file_upload(Rails.root.join('spec/assets/sample.mp3'), 'audio/mpeg') },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response.parsed_body['status']).to eq('already_uploaded')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -165,5 +165,37 @@ RSpec.describe Captain::BaseTaskService, type: :model do
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when subclass opts out via counts_toward_usage?' do
|
||||
let(:test_service_class) do
|
||||
result = perform_result
|
||||
klass = Class.new(described_class) do
|
||||
define_method(:perform) { result }
|
||||
define_method(:event_name) { 'test_event' }
|
||||
define_method(:counts_toward_usage?) { false }
|
||||
end
|
||||
klass.prepend(Enterprise::Captain::BaseTaskService)
|
||||
klass
|
||||
end
|
||||
|
||||
it 'does not increment usage even on a successful result' do
|
||||
expect(account).not_to receive(:increment_response_usage)
|
||||
service.perform
|
||||
end
|
||||
|
||||
context 'when the captain_responses quota is exhausted on Cloud' do
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
allow(account).to receive(:usage_limits).and_return({
|
||||
captain: { responses: { current_available: 0 } }
|
||||
})
|
||||
end
|
||||
|
||||
it 'bypasses the 429 gate and returns the underlying result' do
|
||||
result = service.perform
|
||||
expect(result).to eq(perform_result)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -55,6 +55,7 @@ RSpec.describe SlaEvent, type: :model do
|
||||
before do
|
||||
# to ensure notifications are not sent to other users
|
||||
create(:user, account: account)
|
||||
create(:inbox_member, inbox: inbox, user: assignee)
|
||||
create(:inbox_member, inbox: inbox, user: participant)
|
||||
create(:conversation_participant, conversation: conversation, user: participant)
|
||||
end
|
||||
|
||||
@@ -145,10 +145,10 @@ describe Enterprise::Billing::CreateStripeCustomerService do
|
||||
account.update!(custom_attributes: { stripe_customer_id: stripe_customer_id })
|
||||
end
|
||||
|
||||
context 'when customer has active subscriptions' do
|
||||
context 'when customer has an active non-default subscription' do
|
||||
before do
|
||||
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
|
||||
allow(subscriptions_list).to receive(:data).and_return(['subscription'])
|
||||
allow(subscriptions_list).to receive(:data).and_return([{ 'plan' => { 'id' => 'price_paid_plan' } }])
|
||||
allow(Stripe::Subscription).to receive(:create)
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
require 'rails_helper'
|
||||
|
||||
# Simulate the prepend_mod_with overlay for testing.
|
||||
test_klass = Class.new(Onboarding::WebWidgetCreationService) do
|
||||
prepend Enterprise::Onboarding::WebWidgetCreationService
|
||||
end
|
||||
|
||||
RSpec.describe Enterprise::Onboarding::WebWidgetCreationService do
|
||||
let(:account) do
|
||||
create(:account, name: 'Acme Inc', domain: 'acme.com', custom_attributes: {
|
||||
'brand_info' => { 'slogan' => 'Fallback slogan', 'description' => 'Fallback description' }
|
||||
})
|
||||
end
|
||||
let(:user) { create(:user) }
|
||||
let(:service) { test_klass.new(account, user) }
|
||||
|
||||
before { create(:account_user, account: account, user: user, role: :administrator) }
|
||||
|
||||
describe '#welcome_tagline_text via #perform' do
|
||||
let(:llm_double) { instance_double(Captain::Llm::WidgetTaglineService) }
|
||||
|
||||
before do
|
||||
allow(Captain::Llm::WidgetTaglineService).to receive(:new).and_return(llm_double)
|
||||
end
|
||||
|
||||
context 'when the LLM returns a tagline' do
|
||||
before { allow(llm_double).to receive(:perform).and_return(message: ' LLM tagline ') }
|
||||
|
||||
it 'uses the (stripped) LLM-generated tagline' do
|
||||
expect(service.perform.channel.welcome_tagline).to eq('LLM tagline')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the LLM returns a blank message' do
|
||||
before { allow(llm_double).to receive(:perform).and_return(message: '') }
|
||||
|
||||
it 'falls back to brand_info text' do
|
||||
expect(service.perform.channel.welcome_tagline).to eq('Fallback slogan')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the LLM returns an error response' do
|
||||
before { allow(llm_double).to receive(:perform).and_return(error: 'LLM timeout', error_code: 500) }
|
||||
|
||||
it 'falls back to brand_info text' do
|
||||
expect(service.perform.channel.welcome_tagline).to eq('Fallback slogan')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the LLM raises an exception' do
|
||||
before { allow(llm_double).to receive(:perform).and_raise(StandardError, 'boom') }
|
||||
|
||||
it 'still creates the widget with brand_info fallback (no transaction rollback)' do
|
||||
expect { service.perform }.to change(Channel::WebWidget, :count).by(1)
|
||||
expect(service.perform.channel.welcome_tagline).to eq('Fallback slogan')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -103,7 +103,7 @@ RSpec.describe Voice::InboundCallBuilder do
|
||||
context 'when the WhatsApp wa_id needs Brazil normalization to match an existing ContactInbox' do
|
||||
let(:whatsapp_channel) do
|
||||
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
|
||||
provider_config: { 'phone_number_id' => '123', 'calling_enabled' => true },
|
||||
provider_config: { 'phone_number_id' => '123', 'source' => 'embedded_signup', 'calling_enabled' => true },
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
end
|
||||
let(:whatsapp_inbox) { whatsapp_channel.inbox }
|
||||
@@ -112,6 +112,8 @@ RSpec.describe Voice::InboundCallBuilder do
|
||||
create(:contact_inbox, contact: stored_contact, inbox: whatsapp_inbox, source_id: '5541988887777')
|
||||
end
|
||||
|
||||
before { account.enable_features!('channel_voice') }
|
||||
|
||||
it 'reuses the contact via normalized wa_id rather than forking a new ContactInbox' do
|
||||
call = described_class.perform!(
|
||||
inbox: whatsapp_inbox,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::CallService do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) do
|
||||
create(:channel_whatsapp, provider: 'whatsapp_cloud', account: account,
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
end
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:agent) { create(:user, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:call) do
|
||||
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
|
||||
provider: :whatsapp, direction: :incoming, status: 'ringing', provider_call_id: 'wacid_abc')
|
||||
end
|
||||
let(:provider_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) }
|
||||
|
||||
before do
|
||||
channel.provider_config = channel.provider_config.merge('calling_enabled' => true)
|
||||
channel.save!
|
||||
allow(channel).to receive(:provider_service).and_return(provider_service)
|
||||
allow(inbox).to receive(:channel).and_return(channel)
|
||||
allow(call).to receive(:inbox).and_return(inbox)
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
end
|
||||
|
||||
describe '#accept' do
|
||||
let(:sdp_answer) { "v=0\r\n...sdp..." }
|
||||
|
||||
before do
|
||||
allow(provider_service).to receive(:pre_accept_call).and_return(true)
|
||||
allow(provider_service).to receive(:accept_call).and_return(true)
|
||||
end
|
||||
|
||||
it 'forwards the SDP answer to Meta and transitions the call to in_progress' do
|
||||
described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept
|
||||
|
||||
expect(provider_service).to have_received(:pre_accept_call).with('wacid_abc', sdp_answer)
|
||||
expect(provider_service).to have_received(:accept_call).with('wacid_abc', sdp_answer)
|
||||
expect(call.reload).to have_attributes(status: 'in_progress', accepted_by_agent_id: agent.id, started_at: be_present)
|
||||
expect(call.meta['sdp_answer']).to eq(sdp_answer)
|
||||
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||
"account_#{account.id}", hash_including(event: 'voice_call.accepted')
|
||||
)
|
||||
end
|
||||
|
||||
it 'claims the conversation when no assignee is set' do
|
||||
described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept
|
||||
|
||||
expect(conversation.reload.assignee_id).to eq(agent.id)
|
||||
end
|
||||
|
||||
it 'raises AlreadyAccepted when another agent has already accepted the call' do
|
||||
call.update!(status: 'in_progress')
|
||||
|
||||
expect { described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept }
|
||||
.to raise_error(StandardError) { |error| expect(error.class.name).to eq('Voice::CallErrors::AlreadyAccepted') }
|
||||
end
|
||||
|
||||
it 'raises NotRinging when the call has reached a terminal state' do
|
||||
call.update!(status: 'completed')
|
||||
|
||||
expect { described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept }
|
||||
.to raise_error(StandardError) { |error| expect(error.class.name).to eq('Voice::CallErrors::NotRinging') }
|
||||
end
|
||||
|
||||
it 'raises CallFailed when sdp_answer is missing' do
|
||||
expect { described_class.new(call: call, agent: agent, sdp_answer: nil).accept }
|
||||
.to raise_error(StandardError) do |error|
|
||||
expect(error.class.name).to eq('Voice::CallErrors::CallFailed')
|
||||
expect(error.message).to eq('sdp_answer is required')
|
||||
end
|
||||
end
|
||||
|
||||
it 'wraps Meta transport exceptions as CallFailed and leaves the call ringing' do
|
||||
allow(provider_service).to receive(:pre_accept_call).and_raise(Faraday::TimeoutError)
|
||||
|
||||
expect { described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept }
|
||||
.to raise_error(StandardError) { |error| expect(error.class.name).to eq('Voice::CallErrors::CallFailed') }
|
||||
expect(call.reload.status).to eq('ringing')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#reject' do
|
||||
before { allow(provider_service).to receive(:reject_call).and_return(true) }
|
||||
|
||||
it 'tells Meta to reject and finalizes the call as failed' do
|
||||
described_class.new(call: call, agent: agent).reject
|
||||
|
||||
expect(provider_service).to have_received(:reject_call).with('wacid_abc')
|
||||
expect(call.reload.status).to eq('failed')
|
||||
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||
"account_#{account.id}", hash_including(event: 'voice_call.ended', data: hash_including(status: 'failed'))
|
||||
)
|
||||
end
|
||||
|
||||
it 'is a no-op for already-terminal calls' do
|
||||
call.update!(status: 'completed')
|
||||
|
||||
described_class.new(call: call, agent: agent).reject
|
||||
|
||||
expect(provider_service).not_to have_received(:reject_call)
|
||||
end
|
||||
|
||||
it 'raises CallFailed and leaves the call ringing when Meta rejects the request' do
|
||||
allow(provider_service).to receive(:reject_call).and_return(false)
|
||||
|
||||
expect { described_class.new(call: call, agent: agent).reject }
|
||||
.to raise_error(StandardError) { |error| expect(error.class.name).to eq('Voice::CallErrors::CallFailed') }
|
||||
expect(call.reload.status).to eq('ringing')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#terminate' do
|
||||
before { allow(provider_service).to receive(:terminate_call).and_return(true) }
|
||||
|
||||
it 'finalizes an in-progress call as completed' do
|
||||
call.update!(status: 'in_progress')
|
||||
|
||||
described_class.new(call: call, agent: agent).terminate
|
||||
|
||||
expect(provider_service).to have_received(:terminate_call).with('wacid_abc')
|
||||
expect(call.reload.status).to eq('completed')
|
||||
expect(call.meta['ended_at']).to be_present
|
||||
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||
"account_#{account.id}", hash_including(event: 'voice_call.ended')
|
||||
)
|
||||
end
|
||||
|
||||
it 'finalizes a still-ringing call as no_answer when the agent hangs up before the contact picks up' do
|
||||
described_class.new(call: call, agent: agent).terminate
|
||||
|
||||
expect(call.reload.status).to eq('no_answer')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -16,6 +16,7 @@ FactoryBot.define do
|
||||
imap_login { 'email@example.com' }
|
||||
imap_password { '' }
|
||||
imap_enable_ssl { true }
|
||||
|
||||
provider_config do
|
||||
{
|
||||
expires_on: Time.zone.now + 3600,
|
||||
@@ -33,6 +34,7 @@ FactoryBot.define do
|
||||
imap_login { 'email@example.com' }
|
||||
imap_password { 'random-password' }
|
||||
imap_enable_ssl { true }
|
||||
imap_authentication { 'plain' }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -65,6 +65,13 @@ RSpec.describe HookJob do
|
||||
expect(Integrations::GoogleTranslate::DetectLanguageService).to receive(:new).with(hook: hook, message: event_data[:message])
|
||||
described_class.perform_now(hook, event_name, event_data)
|
||||
end
|
||||
|
||||
it "calls Integrations::Linear::AutoLinkService when it's a linear hook" do
|
||||
hook = create(:integrations_hook, :linear, account: account)
|
||||
allow(Integrations::Linear::AutoLinkService).to receive(:new).and_return(process_service)
|
||||
expect(Integrations::Linear::AutoLinkService).to receive(:new).with(account: account, message: event_data[:message])
|
||||
described_class.perform_now(hook, event_name, event_data)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handleable events like message.updated for slack' do
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Labels::RemoveAssociationsJob do
|
||||
subject(:job) do
|
||||
described_class.perform_later(
|
||||
label_title: label_title,
|
||||
account_id: account_id,
|
||||
label_deleted_at: label_deleted_at
|
||||
)
|
||||
end
|
||||
|
||||
let(:label_title) { 'billing' }
|
||||
let(:account_id) { 1 }
|
||||
let(:label_deleted_at) { Time.current }
|
||||
|
||||
it 'queues the job' do
|
||||
expect { job }.to have_enqueued_job(described_class)
|
||||
.with(label_title: label_title, account_id: account_id, label_deleted_at: label_deleted_at)
|
||||
.on_queue('default')
|
||||
end
|
||||
end
|
||||
@@ -18,6 +18,17 @@ RSpec.describe SendReplyJob do
|
||||
allow(process_service).to receive(:perform)
|
||||
end
|
||||
|
||||
def expect_mapped_service_to_perform(message, service_class_name)
|
||||
channel_name = message.conversation.inbox.channel.class.name
|
||||
service_class = described_class::CHANNEL_SERVICES.fetch(channel_name)
|
||||
|
||||
expect(service_class.name).to eq(service_class_name)
|
||||
expect(service_class).to receive(:new).with(message: message).and_return(process_service)
|
||||
expect(process_service).to receive(:perform)
|
||||
|
||||
described_class.perform_now(message.id)
|
||||
end
|
||||
|
||||
it 'calls Facebook::SendOnFacebookService when its facebook message' do
|
||||
stub_request(:post, /graph.facebook.com/)
|
||||
facebook_channel = create(:channel_facebook_page)
|
||||
@@ -33,65 +44,44 @@ RSpec.describe SendReplyJob do
|
||||
twitter_channel = create(:channel_twitter_profile)
|
||||
twitter_inbox = create(:inbox, channel: twitter_channel)
|
||||
message = create(:message, conversation: create(:conversation, inbox: twitter_inbox))
|
||||
allow(Twitter::SendOnTwitterService).to receive(:new).with(message: message).and_return(process_service)
|
||||
expect(Twitter::SendOnTwitterService).to receive(:new).with(message: message)
|
||||
expect(process_service).to receive(:perform)
|
||||
described_class.perform_now(message.id)
|
||||
expect_mapped_service_to_perform(message, 'Twitter::SendOnTwitterService')
|
||||
end
|
||||
|
||||
it 'calls ::Twilio::SendOnTwilioService when its twilio message' do
|
||||
twilio_channel = create(:channel_twilio_sms)
|
||||
message = create(:message, conversation: create(:conversation, inbox: twilio_channel.inbox))
|
||||
allow(Twilio::SendOnTwilioService).to receive(:new).with(message: message).and_return(process_service)
|
||||
expect(Twilio::SendOnTwilioService).to receive(:new).with(message: message)
|
||||
expect(process_service).to receive(:perform)
|
||||
described_class.perform_now(message.id)
|
||||
expect_mapped_service_to_perform(message, 'Twilio::SendOnTwilioService')
|
||||
end
|
||||
|
||||
it 'calls ::Telegram::SendOnTelegramService when its telegram message' do
|
||||
telegram_channel = create(:channel_telegram)
|
||||
message = create(:message, conversation: create(:conversation, inbox: telegram_channel.inbox))
|
||||
allow(Telegram::SendOnTelegramService).to receive(:new).with(message: message).and_return(process_service)
|
||||
expect(Telegram::SendOnTelegramService).to receive(:new).with(message: message)
|
||||
expect(process_service).to receive(:perform)
|
||||
described_class.perform_now(message.id)
|
||||
expect_mapped_service_to_perform(message, 'Telegram::SendOnTelegramService')
|
||||
end
|
||||
|
||||
it 'calls ::Line:SendOnLineService when its line message' do
|
||||
line_channel = create(:channel_line)
|
||||
message = create(:message, conversation: create(:conversation, inbox: line_channel.inbox))
|
||||
allow(Line::SendOnLineService).to receive(:new).with(message: message).and_return(process_service)
|
||||
expect(Line::SendOnLineService).to receive(:new).with(message: message)
|
||||
expect(process_service).to receive(:perform)
|
||||
described_class.perform_now(message.id)
|
||||
expect_mapped_service_to_perform(message, 'Line::SendOnLineService')
|
||||
end
|
||||
|
||||
it 'calls ::Whatsapp:SendOnWhatsappService when its whatsapp message' do
|
||||
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
|
||||
whatsapp_channel = create(:channel_whatsapp, sync_templates: false)
|
||||
message = create(:message, conversation: create(:conversation, inbox: whatsapp_channel.inbox))
|
||||
allow(Whatsapp::SendOnWhatsappService).to receive(:new).with(message: message).and_return(process_service)
|
||||
expect(Whatsapp::SendOnWhatsappService).to receive(:new).with(message: message)
|
||||
expect(process_service).to receive(:perform)
|
||||
described_class.perform_now(message.id)
|
||||
expect_mapped_service_to_perform(message, 'Whatsapp::SendOnWhatsappService')
|
||||
end
|
||||
|
||||
it 'calls ::Sms::SendOnSmsService when its sms message' do
|
||||
sms_channel = create(:channel_sms)
|
||||
message = create(:message, conversation: create(:conversation, inbox: sms_channel.inbox))
|
||||
allow(Sms::SendOnSmsService).to receive(:new).with(message: message).and_return(process_service)
|
||||
expect(Sms::SendOnSmsService).to receive(:new).with(message: message)
|
||||
expect(process_service).to receive(:perform)
|
||||
described_class.perform_now(message.id)
|
||||
expect_mapped_service_to_perform(message, 'Sms::SendOnSmsService')
|
||||
end
|
||||
|
||||
it 'calls ::Instagram::Direct::SendOnInstagramService when its instagram message' do
|
||||
instagram_channel = create(:channel_instagram)
|
||||
message = create(:message, conversation: create(:conversation, inbox: instagram_channel.inbox))
|
||||
allow(Instagram::SendOnInstagramService).to receive(:new).with(message: message).and_return(process_service)
|
||||
expect(Instagram::SendOnInstagramService).to receive(:new).with(message: message)
|
||||
expect(process_service).to receive(:perform)
|
||||
described_class.perform_now(message.id)
|
||||
expect_mapped_service_to_perform(message, 'Instagram::SendOnInstagramService')
|
||||
end
|
||||
|
||||
it 'calls ::Instagram::Messenger::SendOnInstagramService when its an instagram_direct_message from facebook channel' do
|
||||
@@ -112,37 +102,25 @@ RSpec.describe SendReplyJob do
|
||||
it 'calls ::Email::SendOnEmailService when its email message' do
|
||||
email_channel = create(:channel_email)
|
||||
message = create(:message, conversation: create(:conversation, inbox: email_channel.inbox))
|
||||
allow(Email::SendOnEmailService).to receive(:new).with(message: message).and_return(process_service)
|
||||
expect(Email::SendOnEmailService).to receive(:new).with(message: message)
|
||||
expect(process_service).to receive(:perform)
|
||||
described_class.perform_now(message.id)
|
||||
expect_mapped_service_to_perform(message, 'Email::SendOnEmailService')
|
||||
end
|
||||
|
||||
it 'calls ::Messages::SendEmailNotificationService when its webwidget message' do
|
||||
webwidget_channel = create(:channel_widget)
|
||||
message = create(:message, conversation: create(:conversation, inbox: webwidget_channel.inbox))
|
||||
allow(Messages::SendEmailNotificationService).to receive(:new).with(message: message).and_return(process_service)
|
||||
expect(Messages::SendEmailNotificationService).to receive(:new).with(message: message)
|
||||
expect(process_service).to receive(:perform)
|
||||
described_class.perform_now(message.id)
|
||||
expect_mapped_service_to_perform(message, 'Messages::SendEmailNotificationService')
|
||||
end
|
||||
|
||||
it 'calls ::Messages::SendEmailNotificationService when its api channel message' do
|
||||
api_channel = create(:channel_api)
|
||||
message = create(:message, conversation: create(:conversation, inbox: api_channel.inbox))
|
||||
allow(Messages::SendEmailNotificationService).to receive(:new).with(message: message).and_return(process_service)
|
||||
expect(Messages::SendEmailNotificationService).to receive(:new).with(message: message)
|
||||
expect(process_service).to receive(:perform)
|
||||
described_class.perform_now(message.id)
|
||||
expect_mapped_service_to_perform(message, 'Messages::SendEmailNotificationService')
|
||||
end
|
||||
|
||||
it 'calls ::Tiktok::SendOnTiktokService when its tiktok message' do
|
||||
tiktok_channel = create(:channel_tiktok)
|
||||
message = create(:message, conversation: create(:conversation, inbox: tiktok_channel.inbox))
|
||||
allow(Tiktok::SendOnTiktokService).to receive(:new).with(message: message).and_return(process_service)
|
||||
expect(Tiktok::SendOnTiktokService).to receive(:new).with(message: message)
|
||||
expect(process_service).to receive(:perform)
|
||||
described_class.perform_now(message.id)
|
||||
expect_mapped_service_to_perform(message, 'Tiktok::SendOnTiktokService')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Linear::AutoLinkService do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:processor) { instance_double(Integrations::Linear::ProcessorService) }
|
||||
let(:activity_service) { instance_double(Linear::ActivityMessageService, perform: true) }
|
||||
|
||||
let(:linear_url) { 'https://linear.app/chatwoot/issue/CW-1234/some-slug' }
|
||||
let(:identifier) { 'CW-1234' }
|
||||
let(:node_id) { 'linear-node-id-1' }
|
||||
let(:search_response) do
|
||||
{ data: [{ 'id' => node_id, 'identifier' => identifier, 'title' => 'Issue title',
|
||||
'url' => 'https://linear.app/chatwoot/issue/CW-1234/issue-title' }] }
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Integrations::Linear::ProcessorService).to receive(:new).with(account: account).and_return(processor)
|
||||
allow(Linear::ActivityMessageService).to receive(:new).and_return(activity_service)
|
||||
allow(processor).to receive(:linked_issues).and_return({ data: [] })
|
||||
allow(processor).to receive(:search_issue).and_return(search_response)
|
||||
allow(processor).to receive(:link_issue).and_return({ data: { id: node_id, link_id: 'attachment-1' } })
|
||||
end
|
||||
|
||||
def build_private_note(content)
|
||||
create(:message,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
sender: user,
|
||||
message_type: :outgoing,
|
||||
private: true,
|
||||
content: content)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when the message is not a private note' do
|
||||
it 'does no work' do
|
||||
message = create(:message, account: account, inbox: inbox, conversation: conversation,
|
||||
sender: user, message_type: :outgoing, private: false,
|
||||
content: "see #{linear_url}")
|
||||
|
||||
described_class.new(account: account, message: message).perform
|
||||
|
||||
expect(processor).not_to have_received(:linked_issues)
|
||||
expect(processor).not_to have_received(:search_issue)
|
||||
expect(processor).not_to have_received(:link_issue)
|
||||
expect(Linear::ActivityMessageService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the sender is not a User' do
|
||||
it 'does no work' do
|
||||
contact = create(:contact, account: account)
|
||||
message = create(:message, account: account, inbox: inbox, conversation: conversation,
|
||||
sender: contact, message_type: :incoming, private: true,
|
||||
content: "see #{linear_url}")
|
||||
|
||||
described_class.new(account: account, message: message).perform
|
||||
|
||||
expect(processor).not_to have_received(:link_issue)
|
||||
expect(Linear::ActivityMessageService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the private note has no Linear URL' do
|
||||
it 'does no work' do
|
||||
message = build_private_note('just a regular note with no link')
|
||||
|
||||
described_class.new(account: account, message: message).perform
|
||||
|
||||
expect(processor).not_to have_received(:link_issue)
|
||||
expect(Linear::ActivityMessageService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the issue identifier is already linked from this conversation' do
|
||||
it 'skips silently' do
|
||||
message = build_private_note("see #{linear_url}")
|
||||
allow(processor).to receive(:linked_issues).and_return(
|
||||
{ data: [{ 'id' => 'attachment-prev', 'issue' => { 'id' => node_id, 'identifier' => identifier } }] }
|
||||
)
|
||||
|
||||
described_class.new(account: account, message: message).perform
|
||||
|
||||
expect(processor).not_to have_received(:search_issue)
|
||||
expect(processor).not_to have_received(:link_issue)
|
||||
expect(Linear::ActivityMessageService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear search returns no exact match for the identifier' do
|
||||
it 'does not link' do
|
||||
message = build_private_note("see #{linear_url}")
|
||||
allow(processor).to receive(:search_issue).with(identifier).and_return(
|
||||
{ data: [{ 'id' => 'other', 'identifier' => 'OTHER-1', 'url' => 'https://linear.app/chatwoot/issue/OTHER-1' }] }
|
||||
)
|
||||
|
||||
described_class.new(account: account, message: message).perform
|
||||
|
||||
expect(processor).not_to have_received(:link_issue)
|
||||
expect(Linear::ActivityMessageService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the matching issue belongs to a different Linear workspace' do
|
||||
it 'does not link' do
|
||||
message = build_private_note("see #{linear_url}")
|
||||
allow(processor).to receive(:search_issue).with(identifier).and_return(
|
||||
{ data: [{ 'id' => node_id, 'identifier' => identifier,
|
||||
'url' => 'https://linear.app/other-workspace/issue/CW-1234' }] }
|
||||
)
|
||||
|
||||
described_class.new(account: account, message: message).perform
|
||||
|
||||
expect(processor).not_to have_received(:link_issue)
|
||||
expect(Linear::ActivityMessageService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear search returns an error' do
|
||||
it 'does not link' do
|
||||
message = build_private_note("see #{linear_url}")
|
||||
allow(processor).to receive(:search_issue).with(identifier).and_return({ error: 'boom' })
|
||||
|
||||
described_class.new(account: account, message: message).perform
|
||||
|
||||
expect(processor).not_to have_received(:link_issue)
|
||||
expect(Linear::ActivityMessageService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link_issue returns an error' do
|
||||
it 'does not post the activity message' do
|
||||
message = build_private_note("see #{linear_url}")
|
||||
allow(processor).to receive(:link_issue).and_return({ error: 'nope' })
|
||||
|
||||
described_class.new(account: account, message: message).perform
|
||||
|
||||
expect(Linear::ActivityMessageService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the private note contains a Linear URL' do
|
||||
it 'links the issue and posts an activity message' do
|
||||
message = build_private_note("Found it: #{linear_url}")
|
||||
|
||||
described_class.new(account: account, message: message).perform
|
||||
|
||||
expect(processor).to have_received(:link_issue).with(
|
||||
a_string_matching(%r{/conversations/#{conversation.display_id}\z}),
|
||||
node_id,
|
||||
anything,
|
||||
user
|
||||
)
|
||||
expect(Linear::ActivityMessageService).to have_received(:new).with(
|
||||
conversation: conversation,
|
||||
action_type: :issue_linked,
|
||||
user: user,
|
||||
issue_data: { id: identifier }
|
||||
)
|
||||
expect(activity_service).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'links only the first Linear URL when multiple are present' do
|
||||
second_url = 'https://linear.app/chatwoot/issue/CW-9999'
|
||||
message = build_private_note("see #{linear_url} and #{second_url}")
|
||||
|
||||
described_class.new(account: account, message: message).perform
|
||||
|
||||
expect(processor).to have_received(:search_issue).with(identifier).once
|
||||
expect(processor).not_to have_received(:search_issue).with('CW-9999')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user