## PR2: Report builder refactor — DataSource abstraction
The existing report builders (timeseries + summary) had their SQL
queries inlined — each builder constructed its own scopes, groupings,
and aggregations directly. This made it hard to swap the underlying data
source without duplicating builder logic.
This PR extracts all raw-event querying into a `Reports::RawDataSource`
behind a `Reports::DataSource` factory. Builders now call
`data_source.timeseries`, `.aggregate`, or `.summary` instead of
constructing queries themselves. Behavior is identical —
`DataSource.for(...)` returns `RawDataSource` in all cases today.
The timeseries path had two separate builders (`CountReportBuilder`,
`AverageReportBuilder`) that were selected via a metric-name case
statement in `Conversations::BaseReportBuilder`. These are replaced by a
single `ReportBuilder` that delegates to the data source. The metric
type (count vs average) is now decided inside the data source, not the
builder.
Summary builders similarly moved their inline SQL into
`RawDataSource#summary`, which returns a unified hash keyed by dimension
ID.
the rollup read path.
## Flow
### Before
```
ReportsController ──▶ case metric ──▶ AverageReportBuilder ──▶ inline SQL ──▶ DB
└──▶ CountReportBuilder ──▶ inline SQL ──▶ DB
SummaryController ──▶ AgentSummaryBuilder ──▶ inline SQL ──▶ DB
└──▶ InboxSummaryBuilder ──▶ inline SQL ──▶ DB
└──▶ TeamSummaryBuilder ──▶ inline SQL ──▶ DB
```
### After
```
ReportsController ──▶ ReportBuilder ──┐
├──▶ DataSource.for ──▶ RawDataSource ──▶ DB
SummaryController ──▶ SummaryBuilder ──┘
```
### Expected (after rollup read path)
```
ReportsController ──▶ ReportBuilder ──┐
├──▶ DataSource.for ──▶ RawDataSource ──▶ reporting_events
SummaryController ──▶ SummaryBuilder ──┘ └──▶ RollupDataSource ──▶ reporting_events_rollups
```
### What changed
- `Reports::DataSource` factory + `Reports::RawDataSource`
- `TimezoneHelper#timezone_name_from_params` — prefers IANA name, falls
back to offset
- Unified `Timeseries::ReportBuilder` replaces `CountReportBuilder` +
`AverageReportBuilder`
- Summary builders delegate to `DataSource` instead of querying directly
### How to test
This is a pure refactor — all existing report pages (Overview, Agent,
Inbox, Label, Team) should produce identical numbers. No feature flag or
new config needed.
---------
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Tanmay Deep Sharma <tanmaydeepsharma21@gmail.com>
Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
140 lines
3.6 KiB
Ruby
140 lines
3.6 KiB
Ruby
class V2::ReportBuilder
|
|
include DateRangeHelper
|
|
include ReportHelper
|
|
|
|
attr_reader :account, :params
|
|
|
|
DEFAULT_GROUP_BY = 'day'.freeze
|
|
AGENT_RESULTS_PER_PAGE = 25
|
|
|
|
def initialize(account, params)
|
|
@account = account
|
|
@params = params
|
|
|
|
timezone_offset = (params[:timezone_offset] || 0).to_f
|
|
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
|
|
end
|
|
|
|
def timeseries
|
|
return send(params[:metric]) if metric_valid?
|
|
|
|
Rails.logger.error "ReportBuilder: Invalid metric - #{params[:metric]}"
|
|
{}
|
|
end
|
|
|
|
# For backward compatible with old report
|
|
def build
|
|
if %w[avg_first_response_time avg_resolution_time reply_time].include?(params[:metric])
|
|
timeseries.each_with_object([]) do |p, arr|
|
|
arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i, count: @grouped_values.count[p[0]] }
|
|
end
|
|
else
|
|
timeseries.each_with_object([]) do |p, arr|
|
|
arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i }
|
|
end
|
|
end
|
|
end
|
|
|
|
def summary
|
|
{
|
|
conversations_count: conversations.count,
|
|
incoming_messages_count: incoming_messages.count,
|
|
outgoing_messages_count: outgoing_messages.count,
|
|
avg_first_response_time: avg_first_response_time_summary,
|
|
avg_resolution_time: avg_resolution_time_summary,
|
|
resolutions_count: resolutions.count,
|
|
reply_time: reply_time_summary
|
|
}
|
|
end
|
|
|
|
def short_summary
|
|
{
|
|
conversations_count: conversations.count,
|
|
avg_first_response_time: avg_first_response_time_summary,
|
|
avg_resolution_time: avg_resolution_time_summary
|
|
}
|
|
end
|
|
|
|
def bot_summary
|
|
{
|
|
bot_resolutions_count: bot_resolutions.count,
|
|
bot_handoffs_count: bot_handoffs.count
|
|
}
|
|
end
|
|
|
|
def conversation_metrics
|
|
if params[:type].equal?(:account)
|
|
live_conversations
|
|
else
|
|
agent_metrics.sort_by { |hash| hash[:metric][:open] }.reverse
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def metric_valid?
|
|
%w[conversations_count
|
|
incoming_messages_count
|
|
outgoing_messages_count
|
|
avg_first_response_time
|
|
avg_resolution_time reply_time
|
|
resolutions_count
|
|
bot_resolutions_count
|
|
bot_handoffs_count
|
|
reply_time].include?(params[:metric])
|
|
end
|
|
|
|
def inbox
|
|
@inbox ||= account.inboxes.find(params[:id])
|
|
end
|
|
|
|
def user
|
|
@user ||= account.users.find(params[:id])
|
|
end
|
|
|
|
def label
|
|
@label ||= account.labels.find(params[:id])
|
|
end
|
|
|
|
def team
|
|
@team ||= account.teams.find(params[:id])
|
|
end
|
|
|
|
def get_grouped_values(object_scope)
|
|
@grouped_values = object_scope.group_by_period(
|
|
params[:group_by] || DEFAULT_GROUP_BY,
|
|
:created_at,
|
|
default_value: 0,
|
|
range: range,
|
|
permit: %w[day week month year hour],
|
|
time_zone: @timezone
|
|
)
|
|
end
|
|
|
|
def agent_metrics
|
|
account_users = @account.account_users.page(params[:page]).per(AGENT_RESULTS_PER_PAGE)
|
|
account_users.each_with_object([]) do |account_user, arr|
|
|
@user = account_user.user
|
|
arr << {
|
|
id: @user.id,
|
|
name: @user.name,
|
|
email: @user.email,
|
|
thumbnail: @user.avatar_url,
|
|
availability: account_user.availability_status,
|
|
metric: live_conversations
|
|
}
|
|
end
|
|
end
|
|
|
|
def live_conversations
|
|
@open_conversations = scope.conversations.where(account_id: @account.id).open
|
|
metric = {
|
|
open: @open_conversations.count,
|
|
unattended: @open_conversations.unattended.count
|
|
}
|
|
metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account)
|
|
metric[:pending] = @open_conversations.pending.count if params[:type].equal?(:account)
|
|
metric
|
|
end
|
|
end
|