From b9834c3a183e8fd3b46b15b426742b6fe445889e Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Sun, 28 Jun 2026 20:10:46 -0700 Subject: [PATCH] feat: block replies during agent bot ownership --- .../conversations/messages_controller.rb | 16 +++++++++++ .../widgets/conversation/ReplyBox.vue | 22 +++++++++++++-- .../widgets/conversation/ReplyBoxBanner.vue | 14 +++++----- .../store/modules/conversations/actions.js | 7 +++-- .../store/modules/conversations/index.js | 5 +++- .../specs/conversations/mutations.spec.js | 15 +++++++++++ .../conversations/messages_controller_spec.rb | 27 +++++++++++++++++++ 7 files changed, 94 insertions(+), 12 deletions(-) diff --git a/app/controllers/api/v1/accounts/conversations/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb index 67381a715..7e7d63e98 100644 --- a/app/controllers/api/v1/accounts/conversations/messages_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb @@ -1,5 +1,6 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController before_action :ensure_api_inbox, only: :update + before_action :ensure_agent_bot_takeover, only: :create def index @messages = message_finder.perform @@ -72,6 +73,21 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts:: message.translations.present? && message.translations[permitted_params[:target_language]].present? end + def ensure_agent_bot_takeover + return unless Current.user && @conversation.assignee_agent_bot_id.present? + return if private_message? || incoming_message? + + render json: { error: 'Conversation is assigned to an Agent Bot. Take over the conversation before replying.' }, status: :unprocessable_entity + end + + def private_message? + ActiveModel::Type::Boolean.new.cast(params[:private]) + end + + def incoming_message? + params[:message_type] == 'incoming' + end + # API inbox check def ensure_api_inbox # Only API inboxes can update messages diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 6af876cc6..d39fddad4 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -171,6 +171,9 @@ export default { return this.isATwilioWhatsAppChannel && !this.isPrivate; }, isPrivate() { + if (this.isAgentBotOwned) { + return true; + } if ( this.currentChat.can_reply || this.isAWhatsAppChannel || @@ -197,10 +200,14 @@ export default { }, isReplyRestricted() { return ( - !this.currentChat?.can_reply && - !(this.isAWhatsAppChannel || this.isAPIInbox) + this.isAgentBotOwned || + (!this.currentChat?.can_reply && + !(this.isAWhatsAppChannel || this.isAPIInbox)) ); }, + isAgentBotOwned() { + return this.currentChat?.meta?.assignee_type === 'AgentBot'; + }, inboxId() { return this.currentChat.inbox_id; }, @@ -462,6 +469,11 @@ export default { return; } + if (this.isAgentBotOwned) { + this.replyType = REPLY_EDITOR_MODES.NOTE; + return; + } + if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) { this.replyType = REPLY_EDITOR_MODES.REPLY; } else { @@ -501,6 +513,9 @@ export default { mounted() { this.getFromDraft(); + if (this.isAgentBotOwned) { + this.replyType = REPLY_EDITOR_MODES.NOTE; + } // Don't use the keyboard listener mixin here as the events here are supposed to be // working even if the editor is focussed. document.addEventListener('paste', this.onPaste); @@ -921,6 +936,9 @@ export default { this.hideContentTemplatesModal(); }, setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) { + if (this.isAgentBotOwned && mode === REPLY_EDITOR_MODES.REPLY) { + return; + } // Clear attachments when switching between private note and reply modes // This is to prevent from breaking the upload rules if (this.attachedFiles.length > 0) this.attachedFiles = []; diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBoxBanner.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBoxBanner.vue index 8be715a4f..56e08bb0c 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBoxBanner.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBoxBanner.vue @@ -4,7 +4,6 @@ import { useStore } from 'vuex'; import { useMapGetter } from 'dashboard/composables/store'; import { useAlert } from 'dashboard/composables'; import { useI18n } from 'vue-i18n'; -import wootConstants from 'dashboard/constants/globals'; import Banner from 'dashboard/components/ui/Banner.vue'; @@ -34,6 +33,7 @@ const assignedAgent = computed({ store.dispatch('setCurrentChatAssignee', { conversationId: currentChat.value?.id, assignee: agent, + assigneeType: agent ? 'User' : null, }); store.dispatch('assignAgent', { conversationId: currentChat.value?.id, @@ -56,11 +56,11 @@ const showSelfAssignBanner = computed(() => { ); }); -const showBotHandoffBanner = computed( - () => - isUserTyping.value && - currentChat.value?.status === wootConstants.STATUS_TYPE.PENDING -); +const showBotHandoffBanner = computed(() => { + return ( + isUserTyping.value && currentChat.value?.meta?.assignee_type === 'AgentBot' + ); +}); const botHandoffActionLabel = computed(() => { return assignedAgent.value?.id === currentUser.value?.id @@ -89,7 +89,7 @@ const onClickSelfAssign = async () => { const reopenConversation = async () => { await store.dispatch('toggleStatus', { conversationId: currentChat.value?.id, - status: wootConstants.STATUS_TYPE.OPEN, + status: 'open', }); }; diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js index 72ab8fa5e..ffdfdd0fe 100644 --- a/app/javascript/dashboard/store/modules/conversations/actions.js +++ b/app/javascript/dashboard/store/modules/conversations/actions.js @@ -223,8 +223,11 @@ const actions = { } }, - setCurrentChatAssignee({ commit }, { conversationId, assignee }) { - commit(types.ASSIGN_AGENT, { conversationId, assignee }); + setCurrentChatAssignee( + { commit }, + { conversationId, assignee, assigneeType } + ) { + commit(types.ASSIGN_AGENT, { conversationId, assignee, assigneeType }); }, assignTeam: async ({ dispatch }, { conversationId, teamId }) => { diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js index 8a13940c0..152d6a9f6 100644 --- a/app/javascript/dashboard/store/modules/conversations/index.js +++ b/app/javascript/dashboard/store/modules/conversations/index.js @@ -108,10 +108,13 @@ export const mutations = { } }, - [types.ASSIGN_AGENT](_state, { conversationId, assignee }) { + [types.ASSIGN_AGENT](_state, { conversationId, assignee, assigneeType }) { const chat = getConversationById(_state)(conversationId); if (chat) { chat.meta.assignee = assignee; + if (assigneeType !== undefined) { + chat.meta.assignee_type = assigneeType; + } } }, diff --git a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js index fc1c61b35..d27659e6b 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js @@ -716,6 +716,21 @@ describe('#mutations', () => { expect(state.allConversations[0].meta.assignee).toEqual(assignee); expect(state.allConversations[1].meta.assignee).toBeUndefined(); }); + + it('should update assignee type when provided', () => { + const assignee = { id: 1, name: 'Agent' }; + const state = { + allConversations: [{ id: 1, meta: { assignee_type: 'AgentBot' } }], + }; + + mutations[types.ASSIGN_AGENT](state, { + conversationId: 1, + assignee, + assigneeType: 'User', + }); + + expect(state.allConversations[0].meta.assignee_type).toEqual('User'); + }); }); describe('#ASSIGN_PRIORITY', () => { diff --git a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb index 9ab8d7316..8dd7746b1 100644 --- a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb @@ -36,6 +36,33 @@ RSpec.describe 'Conversation Messages API', type: :request do expect(conversation.messages.first.content).to eq(params[:content]) end + it 'blocks public replies when an agent bot owns the conversation' do + conversation.update!(assignee_agent_bot: create(:agent_bot, account: account)) + params = { content: 'test-message', private: false } + + post api_v1_account_conversation_messages_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(:unprocessable_entity) + expect(response.parsed_body['error']).to eq('Conversation is assigned to an Agent Bot. Take over the conversation before replying.') + expect(conversation.messages.count).to eq(0) + end + + it 'allows private notes when an agent bot owns the conversation' do + conversation.update!(assignee_agent_bot: create(:agent_bot, account: account)) + params = { content: 'test-note', private: true } + + post api_v1_account_conversation_messages_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(conversation.messages.last).to be_private + end + it 'does not create the message' do params = { content: "#{'h' * 150 * 1000}a", private: true }