From ac153394566de1aed26a56fb9aaa8ff72019659f Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Fri, 19 Jun 2026 12:16:47 +0530
Subject: [PATCH 001/134] fix: Resolve Firefox input issues and persist
advanced filters (#14781)
# Pull Request Template
## Description
This PR fixes a few issues in Global Search and removes a non-functional
control.
* Fixes an issue in Firefox where characters could be dropped while
typing in the search input. The search now uses the latest input value
directly, preventing searches from running one character behind.
* Removes a stale query sync in `SearchHeader` that could overwrite
recently typed characters during the debounce window, causing the input
to appear out of sync.
* Fixes advanced search filters being removed from the URL on page
reload. The search page now waits for account data to load before
parsing URL parameters, ensuring agent, inbox, and date range filters
are preserved.
* Removes the non-functional "Sort by relevance" button from the search
tabs bar, as it was disabled and had no effect.
Fixes https://github.com/chatwoot/chatwoot/issues/14684
[CW-7305](https://linear.app/chatwoot/issue/CW-7305/global-search-drops-characters-while-typing-in-firefox-query-truncated)
[CW-7370](https://linear.app/chatwoot/issue/CW-7370/remove-non-functional-relevance-placeholder-button-from-global-search)
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
### Screencast
**Before**
https://github.com/user-attachments/assets/48d72a4e-20f4-4f24-91f4-2c9a9c065eba
**After**
https://github.com/user-attachments/assets/0e41a803-b4fd-410d-a739-549f72d418d6
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
.../search/components/SearchHeader.vue | 12 +------
.../modules/search/components/SearchInput.vue | 9 +++--
.../modules/search/components/SearchTabs.vue | 13 --------
.../modules/search/components/SearchView.vue | 33 ++++++++++++-------
4 files changed, 28 insertions(+), 39 deletions(-)
diff --git a/app/javascript/dashboard/modules/search/components/SearchHeader.vue b/app/javascript/dashboard/modules/search/components/SearchHeader.vue
index 08d4c0c7a..1de642fbe 100644
--- a/app/javascript/dashboard/modules/search/components/SearchHeader.vue
+++ b/app/javascript/dashboard/modules/search/components/SearchHeader.vue
@@ -1,5 +1,5 @@
diff --git a/app/javascript/dashboard/modules/search/components/SearchInput.vue b/app/javascript/dashboard/modules/search/components/SearchInput.vue
index 3edd79fe0..de2db176f 100644
--- a/app/javascript/dashboard/modules/search/components/SearchInput.vue
+++ b/app/javascript/dashboard/modules/search/components/SearchInput.vue
@@ -30,10 +30,13 @@ const debouncedEmit = debounce(
500
);
-const onInput = () => {
- debouncedEmit(searchQuery.value);
+const onInput = e => {
+ // Use the DOM value, not searchQuery.value: the defineModel ref updates a tick
+ // later, so reading it back here lags one character behind.
+ const value = e.target.value;
+ debouncedEmit(value);
- if (searchQuery.value.trim()) {
+ if (value.trim()) {
showRecentSearches.value = false;
} else if (isInputFocused.value) {
showRecentSearches.value = true;
diff --git a/app/javascript/dashboard/modules/search/components/SearchTabs.vue b/app/javascript/dashboard/modules/search/components/SearchTabs.vue
index 86e40df57..1a0fea65c 100644
--- a/app/javascript/dashboard/modules/search/components/SearchTabs.vue
+++ b/app/javascript/dashboard/modules/search/components/SearchTabs.vue
@@ -1,8 +1,6 @@
@@ -39,12 +59,21 @@ const localeCount = computed(() => props.locales?.length);
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
-
+
+
+
+
@@ -54,7 +83,13 @@ const localeCount = computed(() => props.locales?.length);
>
-
+
+
diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
index dfaec8119..ae099a7a9 100644
--- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json
+++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
@@ -552,6 +552,7 @@
"LOCALE": {
"ALL": "All locales"
},
+ "SEARCH_PLACEHOLDER": "Search articles...",
"NEW_ARTICLE": "New article"
},
"EMPTY_STATE": {
@@ -579,6 +580,10 @@
"CATEGORY": {
"TITLE": "There are no articles in this category",
"SUBTITLE": "Articles in this category will appear here"
+ },
+ "SEARCH": {
+ "TITLE": "No matching articles",
+ "SUBTITLE": "We couldn't find any articles matching your search. Try a different term."
}
},
"BULK_TRANSLATE": {
@@ -624,6 +629,7 @@
"CATEGORY_HEADER": {
"NEW_CATEGORY": "New category",
"EDIT_CATEGORY": "Edit category",
+ "SEARCH_PLACEHOLDER": "Search categories...",
"CATEGORIES_COUNT": "{n} category | {n} categories",
"BREADCRUMB": {
"CATEGORY_LOCALE": "Categories ({localeCode})",
@@ -634,6 +640,10 @@
"TITLE": "No categories found",
"SUBTITLE": "Categories will appear here. You can add a category by clicking the 'New Category' button."
},
+ "SEARCH_EMPTY_STATE": {
+ "TITLE": "No matching categories",
+ "SUBTITLE": "We couldn't find any categories matching your search. Try a different term."
+ },
"CATEGORY_CARD": {
"ARTICLES_COUNT": "{count} article | {count} articles"
},
@@ -691,6 +701,11 @@
"LOCALES_PAGE": {
"LOCALES_COUNT": "No locales available | {n} locale | {n} locales",
"NEW_LOCALE_BUTTON_TEXT": "New locale",
+ "SEARCH_PLACEHOLDER": "Search locales...",
+ "SEARCH_EMPTY_STATE": {
+ "TITLE": "No matching locales",
+ "SUBTITLE": "We couldn't find any locales matching your search. Try a different term."
+ },
"LOCALE_CARD": {
"ARTICLES_COUNT": "{count} article | {count} articles",
"CATEGORIES_COUNT": "{count} category | {count} categories",
diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue
index c0fc800e6..5b563203d 100644
--- a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue
+++ b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue
@@ -1,15 +1,17 @@
',
+ 'utm_campaign' => 'launch & learn'
+ )
+
+ described_class.new(account: account, cookies: cookies).perform
+
+ attribution = account.reload.internal_attributes['marketing_attribution']
+ expect(attribution['last_touch']['source']).to eq('')
+ expect(attribution['last_touch']['utm_campaign']).to eq('launch & learn')
+ end
+
+ it 'caps raw attribution values' do
+ cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
+ 'source' => 'google',
+ 'utm_campaign' => '&' * 600
+ )
+
+ described_class.new(account: account, cookies: cookies).perform
+
+ attribution = account.reload.internal_attributes['marketing_attribution']
+ expect(attribution['last_touch']['utm_campaign'].length).to eq(described_class::FIELD_MAX_LENGTH)
+ end
+
+ def encoded_cookie(payload)
+ Base64.urlsafe_encode64(payload.to_json, padding: false)
+ end
+end
From 6a262287d2098413779004a6953446ab808a3d79 Mon Sep 17 00:00:00 2001
From: Pranav
Date: Sat, 20 Jun 2026 14:47:08 -0700
Subject: [PATCH 006/134] feat(captain): allow agents to report Captain
messages (#14799)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds a cloud-only flow for agents to flag incorrect or problematic
Captain (AI) responses. Right-clicking a Captain message surfaces a
"Report message" option that opens a dialog to pick a problem type and
add a description, persisted to a new captain_message_reports table for
the team to review.
---------
Co-authored-by: Claude Opus 4.8 (1M context)
---
.../dashboard/api/captain/messageReports.js | 9 ++
.../components-next/message/Message.vue | 10 ++
.../i18n/locale/en/conversation.json | 19 +++
.../components/MessageContextMenu.vue | 21 ++++
.../components/ReportCaptainMessageDialog.vue | 119 ++++++++++++++++++
config/routes.rb | 1 +
...20000000_create_captain_message_reports.rb | 14 +++
db/schema.rb | 17 ++-
.../captain/message_reports_controller.rb | 38 ++++++
.../app/models/captain/message_report.rb | 46 +++++++
.../app/models/enterprise/concerns/message.rb | 1 +
.../message_reports/create.json.jbuilder | 7 ++
.../message_reports_controller_spec.rb | 117 +++++++++++++++++
.../models/captain/message_report_spec.rb | 40 ++++++
spec/factories/captain/message_report.rb | 8 ++
15 files changed, 466 insertions(+), 1 deletion(-)
create mode 100644 app/javascript/dashboard/api/captain/messageReports.js
create mode 100644 app/javascript/dashboard/modules/conversations/components/ReportCaptainMessageDialog.vue
create mode 100644 db/migrate/20260620000000_create_captain_message_reports.rb
create mode 100644 enterprise/app/controllers/api/v1/accounts/captain/message_reports_controller.rb
create mode 100644 enterprise/app/models/captain/message_report.rb
create mode 100644 enterprise/app/views/api/v1/accounts/captain/message_reports/create.json.jbuilder
create mode 100644 spec/enterprise/controllers/api/v1/accounts/captain/message_reports_controller_spec.rb
create mode 100644 spec/enterprise/models/captain/message_report_spec.rb
create mode 100644 spec/factories/captain/message_report.rb
diff --git a/app/javascript/dashboard/api/captain/messageReports.js b/app/javascript/dashboard/api/captain/messageReports.js
new file mode 100644
index 000000000..2df1e5747
--- /dev/null
+++ b/app/javascript/dashboard/api/captain/messageReports.js
@@ -0,0 +1,9 @@
+import ApiClient from '../ApiClient';
+
+class MessageReports extends ApiClient {
+ constructor() {
+ super('captain/message_reports', { accountScoped: true });
+ }
+}
+
+export default new MessageReports();
diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue
index 0ef64eedf..cd3c7d5dc 100644
--- a/app/javascript/dashboard/components-next/message/Message.vue
+++ b/app/javascript/dashboard/components-next/message/Message.vue
@@ -147,8 +147,14 @@ const { t } = useI18n();
const route = useRoute();
const inboxGetter = useMapGetter('inboxes/getInbox');
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
+const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const { replaceInstallationName } = useBranding();
+const isCaptainMessage = computed(() => {
+ const senderType = props.sender?.type ?? props.senderType;
+ return senderType === SENDER_TYPES.CAPTAIN_ASSISTANT;
+});
+
/**
* Computes the message variant based on props
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
@@ -390,6 +396,10 @@ const contextMenuEnabledOptions = computed(() => {
!props.private &&
props.inboxSupportsReplyTo.outgoing &&
!isFailedOrProcessing,
+ report:
+ isOnChatwootCloud.value &&
+ isCaptainMessage.value &&
+ !isMessageDeleted.value,
};
});
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index 045b8d0d9..c34ed44de 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -304,6 +304,25 @@
"MESSAGE": "You cannot undo this action",
"DELETE": "Delete",
"CANCEL": "Cancel"
+ },
+ "REPORT_MESSAGE": {
+ "LABEL": "Report message",
+ "TITLE": "Report Captain message",
+ "DESCRIPTION": "Found an issue with this AI response? Let us know what went wrong and our team will review it to help improve Captain's accuracy.",
+ "PROBLEM_TYPE": "Problem type",
+ "PROBLEM_TYPE_PLACEHOLDER": "Select a problem type",
+ "DESCRIPTION_LABEL": "Description",
+ "DESCRIPTION_PLACEHOLDER": "Describe the problem in detail",
+ "SUBMIT": "Report",
+ "SUCCESS": "Thanks for reporting. Our team will take a look.",
+ "ERROR": "Could not report this message. Please try again.",
+ "REASONS": {
+ "incorrect_information": "Incorrect information",
+ "inappropriate_response": "Inappropriate response",
+ "incomplete_response": "Incomplete response",
+ "outdated_information": "Outdated information",
+ "other": "Other"
+ }
}
},
"SIDEBAR": {
diff --git a/app/javascript/dashboard/modules/conversations/components/MessageContextMenu.vue b/app/javascript/dashboard/modules/conversations/components/MessageContextMenu.vue
index a100b379f..4cfde97aa 100644
--- a/app/javascript/dashboard/modules/conversations/components/MessageContextMenu.vue
+++ b/app/javascript/dashboard/modules/conversations/components/MessageContextMenu.vue
@@ -14,6 +14,7 @@ import {
import MenuItem from '../../../components/widgets/conversation/contextMenu/menuItem.vue';
import { useTrack } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
+import ReportCaptainMessageDialog from './ReportCaptainMessageDialog.vue';
export default {
components: {
@@ -21,6 +22,7 @@ export default {
MenuItem,
ContextMenu,
NextButton,
+ ReportCaptainMessageDialog,
},
props: {
message: {
@@ -152,6 +154,10 @@ export default {
closeDeleteModal() {
this.showDeleteModal = false;
},
+ openReportDialog() {
+ this.handleClose();
+ this.$refs.reportDialog?.open();
+ },
},
};
@@ -243,6 +249,16 @@ export default {
variant="icon"
@click.stop="showCannedResponseModal"
/>
+
+
+
diff --git a/app/javascript/dashboard/modules/conversations/components/ReportCaptainMessageDialog.vue b/app/javascript/dashboard/modules/conversations/components/ReportCaptainMessageDialog.vue
new file mode 100644
index 000000000..4d36efe78
--- /dev/null
+++ b/app/javascript/dashboard/modules/conversations/components/ReportCaptainMessageDialog.vue
@@ -0,0 +1,119 @@
+
+
+
+
+
diff --git a/config/routes.rb b/config/routes.rb
index f86e3f2cb..64e1df440 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -74,6 +74,7 @@ Rails.application.routes.draw do
resources :scenarios
end
resources :assistant_responses
+ resources :message_reports, only: [:create]
resources :bulk_actions, only: [:create]
resources :copilot_threads, only: [:index, :create] do
resources :copilot_messages, only: [:index, :create]
diff --git a/db/migrate/20260620000000_create_captain_message_reports.rb b/db/migrate/20260620000000_create_captain_message_reports.rb
new file mode 100644
index 000000000..41fc2f037
--- /dev/null
+++ b/db/migrate/20260620000000_create_captain_message_reports.rb
@@ -0,0 +1,14 @@
+class CreateCaptainMessageReports < ActiveRecord::Migration[7.1]
+ def change
+ create_table :captain_message_reports do |t|
+ t.references :account, null: false
+ t.references :conversation, null: false
+ t.references :message, null: false
+ t.references :user, null: false
+ t.string :report_reason, null: false
+ t.text :description
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index cbddbcce2..a3513ca83 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -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_06_11_184600) do
+ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -399,6 +399,21 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_11_184600) do
t.index ["inbox_id"], name: "index_captain_inboxes_on_inbox_id"
end
+ create_table "captain_message_reports", force: :cascade do |t|
+ t.bigint "account_id", null: false
+ t.bigint "conversation_id", null: false
+ t.bigint "message_id", null: false
+ t.bigint "user_id", null: false
+ t.string "report_reason", null: false
+ t.text "description"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_captain_message_reports_on_account_id"
+ t.index ["conversation_id"], name: "index_captain_message_reports_on_conversation_id"
+ t.index ["message_id"], name: "index_captain_message_reports_on_message_id"
+ t.index ["user_id"], name: "index_captain_message_reports_on_user_id"
+ end
+
create_table "captain_scenarios", force: :cascade do |t|
t.string "title"
t.text "description"
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/message_reports_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/message_reports_controller.rb
new file mode 100644
index 000000000..abce5ffcc
--- /dev/null
+++ b/enterprise/app/controllers/api/v1/accounts/captain/message_reports_controller.rb
@@ -0,0 +1,38 @@
+class Api::V1::Accounts::Captain::MessageReportsController < Api::V1::Accounts::BaseController
+ before_action :ensure_cloud_installation
+ before_action :set_message
+ before_action :authorize_conversation
+ before_action :ensure_captain_message
+
+ def create
+ @message_report = @message.message_reports.create!(
+ user: Current.user,
+ report_reason: permitted_params[:report_reason],
+ description: permitted_params[:description]
+ )
+ end
+
+ private
+
+ def ensure_cloud_installation
+ render json: { error: 'Not available' }, status: :not_found unless ChatwootApp.chatwoot_cloud?
+ end
+
+ def set_message
+ @message = Current.account.messages.find(permitted_params[:message_id])
+ end
+
+ def authorize_conversation
+ authorize @message.conversation, :show?
+ end
+
+ def ensure_captain_message
+ return if @message.sender_type == 'Captain::Assistant'
+
+ render json: { error: 'Only Captain messages can be reported' }, status: :unprocessable_entity
+ end
+
+ def permitted_params
+ params.permit(:message_id, :report_reason, :description)
+ end
+end
diff --git a/enterprise/app/models/captain/message_report.rb b/enterprise/app/models/captain/message_report.rb
new file mode 100644
index 000000000..4fcb609fe
--- /dev/null
+++ b/enterprise/app/models/captain/message_report.rb
@@ -0,0 +1,46 @@
+# == Schema Information
+#
+# Table name: captain_message_reports
+#
+# id :bigint not null, primary key
+# description :text
+# report_reason :string not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# account_id :bigint not null
+# conversation_id :bigint not null
+# message_id :bigint not null
+# user_id :bigint not null
+#
+# Indexes
+#
+# index_captain_message_reports_on_account_id (account_id)
+# index_captain_message_reports_on_conversation_id (conversation_id)
+# index_captain_message_reports_on_message_id (message_id)
+# index_captain_message_reports_on_user_id (user_id)
+#
+class Captain::MessageReport < ApplicationRecord
+ self.table_name = 'captain_message_reports'
+
+ REPORT_REASONS = %w[incorrect_information inappropriate_response incomplete_response outdated_information other].freeze
+
+ belongs_to :account
+ # `Captain::Conversation` exists as a job namespace, so the association would
+ # resolve to that module instead of the top-level model without this override.
+ belongs_to :conversation, class_name: '::Conversation'
+ belongs_to :message
+ belongs_to :user
+
+ validates :report_reason, presence: true, inclusion: { in: REPORT_REASONS }
+
+ before_validation :ensure_account_and_conversation
+
+ private
+
+ def ensure_account_and_conversation
+ return if message.blank?
+
+ self.account ||= message.account
+ self.conversation ||= message.conversation
+ end
+end
diff --git a/enterprise/app/models/enterprise/concerns/message.rb b/enterprise/app/models/enterprise/concerns/message.rb
index cfdea430b..3c7cdc3f7 100644
--- a/enterprise/app/models/enterprise/concerns/message.rb
+++ b/enterprise/app/models/enterprise/concerns/message.rb
@@ -3,5 +3,6 @@ module Enterprise::Concerns::Message
included do
has_one :call, dependent: :nullify
+ has_many :message_reports, class_name: 'Captain::MessageReport', dependent: :destroy_async
end
end
diff --git a/enterprise/app/views/api/v1/accounts/captain/message_reports/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/message_reports/create.json.jbuilder
new file mode 100644
index 000000000..f45c0078b
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/captain/message_reports/create.json.jbuilder
@@ -0,0 +1,7 @@
+json.id @message_report.id
+json.message_id @message_report.message_id
+json.conversation_id @message_report.conversation_id
+json.user_id @message_report.user_id
+json.report_reason @message_report.report_reason
+json.description @message_report.description
+json.created_at @message_report.created_at.to_i
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/message_reports_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/message_reports_controller_spec.rb
new file mode 100644
index 000000000..129888812
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/message_reports_controller_spec.rb
@@ -0,0 +1,117 @@
+require 'rails_helper'
+
+RSpec.describe 'Api::V1::Accounts::Captain::MessageReports', type: :request do
+ let(:account) { create(:account) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:message) do
+ create(:message, account: account, conversation: conversation, message_type: :outgoing, sender: assistant)
+ end
+
+ before { create(:inbox_member, user: agent, inbox: inbox) }
+
+ def json_response
+ JSON.parse(response.body, symbolize_names: true)
+ end
+
+ describe 'POST /api/v1/accounts/:account_id/captain/message_reports' do
+ let(:valid_params) do
+ {
+ message_id: message.id,
+ report_reason: 'incorrect_information',
+ description: 'The generated citation is wrong.'
+ }
+ end
+
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ post "/api/v1/accounts/#{account.id}/captain/message_reports", params: valid_params, as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when the installation is not on Chatwoot cloud' do
+ before { InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'self_hosted') }
+
+ it 'returns not found' do
+ post "/api/v1/accounts/#{account.id}/captain/message_reports",
+ params: valid_params, headers: agent.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:not_found)
+ end
+
+ it 'does not create a report' do
+ expect do
+ post "/api/v1/accounts/#{account.id}/captain/message_reports",
+ params: valid_params, headers: agent.create_new_auth_token, as: :json
+ end.not_to change(Captain::MessageReport, :count)
+ end
+ end
+
+ context 'when on Chatwoot cloud' do
+ before { InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'cloud') }
+
+ it 'creates a message report for the reporting agent' do
+ expect do
+ post "/api/v1/accounts/#{account.id}/captain/message_reports",
+ params: valid_params, headers: agent.create_new_auth_token, as: :json
+ end.to change(Captain::MessageReport, :count).by(1)
+
+ report = Captain::MessageReport.last
+ aggregate_failures do
+ expect(response).to have_http_status(:success)
+ expect(report.message_id).to eq(message.id)
+ expect(report.conversation_id).to eq(conversation.id)
+ expect(report.user_id).to eq(agent.id)
+ expect(report.report_reason).to eq('incorrect_information')
+ expect(report.description).to eq('The generated citation is wrong.')
+ expect(json_response[:report_reason]).to eq('incorrect_information')
+ end
+ end
+
+ it 'returns not found when the message does not belong to the account' do
+ other_message = create(:message)
+
+ post "/api/v1/accounts/#{account.id}/captain/message_reports",
+ params: valid_params.merge(message_id: other_message.id),
+ headers: agent.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:not_found)
+ end
+
+ it 'returns unprocessable entity for an invalid report reason' do
+ post "/api/v1/accounts/#{account.id}/captain/message_reports",
+ params: valid_params.merge(report_reason: 'invalid_reason'),
+ headers: agent.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+
+ it 'does not allow an agent without access to the conversation to report' do
+ other_agent = create(:user, account: account, role: :agent)
+
+ expect do
+ post "/api/v1/accounts/#{account.id}/captain/message_reports",
+ params: valid_params, headers: other_agent.create_new_auth_token, as: :json
+ end.not_to change(Captain::MessageReport, :count)
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it 'rejects messages that were not sent by a Captain assistant' do
+ non_captain_message = create(:message, account: account, conversation: conversation)
+
+ expect do
+ post "/api/v1/accounts/#{account.id}/captain/message_reports",
+ params: valid_params.merge(message_id: non_captain_message.id),
+ headers: agent.create_new_auth_token, as: :json
+ end.not_to change(Captain::MessageReport, :count)
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/models/captain/message_report_spec.rb b/spec/enterprise/models/captain/message_report_spec.rb
new file mode 100644
index 000000000..ded42890c
--- /dev/null
+++ b/spec/enterprise/models/captain/message_report_spec.rb
@@ -0,0 +1,40 @@
+require 'rails_helper'
+
+RSpec.describe Captain::MessageReport, type: :model do
+ describe 'associations' do
+ it { is_expected.to belong_to(:account) }
+ it { is_expected.to belong_to(:conversation) }
+ it { is_expected.to belong_to(:message) }
+ it { is_expected.to belong_to(:user) }
+
+ it 'resolves the conversation association to the top-level Conversation model' do
+ # `Captain::Conversation` exists as a job namespace, so without an explicit
+ # class_name the association would resolve to that module instead.
+ expect(described_class.reflect_on_association(:conversation).klass).to eq(Conversation)
+ end
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:report_reason) }
+ it { is_expected.to validate_inclusion_of(:report_reason).in_array(described_class::REPORT_REASONS) }
+ end
+
+ describe 'callbacks' do
+ let(:account) { create(:account) }
+ let(:conversation) { create(:conversation, account: account) }
+ let(:message) { create(:message, account: account, conversation: conversation) }
+
+ it 'derives the account and conversation from the message' do
+ report = described_class.create!(message: message, user: create(:user, account: account), report_reason: 'other')
+
+ expect(report.account).to eq(account)
+ expect(report.conversation).to eq(conversation)
+ end
+ end
+
+ describe 'factory' do
+ it 'creates a valid message report' do
+ expect(build(:captain_message_report)).to be_valid
+ end
+ end
+end
diff --git a/spec/factories/captain/message_report.rb b/spec/factories/captain/message_report.rb
new file mode 100644
index 000000000..d7f1cfca0
--- /dev/null
+++ b/spec/factories/captain/message_report.rb
@@ -0,0 +1,8 @@
+FactoryBot.define do
+ factory :captain_message_report, class: 'Captain::MessageReport' do
+ report_reason { 'incorrect_information' }
+ description { 'The generated citation is wrong.' }
+ association :message
+ association :user
+ end
+end
From 66609f06fd0334643be8b9e643cb5a668d468c11 Mon Sep 17 00:00:00 2001
From: Sony Mathew
Date: Mon, 22 Jun 2026 13:37:58 +0530
Subject: [PATCH 007/134] fix: Bump Nokogiri to 1.19.4 (#14807)
# Pull Request Template
## Description
This updates Nokogiri from `1.19.3` to `1.19.4` so the bundle-audit lint
step stops flagging the newly published Nokogiri advisories. Chatwoot's
direct Nokogiri usage appears limited to ordinary HTML/XML parsing and
selector traversal, but the locked dependency is below the patched
floor, so the safe remediation is the patch-level upgrade rather than an
advisory override.
Fixes
[CW-7397](https://linear.app/chatwoot/issue/CW-7397/upgrade-nokogiri-to-1194-for-bundle-audit-advisories)
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
Reference failed build in CI because of bundle audit:
https://app.circleci.com/pipelines/github/chatwoot/chatwoot/114392/workflows/8a252bf9-5e58-45fd-af18-a32dbebe978b/jobs/160423
- `bundle exec bundle audit update && bundle exec bundle audit check -v`
passed with no vulnerabilities found.
- `bundle exec rspec spec/services/website_branding_service_spec.rb
spec/presenters/html_parser_spec.rb
spec/enterprise/services/enterprise/website_branding_service_spec.rb
spec/enterprise/services/captain/tools/simple_page_crawl_service_spec.rb`
passed with 27 examples and 0 failures.
- `bundle exec rubocop app/services/website_branding_service.rb
app/presenters/html_parser.rb
enterprise/app/services/page_crawler_service.rb
enterprise/app/services/captain/tools/html_page_parser.rb
enterprise/app/services/captain/tools/simple_page_crawl_service.rb`
passed with no offenses.
- `git diff --check` passed.
Note: broad `bundle exec rubocop --parallel` still reports existing
generated DB/schema offenses unrelated to this lockfile-only dependency
bump.
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
Gemfile.lock | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index 8d6132849..a21f01ab3 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -598,14 +598,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.5)
- nokogiri (1.19.3)
+ nokogiri (1.19.4)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
- nokogiri (1.19.3-arm64-darwin)
+ nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4)
- nokogiri (1.19.3-x86_64-darwin)
+ nokogiri (1.19.4-x86_64-darwin)
racc (~> 1.4)
- nokogiri (1.19.3-x86_64-linux-gnu)
+ nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.6)
auth-sanitizer (~> 0.2, >= 0.2.1)
From c27a5916bba9e97ef708d5a8a9bded771417ba52 Mon Sep 17 00:00:00 2001
From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Date: Mon, 22 Jun 2026 13:39:43 +0530
Subject: [PATCH 008/134] fix: auto enable document auto-sync (#14806)
# Pull Request Template
## Description
Auto enables document auto-sync on paid plans
fixes:
https://linear.app/chatwoot/issue/AI-186/captain-auto-sync-doesnt-turn-on-automatically-on-subscription
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
---
.../enterprise/billing/reconcile_plan_features_service.rb | 1 +
enterprise/config/premium_features.yml | 1 +
2 files changed, 2 insertions(+)
diff --git a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb
index a6f76f6b5..fea5b7664 100644
--- a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb
+++ b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb
@@ -13,6 +13,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
channel_instagram
channel_tiktok
captain_integration
+ captain_document_auto_sync
advanced_search_indexing
advanced_search
linear_integration
diff --git a/enterprise/config/premium_features.yml b/enterprise/config/premium_features.yml
index 0cb89df01..282319fe7 100644
--- a/enterprise/config/premium_features.yml
+++ b/enterprise/config/premium_features.yml
@@ -4,5 +4,6 @@
- sla
- custom_roles
- captain_integration
+- captain_document_auto_sync
- csat_review_notes
- conversation_required_attributes
From 6fcc888aee7d990c37b418e215217a82f8972905 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 22 Jun 2026 14:09:28 +0530
Subject: [PATCH 009/134] chore(deps): bump oj from 3.16.10 to 3.17.3 (#14809)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps [oj](https://github.com/ohler55/oj) from 3.16.10 to 3.17.3.
Release notes
Use fast_memcpy16 to quickly copy up to 16 bytes for tail handling
in ARM Neon code and small copies when generating JSON. by @samyron in ohler55/oj#998
A "safe" parser has been added as a variation of the
Oj:Parser thanks to @meinac.
3.16.17 - 2026-04-12
Rails optimize for Hash and Array now overrides as_json
for those
classes. Note that when either is optimized with
Oj.optimize_rails
the Array.as_json and Hash.as_json will not be
called.
Add support for the rails encoder :only and
:except options.
Handle unterminated strings in usual parser (#1002)
Fix read() not handling partial reads for large files (#1004)
Raise error for incomplete primitive literals (#1005)
3.16.16 - 2026-03-13
Not closed arrays and objects are reported corrected in the usual
parser.
3.16.15 - 2026-02-05
Fixed by putting the ostruct dependency back until a better way is
found to conditionally include it.
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sony Mathew
---
Gemfile.lock | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index a21f01ab3..4fdcf804a 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -170,7 +170,7 @@ GEM
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.4.1)
- bigdecimal (3.3.1)
+ bigdecimal (4.1.2)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
@@ -274,8 +274,8 @@ GEM
dry-logic (~> 1.5)
dry-types (~> 1.8)
zeitwerk (~> 2.6)
- dry-types (1.8.3)
- bigdecimal (~> 3.0)
+ dry-types (1.9.1)
+ bigdecimal (>= 3.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0)
dry-inflector (~> 1.0)
@@ -627,7 +627,7 @@ GEM
rack (>= 1.2, < 4)
snaky_hash (~> 2.0, >= 2.0.5)
version_gem (~> 1.1, >= 1.1.11)
- oj (3.16.10)
+ oj (3.17.3)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.4)
@@ -674,7 +674,7 @@ GEM
opentelemetry-api (~> 1.0)
orm_adapter (0.5.0)
os (1.1.4)
- ostruct (0.6.1)
+ ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.8.0)
ast (~> 2.4.1)
From a4be38f8f3baf59a5ea0bc34fb5b29fe46efa38c Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Mon, 22 Jun 2026 12:40:23 +0400
Subject: [PATCH 010/134] fix(facebook): handle messenger sticker attachment
type (#14793)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Facebook Messenger sticker messages no longer break message ingestion.
Meta recently changed its webhook payloads so a sticker now arrives as a
new `sticker` attachment type (alongside the existing `image` attachment
during the transition period). Chatwoot didn't recognise `sticker` as a
valid attachment file type, so the webhook job crashed and the message —
and any others in the same batch — failed to sync. Stickers now appear
in the conversation as a single image, just like before.
Fixes https://linear.app/chatwoot/issue/PLA-177
## How to reproduce
1. Connect a Facebook Page inbox.
2. Send a sticker from Messenger to that page.
3. Before this change: the `Webhooks::FacebookEventsJob` raises
`ArgumentError: 'sticker' is not a valid file_type` and the message is
dropped.
4. After this change: the sticker shows up in the conversation as a
single image attachment.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context)
---
.../messages/messenger/message_builder.rb | 20 ++++++-
.../messages/facebook/message_builder_spec.rb | 60 +++++++++++++++++++
2 files changed, 78 insertions(+), 2 deletions(-)
diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb
index ecd6f06ea..712a24608 100644
--- a/app/builders/messages/messenger/message_builder.rb
+++ b/app/builders/messages/messenger/message_builder.rb
@@ -6,6 +6,11 @@ class Messages::Messenger::MessageBuilder
return if unsupported_file_type?(attachment['type'])
params = attachment_params(attachment)
+ # During Meta's sticker webhook transition, a sticker message carries both an `image`
+ # and a `sticker` attachment pointing to the same URL. Skip the redundant sticker so it
+ # isn't attached twice, while still storing legitimate duplicate attachments of other types.
+ return if duplicate_sticker?(attachment, params[:external_url])
+
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
attachment_obj.save!
if facebook_reel?(attachment)
@@ -13,10 +18,14 @@ class Messages::Messenger::MessageBuilder
elsif params[:remote_file_url]
attach_file(attachment_obj, params[:remote_file_url])
end
+ fetch_attachment_links(attachment_obj)
+ update_attachment_file_type(attachment_obj)
+ end
+
+ def fetch_attachment_links(attachment_obj)
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
- update_attachment_file_type(attachment_obj)
end
def attach_file(attachment, file_url)
@@ -111,13 +120,20 @@ class Messages::Messenger::MessageBuilder
# Facebook may send attachment types that don't directly match our file_type enum.
# Map known aliases to their canonical enum values.
- FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
+ FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel, sticker: :image }.freeze
def normalize_file_type(type)
sym = type.to_sym
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
end
+ def duplicate_sticker?(attachment, url)
+ return false unless attachment['type'].to_sym == :sticker
+ return false if url.blank?
+
+ @message.attachments.any? { |existing| existing.external_url == url }
+ end
+
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
# direct video URLs. Downloading these yields HTML, not video content.
def facebook_reel?(attachment)
diff --git a/spec/builders/messages/facebook/message_builder_spec.rb b/spec/builders/messages/facebook/message_builder_spec.rb
index afa9d5f34..7718d23eb 100644
--- a/spec/builders/messages/facebook/message_builder_spec.rb
+++ b/spec/builders/messages/facebook/message_builder_spec.rb
@@ -140,6 +140,66 @@ describe Messages::Facebook::MessageBuilder do
end
end
+ context 'when message contains a sticker attachment' do
+ let(:sticker_url) { 'https://scontent.xx.fbcdn.net/sticker.png' }
+ let(:sticker_message_object) do
+ {
+ messaging: {
+ sender: { id: '3383290475046708' },
+ recipient: { id: facebook_channel.page_id },
+ timestamp: 1_772_452_164_516,
+ message: {
+ mid: 'm_sticker_test',
+ attachments: [
+ { type: 'image', payload: { url: sticker_url } },
+ { type: 'sticker', payload: { url: sticker_url } }
+ ]
+ }
+ }
+ }.to_json
+ end
+ let(:sticker_message) { Integrations::Facebook::MessageParser.new(sticker_message_object) }
+
+ before do
+ allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
+ allow(fb_object).to receive(:get_object).and_return(
+ { first_name: 'Jane', last_name: 'Dae', profile_pic: 'https://chatwoot-assets.local/sample.png' }.with_indifferent_access
+ )
+ stub_request(:get, sticker_url).to_return(status: 200, body: 'sticker_data', headers: { 'Content-Type' => 'image/png' })
+ end
+
+ it 'stores the sticker as a single image attachment' do
+ described_class.new(sticker_message, facebook_channel.inbox).perform
+
+ message = facebook_channel.inbox.messages.find_by(source_id: 'm_sticker_test')
+ expect(message.attachments.count).to eq(1)
+ expect(message.attachments.first.file_type).to eq('image')
+ expect(message.attachments.first.external_url).to eq(sticker_url)
+ end
+
+ it 'keeps duplicate non-sticker attachments that share a URL' do
+ duplicate_image_object = {
+ messaging: {
+ sender: { id: '3383290475046708' },
+ recipient: { id: facebook_channel.page_id },
+ message: {
+ mid: 'm_duplicate_image_test',
+ attachments: [
+ { type: 'image', payload: { url: sticker_url } },
+ { type: 'image', payload: { url: sticker_url } }
+ ]
+ }
+ }
+ }.to_json
+ duplicate_image_message = Integrations::Facebook::MessageParser.new(duplicate_image_object)
+
+ described_class.new(duplicate_image_message, facebook_channel.inbox).perform
+
+ message = facebook_channel.inbox.messages.find_by(source_id: 'm_duplicate_image_test')
+ expect(message.attachments.count).to eq(2)
+ end
+ end
+
[
{
source_id: 'm_fallback_test',
From 5c00a1601ad32f2ea0a1833dfa8735c1f24f384c Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 22 Jun 2026 14:58:07 +0530
Subject: [PATCH 011/134] chore(deps): bump dompurify from 3.4.0 to 3.4.11
(#14798)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.0 to
3.4.11.
Release notes
This version adds prepare script that runs during
installation. Review the package contents before updating.
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sony Mathew
---
package.json | 2 +-
pnpm-lock.yaml | 22 +++++++++++++++-------
2 files changed, 16 insertions(+), 8 deletions(-)
diff --git a/package.json b/package.json
index 65dd48d27..917a1b97d 100644
--- a/package.json
+++ b/package.json
@@ -69,7 +69,7 @@
"countries-and-timezones": "^3.6.0",
"date-fns": "2.21.1",
"date-fns-tz": "^1.3.3",
- "dompurify": "3.4.0",
+ "dompurify": "3.4.11",
"flag-icons": "^7.2.3",
"floating-vue": "^5.2.2",
"highlight.js": "^11.10.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6c7bebb79..80fbf318a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -130,8 +130,8 @@ importers:
specifier: ^1.3.3
version: 1.3.8(date-fns@2.21.1)
dompurify:
- specifier: 3.4.0
- version: 3.4.0
+ specifier: 3.4.11
+ version: 3.4.11
flag-icons:
specifier: ^7.2.3
version: 7.2.3
@@ -1635,6 +1635,11 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ acorn@8.17.0:
+ resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
activestorage@5.2.8:
resolution: {integrity: sha512-bueFOxBGIAUdrjbLyBZ8Xlkcecy8vr05sCk5VV37BbFi+RehPoEjfvKX3iYYPY7RFVhl+L43W9/ZbN3xNNLPtQ==}
@@ -2218,8 +2223,8 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
- dompurify@3.4.0:
- resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==}
+ dompurify@3.4.11:
+ resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
domutils@3.1.0:
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
@@ -6380,6 +6385,9 @@ snapshots:
acorn@8.16.0: {}
+ acorn@8.17.0:
+ optional: true
+
activestorage@5.2.8:
dependencies:
spark-md5: 3.0.2
@@ -6999,7 +7007,7 @@ snapshots:
dependencies:
domelementtype: 2.3.0
- dompurify@3.4.0:
+ dompurify@3.4.11:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -9511,7 +9519,7 @@ snapshots:
terser@5.33.0:
dependencies:
'@jridgewell/source-map': 0.3.11
- acorn: 8.16.0
+ acorn: 8.17.0
commander: 2.20.3
source-map-support: 0.5.21
optional: true
@@ -9898,7 +9906,7 @@ snapshots:
vue-dompurify-html@5.3.0(vue@3.5.12(typescript@5.6.2)):
dependencies:
- dompurify: 3.4.0
+ dompurify: 3.4.11
vue: 3.5.12(typescript@5.6.2)
vue-eslint-parser@9.4.3(eslint@8.57.0):
From 5b7430f3765b9a4b6e1812cf3c78aac5c81ee310 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 22 Jun 2026 16:08:06 +0530
Subject: [PATCH 012/134] chore(deps): bump concurrent-ruby from 1.3.5 to 1.3.7
(#14810)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps
[concurrent-ruby](https://github.com/ruby-concurrency/concurrent-ruby)
from 1.3.5 to 1.3.7.
Release notes
There are 3 security fixes in this release, so updating is
recommended.
These security vulnerabilities are not very likely to be hit in practice
and have a corresponding Low severity score.
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sony Mathew
---
Gemfile.lock | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index 4fdcf804a..ffaebe115 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -193,7 +193,7 @@ GEM
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
- concurrent-ruby (1.3.5)
+ concurrent-ruby (1.3.7)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
From 44b32eacec053f75558ab343129fad43234f389b Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 22 Jun 2026 17:36:08 +0530
Subject: [PATCH 013/134] chore(deps): bump faraday from 2.14.2 to 2.14.3
(#14811)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps [faraday](https://github.com/lostisland/faraday) from 2.14.2 to
2.14.3.
Release notes
This release contains a security fix, we recommend all users to
upgrade as soon as possible.
A Security Advisory with more details will be posted shortly.
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sony Mathew
---
Gemfile.lock | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index ffaebe115..8da80f52c 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -304,7 +304,7 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
- faraday (2.14.2)
+ faraday (2.14.3)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -474,7 +474,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
- json (2.19.8)
+ json (2.19.9)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
From 2b37fef0e0ce7ce5b9461f522a228facfd35f2b6 Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Mon, 22 Jun 2026 06:49:52 -0700
Subject: [PATCH 014/134] fix(companies): sync contact company names (#14759)
Fixes company-contact name drift when a company is renamed or deleted.
Closes: N/A
## Why
Contacts keep a denormalized `additional_attributes.company_name` for
display and filtering. Company rename/delete flows could leave that
copied value stale even though the actual `company_id` relationship
changed.
## What changed
- Enqueues an async company contact-name sync job when a company name
changes.
- Moves company deletion into `Companies::DeleteJob`.
- The delete job unlinks linked contacts, clears only the copied
`company_name`, and then deletes the company.
- Uses bulk JSON updates for the cleanup path so contact records are not
saved, which avoids contact update callbacks, webhook dispatch, and
automation side effects.
## How to test
- Link a contact to a company, rename the company, and confirm the
contact company name updates after the job runs.
- Delete a company with linked contacts and confirm the delete job
removes the company, unassigns linked contacts, and preserves other
contact additional attributes.
---------
Co-authored-by: Sony Mathew
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
---
.../api/v1/accounts/companies_controller.rb | 2 +-
enterprise/app/jobs/companies/delete_job.rb | 28 ++++++++++++++
.../jobs/companies/sync_contact_names_job.rb | 33 ++++++++++++++++
enterprise/app/models/company.rb | 5 +++
.../v1/accounts/companies_controller_spec.rb | 6 +--
.../jobs/companies/delete_job_spec.rb | 19 ++++++++++
.../companies/sync_contact_names_job_spec.rb | 38 +++++++++++++++++++
spec/enterprise/models/company_spec.rb | 11 ++++++
8 files changed, 138 insertions(+), 4 deletions(-)
create mode 100644 enterprise/app/jobs/companies/delete_job.rb
create mode 100644 enterprise/app/jobs/companies/sync_contact_names_job.rb
create mode 100644 spec/enterprise/jobs/companies/delete_job_spec.rb
create mode 100644 spec/enterprise/jobs/companies/sync_contact_names_job_spec.rb
diff --git a/enterprise/app/controllers/api/v1/accounts/companies_controller.rb b/enterprise/app/controllers/api/v1/accounts/companies_controller.rb
index 1df0e7d88..1f2c38c91 100644
--- a/enterprise/app/controllers/api/v1/accounts/companies_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/companies_controller.rb
@@ -49,7 +49,7 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAcco
end
def destroy
- @company.destroy!
+ Companies::DeleteJob.perform_later(company_id: @company.id)
head :ok
end
diff --git a/enterprise/app/jobs/companies/delete_job.rb b/enterprise/app/jobs/companies/delete_job.rb
new file mode 100644
index 000000000..62c36750d
--- /dev/null
+++ b/enterprise/app/jobs/companies/delete_job.rb
@@ -0,0 +1,28 @@
+class Companies::DeleteJob < ApplicationJob
+ queue_as :low
+
+ BATCH_SIZE = 1000
+ CONTACT_COMPANY_CLEAR_SQL = <<~SQL.squish.freeze
+ company_id = NULL,
+ additional_attributes = COALESCE(additional_attributes, '{}'::jsonb) - 'company_name'
+ SQL
+
+ def perform(company_id:)
+ company = Company.find_by(id: company_id)
+ return if company.blank?
+
+ clear_contact_company_names(company)
+ company.destroy!
+ end
+
+ private
+
+ # Avoid contact callbacks so this cleanup does not dispatch contact automations/webhooks.
+ # rubocop:disable Rails/SkipsModelValidations
+ def clear_contact_company_names(company)
+ company.contacts.in_batches(of: BATCH_SIZE) do |contacts|
+ contacts.update_all(CONTACT_COMPANY_CLEAR_SQL)
+ end
+ end
+ # rubocop:enable Rails/SkipsModelValidations
+end
diff --git a/enterprise/app/jobs/companies/sync_contact_names_job.rb b/enterprise/app/jobs/companies/sync_contact_names_job.rb
new file mode 100644
index 000000000..36f38aacb
--- /dev/null
+++ b/enterprise/app/jobs/companies/sync_contact_names_job.rb
@@ -0,0 +1,33 @@
+class Companies::SyncContactNamesJob < ApplicationJob
+ queue_as :low
+
+ BATCH_SIZE = 1000
+ CONTACT_COMPANY_NAME_UPDATE_SQL = <<~SQL.squish.freeze
+ additional_attributes = jsonb_set(
+ COALESCE(additional_attributes, '{}'::jsonb),
+ '{company_name}',
+ ?::jsonb,
+ true
+ )
+ SQL
+
+ def perform(company_id:)
+ return if company_id.blank?
+
+ company = Company.find_by(id: company_id)
+ return if company.blank?
+
+ sync_company_name(company)
+ end
+
+ private
+
+ # Denormalized display field sync; avoid contact validations, callbacks, and webhook/automation side effects.
+ # rubocop:disable Rails/SkipsModelValidations
+ def sync_company_name(company)
+ company.contacts.in_batches(of: BATCH_SIZE) do |contacts|
+ contacts.update_all([CONTACT_COMPANY_NAME_UPDATE_SQL, company.name.to_json])
+ end
+ end
+ # rubocop:enable Rails/SkipsModelValidations
+end
diff --git a/enterprise/app/models/company.rb b/enterprise/app/models/company.rb
index c60e9423c..b4c42ff3d 100644
--- a/enterprise/app/models/company.rb
+++ b/enterprise/app/models/company.rb
@@ -39,6 +39,7 @@ class Company < ApplicationRecord
has_many :contacts, dependent: :nullify
before_validation :prepare_jsonb_attributes
after_create_commit :fetch_favicon, if: -> { domain.present? }
+ after_update_commit :enqueue_contact_company_name_sync, if: :saved_change_to_name?
scope :ordered_by_name, -> { order(:name) }
scope :search_by_name_or_domain, lambda { |query|
@@ -76,4 +77,8 @@ class Company < ApplicationRecord
def fetch_favicon
Avatar::AvatarFromFaviconJob.set(wait: 5.seconds).perform_later(self)
end
+
+ def enqueue_contact_company_name_sync
+ Companies::SyncContactNamesJob.perform_later(company_id: id)
+ end
end
diff --git a/spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb
index 84b182669..1d6f4870c 100644
--- a/spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb
@@ -385,13 +385,13 @@ RSpec.describe 'Companies API', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) { create(:company, account: account) }
- it 'deletes the company' do
- company
+ it 'enqueues company deletion' do
expect do
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
headers: admin.create_new_auth_token,
as: :json
- end.to change(Company, :count).by(-1)
+ end.to have_enqueued_job(Companies::DeleteJob).with(company_id: company.id)
+
expect(response).to have_http_status(:ok)
end
end
diff --git a/spec/enterprise/jobs/companies/delete_job_spec.rb b/spec/enterprise/jobs/companies/delete_job_spec.rb
new file mode 100644
index 000000000..1d4e4249d
--- /dev/null
+++ b/spec/enterprise/jobs/companies/delete_job_spec.rb
@@ -0,0 +1,19 @@
+require 'rails_helper'
+
+RSpec.describe Companies::DeleteJob, type: :job do
+ describe '#perform' do
+ it 'unlinks contacts, clears company names, and deletes the company' do
+ account = create(:account)
+ company = create(:company, account: account, name: 'Acme')
+ contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme', 'city' => 'Berlin' })
+ other_contact = create(:contact, account: account, additional_attributes: { 'company_name' => 'Acme' })
+
+ described_class.perform_now(company_id: company.id)
+
+ expect { company.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ expect(contact.reload.company_id).to be_nil
+ expect(contact.additional_attributes).to eq('city' => 'Berlin')
+ expect(other_contact.reload.additional_attributes).to eq('company_name' => 'Acme')
+ end
+ end
+end
diff --git a/spec/enterprise/jobs/companies/sync_contact_names_job_spec.rb b/spec/enterprise/jobs/companies/sync_contact_names_job_spec.rb
new file mode 100644
index 000000000..39ed4fc08
--- /dev/null
+++ b/spec/enterprise/jobs/companies/sync_contact_names_job_spec.rb
@@ -0,0 +1,38 @@
+require 'rails_helper'
+
+RSpec.describe Companies::SyncContactNamesJob, type: :job do
+ let(:account) { create(:account) }
+ let(:company) { create(:company, account: account, name: 'Acme') }
+
+ describe '#perform' do
+ it 'updates linked contact company names' do
+ contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme', 'city' => 'Berlin' })
+
+ company.update!(name: 'Acme Labs')
+
+ described_class.perform_now(company_id: company.id)
+
+ expect(contact.reload.additional_attributes).to eq('company_name' => 'Acme Labs', 'city' => 'Berlin')
+ end
+
+ it 'uses the current company name when a stale rename job runs' do
+ contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme' })
+ company.update!(name: 'Acme Labs')
+
+ described_class.perform_now(company_id: company.id)
+
+ expect(contact.reload.additional_attributes).to eq('company_name' => 'Acme Labs')
+ end
+
+ it 'does not save contacts while syncing the denormalized company name' do
+ contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme' })
+ original_updated_at = contact.reload.updated_at
+
+ company.update!(name: 'Acme Labs')
+
+ described_class.perform_now(company_id: company.id)
+
+ expect(contact.reload.updated_at).to eq(original_updated_at)
+ end
+ end
+end
diff --git a/spec/enterprise/models/company_spec.rb b/spec/enterprise/models/company_spec.rb
index 1b681973d..8f65abe5f 100644
--- a/spec/enterprise/models/company_spec.rb
+++ b/spec/enterprise/models/company_spec.rb
@@ -46,4 +46,15 @@ RSpec.describe Company, type: :model do
expect(company.reload.last_activity_at).to be_within(1.second).of(original_activity_at)
end
end
+
+ describe 'contact company name sync' do
+ let(:account) { create(:account) }
+ let(:company) { create(:company, account: account, name: 'Acme') }
+
+ it 'enqueues contact company name sync when the company name changes' do
+ expect do
+ company.update!(name: 'Acme Labs')
+ end.to have_enqueued_job(Companies::SyncContactNamesJob).with(company_id: company.id)
+ end
+ end
end
From e86222034e39b9be4837fea0c058ad9a6a27aa72 Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Mon, 22 Jun 2026 06:51:09 -0700
Subject: [PATCH 015/134] fix(auth): record attribution for oauth signups
(#14796)
Google OAuth signups now persist the same first-party attribution
cookies as email signups on Chatwoot Cloud.
This keeps attribution capture owned by the website and reuses the
existing Enterprise-only account attribution service. The OAuth callback
only records the already-shaped first-touch and last-touch cookie
payload after a new account is created.
## What changed
- Added Enterprise-only attribution persistence to the Google OAuth
signup account creation path.
- Reuses `Internal::Accounts::MarketingAttributionService`.
- Keeps attribution best-effort so failures do not interrupt OAuth
signup.
- Leaves existing email signup and SAML behavior unchanged.
## How to test
- Start from a Chatwoot Cloud-like setup with attribution cookies
present.
- Sign up using the Google OAuth button.
- Confirm the created account has
`internal_attributes['marketing_attribution']` with `first_touch` and
`last_touch`.
- Confirm existing Google OAuth login still redirects normally.
Validation run locally:
- `bundle exec rspec
spec/enterprise/controllers/enterprise/devise_overrides/google_oauth_attribution_spec.rb`
- `bundle exec rspec
spec/controllers/devise/omniauth_callbacks_controller_spec.rb
spec/enterprise/controllers/enterprise/devise_overrides/google_oauth_attribution_spec.rb`
- `bundle exec rubocop
enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb
spec/enterprise/controllers/enterprise/devise_overrides/google_oauth_attribution_spec.rb`
---
.../omniauth_callbacks_controller.rb | 13 +++++
.../google_oauth_attribution_spec.rb | 53 +++++++++++++++++++
2 files changed, 66 insertions(+)
create mode 100644 spec/enterprise/controllers/enterprise/devise_overrides/google_oauth_attribution_spec.rb
diff --git a/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb b/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb
index 3e2713dd7..0b1328c0d 100644
--- a/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb
+++ b/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb
@@ -29,6 +29,19 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
private
+ def create_account_for_user
+ super
+ record_marketing_attribution
+ end
+
+ def record_marketing_attribution
+ return if @account.blank?
+
+ Internal::Accounts::MarketingAttributionService.new(account: @account, cookies: cookies).perform
+ rescue StandardError => e
+ ChatwootExceptionTracker.new(e).capture_exception
+ end
+
def handle_saml_auth
account_id = extract_saml_account_id
relay_state = saml_relay_state
diff --git a/spec/enterprise/controllers/enterprise/devise_overrides/google_oauth_attribution_spec.rb b/spec/enterprise/controllers/enterprise/devise_overrides/google_oauth_attribution_spec.rb
new file mode 100644
index 000000000..948e9af96
--- /dev/null
+++ b/spec/enterprise/controllers/enterprise/devise_overrides/google_oauth_attribution_spec.rb
@@ -0,0 +1,53 @@
+require 'rails_helper'
+require 'base64'
+
+RSpec.describe 'Enterprise Google OAuth attribution', type: :request do
+ let(:email_validation_service) { instance_double(Account::SignUpEmailValidationService) }
+ let(:email) { 'oauth-attribution@example.com' }
+ let(:account_builder) { double }
+ let(:account) { create(:account) }
+ let(:first_touch_cookie) { encoded_cookie('source' => 'reddit', 'source_type' => 'paid_social') }
+ let(:last_touch_cookie) { encoded_cookie('source' => 'github', 'source_type' => 'referral') }
+
+ before do
+ allow(ChatwootApp).to receive(:enterprise?).and_return(true)
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ allow(Account::SignUpEmailValidationService).to receive(:new).and_return(email_validation_service)
+ allow(email_validation_service).to receive(:perform).and_return(true)
+ allow(AccountBuilder).to receive(:new).and_return(account_builder)
+ allow(account_builder).to receive(:perform) do
+ [create(:user, email: email, account: account), account]
+ end
+
+ OmniAuth.config.test_mode = true
+ OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new(
+ provider: 'google',
+ uid: '123545',
+ info: {
+ name: 'OAuth Attribution',
+ email: email,
+ image: 'https://example.com/image.jpg'
+ }
+ )
+ end
+
+ it 'records marketing attribution for Google OAuth signups' do
+ cookies[Internal::Accounts::MarketingAttributionService::FIRST_TOUCH_COOKIE] = first_touch_cookie
+ cookies[Internal::Accounts::MarketingAttributionService::LAST_TOUCH_COOKIE] = last_touch_cookie
+
+ with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', FRONTEND_URL: 'http://www.example.com' do
+ get '/omniauth/google_oauth2/callback'
+ follow_redirect!
+ end
+
+ attribution = account.reload.internal_attributes['marketing_attribution']
+
+ expect(attribution['captured_from']).to eq('cookie')
+ expect(attribution['first_touch']).to include('source' => 'reddit', 'source_type' => 'paid_social')
+ expect(attribution['last_touch']).to include('source' => 'github', 'source_type' => 'referral')
+ end
+
+ def encoded_cookie(payload)
+ Base64.urlsafe_encode64(payload.to_json, padding: false)
+ end
+end
From 961ec86ba9c51adadb8ea5a4940cfae47eb493b7 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Tue, 23 Jun 2026 11:35:52 +0530
Subject: [PATCH 016/134] chore: support custom variables in auto-resolution
message (#14782)
# Pull Request Template
## Description
This PR adds support for custom variables in auto-resolution messages
within Conversation Workflows.
Agents can insert variables (e.g. `{{contact.name}}`) into the custom
auto-resolution message using the variable picker by typing `{{`. These
variables are resolved with actual conversation data when the
auto-resolution message is sent.
The field uses the existing editor with the formatting toolbar hidden,
since auto-resolution messages are delivered across all channels,
including SMS, where formatting isn't supported. Existing message
content is preserved as-is, and typing `{{` opens the variable picker
for quick insertion.
Fixes
https://linear.app/chatwoot/issue/CW-7369/support-custom-variables-in-conversations-workflow-text-fields
## Type of change
- [x] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
### Screenshots
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
app/javascript/dashboard/constants/editor.js | 5 +++++
.../dashboard/settings/account/components/AutoResolve.vue | 8 ++++++--
2 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js
index 5f4fe0e03..cb0996136 100644
--- a/app/javascript/dashboard/constants/editor.js
+++ b/app/javascript/dashboard/constants/editor.js
@@ -163,6 +163,11 @@ export const FORMATTING = {
nodes: [],
menu: [],
},
+ 'Context::NoToolbar': {
+ marks: ['strong', 'em', 'link'],
+ nodes: ['bulletList', 'orderedList'],
+ menu: [],
+ },
};
// Editor menu options for Full Editor
diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/components/AutoResolve.vue b/app/javascript/dashboard/routes/dashboard/settings/account/components/AutoResolve.vue
index 034f40d35..54eafa59e 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/account/components/AutoResolve.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/account/components/AutoResolve.vue
@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import WithLabel from 'v3/components/Form/WithLabel.vue';
-import TextArea from 'next/textarea/TextArea.vue';
+import Editor from 'next/Editor/Editor.vue';
import Switch from 'next/switch/Switch.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import DurationInput from 'next/input/DurationInput.vue';
@@ -162,9 +162,13 @@ const toggleAutoResolve = async () => {
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
>
-