Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46bb601c9c |
+4
-15
@@ -2,19 +2,8 @@
|
||||
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+)
|
||||
# Rails 7.1 has no patched release for the Active Storage proxy range
|
||||
# advisories. Chatwoot limits proxy range requests locally.
|
||||
# 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.
|
||||
- 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: 20
|
||||
parallelism: 18
|
||||
steps:
|
||||
- checkout
|
||||
- node/install:
|
||||
|
||||
@@ -8,7 +8,7 @@ ARG NODE_VERSION
|
||||
ARG RUBY_VERSION
|
||||
ARG USER_UID
|
||||
ARG USER_GID
|
||||
ARG PNPM_VERSION="10.2.0"
|
||||
ARG PNPM_VERSION="10.33.4"
|
||||
ENV PNPM_VERSION ${PNPM_VERSION}
|
||||
ENV RUBY_CONFIGURE_OPTS=--disable-install-doc
|
||||
|
||||
|
||||
+3
-3
@@ -212,7 +212,7 @@ GEM
|
||||
logger
|
||||
msgpack
|
||||
datadog-ruby_core_source (3.4.1)
|
||||
date (3.5.1)
|
||||
date (3.4.1)
|
||||
debug (1.8.0)
|
||||
irb (>= 1.5.0)
|
||||
reline (>= 0.3.1)
|
||||
@@ -574,7 +574,7 @@ GEM
|
||||
uri (>= 0.11.1)
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.4.24)
|
||||
net-imap (0.4.20)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -961,7 +961,7 @@ GEM
|
||||
time_diff (0.3.0)
|
||||
activesupport
|
||||
i18n
|
||||
timeout (0.6.1)
|
||||
timeout (0.4.3)
|
||||
trailblazer-option (0.1.2)
|
||||
twilio-ruby (7.6.0)
|
||||
faraday (>= 0.9, < 3.0)
|
||||
|
||||
@@ -31,24 +31,13 @@ class V2::Reports::BotMetricsBuilder
|
||||
end
|
||||
|
||||
def bot_resolutions_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
|
||||
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
|
||||
created_at: range).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
|
||||
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)
|
||||
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_resolution_rate
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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({ file_attachment: :blob }, message: [:inbox, { sender: { avatar_attachment: :blob } }])
|
||||
.includes(:message)
|
||||
.order(created_at: :desc)
|
||||
.page(attachment_params[:page])
|
||||
.per(ATTACHMENT_RESULTS_PER_PAGE)
|
||||
|
||||
@@ -9,9 +9,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
|
||||
def index
|
||||
@inboxes = policy_scope(Current.account.inboxes)
|
||||
.includes(:channel, :portal, :working_hours, { avatar_attachment: :blob })
|
||||
.order_by_name
|
||||
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
||||
end
|
||||
|
||||
def show; end
|
||||
@@ -87,7 +85,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def fetch_agent_bot
|
||||
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:agent_bot]) if params[:agent_bot]
|
||||
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
||||
end
|
||||
|
||||
def create_channel
|
||||
|
||||
@@ -6,7 +6,8 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
|
||||
{
|
||||
redirect_uri: "#{base_url}/microsoft/callback",
|
||||
scope: scope,
|
||||
state: state
|
||||
state: state,
|
||||
prompt: 'consent'
|
||||
}
|
||||
)
|
||||
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, Current.account, type: :read)
|
||||
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read)
|
||||
else
|
||||
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :all)
|
||||
::Notification::DeleteNotificationJob.perform_later(Current.user, 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.where(account_id: Current.account.id).find(params[:id])
|
||||
@notification = current_user.notifications.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 if params[:blob_id].present?
|
||||
process_attached_logo
|
||||
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 = Current.account.inboxes.find(permitted_params[:inbox_id])
|
||||
inbox = Inbox.find(permitted_params[:inbox_id])
|
||||
return {} unless inbox.web_widget?
|
||||
|
||||
{ channel_web_widget_id: inbox.channel.id }
|
||||
@@ -98,8 +98,6 @@ 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, :website)
|
||||
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role)
|
||||
end
|
||||
|
||||
def settings_params
|
||||
|
||||
@@ -8,8 +8,7 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
|
||||
end
|
||||
|
||||
def destroy
|
||||
notification_subscription = current_user.notification_subscriptions
|
||||
.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
|
||||
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
|
||||
notification_subscription.destroy! if notification_subscription.present?
|
||||
head :ok
|
||||
end
|
||||
|
||||
@@ -83,10 +83,6 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def set_message
|
||||
# `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])
|
||||
@message = @web_widget.inbox.messages.find(permitted_params[:id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,11 +14,4 @@ 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: imap_login_identity, imap_address: imap_address,
|
||||
imap_login: users_data['email'], imap_address: imap_address,
|
||||
imap_port: '993', imap_enabled: true,
|
||||
provider: provider_name,
|
||||
provider_config: {
|
||||
@@ -55,13 +55,6 @@ 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,10 +24,6 @@ class Public::Api::V1::InboxesController < PublicController
|
||||
def set_conversation
|
||||
return if params[:conversation_id].blank?
|
||||
|
||||
@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
|
||||
@conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
|
||||
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, flash: success_flash
|
||||
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -58,21 +58,6 @@ 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,29 +25,6 @@ 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`
|
||||
@@ -65,20 +42,6 @@ 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,12 +1,7 @@
|
||||
class SwaggerController < ApplicationController
|
||||
def respond
|
||||
if Rails.env.development? || Rails.env.test?
|
||||
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
|
||||
render inline: Rails.root.join('swagger', derived_path).read
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
@@ -16,8 +11,8 @@ class SwaggerController < ApplicationController
|
||||
|
||||
def derived_path
|
||||
params[:path] ||= 'index.html'
|
||||
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 = Rack::Utils.clean_path_info(params[:path])
|
||||
path << ".#{Rack::Utils.clean_path_info(params[:format])}" unless path.ends_with?(params[:format].to_s)
|
||||
path
|
||||
end
|
||||
end
|
||||
|
||||
@@ -53,12 +53,13 @@ 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)
|
||||
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
|
||||
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved,
|
||||
created_at: range)
|
||||
end
|
||||
|
||||
def bot_handoffs
|
||||
@@ -66,10 +67,6 @@ 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]
|
||||
|
||||
@@ -32,7 +32,7 @@ const emit = defineEmits([
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="sticky top-0 z-20 px-6">
|
||||
<header class="sticky top-0 z-10 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 z-50"
|
||||
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"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
|
||||
@@ -803,11 +803,6 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
computed(() => props.disabled),
|
||||
() => editorView?.setProps({})
|
||||
);
|
||||
|
||||
watch(
|
||||
computed(() => props.updateSelectionWith),
|
||||
(newValue, oldValue) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
@@ -143,6 +144,8 @@ export default {
|
||||
currentUser: 'getCurrentUser',
|
||||
lastEmail: 'getLastEmailInSelectedChat',
|
||||
globalConfig: 'globalConfig/get',
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
currentContact() {
|
||||
const senderId = this.currentChat?.meta?.sender?.id;
|
||||
@@ -381,8 +384,14 @@ export default {
|
||||
const { slug = '' } = portal;
|
||||
return slug;
|
||||
},
|
||||
isQuotedEmailReplyEnabled() {
|
||||
return this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.QUOTED_EMAIL_REPLY
|
||||
);
|
||||
},
|
||||
quotedReplyPreference() {
|
||||
if (!this.isAnEmailChannel) {
|
||||
if (!this.isAnEmailChannel || !this.isQuotedEmailReplyEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -407,7 +416,11 @@ export default {
|
||||
return truncatePreviewText(this.quotedEmailText, 80);
|
||||
},
|
||||
shouldShowQuotedReplyToggle() {
|
||||
return this.isAnEmailChannel && !this.isOnPrivateNote;
|
||||
return (
|
||||
this.isAnEmailChannel &&
|
||||
!this.isOnPrivateNote &&
|
||||
this.isQuotedEmailReplyEnabled
|
||||
);
|
||||
},
|
||||
shouldShowQuotedPreview() {
|
||||
return (
|
||||
@@ -564,6 +577,7 @@ export default {
|
||||
},
|
||||
shouldIncludeQuotedEmail() {
|
||||
return (
|
||||
this.isQuotedEmailReplyEnabled &&
|
||||
this.quotedReplyPreference &&
|
||||
this.shouldShowQuotedReplyToggle &&
|
||||
!!this.quotedEmailText
|
||||
|
||||
@@ -43,6 +43,7 @@ export const FEATURE_FLAGS = {
|
||||
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',
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"PLACEHOLDER": "Select Inbox"
|
||||
},
|
||||
"SUBMIT": "Create",
|
||||
"VALIDATING_OPENAI": "Validating with OpenAI...",
|
||||
"CANCEL": "Cancel"
|
||||
},
|
||||
"API": {
|
||||
|
||||
@@ -134,7 +134,7 @@ const populateFormFields = () => {
|
||||
|
||||
if (!locale.value) locale.value = detectBestLocale();
|
||||
if (!website.value) {
|
||||
website.value = attrs.website || brandInfo?.domain || '';
|
||||
website.value = account?.domain || brandInfo?.domain || '';
|
||||
}
|
||||
if (!timezone.value) {
|
||||
timezone.value =
|
||||
@@ -216,7 +216,7 @@ const handleSubmit = async () => {
|
||||
await updateAccount({
|
||||
name: accountName.value,
|
||||
locale: locale.value,
|
||||
website: website.value,
|
||||
domain: website.value,
|
||||
industry: industry.value,
|
||||
company_size: companySize.value,
|
||||
timezone: timezone.value,
|
||||
|
||||
@@ -63,13 +63,6 @@ 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() {
|
||||
@@ -161,7 +154,7 @@ export default {
|
||||
/>
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="submitButtonLabel"
|
||||
:label="$t('INTEGRATION_APPS.ADD.FORM.SUBMIT')"
|
||||
:is-loading="uiFlags.isCreatingHook"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 error;
|
||||
throw new Error(error);
|
||||
}
|
||||
},
|
||||
deleteHook: async ({ commit }, { appId, hookId }) => {
|
||||
|
||||
@@ -105,9 +105,7 @@ describe('#actions', () => {
|
||||
});
|
||||
it('sends correct actions if API is error', async () => {
|
||||
axios.post.mockRejectedValue(errorMessage);
|
||||
await expect(actions.createHook({ commit })).rejects.toEqual(
|
||||
errorMessage
|
||||
);
|
||||
await expect(actions.createHook({ commit })).rejects.toThrow(Error);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: true }],
|
||||
[types.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: false }],
|
||||
|
||||
@@ -14,24 +14,24 @@
|
||||
},
|
||||
"THUMBNAIL": {
|
||||
"AUTHOR": {
|
||||
"NOT_AVAILABLE": "No disponible"
|
||||
"NOT_AVAILABLE": "Not available"
|
||||
}
|
||||
},
|
||||
"TEAM_AVAILABILITY": {
|
||||
"ONLINE": "Estamos en línea",
|
||||
"OFFLINE": "Estamos ausentes en este momento",
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "Volveremos tan pronto como sea posible"
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
|
||||
},
|
||||
"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": "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"
|
||||
"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"
|
||||
},
|
||||
"DAY_NAMES": {
|
||||
"SUNDAY": "Domingo",
|
||||
|
||||
@@ -14,24 +14,24 @@
|
||||
},
|
||||
"THUMBNAIL": {
|
||||
"AUTHOR": {
|
||||
"NOT_AVAILABLE": "Niet beschikbaar"
|
||||
"NOT_AVAILABLE": "Not available"
|
||||
}
|
||||
},
|
||||
"TEAM_AVAILABILITY": {
|
||||
"ONLINE": "We zijn online",
|
||||
"OFFLINE": "We zijn momenteel afwezig",
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "We zijn zo snel mogelijk weer terug"
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
|
||||
},
|
||||
"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 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"
|
||||
"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"
|
||||
},
|
||||
"DAY_NAMES": {
|
||||
"SUNDAY": "zondag",
|
||||
|
||||
@@ -8,7 +8,6 @@ class BulkActionsJob < ApplicationJob
|
||||
|
||||
def perform(account:, params:, user:)
|
||||
@account = account
|
||||
@user = user
|
||||
Current.user = user
|
||||
@params = params
|
||||
@records = records_to_updated(params[:ids])
|
||||
@@ -62,7 +61,6 @@ class BulkActionsJob < ApplicationJob
|
||||
current_model = @params[:type].camelcase
|
||||
return unless MODEL_TYPE.include?(current_model)
|
||||
|
||||
scope = current_model.constantize.where(account_id: @account.id, display_id: ids)
|
||||
Conversations::PermissionFilterService.new(scope, @user, @account).perform
|
||||
current_model.constantize&.where(account_id: @account.id, display_id: ids)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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, account, type: :all)
|
||||
notifications = user.notifications.where(account_id: account.id)
|
||||
|
||||
def perform(user, type: :all)
|
||||
ActiveRecord::Base.transaction do
|
||||
if type == :all
|
||||
notifications.destroy_all
|
||||
# Delete all notifications
|
||||
user.notifications.destroy_all
|
||||
elsif type == :read
|
||||
notifications.where.not(read_at: nil).destroy_all
|
||||
# Delete only read notifications
|
||||
user.notifications.where.not(read_at: nil).destroy_all
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,10 +9,4 @@ 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,9 +38,6 @@ 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,7 +104,9 @@ 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.
|
||||
# ActiveStorage's redirect endpoint defaults to Content-Disposition: attachment,
|
||||
# which makes <audio> elements download instead of play. Force inline so the
|
||||
# call-recording chip (and any other audio bubble) can stream directly.
|
||||
data_url: inline_audio_url,
|
||||
transcribed_text: meta&.[]('transcribed_text') || ''
|
||||
}
|
||||
@@ -114,7 +116,9 @@ class Attachment < ApplicationRecord
|
||||
def inline_audio_url
|
||||
return '' unless file.attached?
|
||||
|
||||
Rails.application.routes.url_helpers.rails_storage_redirect_url(file, disposition: 'inline')
|
||||
# Proxy endpoint streams through Rails and honours `disposition: 'inline'`,
|
||||
# unlike the redirect endpoint which always sends Content-Disposition: attachment.
|
||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(file, disposition: 'inline')
|
||||
end
|
||||
|
||||
def file_metadata
|
||||
|
||||
@@ -19,17 +19,7 @@ module OutOfOffisable
|
||||
end
|
||||
|
||||
def weekly_schedule
|
||||
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
|
||||
working_hours.order(day_of_week: :asc).select(*OFFISABLE_ATTRS).as_json(except: :id)
|
||||
end
|
||||
|
||||
# accepts an array of hashes similiar to the format of weekly_schedule
|
||||
|
||||
@@ -15,19 +15,6 @@
|
||||
# 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,7 +29,6 @@ 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
|
||||
@@ -57,10 +56,6 @@ class Integrations::Hook < ApplicationRecord
|
||||
app_id == 'dialogflow'
|
||||
end
|
||||
|
||||
def openai?
|
||||
app_id == 'openai'
|
||||
end
|
||||
|
||||
def notion?
|
||||
app_id == 'notion'
|
||||
end
|
||||
@@ -100,26 +95,6 @@ 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
|
||||
|
||||
@@ -55,7 +55,7 @@ class Onboarding::WebWidgetCreationService
|
||||
end
|
||||
|
||||
def website_url
|
||||
@account.custom_attributes['website'].presence || brand_info[:domain].presence
|
||||
@account.domain.presence || brand_info[:domain].presence
|
||||
end
|
||||
|
||||
def widget_color
|
||||
|
||||
@@ -83,20 +83,11 @@ 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_count_strategy: :exclude_bot_handoffs
|
||||
raw_event_name: :conversation_bot_resolved,
|
||||
rollup_metric: :bot_resolutions_count
|
||||
),
|
||||
bot_handoffs_count: Metric.new(
|
||||
name: :bot_handoffs_count,
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
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,5 +3,15 @@ json.meta do
|
||||
end
|
||||
|
||||
json.payload @attachments do |attachment|
|
||||
json.partial! 'api/v1/models/attachment', formats: [:json], attachment: 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
|
||||
end
|
||||
|
||||
@@ -6,7 +6,6 @@ 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?
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
<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
|
||||
|
||||
@@ -11,44 +11,3 @@ Rails.application.config.active_storage.content_types_allowed_inline += %w[
|
||||
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
|
||||
|
||||
@@ -123,8 +123,6 @@ en:
|
||||
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.
|
||||
|
||||
@@ -209,7 +209,6 @@ 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
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
class EnqueueValidateOpenaiHooksJob < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
Migration::ValidateOpenaiHooksJob.perform_later
|
||||
end
|
||||
|
||||
def down; end
|
||||
end
|
||||
+1
-1
@@ -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_15_000000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_05_07_000000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
|
||||
@@ -229,7 +229,7 @@ function install_dependencies() {
|
||||
nodejs patch ruby-dev zlib1g-dev liblzma-dev \
|
||||
libgmp-dev libncurses5-dev libffi-dev libgdbm6 libgdbm-dev sudo \
|
||||
libvips python3-pip
|
||||
npm install -g pnpm
|
||||
npm install -g pnpm@10.33.4
|
||||
}
|
||||
|
||||
##############################################################################
|
||||
@@ -940,7 +940,7 @@ function get_pnpm() {
|
||||
return
|
||||
fi
|
||||
echo "pnpm is not installed. Installing pnpm..."
|
||||
npm install -g pnpm
|
||||
npm install -g pnpm@10.33.4
|
||||
echo "Cleaning up existing node_modules directory..."
|
||||
sudo -i -u chatwoot << "EOF"
|
||||
cd chatwoot
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ FROM node:24-alpine as node
|
||||
FROM ruby:3.4.4-alpine3.21 AS pre-builder
|
||||
|
||||
ARG NODE_VERSION="24.13.0"
|
||||
ARG PNPM_VERSION="10.2.0"
|
||||
ARG PNPM_VERSION="10.33.4"
|
||||
ENV NODE_VERSION=${NODE_VERSION}
|
||||
ENV PNPM_VERSION=${PNPM_VERSION}
|
||||
|
||||
@@ -99,7 +99,7 @@ RUN rm -rf /gems/ruby/3.4.0/cache/*.gem \
|
||||
FROM ruby:3.4.4-alpine3.21
|
||||
|
||||
ARG NODE_VERSION="24.13.0"
|
||||
ARG PNPM_VERSION="10.2.0"
|
||||
ARG PNPM_VERSION="10.33.4"
|
||||
ENV NODE_VERSION=${NODE_VERSION}
|
||||
ENV PNPM_VERSION=${PNPM_VERSION}
|
||||
|
||||
|
||||
+24
-10
@@ -1,8 +1,26 @@
|
||||
module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
||||
def redirect_callbacks
|
||||
return omniauth_success if params[:provider] == 'saml'
|
||||
def saml
|
||||
# Call parent's omniauth_success which handles the auth
|
||||
omniauth_success
|
||||
end
|
||||
|
||||
super
|
||||
def redirect_callbacks
|
||||
# derive target redirect route from 'resource_class' param, which was set
|
||||
# before authentication.
|
||||
devise_mapping = get_devise_mapping
|
||||
redirect_route = get_redirect_route(devise_mapping)
|
||||
|
||||
# preserve omniauth info for success route. ignore 'extra' in twitter
|
||||
# auth response to avoid CookieOverflow.
|
||||
session['dta.omniauth.auth'] = request.env['omniauth.auth'].except('extra')
|
||||
session['dta.omniauth.params'] = request.env['omniauth.params']
|
||||
|
||||
# For SAML, use 303 See Other to convert POST to GET and preserve session
|
||||
if params[:provider] == 'saml'
|
||||
redirect_to redirect_route, { status: 303 }.merge(redirect_options)
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def omniauth_success
|
||||
@@ -15,7 +33,7 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
||||
end
|
||||
|
||||
def omniauth_failure
|
||||
return super unless params[:strategy] == 'saml'
|
||||
return super unless params[:provider] == 'saml'
|
||||
|
||||
relay_state = saml_relay_state
|
||||
error = params[:message] || 'authentication-failed'
|
||||
@@ -45,15 +63,11 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
||||
end
|
||||
|
||||
def extract_saml_account_id
|
||||
params[:account_id] || request.env['omniauth.params']&.dig('account_id')
|
||||
params[:account_id] || session[:saml_account_id] || request.env['omniauth.params']&.dig('account_id')
|
||||
end
|
||||
|
||||
def saml_relay_state
|
||||
params[:RelayState] || request.env['omniauth.params']&.dig('RelayState')
|
||||
end
|
||||
|
||||
def auth_hash
|
||||
request.env['omniauth.auth'] || super
|
||||
session[:saml_relay_state] || request.env['omniauth.params']&.dig('RelayState')
|
||||
end
|
||||
|
||||
def for_mobile?(relay_state)
|
||||
|
||||
@@ -23,8 +23,6 @@
|
||||
class Company < ApplicationRecord
|
||||
include Avatarable
|
||||
|
||||
ACTIVITY_ROLLUP_INTERVAL = 5.minutes
|
||||
|
||||
validates :account_id, presence: true
|
||||
validates :name, presence: true, length: { maximum: Limits::COMPANY_NAME_LENGTH_LIMIT }
|
||||
validates :domain, allow_blank: true, format: {
|
||||
@@ -60,12 +58,6 @@ class Company < ApplicationRecord
|
||||
)
|
||||
}
|
||||
|
||||
def record_activity_at!(activity_at)
|
||||
return if last_activity_at.present? && last_activity_at > activity_at - ACTIVITY_ROLLUP_INTERVAL
|
||||
|
||||
update!(last_activity_at: activity_at)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prepare_jsonb_attributes
|
||||
|
||||
@@ -6,7 +6,6 @@ module Enterprise::Concerns::Contact
|
||||
after_commit :associate_company_from_email,
|
||||
on: [:create, :update],
|
||||
if: :should_associate_company?
|
||||
after_update_commit :record_company_activity, if: :saved_change_to_last_activity_at?
|
||||
end
|
||||
|
||||
private
|
||||
@@ -29,8 +28,4 @@ module Enterprise::Concerns::Contact
|
||||
Rails.logger.error("Failed to associate company for contact #{id}: #{e.message}")
|
||||
# Don't fail the contact save if the company association fails
|
||||
end
|
||||
|
||||
def record_company_activity
|
||||
company&.record_activity_at!(last_activity_at) if last_activity_at.present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,7 +7,6 @@ class Companies::ContactMembershipService
|
||||
|
||||
def assign(contact:)
|
||||
contact.update!(company: company)
|
||||
company.record_activity_at!(contact.last_activity_at) if contact.last_activity_at.present?
|
||||
end
|
||||
|
||||
def remove(contact:)
|
||||
|
||||
@@ -9,7 +9,6 @@ class Contacts::CompanyAssociationService
|
||||
contact.update_column(:company_id, company.id)
|
||||
Company.increment_counter(:contacts_count, company.id)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
company.record_activity_at!(contact.last_activity_at) if contact.last_activity_at.present?
|
||||
end
|
||||
company
|
||||
end
|
||||
|
||||
@@ -7,12 +7,14 @@ SAML_SETUP_PROC = proc do |env|
|
||||
|
||||
# Extract account_id from various sources
|
||||
account_id = request.params['account_id'] ||
|
||||
request.session[:saml_account_id] ||
|
||||
env['omniauth.params']&.dig('account_id')
|
||||
relay_state = request.params['RelayState'] || ''
|
||||
|
||||
if account_id
|
||||
# Keep SAML request context in OmniAuth env so the callback can be processed
|
||||
# without depending on the Rails session cookie.
|
||||
# Store in session and omniauth params for callback
|
||||
request.session[:saml_account_id] = account_id
|
||||
request.session[:saml_relay_state] = relay_state
|
||||
env['omniauth.params'] ||= {}
|
||||
env['omniauth.params']['account_id'] = account_id
|
||||
env['omniauth.params']['RelayState'] = relay_state
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
module Integrations::Openai::KeyValidator
|
||||
TIMEOUT_SECONDS = 5
|
||||
|
||||
def self.valid?(api_key)
|
||||
return false if api_key.blank?
|
||||
|
||||
connection = Faraday.new do |f|
|
||||
f.options.timeout = TIMEOUT_SECONDS
|
||||
f.options.open_timeout = TIMEOUT_SECONDS
|
||||
end
|
||||
|
||||
response = connection.get("#{api_base}/models") do |req|
|
||||
req.headers['Authorization'] = "Bearer #{api_key}"
|
||||
end
|
||||
|
||||
response.status != 401
|
||||
rescue Faraday::Error => e
|
||||
Rails.logger.warn("[openai-key-validator] #{e.class}: #{e.message}")
|
||||
true
|
||||
end
|
||||
|
||||
def self.api_base
|
||||
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/'
|
||||
"#{endpoint.chomp('/')}/v1"
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -178,5 +178,5 @@
|
||||
"scss-lint"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.2.0+sha512.0d27364e0139c6aadeed65ada153135e0ca96c8da42123bd50047f961339dc7a758fc2e944b428f52be570d1bd3372455c1c65fa2e7aa0bfbf931190f9552001"
|
||||
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
minimumReleaseAge: 10080
|
||||
@@ -181,6 +181,10 @@ describe Messages::MessageBuilder do
|
||||
end
|
||||
|
||||
context 'when custom email content is provided' do
|
||||
before do
|
||||
account.enable_features('quoted_email_reply')
|
||||
end
|
||||
|
||||
it 'creates message with custom HTML email content' do
|
||||
params = ActionController::Parameters.new({
|
||||
content: 'Regular message content',
|
||||
|
||||
@@ -120,8 +120,6 @@ describe V2::ReportBuilder do
|
||||
# Reopen 1 conversation
|
||||
conversations.first.open!
|
||||
end
|
||||
create(:reporting_event, account: account, inbox: account.inboxes.first, conversation: nil, conversation_id: nil,
|
||||
name: 'conversation_bot_handoff', created_at: Time.zone.today)
|
||||
|
||||
builder = described_class.new(account, params)
|
||||
metrics = builder.timeseries
|
||||
|
||||
@@ -4,99 +4,35 @@ RSpec.describe V2::Reports::BotMetricsBuilder do
|
||||
subject(:bot_metrics_builder) { described_class.new(inbox.account, params) }
|
||||
|
||||
let(:inbox) { create(:inbox) }
|
||||
let!(:resolved_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
|
||||
let!(:unresolved_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
|
||||
let(:since) { 1.week.ago.to_i.to_s }
|
||||
let(:until_time) { Time.now.to_i.to_s }
|
||||
let(:params) { { since: since, until: until_time } }
|
||||
|
||||
before do
|
||||
create(:agent_bot_inbox, inbox: inbox)
|
||||
create(:message, account: inbox.account, conversation: resolved_conversation, created_at: 2.days.ago, message_type: 'outgoing')
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_resolved', conversation_id: resolved_conversation.id,
|
||||
created_at: 2.days.ago)
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
|
||||
conversation_id: resolved_conversation.id, created_at: 2.days.ago)
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
|
||||
conversation_id: unresolved_conversation.id, created_at: 2.days.ago)
|
||||
end
|
||||
|
||||
describe '#metrics' do
|
||||
context 'with valid params' do
|
||||
let!(:resolved_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
|
||||
let!(:handoff_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
|
||||
|
||||
before do
|
||||
create(:message, account: inbox.account, conversation: resolved_conversation, created_at: 2.days.ago, message_type: 'outgoing')
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_resolved',
|
||||
conversation_id: resolved_conversation.id, created_at: 2.days.ago)
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
|
||||
conversation_id: handoff_conversation.id, created_at: 2.days.ago)
|
||||
end
|
||||
|
||||
it 'returns correct metrics' do
|
||||
metrics = bot_metrics_builder.metrics
|
||||
|
||||
expect(metrics[:conversation_count]).to eq(2)
|
||||
expect(metrics[:message_count]).to eq(1)
|
||||
expect(metrics[:resolution_rate]).to eq(50)
|
||||
expect(metrics[:handoff_rate]).to eq(50)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a conversation has both bot_resolved and bot_handoff events in the same range' do
|
||||
let!(:double_counted_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
|
||||
let!(:handoff_only_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
|
||||
|
||||
before do
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_resolved',
|
||||
conversation_id: double_counted_conversation.id, created_at: 2.days.ago)
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
|
||||
conversation_id: double_counted_conversation.id, created_at: 2.days.ago)
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
|
||||
conversation_id: handoff_only_conversation.id, created_at: 2.days.ago)
|
||||
end
|
||||
|
||||
it 'excludes the conversation from resolution count — handoff wins' do
|
||||
metrics = bot_metrics_builder.metrics
|
||||
|
||||
expect(metrics[:conversation_count]).to eq(2)
|
||||
expect(metrics[:resolution_rate]).to eq(0)
|
||||
expect(metrics[:handoff_rate]).to eq(100)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when bot_resolved and bot_handoff are in different date ranges' do
|
||||
let!(:multi_cycle_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
|
||||
|
||||
before do
|
||||
# Bot resolved in current range
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_resolved',
|
||||
conversation_id: multi_cycle_conversation.id, created_at: 2.days.ago)
|
||||
# Handoff happened before the range (in a previous cycle)
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
|
||||
conversation_id: multi_cycle_conversation.id, created_at: 2.weeks.ago)
|
||||
end
|
||||
|
||||
it 'counts the resolution since the handoff is outside the range' do
|
||||
metrics = bot_metrics_builder.metrics
|
||||
|
||||
expect(metrics[:conversation_count]).to eq(1)
|
||||
expect(metrics[:resolution_rate]).to eq(100)
|
||||
expect(metrics[:handoff_rate]).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a bot_handoff event has no conversation' do
|
||||
let!(:resolved_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
|
||||
|
||||
before do
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_resolved',
|
||||
conversation_id: resolved_conversation.id, created_at: 2.days.ago)
|
||||
create(:reporting_event, account: inbox.account, inbox: inbox, conversation: nil, conversation_id: nil,
|
||||
name: 'conversation_bot_handoff', created_at: 2.days.ago)
|
||||
end
|
||||
|
||||
it 'does not exclude all bot resolutions' do
|
||||
metrics = bot_metrics_builder.metrics
|
||||
|
||||
expect(metrics[:conversation_count]).to eq(1)
|
||||
expect(metrics[:resolution_rate]).to eq(100)
|
||||
expect(metrics[:handoff_rate]).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with missing params' do
|
||||
let(:params) { {} }
|
||||
|
||||
|
||||
@@ -310,43 +310,4 @@ describe V2::Reports::Timeseries::ReportBuilder do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'bot resolution counts' do
|
||||
subject(:builder) { described_class.new(account, params) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:current_time) { Time.current }
|
||||
let(:params) do
|
||||
{
|
||||
type: 'account',
|
||||
metric: 'bot_resolutions_count',
|
||||
since: (current_time - 1.day).beginning_of_day.to_i.to_s,
|
||||
until: current_time.end_of_day.to_i.to_s,
|
||||
timezone_offset: nil,
|
||||
group_by: 'day'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
travel_to current_time
|
||||
|
||||
resolved_conversation = create(:conversation, account: account, inbox: inbox)
|
||||
double_counted_conversation = create(:conversation, account: account, inbox: inbox)
|
||||
|
||||
create(:reporting_event, name: 'conversation_bot_resolved', account: account, conversation: resolved_conversation,
|
||||
created_at: current_time)
|
||||
create(:reporting_event, name: 'conversation_bot_resolved', account: account, conversation: double_counted_conversation,
|
||||
created_at: current_time)
|
||||
create(:reporting_event, name: 'conversation_bot_handoff', account: account, conversation: double_counted_conversation,
|
||||
created_at: current_time)
|
||||
create(:reporting_event, name: 'conversation_bot_handoff', account: account, inbox: inbox, conversation: nil, conversation_id: nil,
|
||||
created_at: current_time)
|
||||
end
|
||||
|
||||
it 'excludes conversations that also had a bot handoff in the range' do
|
||||
expect(builder.aggregate_value).to eq(1)
|
||||
expect(builder.timeseries.sum { |row| row[:value] }).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -34,10 +34,6 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
|
||||
context 'when it is an authenticated user' do
|
||||
let!(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before do
|
||||
Conversation.all.find_each { |conversation| create(:inbox_member, inbox: conversation.inbox, user: agent) }
|
||||
end
|
||||
|
||||
it 'Ignores bulk_actions for wrong type' do
|
||||
post "/api/v1/accounts/#{account.id}/bulk_actions",
|
||||
headers: agent.create_new_auth_token,
|
||||
@@ -206,10 +202,6 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
|
||||
context 'when it is an authenticated user' do
|
||||
let!(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before do
|
||||
Conversation.all.find_each { |conversation| create(:inbox_member, inbox: conversation.inbox, user: agent) }
|
||||
end
|
||||
|
||||
it 'Bulk delete conversation labels' do
|
||||
Conversation.first.add_labels(%w[support priority_customer])
|
||||
Conversation.second.add_labels(%w[support priority_customer])
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe '/api/v1/accounts/{account.id}/contacts/:id/attachments', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:inbox_1) { create(:inbox, account: account) }
|
||||
let(:inbox_2) { create(:inbox, account: account) }
|
||||
let(:contact_inbox_1) { create(:contact_inbox, contact: contact, inbox: inbox_1) }
|
||||
let(:contact_inbox_2) { create(:contact_inbox, contact: contact, inbox: inbox_2) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:unknown) { create(:user, account: account, role: nil) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: inbox_1)
|
||||
|
||||
conversation_1 = create(:conversation, account: account, inbox: inbox_1, contact: contact, contact_inbox: contact_inbox_1)
|
||||
conversation_2 = create(:conversation, account: account, inbox: inbox_2, contact: contact, contact_inbox: contact_inbox_2)
|
||||
|
||||
create(:message, :with_attachment, conversation: conversation_1, account: account, inbox: inbox_1, message_type: 'incoming')
|
||||
create(:message, :with_attachment, conversation: conversation_2, account: account, inbox: inbox_2, message_type: 'incoming')
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/contacts/:id/attachments' do
|
||||
context 'when unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/attachments"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is logged in' do
|
||||
context 'with user as administrator' do
|
||||
it 'returns attachments from all the contact conversations' do
|
||||
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/attachments",
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['payload'].length).to eq 2
|
||||
expect(json_response['meta']['total_count']).to eq 2
|
||||
end
|
||||
|
||||
it 'serialises the conversation display id as conversation_id' do
|
||||
conversation = contact.conversations.first
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/attachments",
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
payload = response.parsed_body['payload']
|
||||
attachment = payload.find { |a| a['conversation_id'] == conversation.display_id }
|
||||
expect(attachment).not_to be_nil
|
||||
expect(attachment).to include('id', 'message_id', 'data_url', 'file_type', 'created_at', 'sender')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with user as agent' do
|
||||
it 'returns attachments only from inboxes the agent has access to' do
|
||||
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/attachments",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['payload'].length).to eq 1
|
||||
expect(json_response['meta']['total_count']).to eq 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'with user as unknown role' do
|
||||
it 'returns no attachments' do
|
||||
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/attachments",
|
||||
headers: unknown.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['payload']).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1008,19 +1008,6 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'does not allow binding an agent bot from another account' do
|
||||
other_account = create(:account)
|
||||
foreign_bot = create(:agent_bot, account: other_account)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/set_agent_bot",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { agent_bot: foreign_bot.id },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(inbox.reload.agent_bot).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ require 'rails_helper'
|
||||
RSpec.describe 'Integration Apps API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
before { allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) }
|
||||
|
||||
describe 'GET /api/v1/integrations/apps' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
|
||||
@@ -43,7 +43,6 @@ RSpec.describe 'Microsoft Authorization API', type: :request do
|
||||
]
|
||||
expect(params['scope']).to eq(expected_scope)
|
||||
expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback"])
|
||||
expect(url).not_to match(/(?:\?|&)prompt=/)
|
||||
|
||||
# Validate state parameter exists and can be decoded back to the account
|
||||
expect(params['state']).to be_present
|
||||
|
||||
@@ -101,20 +101,6 @@ RSpec.describe 'Notifications API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(notification.reload.read_at).not_to eq('')
|
||||
end
|
||||
|
||||
it 'does not update a notification reached via a different account that the user belongs to' do
|
||||
other_account = create(:account)
|
||||
create(:account_user, account: other_account, user: admin, role: :administrator)
|
||||
original_read_at = notification.read_at
|
||||
|
||||
patch "/api/v1/accounts/#{other_account.id}/notifications/#{notification.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { read_at: true },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(notification.reload.read_at).to eq(original_read_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -241,7 +227,7 @@ RSpec.describe 'Notifications API', type: :request do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'deletes all the read notifications' do
|
||||
expect(Notification::DeleteNotificationJob).to receive(:perform_later).with(admin, account, type: :read)
|
||||
expect(Notification::DeleteNotificationJob).to receive(:perform_later).with(admin, type: :read)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/notifications/destroy_all",
|
||||
headers: admin.create_new_auth_token,
|
||||
@@ -252,7 +238,7 @@ RSpec.describe 'Notifications API', type: :request do
|
||||
end
|
||||
|
||||
it 'deletes all the notifications' do
|
||||
expect(Notification::DeleteNotificationJob).to receive(:perform_later).with(admin, account, type: :all)
|
||||
expect(Notification::DeleteNotificationJob).to receive(:perform_later).with(admin, type: :all)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/notifications/destroy_all",
|
||||
headers: admin.create_new_auth_token,
|
||||
|
||||
@@ -100,41 +100,6 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
|
||||
expect(json_response['name']).to eql('test_portal')
|
||||
expect(json_response['custom_domain']).to eql('support.chatwoot.dev')
|
||||
end
|
||||
|
||||
it 'creates portal when custom_domain is omitted from request body' do
|
||||
portal_params = {
|
||||
portal: {
|
||||
name: 'test_portal_no_domain',
|
||||
slug: 'test_kbase_no_domain'
|
||||
}
|
||||
}
|
||||
post "/api/v1/accounts/#{account.id}/portals",
|
||||
params: portal_params,
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['name']).to eql('test_portal_no_domain')
|
||||
expect(json_response['custom_domain']).to be_nil
|
||||
end
|
||||
|
||||
it 'creates portal when custom_domain is blank' do
|
||||
portal_params = {
|
||||
portal: {
|
||||
name: 'test_portal_blank_domain',
|
||||
slug: 'test_kbase_blank_domain',
|
||||
custom_domain: ''
|
||||
}
|
||||
}
|
||||
post "/api/v1/accounts/#{account.id}/portals",
|
||||
params: portal_params,
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['name']).to eql('test_portal_blank_domain')
|
||||
expect(json_response['custom_domain']).to be_blank
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -227,21 +192,6 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
|
||||
expect(portal.reload.logo).to be_attached
|
||||
end
|
||||
|
||||
it 'does not allow associating an inbox from another account' do
|
||||
other_account = create(:account)
|
||||
foreign_inbox = create(:inbox, account: other_account)
|
||||
|
||||
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
|
||||
params: {
|
||||
portal: { name: portal.name },
|
||||
inbox_id: foreign_inbox.id
|
||||
},
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(portal.reload.channel_web_widget_id).to be_nil
|
||||
end
|
||||
|
||||
it 'clears associated web widget when inbox selection is blank' do
|
||||
web_widget_inbox = create(:inbox, account: account)
|
||||
portal.update!(channel_web_widget: web_widget_inbox.channel)
|
||||
|
||||
@@ -106,21 +106,6 @@ RSpec.describe 'Notifications Subscriptions API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
expect { subscription.reload }.to raise_exception(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
|
||||
it 'does not delete another user notification subscription with the same push token' do
|
||||
victim = create(:user, account: account, role: :agent)
|
||||
victim_subscription = create(:notification_subscription, subscription_type: 'fcm',
|
||||
subscription_attributes: { push_token: 'victimToken' },
|
||||
user: victim)
|
||||
|
||||
delete '/api/v1/notification_subscriptions',
|
||||
params: { push_token: 'victimToken' },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect { victim_subscription.reload }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -212,26 +212,6 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/widget/messages' do
|
||||
context 'when put request targets a message from another visitor in the same inbox' do
|
||||
it 'does not update the foreign message' do
|
||||
other_contact = create(:contact, account: account, email: nil)
|
||||
other_contact_inbox = create(:contact_inbox, contact: other_contact, inbox: web_widget.inbox)
|
||||
other_conversation = create(:conversation, contact: other_contact, account: account,
|
||||
inbox: web_widget.inbox, contact_inbox: other_contact_inbox)
|
||||
foreign_message = create(:message, content_type: 'input_email', account: account,
|
||||
inbox: web_widget.inbox, conversation: other_conversation)
|
||||
original_email = foreign_message.submitted_email
|
||||
|
||||
put api_v1_widget_message_url(foreign_message.id),
|
||||
params: { website_token: web_widget.website_token, contact: { email: Faker::Internet.email } },
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(foreign_message.reload.submitted_email).to eq(original_email)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when put request is made with non existing email' do
|
||||
it 'updates message in conversation and creates a new contact' do
|
||||
message = create(:message, content_type: 'input_email', account: account, inbox: web_widget.inbox, conversation: conversation)
|
||||
|
||||
@@ -34,25 +34,6 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
|
||||
expect(inbox.channel.imap_address).to eq 'outlook.office365.com'
|
||||
end
|
||||
|
||||
it 'sets imap_login from preferred_username when the id_token carries a UPN that differs from email' do
|
||||
upn = 'livetestaccount@capeunionmart.co.za'
|
||||
mailbox = 'LiveTestAccount@cumi.co.za'
|
||||
response_body = {
|
||||
id_token: JWT.encode({ email: mailbox, preferred_username: upn, name: 'test' }, false),
|
||||
access_token: SecureRandom.hex(10), token_type: 'Bearer', refresh_token: SecureRandom.hex(10)
|
||||
}
|
||||
stub_request(:post, 'https://login.microsoftonline.com/common/oauth2/v2.0/token')
|
||||
.with(body: { 'code' => code, 'grant_type' => 'authorization_code',
|
||||
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" })
|
||||
.to_return(status: 200, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
get microsoft_callback_url, params: { code: code, state: state }
|
||||
|
||||
channel = account.inboxes.last.channel
|
||||
expect(channel.imap_login).to eq upn
|
||||
expect(channel.email).to eq mailbox
|
||||
end
|
||||
|
||||
it 'creates updates inbox channel config if inbox exists and authentication is successful' do
|
||||
inbox = create(:channel_email, account: account, email: email)&.inbox
|
||||
expect(inbox.channel.provider_config).to eq({})
|
||||
|
||||
@@ -16,19 +16,6 @@ RSpec.describe 'Public Inbox Contact Conversation Messages API', type: :request
|
||||
data = response.parsed_body
|
||||
expect(data.length).to eq 2
|
||||
end
|
||||
|
||||
it 'does not return messages from a conversation in another inbox even when both share the same contact' do
|
||||
other_channel = create(:channel_api, account: conversation.account)
|
||||
other_contact_inbox = create(:contact_inbox, contact: contact, inbox: other_channel.inbox)
|
||||
foreign_conversation = create(:conversation, contact: contact, account: conversation.account,
|
||||
inbox: other_channel.inbox, contact_inbox: other_contact_inbox)
|
||||
create(:message, account: foreign_conversation.account, inbox: foreign_conversation.inbox, conversation: foreign_conversation)
|
||||
|
||||
get "/public/api/v1/inboxes/#{api_channel.identifier}/contacts/#{contact_inbox.source_id}/conversations/" \
|
||||
"#{foreign_conversation.display_id}/messages"
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /public/api/v1/inboxes/{identifier}/contact/{source_id}/conversations/{conversation_id}/messages' do
|
||||
|
||||
@@ -38,24 +38,10 @@ RSpec.describe 'Super Admin Application Config API', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:found)
|
||||
expect(response).to redirect_to(super_admin_settings_path)
|
||||
expect(flash[:notice]).to be_present
|
||||
expect(flash[:alert]).to be_blank
|
||||
expect(flash[:success]).to be_blank
|
||||
|
||||
config = GlobalConfig.get('FB_APP_ID')
|
||||
expect(config['FB_APP_ID']).to eq('FB_APP_ID')
|
||||
end
|
||||
|
||||
it 'asks admins to restart web and worker processes for runtime config changes' do
|
||||
sign_in(super_admin, scope: :super_admin)
|
||||
post '/super_admin/app_config?config=captain', params: { app_config: { CAPTAIN_OPEN_AI_ENDPOINT: 'https://api.openai.com' } }
|
||||
|
||||
expect(response).to have_http_status(:found)
|
||||
expect(response).to redirect_to(super_admin_settings_path)
|
||||
expect(flash[:success]).to be_present
|
||||
expect(flash[:alert]).to be_blank
|
||||
expect(flash[:notice]).to be_blank
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -39,34 +39,4 @@ RSpec.describe 'Super Admin Installation Config API', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /super_admin/installation_configs/:id' do
|
||||
context 'when it is an authenticated super admin' do
|
||||
it 'shows a regular success notice for config that does not require restart' do
|
||||
sign_in(super_admin, scope: :super_admin)
|
||||
config = create(:installation_config, name: 'TESTCONFIG', value: 'TESTVALUE', locked: false)
|
||||
|
||||
patch "/super_admin/installation_configs/#{config.id}", params: {
|
||||
installation_config: { name: config.name, value: 'UPDATEDVALUE' }
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:found)
|
||||
expect(flash[:notice]).to be_present
|
||||
expect(flash[:success]).to be_blank
|
||||
end
|
||||
|
||||
it 'shows a restart success notice for runtime config changes' do
|
||||
sign_in(super_admin, scope: :super_admin)
|
||||
config = create(:installation_config, name: 'OTEL_PROVIDER', value: 'langfuse', locked: false)
|
||||
|
||||
patch "/super_admin/installation_configs/#{config.id}", params: {
|
||||
installation_config: { name: config.name, value: 'langfuse' }
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:found)
|
||||
expect(flash[:success]).to be_present
|
||||
expect(flash[:notice]).to be_blank
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,10 +8,5 @@ describe '/swagger', type: :request do
|
||||
expect(response.body).to include('redoc')
|
||||
expect(response.body).to include('/swagger.json')
|
||||
end
|
||||
|
||||
it 'does not render files outside the swagger directory' do
|
||||
get '/swagger/%2Fetc%2Fpasswd'
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -46,8 +46,7 @@ RSpec.describe 'Company contacts API', type: :request do
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/companies/{company.id}/contacts' do
|
||||
it 'links an existing contact to the company' do
|
||||
contact = create(:contact, name: 'Jane Contact', account: account, last_activity_at: 1.hour.ago,
|
||||
additional_attributes: { 'city' => 'Berlin' })
|
||||
contact = create(:contact, name: 'Jane Contact', account: account, additional_attributes: { 'city' => 'Berlin' })
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/companies/#{company.id}/contacts",
|
||||
params: { contact_id: contact.id },
|
||||
@@ -59,7 +58,6 @@ RSpec.describe 'Company contacts API', type: :request do
|
||||
expect(contact.additional_attributes).to eq('city' => 'Berlin')
|
||||
expect(response.parsed_body['payload']['company_id']).to eq(company.id)
|
||||
expect(response.parsed_body['payload']['linked_to_current_company']).to be true
|
||||
expect(company.reload.last_activity_at).to be_within(1.second).of(contact.last_activity_at)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+12
-11
@@ -29,6 +29,11 @@ RSpec.describe 'Enterprise SAML OmniAuth Callbacks', type: :request do
|
||||
|
||||
get "/omniauth/saml/callback?account_id=#{account.id}"
|
||||
|
||||
# expect a 302 redirect to auth/saml/callback
|
||||
expect(response).to redirect_to('http://www.example.com/auth/saml/callback')
|
||||
follow_redirect!
|
||||
|
||||
# expect redirect to login with SSO token
|
||||
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
|
||||
|
||||
# verify user was created
|
||||
@@ -45,21 +50,14 @@ RSpec.describe 'Enterprise SAML OmniAuth Callbacks', type: :request do
|
||||
|
||||
get "/omniauth/saml/callback?account_id=#{account.id}"
|
||||
|
||||
# expect a 302 redirect to auth/saml/callback
|
||||
expect(response).to redirect_to('http://www.example.com/auth/saml/callback')
|
||||
follow_redirect!
|
||||
|
||||
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
|
||||
end
|
||||
end
|
||||
|
||||
it 'redirects mobile SAML login to the mobile deep link' do
|
||||
with_modified_env FRONTEND_URL: 'http://www.example.com' do
|
||||
create(:user, email: 'mobile@example.com', account: account)
|
||||
set_saml_config('mobile@example.com')
|
||||
|
||||
get "/omniauth/saml/callback?account_id=#{account.id}&RelayState=mobile"
|
||||
|
||||
expect(response).to redirect_to(%r{\Achatwootapp://auth/saml\?email=.+&sso_auth_token=.+\z})
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects an existing user from another account' do
|
||||
with_modified_env FRONTEND_URL: 'http://www.example.com' do
|
||||
other_account = create(:account)
|
||||
@@ -68,6 +66,9 @@ RSpec.describe 'Enterprise SAML OmniAuth Callbacks', type: :request do
|
||||
|
||||
get "/omniauth/saml/callback?account_id=#{account.id}"
|
||||
|
||||
expect(response).to redirect_to('http://www.example.com/auth/saml/callback')
|
||||
follow_redirect!
|
||||
|
||||
expect(response).to redirect_to('http://www.example.com/app/login?error=saml-authentication-failed')
|
||||
expect(existing_user.reload.provider).to eq('email')
|
||||
expect(existing_user.accounts).not_to include(account)
|
||||
|
||||
@@ -15,7 +15,6 @@ RSpec.describe Captain::ConversationCompletionService do
|
||||
allow(mock_chat).to receive(:with_schema).and_return(mock_chat)
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
|
||||
@@ -35,15 +35,4 @@ RSpec.describe Company, type: :model do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#record_activity_at!' do
|
||||
it 'does not move company activity backwards' do
|
||||
company = create(:company, last_activity_at: Time.zone.now)
|
||||
original_activity_at = company.last_activity_at
|
||||
|
||||
company.record_activity_at!(1.hour.ago)
|
||||
|
||||
expect(company.reload.last_activity_at).to be_within(1.second).of(original_activity_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -43,15 +43,6 @@ RSpec.describe Contact, type: :model do
|
||||
contact.reload
|
||||
expect(contact.company).to eq(existing_company)
|
||||
end
|
||||
|
||||
it 'updates company activity when contact activity changes' do
|
||||
company = create(:company, account: account)
|
||||
contact = create(:contact, account: account, company: company)
|
||||
|
||||
contact.update!(last_activity_at: Time.zone.now)
|
||||
|
||||
expect(company.reload.last_activity_at).to be_within(1.second).of(contact.last_activity_at)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when multiple contacts share the same domain' do
|
||||
|
||||
@@ -7,8 +7,7 @@ end
|
||||
|
||||
RSpec.describe Enterprise::Onboarding::WebWidgetCreationService do
|
||||
let(:account) do
|
||||
create(:account, name: 'Acme Inc', custom_attributes: {
|
||||
'website' => 'acme.com',
|
||||
create(:account, name: 'Acme Inc', domain: 'acme.com', custom_attributes: {
|
||||
'brand_info' => { 'slogan' => 'Fallback slogan', 'description' => 'Fallback description' }
|
||||
})
|
||||
end
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe BulkActionsJob do
|
||||
params = {
|
||||
type: 'Conversation',
|
||||
fields: { status: 'snoozed' },
|
||||
ids: Conversation.first(3).pluck(:display_id)
|
||||
}
|
||||
|
||||
subject(:job) { described_class.perform_later(account: account, params: params, user: agent) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
@@ -8,11 +14,9 @@ RSpec.describe BulkActionsJob do
|
||||
let!(:conversation_1) { create(:conversation, account_id: account.id, status: :open) }
|
||||
let!(:conversation_2) { create(:conversation, account_id: account.id, status: :open) }
|
||||
let!(:conversation_3) { create(:conversation, account_id: account.id, status: :open) }
|
||||
let(:conversation_ids) { [conversation_1.display_id, conversation_2.display_id, conversation_3.display_id] }
|
||||
let(:params) { { type: 'Conversation', fields: { status: 'snoozed' }, ids: conversation_ids } }
|
||||
|
||||
before do
|
||||
[conversation_1, conversation_2, conversation_3].each do |conversation|
|
||||
Conversation.all.find_each do |conversation|
|
||||
create(:inbox_member, inbox: conversation.inbox, user: agent)
|
||||
end
|
||||
end
|
||||
@@ -34,10 +38,10 @@ RSpec.describe BulkActionsJob do
|
||||
params = {
|
||||
type: 'Conversation',
|
||||
fields: { status: 'snoozed', assignee_id: agent.id },
|
||||
ids: conversation_ids
|
||||
ids: Conversation.first(3).pluck(:display_id)
|
||||
}
|
||||
|
||||
expect(conversation_1.status).to eq('open')
|
||||
expect(Conversation.first.status).to eq('open')
|
||||
|
||||
described_class.perform_now(account: account, params: params, user: agent)
|
||||
|
||||
@@ -50,46 +54,32 @@ RSpec.describe BulkActionsJob do
|
||||
params = {
|
||||
type: 'Conversation',
|
||||
fields: { status: 'snoozed', assignee_id: agent.id },
|
||||
ids: conversation_ids
|
||||
ids: Conversation.first(3).pluck(:display_id)
|
||||
}
|
||||
|
||||
expect(conversation_1.assignee_id).to be_nil
|
||||
expect(Conversation.first.assignee_id).to be_nil
|
||||
|
||||
described_class.perform_now(account: account, params: params, user: agent)
|
||||
|
||||
expect(conversation_1.reload.assignee_id).to eq(agent.id)
|
||||
expect(conversation_2.reload.assignee_id).to eq(agent.id)
|
||||
expect(conversation_3.reload.assignee_id).to eq(agent.id)
|
||||
expect(Conversation.first.assignee_id).to eq(agent.id)
|
||||
expect(Conversation.second.assignee_id).to eq(agent.id)
|
||||
expect(Conversation.third.assignee_id).to eq(agent.id)
|
||||
end
|
||||
|
||||
it 'bulk updates the snoozed_until' do
|
||||
params = {
|
||||
type: 'Conversation',
|
||||
fields: { status: 'snoozed', snoozed_until: Time.zone.now },
|
||||
ids: conversation_ids
|
||||
ids: Conversation.first(3).pluck(:display_id)
|
||||
}
|
||||
|
||||
expect(conversation_1.snoozed_until).to be_nil
|
||||
expect(Conversation.first.snoozed_until).to be_nil
|
||||
|
||||
described_class.perform_now(account: account, params: params, user: agent)
|
||||
|
||||
expect(conversation_1.reload.snoozed_until).to be_present
|
||||
expect(conversation_2.reload.snoozed_until).to be_present
|
||||
expect(conversation_3.reload.snoozed_until).to be_present
|
||||
end
|
||||
|
||||
it 'skips conversations whose inbox the agent does not belong to' do
|
||||
forbidden_conversation = create(:conversation, account_id: account.id, status: :open)
|
||||
params = {
|
||||
type: 'Conversation',
|
||||
fields: { status: 'resolved' },
|
||||
ids: [conversation_1.display_id, forbidden_conversation.display_id]
|
||||
}
|
||||
|
||||
described_class.perform_now(account: account, params: params, user: agent)
|
||||
|
||||
expect(conversation_1.reload.status).to eq('resolved')
|
||||
expect(forbidden_conversation.reload.status).to eq('open')
|
||||
expect(Conversation.first.snoozed_until).to be_present
|
||||
expect(Conversation.second.snoozed_until).to be_present
|
||||
expect(Conversation.third.snoozed_until).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Migration::ValidateOpenaiHooksJob do
|
||||
let(:integrations_mailer) { instance_double(AdministratorNotifications::IntegrationsNotificationMailer) }
|
||||
let(:mailer_response) { instance_double(ActionMailer::MessageDelivery, deliver_later: true) }
|
||||
|
||||
before do
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
|
||||
allow(AdministratorNotifications::IntegrationsNotificationMailer).to receive(:with).and_return(integrations_mailer)
|
||||
allow(integrations_mailer).to receive(:openai_disconnect).and_return(mailer_response)
|
||||
end
|
||||
|
||||
def create_openai_hook(account:, api_key: 'sk-good')
|
||||
create(:integrations_hook, :openai, account: account, settings: { 'api_key' => api_key })
|
||||
end
|
||||
|
||||
it 'destroys invalid hooks, preserves valid ones, sends disconnect email, and reports stats' do
|
||||
account_a = create(:account)
|
||||
account_b = create(:account)
|
||||
valid_hook = create_openai_hook(account: account_a, api_key: 'sk-good')
|
||||
invalid_hook = create_openai_hook(account: account_b, api_key: 'sk-bad')
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).with('sk-bad').and_return(false)
|
||||
|
||||
result = described_class.perform_now
|
||||
|
||||
expect(valid_hook.reload).to be_enabled
|
||||
expect { invalid_hook.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
expect(AdministratorNotifications::IntegrationsNotificationMailer).to have_received(:with).with(account: account_b)
|
||||
expect(result).to eq(checked: 2, destroyed: 1)
|
||||
end
|
||||
|
||||
it 'scopes to a specific account when provided' do
|
||||
account_a = create(:account)
|
||||
account_b = create(:account)
|
||||
hook_a = create_openai_hook(account: account_a, api_key: 'sk-bad')
|
||||
hook_b = create_openai_hook(account: account_b, api_key: 'sk-bad')
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).with('sk-bad').and_return(false)
|
||||
|
||||
described_class.perform_now(account: account_a)
|
||||
|
||||
expect { hook_a.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
expect(hook_b.reload).to be_enabled
|
||||
end
|
||||
|
||||
it 'only checks enabled OpenAI hooks' do
|
||||
account = create(:account)
|
||||
slack_hook = create(:integrations_hook, account: account, app_id: 'slack')
|
||||
disabled_hook = create_openai_hook(account: account)
|
||||
disabled_hook.disable
|
||||
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false)
|
||||
|
||||
described_class.perform_now
|
||||
|
||||
expect(slack_hook.reload).to be_enabled
|
||||
expect(disabled_hook.reload).to be_disabled # still disabled, not re-checked
|
||||
end
|
||||
end
|
||||
@@ -2,45 +2,37 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe Notification::DeleteNotificationJob do
|
||||
let(:user) { create(:user) }
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation) }
|
||||
|
||||
context 'when enqueuing the job' do
|
||||
it 'enqueues the job to delete all notifications' do
|
||||
expect do
|
||||
described_class.perform_later(user, account, type: :all)
|
||||
described_class.perform_later(user.id, type: :all)
|
||||
end.to have_enqueued_job(described_class).on_queue('low')
|
||||
end
|
||||
|
||||
it 'enqueues the job to delete read notifications' do
|
||||
expect do
|
||||
described_class.perform_later(user, account, type: :read)
|
||||
described_class.perform_later(user.id, type: :read)
|
||||
end.to have_enqueued_job(described_class).on_queue('low')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when performing the job' do
|
||||
let(:other_account) { create(:account) }
|
||||
|
||||
before do
|
||||
create(:notification, account: account, user: user, read_at: nil)
|
||||
create(:notification, account: account, user: user, read_at: Time.current)
|
||||
create(:notification, account: other_account, user: user, read_at: Time.current)
|
||||
create(:notification, user: user, read_at: nil)
|
||||
create(:notification, user: user, read_at: Time.current)
|
||||
end
|
||||
|
||||
it 'deletes all notifications for the requested account' do
|
||||
described_class.perform_now(user, account, type: :all)
|
||||
|
||||
expect(user.notifications.where(account_id: account.id).count).to eq(0)
|
||||
expect(user.notifications.where(account_id: other_account.id).count).to eq(1)
|
||||
it 'deletes all notifications' do
|
||||
described_class.perform_now(user, type: :all)
|
||||
expect(user.notifications.count).to eq(0)
|
||||
end
|
||||
|
||||
it 'deletes only read notifications for the requested account' do
|
||||
described_class.perform_now(user, account, type: :read)
|
||||
|
||||
expect(user.notifications.where(account_id: account.id).count).to eq(1)
|
||||
expect(user.notifications.where(account_id: account.id, read_at: nil).count).to eq(1)
|
||||
expect(user.notifications.where(account_id: other_account.id).count).to eq(1)
|
||||
it 'deletes only read notifications' do
|
||||
described_class.perform_now(user, type: :read)
|
||||
expect(user.notifications.count).to eq(1)
|
||||
expect(user.notifications.where(read_at: nil).count).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -26,7 +26,6 @@ RSpec.describe Captain::BaseTaskService do
|
||||
# without enterprise module interference
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
|
||||
@@ -10,8 +10,6 @@ RSpec.describe Integrations::LlmBaseService do
|
||||
let(:error) { StandardError.new('API Error') }
|
||||
let(:body) { { model: 'gpt-4', messages: [{ role: 'user', content: 'Hello' }] }.to_json }
|
||||
|
||||
before { allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) }
|
||||
|
||||
describe '#make_api_call' do
|
||||
before do
|
||||
allow(service).to receive(:instrument_llm_call).and_yield
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Integrations::Openai::KeyValidator do
|
||||
let(:api_key) { 'sk-test-valid-key-123456789' }
|
||||
let(:probe_url) { 'https://api.openai.com/v1/models' }
|
||||
|
||||
it 'accepts keys that OpenAI recognizes' do
|
||||
stub_request(:get, probe_url).to_return(status: 200)
|
||||
expect(described_class.valid?(api_key)).to be true
|
||||
end
|
||||
|
||||
it 'rejects keys that OpenAI does not recognize' do
|
||||
stub_request(:get, probe_url).to_return(status: 401)
|
||||
expect(described_class.valid?(api_key)).to be false
|
||||
end
|
||||
|
||||
it 'rejects blank keys without making a network call' do
|
||||
expect(described_class.valid?(nil)).to be false
|
||||
expect(described_class.valid?('')).to be false
|
||||
end
|
||||
|
||||
it 'treats transient failures as valid to avoid blocking saves' do
|
||||
stub_request(:get, probe_url).to_return(status: 500)
|
||||
expect(described_class.valid?(api_key)).to be true
|
||||
|
||||
stub_request(:get, probe_url).to_timeout
|
||||
expect(described_class.valid?(api_key)).to be true
|
||||
end
|
||||
|
||||
it 'routes the probe through the configured endpoint' do
|
||||
custom_url = 'https://proxy.example.com/v1/models'
|
||||
allow(InstallationConfig).to receive(:find_by).with(name: 'CAPTAIN_OPEN_AI_ENDPOINT')
|
||||
.and_return(instance_double(InstallationConfig, value: 'https://proxy.example.com/'))
|
||||
stub_request(:get, custom_url).to_return(status: 200)
|
||||
|
||||
described_class.valid?(api_key)
|
||||
|
||||
expect(WebMock).to have_requested(:get, custom_url)
|
||||
expect(WebMock).not_to have_requested(:get, probe_url)
|
||||
end
|
||||
end
|
||||
+46
-62
@@ -144,66 +144,56 @@ RSpec.describe SafeFetch do
|
||||
|
||||
context 'with URL validation' do
|
||||
it 'raises InvalidUrlError for javascript: URLs' do
|
||||
expect { described_class.fetch('javascript:alert(1)') { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
|
||||
end
|
||||
expect { described_class.fetch('javascript:alert(1)') { nil } }
|
||||
.to raise_error(described_class::InvalidUrlError)
|
||||
end
|
||||
|
||||
it 'raises InvalidUrlError for mailto: URLs' do
|
||||
expect { described_class.fetch('mailto:test@example.com') { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
|
||||
end
|
||||
expect { described_class.fetch('mailto:test@example.com') { nil } }
|
||||
.to raise_error(described_class::InvalidUrlError)
|
||||
end
|
||||
|
||||
it 'raises InvalidUrlError for data: URLs' do
|
||||
expect { described_class.fetch('data:text/html,<x>') { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
|
||||
end
|
||||
expect { described_class.fetch('data:text/html,<x>') { nil } }
|
||||
.to raise_error(described_class::InvalidUrlError)
|
||||
end
|
||||
|
||||
it 'raises InvalidUrlError for ftp: URLs' do
|
||||
expect { described_class.fetch('ftp://example.com/file') { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
|
||||
end
|
||||
expect { described_class.fetch('ftp://example.com/file') { nil } }
|
||||
.to raise_error(described_class::InvalidUrlError)
|
||||
end
|
||||
|
||||
it 'raises InvalidUrlError for malformed URLs' do
|
||||
expect { described_class.fetch('not_a_url') { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
|
||||
end
|
||||
expect { described_class.fetch('not_a_url') { nil } }
|
||||
.to raise_error(described_class::InvalidUrlError)
|
||||
end
|
||||
|
||||
it 'raises InvalidUrlError when host is missing' do
|
||||
expect { described_class.fetch('http:///path') { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
|
||||
end
|
||||
expect { described_class.fetch('http:///path') { nil } }
|
||||
.to raise_error(described_class::InvalidUrlError, /missing host/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with SSRF protection (integration with ssrf_filter)' do
|
||||
it 'raises UnsafeUrlError for private IP literals (10.x.x.x)' do
|
||||
expect { described_class.fetch('http://10.0.0.1/secret') { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::UnsafeUrlError')
|
||||
end
|
||||
expect { described_class.fetch('http://10.0.0.1/secret') { nil } }
|
||||
.to raise_error(described_class::UnsafeUrlError)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for loopback addresses' do
|
||||
expect { described_class.fetch('http://127.0.0.1/secret') { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::UnsafeUrlError')
|
||||
end
|
||||
expect { described_class.fetch('http://127.0.0.1/secret') { nil } }
|
||||
.to raise_error(described_class::UnsafeUrlError)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for AWS metadata IP (169.254.169.254)' do
|
||||
expect { described_class.fetch('http://169.254.169.254/latest/meta-data/') { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::UnsafeUrlError')
|
||||
end
|
||||
expect { described_class.fetch('http://169.254.169.254/latest/meta-data/') { nil } }
|
||||
.to raise_error(described_class::UnsafeUrlError)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError when hostname resolves to a private IP (DNS rebinding)' do
|
||||
allow(Resolv).to receive(:getaddresses).with('evil.example.com').and_return(['10.0.0.1'])
|
||||
expect { described_class.fetch('http://evil.example.com/secret') { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::UnsafeUrlError')
|
||||
end
|
||||
expect { described_class.fetch('http://evil.example.com/secret') { nil } }
|
||||
.to raise_error(described_class::UnsafeUrlError)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -215,9 +205,8 @@ RSpec.describe SafeFetch do
|
||||
headers: { 'Content-Type' => 'text/html' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::UnsupportedContentTypeError')
|
||||
end
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(described_class::UnsupportedContentTypeError)
|
||||
end
|
||||
|
||||
it 'rejects application/octet-stream responses' do
|
||||
@@ -227,9 +216,8 @@ RSpec.describe SafeFetch do
|
||||
headers: { 'Content-Type' => 'application/octet-stream' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::UnsupportedContentTypeError')
|
||||
end
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(described_class::UnsupportedContentTypeError)
|
||||
end
|
||||
|
||||
it 'allows video/mp4 responses' do
|
||||
@@ -278,18 +266,20 @@ RSpec.describe SafeFetch do
|
||||
headers: { 'Content-Type' => 'image/webp' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url, allowed_content_type_prefixes: [], allowed_content_types: ['image/png']) { nil } }
|
||||
.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::UnsupportedContentTypeError')
|
||||
end
|
||||
expect do
|
||||
described_class.fetch(
|
||||
url,
|
||||
allowed_content_type_prefixes: [],
|
||||
allowed_content_types: ['image/png']
|
||||
) { nil }
|
||||
end.to raise_error(described_class::UnsupportedContentTypeError)
|
||||
end
|
||||
|
||||
it 'rejects when the content-type header is missing' do
|
||||
stub_request(:get, url).to_return(status: 200, body: 'x', headers: {})
|
||||
|
||||
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::UnsupportedContentTypeError')
|
||||
end
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(described_class::UnsupportedContentTypeError)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -357,9 +347,8 @@ RSpec.describe SafeFetch do
|
||||
end
|
||||
|
||||
it 'raises UnsupportedMethodError for unsupported HTTP methods' do
|
||||
expect { described_class.fetch(url, method: :options) { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::UnsupportedMethodError')
|
||||
end
|
||||
expect { described_class.fetch(url, method: :options) { nil } }
|
||||
.to raise_error(described_class::UnsupportedMethodError)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -371,9 +360,8 @@ RSpec.describe SafeFetch do
|
||||
headers: { 'Content-Type' => 'image/png' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url, max_bytes: 2) { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::FileTooLargeError')
|
||||
end
|
||||
expect { described_class.fetch(url, max_bytes: 2) { nil } }
|
||||
.to raise_error(described_class::FileTooLargeError)
|
||||
end
|
||||
|
||||
it 'reads the default cap from GlobalConfigService MAXIMUM_FILE_UPLOAD_SIZE (matching Attachment#validate_file_size)' do
|
||||
@@ -387,9 +375,8 @@ RSpec.describe SafeFetch do
|
||||
headers: { 'Content-Type' => 'image/png' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::FileTooLargeError')
|
||||
end
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(described_class::FileTooLargeError)
|
||||
end
|
||||
|
||||
it 'falls back to 40 MB when GlobalConfigService returns a non-positive value' do
|
||||
@@ -427,27 +414,24 @@ RSpec.describe SafeFetch do
|
||||
it 'maps Net::ReadTimeout to FetchError' do
|
||||
stub_request(:get, url).to_raise(Net::ReadTimeout)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::FetchError')
|
||||
end
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(described_class::FetchError)
|
||||
end
|
||||
|
||||
it 'maps SocketError to FetchError' do
|
||||
stub_request(:get, url).to_raise(SocketError.new('connection refused'))
|
||||
|
||||
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::FetchError')
|
||||
end
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(described_class::FetchError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with non-2xx upstream responses' do
|
||||
it 'raises HttpError on non-2xx responses' do
|
||||
it 'raises HttpError with the status code in the message' do
|
||||
stub_request(:get, url).to_return(status: 404, body: '', headers: {})
|
||||
|
||||
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::HttpError')
|
||||
end
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(described_class::HttpError, /404/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -39,20 +39,4 @@ RSpec.describe AdministratorNotifications::IntegrationsNotificationMailer do
|
||||
expect(mail.to).to contain_exactly(administrator.email, another_administrator.email)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'openai_disconnect' do
|
||||
let(:mail) { described_class.with(account: account).openai_disconnect.deliver_now }
|
||||
|
||||
it 'renders the subject' do
|
||||
expect(mail.subject).to eq('Your OpenAI integration was disconnected')
|
||||
end
|
||||
|
||||
it 'renders the content' do
|
||||
expect(mail.body.encoded).to include('the configured API key is invalid or revoked')
|
||||
end
|
||||
|
||||
it 'renders the receiver email' do
|
||||
expect(mail.to).to contain_exactly(administrator.email, another_administrator.email)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,8 +5,6 @@ RSpec.describe Integrations::App do
|
||||
let(:app) { apps.find(id: app_name) }
|
||||
let(:account) { create(:account) }
|
||||
|
||||
before { allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) }
|
||||
|
||||
describe '#name' do
|
||||
let(:app_name) { 'slack' }
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user