diff --git a/config/routes.rb b/config/routes.rb index c5e7ded2f..c31400719 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20260622000000_add_account_created_at_index_to_calls.rb b/db/migrate/20260622000000_add_account_created_at_index_to_calls.rb new file mode 100644 index 000000000..9c15dcda6 --- /dev/null +++ b/db/migrate/20260622000000_add_account_created_at_index_to_calls.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 1a676dd59..99d083d89 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/enterprise/app/controllers/api/v1/accounts/calls_controller.rb b/enterprise/app/controllers/api/v1/accounts/calls_controller.rb new file mode 100644 index 000000000..71772e4a0 --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/calls_controller.rb @@ -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 diff --git a/enterprise/app/finders/call_finder.rb b/enterprise/app/finders/call_finder.rb new file mode 100644 index 000000000..31d6ae5f2 --- /dev/null +++ b/enterprise/app/finders/call_finder.rb @@ -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 diff --git a/enterprise/app/models/call.rb b/enterprise/app/models/call.rb index 71dfac100..8c76103ea 100644 --- a/enterprise/app/models/call.rb +++ b/enterprise/app/models/call.rb @@ -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 diff --git a/enterprise/app/views/api/v1/accounts/calls/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/calls/index.json.jbuilder new file mode 100644 index 000000000..15f66ddc1 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/calls/index.json.jbuilder @@ -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 diff --git a/enterprise/app/views/api/v1/models/_call.json.jbuilder b/enterprise/app/views/api/v1/models/_call.json.jbuilder new file mode 100644 index 000000000..7a3531b39 --- /dev/null +++ b/enterprise/app/views/api/v1/models/_call.json.jbuilder @@ -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 diff --git a/spec/enterprise/controllers/api/v1/accounts/calls_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/calls_controller_spec.rb new file mode 100644 index 000000000..86e4fb317 --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/calls_controller_spec.rb @@ -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 diff --git a/spec/enterprise/finders/call_finder_spec.rb b/spec/enterprise/finders/call_finder_spec.rb new file mode 100644 index 000000000..4f4607370 --- /dev/null +++ b/spec/enterprise/finders/call_finder_spec.rb @@ -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