diff --git a/db/migrate/20260731140853_create_conversation_outcomes.rb b/db/migrate/20260731140853_create_conversation_outcomes.rb new file mode 100644 index 000000000..ddf846ac9 --- /dev/null +++ b/db/migrate/20260731140853_create_conversation_outcomes.rb @@ -0,0 +1,42 @@ +class CreateConversationOutcomes < ActiveRecord::Migration[7.1] + def change + create_table :conversation_outcomes do |t| + t.references :account, null: false + t.references :assistant, null: false + t.references :conversation, null: false + t.references :inbox, null: false + + t.datetime :first_captain_reply_at + t.datetime :last_captain_reply_at + t.integer :captain_reply_count, null: false, default: 0 + t.datetime :first_human_reply_at + + t.datetime :handoff_at + t.string :handoff_reason_category + + t.datetime :resolved_at + t.datetime :last_reopened_at + t.integer :reopen_count, null: false, default: 0 + + t.integer :csat_rating + t.datetime :csat_received_at + + t.timestamps + end + + add_reporting_indexes + end + + private + + def add_reporting_indexes + add_index :conversation_outcomes, [:account_id, :assistant_id, :conversation_id], + unique: true, name: 'idx_conversation_outcomes_unique_conversation' + add_index :conversation_outcomes, [:account_id, :assistant_id, :resolved_at], + name: 'idx_conversation_outcomes_on_assistant_resolved_at' + add_index :conversation_outcomes, [:account_id, :assistant_id, :handoff_at], + name: 'idx_conversation_outcomes_on_assistant_handoff_at' + add_index :conversation_outcomes, [:account_id, :assistant_id, :created_at], + name: 'idx_conversation_outcomes_on_assistant_created_at' + end +end diff --git a/db/schema.rb b/db/schema.rb index 75bd5480c..682d2dc76 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_07_29_051500) do +ActiveRecord::Schema[7.1].define(version: 2026_07_31_140853) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -775,6 +775,34 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_29_051500) do t.index ["phone_number", "account_id"], name: "index_contacts_on_phone_number_and_account_id" end + create_table "conversation_outcomes", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "assistant_id", null: false + t.bigint "conversation_id", null: false + t.bigint "inbox_id", null: false + t.datetime "first_captain_reply_at" + t.datetime "last_captain_reply_at" + t.integer "captain_reply_count", default: 0, null: false + t.datetime "first_human_reply_at" + t.datetime "handoff_at" + t.string "handoff_reason_category" + t.datetime "resolved_at" + t.datetime "last_reopened_at" + t.integer "reopen_count", default: 0, null: false + t.integer "csat_rating" + t.datetime "csat_received_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "assistant_id", "conversation_id"], name: "idx_conversation_outcomes_unique_conversation", unique: true + t.index ["account_id", "assistant_id", "created_at"], name: "idx_conversation_outcomes_on_assistant_created_at" + t.index ["account_id", "assistant_id", "handoff_at"], name: "idx_conversation_outcomes_on_assistant_handoff_at" + t.index ["account_id", "assistant_id", "resolved_at"], name: "idx_conversation_outcomes_on_assistant_resolved_at" + t.index ["account_id"], name: "index_conversation_outcomes_on_account_id" + t.index ["assistant_id"], name: "index_conversation_outcomes_on_assistant_id" + t.index ["conversation_id"], name: "index_conversation_outcomes_on_conversation_id" + t.index ["inbox_id"], name: "index_conversation_outcomes_on_inbox_id" + end + create_table "conversation_participants", force: :cascade do |t| t.bigint "account_id", null: false t.bigint "user_id", null: false diff --git a/enterprise/app/models/conversation_outcome.rb b/enterprise/app/models/conversation_outcome.rb new file mode 100644 index 000000000..f79fc00fc --- /dev/null +++ b/enterprise/app/models/conversation_outcome.rb @@ -0,0 +1,67 @@ +# == Schema Information +# +# Table name: conversation_outcomes +# +# id :bigint not null, primary key +# captain_reply_count :integer default(0), not null +# csat_rating :integer +# csat_received_at :datetime +# first_captain_reply_at :datetime +# first_human_reply_at :datetime +# handoff_at :datetime +# handoff_reason_category :string +# last_captain_reply_at :datetime +# last_reopened_at :datetime +# reopen_count :integer default(0), not null +# resolved_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# assistant_id :bigint not null +# conversation_id :bigint not null +# inbox_id :bigint not null +# +# Indexes +# +# idx_conversation_outcomes_on_assistant_created_at (account_id,assistant_id,created_at) +# idx_conversation_outcomes_on_assistant_handoff_at (account_id,assistant_id,handoff_at) +# idx_conversation_outcomes_on_assistant_resolved_at (account_id,assistant_id,resolved_at) +# idx_conversation_outcomes_unique_conversation (account_id,assistant_id,conversation_id) UNIQUE +# index_conversation_outcomes_on_account_id (account_id) +# index_conversation_outcomes_on_assistant_id (assistant_id) +# index_conversation_outcomes_on_conversation_id (conversation_id) +# index_conversation_outcomes_on_inbox_id (inbox_id) +# +class ConversationOutcome < ApplicationRecord + HANDOFF_REASON_CATEGORIES = %w[ + customer_request + missing_knowledge + unsupported_request + policy_restriction + tool_failure + pending_clarification + usage_limit + ].freeze + + belongs_to :account + belongs_to :assistant, class_name: 'Captain::Assistant' + belongs_to :conversation, class_name: '::Conversation' + belongs_to :inbox + + enum :handoff_reason_category, + HANDOFF_REASON_CATEGORIES.index_by(&:itself), + prefix: :handoff_reason, + validate: { allow_nil: true } + + validates :conversation_id, uniqueness: { scope: [:account_id, :assistant_id] } + validate :associations_must_belong_to_account + + private + + def associations_must_belong_to_account + [:assistant, :conversation, :inbox].each do |association| + record = public_send(association) + errors.add(association, 'must belong to the same account') if record && record.account_id != account_id + end + end +end diff --git a/spec/enterprise/models/conversation_outcome_spec.rb b/spec/enterprise/models/conversation_outcome_spec.rb new file mode 100644 index 000000000..4183490c6 --- /dev/null +++ b/spec/enterprise/models/conversation_outcome_spec.rb @@ -0,0 +1,69 @@ +require 'rails_helper' + +RSpec.describe ConversationOutcome, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:account) } + it { is_expected.to belong_to(:assistant).class_name('Captain::Assistant') } + it { is_expected.to belong_to(:conversation).class_name('::Conversation') } + it { is_expected.to belong_to(:inbox) } + end + + describe 'enums' do + it { + expect(subject).to define_enum_for(:handoff_reason_category) + .with_values(described_class::HANDOFF_REASON_CATEGORIES.index_by(&:itself)) + .backed_by_column_of_type(:string) + .with_prefix(:handoff_reason) + } + end + + describe 'validations' do + it 'allows a handoff without a reason category for unclassified handoffs' do + outcome = build(:conversation_outcome, handoff_at: Time.current) + + expect(outcome).to be_valid + end + + it 'rejects an assistant from another account' do + outcome = build(:conversation_outcome) + outcome.assistant = create(:captain_assistant, account: create(:account)) + + expect(outcome).not_to be_valid + expect(outcome.errors[:assistant]).to be_present + end + + it 'rejects a conversation from another account' do + outcome = build(:conversation_outcome) + outcome.conversation = create(:conversation, account: create(:account)) + + expect(outcome).not_to be_valid + expect(outcome.errors[:conversation]).to be_present + end + + it 'rejects an inbox from another account' do + outcome = build(:conversation_outcome) + outcome.inbox = create(:inbox, account: create(:account)) + + expect(outcome).not_to be_valid + expect(outcome.errors[:inbox]).to be_present + end + + it 'requires unique conversations per assistant and account' do + existing = create(:conversation_outcome) + duplicate = build( + :conversation_outcome, + account: existing.account, + assistant: existing.assistant, + conversation: existing.conversation, + inbox: existing.inbox + ) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:conversation_id]).to be_present + end + end + + it 'builds a valid outcome' do + expect(build(:conversation_outcome)).to be_valid + end +end diff --git a/spec/factories/conversation_outcomes.rb b/spec/factories/conversation_outcomes.rb new file mode 100644 index 000000000..b560825aa --- /dev/null +++ b/spec/factories/conversation_outcomes.rb @@ -0,0 +1,11 @@ +FactoryBot.define do + factory :conversation_outcome do + account { create(:account) } + + after(:build) do |outcome| + outcome.assistant ||= create(:captain_assistant, account: outcome.account) + outcome.inbox ||= create(:inbox, account: outcome.account) + outcome.conversation ||= create(:conversation, account: outcome.account, inbox: outcome.inbox) + end + end +end