feat: account calls dashboard index endpoint (#14780)

## Description

Adds a backend endpoint that powers an account-wide calls dashboard,
letting users list and filter all calls in the account.

## Linear Ticket
- https://linear.app/chatwoot/issue/UPM-28/voice-call-dashboard-view

## Type of change

- [ ] New feature (non-breaking change which adds functionality)

## 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
This commit is contained in:
Tanmay Deep Sharma
2026-07-08 15:31:03 +05:30
committed by GitHub
parent 0e07a27c74
commit f6c18f5225
10 changed files with 302 additions and 0 deletions
+1
View File
@@ -240,6 +240,7 @@ Rails.application.routes.draw do
resources :reporting_events, only: [:index] if ChatwootApp.enterprise?
if ChatwootApp.enterprise?
resources :calls, only: [:index]
resources :whatsapp_calls, only: [:show] do
member do
post :accept
@@ -0,0 +1,7 @@
class AddAccountCreatedAtIndexToCalls < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_index :calls, [:account_id, :created_at], algorithm: :concurrently
end
end
+1
View File
@@ -282,6 +282,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_30_000000) do
t.datetime "updated_at", null: false
t.index ["account_id", "contact_id"], name: "index_calls_on_account_id_and_contact_id"
t.index ["account_id", "conversation_id"], name: "index_calls_on_account_id_and_conversation_id"
t.index ["account_id", "created_at"], name: "index_calls_on_account_id_and_created_at"
t.index ["message_id"], name: "index_calls_on_message_id"
t.index ["provider", "provider_call_id"], name: "index_calls_on_provider_and_provider_call_id", unique: true
end
@@ -0,0 +1,7 @@
class Api::V1::Accounts::CallsController < Api::V1::Accounts::EnterpriseAccountsController
def index
result = CallFinder.new(Current.user, Current.account, params).perform
@calls = result[:calls]
@calls_count = result[:count]
end
end
+70
View File
@@ -0,0 +1,70 @@
class CallFinder
RESULTS_PER_PAGE = 25
def initialize(current_user, current_account, params)
@current_user = current_user
@current_account = current_account
@params = params
end
def perform
@calls = @current_account.calls
filter_by_visibility
filter_by_status
filter_by_direction
filter_by_inbox
filter_by_agent
filter_by_date_range
{ calls: paginated_calls, count: @calls.count }
end
private
# Admins and report managers see the whole account; everyone else only sees
# calls they handled within conversations they can still access.
def filter_by_visibility
return if account_wide_access?
@calls = @calls.where(accepted_by_agent_id: @current_user.id, conversation_id: accessible_conversations)
end
def accessible_conversations
Conversations::PermissionFilterService.new(@current_account.conversations, @current_user, @current_account).perform.select(:id)
end
def account_wide_access?
account_user = Current.account_user
account_user&.administrator? || account_user&.custom_role&.permissions&.include?('report_manage')
end
def filter_by_status
@calls = @calls.where(status: Call.status_from_display(@params[:status])) if @params[:status].present?
end
def filter_by_direction
@calls = @calls.where(direction: Call.direction_from_label(@params[:direction])) if @params[:direction].present?
end
def filter_by_inbox
@calls = @calls.where(inbox_id: @params[:inbox_id]) if @params[:inbox_id].present?
end
def filter_by_agent
@calls = @calls.where(accepted_by_agent_id: @params[:agent_id]) if @params[:agent_id].present?
end
# since/until are unix timestamps, matching DateRangeHelper conventions.
def filter_by_date_range
return if @params[:since].blank? || @params[:until].blank?
@calls = @calls.where(created_at: Time.zone.at(@params[:since].to_i)..Time.zone.at(@params[:until].to_i))
end
def paginated_calls
@calls.includes(:contact, :inbox, :conversation, :accepted_by_agent)
.order(created_at: :desc)
.page(@params[:page] || 1)
.per(RESULTS_PER_PAGE)
end
end
+11
View File
@@ -78,6 +78,17 @@ class Call < ApplicationRecord
DISPLAY_DIRECTION[direction]
end
# Normalize filter values back to stored forms so API/dashboard clients can
# query using either the display value (inbound/outbound, in-progress) or the
# stored value (incoming/outgoing, in_progress).
def self.direction_from_label(value)
DISPLAY_DIRECTION.key(value) || value
end
def self.status_from_display(value)
value.to_s.tr('-', '_')
end
def ringing?
status == 'ringing'
end
@@ -0,0 +1,11 @@
json.meta do
json.count @calls_count
json.current_page @calls.current_page
json.total_pages @calls.total_pages
end
json.payload do
json.array! @calls do |call|
json.partial! 'api/v1/models/call', formats: [:json], call: call
end
end
@@ -0,0 +1,40 @@
json.id call.id
json.call_id call.provider_call_id
json.provider call.provider
json.status call.display_status
json.direction call.direction_label
json.duration_seconds call.duration_seconds
json.end_reason call.end_reason
json.started_at call.started_at&.to_i
json.created_at call.created_at.to_i
json.message_id call.message_id
json.recording_url call.recording_url
json.transcript call.transcript
json.conversation do
json.id call.conversation_id
json.display_id call.conversation.display_id
end
json.inbox do
json.id call.inbox_id
json.name call.inbox.name
end
if call.accepted_by_agent
json.agent do
json.id call.accepted_by_agent.id
json.name call.accepted_by_agent.available_name
json.avatar call.accepted_by_agent.avatar_url
end
else
json.agent nil
end
contact = call.contact
json.contact do
json.id contact.id
json.name contact.name
json.phone_number contact.phone_number
json.avatar contact.avatar_url
end
@@ -0,0 +1,46 @@
require 'rails_helper'
RSpec.describe 'Calls API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:inbox) { create(:inbox, account: account) }
let(:contact) { create(:contact, :with_phone_number, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
let!(:agent_call) do
create(:call, account: account, inbox: inbox, conversation: conversation, contact: contact,
accepted_by_agent: agent, status: 'completed', transcript: 'hello world')
end
let!(:other_call) do
create(:call, account: account, inbox: inbox, conversation: conversation, contact: contact, accepted_by_agent: admin)
end
before { create(:inbox_member, user: agent, inbox: inbox) }
describe 'GET /api/v1/accounts/:account_id/calls' do
it 'returns 401 when unauthenticated' do
get "/api/v1/accounts/#{account.id}/calls"
expect(response).to have_http_status(:unauthorized)
end
it 'returns the whole account with sensitive fields for an administrator' do
get "/api/v1/accounts/#{account.id}/calls", headers: admin.create_new_auth_token
expect(response).to have_http_status(:ok)
body = response.parsed_body
expect(body['payload'].map { |c| c['id'] }).to contain_exactly(agent_call.id, other_call.id)
item = body['payload'].find { |c| c['id'] == agent_call.id }
expect(item['transcript']).to eq('hello world')
expect(item['contact']['phone_number']).to eq(contact.phone_number)
end
it 'scopes the list to calls the agent accepted' do
get "/api/v1/accounts/#{account.id}/calls", headers: agent.create_new_auth_token
expect(response).to have_http_status(:ok)
body = response.parsed_body
expect(body['meta']['count']).to eq(1)
expect(body['payload'].map { |c| c['id'] }).to contain_exactly(agent_call.id)
end
end
end
+108
View File
@@ -0,0 +1,108 @@
require 'rails_helper'
describe CallFinder do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
before { create(:inbox_member, user: agent, inbox: inbox) }
def perform(user, params = {})
Current.account = account
Current.account_user = account.account_users.find_by(user_id: user.id)
described_class.new(user, account, params).perform
end
describe 'visibility' do
let!(:agent_call) do
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact, accepted_by_agent: agent)
end
let!(:other_call) do
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact, accepted_by_agent: admin)
end
it 'lets an administrator see every call in the account' do
result = perform(admin)
expect(result[:count]).to eq(2)
expect(result[:calls].map(&:id)).to contain_exactly(agent_call.id, other_call.id)
end
it 'lets an agent with report_manage see every call in the account' do
report_manager = create(:user, account: account, role: :agent)
custom_role = create(:custom_role, account: account, permissions: ['report_manage'])
account.account_users.find_by(user_id: report_manager.id).update!(custom_role: custom_role)
result = perform(report_manager)
expect(result[:calls].map(&:id)).to contain_exactly(agent_call.id, other_call.id)
end
it 'limits a regular agent to calls they accepted in accessible conversations' do
result = perform(agent)
expect(result[:calls].map(&:id)).to contain_exactly(agent_call.id)
end
it 'limits a custom-role agent without report_manage to their own accepted calls' do
scoped_agent = create(:user, account: account, role: :agent)
custom_role = create(:custom_role, account: account, permissions: ['conversation_manage'])
account.account_users.find_by(user_id: scoped_agent.id).update!(custom_role: custom_role)
create(:inbox_member, user: scoped_agent, inbox: inbox)
scoped_call = create(:call, account: account, inbox: inbox, conversation: conversation,
contact: conversation.contact, accepted_by_agent: scoped_agent)
result = perform(scoped_agent)
expect(result[:calls].map(&:id)).to contain_exactly(scoped_call.id)
end
end
describe 'filters' do
let(:inbox2) { create(:inbox, account: account) }
let(:conversation2) { create(:conversation, account: account, inbox: inbox2) }
let(:agent2) { create(:user, account: account, role: :agent) }
let!(:ringing) do
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
status: 'ringing', direction: :incoming, accepted_by_agent: agent)
end
let!(:in_progress) do
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
status: 'in_progress', direction: :incoming, accepted_by_agent: agent)
end
let!(:completed) do
create(:call, account: account, inbox: inbox2, conversation: conversation2, contact: conversation2.contact,
status: 'completed', direction: :outgoing, accepted_by_agent: agent2, created_at: 10.days.ago)
end
it 'filters by status using the display value' do
expect(perform(admin, status: 'in-progress')[:calls].map(&:id)).to contain_exactly(in_progress.id)
end
it 'filters by direction using the display label' do
expect(perform(admin, direction: 'outbound')[:calls].map(&:id)).to contain_exactly(completed.id)
end
it 'filters by inbox' do
expect(perform(admin, inbox_id: inbox2.id)[:calls].map(&:id)).to contain_exactly(completed.id)
end
it 'filters by agent' do
expect(perform(admin, agent_id: agent2.id)[:calls].map(&:id)).to contain_exactly(completed.id)
end
it 'filters by created_at date range' do
params = { since: 2.days.ago.to_i.to_s, until: 1.hour.from_now.to_i.to_s }
expect(perform(admin, params)[:calls].map(&:id)).to contain_exactly(ringing.id, in_progress.id)
end
end
describe 'account scoping' do
it 'never returns calls from another account' do
other_account = create(:account)
other_conversation = create(:conversation, account: other_account)
create(:call, account: other_account, inbox: other_conversation.inbox, conversation: other_conversation,
contact: other_conversation.contact)
expect(perform(admin)[:count]).to eq(0)
end
end
end