Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56d9cc7e0b | ||
|
|
092f9ef500 | ||
|
|
1d2f3e86dd | ||
|
|
3253e863ed | ||
|
|
059d840272 | ||
|
|
b8deb89613 | ||
|
|
ef27e571f7 | ||
|
|
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
|
||||
|
||||
@@ -6,8 +6,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
|
||||
{
|
||||
redirect_uri: "#{base_url}/microsoft/callback",
|
||||
scope: scope,
|
||||
state: state,
|
||||
prompt: 'consent'
|
||||
state: state
|
||||
}
|
||||
)
|
||||
if redirect_url
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,4 +14,11 @@ class Microsoft::CallbacksController < OauthCallbackController
|
||||
def imap_address
|
||||
'outlook.office365.com'
|
||||
end
|
||||
|
||||
# Exchange Online's SMTP AUTH (XOAUTH2) rejects proxy addresses in the SASL `user=` field;
|
||||
# it must match the token's UPN. `preferred_username` is the documented v2.0 claim;
|
||||
# `upn` is the v1.0 fallback.
|
||||
def imap_login_identity
|
||||
users_data['preferred_username'] || users_data['upn'] || super
|
||||
end
|
||||
end
|
||||
|
||||
@@ -44,7 +44,7 @@ class OauthCallbackController < ApplicationController
|
||||
|
||||
def update_channel(channel_email)
|
||||
channel_email.update!({
|
||||
imap_login: users_data['email'], imap_address: imap_address,
|
||||
imap_login: imap_login_identity, imap_address: imap_address,
|
||||
imap_port: '993', imap_enabled: true,
|
||||
provider: provider_name,
|
||||
provider_config: {
|
||||
@@ -55,6 +55,13 @@ class OauthCallbackController < ApplicationController
|
||||
})
|
||||
end
|
||||
|
||||
# Identity used as the IMAP/SMTP login (SASL XOAUTH2 `user=` field). Defaults to the
|
||||
# id_token's email claim; providers override when their server requires a different
|
||||
# claim (e.g. Microsoft SMTP requires UPN).
|
||||
def imap_login_identity
|
||||
users_data['email']
|
||||
end
|
||||
|
||||
def provider_name
|
||||
raise NotImplementedError
|
||||
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
|
||||
|
||||
@@ -27,7 +27,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
if errors.any?
|
||||
redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ')
|
||||
else
|
||||
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
|
||||
redirect_to super_admin_settings_path, flash: success_flash
|
||||
end
|
||||
end
|
||||
|
||||
@@ -58,6 +58,21 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
|
||||
)
|
||||
end
|
||||
|
||||
def success_notice
|
||||
message = "#{@config.titleize} settings updated successfully"
|
||||
return message unless restart_required_config_saved?
|
||||
|
||||
"#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere."
|
||||
end
|
||||
|
||||
def success_flash
|
||||
restart_required_config_saved? ? { success: success_notice } : { notice: success_notice }
|
||||
end
|
||||
|
||||
def restart_required_config_saved?
|
||||
params.fetch('app_config', {}).keys.intersect?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS)
|
||||
end
|
||||
end
|
||||
|
||||
SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController')
|
||||
|
||||
@@ -25,6 +25,29 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl
|
||||
resource_class.editable
|
||||
end
|
||||
|
||||
def create
|
||||
resource = new_resource(resource_params)
|
||||
authorize_resource(resource)
|
||||
|
||||
if resource.save
|
||||
redirect_to after_resource_created_path(resource), flash: success_flash(resource)
|
||||
else
|
||||
render :new, locals: {
|
||||
page: Administrate::Page::Form.new(dashboard, resource)
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if requested_resource.update(resource_params)
|
||||
redirect_to after_resource_updated_path(requested_resource), flash: success_flash(requested_resource)
|
||||
else
|
||||
render :edit, locals: {
|
||||
page: Administrate::Page::Form.new(dashboard, requested_resource)
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# Override `resource_params` if you want to transform the submitted
|
||||
# data before it's persisted. For example, the following would turn all
|
||||
# empty values into nil values. It uses other APIs such as `resource_class`
|
||||
@@ -42,6 +65,20 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl
|
||||
.transform_values { |value| value == '' ? nil : value }.merge(locked: false)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def success_flash(resource)
|
||||
message = translate_with_resource('update.success')
|
||||
message = translate_with_resource('create.success') if action_name == 'create'
|
||||
return { notice: message } unless restart_required_config?(resource)
|
||||
|
||||
{ success: "#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere." }
|
||||
end
|
||||
|
||||
def restart_required_config?(resource)
|
||||
resource.name.in?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS)
|
||||
end
|
||||
|
||||
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
|
||||
# for more information
|
||||
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) => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import ReplyToMessage from './ReplyToMessage.vue';
|
||||
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
|
||||
@@ -144,8 +143,6 @@ export default {
|
||||
currentUser: 'getCurrentUser',
|
||||
lastEmail: 'getLastEmailInSelectedChat',
|
||||
globalConfig: 'globalConfig/get',
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
currentContact() {
|
||||
const senderId = this.currentChat?.meta?.sender?.id;
|
||||
@@ -384,14 +381,8 @@ export default {
|
||||
const { slug = '' } = portal;
|
||||
return slug;
|
||||
},
|
||||
isQuotedEmailReplyEnabled() {
|
||||
return this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.QUOTED_EMAIL_REPLY
|
||||
);
|
||||
},
|
||||
quotedReplyPreference() {
|
||||
if (!this.isAnEmailChannel || !this.isQuotedEmailReplyEnabled) {
|
||||
if (!this.isAnEmailChannel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -416,11 +407,7 @@ export default {
|
||||
return truncatePreviewText(this.quotedEmailText, 80);
|
||||
},
|
||||
shouldShowQuotedReplyToggle() {
|
||||
return (
|
||||
this.isAnEmailChannel &&
|
||||
!this.isOnPrivateNote &&
|
||||
this.isQuotedEmailReplyEnabled
|
||||
);
|
||||
return this.isAnEmailChannel && !this.isOnPrivateNote;
|
||||
},
|
||||
shouldShowQuotedPreview() {
|
||||
return (
|
||||
@@ -577,7 +564,6 @@ export default {
|
||||
},
|
||||
shouldIncludeQuotedEmail() {
|
||||
return (
|
||||
this.isQuotedEmailReplyEnabled &&
|
||||
this.quotedReplyPreference &&
|
||||
this.shouldShowQuotedReplyToggle &&
|
||||
!!this.quotedEmailText
|
||||
|
||||
+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,8 +41,8 @@ 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',
|
||||
ADVANCED_SEARCH: 'advanced_search',
|
||||
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"PLACEHOLDER": "Select Inbox"
|
||||
},
|
||||
"SUBMIT": "Create",
|
||||
"VALIDATING_OPENAI": "Validating with OpenAI...",
|
||||
"CANCEL": "Cancel"
|
||||
},
|
||||
"API": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -63,6 +63,13 @@ export default {
|
||||
isIntegrationDialogflow() {
|
||||
return this.integration.id === 'dialogflow';
|
||||
},
|
||||
submitButtonLabel() {
|
||||
if (this.integration.id === 'openai' && this.uiFlags.isCreatingHook) {
|
||||
return this.$t('INTEGRATION_APPS.ADD.FORM.VALIDATING_OPENAI');
|
||||
}
|
||||
|
||||
return this.$t('INTEGRATION_APPS.ADD.FORM.SUBMIT');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClose() {
|
||||
@@ -154,7 +161,7 @@ export default {
|
||||
/>
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('INTEGRATION_APPS.ADD.FORM.SUBMIT')"
|
||||
:label="submitButtonLabel"
|
||||
:is-loading="uiFlags.isCreatingHook"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -121,7 +121,7 @@ export const actions = {
|
||||
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: false });
|
||||
} catch (error) {
|
||||
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: false });
|
||||
throw new Error(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
deleteHook: async ({ commit }, { appId, hookId }) => {
|
||||
|
||||
@@ -105,7 +105,9 @@ describe('#actions', () => {
|
||||
});
|
||||
it('sends correct actions if API is error', async () => {
|
||||
axios.post.mockRejectedValue(errorMessage);
|
||||
await expect(actions.createHook({ commit })).rejects.toThrow(Error);
|
||||
await expect(actions.createHook({ commit })).rejects.toEqual(
|
||||
errorMessage
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: true }],
|
||||
[types.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: false }],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
class Migration::ValidateOpenaiHooksJob < ApplicationJob
|
||||
queue_as :async_database_migration
|
||||
|
||||
def perform(account: nil)
|
||||
scope = Integrations::Hook.where(app_id: 'openai', status: 'enabled')
|
||||
scope = scope.where(account_id: account.id) if account
|
||||
|
||||
stats = { checked: 0, destroyed: 0 }
|
||||
|
||||
scope.find_each do |hook|
|
||||
stats[:checked] += 1
|
||||
next if Integrations::Openai::KeyValidator.valid?(hook.settings&.dig('api_key'))
|
||||
|
||||
AdministratorNotifications::IntegrationsNotificationMailer.with(account: hook.account).openai_disconnect.deliver_later
|
||||
hook.destroy!
|
||||
stats[:destroyed] += 1
|
||||
Rails.logger.warn("[openai-backfill] destroyed hook_id=#{hook.id} account_id=#{hook.account_id}")
|
||||
end
|
||||
|
||||
Rails.logger.info("[openai-backfill] completed #{stats.inspect}")
|
||||
stats
|
||||
end
|
||||
end
|
||||
@@ -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)
|
||||
|
||||
@@ -9,4 +9,10 @@ class AdministratorNotifications::IntegrationsNotificationMailer < Administrator
|
||||
subject = 'Your Dialogflow integration was disconnected'
|
||||
send_notification(subject)
|
||||
end
|
||||
|
||||
def openai_disconnect
|
||||
subject = 'Your OpenAI integration was disconnected'
|
||||
action_url = settings_url('integrations/openai')
|
||||
send_notification(subject, action_url: action_url)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,19 @@
|
||||
# index_installation_configs_on_name_and_created_at (name,created_at) UNIQUE
|
||||
#
|
||||
class InstallationConfig < ApplicationRecord
|
||||
CAPTAIN_LLM_CONFIG_KEYS = %w[
|
||||
CAPTAIN_OPEN_AI_API_KEY
|
||||
CAPTAIN_OPEN_AI_ENDPOINT
|
||||
CAPTAIN_OPEN_AI_MODEL
|
||||
].freeze
|
||||
|
||||
RESTART_REQUIRED_CONFIG_KEYS = (CAPTAIN_LLM_CONFIG_KEYS + %w[
|
||||
LANGFUSE_BASE_URL
|
||||
LANGFUSE_PUBLIC_KEY
|
||||
LANGFUSE_SECRET_KEY
|
||||
OTEL_PROVIDER
|
||||
]).freeze
|
||||
|
||||
# https://stackoverflow.com/questions/72970170/upgrading-to-rails-6-1-6-1-causes-psychdisallowedclass-tried-to-load-unspecif
|
||||
# https://discuss.rubyonrails.org/t/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record/81017
|
||||
# FIX ME : fixes breakage of installation config. we need to migrate.
|
||||
|
||||
@@ -29,6 +29,7 @@ class Integrations::Hook < ApplicationRecord
|
||||
validates :inbox_id, presence: true, if: -> { hook_type == 'inbox' }
|
||||
validate :validate_settings_json_schema
|
||||
validate :ensure_feature_enabled
|
||||
validate :validate_openai_api_key, if: :validate_openai_api_key?
|
||||
validates :app_id, uniqueness: { scope: [:account_id], unless: -> { app.present? && app.params[:allow_multiple_hooks].present? } }
|
||||
|
||||
# TODO: This seems to be only used for slack at the moment
|
||||
@@ -56,6 +57,10 @@ class Integrations::Hook < ApplicationRecord
|
||||
app_id == 'dialogflow'
|
||||
end
|
||||
|
||||
def openai?
|
||||
app_id == 'openai'
|
||||
end
|
||||
|
||||
def notion?
|
||||
app_id == 'notion'
|
||||
end
|
||||
@@ -95,6 +100,26 @@ class Integrations::Hook < ApplicationRecord
|
||||
errors.add(:settings, ': Invalid settings data') unless JSONSchemer.schema(app.params[:settings_json_schema]).valid?(settings)
|
||||
end
|
||||
|
||||
# TODO: When adding credential validation for other integrations (dialogflow, dyte, etc.),
|
||||
# extract this into an app-level config flag in apps.yml instead of hardcoding app_id checks.
|
||||
def validate_openai_api_key?
|
||||
openai? && enabled? && (new_record? || openai_api_key_changed? || will_save_change_to_status?)
|
||||
end
|
||||
|
||||
def openai_api_key_changed?
|
||||
settings_api_key(settings) != settings_api_key(settings_in_database)
|
||||
end
|
||||
|
||||
def validate_openai_api_key
|
||||
return if Integrations::Openai::KeyValidator.valid?(settings_api_key(settings))
|
||||
|
||||
errors.add(:base, I18n.t('errors.openai.invalid_api_key'))
|
||||
end
|
||||
|
||||
def settings_api_key(value)
|
||||
value&.dig('api_key') || value&.dig(:api_key)
|
||||
end
|
||||
|
||||
def trigger_setup_if_crm
|
||||
# we need setup services to create data prerequisite to functioning of the integration
|
||||
# in case of Leadsquared, we need to create a custom activity type for capturing conversations and transcripts
|
||||
|
||||
@@ -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?)
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>Your OpenAI integration was disconnected because the configured API key is invalid or revoked. To continue using OpenAI-powered features, reconnect the integration with a valid API key.</p>
|
||||
|
||||
<p>
|
||||
Click <a href="{{action_url}}">here</a> to reconnect.
|
||||
</p>
|
||||
+1
-1
@@ -219,11 +219,11 @@
|
||||
- name: quoted_email_reply
|
||||
display_name: Quoted Email Reply
|
||||
enabled: false
|
||||
deprecated: true
|
||||
- name: companies
|
||||
display_name: Companies
|
||||
enabled: false
|
||||
premium: true
|
||||
chatwoot_internal: true
|
||||
- name: channel_tiktok
|
||||
display_name: TikTok Channel
|
||||
enabled: true
|
||||
|
||||
@@ -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,15 @@ 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'
|
||||
openai:
|
||||
invalid_api_key: 'OpenAI API key is invalid or revoked. Please check your key in your OpenAI dashboard.'
|
||||
inboxes:
|
||||
imap:
|
||||
socket_error: Please check the network connection, IMAP address and try again.
|
||||
@@ -246,6 +255,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 +302,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 +369,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
|
||||
@@ -0,0 +1,7 @@
|
||||
class EnqueueValidateOpenaiHooksJob < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
Migration::ValidateOpenaiHooksJob.perform_later
|
||||
end
|
||||
|
||||
def down; end
|
||||
end
|
||||
+3
-2
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_05_07_000000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -381,11 +381,12 @@ ActiveRecord::Schema[7.1].define(version: 2026_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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user