feat(captain): allow agents to report Captain messages (#14799)
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. <img width="636" height="542" alt="Screenshot 2026-06-20 at 9 15 56 AM" src="https://github.com/user-attachments/assets/afaa233d-6bd6-455a-8a33-a3796a3e3ef6" /> <img width="580" height="502" alt="Screenshot 2026-06-20 at 9 16 03 AM" src="https://github.com/user-attachments/assets/2d220d99-98cc-4c5e-a325-778ceb4f7bc9" /> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
dfd656a7d9
commit
6a262287d2
@@ -0,0 +1,9 @@
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class MessageReports extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/message_reports', { accountScoped: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default new MessageReports();
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -243,6 +249,16 @@ export default {
|
||||
variant="icon"
|
||||
@click.stop="showCannedResponseModal"
|
||||
/>
|
||||
<hr v-if="enabledOptions['report']" />
|
||||
<MenuItem
|
||||
v-if="enabledOptions['report']"
|
||||
:option="{
|
||||
icon: 'warning',
|
||||
label: $t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.LABEL'),
|
||||
}"
|
||||
variant="icon"
|
||||
@click.stop="openReportDialog"
|
||||
/>
|
||||
<hr v-if="enabledOptions['delete']" />
|
||||
<MenuItem
|
||||
v-if="enabledOptions['delete']"
|
||||
@@ -255,6 +271,11 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
<ReportCaptainMessageDialog
|
||||
v-if="enabledOptions['report']"
|
||||
ref="reportDialog"
|
||||
:message-id="messageId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import MessageReportsAPI from 'dashboard/api/captain/messageReports';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Select from 'dashboard/components-next/select/Select.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
|
||||
const props = defineProps({
|
||||
messageId: { type: [Number, String], required: true },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const dialogRef = ref(null);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const REPORT_REASONS = [
|
||||
'incorrect_information',
|
||||
'inappropriate_response',
|
||||
'incomplete_response',
|
||||
'outdated_information',
|
||||
'other',
|
||||
];
|
||||
|
||||
const reasonOptions = computed(() =>
|
||||
REPORT_REASONS.map(value => ({
|
||||
value,
|
||||
label: t(`CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.REASONS.${value}`),
|
||||
}))
|
||||
);
|
||||
|
||||
const form = reactive({ reportReason: '', description: '' });
|
||||
|
||||
const isFormInvalid = computed(() => !form.reportReason);
|
||||
|
||||
const resetForm = () => {
|
||||
form.reportReason = '';
|
||||
form.description = '';
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
resetForm();
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (isFormInvalid.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await MessageReportsAPI.create({
|
||||
message_id: props.messageId,
|
||||
report_reason: form.reportReason,
|
||||
description: form.description.trim() || null,
|
||||
});
|
||||
useAlert(t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.SUCCESS'));
|
||||
close();
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.ERROR'));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.TITLE')"
|
||||
:description="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION')"
|
||||
:confirm-button-label="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.SUBMIT')"
|
||||
:is-loading="isLoading"
|
||||
:disable-confirm-button="isFormInvalid"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.PROBLEM_TYPE') }}
|
||||
</label>
|
||||
<Select
|
||||
v-model="form.reportReason"
|
||||
class="!w-full [&>select]:w-full"
|
||||
:options="reasonOptions"
|
||||
:placeholder="
|
||||
t(
|
||||
'CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.PROBLEM_TYPE_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION_LABEL') }}
|
||||
</label>
|
||||
<TextArea
|
||||
v-model="form.description"
|
||||
class="w-full"
|
||||
:placeholder="
|
||||
t(
|
||||
'CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:max-length="500"
|
||||
show-character-count
|
||||
auto-height
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
+16
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+117
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user