Compare commits

..
Author SHA1 Message Date
aakashb95 46bb601c9c chore(deps): enforce pnpm release age 2026-05-12 16:18:09 +05:30
102 changed files with 244 additions and 1192 deletions
+4 -15
View File
@@ -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
+1 -1
View File
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 20
parallelism: 18
steps:
- checkout
- node/install:
+1 -1
View File
@@ -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
View File
@@ -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)
+4 -15
View File
@@ -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
+1 -8
View File
@@ -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
+3 -8
View File
@@ -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
+4 -7
View File
@@ -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"
>
@@ -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
+1
View File
@@ -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 }],
+8 -8
View File
@@ -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",
+8 -8
View File
@@ -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",
+1 -3
View File
@@ -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
-3
View File
@@ -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,
+6 -2
View File
@@ -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
+1 -11
View File
@@ -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
-13
View File
@@ -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.
-25
View File
@@ -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
-9
View File
@@ -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
@@ -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
View File
@@ -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
-41
View File
@@ -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
-2
View File
@@ -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.
-1
View File
@@ -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
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_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"
+2 -2
View File
@@ -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
View File
@@ -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}
@@ -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)
-8
View File
@@ -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
-26
View File
@@ -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
View File
@@ -178,5 +178,5 @@
"scss-lint"
]
},
"packageManager": "pnpm@10.2.0+sha512.0d27364e0139c6aadeed65ada153135e0ca96c8da42123bd50047f961339dc7a758fc2e944b428f52be570d1bd3372455c1c65fa2e7aa0bfbf931190f9552001"
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
}
+1
View File
@@ -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',
-2
View File
@@ -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
@@ -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
-11
View File
@@ -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
+19 -29
View File
@@ -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
View File
@@ -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
-2
View File
@@ -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