## 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>
214 lines
5.6 KiB
Ruby
214 lines
5.6 KiB
Ruby
class V2::Reports::DrilldownBuilder
|
|
include DateRangeHelper
|
|
include TimezoneHelper
|
|
|
|
DEFAULT_GROUP_BY = 'day'.freeze
|
|
DEFAULT_PAGE = 1
|
|
DEFAULT_PER_PAGE = 25
|
|
MAX_PER_PAGE = 100
|
|
SUPPORTED_GROUP_BY = %w[hour day week month year].freeze
|
|
SUPPORTED_DIMENSION_TYPES = %w[account inbox agent label team].freeze
|
|
MESSAGE_METRICS = {
|
|
'incoming_messages_count' => :incoming,
|
|
'outgoing_messages_count' => :outgoing
|
|
}.freeze
|
|
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
|
|
|
|
pattr_initialize :account, :params
|
|
|
|
def self.supported_dimension_type?(type) = SUPPORTED_DIMENSION_TYPES.include?((type.presence || 'account').to_s)
|
|
|
|
def build
|
|
records = paginated_records.to_a
|
|
{ meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } }
|
|
end
|
|
|
|
private
|
|
|
|
def meta
|
|
{
|
|
metric: metric,
|
|
record_type: record_type,
|
|
bucket: {
|
|
since: bucket_range.begin.to_i,
|
|
until: bucket_range.end.to_i
|
|
},
|
|
current_page: current_page,
|
|
per_page: per_page,
|
|
total_count: paginated_records.total_count,
|
|
conversation_count: conversation_count
|
|
}
|
|
end
|
|
|
|
def conversation_count
|
|
return paginated_records.total_count if conversation_metric?
|
|
|
|
drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id)
|
|
end
|
|
|
|
def paginated_records
|
|
@paginated_records ||= drilldown_scope.page(current_page).per(per_page)
|
|
end
|
|
|
|
def drilldown_scope
|
|
if message_metric?
|
|
message_scope
|
|
elsif conversation_metric?
|
|
conversation_scope
|
|
else
|
|
reporting_event_scope
|
|
end
|
|
end
|
|
|
|
def message_scope
|
|
scope.messages
|
|
.where(account_id: account.id, created_at: bucket_range)
|
|
.public_send(MESSAGE_METRICS.fetch(metric))
|
|
.includes(:sender, conversation: [:assignee, :contact, :inbox])
|
|
.reorder(created_at: :desc)
|
|
end
|
|
|
|
def conversation_scope
|
|
scope.conversations
|
|
.where(account_id: account.id, created_at: bucket_range)
|
|
.includes(:assignee, :contact, :inbox)
|
|
.order(created_at: :desc)
|
|
end
|
|
|
|
def reporting_event_scope
|
|
events = scope.reporting_events
|
|
.where(account_id: account.id, name: raw_event_name, created_at: bucket_range)
|
|
.includes(:user, :inbox, conversation: [:assignee, :contact, :inbox])
|
|
.order(created_at: :desc)
|
|
|
|
if raw_count_strategy == :exclude_bot_handoffs
|
|
events = events.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
|
|
elsif raw_count_strategy == :distinct_conversation
|
|
events = events.where(id: distinct_conversation_event_ids(events))
|
|
end
|
|
|
|
events
|
|
end
|
|
|
|
def bot_handoff_conversation_ids_subquery
|
|
scope.reporting_events
|
|
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range)
|
|
.where.not(conversation_id: nil)
|
|
.select(:conversation_id)
|
|
end
|
|
|
|
def distinct_conversation_event_ids(events)
|
|
events.reorder(nil)
|
|
.where.not(conversation_id: nil)
|
|
.select('MAX(reporting_events.id)')
|
|
.group(:conversation_id)
|
|
end
|
|
|
|
def record_serializer(records)
|
|
@record_serializer ||= V2::Reports::DrilldownRecordSerializer.new(
|
|
account,
|
|
metric,
|
|
use_business_hours?,
|
|
records
|
|
)
|
|
end
|
|
|
|
def bucket_range
|
|
@bucket_range ||= begin
|
|
bucket_start = Time.zone.at(params[:bucket_timestamp].to_i).in_time_zone(timezone)
|
|
bucket_end = bucket_end_for(bucket_start)
|
|
requested_start = Time.zone.at(params[:since].to_i)
|
|
requested_end = Time.zone.at(params[:until].to_i)
|
|
|
|
[bucket_start, requested_start].max...[bucket_end, requested_end].min
|
|
end
|
|
end
|
|
|
|
def bucket_end_for(bucket_start)
|
|
{
|
|
'hour' => bucket_start + 1.hour,
|
|
'day' => bucket_start + 1.day,
|
|
'week' => bucket_start + 1.week,
|
|
'month' => bucket_start + 1.month,
|
|
'year' => bucket_start + 1.year
|
|
}.fetch(group_by)
|
|
end
|
|
|
|
def scope
|
|
case dimension_type
|
|
when 'account' then account
|
|
when 'inbox' then inbox
|
|
when 'agent' then user
|
|
when 'label' then label
|
|
when 'team' then team
|
|
else
|
|
raise ArgumentError, "Unsupported drilldown dimension type: #{dimension_type}"
|
|
end
|
|
end
|
|
|
|
def inbox = @inbox ||= account.inboxes.find(params[:id])
|
|
|
|
def user = @user ||= account.users.find(params[:id])
|
|
|
|
def label = @label ||= account.labels.find(params[:id])
|
|
|
|
def team = @team ||= account.teams.find(params[:id])
|
|
|
|
def metric
|
|
params[:metric].to_s
|
|
end
|
|
|
|
def report_metric
|
|
@report_metric ||= Reports::ReportMetricRegistry.fetch(metric)
|
|
end
|
|
|
|
def raw_event_name
|
|
report_metric&.raw_event_name
|
|
end
|
|
|
|
def raw_count_strategy
|
|
report_metric&.raw_count_strategy
|
|
end
|
|
|
|
def record_type
|
|
return 'message' if message_metric? || MESSAGE_EVENT_METRICS.include?(metric)
|
|
|
|
'conversation'
|
|
end
|
|
|
|
def message_metric?
|
|
MESSAGE_METRICS.key?(metric)
|
|
end
|
|
|
|
def conversation_metric?
|
|
metric == 'conversations_count'
|
|
end
|
|
|
|
def dimension_type
|
|
(params[:type].presence || 'account').to_s
|
|
end
|
|
|
|
def group_by
|
|
@group_by ||= SUPPORTED_GROUP_BY.include?(params[:group_by].to_s) ? params[:group_by].to_s : DEFAULT_GROUP_BY
|
|
end
|
|
|
|
def timezone
|
|
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
|
|
end
|
|
|
|
def current_page
|
|
[params[:page].to_i, DEFAULT_PAGE].max
|
|
end
|
|
|
|
def per_page
|
|
requested_per_page = params[:per_page].to_i
|
|
requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0
|
|
|
|
[requested_per_page, MAX_PER_PAGE].min
|
|
end
|
|
|
|
def use_business_hours?
|
|
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
|
|
end
|
|
end
|