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:
Tanmay Deep Sharma
2026-06-23 12:22:29 +05:30
committed by GitHub
co-authored by Sony Mathew
parent 961ec86ba9
commit 647cfc2d83
12 changed files with 45 additions and 13 deletions
@@ -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(() => {
+1
View File
@@ -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
+2 -2
View File
@@ -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