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:
Tanmay Deep Sharma
2026-05-12 17:38:27 +05:30
107 changed files with 43319 additions and 2820 deletions
+2 -2
View File
@@ -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
View File
@@ -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
+15
View File
@@ -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
+18 -10
View File
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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,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>
+1
View File
@@ -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'
);
});
});
+4 -1
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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)
+2 -1
View File
@@ -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)
+31
View File
@@ -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
+1 -1
View File
@@ -6,7 +6,7 @@ class Imap::FetchEmailService < Imap::BaseFetchEmailService
private
def authentication_type
'PLAIN'
channel.imap_authentication || 'plain'
end
def imap_password
+60
View File
@@ -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
+1 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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.'
+2
View File
@@ -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
View File
@@ -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
+11
View File
@@ -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
+10
View File
@@ -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
+1
View File
@@ -54,6 +54,7 @@ module Linear::Queries
title
description
identifier
url
state {
name
color
+1 -6
View File
@@ -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
+64 -1
View File
@@ -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
+1
View File
@@ -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
+2
View File
@@ -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
+7
View File
@@ -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
+22 -44
View File
@@ -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