diff --git a/app/controllers/api/v1/accounts/conversations/assignments_controller.rb b/app/controllers/api/v1/accounts/conversations/assignments_controller.rb index 49806e97c..f61a06826 100644 --- a/app/controllers/api/v1/accounts/conversations/assignments_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/assignments_controller.rb @@ -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 diff --git a/app/models/conversation.rb b/app/models/conversation.rb index d9c06c4d8..ca656f130 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -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? diff --git a/app/services/conversations/assignment_service.rb b/app/services/conversations/assignment_service.rb index adca34f06..46b58001b 100644 --- a/app/services/conversations/assignment_service.rb +++ b/app/services/conversations/assignment_service.rb @@ -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 diff --git a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder index 4cb13f543..956d6bfd4 100644 --- a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder +++ b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder @@ -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 diff --git a/app/views/api/v1/models/_captain_assistant_slim.json.jbuilder b/app/views/api/v1/models/_captain_assistant_slim.json.jbuilder new file mode 100644 index 000000000..269bf2e2f --- /dev/null +++ b/app/views/api/v1/models/_captain_assistant_slim.json.jbuilder @@ -0,0 +1,4 @@ +json.id resource.id +json.name resource.name +json.description resource.description +json.thumbnail resource.push_event_data[:avatar_url] diff --git a/db/migrate/20260628000000_add_assignee_captain_assistant_to_conversations.rb b/db/migrate/20260628000000_add_assignee_captain_assistant_to_conversations.rb new file mode 100644 index 000000000..ea9007738 --- /dev/null +++ b/db/migrate/20260628000000_add_assignee_captain_assistant_to_conversations.rb @@ -0,0 +1,5 @@ +class AddAssigneeCaptainAssistantToConversations < ActiveRecord::Migration[7.1] + def change + add_reference :conversations, :assignee_captain_assistant, type: :bigint, index: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 05afea9a1..08e134309 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_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" diff --git a/spec/controllers/api/v1/accounts/conversations/assignments_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations/assignments_controller_spec.rb index 18c652c0a..fbf5f583a 100644 --- a/spec/controllers/api/v1/accounts/conversations/assignments_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations/assignments_controller_spec.rb @@ -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) diff --git a/spec/services/conversations/assignment_service_spec.rb b/spec/services/conversations/assignment_service_spec.rb index 899236e1d..ea34b2aea 100644 --- a/spec/services/conversations/assignment_service_spec.rb +++ b/spec/services/conversations/assignment_service_spec.rb @@ -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