Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abac8268ec | ||
|
|
1528fcde0c | ||
|
|
5f6bd951b9 | ||
|
|
cfc7699b7e | ||
|
|
dc332dd93e | ||
|
|
13f66e3a88 | ||
|
|
fbcb89e955 | ||
|
|
8712879681 | ||
|
|
ffbf40c720 | ||
|
|
dd7f5c27e5 | ||
|
|
c2523c5d8b | ||
|
|
05bda5f742 | ||
|
|
379e28df1f | ||
|
|
c6dceb0e07 | ||
|
|
cd33cea69f | ||
|
|
71cc5168be | ||
|
|
58fdd20625 | ||
|
|
6c67eb9ba0 | ||
|
|
3df827c931 | ||
|
|
de696a55cb | ||
|
|
79a7423f9f | ||
|
|
85ddc68834 | ||
|
|
086aa36ffe | ||
|
|
f6be0d80ef | ||
|
|
3489298726 | ||
|
|
2e13f69fdf | ||
|
|
bc768bf04f | ||
|
|
cc612e755b | ||
|
|
aa10d42237 |
+15
-4
@@ -2,8 +2,19 @@
|
||||
ignore:
|
||||
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
|
||||
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
|
||||
# Chatwoot defaults to Active Storage redirect-style URLs, and its recommended
|
||||
# storage setup uses local/cloud storage with optional direct uploads to the
|
||||
# storage provider rather than Rails proxy mode. Revisit if we enable
|
||||
# rails_storage_proxy or other app-served Active Storage proxy routes.
|
||||
# Rails 7.1 has no patched release for the Active Storage proxy range
|
||||
# advisories. Chatwoot limits proxy range requests locally.
|
||||
- CVE-2026-33658
|
||||
# Rails 7.1 has no patched release for this Active Storage direct-upload
|
||||
# advisory. Chatwoot filters internal metadata keys locally.
|
||||
- CVE-2026-33173
|
||||
- CVE-2026-33174
|
||||
# Rails 7.1 has no patched release for these Rails advisories. These are not
|
||||
# reachable through Chatwoot's current usage patterns and should be removed
|
||||
# once we upgrade to Rails 7.2.3.1+.
|
||||
- CVE-2026-33168
|
||||
- CVE-2026-33169
|
||||
- CVE-2026-33170
|
||||
- CVE-2026-33176
|
||||
- CVE-2026-33195
|
||||
- CVE-2026-33202
|
||||
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
# Backend tests with parallelization
|
||||
backend-tests:
|
||||
<<: *defaults
|
||||
parallelism: 18
|
||||
parallelism: 20
|
||||
steps:
|
||||
- checkout
|
||||
- node/install:
|
||||
|
||||
@@ -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'
|
||||
|
||||
+19
-19
@@ -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)
|
||||
@@ -212,7 +212,7 @@ GEM
|
||||
logger
|
||||
msgpack
|
||||
datadog-ruby_core_source (3.4.1)
|
||||
date (3.4.1)
|
||||
date (3.5.1)
|
||||
debug (1.8.0)
|
||||
irb (>= 1.5.0)
|
||||
reline (>= 0.3.1)
|
||||
@@ -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)
|
||||
@@ -574,7 +574,7 @@ GEM
|
||||
uri (>= 0.11.1)
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.4.20)
|
||||
net-imap (0.4.24)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -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)
|
||||
@@ -961,7 +961,7 @@ GEM
|
||||
time_diff (0.3.0)
|
||||
activesupport
|
||||
i18n
|
||||
timeout (0.4.3)
|
||||
timeout (0.6.1)
|
||||
trailblazer-option (0.1.2)
|
||||
twilio-ruby (7.6.0)
|
||||
faraday (>= 0.9, < 3.0)
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,10 @@ class Messages::Messenger::MessageBuilder
|
||||
filename: attachment_file.original_filename,
|
||||
content_type: attachment_file.content_type
|
||||
)
|
||||
# The Attachment row is saved before the blob is attached, so the
|
||||
# after_create_commit broadcast bails on `file.attached?`. Re-fire here
|
||||
# for audio so the bubble updates without waiting on transcription.
|
||||
attachment.message&.reload&.send_update_event if attachment.file_type.to_sym == :audio
|
||||
end
|
||||
|
||||
def attachment_params(attachment)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,13 +31,24 @@ class V2::Reports::BotMetricsBuilder
|
||||
end
|
||||
|
||||
def bot_resolutions_count
|
||||
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
|
||||
created_at: range).distinct.count
|
||||
# Exclude conversations that also had a handoff in the same range — handoff wins
|
||||
account.reporting_events.joins(:conversation).select(:conversation_id)
|
||||
.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range)
|
||||
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
|
||||
.distinct.count
|
||||
end
|
||||
|
||||
def bot_handoffs_count
|
||||
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
|
||||
created_at: range).distinct.count
|
||||
account.reporting_events.joins(:conversation).select(:conversation_id)
|
||||
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range)
|
||||
.distinct.count
|
||||
end
|
||||
|
||||
def bot_handoff_conversation_ids_subquery
|
||||
account.reporting_events
|
||||
.where(name: :conversation_bot_handoff, created_at: range)
|
||||
.where.not(conversation_id: nil)
|
||||
.select(:conversation_id)
|
||||
end
|
||||
|
||||
def bot_resolution_rate
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
class Api::V1::Accounts::Contacts::AttachmentsController < Api::V1::Accounts::Contacts::BaseController
|
||||
RESULTS_PER_PAGE = 100
|
||||
|
||||
def index
|
||||
conversations = Conversations::PermissionFilterService.new(
|
||||
Current.account.conversations.where(contact_id: @contact.id),
|
||||
Current.user,
|
||||
Current.account
|
||||
).perform
|
||||
|
||||
@attachments = Attachment.where(message_id: Message.where(conversation_id: conversations).select(:id))
|
||||
.includes({ file_attachment: :blob }, message: [:conversation, :inbox, { sender: { avatar_attachment: :blob } }])
|
||||
.order(created_at: :desc)
|
||||
.page(params[:page])
|
||||
.per(RESULTS_PER_PAGE)
|
||||
@attachments_count = @attachments.total_count
|
||||
end
|
||||
end
|
||||
@@ -28,7 +28,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
def attachments
|
||||
@attachments_count = @conversation.attachments.count
|
||||
@attachments = @conversation.attachments
|
||||
.includes(:message)
|
||||
.includes({ file_attachment: :blob }, message: [:inbox, { sender: { avatar_attachment: :blob } }])
|
||||
.order(created_at: :desc)
|
||||
.page(attachment_params[:page])
|
||||
.per(ATTACHMENT_RESULTS_PER_PAGE)
|
||||
|
||||
@@ -9,7 +9,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
|
||||
def index
|
||||
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
||||
@inboxes = policy_scope(Current.account.inboxes)
|
||||
.includes(:channel, :portal, :working_hours, { avatar_attachment: :blob })
|
||||
.order_by_name
|
||||
end
|
||||
|
||||
def show; end
|
||||
@@ -85,7 +87,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def fetch_agent_bot
|
||||
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
||||
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:agent_bot]) if params[:agent_bot]
|
||||
end
|
||||
|
||||
def create_channel
|
||||
|
||||
@@ -41,9 +41,9 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
def destroy_all
|
||||
if params[:type] == 'read'
|
||||
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read)
|
||||
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :read)
|
||||
else
|
||||
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all)
|
||||
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :all)
|
||||
end
|
||||
head :ok
|
||||
end
|
||||
@@ -69,7 +69,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def fetch_notification
|
||||
@notification = current_user.notifications.find(params[:id])
|
||||
@notification = current_user.notifications.where(account_id: Current.account.id).find(params[:id])
|
||||
end
|
||||
|
||||
def set_current_page
|
||||
|
||||
@@ -18,7 +18,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
@portal = Current.account.portals.build(portal_params.merge(live_chat_widget_params))
|
||||
@portal.custom_domain = parsed_custom_domain
|
||||
@portal.save!
|
||||
process_attached_logo
|
||||
process_attached_logo if params[:blob_id].present?
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -87,7 +87,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
return {} unless permitted_params.key?(:inbox_id)
|
||||
return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank?
|
||||
|
||||
inbox = Inbox.find(permitted_params[:inbox_id])
|
||||
inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
|
||||
return {} unless inbox.web_widget?
|
||||
|
||||
{ channel_web_widget_id: inbox.channel.id }
|
||||
@@ -98,6 +98,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def parsed_custom_domain
|
||||
return @portal.custom_domain if @portal.custom_domain.blank?
|
||||
|
||||
domain = URI.parse(@portal.custom_domain)
|
||||
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
|
||||
end
|
||||
|
||||
@@ -111,7 +111,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def custom_attributes_params
|
||||
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role)
|
||||
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
|
||||
end
|
||||
|
||||
def settings_params
|
||||
|
||||
@@ -8,7 +8,8 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
|
||||
end
|
||||
|
||||
def destroy
|
||||
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
|
||||
notification_subscription = current_user.notification_subscriptions
|
||||
.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
|
||||
notification_subscription.destroy! if notification_subscription.present?
|
||||
head :ok
|
||||
end
|
||||
|
||||
@@ -83,6 +83,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def set_message
|
||||
@message = @web_widget.inbox.messages.find(permitted_params[:id])
|
||||
# `conversation.messages.find` would be simpler, but `conversation` is `conversations.last`,
|
||||
# which means a visitor with more than one open thread could not edit a message in any
|
||||
# but their most recent one. Scoping across all of the visitor's conversations keeps the
|
||||
# happy path correct for that future multi-conversation widget flow.
|
||||
@message = Message.where(conversation_id: conversations.select(:id)).find(permitted_params[:id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -24,6 +24,10 @@ class Public::Api::V1::InboxesController < PublicController
|
||||
def set_conversation
|
||||
return if params[:conversation_id].blank?
|
||||
|
||||
@conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
|
||||
@conversation = if @contact_inbox.hmac_verified?
|
||||
@contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
|
||||
else
|
||||
@contact_inbox.conversations.find_by!(display_id: params[:conversation_id])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
class SwaggerController < ApplicationController
|
||||
def respond
|
||||
if Rails.env.development? || Rails.env.test?
|
||||
render inline: Rails.root.join('swagger', derived_path).read
|
||||
swagger_root = Rails.root.join('swagger')
|
||||
file_path = swagger_root.join(derived_path).cleanpath
|
||||
|
||||
return head :not_found unless file_path.to_s.start_with?("#{swagger_root}/") && file_path.file?
|
||||
|
||||
render inline: file_path.read
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
@@ -11,8 +16,8 @@ class SwaggerController < ApplicationController
|
||||
|
||||
def derived_path
|
||||
params[:path] ||= 'index.html'
|
||||
path = Rack::Utils.clean_path_info(params[:path])
|
||||
path << ".#{Rack::Utils.clean_path_info(params[:format])}" unless path.ends_with?(params[:format].to_s)
|
||||
path = Rack::Utils.clean_path_info(params[:path]).delete_prefix('/')
|
||||
path << ".#{Rack::Utils.clean_path_info(params[:format]).delete_prefix('/')}" unless path.ends_with?(params[:format].to_s)
|
||||
path
|
||||
end
|
||||
end
|
||||
|
||||
@@ -53,13 +53,12 @@ module ReportHelper
|
||||
end
|
||||
|
||||
def resolutions
|
||||
scope.reporting_events.where(account_id: account.id, name: :conversation_resolved,
|
||||
created_at: range)
|
||||
scope.reporting_events.where(account_id: account.id, name: :conversation_resolved, created_at: range)
|
||||
end
|
||||
|
||||
def bot_resolutions
|
||||
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved,
|
||||
created_at: range)
|
||||
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range)
|
||||
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
|
||||
end
|
||||
|
||||
def bot_handoffs
|
||||
@@ -67,6 +66,10 @@ module ReportHelper
|
||||
created_at: range).distinct
|
||||
end
|
||||
|
||||
def bot_handoff_conversation_ids_subquery
|
||||
bot_handoffs
|
||||
end
|
||||
|
||||
def avg_first_response_time
|
||||
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id))
|
||||
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
|
||||
|
||||
@@ -6,15 +6,22 @@ class CaptainDocument extends ApiClient {
|
||||
super('captain/documents', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ page = 1, searchKey, assistantId } = {}) {
|
||||
get({ page = 1, searchKey, assistantId, filter, source, sort } = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
page,
|
||||
searchKey,
|
||||
search_key: searchKey,
|
||||
assistant_id: assistantId,
|
||||
filter,
|
||||
source,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
sync(id) {
|
||||
return axios.post(`${this.url}/${id}/sync`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainDocument();
|
||||
|
||||
@@ -28,6 +28,14 @@ class CompanyAPI extends ApiClient {
|
||||
return axios.get(`${this.url}/${id}/contacts?${buildParams({ page })}`);
|
||||
}
|
||||
|
||||
listNotes(id) {
|
||||
return axios.get(`${this.url}/${id}/notes`);
|
||||
}
|
||||
|
||||
listConversations(id) {
|
||||
return axios.get(`${this.url}/${id}/conversations`);
|
||||
}
|
||||
|
||||
searchContacts(id, query = '', page = 1) {
|
||||
const requestURL = `${this.url}/${id}/contacts/search?${buildParams({ q: query, page })}`;
|
||||
return axios.get(requestURL);
|
||||
|
||||
@@ -56,9 +56,14 @@ const closeMobileSidebar = () => {
|
||||
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
<div class="shrink-0">
|
||||
<slot name="sidebarHeader" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto pb-6 pt-3">
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -105,9 +110,14 @@ const closeMobileSidebar = () => {
|
||||
<div
|
||||
v-if="isSidebarOpen"
|
||||
id="details-sidebar-content"
|
||||
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
|
||||
class="order-2 w-[85%] sm:w-[50%] flex flex-col bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak shadow-lg"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
<div class="shrink-0">
|
||||
<slot name="sidebarHeader" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto pb-6 pt-3">
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -169,7 +169,7 @@ const handleContactSelect = contactId => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 px-6 pb-8 pt-1">
|
||||
<div class="flex flex-col gap-6 px-6 pb-8">
|
||||
<div v-if="!selectedContact" class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-base text-n-slate-12">
|
||||
@@ -288,7 +288,7 @@ const handleContactSelect = contactId => {
|
||||
|
||||
<div
|
||||
v-else-if="!hasContacts"
|
||||
class="py-8 text-sm text-center rounded-xl border border-dashed border-n-weak text-n-slate-11"
|
||||
class="py-8 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
|
||||
>
|
||||
{{ t('COMPANIES.DETAIL.CONTACTS.EMPTY') }}
|
||||
</div>
|
||||
@@ -346,7 +346,7 @@ const handleContactSelect = contactId => {
|
||||
:current-page="currentPage"
|
||||
:total-items="totalContacts"
|
||||
:items-per-page="15"
|
||||
class="!px-0 before:hidden"
|
||||
class="!px-0 before:hidden bg-transparent"
|
||||
@update:current-page="emit('update:currentPage', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
defineProps({
|
||||
conversations: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const contactsById = useMapGetter('contacts/getContactById');
|
||||
const stateInbox = useMapGetter('inboxes/getInboxById');
|
||||
const accountLabels = useMapGetter('labels/getLabels');
|
||||
|
||||
const accountLabelsValue = computed(() => accountLabels.value);
|
||||
const conversationContact = conversation => {
|
||||
const sender = conversation.meta?.sender || {};
|
||||
const contact = contactsById.value(sender.id);
|
||||
return contact.id ? contact : sender;
|
||||
};
|
||||
const conversationInbox = conversation =>
|
||||
stateInbox.value(conversation.inboxId) || {
|
||||
name: '',
|
||||
channelType: conversation.meta?.channel,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="conversations.length > 0"
|
||||
class="px-6 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
|
||||
>
|
||||
<ConversationCard
|
||||
v-for="conversation in conversations"
|
||||
:key="conversation.id"
|
||||
:conversation="conversation"
|
||||
:contact="conversationContact(conversation)"
|
||||
:state-inbox="conversationInbox(conversation)"
|
||||
:account-labels="accountLabelsValue"
|
||||
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-else
|
||||
class="py-8 px-4 mx-6 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
|
||||
>
|
||||
{{ t('COMPANIES.DETAIL.HISTORY.EMPTY') }}
|
||||
</p>
|
||||
</template>
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
notes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const hasNotes = computed(() => props.notes.length > 0);
|
||||
|
||||
const contactName = contact =>
|
||||
contact?.name || t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
|
||||
|
||||
const getWrittenBy = note => {
|
||||
const isCurrentUser = note?.user?.id === currentUser.value.id;
|
||||
return isCurrentUser
|
||||
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
|
||||
: note?.user?.name || 'Bot';
|
||||
};
|
||||
|
||||
const openContact = contactId => {
|
||||
router.push({
|
||||
name: 'contacts_edit',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
contactId,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasNotes" class="flex flex-col px-6">
|
||||
<div class="flex flex-col divide-y divide-n-strong">
|
||||
<div
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
class="flex flex-col gap-2 py-4 group/note"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<Avatar
|
||||
:name="contactName(note.contact)"
|
||||
:src="note.contact?.thumbnail"
|
||||
:size="16"
|
||||
rounded-full
|
||||
hide-offline-status
|
||||
/>
|
||||
<div
|
||||
class="flex items-center justify-between min-w-0 gap-1 w-full text-sm text-n-slate-11"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 font-medium truncate text-start text-n-slate-12 hover:text-n-blue-11 p-0"
|
||||
@click="openContact(note.contact.id)"
|
||||
>
|
||||
{{ contactName(note.contact) }}
|
||||
</button>
|
||||
<div class="min-w-0 truncate">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-sm text-n-slate-10"
|
||||
>
|
||||
<span class="font-medium text-n-slate-11">
|
||||
{{ getWrittenBy(note) }}
|
||||
</span>
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
|
||||
<span class="font-medium text-n-slate-11">
|
||||
{{ dynamicTime(note.createdAt) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-dompurify-html="formatMessage(note.content || '')"
|
||||
class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="isLoading"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-else
|
||||
class="py-8 mx-6 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
|
||||
>
|
||||
{{ t('COMPANIES.DETAIL.NOTES.EMPTY') }}
|
||||
</p>
|
||||
</template>
|
||||
@@ -32,7 +32,7 @@ const emit = defineEmits([
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="sticky top-0 z-10 px-6">
|
||||
<header class="sticky top-0 z-20 px-6">
|
||||
<div
|
||||
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
|
||||
>
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ const handleOrderChange = value => {
|
||||
<div
|
||||
v-if="isMenuOpen"
|
||||
v-on-clickaway="() => (isMenuOpen = false)"
|
||||
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4 z-50"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedIds: { type: Set, default: () => new Set() },
|
||||
documents: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:selectedIds',
|
||||
'bulkSyncQueued',
|
||||
'bulkDeleteSucceeded',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const bulkDeleteDialog = ref(null);
|
||||
|
||||
const isSyncableDocument = doc =>
|
||||
!doc.pdf_document && doc.status === 'available' && !doc.sync_in_progress;
|
||||
|
||||
const syncableSelectedIds = computed(() => {
|
||||
if (!props.selectedIds.size) return [];
|
||||
return props.documents
|
||||
.filter(doc => props.selectedIds.has(doc.id) && isSyncableDocument(doc))
|
||||
.map(doc => doc.id);
|
||||
});
|
||||
|
||||
const hasSyncableSelection = computed(
|
||||
() => syncableSelectedIds.value.length > 0
|
||||
);
|
||||
|
||||
const selectAllLabel = computed(() => {
|
||||
const count = props.documents.length;
|
||||
const isAllSelected = props.selectedIds.size === count && count > 0;
|
||||
return isAllSelected
|
||||
? t('CAPTAIN.DOCUMENTS.UNSELECT_ALL', { count })
|
||||
: t('CAPTAIN.DOCUMENTS.SELECT_ALL', { count });
|
||||
});
|
||||
|
||||
const selectedCountLabel = computed(() =>
|
||||
t('CAPTAIN.DOCUMENTS.SELECTED', { count: props.selectedIds.size })
|
||||
);
|
||||
|
||||
const handleBulkSync = async () => {
|
||||
const ids = syncableSelectedIds.value;
|
||||
if (!ids.length) return;
|
||||
|
||||
try {
|
||||
const response = await store.dispatch('captainBulkActions/handleBulkSync', {
|
||||
ids,
|
||||
});
|
||||
const queuedCount = response?.count ?? response?.ids?.length ?? 0;
|
||||
let message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.ZERO_MESSAGE');
|
||||
|
||||
if (queuedCount === 1) {
|
||||
message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.SUCCESS_MESSAGE_ONE');
|
||||
} else if (queuedCount > 1) {
|
||||
message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.SUCCESS_MESSAGE', {
|
||||
count: queuedCount,
|
||||
});
|
||||
}
|
||||
|
||||
useAlert(message);
|
||||
emit('update:selectedIds', new Set());
|
||||
if (queuedCount > 0) emit('bulkSyncQueued');
|
||||
} catch (error) {
|
||||
useAlert(t('CAPTAIN.DOCUMENTS.BULK_SYNC.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BulkSelectBar
|
||||
:model-value="selectedIds"
|
||||
:all-items="documents"
|
||||
:select-all-label="selectAllLabel"
|
||||
:selected-count-label="selectedCountLabel"
|
||||
:delete-label="$t('CAPTAIN.DOCUMENTS.BULK_DELETE_BUTTON')"
|
||||
class="w-fit"
|
||||
:class="{ 'mb-2': selectedIds.size > 0 }"
|
||||
@update:model-value="emit('update:selectedIds', $event)"
|
||||
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
|
||||
>
|
||||
<template v-if="hasSyncableSelection" #secondaryActions>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.DOCUMENTS.BULK_SYNC_BUTTON')"
|
||||
sm
|
||||
slate
|
||||
ghost
|
||||
icon="i-lucide-refresh-cw"
|
||||
class="!px-1.5"
|
||||
@click="handleBulkSync"
|
||||
/>
|
||||
</template>
|
||||
</BulkSelectBar>
|
||||
<BulkDeleteDialog
|
||||
ref="bulkDeleteDialog"
|
||||
:bulk-ids="selectedIds"
|
||||
type="AssistantDocument"
|
||||
@delete-success="emit('bulkDeleteSucceeded')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,14 +5,17 @@ import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import {
|
||||
isPdfDocument,
|
||||
isSafeHttpLink,
|
||||
formatDocumentLink,
|
||||
getDocumentDisplayPath,
|
||||
} from 'shared/helpers/documentHelper';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import DocumentSyncStatus from 'dashboard/components-next/captain/assistant/DocumentSyncStatus.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -31,10 +34,38 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
pdfDocument: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
createdAt: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
syncStatus: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
lastSyncedAt: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
lastSyncErrorCode: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
syncInProgress: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
syncStaleAfterHours: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -64,6 +95,20 @@ const modelValue = computed({
|
||||
set: () => emit('select', props.id),
|
||||
});
|
||||
|
||||
const isPdf = computed(() => props.pdfDocument);
|
||||
const hasSafeLink = computed(() => isSafeHttpLink(props.externalLink));
|
||||
const canManage = computed(() => checkPermissions(['administrator']));
|
||||
const isAvailable = computed(() => props.status === 'available');
|
||||
const canSync = computed(
|
||||
() => canManage.value && !isPdf.value && isAvailable.value
|
||||
);
|
||||
const isSyncing = computed(() => props.syncStatus === 'syncing');
|
||||
const isFailed = computed(() => props.syncStatus === 'failed');
|
||||
const isRetryableSync = computed(
|
||||
() => isFailed.value || (isSyncing.value && !props.syncInProgress)
|
||||
);
|
||||
const showSyncStatus = computed(() => !isPdf.value);
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const allOptions = [
|
||||
{
|
||||
@@ -74,7 +119,19 @@ const menuItems = computed(() => {
|
||||
},
|
||||
];
|
||||
|
||||
if (checkPermissions(['administrator'])) {
|
||||
if (canSync.value) {
|
||||
allOptions.push({
|
||||
label: isRetryableSync.value
|
||||
? t('CAPTAIN.DOCUMENTS.OPTIONS.RETRY_SYNC')
|
||||
: t('CAPTAIN.DOCUMENTS.OPTIONS.SYNC_NOW'),
|
||||
value: 'sync',
|
||||
action: 'sync',
|
||||
icon: 'i-lucide-refresh-cw',
|
||||
disabled: props.syncInProgress,
|
||||
});
|
||||
}
|
||||
|
||||
if (canManage.value) {
|
||||
allOptions.push({
|
||||
label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'),
|
||||
value: 'delete',
|
||||
@@ -86,17 +143,25 @@ const menuItems = computed(() => {
|
||||
return allOptions;
|
||||
});
|
||||
|
||||
const createdAt = computed(() => dynamicTime(props.createdAt));
|
||||
const createdAtLabel = computed(() => dynamicTime(props.createdAt));
|
||||
|
||||
const displayLink = computed(() => formatDocumentLink(props.externalLink));
|
||||
const displayLink = computed(() =>
|
||||
isPdf.value
|
||||
? formatDocumentLink(props.externalLink)
|
||||
: getDocumentDisplayPath(props.externalLink)
|
||||
);
|
||||
const linkIcon = computed(() =>
|
||||
isPdfDocument(props.externalLink) ? 'i-ph-file-pdf' : 'i-ph-link-simple'
|
||||
isPdf.value ? 'i-ph-file-pdf' : 'i-ph-link-simple'
|
||||
);
|
||||
|
||||
const handleAction = ({ action, value }) => {
|
||||
toggleDropdown(false);
|
||||
emit('action', { action, value, id: props.id });
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
emit('action', { action: 'sync', id: props.id });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -141,17 +206,41 @@ const handleAction = ({ action, value }) => {
|
||||
<span
|
||||
class="flex gap-1 items-center text-sm truncate shrink-0 text-n-slate-11"
|
||||
>
|
||||
<i class="i-woot-captain" />
|
||||
<Icon icon="i-woot-captain" />
|
||||
{{ assistant?.name || '' }}
|
||||
</span>
|
||||
<a
|
||||
v-if="!isPdf && hasSafeLink"
|
||||
:href="externalLink"
|
||||
:title="externalLink"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex flex-1 gap-1 justify-start items-center text-sm truncate text-n-slate-11 hover:text-n-slate-12 hover:underline"
|
||||
@click.stop
|
||||
>
|
||||
<Icon :icon="linkIcon" class="shrink-0" />
|
||||
<span class="truncate">{{ displayLink }}</span>
|
||||
<Icon icon="i-lucide-external-link size-3 shrink-0 opacity-70" />
|
||||
</a>
|
||||
<span
|
||||
v-else
|
||||
class="flex flex-1 gap-1 justify-start items-center text-sm truncate text-n-slate-11"
|
||||
>
|
||||
<i :class="linkIcon" class="shrink-0" />
|
||||
<Icon :icon="linkIcon" class="shrink-0" />
|
||||
<span class="truncate">{{ displayLink }}</span>
|
||||
</span>
|
||||
<div class="text-sm shrink-0 text-n-slate-11 line-clamp-1">
|
||||
{{ createdAt }}
|
||||
<DocumentSyncStatus
|
||||
v-if="showSyncStatus"
|
||||
:status="syncStatus"
|
||||
:last-synced-at="lastSyncedAt"
|
||||
:error-code="lastSyncErrorCode"
|
||||
:sync-in-progress="syncInProgress"
|
||||
:stale-after-hours="syncStaleAfterHours"
|
||||
:show-retry="canSync && isRetryableSync"
|
||||
@retry="handleRetry"
|
||||
/>
|
||||
<div v-else class="text-sm shrink-0 text-n-slate-11 line-clamp-1">
|
||||
{{ createdAtLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</CardLayout>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import DocumentFiltersBar from 'dashboard/components-next/captain/assistant/DocumentFiltersBar.vue';
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
|
||||
const source = ref('all');
|
||||
const status = ref(null);
|
||||
const sort = ref('recently_updated');
|
||||
|
||||
const hasActiveFilters = computed(
|
||||
() => source.value !== 'all' || Boolean(status.value)
|
||||
);
|
||||
|
||||
const buildParams = (page = 1) => {
|
||||
const params = { page };
|
||||
if (source.value !== 'all') params.source = source.value;
|
||||
if (status.value) params.filter = status.value;
|
||||
if (sort.value) params.sort = sort.value;
|
||||
return params;
|
||||
};
|
||||
|
||||
const emitChange = () => emit('change');
|
||||
|
||||
const handleSourceSelect = sourceKey => {
|
||||
source.value = sourceKey;
|
||||
if (sourceKey !== 'web') status.value = null;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const handleStatusSelect = statusKey => {
|
||||
status.value = statusKey;
|
||||
if (statusKey) source.value = 'web';
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const handleSortSelect = sortKey => {
|
||||
sort.value = sortKey;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
source.value = 'all';
|
||||
status.value = null;
|
||||
sort.value = 'recently_updated';
|
||||
};
|
||||
|
||||
defineExpose({ buildParams, reset, hasActiveFilters });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DocumentFiltersBar
|
||||
:active-source-filter="source"
|
||||
:active-status-filter="status"
|
||||
:active-sort="sort"
|
||||
@select-source="handleSourceSelect"
|
||||
@select-status="handleStatusSelect"
|
||||
@select-sort="handleSortSelect"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeSourceFilter: { type: String, default: 'all' },
|
||||
activeStatusFilter: { type: String, default: null },
|
||||
activeSort: { type: String, default: 'recently_updated' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectSource', 'selectStatus', 'selectSort']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const openMenu = ref(null);
|
||||
|
||||
const MENU_CONFIG = [
|
||||
{
|
||||
key: 'source',
|
||||
activeKey: 'activeSourceFilter',
|
||||
dropdownClass: 'min-w-48',
|
||||
options: [
|
||||
{ labelKey: 'SOURCE.ALL', value: 'all', icon: 'i-lucide-files' },
|
||||
{ labelKey: 'SOURCE.WEB', value: 'web', icon: 'i-lucide-link' },
|
||||
{ labelKey: 'SOURCE.PDF', value: 'pdf', icon: 'i-lucide-file-text' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
activeKey: 'activeStatusFilter',
|
||||
dropdownClass: 'min-w-52',
|
||||
options: [
|
||||
{ labelKey: 'STATUS.ANY', value: null, icon: 'i-lucide-circle-dashed' },
|
||||
{
|
||||
labelKey: 'STATUS.UPDATED',
|
||||
value: 'synced',
|
||||
icon: 'i-lucide-check-circle',
|
||||
},
|
||||
{
|
||||
labelKey: 'STATUS.NEEDS_UPDATE',
|
||||
value: 'stale',
|
||||
icon: 'i-lucide-clock',
|
||||
},
|
||||
{
|
||||
labelKey: 'STATUS.UPDATING',
|
||||
value: 'syncing',
|
||||
icon: 'i-lucide-refresh-cw',
|
||||
},
|
||||
{
|
||||
labelKey: 'STATUS.FAILED',
|
||||
value: 'failed',
|
||||
icon: 'i-lucide-circle-x',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sort',
|
||||
activeKey: 'activeSort',
|
||||
dropdownClass: 'min-w-56',
|
||||
options: [
|
||||
{
|
||||
labelKey: 'SORT.RECENTLY_UPDATED',
|
||||
value: 'recently_updated',
|
||||
icon: 'i-lucide-arrow-down-up',
|
||||
},
|
||||
{
|
||||
labelKey: 'SORT.RECENTLY_CREATED',
|
||||
value: 'recently_created',
|
||||
icon: 'i-lucide-clock',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const filterMenus = computed(() =>
|
||||
MENU_CONFIG.filter(
|
||||
menu => !(menu.key === 'status' && props.activeSourceFilter === 'pdf')
|
||||
).map(menu => {
|
||||
const active = props[menu.activeKey];
|
||||
const items = menu.options.map(opt => ({
|
||||
label: t(`CAPTAIN.DOCUMENTS.FILTERS.${opt.labelKey}`),
|
||||
value: opt.value,
|
||||
icon: opt.icon,
|
||||
action: menu.key,
|
||||
isSelected: opt.value === active,
|
||||
}));
|
||||
return {
|
||||
...menu,
|
||||
items,
|
||||
selected: items.find(item => item.isSelected) || items[0],
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const closeMenu = () => {
|
||||
openMenu.value = null;
|
||||
};
|
||||
|
||||
const toggleMenu = menu => {
|
||||
openMenu.value = openMenu.value === menu ? null : menu;
|
||||
};
|
||||
|
||||
const handleMenuAction = ({ action, value }) => {
|
||||
closeMenu();
|
||||
if (action === 'source') emit('selectSource', value);
|
||||
else if (action === 'status') emit('selectStatus', value);
|
||||
else if (action === 'sort') emit('selectSort', value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="closeMenu"
|
||||
class="inline-flex flex-wrap items-center gap-2 pt-2 w-fit"
|
||||
>
|
||||
<div v-for="menu in filterMenus" :key="menu.key" class="relative">
|
||||
<Button
|
||||
:icon="menu.selected.icon"
|
||||
slate
|
||||
size="sm"
|
||||
:class="{ 'bg-n-slate-9/10': openMenu === menu.key }"
|
||||
@click="toggleMenu(menu.key)"
|
||||
>
|
||||
<span class="min-w-0 truncate">{{ menu.selected.label }}</span>
|
||||
<Icon icon="i-lucide-chevron-down" class="shrink-0 size-4" />
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
v-if="openMenu === menu.key"
|
||||
:menu-items="menu.items"
|
||||
:class="menu.dropdownClass"
|
||||
class="top-full mt-2 ltr:left-0 rtl:right-0"
|
||||
@action="handleMenuAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
lastSyncedAt: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
errorCode: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
syncInProgress: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
staleAfterHours: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
showRetry: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['retry']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const SECONDS_PER_HOUR = 3600;
|
||||
|
||||
const SYNCING = 'syncing';
|
||||
const FAILED = 'failed';
|
||||
|
||||
const ERROR_CODE_LABELS = {
|
||||
not_found: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.NOT_FOUND',
|
||||
access_denied: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.ACCESS_DENIED',
|
||||
timeout: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.TIMEOUT',
|
||||
content_empty: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.CONTENT_EMPTY',
|
||||
fetch_failed: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.FETCH_FAILED',
|
||||
sync_error: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.SYNC_ERROR',
|
||||
};
|
||||
const DEFAULT_ERROR_LABEL = 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.DEFAULT';
|
||||
|
||||
const hasSyncingStatus = computed(() => props.status === SYNCING);
|
||||
const isSyncing = computed(
|
||||
() => hasSyncingStatus.value && props.syncInProgress
|
||||
);
|
||||
const isStaleSync = computed(
|
||||
() => hasSyncingStatus.value && !props.syncInProgress
|
||||
);
|
||||
const isFailed = computed(() => props.status === FAILED);
|
||||
const canRetry = computed(() => isFailed.value || isStaleSync.value);
|
||||
const hasBeenSynced = computed(() => Boolean(props.lastSyncedAt));
|
||||
|
||||
const ageInHours = computed(() => {
|
||||
if (!props.lastSyncedAt) return null;
|
||||
const nowSeconds = Date.now() / 1000;
|
||||
return (nowSeconds - props.lastSyncedAt) / SECONDS_PER_HOUR;
|
||||
});
|
||||
|
||||
const staleAfterHours = computed(() => Number(props.staleAfterHours));
|
||||
const hasStaleThreshold = computed(
|
||||
() => Number.isFinite(staleAfterHours.value) && staleAfterHours.value > 0
|
||||
);
|
||||
const isStale = computed(
|
||||
() =>
|
||||
hasStaleThreshold.value &&
|
||||
ageInHours.value !== null &&
|
||||
ageInHours.value >= staleAfterHours.value
|
||||
);
|
||||
|
||||
const errorLabel = computed(() =>
|
||||
t(ERROR_CODE_LABELS[props.errorCode] || DEFAULT_ERROR_LABEL)
|
||||
);
|
||||
|
||||
const label = computed(() => {
|
||||
if (isSyncing.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCING');
|
||||
if (isStaleSync.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.STALE_SYNC');
|
||||
if (isFailed.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.FAILED');
|
||||
if (hasBeenSynced.value)
|
||||
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCED', {
|
||||
time: dynamicTime(props.lastSyncedAt),
|
||||
});
|
||||
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.NEVER_SYNCED');
|
||||
});
|
||||
|
||||
const fullLabel = computed(() => {
|
||||
if (isSyncing.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCING');
|
||||
if (isStaleSync.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.STALE_SYNC');
|
||||
if (isFailed.value)
|
||||
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.FAILED', {
|
||||
error: errorLabel.value,
|
||||
});
|
||||
if (hasBeenSynced.value)
|
||||
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCED', {
|
||||
time: dynamicTime(props.lastSyncedAt),
|
||||
});
|
||||
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.NEVER_SYNCED');
|
||||
});
|
||||
|
||||
const tone = computed(() => {
|
||||
if (isSyncing.value) return 'amber';
|
||||
if (isStaleSync.value) return 'amber';
|
||||
if (isFailed.value) return 'ruby';
|
||||
if (isStale.value) return 'amber';
|
||||
return 'slate';
|
||||
});
|
||||
|
||||
const textClass = computed(() => {
|
||||
if (tone.value === 'amber') return 'text-n-amber-11';
|
||||
if (tone.value === 'ruby') return 'text-n-ruby-11';
|
||||
return 'text-n-slate-11';
|
||||
});
|
||||
|
||||
const statusIcon = computed(() => {
|
||||
if (isFailed.value || isStale.value || isStaleSync.value) {
|
||||
return 'i-lucide-circle-alert';
|
||||
}
|
||||
return 'i-lucide-refresh-cw';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="flex gap-1.5 items-center text-sm truncate shrink-0 tabular-nums"
|
||||
:class="textClass"
|
||||
:title="fullLabel"
|
||||
>
|
||||
<Spinner v-if="isSyncing" class="text-n-amber-11 size-3" />
|
||||
<Icon v-else :icon="statusIcon" class="shrink-0 size-3.5" />
|
||||
<span class="truncate">{{ label }}</span>
|
||||
<Button
|
||||
v-if="showRetry && canRetry"
|
||||
:label="t('CAPTAIN.DOCUMENTS.OPTIONS.RETRY_SYNC')"
|
||||
xs
|
||||
link
|
||||
ruby
|
||||
icon="i-lucide-refresh-cw"
|
||||
class="hover:!no-underline !gap-1 ms-1"
|
||||
@click.stop="emit('retry')"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
+2
-1
@@ -15,7 +15,7 @@ defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const emit = defineEmits(['close', 'createSuccess']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
@@ -26,6 +26,7 @@ const i18nKey = 'CAPTAIN.DOCUMENTS.CREATE';
|
||||
const handleSubmit = async newDocument => {
|
||||
try {
|
||||
await store.dispatch('captainDocuments/create', newDocument);
|
||||
emit('createSuccess');
|
||||
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
|
||||
dialogRef.value.close();
|
||||
} catch (error) {
|
||||
|
||||
@@ -803,6 +803,11 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
computed(() => props.disabled),
|
||||
() => editorView?.setProps({})
|
||||
);
|
||||
|
||||
watch(
|
||||
computed(() => props.updateSelectionWith),
|
||||
(newValue, oldValue) => {
|
||||
|
||||
@@ -217,7 +217,11 @@ export default {
|
||||
isReplyButtonDisabled() {
|
||||
if (this.isEditorDisabled) return true;
|
||||
if (this.isATwitterInbox) return true;
|
||||
if (this.hasAttachments || this.hasRecordedAudio) return false;
|
||||
// Note: only `hasAttachments` (attachedFiles populated) enables send.
|
||||
// `hasRecordedAudio` becomes true the moment recording stops, but the
|
||||
// file upload that follows is async — sending in that window produced
|
||||
// empty messages that Meta rejected with "text.body is required".
|
||||
if (this.hasAttachments) return false;
|
||||
|
||||
return (
|
||||
this.isMessageEmpty ||
|
||||
@@ -764,6 +768,13 @@ export default {
|
||||
if (this.isReplyButtonDisabled) {
|
||||
return;
|
||||
}
|
||||
// Defensive: never send a payload that has neither content nor a file.
|
||||
// The send button's disabled state already covers this, but guard here
|
||||
// too so any other entry point (hotkey, programmatic) cannot create the
|
||||
// empty Message rows that Meta rejects with "text.body is required".
|
||||
if (this.isMessageEmpty && !this.hasAttachments) {
|
||||
return;
|
||||
}
|
||||
if (!this.showMentions) {
|
||||
const copilotAcceptedMessage = this.getCopilotAcceptedMessage();
|
||||
const isOnWhatsApp =
|
||||
|
||||
+1
-7
@@ -1,9 +1,8 @@
|
||||
<script setup>
|
||||
import { useTemplateRef, computed, ref, onMounted } from 'vue';
|
||||
import { useTemplateRef, computed, ref } from 'vue';
|
||||
import { useI18n, I18nT } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -19,7 +18,6 @@ const props = defineProps({
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const containerRef = useTemplateRef('containerRef');
|
||||
const [showDropdown, toggleDropdown] = useToggle(false);
|
||||
const selectedTeam = ref(null);
|
||||
@@ -77,10 +75,6 @@ const handleDismiss = () => {
|
||||
selectedTeam.value = null;
|
||||
toggleDropdown(false);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('teams/get');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -41,6 +41,7 @@ export const FEATURE_FLAGS = {
|
||||
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
CAPTAIN_TASKS: 'captain_tasks',
|
||||
CAPTAIN_DOCUMENT_AUTO_SYNC: 'captain_document_auto_sync',
|
||||
SAML: 'saml',
|
||||
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
|
||||
COMPANIES: 'companies',
|
||||
|
||||
@@ -44,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.",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -134,7 +134,7 @@ const populateFormFields = () => {
|
||||
|
||||
if (!locale.value) locale.value = detectBestLocale();
|
||||
if (!website.value) {
|
||||
website.value = account?.domain || brandInfo?.domain || '';
|
||||
website.value = attrs.website || brandInfo?.domain || '';
|
||||
}
|
||||
if (!timezone.value) {
|
||||
timezone.value =
|
||||
@@ -216,7 +216,7 @@ const handleSubmit = async () => {
|
||||
await updateAccount({
|
||||
name: accountName.value,
|
||||
locale: locale.value,
|
||||
domain: website.value,
|
||||
website: website.value,
|
||||
industry: industry.value,
|
||||
company_size: companySize.value,
|
||||
timezone: timezone.value,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -13,6 +13,8 @@ const createInitialUIFlags = () => ({
|
||||
deletingAvatar: false,
|
||||
deletingCustomAttributes: false,
|
||||
fetchingContacts: false,
|
||||
fetchingConversations: false,
|
||||
fetchingNotes: false,
|
||||
searchingContacts: false,
|
||||
creatingContact: false,
|
||||
removingContact: false,
|
||||
@@ -67,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,
|
||||
@@ -270,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) {
|
||||
@@ -379,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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,24 +14,24 @@
|
||||
},
|
||||
"THUMBNAIL": {
|
||||
"AUTHOR": {
|
||||
"NOT_AVAILABLE": "Not available"
|
||||
"NOT_AVAILABLE": "No disponible"
|
||||
}
|
||||
},
|
||||
"TEAM_AVAILABILITY": {
|
||||
"ONLINE": "Estamos en línea",
|
||||
"OFFLINE": "Estamos ausentes en este momento",
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "Volveremos tan pronto como sea posible"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"IN_A_FEW_MINUTES": "Normalmente responde en unos minutos",
|
||||
"IN_A_FEW_HOURS": "Normalmente responde en unas pocas horas",
|
||||
"IN_A_DAY": "Normalmente responde en un día",
|
||||
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
|
||||
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
|
||||
"BACK_AT_TIME": "We will be back online at {time}",
|
||||
"BACK_ON_DAY": "We will be back online on {day}",
|
||||
"BACK_TOMORROW": "We will be back online tomorrow",
|
||||
"BACK_IN_SOME_TIME": "We will be back online in some time"
|
||||
"BACK_IN_HOURS": "Volveremos a estar en línea en {n} hora | Volveremos a estar en línea en {n} horas",
|
||||
"BACK_IN_MINUTES": "Volveremos a estar en línea en {time} minutos",
|
||||
"BACK_AT_TIME": "Volveremos a estar en línea a las {time}",
|
||||
"BACK_ON_DAY": "Volveremos a estar en línea el {day}",
|
||||
"BACK_TOMORROW": "Volveremos a estar en línea mañana",
|
||||
"BACK_IN_SOME_TIME": "Volveremos a estar en línea dentro de un rato"
|
||||
},
|
||||
"DAY_NAMES": {
|
||||
"SUNDAY": "Domingo",
|
||||
|
||||
@@ -14,24 +14,24 @@
|
||||
},
|
||||
"THUMBNAIL": {
|
||||
"AUTHOR": {
|
||||
"NOT_AVAILABLE": "Not available"
|
||||
"NOT_AVAILABLE": "Niet beschikbaar"
|
||||
}
|
||||
},
|
||||
"TEAM_AVAILABILITY": {
|
||||
"ONLINE": "We zijn online",
|
||||
"OFFLINE": "We zijn momenteel afwezig",
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "We zijn zo snel mogelijk weer terug"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"IN_A_FEW_MINUTES": "Reageert meestal binnen een paar minuten",
|
||||
"IN_A_FEW_HOURS": "Reageert meestal binnen een paar uur",
|
||||
"IN_A_DAY": "Reageert meestal binnen een dag",
|
||||
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
|
||||
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
|
||||
"BACK_AT_TIME": "We will be back online at {time}",
|
||||
"BACK_ON_DAY": "We will be back online on {day}",
|
||||
"BACK_TOMORROW": "We will be back online tomorrow",
|
||||
"BACK_IN_SOME_TIME": "We will be back online in some time"
|
||||
"BACK_IN_HOURS": "We zullen weer online zijn in {n} uur | We zijn weer online in {n} uur",
|
||||
"BACK_IN_MINUTES": "We zijn weer online in {time} minuten",
|
||||
"BACK_AT_TIME": "We zijn weer online op {time}",
|
||||
"BACK_ON_DAY": "We zijn weer online op {day}",
|
||||
"BACK_TOMORROW": "We zullen morgen weer online zijn",
|
||||
"BACK_IN_SOME_TIME": "We zijn weer online binnen een bepaalde tijd"
|
||||
},
|
||||
"DAY_NAMES": {
|
||||
"SUNDAY": "zondag",
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -8,6 +8,7 @@ class BulkActionsJob < ApplicationJob
|
||||
|
||||
def perform(account:, params:, user:)
|
||||
@account = account
|
||||
@user = user
|
||||
Current.user = user
|
||||
@params = params
|
||||
@records = records_to_updated(params[:ids])
|
||||
@@ -61,6 +62,7 @@ class BulkActionsJob < ApplicationJob
|
||||
current_model = @params[:type].camelcase
|
||||
return unless MODEL_TYPE.include?(current_model)
|
||||
|
||||
current_model.constantize&.where(account_id: @account.id, display_id: ids)
|
||||
scope = current_model.constantize.where(account_id: @account.id, display_id: ids)
|
||||
Conversations::PermissionFilterService.new(scope, @user, @account).perform
|
||||
end
|
||||
end
|
||||
|
||||
+17
-10
@@ -3,19 +3,19 @@ class HookJob < MutexApplicationJob
|
||||
|
||||
queue_as :medium
|
||||
|
||||
INTEGRATION_PROCESSORS = {
|
||||
'slack' => :process_slack_integration,
|
||||
'dialogflow' => :process_dialogflow_integration,
|
||||
'google_translate' => :google_translate_integration,
|
||||
'leadsquared' => :process_leadsquared_integration_with_lock,
|
||||
'linear' => :process_linear_integration
|
||||
}.freeze
|
||||
|
||||
def perform(hook, event_name, event_data = {})
|
||||
return if hook.disabled?
|
||||
|
||||
case hook.app_id
|
||||
when 'slack'
|
||||
process_slack_integration(hook, event_name, event_data)
|
||||
when 'dialogflow'
|
||||
process_dialogflow_integration(hook, event_name, event_data)
|
||||
when 'google_translate'
|
||||
google_translate_integration(hook, event_name, event_data)
|
||||
when 'leadsquared'
|
||||
process_leadsquared_integration_with_lock(hook, event_name, event_data)
|
||||
end
|
||||
processor = INTEGRATION_PROCESSORS[hook.app_id]
|
||||
send(processor, hook, event_name, event_data) if processor
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
end
|
||||
@@ -57,6 +57,13 @@ class HookJob < MutexApplicationJob
|
||||
Integrations::GoogleTranslate::DetectLanguageService.new(hook: hook, message: message).perform
|
||||
end
|
||||
|
||||
def process_linear_integration(hook, event_name, event_data)
|
||||
return unless event_name == 'message.created'
|
||||
|
||||
message = event_data[:message]
|
||||
Integrations::Linear::AutoLinkService.new(account: hook.account, message: message).perform
|
||||
end
|
||||
|
||||
def process_leadsquared_integration_with_lock(hook, event_name, event_data)
|
||||
# Why do we need a mutex here? glad you asked
|
||||
# When a new conversation is created. We get a contact created event, immediately followed by
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
class Notification::DeleteNotificationJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(user, type: :all)
|
||||
def perform(user, account, type: :all)
|
||||
notifications = user.notifications.where(account_id: account.id)
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
if type == :all
|
||||
# Delete all notifications
|
||||
user.notifications.destroy_all
|
||||
notifications.destroy_all
|
||||
elsif type == :read
|
||||
# Delete only read notifications
|
||||
user.notifications.where.not(read_at: nil).destroy_all
|
||||
notifications.where.not(read_at: nil).destroy_all
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -128,3 +128,5 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
|
||||
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
||||
end
|
||||
end
|
||||
|
||||
Webhooks::WhatsappEventsJob.prepend_mod_with('Webhooks::WhatsappEventsJob')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -38,6 +38,9 @@ class Account < ApplicationRecord
|
||||
}.freeze
|
||||
|
||||
validates :name, presence: true
|
||||
# `domain` is the inbound email domain used to construct reply addresses
|
||||
# (see `inbound_email_domain`). Do not repurpose it for a website or any
|
||||
# non-mail-related domain.
|
||||
validates :domain, length: { maximum: 100 }
|
||||
validates_with JsonSchemaValidator,
|
||||
schema: SETTINGS_PARAMS_SCHEMA,
|
||||
|
||||
@@ -104,11 +104,19 @@ class Attachment < ApplicationRecord
|
||||
audio_file_data = base_data.merge(file_metadata)
|
||||
audio_file_data.merge(
|
||||
{
|
||||
# Keep audio playback inline while avoiding the ActiveStorage proxy path.
|
||||
data_url: inline_audio_url,
|
||||
transcribed_text: meta&.[]('transcribed_text') || ''
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def inline_audio_url
|
||||
return '' unless file.attached?
|
||||
|
||||
Rails.application.routes.url_helpers.rails_storage_redirect_url(file, disposition: 'inline')
|
||||
end
|
||||
|
||||
def file_metadata
|
||||
metadata = {
|
||||
extension: extension,
|
||||
|
||||
@@ -40,6 +40,16 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
'Whatsapp'
|
||||
end
|
||||
|
||||
# Mirrors Channel::TwilioSms#voice_enabled? so the call subsystem can duck-type across providers.
|
||||
# Meta's Calling API is only available via the embedded-signup whatsapp_cloud flow —
|
||||
# 360dialog (default provider) and manual whatsapp_cloud setups can't reach the call APIs.
|
||||
def voice_enabled?
|
||||
provider == 'whatsapp_cloud' &&
|
||||
provider_config['source'] == 'embedded_signup' &&
|
||||
provider_config['calling_enabled'].present? &&
|
||||
account.feature_enabled?('channel_voice')
|
||||
end
|
||||
|
||||
def provider_service
|
||||
if provider == 'whatsapp_cloud'
|
||||
Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self)
|
||||
|
||||
@@ -19,7 +19,17 @@ module OutOfOffisable
|
||||
end
|
||||
|
||||
def weekly_schedule
|
||||
working_hours.order(day_of_week: :asc).select(*OFFISABLE_ATTRS).as_json(except: :id)
|
||||
working_hours.sort_by(&:day_of_week).map do |wh|
|
||||
{
|
||||
'day_of_week' => wh.day_of_week,
|
||||
'closed_all_day' => wh.closed_all_day,
|
||||
'open_hour' => wh.open_hour,
|
||||
'open_minutes' => wh.open_minutes,
|
||||
'close_hour' => wh.close_hour,
|
||||
'close_minutes' => wh.close_minutes,
|
||||
'open_all_day' => wh.open_all_day
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# accepts an array of hashes similiar to the format of weekly_schedule
|
||||
|
||||
@@ -46,7 +46,8 @@ class Base::SendOnChannelService
|
||||
def invalid_message?
|
||||
# private notes aren't send to the channels
|
||||
# we should also avoid the case of message loops, when outgoing messages are created from channel
|
||||
message.private? || outgoing_message_originated_from_channel?
|
||||
# voice_call bubbles are call status indicators, not deliverable messages
|
||||
message.private? || outgoing_message_originated_from_channel? || message.content_type == 'voice_call'
|
||||
end
|
||||
|
||||
def validate_target_channel
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.custom_attributes['website'].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')
|
||||
@@ -83,11 +83,20 @@ class Reports::RawDataSource < Reports::DataSource
|
||||
created_at: range
|
||||
)
|
||||
|
||||
return events.where.not(conversation_id: bot_handoff_conversation_ids_subquery) if raw_count_strategy == :exclude_bot_handoffs
|
||||
return events unless raw_count_strategy == :distinct_conversation
|
||||
|
||||
events.joins(:conversation).select(:conversation_id).distinct
|
||||
end
|
||||
|
||||
def bot_handoff_conversation_ids_subquery
|
||||
scope.reporting_events.where(
|
||||
name: :conversation_bot_handoff,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
).where.not(conversation_id: nil).select(:conversation_id)
|
||||
end
|
||||
|
||||
def summary_scope
|
||||
scope = account.reporting_events.where(created_at: range)
|
||||
return scope.joins(:conversation) if dimension_type == 'team'
|
||||
|
||||
@@ -79,8 +79,8 @@ module Reports::ReportMetricRegistry
|
||||
bot_resolutions_count: Metric.new(
|
||||
name: :bot_resolutions_count,
|
||||
aggregate: :count,
|
||||
raw_event_name: :conversation_bot_resolved,
|
||||
rollup_metric: :bot_resolutions_count
|
||||
raw_event_name: :conversation_bot_resolved, rollup_metric: :bot_resolutions_count,
|
||||
raw_count_strategy: :exclude_bot_handoffs
|
||||
),
|
||||
bot_handoffs_count: Metric.new(
|
||||
name: :bot_handoffs_count,
|
||||
|
||||
@@ -86,7 +86,7 @@ class Whatsapp::FacebookApiClient
|
||||
body: {
|
||||
override_callback_uri: callback_url,
|
||||
verify_token: verify_token,
|
||||
subscribed_fields: %w[messages smb_message_echoes]
|
||||
subscribed_fields: %w[messages smb_message_echoes calls]
|
||||
}.to_json
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
json.meta do
|
||||
json.total_count @attachments_count
|
||||
end
|
||||
|
||||
json.payload @attachments do |attachment|
|
||||
json.partial! 'api/v1/models/attachment', formats: [:json], attachment: attachment
|
||||
json.conversation_id attachment.message.conversation.display_id
|
||||
end
|
||||
@@ -3,15 +3,5 @@ json.meta do
|
||||
end
|
||||
|
||||
json.payload @attachments do |attachment|
|
||||
json.id attachment.push_event_data[:id]
|
||||
json.message_id attachment.push_event_data[:message_id]
|
||||
json.thumb_url attachment.push_event_data[:thumb_url]
|
||||
json.data_url attachment.push_event_data[:data_url]
|
||||
json.file_size attachment.push_event_data[:file_size]
|
||||
json.file_type attachment.push_event_data[:file_type]
|
||||
json.extension attachment.push_event_data[:extension]
|
||||
json.width attachment.push_event_data[:width]
|
||||
json.height attachment.push_event_data[:height]
|
||||
json.created_at attachment.message.created_at.to_i
|
||||
json.sender attachment.message.sender.push_event_data if attachment.message.sender
|
||||
json.partial! 'api/v1/models/attachment', formats: [:json], attachment: attachment
|
||||
end
|
||||
|
||||
@@ -6,6 +6,7 @@ if resource.custom_attributes.present?
|
||||
json.subscribed_quantity resource.custom_attributes['subscribed_quantity']
|
||||
json.subscription_status resource.custom_attributes['subscription_status']
|
||||
json.subscription_ends_on resource.custom_attributes['subscription_ends_on']
|
||||
json.website resource.custom_attributes['website'] if resource.custom_attributes['website'].present?
|
||||
json.industry resource.custom_attributes['industry'] if resource.custom_attributes['industry'].present?
|
||||
json.company_size resource.custom_attributes['company_size'] if resource.custom_attributes['company_size'].present?
|
||||
json.timezone resource.custom_attributes['timezone'] if resource.custom_attributes['timezone'].present?
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
attachment_data = attachment.push_event_data
|
||||
|
||||
json.id attachment_data[:id]
|
||||
json.message_id attachment_data[:message_id]
|
||||
json.thumb_url attachment_data[:thumb_url]
|
||||
json.data_url attachment_data[:data_url]
|
||||
json.file_size attachment_data[:file_size]
|
||||
json.file_type attachment_data[:file_type]
|
||||
json.extension attachment_data[:extension]
|
||||
json.width attachment_data[:width]
|
||||
json.height attachment_data[:height]
|
||||
json.created_at attachment.message.created_at.to_i
|
||||
json.sender attachment.message.sender.push_event_data if attachment.message.sender
|
||||
@@ -143,3 +143,6 @@ if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
|
||||
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
|
||||
end
|
||||
end
|
||||
|
||||
## Voice attribute for WhatsApp Cloud (only embedded-signup channels surface true)
|
||||
json.voice_enabled resource.channel.voice_enabled? if resource.channel_type == 'Channel::Whatsapp' && resource.channel.respond_to?(:voice_enabled?)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Allow audio attachments (call recordings, voice notes) to serve inline so the
|
||||
# in-app <audio> player can stream them. Without this, ActiveStorage's blob model
|
||||
# forces Content-Disposition: attachment for any MIME outside the default allowlist
|
||||
# (images + PDF), which makes the browser download instead of play.
|
||||
Rails.application.config.active_storage.content_types_allowed_inline += %w[
|
||||
audio/webm
|
||||
audio/ogg
|
||||
audio/mpeg
|
||||
audio/mp4
|
||||
audio/x-m4a
|
||||
audio/wav
|
||||
audio/x-wav
|
||||
]
|
||||
|
||||
module ActiveStorageDirectUploadMetadataFilter
|
||||
INTERNAL_METADATA_KEYS = %w[identified analyzed composed].freeze
|
||||
|
||||
private
|
||||
|
||||
def blob_args
|
||||
super.tap do |args|
|
||||
args[:metadata]&.except!(*INTERNAL_METADATA_KEYS, *INTERNAL_METADATA_KEYS.map(&:to_sym))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module ActiveStorageProxyRangeLimit
|
||||
STREAMING_MAX_RANGES = 1
|
||||
STREAMING_CHUNK_MAX_SIZE = 100.megabytes
|
||||
|
||||
private
|
||||
|
||||
def send_blob_byte_range_data(blob, range_header, disposition: nil)
|
||||
ranges = Rack::Utils.get_byte_ranges(range_header, blob.byte_size)
|
||||
return head(:range_not_satisfiable) unless valid_ranges?(ranges)
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def valid_ranges?(ranges)
|
||||
ranges.present? &&
|
||||
ranges.any?(&:present?) &&
|
||||
ranges.length <= STREAMING_MAX_RANGES &&
|
||||
ranges.sum { |range| range.end - range.begin } < STREAMING_CHUNK_MAX_SIZE
|
||||
end
|
||||
end
|
||||
|
||||
Rails.application.config.to_prepare do
|
||||
unless ActiveStorage::DirectUploadsController < ActiveStorageDirectUploadMetadataFilter
|
||||
ActiveStorage::DirectUploadsController.prepend(ActiveStorageDirectUploadMetadataFilter)
|
||||
end
|
||||
|
||||
ActiveStorage::Streaming.prepend(ActiveStorageProxyRangeLimit) unless ActiveStorage::Streaming < ActiveStorageProxyRangeLimit
|
||||
end
|
||||
+39788
-2540
File diff suppressed because it is too large
Load Diff
@@ -116,6 +116,13 @@ en:
|
||||
reauthorization:
|
||||
generic: 'Failed to reauthorize WhatsApp. Please try again.'
|
||||
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
|
||||
calls:
|
||||
not_enabled: 'Calling is not enabled for this inbox'
|
||||
no_recording: 'No recording file provided'
|
||||
no_message: 'Call has no associated message'
|
||||
sdp_offer_required: 'sdp_offer is required'
|
||||
contact_phone_required: 'Contact phone number is required'
|
||||
permission_request_failed: 'Failed to send call permission request'
|
||||
inboxes:
|
||||
imap:
|
||||
socket_error: Please check the network connection, IMAP address and try again.
|
||||
@@ -246,6 +253,9 @@ en:
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
call_permission_request_body: 'We would like to call you regarding your conversation.'
|
||||
voice_call:
|
||||
twilio: 'Voice Call'
|
||||
whatsapp: 'WhatsApp Call'
|
||||
delivery_status:
|
||||
error_code: 'Error code: %{error_code}'
|
||||
activity:
|
||||
@@ -290,6 +300,9 @@ en:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
whatsapp_call:
|
||||
permission_requested: 'Sent a call permission request to %{contact_name}.'
|
||||
permission_granted: '%{contact_name} accepted the call permission request.'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
auto_resolve:
|
||||
@@ -354,6 +367,7 @@ en:
|
||||
name: 'Linear'
|
||||
short_description: 'Create and link Linear issues directly from conversations.'
|
||||
description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
|
||||
attachment_link_title: 'Conversation (#%{conversation_id}) with %{name}'
|
||||
notion:
|
||||
name: 'Notion'
|
||||
short_description: 'Integrate databases, documents and pages directly with Captain.'
|
||||
|
||||
@@ -187,6 +187,8 @@ Rails.application.routes.draw do
|
||||
get :search
|
||||
end
|
||||
end
|
||||
resources :conversations, only: [:index]
|
||||
resources :notes, only: [:index]
|
||||
end
|
||||
end
|
||||
resources :contacts, only: [:index, :show, :update, :create, :destroy] do
|
||||
@@ -207,6 +209,7 @@ Rails.application.routes.draw do
|
||||
resources :contact_inboxes, only: [:create]
|
||||
resources :labels, only: [:create, :index]
|
||||
resources :notes
|
||||
get :attachments, to: 'attachments#index'
|
||||
post :call, on: :member, to: 'calls#create' if ChatwootApp.enterprise?
|
||||
end
|
||||
end
|
||||
@@ -226,6 +229,21 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
resources :reporting_events, only: [:index] if ChatwootApp.enterprise?
|
||||
|
||||
if ChatwootApp.enterprise?
|
||||
resources :whatsapp_calls, only: [:show] do
|
||||
member do
|
||||
post :accept
|
||||
post :reject
|
||||
post :terminate
|
||||
post :upload_recording
|
||||
end
|
||||
collection do
|
||||
post :initiate
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy]
|
||||
resources :custom_filters, only: [:index, :show, :create, :update, :destroy]
|
||||
resources :inboxes, only: [:index, :show, :create, :update, :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
|
||||
+2
-1
@@ -381,11 +381,12 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_07_000000) 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|
|
||||
|
||||
@@ -2,13 +2,12 @@ module Enterprise::Messages::MessageBuilder
|
||||
private
|
||||
|
||||
def message_type
|
||||
return @message_type if @message_type == 'incoming' && twilio_voice_inbox? && @params[:content_type] == 'voice_call'
|
||||
return @message_type if @message_type == 'incoming' && voice_call_inbox? && @params[:content_type] == 'voice_call'
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def twilio_voice_inbox?
|
||||
inbox = @conversation.inbox
|
||||
inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled?
|
||||
def voice_call_inbox?
|
||||
@conversation.inbox.channel.try(:voice_enabled?)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -10,7 +10,8 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
|
||||
account: Current.account,
|
||||
inbox: voice_inbox,
|
||||
user: Current.user,
|
||||
contact: contact
|
||||
contact: contact,
|
||||
conversation: existing_conversation
|
||||
)
|
||||
|
||||
render json: {
|
||||
@@ -38,4 +39,20 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
|
||||
inbox
|
||||
end
|
||||
end
|
||||
|
||||
# Reuse the open conversation when the caller is already inside it; ignore
|
||||
# the hint if it doesn't belong to the picked voice inbox or dialed contact.
|
||||
# Only reuse open conversations — Message#reopen_conversation skips outgoing
|
||||
# messages, so dropping a voice_call bubble into a resolved/snoozed/pending
|
||||
# thread leaves it stuck in that state.
|
||||
def existing_conversation
|
||||
return nil if params[:conversation_id].blank?
|
||||
|
||||
conversation = Current.account.conversations.find_by(display_id: params[:conversation_id])
|
||||
return nil unless conversation
|
||||
return nil unless conversation.inbox_id == voice_inbox.id && conversation.contact_id == contact.id
|
||||
return nil unless conversation.open?
|
||||
|
||||
conversation
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseController
|
||||
PERMISSION_REQUEST_THROTTLE = 5.minutes
|
||||
|
||||
before_action :set_call, only: %i[show accept reject terminate upload_recording]
|
||||
before_action :set_conversation, only: :initiate
|
||||
before_action :ensure_calling_enabled, only: :initiate
|
||||
before_action :ensure_sdp_offer, only: :initiate
|
||||
before_action :ensure_contact_phone, only: :initiate
|
||||
before_action :ensure_recording_present, only: :upload_recording
|
||||
before_action :ensure_call_message, only: :upload_recording
|
||||
|
||||
rescue_from Voice::CallErrors::NotRinging,
|
||||
Voice::CallErrors::AlreadyAccepted,
|
||||
Voice::CallErrors::CallFailed,
|
||||
with: :render_call_error
|
||||
rescue_from Voice::CallErrors::NoCallPermission, with: :render_permission_request
|
||||
|
||||
def show; end
|
||||
|
||||
def accept
|
||||
call_service.accept
|
||||
end
|
||||
|
||||
def reject
|
||||
call_service.reject
|
||||
end
|
||||
|
||||
def terminate
|
||||
call_service.terminate
|
||||
end
|
||||
|
||||
def upload_recording
|
||||
@upload_status = @call.message.with_lock { attach_recording_idempotently }
|
||||
end
|
||||
|
||||
def initiate
|
||||
@call = create_outbound_call
|
||||
@message = Voice::CallMessageBuilder.new(@call).perform!
|
||||
@call.update!(message_id: @message.id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def call_service
|
||||
@call_service ||= Whatsapp::CallService.new(call: @call, agent: Current.user, sdp_answer: params[:sdp_answer])
|
||||
end
|
||||
|
||||
def provider_service
|
||||
@provider_service ||= @conversation.inbox.channel.provider_service
|
||||
end
|
||||
|
||||
def set_call
|
||||
@call = Current.account.calls.whatsapp.find(params[:id])
|
||||
authorize @call.conversation, :show?
|
||||
end
|
||||
|
||||
def set_conversation
|
||||
@conversation = Current.account.conversations.find_by!(display_id: params[:conversation_id])
|
||||
authorize @conversation, :show?
|
||||
end
|
||||
|
||||
def ensure_calling_enabled
|
||||
channel = @conversation.inbox.channel
|
||||
return if channel.is_a?(Channel::Whatsapp) && channel.voice_enabled?
|
||||
|
||||
render_could_not_create_error(I18n.t('errors.whatsapp.calls.not_enabled'))
|
||||
end
|
||||
|
||||
def ensure_sdp_offer
|
||||
return if params[:sdp_offer].present?
|
||||
|
||||
render_could_not_create_error(I18n.t('errors.whatsapp.calls.sdp_offer_required'))
|
||||
end
|
||||
|
||||
def ensure_contact_phone
|
||||
return if @conversation.contact&.phone_number.present?
|
||||
|
||||
render_could_not_create_error(I18n.t('errors.whatsapp.calls.contact_phone_required'))
|
||||
end
|
||||
|
||||
def ensure_recording_present
|
||||
return if params[:recording].present?
|
||||
|
||||
render_could_not_create_error(I18n.t('errors.whatsapp.calls.no_recording'))
|
||||
end
|
||||
|
||||
def ensure_call_message
|
||||
return if @call.message.present?
|
||||
|
||||
render_could_not_create_error(I18n.t('errors.whatsapp.calls.no_message'))
|
||||
end
|
||||
|
||||
def attach_recording_idempotently
|
||||
return 'already_uploaded' if @call.message.attachments.exists?(file_type: :audio)
|
||||
|
||||
@call.message.attachments.create!(account_id: @call.account_id, file_type: :audio, file: params[:recording])
|
||||
'uploaded'
|
||||
end
|
||||
|
||||
def create_outbound_call
|
||||
contact_phone = @conversation.contact.phone_number.delete('+')
|
||||
result = provider_service.initiate_call(contact_phone, params[:sdp_offer])
|
||||
provider_call_id = result.dig('calls', 0, 'id') || result['call_id']
|
||||
|
||||
Current.account.calls.create!(
|
||||
provider: :whatsapp, inbox: @conversation.inbox, conversation: @conversation, contact: @conversation.contact,
|
||||
provider_call_id: provider_call_id, direction: :outgoing, status: 'ringing',
|
||||
accepted_by_agent_id: Current.user.id,
|
||||
meta: { 'sdp_offer' => params[:sdp_offer], 'ice_servers' => Call.default_ice_servers }
|
||||
)
|
||||
end
|
||||
|
||||
# Meta error 138006 means the contact hasn't opted in yet; send the opt-in
|
||||
# template (throttled, behind a conversation lock to prevent double-send).
|
||||
def render_permission_request
|
||||
status = nil
|
||||
@conversation.with_lock do
|
||||
if permission_request_throttled?
|
||||
status = 'permission_pending'
|
||||
next
|
||||
end
|
||||
|
||||
sent = send_permission_request_safely
|
||||
if sent
|
||||
record_permission_request_wamid(sent)
|
||||
emit_permission_requested_activity
|
||||
status = 'permission_requested'
|
||||
else
|
||||
status = 'failed'
|
||||
end
|
||||
end
|
||||
|
||||
return render_could_not_create_error(I18n.t('errors.whatsapp.calls.permission_request_failed')) if status == 'failed'
|
||||
|
||||
# 422 (not 200) so any client treating 2xx as "call placed" can't mistake
|
||||
# the permission-template path for a successful dial. The FE composable
|
||||
# detects this status and surfaces the banner instead of throwing.
|
||||
render json: { status: status }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def permission_request_throttled?
|
||||
last_requested = @conversation.additional_attributes&.dig('call_permission_requested_at')
|
||||
last_requested.present? && Time.zone.parse(last_requested) > PERMISSION_REQUEST_THROTTLE.ago
|
||||
end
|
||||
|
||||
# Treat transport errors as a falsy return so we render 422 rather than 500.
|
||||
def send_permission_request_safely
|
||||
provider_service.send_call_permission_request(
|
||||
@conversation.contact.phone_number.delete('+'),
|
||||
*permission_request_body_args
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[WHATSAPP CALL] permission_request failed: #{e.class} #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
# Pass the inbox-level override only when present so the provider falls back
|
||||
# to the i18n default for inboxes that haven't customized the prompt.
|
||||
def permission_request_body_args
|
||||
custom_body = @conversation.inbox.channel.provider_config&.dig('call_permission_request_body').presence
|
||||
custom_body ? [custom_body] : []
|
||||
end
|
||||
|
||||
def emit_permission_requested_activity
|
||||
content = I18n.t(
|
||||
'conversations.activity.whatsapp_call.permission_requested',
|
||||
contact_name: @conversation.contact.name
|
||||
)
|
||||
::Conversations::ActivityMessageJob.perform_later(
|
||||
@conversation,
|
||||
{ account_id: @conversation.account_id, inbox_id: @conversation.inbox_id, message_type: :activity, content: content }
|
||||
)
|
||||
end
|
||||
|
||||
# Stash the outbound wamid so the reply webhook can match context.id back here.
|
||||
def record_permission_request_wamid(sent)
|
||||
attrs = (@conversation.additional_attributes || {}).merge(
|
||||
'call_permission_requested_at' => Time.current.iso8601,
|
||||
'call_permission_request_message_id' => sent.dig('messages', 0, 'id')
|
||||
)
|
||||
@conversation.update!(additional_attributes: attrs)
|
||||
end
|
||||
|
||||
def render_call_error(error)
|
||||
render_could_not_create_error(error.message)
|
||||
end
|
||||
end
|
||||
@@ -94,7 +94,6 @@ class Twilio::VoiceController < ApplicationController
|
||||
case twilio_direction
|
||||
when 'inbound'
|
||||
Voice::InboundCallBuilder.perform!(
|
||||
account: current_account,
|
||||
inbox: inbox,
|
||||
from_number: twilio_from,
|
||||
call_sid: twilio_call_sid
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
module Enterprise::Webhooks::WhatsappEventsJob
|
||||
def handle_message_events(channel, params)
|
||||
return handle_call_events(channel, params) if call_event?(params)
|
||||
return handle_call_permission_reply(channel, params) if call_permission_reply?(params)
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Lock per-call_id inside handle_call_events instead of the parent's per-sender mutex.
|
||||
def contact_sender_id(params)
|
||||
return nil if call_event?(params)
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def call_event?(params)
|
||||
params.dig(:entry, 0, :changes, 0, :field) == 'calls'
|
||||
end
|
||||
|
||||
def call_permission_reply?(params)
|
||||
params.dig(:entry, 0, :changes, 0, :value, :messages, 0, :interactive, :type) == 'call_permission_reply'
|
||||
end
|
||||
|
||||
# Per-call_id mutex so connect/status/terminate for the same call serialize
|
||||
# across batches. Meta delivers two payload shapes under field=calls:
|
||||
# - value.calls[] → event-based (connect, terminate)
|
||||
# - value.statuses[] → status-based (RINGING, ACCEPTED) — the real pickup
|
||||
# signal for outbound; without this, only `connect` (tunnel-up) is seen
|
||||
# and timer/recorder kick off before the contact actually answers.
|
||||
def handle_call_events(channel, params)
|
||||
value = params.dig(:entry, 0, :changes, 0, :value) || {}
|
||||
|
||||
Array(value[:calls]).each do |call_payload|
|
||||
with_call_lock(channel, call_payload[:id]) do
|
||||
Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { calls: [call_payload] }).perform
|
||||
end
|
||||
end
|
||||
|
||||
Array(value[:statuses]).each do |status_payload|
|
||||
next unless status_payload[:type] == 'call'
|
||||
|
||||
with_call_lock(channel, status_payload[:id]) do
|
||||
Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { statuses: [status_payload] }).perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def with_call_lock(channel, call_id, &)
|
||||
lock_key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX,
|
||||
inbox_id: channel.inbox.id, sender_id: "call:#{call_id}")
|
||||
with_lock(lock_key, 30.seconds, &)
|
||||
end
|
||||
|
||||
def handle_call_permission_reply(channel, params)
|
||||
Whatsapp::CallPermissionReplyService.new(inbox: channel.inbox, params: params).perform
|
||||
end
|
||||
end
|
||||
@@ -29,13 +29,16 @@
|
||||
# index_calls_on_provider_and_provider_call_id (provider,provider_call_id) UNIQUE
|
||||
#
|
||||
class Call < ApplicationRecord
|
||||
# All valid call statuses
|
||||
STATUSES = %w[ringing in_progress completed no_answer failed].freeze
|
||||
# Statuses where the call is finished and won't change again
|
||||
TERMINAL_STATUSES = %w[completed no_answer failed].freeze
|
||||
|
||||
store_accessor :meta, :conference_sid, :twilio_conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
|
||||
|
||||
# Frontend voice bubbles/stores expect inbound/outbound string values
|
||||
DISPLAY_DIRECTION = { 'incoming' => 'inbound', 'outgoing' => 'outbound' }.freeze
|
||||
|
||||
DEFAULT_STUN_URL = 'stun:stun.l.google.com:19302'.freeze
|
||||
|
||||
enum :provider, { twilio: 0, whatsapp: 1 }
|
||||
enum :direction, { incoming: 0, outgoing: 1 }
|
||||
|
||||
@@ -65,6 +68,28 @@ class Call < ApplicationRecord
|
||||
"conf_account_#{account_id}_call_#{id}"
|
||||
end
|
||||
|
||||
# Browser ↔ Meta WebRTC needs at least one STUN server to discover its public srflx candidate.
|
||||
def self.default_ice_servers
|
||||
urls = ENV.fetch('VOICE_CALL_STUN_URLS', DEFAULT_STUN_URL).split(',').filter_map { |u| u.strip.presence }
|
||||
[{ urls: urls }]
|
||||
end
|
||||
|
||||
def direction_label
|
||||
DISPLAY_DIRECTION[direction]
|
||||
end
|
||||
|
||||
def ringing?
|
||||
status == 'ringing'
|
||||
end
|
||||
|
||||
def in_progress?
|
||||
status == 'in_progress'
|
||||
end
|
||||
|
||||
def terminal?
|
||||
TERMINAL_STATUSES.include?(status)
|
||||
end
|
||||
|
||||
def display_status
|
||||
status.to_s.tr('_', '-')
|
||||
end
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# content :text
|
||||
# content_fingerprint :string
|
||||
# external_link :string not null
|
||||
# last_sync_attempted_at :datetime
|
||||
# last_sync_error_code :string
|
||||
# last_synced_at :datetime
|
||||
# metadata :jsonb
|
||||
# name :string
|
||||
@@ -18,6 +20,7 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_captain_documents_on_account_assistant_sync_stats (account_id,assistant_id,sync_status,last_synced_at)
|
||||
# index_captain_documents_on_account_id (account_id)
|
||||
# index_captain_documents_on_account_id_and_sync_status (account_id,sync_status)
|
||||
# index_captain_documents_on_assistant_id (assistant_id)
|
||||
@@ -62,6 +65,14 @@ class Captain::Document < ApplicationRecord
|
||||
scope :for_account, ->(account_id) { where(account_id: account_id) }
|
||||
scope :for_assistant, ->(assistant_id) { where(assistant_id: assistant_id) }
|
||||
scope :syncable, -> { where("external_link NOT LIKE 'PDF:%' AND external_link NOT LIKE '%.pdf'") }
|
||||
scope :pdf_documents, -> { where("external_link LIKE 'PDF:%' OR external_link LIKE '%.pdf'") }
|
||||
scope :sync_in_progress, -> { sync_syncing.where(arel_table[:last_sync_attempted_at].gteq(SYNC_STALE_TIMEOUT.ago)) }
|
||||
scope :stale, lambda { |stale_before|
|
||||
sync_failed.or(sync_synced.where(arel_table[:last_synced_at].lt(stale_before)))
|
||||
}
|
||||
scope :synced_since, lambda { |time|
|
||||
sync_synced.where(arel_table[:last_synced_at].gteq(time))
|
||||
}
|
||||
|
||||
def pdf_document?
|
||||
return true if pdf_file.attached? && pdf_file.blob.content_type == 'application/pdf'
|
||||
|
||||
@@ -3,6 +3,11 @@ module Enterprise::Concerns::Attachment
|
||||
|
||||
included do
|
||||
after_create_commit :enqueue_audio_transcription
|
||||
# Broadcast the message update so the FE bubble picks up the new audio
|
||||
# attachment immediately. Without this, the FE has to wait until Whisper
|
||||
# finishes (or fall back to a page refresh) — and if Whisper returns blank,
|
||||
# the bubble never gets the audio at all.
|
||||
after_create_commit :broadcast_message_update_for_audio
|
||||
end
|
||||
|
||||
private
|
||||
@@ -10,6 +15,20 @@ module Enterprise::Concerns::Attachment
|
||||
def enqueue_audio_transcription
|
||||
return unless file_type.to_sym == :audio
|
||||
|
||||
# No file.attached? guard: the social-media ingest path saves the
|
||||
# Attachment before attaching the blob. AudioTranscriptionJob retries
|
||||
# on ActiveStorage::FileNotFoundError to ride out that race.
|
||||
Messages::AudioTranscriptionJob.perform_later(id)
|
||||
end
|
||||
|
||||
def broadcast_message_update_for_audio
|
||||
return unless file_type.to_sym == :audio
|
||||
return unless message
|
||||
# Without an attached file, the message serializer's audio_metadata path
|
||||
# dereferences `file.metadata[:width]` on nil and raises. The pre-attach
|
||||
# broadcast wouldn't carry useful audio info anyway — skip until upload completes.
|
||||
return unless file.attached?
|
||||
|
||||
message.reload.send_update_event
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,6 +13,12 @@ module Enterprise::Conversation
|
||||
super + %w[sla_policy_id]
|
||||
end
|
||||
|
||||
# Surface call lifecycle changes to the FE: writes to additional_attributes
|
||||
# call_status/call_direction should rebroadcast conversation_updated.
|
||||
def allowed_keys?
|
||||
super || call_attributes_changed?
|
||||
end
|
||||
|
||||
def with_captain_activity_context(reason:, reason_type:)
|
||||
previous_reason = captain_activity_reason
|
||||
previous_reason_type = captain_activity_reason_type
|
||||
@@ -30,4 +36,13 @@ module Enterprise::Conversation
|
||||
def dispatch_captain_inference_event(event_name)
|
||||
dispatcher_dispatch(event_name)
|
||||
end
|
||||
|
||||
def call_attributes_changed?
|
||||
return false if previous_changes['additional_attributes'].blank?
|
||||
|
||||
# Compare before/after values for call keys — checking key presence alone
|
||||
# rebroadcasts on any unrelated additional_attributes write once the keys exist.
|
||||
before, after = previous_changes['additional_attributes']
|
||||
%w[call_status call_direction].any? { |key| (before || {})[key] != (after || {})[key] }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -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?
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user