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