diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/VoiceCallStatus.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/VoiceCallStatus.vue index e6bd3c272..bca6e877b 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/VoiceCallStatus.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/VoiceCallStatus.vue @@ -20,6 +20,7 @@ const ICON_MAP = { [VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call', [VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x', [VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x', + [VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x', }; const COLOR_MAP = { @@ -28,13 +29,18 @@ const COLOR_MAP = { [VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11', [VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9', [VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9', + [VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9', }; const isOutbound = computed( () => props.direction === VOICE_CALL_DIRECTION.OUTBOUND ); const isFailed = computed(() => - [VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status) + [ + VOICE_CALL_STATUS.NO_ANSWER, + VOICE_CALL_STATUS.FAILED, + VOICE_CALL_STATUS.REJECTED, + ].includes(props.status) ); const labelKey = computed(() => { diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index 37a80e8fc..34eb1eaff 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -34,6 +34,7 @@ const ICON_MAP = { [VOICE_CALL_STATUS.COMPLETED]: 'i-ph-phone-bold', [VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x-bold', [VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x-bold', + [VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x-bold', }; const { t } = useI18n(); @@ -81,7 +82,11 @@ const isWhatsapp = computed( () => call.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP ); const isFailed = computed(() => - [VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value) + [ + VOICE_CALL_STATUS.NO_ANSWER, + VOICE_CALL_STATUS.FAILED, + VOICE_CALL_STATUS.REJECTED, + ].includes(status.value) ); const isMissedInbound = computed(() => isFailed.value && !isOutbound.value); const endReason = computed(() => call.value?.endReason); diff --git a/app/javascript/dashboard/components-next/message/constants.js b/app/javascript/dashboard/components-next/message/constants.js index 517c083a1..72a2c503a 100644 --- a/app/javascript/dashboard/components-next/message/constants.js +++ b/app/javascript/dashboard/components-next/message/constants.js @@ -90,6 +90,7 @@ export const VOICE_CALL_STATUS = { COMPLETED: 'completed', NO_ANSWER: 'no-answer', FAILED: 'failed', + REJECTED: 'rejected', }; export const VOICE_CALL_DIRECTION = { diff --git a/app/javascript/dashboard/components/widgets/conversation/VoiceCallStatus.vue b/app/javascript/dashboard/components/widgets/conversation/VoiceCallStatus.vue index f5fa54e75..42be7645f 100644 --- a/app/javascript/dashboard/components/widgets/conversation/VoiceCallStatus.vue +++ b/app/javascript/dashboard/components/widgets/conversation/VoiceCallStatus.vue @@ -21,6 +21,7 @@ const ICON_MAP = { [VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call', [VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x', [VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x', + [VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x', }; const COLOR_MAP = { @@ -29,13 +30,18 @@ const COLOR_MAP = { [VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11', [VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9', [VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9', + [VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9', }; const isOutbound = computed( () => props.direction === VOICE_CALL_DIRECTION.OUTBOUND ); const isFailed = computed(() => - [VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status) + [ + VOICE_CALL_STATUS.NO_ANSWER, + VOICE_CALL_STATUS.FAILED, + VOICE_CALL_STATUS.REJECTED, + ].includes(props.status) ); const labelKey = computed(() => { diff --git a/app/javascript/dashboard/helper/voice.js b/app/javascript/dashboard/helper/voice.js index eaa523d75..224dce88a 100644 --- a/app/javascript/dashboard/helper/voice.js +++ b/app/javascript/dashboard/helper/voice.js @@ -7,6 +7,7 @@ export const TERMINAL_STATUSES = [ 'completed', 'busy', 'failed', + 'rejected', 'no-answer', 'canceled', 'missed', diff --git a/db/migrate/20260618000000_backfill_rejected_call_status.rb b/db/migrate/20260618000000_backfill_rejected_call_status.rb new file mode 100644 index 000000000..881d22b1c --- /dev/null +++ b/db/migrate/20260618000000_backfill_rejected_call_status.rb @@ -0,0 +1,9 @@ +class BackfillRejectedCallStatus < ActiveRecord::Migration[7.1] + def up + execute("UPDATE calls SET status = 'rejected' WHERE status = 'failed' AND end_reason = 'agent_rejected'") + end + + def down + execute("UPDATE calls SET status = 'failed' WHERE status = 'rejected' AND end_reason = 'agent_rejected'") + end +end diff --git a/enterprise/app/controllers/api/v1/accounts/conference_controller.rb b/enterprise/app/controllers/api/v1/accounts/conference_controller.rb index 0bea29843..f1d0dc89b 100644 --- a/enterprise/app/controllers/api/v1/accounts/conference_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/conference_controller.rb @@ -74,9 +74,9 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle rejected = call.with_lock do next false unless agent_rejecting_before_pickup?(call) - call.update!(status: 'failed', end_reason: 'agent_rejected', accepted_by_agent_id: Current.user.id) + call.update!(status: 'rejected', end_reason: 'agent_rejected', accepted_by_agent_id: Current.user.id) true end - Voice::CallMessageBuilder.new(call).update_status!(status: 'failed', agent: Current.user) if rejected + Voice::CallMessageBuilder.new(call).update_status!(status: 'rejected', agent: Current.user) if rejected end end diff --git a/enterprise/app/models/call.rb b/enterprise/app/models/call.rb index e111cdd48..71dfac100 100644 --- a/enterprise/app/models/call.rb +++ b/enterprise/app/models/call.rb @@ -29,8 +29,8 @@ # index_calls_on_provider_and_provider_call_id (provider,provider_call_id) UNIQUE # class Call < ApplicationRecord - STATUSES = %w[ringing in_progress completed no_answer failed].freeze - TERMINAL_STATUSES = %w[completed no_answer failed].freeze + STATUSES = %w[ringing in_progress completed no_answer failed rejected].freeze + TERMINAL_STATUSES = %w[completed no_answer failed rejected].freeze store_accessor :meta, :conference_sid, :twilio_conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at diff --git a/enterprise/app/services/whatsapp/call_service.rb b/enterprise/app/services/whatsapp/call_service.rb index 743409839..362af33cb 100644 --- a/enterprise/app/services/whatsapp/call_service.rb +++ b/enterprise/app/services/whatsapp/call_service.rb @@ -21,7 +21,7 @@ class Whatsapp::CallService invoke_provider!(:reject_call) call.update!(accepted_by_agent_id: agent.id) if call.accepted_by_agent_id.nil? - finalize_call('failed', end_reason: 'agent_rejected') + finalize_call('rejected', end_reason: 'agent_rejected') end call end diff --git a/spec/enterprise/controllers/api/v1/accounts/conference_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/conference_controller_spec.rb index c35689c84..2d8ec80db 100644 --- a/spec/enterprise/controllers/api/v1/accounts/conference_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/conference_controller_spec.rb @@ -143,7 +143,7 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do ) end - it 'ends the conference for the resolved call' do + it 'ends the conference and marks a pre-pickup hangup as rejected' do delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference", headers: agent.create_new_auth_token, params: { conversation_id: conversation.display_id, call_sid: 'CALL123' } @@ -151,6 +151,9 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do expect(response).to have_http_status(:ok) expect(response.parsed_body['id']).to eq(conversation.display_id) expect(conference_service).to have_received(:end_conference) + call = Call.find_by(provider_call_id: 'CALL123') + expect(call.status).to eq('rejected') + expect(call.end_reason).to eq('agent_rejected') end it 'does not allow ending conferences for calls from inboxes without access' do diff --git a/spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb index 264f428ce..500255983 100644 --- a/spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb @@ -68,7 +68,7 @@ RSpec.describe 'WhatsApp Calls API', type: :request do headers: agent.create_new_auth_token expect(response).to have_http_status(:ok) - expect(call.reload.status).to eq('failed') + expect(call.reload.status).to eq('rejected') end end diff --git a/spec/enterprise/services/whatsapp/call_service_spec.rb b/spec/enterprise/services/whatsapp/call_service_spec.rb index a17f7572a..926771a65 100644 --- a/spec/enterprise/services/whatsapp/call_service_spec.rb +++ b/spec/enterprise/services/whatsapp/call_service_spec.rb @@ -84,13 +84,14 @@ describe Whatsapp::CallService do describe '#reject' do before { allow(provider_service).to receive(:reject_call).and_return(true) } - it 'tells Meta to reject and finalizes the call as failed' do + it 'tells Meta to reject and finalizes the call as rejected' do described_class.new(call: call, agent: agent).reject expect(provider_service).to have_received(:reject_call).with('wacid_abc') - expect(call.reload.status).to eq('failed') + expect(call.reload.status).to eq('rejected') + expect(call.end_reason).to eq('agent_rejected') expect(ActionCable.server).to have_received(:broadcast).with( - "account_#{account.id}", hash_including(event: 'voice_call.ended', data: hash_including(status: 'failed')) + "account_#{account.id}", hash_including(event: 'voice_call.ended', data: hash_including(status: 'rejected')) ) end