Files
chatwoot/enterprise/app/builders/captain/assistant_drilldown_builder.rb
8d2ef4ec5e feat: assistant overview drilldown reports [CW-7408] (#14920)
<img width="2616" height="1716" alt="CleanShot 2026-07-09 at 14 22
42@2x"
src="https://github.com/user-attachments/assets/504e58df-0243-4cb9-a811-62504fdcb0ac"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-09 16:25:22 +05:30

136 lines
4.9 KiB
Ruby

# Lists the underlying records behind a single Captain assistant stat card, so a
# viewer can drill from an aggregate (e.g. "auto-resolution 42%") into the exact
# conversations that produced it.
#
# The window is resolved by Captain::AssistantStatsWindow from the same `range`
# and `timezone_offset` the stat card used, so the drilldown covers precisely the
# rows the card counted. Records are serialized with the shared reports drilldown
# serializer, so the existing frontend drilldown drawer/card can render them.
class Captain::AssistantDrilldownBuilder
ASSISTANT_SENDER_TYPE = 'Captain::Assistant'.freeze
RESOLVED_EVENT_NAMES = Captain::AssistantStatsBuilder::RESOLVED_EVENT_NAMES
HANDOFF_EVENT_NAMES = Captain::AssistantStatsBuilder::HANDOFF_EVENT_NAMES
SUPPORTED_METRICS = %w[
conversations_handled auto_resolution_rate handoff_rate reopen_rate
].freeze
DEFAULT_PAGE = 1
DEFAULT_PER_PAGE = 25
MAX_PER_PAGE = 100
pattr_initialize :assistant, :params
def self.supported_metric?(metric) = SUPPORTED_METRICS.include?(metric.to_s)
def build
records = paginated_records.to_a
{ meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } }
end
private
def account = assistant.account
def window
@window ||= Captain::AssistantStatsWindow.new(params[:range], params[:timezone_offset])
end
def range = window.current
def meta
{
metric: metric,
current_page: current_page,
per_page: per_page,
total_count: paginated_records.total_count,
conversation_count: paginated_records.total_count,
range: { since: range.first.to_i, until: range.last.to_i }
}
end
def paginated_records
@paginated_records ||= drilldown_scope.page(current_page).per(per_page)
end
def drilldown_scope
case metric
when 'conversations_handled' then handled_conversations
when 'auto_resolution_rate' then conversations_for(resolved_events.select(:conversation_id))
when 'handoff_rate' then event_conversations(HANDOFF_EVENT_NAMES)
when 'reopen_rate' then reopened_conversations
else
raise ArgumentError, "Unsupported assistant drilldown metric: #{metric}"
end
end
# Messages the assistant authored in the window; the cohort every metric derives from.
def handled_messages
account.messages.where(sender_type: ASSISTANT_SENDER_TYPE, sender_id: assistant.id, created_at: range)
end
def handled_conversation_ids
handled_messages.select(:conversation_id)
end
def handled_conversations
conversations_for(handled_conversation_ids)
end
# Conversations in the handled cohort that recorded one of the given reporting
# events in the window (resolved or handed-off).
def event_conversations(event_names)
ids = account.reporting_events
.where(name: event_names, created_at: range, conversation_id: handled_conversation_ids)
.select(:conversation_id)
conversations_for(ids)
end
# Captain resolves in the window, excluding bot-resolved rows whose conversation
# was also handed off, mirroring AssistantStatsBuilder#resolved_clause so the
# drilldown lists exactly the conversations the auto-resolution card counted.
def resolved_events
handoff_ids = account.reporting_events.where(name: HANDOFF_EVENT_NAMES, created_at: range).select(:conversation_id)
account.reporting_events
.where(name: RESOLVED_EVENT_NAMES, created_at: range, conversation_id: handled_conversation_ids)
.where("NOT (name = ? AND conversation_id IN (#{handoff_ids.to_sql}))",
Captain::AssistantStatsBuilder::BOT_RESOLVED_EVENT_NAME)
end
# Auto-resolved conversations that reopened at/after their Captain resolve,
# mirroring AssistantStatsBuilder#reopen_rate's numerator cohort.
def reopened_conversations
ids = account.reporting_events
.where(name: 'conversation_opened')
.where('reporting_events.value > 0')
.where('reporting_events.event_end_time <= ?', range.last)
.joins("INNER JOIN (#{resolved_events.to_sql}) resolves " \
'ON resolves.conversation_id = reporting_events.conversation_id ' \
'AND reporting_events.event_end_time >= resolves.event_end_time')
.select('reporting_events.conversation_id')
conversations_for(ids)
end
def conversations_for(conversation_ids)
account.conversations
.where(id: conversation_ids)
.includes(:assignee, :contact, :inbox)
.order(created_at: :desc)
end
def record_serializer(records)
@record_serializer ||= V2::Reports::DrilldownRecordSerializer.new(account, metric, false, records)
end
def metric = params[:metric].to_s
def current_page = [params[:page].to_i, DEFAULT_PAGE].max
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
end