Compare commits

...
Author SHA1 Message Date
Sojan Jose da0a2661ac feat: add captain conversation ownership 2026-06-28 20:30:24 -07:00
9 changed files with 115 additions and 17 deletions
@@ -1,7 +1,7 @@
class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
# assigns agent/team to a conversation
def create
if params.key?(:assignee_id) || agent_bot_assignment?
if params.key?(:assignee_id) || ai_assignment?
set_agent
elsif params.key?(:team_id)
set_team
@@ -28,6 +28,8 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: resource }
when AgentBot
render partial: 'api/v1/models/agent_bot_slim', formats: [:json], locals: { resource: resource }
when Captain::Assistant
render partial: 'api/v1/models/captain_assistant_slim', formats: [:json], locals: { resource: resource }
else
render json: nil
end
@@ -39,7 +41,7 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
render json: @team
end
def agent_bot_assignment?
params[:assignee_type].to_s == 'AgentBot'
def ai_assignment?
params[:assignee_type].to_s.in?(%w[AgentBot CaptainAssistant])
end
end
+9 -5
View File
@@ -21,6 +21,7 @@
# updated_at :datetime not null
# account_id :integer not null
# assignee_agent_bot_id :bigint
# assignee_captain_assistant_id :bigint
# assignee_id :integer
# campaign_id :bigint
# contact_id :bigint
@@ -66,7 +67,7 @@ class Conversation < ApplicationRecord
validates :inbox_id, presence: true
validates :contact_id, presence: true
before_validation :validate_additional_attributes
before_validation :reset_agent_bot_when_assignee_present
before_validation :reset_ai_assignees_when_assignee_present
validates :additional_attributes, jsonb_attributes_length: true
validates :custom_attributes, jsonb_attributes_length: true
validates :uuid, uniqueness: true
@@ -104,6 +105,7 @@ class Conversation < ApplicationRecord
belongs_to :inbox
belongs_to :assignee, class_name: 'User', optional: true, inverse_of: :assigned_conversations
belongs_to :assignee_agent_bot, class_name: 'AgentBot', optional: true
belongs_to :assignee_captain_assistant, class_name: 'Captain::Assistant', optional: true
belongs_to :contact
belongs_to :contact_inbox
belongs_to :team, optional: true
@@ -195,6 +197,7 @@ class Conversation < ApplicationRecord
# Virtual attribute till we switch completely to polymorphic assignee
def assignee_type
return 'CaptainAssistant' if assignee_captain_assistant_id.present?
return 'AgentBot' if assignee_agent_bot_id.present?
return 'User' if assignee_id.present?
@@ -202,7 +205,7 @@ class Conversation < ApplicationRecord
end
def assigned_entity
assignee_agent_bot || assignee
assignee_captain_assistant || assignee_agent_bot || assignee
end
def tweet?
@@ -271,10 +274,11 @@ class Conversation < ApplicationRecord
self.additional_attributes = {} unless additional_attributes.is_a?(Hash)
end
def reset_agent_bot_when_assignee_present
def reset_ai_assignees_when_assignee_present
return if assignee_id.blank?
self.assignee_agent_bot_id = nil
self.assignee_captain_assistant_id = nil
end
def determine_conversation_status
@@ -308,8 +312,8 @@ class Conversation < ApplicationRecord
end
def list_of_keys
%w[team_id assignee_id assignee_agent_bot_id status snoozed_until custom_attributes label_list waiting_since
first_reply_created_at priority]
%w[team_id assignee_id assignee_agent_bot_id assignee_captain_assistant_id status snoozed_until custom_attributes label_list
waiting_since first_reply_created_at priority]
end
def allowed_keys?
@@ -6,7 +6,10 @@ class Conversations::AssignmentService
end
def perform
agent_bot_assignment? ? assign_agent_bot : assign_agent
return assign_agent_bot if agent_bot_assignment?
return assign_captain_assistant if captain_assistant_assignment?
assign_agent
end
private
@@ -14,8 +17,11 @@ class Conversations::AssignmentService
attr_reader :conversation, :assignee_id, :assignee_type
def assign_agent
captain_owned = conversation.assignee_captain_assistant_id.present?
conversation.assignee = assignee
conversation.assignee_agent_bot = nil
conversation.assignee_captain_assistant = nil
conversation.status = :open if assignee.present? && captain_owned && conversation.pending?
conversation.save!
assignee
end
@@ -25,10 +31,22 @@ class Conversations::AssignmentService
conversation.assignee = nil
conversation.assignee_agent_bot = agent_bot
conversation.assignee_captain_assistant = nil
conversation.save!
agent_bot
end
def assign_captain_assistant
return unless captain_assistant
conversation.assignee = nil
conversation.assignee_agent_bot = nil
conversation.assignee_captain_assistant = captain_assistant
conversation.status = :pending
conversation.save!
captain_assistant
end
def assignee
@assignee ||= conversation.account.users.find_by(id: assignee_id)
end
@@ -37,7 +55,15 @@ class Conversations::AssignmentService
@agent_bot ||= AgentBot.accessible_to(conversation.account).find_by(id: assignee_id)
end
def captain_assistant
@captain_assistant ||= Captain::Assistant.for_account(conversation.account_id).find_by(id: assignee_id)
end
def agent_bot_assignment?
assignee_type.to_s == 'AgentBot'
end
def captain_assistant_assignment?
assignee_type.to_s == 'CaptainAssistant'
end
end
@@ -7,7 +7,12 @@ json.meta do
json.partial! 'api/v1/models/contact', formats: [:json], resource: conversation.contact
end
json.channel conversation.inbox.try(:channel_type)
if conversation.assigned_entity.is_a?(AgentBot)
if conversation.assignee_type == 'CaptainAssistant'
json.assignee do
json.partial! 'api/v1/models/captain_assistant_slim', formats: [:json], resource: conversation.assigned_entity
end
json.assignee_type 'CaptainAssistant'
elsif conversation.assigned_entity.is_a?(AgentBot)
json.assignee do
json.partial! 'api/v1/models/agent_bot_slim', formats: [:json], resource: conversation.assigned_entity
end
@@ -0,0 +1,4 @@
json.id resource.id
json.name resource.name
json.description resource.description
json.thumbnail resource.push_event_data[:avatar_url]
@@ -0,0 +1,5 @@
class AddAssigneeCaptainAssistantToConversations < ActiveRecord::Migration[7.1]
def change
add_reference :conversations, :assignee_captain_assistant, type: :bigint, index: true
end
end
+3 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
ActiveRecord::Schema[7.1].define(version: 2026_06_28_000000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -726,11 +726,13 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
t.datetime "waiting_since"
t.text "cached_label_list"
t.bigint "assignee_agent_bot_id"
t.bigint "assignee_captain_assistant_id"
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id"
t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx"
t.index ["account_id"], name: "index_conversations_on_account_id"
t.index ["assignee_id", "account_id"], name: "index_conversations_on_assignee_id_and_account_id"
t.index ["assignee_captain_assistant_id"], name: "index_conversations_on_assignee_captain_assistant_id"
t.index ["campaign_id"], name: "index_conversations_on_campaign_id"
t.index ["contact_id"], name: "index_conversations_on_contact_id"
t.index ["contact_inbox_id"], name: "index_conversations_on_contact_inbox_id"
@@ -37,6 +37,7 @@ RSpec.describe 'Conversation Assignment API', type: :request do
context 'when it is an authenticated user with access to the inbox' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:agent_bot) { create(:agent_bot, account: account) }
let(:captain_assistant) { create(:captain_assistant, account: account) }
let(:team) { create(:team, account: account) }
before do
@@ -74,6 +75,27 @@ RSpec.describe 'Conversation Assignment API', type: :request do
expect(conversation.assignee).to be_nil
end
it 'assigns a Captain assistant to the conversation' do
params = { assignee_id: captain_assistant.id, assignee_type: 'CaptainAssistant' }
expect(Conversations::AssignmentService).to receive(:new)
.with(hash_including(conversation: conversation, assignee_id: captain_assistant.id, assignee_type: 'CaptainAssistant'))
.and_call_original
post api_v1_account_conversation_assignments_url(account_id: account.id, conversation_id: conversation.display_id),
params: params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['name']).to eq(captain_assistant.name)
conversation.reload
expect(conversation.assignee_captain_assistant).to eq(captain_assistant)
expect(conversation.assignee_agent_bot).to be_nil
expect(conversation.assignee).to be_nil
expect(conversation.status).to eq('pending')
end
it 'assigns a team to the conversation' do
team_member = create(:user, account: account, role: :agent, auto_offline: false)
create(:inbox_member, inbox: conversation.inbox, user: team_member)
@@ -4,35 +4,39 @@ describe Conversations::AssignmentService do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account) }
let(:agent_bot) { create(:agent_bot, account: account) }
let(:captain_assistant) { create(:captain_assistant, account: account) }
let(:conversation) { create(:conversation, account: account) }
describe '#perform' do
context 'when assignee_id is blank' do
before do
conversation.update!(assignee: agent, assignee_agent_bot: agent_bot)
conversation.update!(assignee_agent_bot: agent_bot, assignee_captain_assistant: captain_assistant)
end
it 'clears both human and bot assignees' do
it 'clears human and AI assignees' do
described_class.new(conversation: conversation, assignee_id: nil).perform
conversation.reload
expect(conversation.assignee_id).to be_nil
expect(conversation.assignee_agent_bot_id).to be_nil
expect(conversation.assignee_captain_assistant_id).to be_nil
end
end
context 'when assigning a user' do
before do
conversation.update!(assignee_agent_bot: agent_bot, assignee: nil)
conversation.update!(assignee_captain_assistant: captain_assistant, assignee: nil, status: :pending)
end
it 'sets the agent and clears agent bot' do
it 'sets the agent and clears AI ownership' do
result = described_class.new(conversation: conversation, assignee_id: agent.id).perform
conversation.reload
expect(result).to eq(agent)
expect(conversation.assignee_id).to eq(agent.id)
expect(conversation.assignee_agent_bot_id).to be_nil
expect(conversation.assignee_captain_assistant_id).to be_nil
expect(conversation.status).to eq('open')
end
end
@@ -45,8 +49,8 @@ describe Conversations::AssignmentService do
)
end
it 'sets the agent bot and clears human assignee' do
conversation.update!(assignee: agent, assignee_agent_bot: nil)
it 'sets the agent bot and clears other assignees' do
conversation.update!(assignee_agent_bot: nil, assignee_captain_assistant: captain_assistant)
result = service.perform
@@ -54,6 +58,30 @@ describe Conversations::AssignmentService do
expect(result).to eq(agent_bot)
expect(conversation.assignee_agent_bot_id).to eq(agent_bot.id)
expect(conversation.assignee_id).to be_nil
expect(conversation.assignee_captain_assistant_id).to be_nil
end
end
context 'when assigning a Captain assistant' do
let(:service) do
described_class.new(
conversation: conversation,
assignee_id: captain_assistant.id,
assignee_type: 'CaptainAssistant'
)
end
it 'sets the Captain assistant, clears other assignees, and marks pending' do
conversation.update!(assignee_agent_bot: agent_bot, status: :resolved)
result = service.perform
conversation.reload
expect(result).to eq(captain_assistant)
expect(conversation.assignee_captain_assistant_id).to eq(captain_assistant.id)
expect(conversation.assignee_agent_bot_id).to be_nil
expect(conversation.assignee_id).to be_nil
expect(conversation.status).to eq('pending')
end
end
end