## Description Adds drilldown support for report bar charts powered by `ReportContainer`. Clicking a non-zero report bar now opens a right-side drawer with the conversations or messages that contributed to that bucket, with each row linking to the underlying conversation and message rows linking with `messageId`. This includes a new `GET /api/v2/accounts/:account_id/reports/drilldown` endpoint, backend drilldown builders/serializers, generic chart click emission, local drawer state via `useReportDrilldown`, compact drilldown cards, pagination, stale-response protection, and validation for unsupported drilldown dimensions. Fixes # CW-4497 https://linear.app/chatwoot/issue/CW-4497/drill-down-on-agent-conversations-report ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? Ran the focused backend and frontend checks for the drilldown endpoint, builder, chart click handling, drawer/card UI, API helper, and stale-response handling. Here are the screenshots on how it looks like: <img width="1792" height="1199" alt="Screenshot 2026-06-02 at 11 32 11 PM" src="https://github.com/user-attachments/assets/6bdb8832-b9df-4bf3-9a2a-beaefe203b6e" /> <img width="1791" height="1230" alt="Screenshot 2026-06-02 at 11 32 34 PM" src="https://github.com/user-attachments/assets/36e92eb7-3208-4855-87f4-0c7f316df54d" /> <img width="1784" height="1235" alt="Screenshot 2026-06-02 at 11 32 46 PM" src="https://github.com/user-attachments/assets/f7a53916-74f2-4622-9305-042e0ac9e877" /> ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com> Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
231 lines
11 KiB
Ruby
231 lines
11 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe V2::Reports::DrilldownBuilder do
|
|
subject(:drilldown) { described_class.new(account, params).build }
|
|
|
|
let(:account) { create(:account) }
|
|
let(:inbox) { create(:inbox, account: account) }
|
|
let(:current_time) { Time.zone.parse('2026-05-20 12:00') }
|
|
let(:bucket_start) { current_time.beginning_of_day }
|
|
let(:bucket_end) { bucket_start + 1.day }
|
|
let(:metric) { 'conversations_count' }
|
|
let(:params) do
|
|
{
|
|
metric: metric,
|
|
type: filter_type,
|
|
id: filter_id,
|
|
since: bucket_start.to_i.to_s,
|
|
until: bucket_end.to_i.to_s,
|
|
bucket_timestamp: bucket_start.to_i.to_s,
|
|
group_by: 'day',
|
|
timezone_offset: '0',
|
|
business_hours: false
|
|
}
|
|
end
|
|
let(:filter_type) { :account }
|
|
let(:filter_id) { nil }
|
|
|
|
before do
|
|
travel_to current_time
|
|
end
|
|
|
|
describe '#build' do
|
|
context 'with conversation count metric' do
|
|
it 'returns conversations created in the clicked bucket' do
|
|
conversation = create(
|
|
:conversation,
|
|
account: account,
|
|
inbox: inbox,
|
|
created_at: bucket_start + 2.hours,
|
|
last_activity_at: bucket_start + 4.hours
|
|
)
|
|
last_message = create(
|
|
:message,
|
|
account: account,
|
|
inbox: inbox,
|
|
conversation: conversation,
|
|
message_type: :incoming,
|
|
content: 'Latest customer note',
|
|
created_at: bucket_start + 3.hours
|
|
)
|
|
conversation.update!(last_activity_at: bucket_start + 4.hours)
|
|
create(:conversation, account: account, inbox: inbox, created_at: bucket_start - 1.hour)
|
|
|
|
expect(drilldown[:meta]).to include(metric: 'conversations_count', record_type: 'conversation', total_count: 1)
|
|
expect(drilldown[:meta][:bucket]).to eq({ since: bucket_start.to_i, until: bucket_end.to_i })
|
|
expect(drilldown[:payload].first[:conversation][:display_id]).to eq(conversation.display_id)
|
|
expect(drilldown[:payload].first[:conversation][:created_at]).to eq(
|
|
(bucket_start + 2.hours).to_i
|
|
)
|
|
expect(drilldown[:payload].first[:conversation][:last_activity_at]).to eq(
|
|
(bucket_start + 4.hours).to_i
|
|
)
|
|
expect(drilldown[:payload].first[:conversation][:last_message][:id]).to eq(last_message.id)
|
|
expect(drilldown[:payload].first[:conversation][:last_message][:content]).to eq('Latest customer note')
|
|
end
|
|
|
|
it 'loads latest messages in one query for the page conversations' do
|
|
first_conversation = create(:conversation, account: account, inbox: inbox, created_at: bucket_start + 2.hours)
|
|
second_conversation = create(:conversation, account: account, inbox: inbox, created_at: bucket_start + 3.hours)
|
|
first_message = create(:message, account: account, inbox: inbox, conversation: first_conversation, created_at: bucket_start + 4.hours)
|
|
second_message = create(:message, account: account, inbox: inbox, conversation: second_conversation, created_at: bucket_start + 5.hours)
|
|
|
|
message_queries = []
|
|
subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |_name, _started, _finished, _unique_id, payload|
|
|
message_queries << payload[:sql] if payload[:sql].match?(/\ASELECT .*FROM "messages"/m) && !payload[:cached]
|
|
end
|
|
|
|
payload = drilldown[:payload]
|
|
|
|
expect(payload.map { |row| row[:conversation][:last_message][:id] }).to contain_exactly(
|
|
first_message.id,
|
|
second_message.id
|
|
)
|
|
expect(message_queries.size).to eq(1)
|
|
ensure
|
|
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
|
|
end
|
|
|
|
context 'when filtering by agent' do
|
|
let(:metric) { 'conversations_count' }
|
|
let(:filter_type) { :agent }
|
|
let(:filter_id) { agent.id }
|
|
let(:agent) { create(:user, account: account) }
|
|
let(:other_agent) { create(:user, account: account) }
|
|
|
|
it 'returns only conversations assigned to the selected agent' do
|
|
conversation = create(:conversation, account: account, inbox: inbox, assignee: agent, created_at: bucket_start + 2.hours)
|
|
create(:conversation, account: account, inbox: inbox, assignee: other_agent, created_at: bucket_start + 3.hours)
|
|
|
|
expect(drilldown[:meta][:total_count]).to eq(1)
|
|
expect(drilldown[:payload].first[:conversation][:id]).to eq(conversation.id)
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'with message count metric' do
|
|
let(:metric) { 'incoming_messages_count' }
|
|
|
|
it 'returns messages created in the clicked bucket' do
|
|
conversation = create(:conversation, account: account, inbox: inbox)
|
|
message = create(:message, account: account, inbox: inbox, conversation: conversation,
|
|
message_type: :incoming, content: 'Need help', created_at: bucket_start + 1.hour)
|
|
create(:message, account: account, inbox: inbox, conversation: conversation,
|
|
message_type: :outgoing, created_at: bucket_start + 2.hours)
|
|
|
|
expect(drilldown[:meta]).to include(record_type: 'message', total_count: 1)
|
|
expect(drilldown[:payload].first[:record_type]).to eq('message')
|
|
expect(drilldown[:payload].first[:message][:id]).to eq(message.id)
|
|
expect(drilldown[:payload].first[:message][:content]).to eq('Need help')
|
|
end
|
|
end
|
|
|
|
context 'with first response time metric' do
|
|
let(:metric) { 'avg_first_response_time' }
|
|
let(:agent) { create(:user, account: account) }
|
|
|
|
it 'infers the related outgoing message and uses the selected metric value' do
|
|
conversation = create(:conversation, account: account, inbox: inbox)
|
|
message = create(:message, account: account, inbox: inbox, conversation: conversation,
|
|
sender: agent, message_type: :outgoing, created_at: bucket_start + 2.hours)
|
|
create(:reporting_event, account: account, inbox: inbox, conversation: conversation, user: agent,
|
|
name: 'first_response', value: 120, value_in_business_hours: 45,
|
|
created_at: bucket_start + 2.hours, event_end_time: message.created_at)
|
|
|
|
params[:business_hours] = true
|
|
|
|
expect(drilldown[:meta]).to include(record_type: 'message', total_count: 1)
|
|
expect(drilldown[:payload].first[:record_type]).to eq('message')
|
|
expect(drilldown[:payload].first[:message][:id]).to eq(message.id)
|
|
expect(drilldown[:payload].first[:metric_value]).to eq(45)
|
|
end
|
|
|
|
it 'loads inferred and latest messages in two queries for the page events' do
|
|
first_conversation = create(:conversation, account: account, inbox: inbox)
|
|
second_conversation = create(:conversation, account: account, inbox: inbox)
|
|
first_message = create(:message, account: account, inbox: inbox, conversation: first_conversation,
|
|
sender: agent, message_type: :outgoing, created_at: bucket_start + 2.hours)
|
|
second_message = create(:message, account: account, inbox: inbox, conversation: second_conversation,
|
|
sender: agent, message_type: :outgoing, created_at: bucket_start + 3.hours)
|
|
create(:reporting_event, account: account, inbox: inbox, conversation: first_conversation, user: agent,
|
|
name: 'first_response', value: 120, created_at: bucket_start + 2.hours,
|
|
event_end_time: first_message.created_at)
|
|
create(:reporting_event, account: account, inbox: inbox, conversation: second_conversation, user: agent,
|
|
name: 'first_response', value: 90, created_at: bucket_start + 3.hours,
|
|
event_end_time: second_message.created_at)
|
|
|
|
message_queries = []
|
|
subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |_name, _started, _finished, _unique_id, payload|
|
|
message_queries << payload[:sql] if payload[:sql].match?(/\ASELECT .*FROM "messages"/m) && !payload[:cached]
|
|
end
|
|
|
|
payload = drilldown[:payload]
|
|
|
|
expect(payload.map { |row| row[:message][:id] }).to contain_exactly(
|
|
first_message.id,
|
|
second_message.id
|
|
)
|
|
expect(message_queries.size).to eq(2)
|
|
ensure
|
|
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
|
|
end
|
|
|
|
it 'falls back to the conversation when no matching message is found' do
|
|
conversation = create(:conversation, account: account, inbox: inbox)
|
|
create(:reporting_event, account: account, inbox: inbox, conversation: conversation, user: agent,
|
|
name: 'first_response', value: 120, created_at: bucket_start + 2.hours,
|
|
event_end_time: bucket_start + 2.hours)
|
|
|
|
expect(drilldown[:payload].first[:record_type]).to eq('conversation')
|
|
expect(drilldown[:payload].first[:conversation][:id]).to eq(conversation.id)
|
|
end
|
|
end
|
|
|
|
context 'with bot handoff count metric' do
|
|
let(:metric) { 'bot_handoffs_count' }
|
|
|
|
it 'returns one row per handoff conversation' do
|
|
first_conversation = create(:conversation, account: account, inbox: inbox)
|
|
second_conversation = create(:conversation, account: account, inbox: inbox)
|
|
|
|
create(:reporting_event, account: account, inbox: inbox, conversation: first_conversation,
|
|
name: 'conversation_bot_handoff', created_at: bucket_start + 1.hour)
|
|
create(:reporting_event, account: account, inbox: inbox, conversation: first_conversation,
|
|
name: 'conversation_bot_handoff', created_at: bucket_start + 2.hours)
|
|
create(:reporting_event, account: account, inbox: inbox, conversation: second_conversation,
|
|
name: 'conversation_bot_handoff', created_at: bucket_start + 3.hours)
|
|
|
|
expect(drilldown[:meta][:total_count]).to eq(2)
|
|
expect(drilldown[:payload].map { |row| row[:conversation][:id] }).to contain_exactly(
|
|
first_conversation.id,
|
|
second_conversation.id
|
|
)
|
|
expect(drilldown[:payload].pluck(:event_name)).to all(eq('conversation_bot_handoff'))
|
|
end
|
|
end
|
|
|
|
context 'with bot resolution count metric' do
|
|
let(:metric) { 'bot_resolutions_count' }
|
|
|
|
before do
|
|
params[:until] = (bucket_start + 2.days).to_i.to_s
|
|
end
|
|
|
|
it 'excludes conversations with handoffs anywhere in the selected report range' do
|
|
resolved_conversation = create(:conversation, account: account, inbox: inbox)
|
|
handed_off_conversation = create(:conversation, account: account, inbox: inbox)
|
|
|
|
create(:reporting_event, account: account, inbox: inbox, conversation: resolved_conversation,
|
|
name: 'conversation_bot_resolved', created_at: bucket_start + 1.hour)
|
|
create(:reporting_event, account: account, inbox: inbox, conversation: handed_off_conversation,
|
|
name: 'conversation_bot_resolved', created_at: bucket_start + 2.hours)
|
|
create(:reporting_event, account: account, inbox: inbox, conversation: handed_off_conversation,
|
|
name: 'conversation_bot_handoff', created_at: bucket_start + 1.day)
|
|
|
|
expect(drilldown[:meta][:total_count]).to eq(1)
|
|
expect(drilldown[:payload].first[:conversation][:id]).to eq(resolved_conversation.id)
|
|
end
|
|
end
|
|
end
|
|
end
|