feat: distinguish agent-declined calls with a rejected status (#14783)
## Linear ticket - https://linear.app/chatwoot/issue/CW-7374/agent-declined-voice-calls-miscounted-as-failed ## Description Agent rejections were stored with status: failed, so call reports lumped deliberate declines together with real technical failure. Fix: give declines their own terminal rejected status (Twilio + WhatsApp paths), keeping end_reason: agent_rejected. Genuine provider/network failures stay failed. Frontend renders declines exactly as before; existing rows backfilled via migration. ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - Tested on UI ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sony Mathew <sony@chatwoot.com>
This commit is contained in:
co-authored by
Sony Mathew
parent
961ec86ba9
commit
647cfc2d83
+7
-1
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -90,6 +90,7 @@ export const VOICE_CALL_STATUS = {
|
||||
COMPLETED: 'completed',
|
||||
NO_ANSWER: 'no-answer',
|
||||
FAILED: 'failed',
|
||||
REJECTED: 'rejected',
|
||||
};
|
||||
|
||||
export const VOICE_CALL_DIRECTION = {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -7,6 +7,7 @@ export const TERMINAL_STATUSES = [
|
||||
'completed',
|
||||
'busy',
|
||||
'failed',
|
||||
'rejected',
|
||||
'no-answer',
|
||||
'canceled',
|
||||
'missed',
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user