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 class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
# assigns agent/team to a conversation # assigns agent/team to a conversation
def create def create
if params.key?(:assignee_id) || agent_bot_assignment? if params.key?(:assignee_id) || ai_assignment?
set_agent set_agent
elsif params.key?(:team_id) elsif params.key?(:team_id)
set_team 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 } render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: resource }
when AgentBot when AgentBot
render partial: 'api/v1/models/agent_bot_slim', formats: [:json], locals: { resource: resource } 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 else
render json: nil render json: nil
end end
@@ -39,7 +41,7 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
render json: @team render json: @team
end end
def agent_bot_assignment? def ai_assignment?
params[:assignee_type].to_s == 'AgentBot' params[:assignee_type].to_s.in?(%w[AgentBot CaptainAssistant])
end end
end end
+9 -5
View File
@@ -21,6 +21,7 @@
# updated_at :datetime not null # updated_at :datetime not null
# account_id :integer not null # account_id :integer not null
# assignee_agent_bot_id :bigint # assignee_agent_bot_id :bigint
# assignee_captain_assistant_id :bigint
# assignee_id :integer # assignee_id :integer
# campaign_id :bigint # campaign_id :bigint
# contact_id :bigint # contact_id :bigint
@@ -66,7 +67,7 @@ class Conversation < ApplicationRecord
validates :inbox_id, presence: true validates :inbox_id, presence: true
validates :contact_id, presence: true validates :contact_id, presence: true
before_validation :validate_additional_attributes 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 :additional_attributes, jsonb_attributes_length: true
validates :custom_attributes, jsonb_attributes_length: true validates :custom_attributes, jsonb_attributes_length: true
validates :uuid, uniqueness: true validates :uuid, uniqueness: true
@@ -104,6 +105,7 @@ class Conversation < ApplicationRecord
belongs_to :inbox belongs_to :inbox
belongs_to :assignee, class_name: 'User', optional: true, inverse_of: :assigned_conversations 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_agent_bot, class_name: 'AgentBot', optional: true
belongs_to :assignee_captain_assistant, class_name: 'Captain::Assistant', optional: true
belongs_to :contact belongs_to :contact
belongs_to :contact_inbox belongs_to :contact_inbox
belongs_to :team, optional: true belongs_to :team, optional: true
@@ -195,6 +197,7 @@ class Conversation < ApplicationRecord
# Virtual attribute till we switch completely to polymorphic assignee # Virtual attribute till we switch completely to polymorphic assignee
def assignee_type def assignee_type
return 'CaptainAssistant' if assignee_captain_assistant_id.present?
return 'AgentBot' if assignee_agent_bot_id.present? return 'AgentBot' if assignee_agent_bot_id.present?
return 'User' if assignee_id.present? return 'User' if assignee_id.present?
@@ -202,7 +205,7 @@ class Conversation < ApplicationRecord
end end
def assigned_entity def assigned_entity
assignee_agent_bot || assignee assignee_captain_assistant || assignee_agent_bot || assignee
end end
def tweet? def tweet?
@@ -271,10 +274,11 @@ class Conversation < ApplicationRecord
self.additional_attributes = {} unless additional_attributes.is_a?(Hash) self.additional_attributes = {} unless additional_attributes.is_a?(Hash)
end end
def reset_agent_bot_when_assignee_present def reset_ai_assignees_when_assignee_present
return if assignee_id.blank? return if assignee_id.blank?
self.assignee_agent_bot_id = nil self.assignee_agent_bot_id = nil
self.assignee_captain_assistant_id = nil
end end
def determine_conversation_status def determine_conversation_status
@@ -308,8 +312,8 @@ class Conversation < ApplicationRecord
end end
def list_of_keys def list_of_keys
%w[team_id assignee_id assignee_agent_bot_id status snoozed_until custom_attributes label_list waiting_since %w[team_id assignee_id assignee_agent_bot_id assignee_captain_assistant_id status snoozed_until custom_attributes label_list
first_reply_created_at priority] waiting_since first_reply_created_at priority]
end end
def allowed_keys? def allowed_keys?
@@ -6,7 +6,10 @@ class Conversations::AssignmentService
end end
def perform 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 end
private private
@@ -14,8 +17,11 @@ class Conversations::AssignmentService
attr_reader :conversation, :assignee_id, :assignee_type attr_reader :conversation, :assignee_id, :assignee_type
def assign_agent def assign_agent
captain_owned = conversation.assignee_captain_assistant_id.present?
conversation.assignee = assignee conversation.assignee = assignee
conversation.assignee_agent_bot = nil conversation.assignee_agent_bot = nil
conversation.assignee_captain_assistant = nil
conversation.status = :open if assignee.present? && captain_owned && conversation.pending?
conversation.save! conversation.save!
assignee assignee
end end
@@ -25,10 +31,22 @@ class Conversations::AssignmentService
conversation.assignee = nil conversation.assignee = nil
conversation.assignee_agent_bot = agent_bot conversation.assignee_agent_bot = agent_bot
conversation.assignee_captain_assistant = nil
conversation.save! conversation.save!
agent_bot agent_bot
end 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 def assignee
@assignee ||= conversation.account.users.find_by(id: assignee_id) @assignee ||= conversation.account.users.find_by(id: assignee_id)
end end
@@ -37,7 +55,15 @@ class Conversations::AssignmentService
@agent_bot ||= AgentBot.accessible_to(conversation.account).find_by(id: assignee_id) @agent_bot ||= AgentBot.accessible_to(conversation.account).find_by(id: assignee_id)
end end
def captain_assistant
@captain_assistant ||= Captain::Assistant.for_account(conversation.account_id).find_by(id: assignee_id)
end
def agent_bot_assignment? def agent_bot_assignment?
assignee_type.to_s == 'AgentBot' assignee_type.to_s == 'AgentBot'
end end
def captain_assistant_assignment?
assignee_type.to_s == 'CaptainAssistant'
end
end end
@@ -7,7 +7,12 @@ json.meta do
json.partial! 'api/v1/models/contact', formats: [:json], resource: conversation.contact json.partial! 'api/v1/models/contact', formats: [:json], resource: conversation.contact
end end
json.channel conversation.inbox.try(:channel_type) 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.assignee do
json.partial! 'api/v1/models/agent_bot_slim', formats: [:json], resource: conversation.assigned_entity json.partial! 'api/v1/models/agent_bot_slim', formats: [:json], resource: conversation.assigned_entity
end 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. # 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 # These extensions should be enabled to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
enable_extension "pg_trgm" enable_extension "pg_trgm"
@@ -726,11 +726,13 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
t.datetime "waiting_since" t.datetime "waiting_since"
t.text "cached_label_list" t.text "cached_label_list"
t.bigint "assignee_agent_bot_id" 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", "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", "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", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx"
t.index ["account_id"], name: "index_conversations_on_account_id" 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_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 ["campaign_id"], name: "index_conversations_on_campaign_id"
t.index ["contact_id"], name: "index_conversations_on_contact_id" t.index ["contact_id"], name: "index_conversations_on_contact_id"
t.index ["contact_inbox_id"], name: "index_conversations_on_contact_inbox_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 context 'when it is an authenticated user with access to the inbox' do
let(:agent) { create(:user, account: account, role: :agent) } let(:agent) { create(:user, account: account, role: :agent) }
let(:agent_bot) { create(:agent_bot, account: account) } let(:agent_bot) { create(:agent_bot, account: account) }
let(:captain_assistant) { create(:captain_assistant, account: account) }
let(:team) { create(:team, account: account) } let(:team) { create(:team, account: account) }
before do before do
@@ -74,6 +75,27 @@ RSpec.describe 'Conversation Assignment API', type: :request do
expect(conversation.assignee).to be_nil expect(conversation.assignee).to be_nil
end 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 it 'assigns a team to the conversation' do
team_member = create(:user, account: account, role: :agent, auto_offline: false) team_member = create(:user, account: account, role: :agent, auto_offline: false)
create(:inbox_member, inbox: conversation.inbox, user: team_member) create(:inbox_member, inbox: conversation.inbox, user: team_member)
@@ -4,35 +4,39 @@ describe Conversations::AssignmentService do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:agent) { create(:user, account: account) } let(:agent) { create(:user, account: account) }
let(:agent_bot) { create(:agent_bot, account: account) } let(:agent_bot) { create(:agent_bot, account: account) }
let(:captain_assistant) { create(:captain_assistant, account: account) }
let(:conversation) { create(:conversation, account: account) } let(:conversation) { create(:conversation, account: account) }
describe '#perform' do describe '#perform' do
context 'when assignee_id is blank' do context 'when assignee_id is blank' do
before 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 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 described_class.new(conversation: conversation, assignee_id: nil).perform
conversation.reload conversation.reload
expect(conversation.assignee_id).to be_nil expect(conversation.assignee_id).to be_nil
expect(conversation.assignee_agent_bot_id).to be_nil expect(conversation.assignee_agent_bot_id).to be_nil
expect(conversation.assignee_captain_assistant_id).to be_nil
end end
end end
context 'when assigning a user' do context 'when assigning a user' do
before do before do
conversation.update!(assignee_agent_bot: agent_bot, assignee: nil) conversation.update!(assignee_captain_assistant: captain_assistant, assignee: nil, status: :pending)
end 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 result = described_class.new(conversation: conversation, assignee_id: agent.id).perform
conversation.reload conversation.reload
expect(result).to eq(agent) expect(result).to eq(agent)
expect(conversation.assignee_id).to eq(agent.id) expect(conversation.assignee_id).to eq(agent.id)
expect(conversation.assignee_agent_bot_id).to be_nil 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
end end
@@ -45,8 +49,8 @@ describe Conversations::AssignmentService do
) )
end end
it 'sets the agent bot and clears human assignee' do it 'sets the agent bot and clears other assignees' do
conversation.update!(assignee: agent, assignee_agent_bot: nil) conversation.update!(assignee_agent_bot: nil, assignee_captain_assistant: captain_assistant)
result = service.perform result = service.perform
@@ -54,6 +58,30 @@ describe Conversations::AssignmentService do
expect(result).to eq(agent_bot) expect(result).to eq(agent_bot)
expect(conversation.assignee_agent_bot_id).to eq(agent_bot.id) expect(conversation.assignee_agent_bot_id).to eq(agent_bot.id)
expect(conversation.assignee_id).to be_nil 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 end
end end