Compare commits

...
Author SHA1 Message Date
Shivam MishraandGitHub c440df9b18 Merge branch 'feat/rollup/2-report-data-source' into feat/rollup/3-rollup-read-path 2026-03-16 19:19:24 +05:30
Shivam MishraandGitHub 68da47753e Merge branch 'feat/rollup/1-model-write-path' into feat/rollup/2-report-data-source 2026-03-16 19:19:14 +05:30
Shivam Mishra d054a60a65 fix: spec flag name 2026-03-16 19:18:49 +05:30
Shivam Mishra 8b7def05dc fix: feature flag 2026-03-16 19:16:51 +05:30
Shivam MishraandGitHub f126230f73 Merge branch 'feat/rollup/2-report-data-source' into feat/rollup/3-rollup-read-path 2026-03-16 19:14:03 +05:30
Shivam MishraandGitHub e42b0be530 Merge branch 'feat/rollup/1-model-write-path' into feat/rollup/2-report-data-source 2026-03-16 19:13:55 +05:30
Shivam Mishra 33b3a56d4c fix: feature flag 2026-03-16 19:06:56 +05:30
Shivam Mishra 6693a4d6f7 fix: rollup 2026-03-16 19:05:45 +05:30
Shivam Mishra 69a44ccf4d refactor: wrap rollup action in a rescue block 2026-03-16 18:53:38 +05:30
Muhsin KelothandGitHub 95727bc608 Merge branch 'develop' into feat/rollup/1-model-write-path 2026-03-16 12:28:41 +04:00
Shivam Mishra 2fab77d7c7 refactor: split large examples in backfill service spec 2026-03-16 12:45:26 +05:30
Shivam Mishra 7ad8011a1b style: fix ABC size 2026-03-16 12:39:52 +05:30
Shivam Mishra c83546e67a style: split specs 2026-03-16 12:37:47 +05:30
Shivam Mishra 1154f9ccb4 chore: remove prompt enable outside resuce 2026-03-16 12:31:45 +05:30
Shivam Mishra e7d5924ca1 feat: make the rollup backfill atomic 2026-03-16 12:26:38 +05:30
Shivam Mishra 89e53e24db fix: timezone offset parsing 2026-03-16 12:04:29 +05:30
Shivam MishraandGitHub 47ecd349cc Merge branch 'develop' into feat/rollup/1-model-write-path 2026-03-16 11:35:49 +05:30
Shivam Mishra b8543c09fb refactor: unify backfill service to per-dimension query pattern
Replace the two-path aggregation (group-then-fan-out for standard events,
per-dimension for distinct-count) with a single per-dimension loop for both.
This removes the bug-prone fan-out logic and the conceptual split.

Strengthen specs to cover all three dimensions (account, agent, inbox),
assert total rollup row counts, and add distinct-count deduplication test.
2026-03-13 15:17:46 +05:30
Shivam MishraandGitHub e37e9fd814 Merge branch 'feat/rollup/2-report-data-source' into feat/rollup/3-rollup-read-path 2026-03-13 13:55:24 +05:30
Shivam MishraandGitHub e6161d2878 Merge branch 'feat/rollup/1-model-write-path' into feat/rollup/2-report-data-source 2026-03-13 13:55:18 +05:30
Shivam Mishra 4e2924538f fix: distinct counts in backfill 2026-03-13 13:48:45 +05:30
Shivam MishraandGitHub e71bda7112 Merge branch 'develop' into feat/rollup/1-model-write-path 2026-03-13 13:18:06 +05:30
Shivam Mishra 31e3e9c1ff chore: add comment 2026-03-13 13:17:49 +05:30
Shivam Mishra 10f1c23742 refactor: use the new registry 2026-03-13 12:46:12 +05:30
Shivam MishraandGitHub 3fd14942f1 Merge branch 'feat/rollup/2-report-data-source' into feat/rollup/3-rollup-read-path 2026-03-13 12:45:20 +05:30
Shivam Mishra 7661104262 refactor: better defaults for metric 2026-03-13 12:44:46 +05:30
Shivam Mishra 38b5402623 refactor: use report metric registry 2026-03-13 12:37:23 +05:30
Shivam MishraandGitHub 3dd29080b5 Merge branch 'feat/rollup/1-model-write-path' into feat/rollup/2-report-data-source 2026-03-13 12:36:23 +05:30
Shivam Mishra e56b4b2539 refactor: metric registry 2026-03-13 12:35:09 +05:30
Shivam MishraandGitHub 5ede10c6df Merge branch 'feat/rollup/1-model-write-path' into feat/rollup/2-report-data-source 2026-03-13 12:29:30 +05:30
Shivam Mishra 8a4c1ec07e chore: add comments 2026-03-13 12:24:21 +05:30
Shivam Mishra ac7a02067e chore: flatten name 2026-03-13 12:15:55 +05:30
Shivam Mishra 2e591afaf6 fix: use IANA 2026-03-13 11:51:28 +05:30
Shivam Mishra 32e923dba0 feat: add rollup data source and route eligible reports through it 2026-03-13 11:02:12 +05:30
Shivam Mishra b824f8ba47 refactor: pass timezone param through report controllers 2026-03-13 10:59:43 +05:30
Shivam Mishra 57d40c373d refactor: route summary builders through DataSource 2026-03-13 10:59:25 +05:30
Shivam Mishra 3c5cd46d2e refactor: replace count and average timeseries builders with unified ReportBuilder 2026-03-13 10:58:57 +05:30
Shivam Mishra d34da0cbf8 feat: extract raw report queries into DataSource abstraction 2026-03-13 10:58:40 +05:30
Shivam Mishra 32b1e16e74 refactor: add timezone_name_from_params to TimezoneHelper 2026-03-13 10:55:32 +05:30
Shivam Mishra 7e14153784 feat: add rake tasks for rollup backfill and timezone setup 2026-03-13 10:55:19 +05:30
Shivam Mishra 914e70568f feat: add backfill service for historical rollup data 2026-03-13 10:55:10 +05:30
Shivam Mishra 13860b9163 feat: write rollups from reporting event listener 2026-03-13 10:54:43 +05:30
Shivam Mishra 42d7f1768c feat: add rollup service to aggregate events in real time 2026-03-13 10:53:37 +05:30
Shivam Mishra 911f211427 feat: add metric registry mapping events to rollup columns 2026-03-13 10:53:29 +05:30
Shivam Mishra ea5779a712 feat: add reporting_timezone setting to account 2026-03-13 10:53:19 +05:30
Shivam Mishra 9ecf5f2504 feat: add reporting_events_rollup table and model 2026-03-13 10:52:57 +05:30
51 changed files with 3790 additions and 517 deletions
+1
View File
@@ -1,6 +1,7 @@
class V2::ReportBuilder
include DateRangeHelper
include ReportHelper
attr_reader :account, :params
DEFAULT_GROUP_BY = 'day'.freeze
@@ -11,10 +11,6 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def fetch_conversations_count
account.conversations.where(created_at: range).group('assignee_id').count
end
def prepare_report
account.account_users.map do |account_user|
build_agent_stats(account_user)
+29 -32
View File
@@ -1,5 +1,6 @@
class V2::Reports::BaseSummaryBuilder
include DateRangeHelper
include TimezoneHelper
def build
load_data
@@ -9,37 +10,13 @@ class V2::Reports::BaseSummaryBuilder
private
def load_data
@conversations_count = fetch_conversations_count
load_reporting_events_data
end
results = data_source.summary
def load_reporting_events_data
# Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id')
index_key = group_by_key.to_s.split('.').last
results = reporting_events
.select(
"#{group_by_key} as #{index_key}",
"COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count",
"AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time",
"AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time",
"AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time"
)
.group(group_by_key)
.index_by { |record| record.public_send(index_key) }
@resolved_count = results.transform_values(&:resolved_count)
@avg_resolution_time = results.transform_values(&:avg_resolution_time)
@avg_first_response_time = results.transform_values(&:avg_first_response_time)
@avg_reply_time = results.transform_values(&:avg_reply_time)
end
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range)
end
def fetch_conversations_count
# Override this method
@conversations_count = results.transform_values { |data| data[:conversations_count] }
@resolved_count = results.transform_values { |data| data[:resolved_conversations_count] }
@avg_resolution_time = results.transform_values { |data| data[:avg_resolution_time] }
@avg_first_response_time = results.transform_values { |data| data[:avg_first_response_time] }
@avg_reply_time = results.transform_values { |data| data[:avg_reply_time] }
end
def group_by_key
@@ -50,7 +27,27 @@ class V2::Reports::BaseSummaryBuilder
# Override this method
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
def data_source
@data_source ||= Reports::DataSource.for(
account: account,
metric: nil,
dimension_type: summary_dimension_type,
dimension_id: nil,
scope: nil,
range: range,
group_by: 'day',
timezone: timezone_name_from_params(params[:timezone], params[:timezone_offset]),
timezone_offset: params[:timezone_offset],
business_hours: params[:business_hours]
)
end
def summary_dimension_type
{
'account_id' => 'account',
'user_id' => 'agent',
'inbox_id' => 'inbox',
'conversations.team_id' => 'team'
}.fetch(group_by_key.to_s)
end
end
@@ -3,23 +3,10 @@ class V2::Reports::Conversations::BaseReportBuilder
private
AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze
COUNT_METRICS = %w[
conversations_count
incoming_messages_count
outgoing_messages_count
resolutions_count
bot_resolutions_count
bot_handoffs_count
].freeze
def builder_class(metric)
case metric
when *AVG_METRICS
V2::Reports::Timeseries::AverageReportBuilder
when *COUNT_METRICS
V2::Reports::Timeseries::CountReportBuilder
end
return unless Reports::ReportMetricRegistry.supported?(metric)
V2::Reports::Timeseries::ReportBuilder
end
def log_invalid_metric
@@ -11,15 +11,6 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def load_data
@conversations_count = fetch_conversations_count
load_reporting_events_data
end
def fetch_conversations_count
account.conversations.where(created_at: range).group(group_by_key).count
end
def prepare_report
account.inboxes.map do |inbox|
build_inbox_stats(inbox)
@@ -40,8 +31,4 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
def group_by_key
:inbox_id
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
end
end
@@ -7,8 +7,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
@account = account
@params = params
timezone_offset = (params[:timezone_offset] || 0).to_f
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
@timezone = timezone_name_from_params(params[:timezone], params[:timezone_offset])
end
# rubocop:enable Lint/MissingSuper
@@ -6,14 +6,6 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def fetch_conversations_count
account.conversations.where(created_at: range).group(:team_id).count
end
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation)
end
def prepare_report
account.teams.map do |team|
build_team_stats(team)
@@ -1,48 +0,0 @@
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
grouped_average_time = reporting_events.average(average_value_key)
grouped_event_count = reporting_events.count
grouped_average_time.each_with_object([]) do |element, arr|
event_date, average_time = element
arr << {
value: average_time,
timestamp: event_date.in_time_zone(timezone).to_i,
count: grouped_event_count[event_date]
}
end
end
def aggregate_value
object_scope.average(average_value_key)
end
private
def event_name
metric_to_event_name = {
avg_first_response_time: :first_response,
avg_resolution_time: :conversation_resolved,
reply_time: :reply_time
}
metric_to_event_name[params[:metric].to_sym]
end
def object_scope
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id)
end
def reporting_events
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
)
end
def average_value_key
@average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value
end
end
@@ -1,12 +1,13 @@
class V2::Reports::Timeseries::BaseTimeseriesBuilder
include TimezoneHelper
include DateRangeHelper
DEFAULT_GROUP_BY = 'day'.freeze
pattr_initialize :account, :params
def scope
case params[:type].to_sym
case dimension_type.to_sym
when :account
account
when :inbox
@@ -20,6 +21,21 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
end
end
def data_source
@data_source ||= Reports::DataSource.for(
account: account,
metric: params[:metric],
dimension_type: dimension_type,
dimension_id: params[:id],
scope: scope,
range: range,
group_by: group_by,
timezone: timezone,
timezone_offset: params[:timezone_offset],
business_hours: params[:business_hours]
)
end
def inbox
@inbox ||= account.inboxes.find(params[:id])
end
@@ -41,6 +57,12 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
end
def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
@timezone ||= timezone_name_from_params(params[:timezone], params[:timezone_offset])
end
private
def dimension_type
(params[:type].presence || 'account').to_s
end
end
@@ -1,78 +0,0 @@
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
grouped_count.each_with_object([]) do |element, arr|
event_date, event_count = element
# The `event_date` is in Date format (without time), such as "Wed, 15 May 2024".
# We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i`
# because it converts the date to 12:00 AM server timezone.
# The desired output should be 12:00 AM in the specified timezone.
arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
end
end
def aggregate_value
object_scope.count
end
private
def metric
@metric ||= params[:metric]
end
def object_scope
send("scope_for_#{metric}")
end
def scope_for_conversations_count
scope.conversations.where(account_id: account.id, created_at: range)
end
def scope_for_incoming_messages_count
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
end
def scope_for_outgoing_messages_count
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
end
def scope_for_resolutions_count
scope.reporting_events.where(
name: :conversation_resolved,
account_id: account.id,
created_at: range
)
end
def scope_for_bot_resolutions_count
scope.reporting_events.where(
name: :conversation_bot_resolved,
account_id: account.id,
created_at: range
)
end
def scope_for_bot_handoffs_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_handoff,
account_id: account.id,
created_at: range
).distinct
end
def grouped_count
# IMPORTANT: time_zone parameter affects both data grouping AND output timestamps
# It converts timestamps to the target timezone before grouping, which means
# the same event can fall into different day buckets depending on timezone
# Example: 2024-01-15 00:00 UTC becomes 2024-01-14 16:00 PST (falls on different day)
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
).count
end
end
@@ -0,0 +1,9 @@
class V2::Reports::Timeseries::ReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
data_source.timeseries
end
def aggregate_value
data_source.aggregate
end
end
@@ -112,6 +112,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
common_params.merge({
since: range[:current][:since],
until: range[:current][:until],
timezone: params[:timezone],
timezone_offset: params[:timezone_offset]
})
end
@@ -120,6 +121,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
common_params.merge({
since: range[:previous][:since],
until: range[:previous][:until],
timezone: params[:timezone],
timezone_offset: params[:timezone_offset]
})
end
@@ -129,6 +131,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
metric: params[:metric],
since: params[:since],
until: params[:until],
timezone: params[:timezone],
timezone_offset: params[:timezone_offset]
})
end
@@ -3,15 +3,15 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
render_report_with(V2::Reports::AgentSummaryBuilder, type: :agent)
end
def team
render_report_with(V2::Reports::TeamSummaryBuilder)
render_report_with(V2::Reports::TeamSummaryBuilder, type: :team)
end
def inbox
render_report_with(V2::Reports::InboxSummaryBuilder)
render_report_with(V2::Reports::InboxSummaryBuilder, type: :inbox)
end
def label
@@ -36,15 +36,18 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
until: permitted_params[:until],
business_hours: ActiveModel::Type::Boolean.new.cast(permitted_params[:business_hours])
}
@builder_params[:timezone] = permitted_params[:timezone] if permitted_params[:timezone].present?
@builder_params[:timezone_offset] = permitted_params[:timezone_offset] if permitted_params[:timezone_offset].present?
end
def render_report_with(builder_class)
builder = builder_class.new(account: Current.account, params: @builder_params)
def render_report_with(builder_class, type: nil)
builder_params = type.present? ? @builder_params.merge(type: type) : @builder_params
builder = builder_class.new(account: Current.account, params: builder_params)
render json: builder.build
end
def permitted_params
params.permit(:since, :until, :business_hours)
params.permit(:since, :until, :business_hours, :timezone, :timezone_offset)
end
def date_range_too_long?
+6
View File
@@ -1,4 +1,10 @@
module TimezoneHelper
def timezone_name_from_params(timezone, offset)
return timezone if timezone.present? && ActiveSupport::TimeZone[timezone].present?
timezone_name_from_offset(offset)
end
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
# would return the timezone without considering day light savings. To get the correct timezone,
# this method uses zone.now.utc_offset for comparison as referenced in the issues below
+5
View File
@@ -2,6 +2,7 @@
import ApiClient from './ApiClient';
const getTimeOffset = () => -new Date().getTimezoneOffset() / 60;
const getTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
class ReportsAPI extends ApiClient {
constructor() {
@@ -26,6 +27,7 @@ class ReportsAPI extends ApiClient {
id,
group_by: groupBy,
business_hours: businessHours,
timezone: getTimeZone(),
timezone_offset: getTimeOffset(),
},
});
@@ -41,6 +43,7 @@ class ReportsAPI extends ApiClient {
id,
group_by: groupBy,
business_hours: businessHours,
timezone: getTimeZone(),
timezone_offset: getTimeOffset(),
},
});
@@ -105,6 +108,8 @@ class ReportsAPI extends ApiClient {
type: 'account',
group_by: groupBy,
business_hours: businessHours,
timezone: getTimeZone(),
timezone_offset: getTimeOffset(),
},
});
}
@@ -2,6 +2,8 @@ import reportsAPI from '../reports';
import ApiClient from '../ApiClient';
describe('#Reports API', () => {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
it('creates correct instance', () => {
expect(reportsAPI).toBeInstanceOf(ApiClient);
expect(reportsAPI.apiVersion).toBe('/api/v2');
@@ -46,6 +48,7 @@ describe('#Reports API', () => {
since: 1621103400,
until: 1621621800,
type: 'account',
timezone,
timezone_offset: -0,
},
});
@@ -59,6 +62,7 @@ describe('#Reports API', () => {
group_by: undefined,
id: undefined,
since: 1621103400,
timezone,
timezone_offset: -0,
type: 'account',
until: 1621621800,
@@ -140,6 +144,8 @@ describe('#Reports API', () => {
type: 'account',
group_by: 'date',
business_hours: true,
timezone,
timezone_offset: -0,
},
}
);
@@ -0,0 +1,47 @@
import summaryReportsAPI from '../summaryReports';
import ApiClient from '../ApiClient';
describe('#Summary Reports API', () => {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
it('creates correct instance', () => {
expect(summaryReportsAPI).toBeInstanceOf(ApiClient);
expect(summaryReportsAPI.apiVersion).toBe('/api/v2');
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
get: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('includes timezone data in summary report requests', () => {
summaryReportsAPI.getAgentReports({
since: 1621103400,
until: 1621621800,
businessHours: true,
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v2/summary_reports/agent',
{
params: {
since: 1621103400,
until: 1621621800,
business_hours: true,
timezone,
timezone_offset: -0,
},
}
);
});
});
});
@@ -1,6 +1,9 @@
/* global axios */
import ApiClient from './ApiClient';
const getTimeOffset = () => -new Date().getTimezoneOffset() / 60;
const getTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
class SummaryReportsAPI extends ApiClient {
constructor() {
super('summary_reports', { accountScoped: true, apiVersion: 'v2' });
@@ -12,6 +15,8 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone: getTimeZone(),
timezone_offset: getTimeOffset(),
},
});
}
@@ -22,6 +27,8 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone: getTimeZone(),
timezone_offset: getTimeOffset(),
},
});
}
@@ -32,6 +39,8 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone: getTimeZone(),
timezone_offset: getTimeOffset(),
},
});
}
@@ -42,6 +51,8 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone: getTimeZone(),
timezone_offset: getTimeOffset(),
},
});
}
+18 -1
View File
@@ -21,6 +21,7 @@ class ReportingEventListener < BaseListener
create_bot_resolved_event(conversation, reporting_event)
reporting_event.save!
safe_rollup(reporting_event)
end
def first_reply_created(event)
@@ -42,6 +43,7 @@ class ReportingEventListener < BaseListener
)
reporting_event.save!
safe_rollup(reporting_event)
end
def reply_created(event)
@@ -66,13 +68,16 @@ class ReportingEventListener < BaseListener
event_end_time: message.created_at
)
reporting_event.save!
safe_rollup(reporting_event)
end
def conversation_bot_handoff(event)
conversation = extract_conversation_and_account(event)[0]
event_end_time = event.timestamp
# check if a conversation_bot_handoff event exists for this conversation
# Best-effort guard: raw report reads count bot handoffs with DISTINCT conversation_id,
# while rollup counts assume one conversation_bot_handoff event per conversation.
# That uniqueness is not currently enforced at the database level.
bot_handoff_event = ReportingEvent.find_by(conversation_id: conversation.id, name: 'conversation_bot_handoff')
return if bot_handoff_event.present?
@@ -90,6 +95,7 @@ class ReportingEventListener < BaseListener
event_end_time: event_end_time
)
reporting_event.save!
safe_rollup(reporting_event)
end
def conversation_captain_inference_resolved(event)
@@ -166,5 +172,16 @@ class ReportingEventListener < BaseListener
bot_resolved_event = reporting_event.dup
bot_resolved_event.name = 'conversation_bot_resolved'
bot_resolved_event.save!
safe_rollup(bot_resolved_event)
end
def safe_rollup(reporting_event)
# Rollups are derived from the raw reporting event. If a transient rollup write
# failure bubbles out here, Sidekiq retries the dispatcher job and can insert the
# same raw event again. That can temporarily under-report rollups, but the source
# event is preserved and rollup data can be rebuilt or re-applied later.
ReportingEvents::RollupService.perform(reporting_event)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: reporting_event.account).capture_exception
end
end
+8
View File
@@ -85,11 +85,13 @@ class Account < ApplicationRecord
validates_with JsonSchemaValidator,
schema: SETTINGS_PARAMS_SCHEMA,
attribute_resolver: ->(record) { record.settings }
validate :validate_reporting_timezone
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
store_accessor :settings, :captain_models, :captain_features
store_accessor :settings, :reporting_timezone
store_accessor :settings, :keep_pending_on_bot_failure
store_accessor :settings, :captain_auto_resolve_mode
include AccountCaptainAutoResolve
@@ -215,6 +217,12 @@ class Account < ApplicationRecord
# method overridden in enterprise module
end
def validate_reporting_timezone
return if reporting_timezone.blank? || ActiveSupport::TimeZone[reporting_timezone].present?
errors.add(:reporting_timezone, I18n.t('errors.account.reporting_timezone.invalid'))
end
def remove_account_sequences
ActiveRecord::Base.connection.exec_query("drop sequence IF EXISTS camp_dpid_seq_#{id}")
ActiveRecord::Base.connection.exec_query("drop sequence IF EXISTS conv_dpid_seq_#{id}")
+48
View File
@@ -0,0 +1,48 @@
# == Schema Information
#
# Table name: reporting_events_rollups
#
# id :bigint not null, primary key
# count :bigint default(0), not null
# date :date not null
# dimension_id :bigint not null
# dimension_type :string not null
# metric :string not null
# sum_value :float default(0.0), not null
# sum_value_business_hours :float default(0.0), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
#
# Indexes
#
# index_rollup_summary (account_id,dimension_type,date)
# index_rollup_timeseries (account_id,metric,date)
# index_rollup_unique_key (account_id,date,dimension_type,dimension_id,metric) UNIQUE
#
class ReportingEventsRollup < ApplicationRecord
belongs_to :account
# Store string values directly in the database for better readability and debugging
enum :dimension_type, %w[account agent inbox team].index_by(&:itself)
enum :metric, %w[
resolutions_count
first_response
resolution_time
reply_time
bot_resolutions_count
bot_handoffs_count
].index_by(&:itself)
validates :account_id, presence: true
validates :date, presence: true
validates :dimension_type, presence: true
validates :dimension_id, presence: true
validates :metric, presence: true
validates :count, numericality: { greater_than_or_equal_to: 0 }
scope :for_date_range, ->(start_date, end_date) { where(date: start_date..end_date) }
scope :for_dimension, ->(type, id) { where(dimension_type: type, dimension_id: id) }
scope :for_metric, ->(metric) { where(metric: metric) }
end
@@ -0,0 +1,142 @@
# frozen_string_literal: true
class ReportingEvents::BackfillService
DIMENSIONS = [
{ type: 'account', group_column: nil },
{ type: 'agent', group_column: :user_id },
{ type: 'inbox', group_column: :inbox_id }
].freeze
# TODO: Move this to EventMetricRegistry when we expand distinct-counting support.
# The live path already guards uniqueness in ReportingEventListener#conversation_bot_handoff,
# but historical duplicates can exist since it's not enforced at the DB level.
# These events are queried per-dimension (not group-then-sum) because COUNT(DISTINCT) is not additive.
DISTINCT_COUNT_EVENTS = %w[conversation_bot_handoff].freeze
DISTINCT_COUNT_SQL = Arel.sql('COUNT(DISTINCT conversation_id)')
def self.backfill_date(account, date)
new(account, date).perform
end
def initialize(account, date)
@account = account
@date = date
end
def perform
start_utc, end_utc = date_boundaries_in_utc
rollup_rows = build_rollup_rows(start_utc, end_utc)
ReportingEventsRollup.transaction do
delete_existing_rollups
bulk_insert_rollups(rollup_rows) if rollup_rows.any?
end
end
private
def delete_existing_rollups
ReportingEventsRollup.where(account_id: @account.id, date: @date).delete_all
end
def date_boundaries_in_utc
tz = ActiveSupport::TimeZone[@account.reporting_timezone]
start_in_tz = tz.parse(@date.to_s)
end_in_tz = start_in_tz + 1.day
[start_in_tz.utc, end_in_tz.utc]
end
def build_rollup_rows(start_utc, end_utc)
aggregates = build_aggregates(start_utc, end_utc)
aggregates.map do |(dimension_type, dimension_id, metric), data|
{
account_id: @account.id,
date: @date,
dimension_type: dimension_type,
dimension_id: dimension_id,
metric: metric,
count: data[:count],
sum_value: data[:sum_value],
sum_value_business_hours: data[:sum_value_business_hours],
created_at: Time.current,
updated_at: Time.current
}
end
end
def build_aggregates(start_utc, end_utc)
aggregates = Hash.new { |h, k| h[k] = { count: 0, sum_value: 0.0, sum_value_business_hours: 0.0 } }
standard_names = ReportingEvents::EventMetricRegistry.event_names - DISTINCT_COUNT_EVENTS
base = @account.reporting_events.where(created_at: start_utc...end_utc)
DIMENSIONS.each do |dimension|
aggregate_standard_events(aggregates, base.where(name: standard_names), dimension)
aggregate_distinct_events(aggregates, base.where(name: DISTINCT_COUNT_EVENTS), dimension)
end
aggregates
end
def aggregate_standard_events(aggregates, scope, dimension)
group_cols, selects = dimension_groups_and_selects(dimension)
scope.group(*group_cols).pluck(*selects).each do |row|
event_name, dimension_id, count, sum_value, sum_value_business_hours = unpack_row(row, dimension)
next if dimension_id.nil?
accumulate_metrics(aggregates, dimension[:type], dimension_id, event_name,
{ count: count, sum_value: sum_value, sum_value_business_hours: sum_value_business_hours })
end
end
def accumulate_metrics(aggregates, dimension_type, dimension_id, event_name, values)
ReportingEvents::EventMetricRegistry.metrics_for_aggregate(event_name, **values).each do |metric, metric_data|
key = [dimension_type, dimension_id, metric]
aggregates[key][:count] += metric_data[:count]
aggregates[key][:sum_value] += metric_data[:sum_value].to_f
aggregates[key][:sum_value_business_hours] += metric_data[:sum_value_business_hours].to_f
end
end
def aggregate_distinct_events(aggregates, scope, dimension)
return if DISTINCT_COUNT_EVENTS.empty?
group_cols = dimension[:group_column] ? [:name, dimension[:group_column]] : [:name]
scope.group(*group_cols).pluck(*group_cols, DISTINCT_COUNT_SQL).each do |row|
event_name, dimension_id, count = dimension[:group_column] ? row : [row[0], @account.id, row[1]]
next if dimension_id.nil?
accumulate_metrics(aggregates, dimension[:type], dimension_id, event_name,
{ count: count, sum_value: 0, sum_value_business_hours: 0 })
end
end
def dimension_groups_and_selects(dimension)
agg_selects = [Arel.sql('COUNT(*)'), Arel.sql('COALESCE(SUM(value), 0)'), Arel.sql('COALESCE(SUM(value_in_business_hours), 0)')]
if dimension[:group_column]
[[:name, dimension[:group_column]], [:name, dimension[:group_column], *agg_selects]]
else
[[:name], [:name, *agg_selects]]
end
end
def unpack_row(row, dimension)
if dimension[:group_column]
# [name, dimension_id, count, sum_value, sum_value_business_hours]
row
else
# [name, count, sum_value, sum_value_business_hours] → inject account id
[row[0], @account.id, row[1], row[2], row[3]]
end
end
def bulk_insert_rollups(rollup_rows)
# rubocop:disable Rails/SkipsModelValidations
ReportingEventsRollup.insert_all(rollup_rows)
# rubocop:enable Rails/SkipsModelValidations
end
end
@@ -0,0 +1,79 @@
module ReportingEvents::EventMetricRegistry
# Describes one rollup metric emitted by a raw reporting event.
# rollup_metric: metric name stored in reporting_events_rollups.
# payload_kind: whether the emitted row carries only a count or a duration payload.
Metric = Data.define(:rollup_metric, :payload_kind)
EVENTS = {
conversation_resolved: [
Metric.new(rollup_metric: :resolutions_count, payload_kind: :count),
Metric.new(rollup_metric: :resolution_time, payload_kind: :duration)
].freeze,
first_response: [
Metric.new(rollup_metric: :first_response, payload_kind: :duration)
].freeze,
reply_time: [
Metric.new(rollup_metric: :reply_time, payload_kind: :duration)
].freeze,
conversation_bot_resolved: [
Metric.new(rollup_metric: :bot_resolutions_count, payload_kind: :count)
].freeze,
conversation_bot_handoff: [
Metric.new(rollup_metric: :bot_handoffs_count, payload_kind: :count)
].freeze
}.freeze
module_function
def event_names
EVENTS.keys.map(&:to_s)
end
def metrics_for(event)
return {} if event.blank?
metrics_for_aggregate(
event.name,
count: 1,
sum_value: event.try(:value),
sum_value_business_hours: event.try(:value_in_business_hours)
)
end
def metrics_for_aggregate(event_name, count:, sum_value:, sum_value_business_hours:)
return {} if event_name.blank?
values = {
count: count.to_i,
sum_value: sum_value.to_f,
sum_value_business_hours: sum_value_business_hours.to_f
}
EVENTS.fetch(event_name.to_sym, []).to_h do |metric|
[metric.rollup_metric, metric_values(metric.payload_kind, values)]
end
end
private_class_method def metric_values(payload_kind, values)
case payload_kind
when :count
count_values(values[:count])
when :duration
duration_values(values)
else
raise ArgumentError, "Unknown metric payload kind: #{payload_kind.inspect}"
end
end
private_class_method def count_values(count)
{ count: count, sum_value: 0, sum_value_business_hours: 0 }
end
private_class_method def duration_values(values)
{
count: values[:count],
sum_value: values[:sum_value],
sum_value_business_hours: values[:sum_value_business_hours]
}
end
end
@@ -0,0 +1,107 @@
# Raw reporting events and rollup rows do not share a single metric namespace; this registry keeps write and read paths aligned.
# TODO: Split this into separate registries for raw event mappings and report metric definitions.
module ReportingEvents::MetricRegistry
# Maps report summary response keys to the metric definitions they read from.
SUMMARY_METRICS = {
resolutions_count: :resolved_conversations_count,
avg_resolution_time: :avg_resolution_time,
avg_first_response_time: :avg_first_response_time,
reply_time: :avg_reply_time
}.freeze
# Expands each raw reporting event into the rollup metric payloads persisted for aggregation.
EVENT_METRICS = {
'conversation_resolved' => lambda do |values|
{
resolutions_count: count_metric(values[:count]),
resolution_time: duration_metric(values)
}
end,
'first_response' => ->(values) { { first_response: duration_metric(values) } },
'reply_time' => ->(values) { { reply_time: duration_metric(values) } },
'conversation_bot_resolved' => ->(values) { { bot_resolutions_count: count_metric(values[:count]) } },
'conversation_bot_handoff' => ->(values) { { bot_handoffs_count: count_metric(values[:count]) } }
}.freeze
# Describes which report metrics are supported and how each one is sourced and aggregated.
REPORT_METRICS = {
conversations_count: { aggregate: :count }.freeze,
incoming_messages_count: { aggregate: :count }.freeze,
outgoing_messages_count: { aggregate: :count }.freeze,
avg_first_response_time: { raw_event_name: :first_response, rollup_metric: :first_response, aggregate: :average }.freeze,
avg_resolution_time: { raw_event_name: :conversation_resolved, rollup_metric: :resolution_time, aggregate: :average }.freeze,
reply_time: { raw_event_name: :reply_time, rollup_metric: :reply_time, aggregate: :average }.freeze,
resolutions_count: { raw_event_name: :conversation_resolved, rollup_metric: :resolutions_count, aggregate: :count }.freeze,
bot_resolutions_count: { raw_event_name: :conversation_bot_resolved, rollup_metric: :bot_resolutions_count, aggregate: :count }.freeze,
bot_handoffs_count: { raw_event_name: :conversation_bot_handoff, rollup_metric: :bot_handoffs_count, aggregate: :count,
raw_count_strategy: :distinct_conversation }.freeze
}.freeze
module_function
def event_metrics_for(event)
return {} if event.blank?
return {} unless EVENT_METRICS.key?(event.name.to_s)
event_metrics_for_aggregate(
event.name,
count: 1,
sum_value: event.try(:value),
sum_value_business_hours: event.try(:value_in_business_hours)
)
end
def event_metrics_for_aggregate(event_name, count:, sum_value:, sum_value_business_hours:)
values = {
count: count.to_i,
sum_value: sum_value.to_f,
sum_value_business_hours: sum_value_business_hours.to_f
}
EVENT_METRICS[event_name.to_s]&.call(values) || {}
end
def report_metric(metric)
return if metric.blank?
REPORT_METRICS[metric.to_sym]
end
def supported_metric?(metric)
report_metric(metric).present?
end
def aggregate_for(metric)
report_metric(metric)&.dig(:aggregate)
end
def rollup_supported_metric?(metric)
rollup_metric_for(metric).present?
end
def rollup_metric_for(metric)
report_metric(metric)&.dig(:rollup_metric)
end
def raw_event_name_for(metric)
report_metric(metric)&.dig(:raw_event_name)
end
def summary_metrics
SUMMARY_METRICS.map do |metric_name, summary_key|
report_metric(metric_name).merge(metric_name: metric_name, summary_key: summary_key)
end
end
private_class_method def count_metric(count)
{ count: count, sum_value: 0, sum_value_business_hours: 0 }
end
private_class_method def duration_metric(values)
{
count: values[:count],
sum_value: values[:sum_value],
sum_value_business_hours: values[:sum_value_business_hours]
}
end
end
@@ -0,0 +1,81 @@
class ReportingEvents::RollupService
def self.perform(reporting_event)
new(reporting_event).perform
end
def initialize(reporting_event)
@reporting_event = reporting_event
@account = reporting_event.account
end
def perform
return unless rollup_enabled?
rows = build_rollup_rows
return if rows.empty?
upsert_rollups(rows)
end
private
# NOTE: This is intentionally not gated by the reporting_events_rollup feature flag.
# Rollup data is collected for all accounts with a valid reporting timezone (soft toggle).
# The feature flag only controls the read path — whether reports query rollups or raw events.
def rollup_enabled?
@account.reporting_timezone.present? && ActiveSupport::TimeZone[@account.reporting_timezone].present?
end
def event_date
@event_date ||= @reporting_event.created_at.in_time_zone(@account.reporting_timezone).to_date
end
def dimensions
{
account: @account.id,
agent: @reporting_event.user_id,
inbox: @reporting_event.inbox_id
}
end
def build_rollup_rows
event_metrics = ReportingEvents::EventMetricRegistry.metrics_for(@reporting_event)
dimensions.each_with_object([]) do |(dimension_type, dimension_id), rows|
next if dimension_id.nil?
event_metrics.each do |metric, metric_data|
rows << rollup_attributes(dimension_type, dimension_id, metric, metric_data)
end
end
end
def upsert_rollups(rows)
# rubocop:disable Rails/SkipsModelValidations
ReportingEventsRollup.upsert_all(
rows,
unique_by: [:account_id, :date, :dimension_type, :dimension_id, :metric],
on_duplicate: upsert_on_duplicate_sql
)
# rubocop:enable Rails/SkipsModelValidations
end
def rollup_attributes(dimension_type, dimension_id, metric, metric_data)
{
account_id: @account.id, date: event_date,
dimension_type: dimension_type, dimension_id: dimension_id, metric: metric,
count: metric_data[:count], sum_value: metric_data[:sum_value].to_f,
sum_value_business_hours: metric_data[:sum_value_business_hours].to_f,
created_at: Time.current, updated_at: Time.current
}
end
def upsert_on_duplicate_sql
Arel.sql(
'count = reporting_events_rollups.count + EXCLUDED.count, ' \
'sum_value = reporting_events_rollups.sum_value + EXCLUDED.sum_value, ' \
'sum_value_business_hours = reporting_events_rollups.sum_value_business_hours + EXCLUDED.sum_value_business_hours, ' \
'updated_at = EXCLUDED.updated_at'
)
end
end
+106
View File
@@ -0,0 +1,106 @@
class Reports::DataSource
SUPPORTED_ROLLUP_DIMENSIONS = %w[account agent inbox].freeze
attr_reader :account, :metric, :dimension_type, :dimension_id,
:scope, :range, :group_by, :timezone,
:timezone_offset, :business_hours
class << self
def for(**context)
adapter_class_for(**context).new(**context)
end
def rollup_eligible?(**context)
account = context[:account]
rollup_enabled_for_account?(account) &&
!hourly_grouping?(context[:group_by]) &&
supported_dimension?(context[:dimension_type]) &&
timezone_matches_account?(account, context[:timezone], context[:timezone_offset]) &&
supported_metric?(context[:metric])
end
def timezone_matches_account?(account, timezone, timezone_offset)
return normalized_timezone_identifier(timezone) == normalized_timezone_identifier(account.reporting_timezone) if timezone.present?
return false if timezone_offset.blank?
offset_in_seconds = timezone_offset.to_f * 3600
account_zone = ActiveSupport::TimeZone[account.reporting_timezone]
account_zone&.now&.utc_offset == offset_in_seconds
end
private
def adapter_class_for(**context)
rollup_eligible?(**context) ? Reports::RollupDataSource : Reports::RawDataSource
end
def rollup_enabled_for_account?(account)
account.reporting_timezone.present? && account.feature_enabled?(:report_rollup)
end
def hourly_grouping?(group_by)
group_by.to_s == 'hour'
end
def supported_dimension?(dimension_type)
SUPPORTED_ROLLUP_DIMENSIONS.include?((dimension_type.presence || 'account').to_s)
end
def supported_metric?(metric)
metric.blank? || Reports::ReportMetricRegistry.rollup_supported?(metric)
end
def normalized_timezone_identifier(timezone)
ActiveSupport::TimeZone[timezone]&.tzinfo&.name
end
end
def initialize(**context)
@account = context[:account]
@metric = context[:metric]
@dimension_type = (context[:dimension_type].presence || 'account').to_s
@dimension_id = context[:dimension_id]
@scope = context[:scope]
@range = context[:range]
@group_by = context[:group_by].to_s.presence || 'day'
@timezone = context[:timezone]
@timezone_offset = context[:timezone_offset]
@business_hours = context[:business_hours]
end
private
def report_metric
@report_metric ||= Reports::ReportMetricRegistry.fetch(metric)
end
def average_metric?
report_metric&.average?
end
def count_metric?
!average_metric?
end
def rollup_metric
report_metric&.rollup_metric
end
def raw_event_name
report_metric&.raw_event_name
end
def raw_count_strategy
report_metric&.raw_count_strategy
end
def summary_metrics
@summary_metrics ||= Reports::ReportMetricRegistry.summary_metrics
end
def use_business_hours?
ActiveModel::Type::Boolean.new.cast(business_hours)
end
end
+156
View File
@@ -0,0 +1,156 @@
class Reports::RawDataSource < Reports::DataSource
def timeseries
average_metric? ? average_timeseries : count_timeseries
end
def aggregate
average_metric? ? average_scope.average(average_value_key) : count_scope.count
end
def summary
metric_results = summary_scope
.select(*summary_select_fields)
.group(summary_group_by_key)
.index_by { |record| record.public_send(summary_index_key) }
merge_summary_results(metric_results, summary_conversation_counts)
end
private
def count_timeseries
grouped_count.map do |event_date, event_count|
{ value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
end
end
def average_timeseries
grouped_average_time = grouped_average_scope.average(average_value_key)
grouped_event_count = grouped_average_scope.count
grouped_average_time.each_with_object([]) do |(event_date, average_time), results|
results << {
value: average_time,
timestamp: event_date.in_time_zone(timezone).to_i,
count: grouped_event_count[event_date]
}
end
end
def grouped_average_scope
average_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
)
end
def grouped_count
count_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
).count
end
def average_scope
scope.reporting_events.where(name: raw_event_name, created_at: range, account_id: account.id)
end
def count_scope
case metric.to_s
when 'conversations_count'
scope.conversations.where(account_id: account.id, created_at: range)
when 'incoming_messages_count'
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
when 'outgoing_messages_count'
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
else
reporting_event_count_scope
end
end
def reporting_event_count_scope
events = scope.reporting_events.where(
name: raw_event_name,
account_id: account.id,
created_at: range
)
return events unless raw_count_strategy == :distinct_conversation
events.joins(:conversation).select(:conversation_id).distinct
end
def summary_scope
scope = account.reporting_events.where(created_at: range)
return scope.joins(:conversation) if dimension_type == 'team'
scope
end
def summary_conversation_counts
account.conversations
.where(created_at: range)
.group(summary_conversation_group_by_key)
.count
end
def merge_summary_results(metric_results, conversation_counts)
(metric_results.keys | conversation_counts.keys).each_with_object({}) do |dimension_id, results|
record = metric_results[dimension_id]
results[dimension_id] = summary_attributes_for(record, conversation_counts[dimension_id])
end
end
def summary_select_fields
["#{summary_group_by_key} as #{summary_index_key}"] + summary_metrics.map { |definition| summary_select_field(definition) }
end
def summary_select_field(definition)
if definition.count?
"COUNT(CASE WHEN name = '#{definition.raw_event_name}' THEN 1 END) as #{definition.summary_key}"
else
"AVG(CASE WHEN name = '#{definition.raw_event_name}' THEN #{average_value_key} END) as #{definition.summary_key}"
end
end
def summary_attributes_for(record, conversations_count = 0)
summary_metrics.each_with_object({ conversations_count: conversations_count.to_i }) do |definition, attributes|
value = record&.public_send(definition.summary_key)
attributes[definition.summary_key] = definition.count? ? value.to_i : value
end
end
def summary_group_by_key
{
'account' => :account_id,
'agent' => :user_id,
'inbox' => :inbox_id,
'team' => 'conversations.team_id'
}[dimension_type]
end
def summary_conversation_group_by_key
{
'account' => :account_id,
'agent' => :assignee_id,
'inbox' => :inbox_id,
'team' => :team_id
}[dimension_type]
end
def summary_index_key
summary_group_by_key.to_s.split('.').last
end
def average_value_key
use_business_hours? ? :value_in_business_hours : :value
end
end
@@ -0,0 +1,120 @@
module Reports::ReportMetricRegistry
# Describes one public report metric.
# name: API-facing metric name requested by reports.
# aggregate: whether the metric is a count or average.
# raw_event_name: source reporting_events name for raw queries.
# rollup_metric: source reporting_events_rollups metric for rollup queries.
# summary_key: key used when this metric appears in grouped summary responses.
# raw_count_strategy: optional raw-query counting rule, such as distinct conversations.
Metric = Data.define(
:name,
:aggregate,
:raw_event_name,
:rollup_metric,
:summary_key,
:raw_count_strategy
) do
def initialize(name:, aggregate:, raw_event_name: nil, rollup_metric: nil, summary_key: nil, raw_count_strategy: nil)
super
end
def average?
aggregate == :average
end
def count?
aggregate == :count
end
def rollup_supported?
rollup_metric.present?
end
def summary?
summary_key.present?
end
end
METRICS = {
conversations_count: Metric.new(
name: :conversations_count,
aggregate: :count
),
incoming_messages_count: Metric.new(
name: :incoming_messages_count,
aggregate: :count
),
outgoing_messages_count: Metric.new(
name: :outgoing_messages_count,
aggregate: :count
),
avg_first_response_time: Metric.new(
name: :avg_first_response_time,
aggregate: :average,
raw_event_name: :first_response,
rollup_metric: :first_response,
summary_key: :avg_first_response_time
),
avg_resolution_time: Metric.new(
name: :avg_resolution_time,
aggregate: :average,
raw_event_name: :conversation_resolved,
rollup_metric: :resolution_time,
summary_key: :avg_resolution_time
),
reply_time: Metric.new(
name: :reply_time,
aggregate: :average,
raw_event_name: :reply_time,
rollup_metric: :reply_time,
summary_key: :avg_reply_time
),
resolutions_count: Metric.new(
name: :resolutions_count,
aggregate: :count,
raw_event_name: :conversation_resolved,
rollup_metric: :resolutions_count,
summary_key: :resolved_conversations_count
),
bot_resolutions_count: Metric.new(
name: :bot_resolutions_count,
aggregate: :count,
raw_event_name: :conversation_bot_resolved,
rollup_metric: :bot_resolutions_count
),
bot_handoffs_count: Metric.new(
name: :bot_handoffs_count,
aggregate: :count,
raw_event_name: :conversation_bot_handoff,
rollup_metric: :bot_handoffs_count,
raw_count_strategy: :distinct_conversation
)
}.freeze
SUMMARY_METRIC_NAMES = %i[
resolutions_count
avg_resolution_time
avg_first_response_time
reply_time
].freeze
module_function
def fetch(name)
return if name.blank?
METRICS[name.to_sym]
end
def supported?(name)
fetch(name).present?
end
def rollup_supported?(name)
fetch(name)&.rollup_supported? || false
end
def summary_metrics
SUMMARY_METRIC_NAMES.map { |metric_name| METRICS.fetch(metric_name) }
end
end
+199
View File
@@ -0,0 +1,199 @@
class Reports::RollupDataSource < Reports::DataSource
def timeseries
count_metric? ? count_timeseries : average_timeseries
end
def aggregate
count_metric? ? count_aggregate : average_aggregate
end
def summary
metric_results = summary_rows.index_by(&:dimension_id)
merge_summary_results(metric_results, summary_conversation_counts)
end
private
def count_timeseries
grouped_data = all_periods_in_range.index_with { 0 }
rollup_scope.each do |row|
date_key = normalized_period_key(row.date)
grouped_data[date_key] ||= 0
grouped_data[date_key] += row.count
end
results = grouped_data.map do |date_key, count|
{ value: count, timestamp: date_key.in_time_zone(timezone).to_i }
end
results.sort_by { |result| result[:timestamp] }
end
def average_timeseries
grouped_data = all_periods_in_range.index_with { { count: 0, sum_value: 0.0 } }
rollup_scope.each { |row| accumulate_average_row(grouped_data, row) }
results = grouped_data.map do |date_key, data|
{
value: data[:count].zero? ? 0 : data[:sum_value] / data[:count],
timestamp: date_key.in_time_zone(timezone).to_i,
count: data[:count]
}
end
results.sort_by { |result| result[:timestamp] }
end
def count_aggregate
rollup_scope.sum(:count).to_i
end
def average_aggregate
result = rollup_scope.pick(Arel.sql("SUM(count), SUM(#{rollup_value_column})"))
return nil if result.blank? || result[0].to_i.zero?
result[1].to_f / result[0].to_i
end
def rollup_scope
ReportingEventsRollup.where(
account_id: account.id,
metric: rollup_metric,
dimension_type: dimension_type,
dimension_id: dimension_id_for_rollup,
date: rollup_date_range
)
end
def summary_rows
ReportingEventsRollup.where(
account_id: account.id,
dimension_type: dimension_type,
date: rollup_date_range
).group(:dimension_id).select(*summary_select_fields)
end
def summary_conversation_counts
account.conversations
.where(created_at: range)
.group(summary_conversation_group_by_key)
.count
end
def merge_summary_results(metric_results, conversation_counts)
(metric_results.keys | conversation_counts.keys).index_with do |dimension_id|
summary_attributes_for(metric_results[dimension_id], conversation_counts[dimension_id])
end
end
def summary_select_fields
['dimension_id'] + summary_metrics.flat_map { |definition| summary_select_fields_for_metric(definition) }
end
def summary_select_fields_for_metric(definition)
return [sum_count_select(definition.rollup_metric, definition.summary_key)] if definition.count?
[
sum_count_select(definition.rollup_metric, summary_count_alias(definition)),
sum_value_select(definition.rollup_metric, summary_sum_alias(definition))
]
end
def sum_count_select(rollup_metric_name, alias_name)
"SUM(CASE WHEN metric = '#{rollup_metric_name}' THEN count ELSE 0 END) as #{alias_name}"
end
def sum_value_select(rollup_metric_name, alias_name)
"SUM(CASE WHEN metric = '#{rollup_metric_name}' THEN #{rollup_value_column} ELSE 0 END) as #{alias_name}"
end
def summary_attributes_for(row, conversations_count = 0)
summary_metrics.each_with_object({ conversations_count: conversations_count.to_i }) do |definition, attributes|
attributes[definition.summary_key] = summary_value_for(row, definition)
end
end
def summary_value_for(row, definition)
return row&.public_send(definition.summary_key).to_i if definition.count?
average_from(row&.public_send(summary_sum_alias(definition)), row&.public_send(summary_count_alias(definition)))
end
def summary_count_alias(definition)
"#{definition.summary_key}_count"
end
def summary_sum_alias(definition)
"#{definition.summary_key}_sum_value"
end
def dimension_id_for_rollup
dimension_type == 'account' ? account.id : scope.id
end
def summary_conversation_group_by_key
{
'account' => :account_id,
'agent' => :assignee_id,
'inbox' => :inbox_id,
'team' => :team_id
}[dimension_type]
end
def rollup_value_column
use_business_hours? ? :sum_value_business_hours : :sum_value
end
def rollup_date_range
tz = ActiveSupport::TimeZone[account.reporting_timezone]
start_date = range.first.in_time_zone(tz).to_date
end_date = (range.last - 1.second).in_time_zone(tz).to_date
start_date..end_date
end
def all_periods_in_range
current = normalized_period_key(rollup_date_range.first)
periods = []
while current <= rollup_date_range.last
periods << current
current = advance_period(current)
end
periods
end
def accumulate_average_row(grouped_data, row)
date_key = normalized_period_key(row.date)
grouped_data[date_key] ||= { count: 0, sum_value: 0.0 }
grouped_data[date_key][:count] += row.count
grouped_data[date_key][:sum_value] += row.public_send(rollup_value_column)
end
def normalized_period_key(date)
case group_by
when 'week' then date.beginning_of_week(:sunday)
when 'month' then date.beginning_of_month
when 'year' then date.beginning_of_year
else date
end
end
def advance_period(date)
case group_by
when 'week' then date + 1.week
when 'month' then date + 1.month
when 'year' then date + 1.year
else date + 1.day
end
end
def average_from(sum_value, count)
return nil if count.to_i.zero?
sum_value.to_f / count.to_i
end
end
+3
View File
@@ -48,6 +48,9 @@ en:
inbox_deletetion_response: Your inbox deletion request will be processed in some time.
errors:
account:
reporting_timezone:
invalid: is not a valid timezone
validations:
presence: must not be blank
webhook:
@@ -0,0 +1,39 @@
class CreateReportingEventsRollup < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
create_rollups_table
add_rollups_indexes
end
private
def create_rollups_table
create_table :reporting_events_rollups do |t|
t.integer :account_id, null: false
t.date :date, null: false
t.string :dimension_type, null: false
t.bigint :dimension_id, null: false
t.string :metric, null: false
t.bigint :count, default: 0, null: false
t.float :sum_value, default: 0.0, null: false
t.float :sum_value_business_hours, default: 0.0, null: false
t.timestamps
end
end
def add_rollups_indexes
add_index :reporting_events_rollups,
[:account_id, :date, :dimension_type, :dimension_id, :metric],
unique: true, name: 'index_rollup_unique_key', algorithm: :concurrently
add_index :reporting_events_rollups,
[:account_id, :metric, :date],
name: 'index_rollup_timeseries', algorithm: :concurrently
add_index :reporting_events_rollups,
[:account_id, :dimension_type, :date],
name: 'index_rollup_summary', algorithm: :concurrently
end
end
+16
View File
@@ -1124,6 +1124,22 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_153427) do
t.index ["user_id"], name: "index_reporting_events_on_user_id"
end
create_table "reporting_events_rollups", force: :cascade do |t|
t.integer "account_id", null: false
t.date "date", null: false
t.string "dimension_type", null: false
t.bigint "dimension_id", null: false
t.string "metric", null: false
t.bigint "count", default: 0, null: false
t.float "sum_value", default: 0.0, null: false
t.float "sum_value_business_hours", default: 0.0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "date", "dimension_type", "dimension_id", "metric"], name: "index_rollup_unique_key", unique: true
t.index ["account_id", "dimension_type", "date"], name: "index_rollup_summary"
t.index ["account_id", "metric", "date"], name: "index_rollup_timeseries"
end
create_table "sla_events", force: :cascade do |t|
t.bigint "applied_sla_id", null: false
t.bigint "conversation_id", null: false
+268
View File
@@ -0,0 +1,268 @@
# frozen_string_literal: true
namespace :reporting_events_rollup do
desc 'Backfill rollup table from historical reporting events'
task backfill: :environment do
ReportingEventsRollupBackfill.new.run
end
end
class ReportingEventsRollupBackfill # rubocop:disable Metrics/ClassLength
def run
print_header
account = prompt_account
timezone = resolve_timezone(account)
first_event, last_event = discover_events(account)
start_date, end_date, total_days = resolve_date_range(account, timezone, first_event, last_event)
dry_run = prompt_dry_run?
print_plan(account, timezone, start_date, end_date, total_days, first_event, last_event, dry_run)
return if dry_run
confirm_and_execute(account, start_date, end_date, total_days)
end
private
def print_header
puts ''
puts color('=' * 70, :cyan)
puts color('Reporting Events Rollup Backfill', :bold, :cyan)
puts color('=' * 70, :cyan)
puts color('Plan:', :bold, :yellow)
puts '1. Ensure account.reporting_timezone is set before running this task.'
puts '2. Wait for the current day to end in that account timezone.'
puts '3. Run backfill for closed days only (today is skipped by default).'
puts '4. Verify parity, then enable reporting_events_rollup read path.'
puts ''
puts color('Note:', :bold, :yellow)
puts '- This task always uses account.reporting_timezone.'
puts '- Default range is first event day -> yesterday (in account timezone).'
puts ''
end
def prompt_account
print 'Enter Account ID: '
account_id = $stdin.gets.chomp
abort color('Error: Account ID is required', :red, :bold) if account_id.blank?
account = Account.find_by(id: account_id)
abort color("Error: Account with ID #{account_id} not found", :red, :bold) unless account
puts color("Found account: #{account.name}", :gray)
puts ''
account
end
def resolve_timezone(account)
timezone = account.reporting_timezone
abort color("Error: Account #{account.id} must have reporting_timezone set", :red, :bold) if timezone.blank?
abort color("Error: Account #{account.id} has invalid reporting_timezone '#{timezone}'", :red, :bold) if ActiveSupport::TimeZone[timezone].blank?
puts color("Using account reporting timezone: #{timezone}", :gray)
puts ''
timezone
end
def discover_events(account)
first_event = account.reporting_events.order(:created_at).first
last_event = account.reporting_events.order(:created_at).last
if first_event.nil?
puts ''
puts "No reporting events found for account #{account.id}"
puts 'Nothing to backfill.'
exit(0)
end
[first_event, last_event]
end
def resolve_date_range(account, timezone, first_event, last_event)
dates = discovered_dates(timezone, first_event, last_event)
print_discovered_date_range(account, dates)
build_date_range(dates)
end
def prompt_dry_run?
print 'Dry run? (y/N): '
input = $stdin.gets.chomp.downcase
puts ''
%w[y yes].include?(input)
end
# rubocop:disable Metrics/ParameterLists
def print_plan(account, timezone, start_date, end_date, total_days, first_event, last_event, dry_run)
zone = ActiveSupport::TimeZone[timezone]
print_plan_summary(account, timezone, start_date, end_date, total_days, zone, first_event, last_event, dry_run)
return unless dry_run
puts color("DRY RUN MODE: Would process #{total_days} days", :yellow, :bold)
puts "Would use account reporting_timezone '#{timezone}'"
puts 'Run without dry run to execute backfill'
end
# rubocop:enable Metrics/ParameterLists
def print_plan_summary(account, timezone, start_date, end_date, total_days, zone, first_event, last_event, dry_run) # rubocop:disable Metrics/ParameterLists
puts color('=' * 70, :cyan)
puts color('Backfill Plan Summary', :bold, :cyan)
puts color('=' * 70, :cyan)
puts "Account: #{account.name} (ID: #{account.id})"
puts "Timezone: #{timezone}"
puts "Date Range: #{start_date} to #{end_date} (#{total_days} days)"
puts "First Event: #{format_event_time(first_event, zone)}"
puts "Last Event: #{format_event_time(last_event, zone)}"
puts "Dry Run: #{dry_run ? 'YES (no data will be written)' : 'NO'}"
puts color('=' * 70, :cyan)
puts ''
end
def format_event_time(event, zone)
event.created_at.in_time_zone(zone).strftime('%Y-%m-%d %H:%M:%S %Z')
end
def discovered_dates(timezone, first_event, last_event)
tz = ActiveSupport::TimeZone[timezone]
discovered_start = first_event.created_at.in_time_zone(tz).to_date
discovered_end = last_event.created_at.in_time_zone(tz).to_date
{
discovered_start: discovered_start,
discovered_end: discovered_end,
discovered_days: (discovered_end - discovered_start).to_i + 1,
default_end: [discovered_end, Time.current.in_time_zone(tz).to_date - 1.day].min
}
end
def print_discovered_date_range(account, dates)
message = "Discovered date range: #{dates[:discovered_start]} to #{dates[:discovered_end]} " \
"(#{dates[:discovered_days]} days) [Account: #{account.name}]"
puts color(message, :gray)
puts color("Default end date (excluding today): #{dates[:default_end]}", :gray)
puts ''
end
def build_date_range(dates)
start_date = dates[:discovered_start]
end_date = dates[:default_end]
total_days = (end_date - start_date).to_i + 1
abort_no_closed_days if total_days <= 0
[start_date, end_date, total_days]
end
def abort_no_closed_days
puts 'No closed days available to backfill in the default range.'
exit(0)
end
def confirm_and_execute(account, start_date, end_date, total_days)
if total_days > 730
puts color("WARNING: Large backfill detected (#{total_days} days / #{(total_days / 365.0).round(1)} years)", :yellow, :bold)
puts ''
end
print 'Proceed with backfill? (y/N): '
confirm = $stdin.gets.chomp.downcase
abort 'Backfill cancelled' unless %w[y yes].include?(confirm)
puts ''
execute_backfill(account, start_date, end_date, total_days)
end
def execute_backfill(account, start_date, end_date, total_days)
puts 'Processing dates...'
puts ''
start_time = Time.current
days_processed = 0
(start_date..end_date).each do |date|
ReportingEvents::BackfillService.backfill_date(account, date)
days_processed += 1
percentage = (days_processed.to_f / total_days * 100).round(1)
print "\r#{date} | #{days_processed}/#{total_days} days | #{percentage}% "
$stdout.flush
end
print_success(account, days_processed, total_days, Time.current - start_time)
rescue StandardError => e
print_failure(e, days_processed, total_days)
else
prompt_enable_rollup_read_path(account)
end
def print_success(account, days_processed, _total_days, elapsed_time)
puts "\n\n"
puts color('=' * 70, :green)
puts color('BACKFILL COMPLETE', :bold, :green)
puts color('=' * 70, :green)
puts "Total Days Processed: #{days_processed}"
puts "Total Time: #{elapsed_time.round(2)} seconds"
puts "Average per Day: #{(elapsed_time / days_processed).round(3)} seconds"
puts ''
puts 'Next steps:'
puts '1. Verify parity before enabling the reporting_events_rollup read path.'
puts '2. Verify rollups in database:'
puts " ReportingEventsRollup.where(account_id: #{account.id}).count"
puts '3. Test reports to compare rollup vs raw performance'
puts color('=' * 70, :green)
end
def prompt_enable_rollup_read_path(account)
if account.feature_enabled?(:report_rollup)
puts color('report_rollup is already enabled for this account.', :yellow, :bold)
return
end
print 'Enable report_rollup read path now? Only do this after parity verification. (y/N): '
confirm = $stdin.gets.to_s.chomp.downcase
puts ''
return unless %w[y yes].include?(confirm)
account.enable_features!('report_rollup')
puts color("Enabled report_rollup for account #{account.id}", :green, :bold)
end
def print_failure(error, days_processed, total_days)
puts "\n\n"
puts color('=' * 70, :red)
puts color('BACKFILL FAILED', :bold, :red)
puts color('=' * 70, :red)
print_error_details(error)
print_progress(days_processed, total_days)
exit(1)
end
def print_error_details(error)
puts color("Error: #{error.class.name} - #{error.message}", :red, :bold)
puts ''
puts 'Stack trace:'
puts error.backtrace.first(10).map { |line| " #{line}" }.join("\n")
puts ''
end
def print_progress(days_processed, total_days)
percentage = (days_processed.to_f / total_days * 100).round(1)
puts "Processed: #{days_processed}/#{total_days} days (#{percentage}%)"
puts color('=' * 70, :red)
end
ANSI_COLORS = {
reset: "\e[0m",
bold: "\e[1m",
red: "\e[31m",
green: "\e[32m",
yellow: "\e[33m",
cyan: "\e[36m",
gray: "\e[90m"
}.freeze
def color(text, *styles)
return text unless $stdout.tty?
codes = styles.filter_map { |style| ANSI_COLORS[style] }.join
"#{codes}#{text}#{ANSI_COLORS[:reset]}"
end
end
@@ -0,0 +1,196 @@
# frozen_string_literal: true
namespace :reporting_events_rollup do
desc 'Interactively set account.reporting_timezone and show recommended backfill run times'
task set_timezone: :environment do
ReportingEventsRollupTimezoneSetup.new.run
end
end
class ReportingEventsRollupTimezoneSetup
def run
print_header
account = prompt_account
print_current_timezone(account)
timezone = prompt_timezone
confirm_and_update(account, timezone)
print_next_steps(account, timezone)
end
private
def print_header
puts ''
puts color('=' * 70, :cyan)
puts color('Reporting Events Rollup Timezone Setup', :bold, :cyan)
puts color('=' * 70, :cyan)
puts color('Help:', :bold, :yellow)
puts '1. This task writes a valid account.reporting_timezone.'
puts '2. Backfill uses this timezone and skips today by default.'
puts '3. Run backfill only after the account timezone day closes.'
puts ''
end
def prompt_account
print 'Enter Account ID: '
account_id = $stdin.gets.chomp
abort color('Error: Account ID is required', :red, :bold) if account_id.blank?
account = Account.find_by(id: account_id)
abort color("Error: Account with ID #{account_id} not found", :red, :bold) unless account
puts color("Found account: #{account.name}", :gray)
puts ''
account
end
def print_current_timezone(account)
current_timezone = account.reporting_timezone.presence || '(not set)'
puts color("Current reporting_timezone: #{current_timezone}", :gray)
puts ''
end
def prompt_timezone
loop do
print 'Enter UTC offset to pick timezone (e.g., +5:30, -8, 0): '
offset_input = $stdin.gets.chomp
abort color('Error: UTC offset is required', :red, :bold) if offset_input.blank?
matching_zones = find_matching_zones(offset_input)
abort color("Error: No timezones found for offset '#{offset_input}'", :red, :bold) if matching_zones.empty?
display_matching_zones(matching_zones, offset_input)
timezone = select_timezone(matching_zones)
return timezone if timezone.present?
end
end
def find_matching_zones(offset_input)
total_seconds = utc_offset_in_seconds(offset_input)
return [] unless total_seconds
ActiveSupport::TimeZone.all.select { |tz| tz.utc_offset == total_seconds }
end
def utc_offset_in_seconds(offset_input)
normalized = offset_input.strip
return unless normalized.match?(/\A[+-]?\d{1,2}(:\d{2})?\z/)
sign = normalized.start_with?('-') ? -1 : 1
raw = normalized.delete_prefix('+').delete_prefix('-')
hours_part, minutes_part = raw.split(':', 2)
hours = Integer(hours_part, 10)
minutes = Integer(minutes_part || '0', 10)
return unless minutes.between?(0, 59)
total_minutes = (hours * 60) + minutes
return if total_minutes > max_utc_offset_minutes(sign)
sign * total_minutes * 60
rescue ArgumentError
nil
end
def max_utc_offset_minutes(sign)
sign.negative? ? 12 * 60 : 14 * 60
end
def display_matching_zones(zones, offset_input)
puts ''
puts color("Timezones matching UTC#{offset_input}:", :yellow, :bold)
puts ''
zones.each_with_index do |tz, index|
puts " #{index + 1}. #{tz.name} (#{tz.tzinfo.identifier})"
end
puts ' 0. Re-enter UTC offset'
puts ''
end
def select_timezone(zones)
print "Select timezone (1-#{zones.size}, 0 to go back): "
selection = $stdin.gets.chomp.to_i
return if selection.zero?
abort color('Error: Invalid selection', :red, :bold) if selection < 1 || selection > zones.size
timezone = zones[selection - 1].tzinfo.identifier
puts color("Selected timezone: #{timezone}", :gray)
puts ''
timezone
end
def confirm_and_update(account, timezone)
print "Update account #{account.id} reporting_timezone to '#{timezone}'? (y/N): "
confirm = $stdin.gets.chomp.downcase
abort 'Timezone setup cancelled' unless %w[y yes].include?(confirm)
account.update!(reporting_timezone: timezone)
puts ''
puts color("Updated reporting_timezone for account '#{account.name}' to '#{timezone}'", :green, :bold)
puts ''
end
def print_next_steps(account, timezone)
run_times = recommended_run_times(timezone)
print_next_steps_header
print_next_steps_schedule(timezone, run_times)
print_next_steps_backfill(account)
puts color('=' * 70, :green)
end
def print_next_steps_header
puts color('=' * 70, :green)
puts color('Next Steps', :bold, :green)
puts color('=' * 70, :green)
end
def print_next_steps_schedule(timezone, run_times)
puts "1. Wait for today's day-boundary to pass in #{timezone}."
puts '2. Recommended earliest backfill start time:'
puts " - #{timezone}: #{format_time(run_times[:account_tz])}"
puts " - UTC: #{format_time(run_times[:utc])}"
puts " - IST: #{format_time(run_times[:ist])}"
puts " - PCT/PT: #{format_time(run_times[:pct])}"
end
def print_next_steps_backfill(account)
puts '3. Run backfill:'
puts ' bundle exec rake reporting_events_rollup:backfill'
puts "4. Backfill will use account.reporting_timezone and skip today by default for account #{account.id}."
end
def recommended_run_times(timezone)
account_zone = ActiveSupport::TimeZone[timezone]
next_day = Time.current.in_time_zone(account_zone).to_date + 1.day
account_time = account_zone.parse(next_day.to_s) + 30.minutes
{
account_tz: account_time,
utc: account_time.in_time_zone('UTC'),
ist: account_time.in_time_zone('Asia/Kolkata'),
pct: account_time.in_time_zone('Pacific Time (US & Canada)')
}
end
def format_time(time)
time.strftime('%Y-%m-%d %H:%M:%S %Z')
end
ANSI_COLORS = {
reset: "\e[0m",
bold: "\e[1m",
red: "\e[31m",
green: "\e[32m",
yellow: "\e[33m",
cyan: "\e[36m",
gray: "\e[90m"
}.freeze
def color(text, *styles)
return text unless $stdout.tty?
codes = styles.filter_map { |style| ANSI_COLORS[style] }.join
"#{codes}#{text}#{ANSI_COLORS[:reset]}"
end
end
@@ -5,12 +5,10 @@ RSpec.describe V2::Reports::Conversations::MetricBuilder, type: :model do
let(:account) { create(:account) }
let(:params) { { since: '2023-01-01', until: '2024-01-01' } }
let(:count_builder_instance) { instance_double(V2::Reports::Timeseries::CountReportBuilder, aggregate_value: 42) }
let(:avg_builder_instance) { instance_double(V2::Reports::Timeseries::AverageReportBuilder, aggregate_value: 42) }
let(:builder_instance) { instance_double(V2::Reports::Timeseries::ReportBuilder, aggregate_value: 42) }
before do
allow(V2::Reports::Timeseries::CountReportBuilder).to receive(:new).and_return(count_builder_instance)
allow(V2::Reports::Timeseries::AverageReportBuilder).to receive(:new).and_return(avg_builder_instance)
allow(V2::Reports::Timeseries::ReportBuilder).to receive(:new).and_return(builder_instance)
end
describe '#summary' do
@@ -31,8 +29,8 @@ RSpec.describe V2::Reports::Conversations::MetricBuilder, type: :model do
it 'creates builders with proper params' do
subject.summary
expect(V2::Reports::Timeseries::CountReportBuilder).to have_received(:new).with(account, params.merge(metric: 'conversations_count'))
expect(V2::Reports::Timeseries::AverageReportBuilder).to have_received(:new).with(account, params.merge(metric: 'avg_first_response_time'))
expect(V2::Reports::Timeseries::ReportBuilder).to have_received(:new).with(account, params.merge(metric: 'conversations_count'))
expect(V2::Reports::Timeseries::ReportBuilder).to have_received(:new).with(account, params.merge(metric: 'avg_first_response_time'))
end
end
@@ -4,19 +4,19 @@ describe V2::Reports::Conversations::ReportBuilder do
subject { described_class.new(account, params) }
let(:account) { create(:account) }
let(:average_builder) { V2::Reports::Timeseries::AverageReportBuilder }
let(:count_builder) { V2::Reports::Timeseries::CountReportBuilder }
let(:builder) { V2::Reports::Timeseries::ReportBuilder }
shared_examples 'valid metric handler' do |metric, method, builder|
shared_examples 'valid metric handler' do |metric, method|
context 'when a valid metric is given' do
let(:params) { { metric: metric } }
it "calls the correct #{method} builder for #{metric}" do
it "calls the shared #{method} builder for #{metric}" do
builder_instance = instance_double(builder)
allow(builder).to receive(:new).and_return(builder_instance)
allow(builder_instance).to receive(method)
allow(builder_instance).to receive(method).and_return(:result)
builder_instance.public_send(method)
expect(subject.public_send(method)).to eq(:result)
expect(builder).to have_received(:new).with(account, params)
expect(builder_instance).to have_received(method)
end
end
@@ -33,12 +33,12 @@ describe V2::Reports::Conversations::ReportBuilder do
end
describe '#timeseries' do
it_behaves_like 'valid metric handler', 'avg_first_response_time', :timeseries, V2::Reports::Timeseries::AverageReportBuilder
it_behaves_like 'valid metric handler', 'conversations_count', :timeseries, V2::Reports::Timeseries::CountReportBuilder
it_behaves_like 'valid metric handler', 'avg_first_response_time', :timeseries
it_behaves_like 'valid metric handler', 'conversations_count', :timeseries
end
describe '#aggregate_value' do
it_behaves_like 'valid metric handler', 'avg_first_response_time', :aggregate_value, V2::Reports::Timeseries::AverageReportBuilder
it_behaves_like 'valid metric handler', 'conversations_count', :aggregate_value, V2::Reports::Timeseries::CountReportBuilder
it_behaves_like 'valid metric handler', 'avg_first_response_time', :aggregate_value
it_behaves_like 'valid metric handler', 'conversations_count', :aggregate_value
end
end
@@ -33,7 +33,13 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
it 'sets timezone from timezone_offset' do
builder_with_offset = described_class.new(account: account, params: { timezone_offset: -8 })
expect(builder_with_offset.instance_variable_get(:@timezone)).to eq('Pacific Time (US & Canada)')
expected_timezone = ActiveSupport::TimeZone.all.find { |zone| zone.now.utc_offset == -8.hours }.name
expect(builder_with_offset.instance_variable_get(:@timezone)).to eq(expected_timezone)
end
it 'prefers timezone when it is provided' do
builder_with_timezone = described_class.new(account: account, params: { timezone: 'Asia/Kolkata', timezone_offset: -8 })
expect(builder_with_timezone.instance_variable_get(:@timezone)).to eq('Asia/Kolkata')
end
it 'defaults timezone when timezone_offset is not provided' do
@@ -1,174 +0,0 @@
require 'rails_helper'
describe V2::Reports::Timeseries::AverageReportBuilder do
subject { described_class.new(account, params) }
let(:account) { create(:account) }
let(:team) { create(:team, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:label) { create(:label, title: 'spec-billing', account: account) }
let!(:conversation) { create(:conversation, account: account, inbox: inbox, team: team) }
let(:current_time) { '26.10.2020 10:00'.to_datetime }
let(:params) do
{
type: filter_type,
business_hours: business_hours,
timezone_offset: timezone_offset,
group_by: group_by,
metric: metric,
since: (current_time - 1.week).beginning_of_day.to_i.to_s,
until: current_time.end_of_day.to_i.to_s,
id: filter_id
}
end
let(:timezone_offset) { nil }
let(:group_by) { 'day' }
let(:metric) { 'avg_first_response_time' }
let(:business_hours) { false }
let(:filter_type) { :account }
let(:filter_id) { '' }
before do
travel_to current_time
conversation.label_list.add(label.title)
conversation.save!
create(:reporting_event, name: 'first_response', value: 80, value_in_business_hours: 10, account: account, created_at: Time.zone.now,
conversation: conversation, inbox: inbox)
create(:reporting_event, name: 'first_response', value: 100, value_in_business_hours: 20, account: account, created_at: 1.hour.ago)
create(:reporting_event, name: 'first_response', value: 93, value_in_business_hours: 30, account: account, created_at: 1.week.ago)
end
describe '#timeseries' do
context 'when there is no filter applied' do
it 'returns the correct values' do
timeseries_values = subject.timeseries
expect(timeseries_values).to eq(
[
{ count: 1, timestamp: 1_603_065_600, value: 93.0 },
{ count: 0, timestamp: 1_603_152_000, value: 0 },
{ count: 0, timestamp: 1_603_238_400, value: 0 },
{ count: 0, timestamp: 1_603_324_800, value: 0 },
{ count: 0, timestamp: 1_603_411_200, value: 0 },
{ count: 0, timestamp: 1_603_497_600, value: 0 },
{ count: 0, timestamp: 1_603_584_000, value: 0 },
{ count: 2, timestamp: 1_603_670_400, value: 90.0 }
]
)
end
context 'when business hours is provided' do
let(:business_hours) { true }
it 'returns correct timeseries' do
timeseries_values = subject.timeseries
expect(timeseries_values).to eq(
[
{ count: 1, timestamp: 1_603_065_600, value: 30.0 },
{ count: 0, timestamp: 1_603_152_000, value: 0 },
{ count: 0, timestamp: 1_603_238_400, value: 0 },
{ count: 0, timestamp: 1_603_324_800, value: 0 },
{ count: 0, timestamp: 1_603_411_200, value: 0 },
{ count: 0, timestamp: 1_603_497_600, value: 0 },
{ count: 0, timestamp: 1_603_584_000, value: 0 },
{ count: 2, timestamp: 1_603_670_400, value: 15.0 }
]
)
end
end
context 'when group_by is provided' do
let(:group_by) { 'week' }
it 'returns correct timeseries' do
timeseries_values = subject.timeseries
expect(timeseries_values).to eq(
[
{ count: 1, timestamp: (current_time - 1.week).beginning_of_week(:sunday).to_i, value: 93.0 },
{ count: 2, timestamp: current_time.beginning_of_week(:sunday).to_i, value: 90.0 }
]
)
end
end
context 'when timezone offset is provided' do
let(:timezone_offset) { '5.5' }
let(:group_by) { 'week' }
it 'returns correct timeseries' do
timeseries_values = subject.timeseries
expect(timeseries_values).to eq(
[
{ count: 1, timestamp: (current_time - 1.week).in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 93.0 },
{ count: 2, timestamp: current_time.in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 90.0 }
]
)
end
end
end
context 'when the label filter is applied' do
let(:group_by) { 'week' }
let(:filter_type) { 'label' }
let(:filter_id) { label.id }
it 'returns correct timeseries' do
timeseries_values = subject.timeseries
start_of_the_week = current_time.beginning_of_week(:sunday).to_i
last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i
expect(timeseries_values).to eq(
[
{ count: 0, timestamp: last_week_start_of_the_week, value: 0 },
{ count: 1, timestamp: start_of_the_week, value: 80.0 }
]
)
end
end
context 'when the inbox filter is applied' do
let(:group_by) { 'week' }
let(:filter_type) { 'inbox' }
let(:filter_id) { inbox.id }
it 'returns correct timeseries' do
timeseries_values = subject.timeseries
start_of_the_week = current_time.beginning_of_week(:sunday).to_i
last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i
expect(timeseries_values).to eq(
[
{ count: 0, timestamp: last_week_start_of_the_week, value: 0 },
{ count: 1, timestamp: start_of_the_week, value: 80.0 }
]
)
end
end
context 'when the team filter is applied' do
let(:group_by) { 'week' }
let(:filter_type) { 'team' }
let(:filter_id) { team.id }
it 'returns correct timeseries' do
timeseries_values = subject.timeseries
start_of_the_week = current_time.beginning_of_week(:sunday).to_i
last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i
expect(timeseries_values).to eq(
[
{ count: 0, timestamp: last_week_start_of_the_week, value: 0 },
{ count: 1, timestamp: start_of_the_week, value: 80.0 }
]
)
end
end
end
describe '#aggregate_value' do
context 'when there is no filter applied' do
it 'returns the correct average value' do
expect(subject.aggregate_value).to eq 91.0
end
end
end
end
@@ -1,113 +0,0 @@
require 'rails_helper'
describe V2::Reports::Timeseries::CountReportBuilder do
subject { described_class.new(account, params) }
let(:account) { create(:account) }
let(:account2) { create(:account) }
let(:user) { create(:user, email: 'agent1@example.com') }
let(:inbox) { create(:inbox, account: account) }
let(:inbox2) { create(:inbox, account: account2) }
let(:current_time) { Time.current }
let(:params) do
{
type: 'agent',
metric: 'resolutions_count',
since: (current_time - 1.day).beginning_of_day.to_i.to_s,
until: current_time.end_of_day.to_i.to_s,
id: user.id.to_s
}
end
before do
travel_to current_time
# Add the same user to both accounts
create(:account_user, account: account, user: user)
create(:account_user, account: account2, user: user)
# Create conversations in account1
conversation1 = create(:conversation, account: account, inbox: inbox, assignee: user)
conversation2 = create(:conversation, account: account, inbox: inbox, assignee: user)
# Create conversations in account2
conversation3 = create(:conversation, account: account2, inbox: inbox2, assignee: user)
conversation4 = create(:conversation, account: account2, inbox: inbox2, assignee: user)
# User resolves 2 conversations in account1
create(:reporting_event,
name: 'conversation_resolved',
account: account,
user: user,
conversation: conversation1,
created_at: current_time - 12.hours)
create(:reporting_event,
name: 'conversation_resolved',
account: account,
user: user,
conversation: conversation2,
created_at: current_time - 6.hours)
# Same user resolves 3 conversations in account2 - these should NOT be counted for account1
create(:reporting_event,
name: 'conversation_resolved',
account: account2,
user: user,
conversation: conversation3,
created_at: current_time - 8.hours)
create(:reporting_event,
name: 'conversation_resolved',
account: account2,
user: user,
conversation: conversation4,
created_at: current_time - 4.hours)
# Create another conversation in account2 for testing
conversation5 = create(:conversation, account: account2, inbox: inbox2, assignee: user)
create(:reporting_event,
name: 'conversation_resolved',
account: account2,
user: user,
conversation: conversation5,
created_at: current_time - 2.hours)
end
describe '#aggregate_value' do
it 'returns only resolutions performed by the user in the specified account' do
# User should have 2 resolutions in account1, not 5 (total across both accounts)
expect(subject.aggregate_value).to eq(2)
end
context 'when querying account2' do
subject { described_class.new(account2, params) }
it 'returns only resolutions for account2' do
# User should have 3 resolutions in account2
expect(subject.aggregate_value).to eq(3)
end
end
end
describe '#timeseries' do
it 'filters resolutions by account' do
result = subject.timeseries
# Should only count the 2 resolutions from account1
total_count = result.sum { |r| r[:value] }
expect(total_count).to eq(2)
end
end
describe 'account isolation' do
it 'does not leak data between accounts' do
# If account isolation works correctly, the counts should be different
account1_count = described_class.new(account, params).aggregate_value
account2_count = described_class.new(account2, params).aggregate_value
expect(account1_count).to eq(2)
expect(account2_count).to eq(3)
end
end
end
@@ -0,0 +1,470 @@
require 'rails_helper'
describe V2::Reports::Timeseries::ReportBuilder do
describe 'average metrics' do
subject { described_class.new(account, params) }
let(:account) { create(:account) }
let(:team) { create(:team, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:label) { create(:label, title: 'spec-billing', account: account) }
let!(:conversation) { create(:conversation, account: account, inbox: inbox, team: team) }
let(:current_time) { '26.10.2020 10:00'.to_datetime }
let(:params) do
{
type: filter_type,
business_hours: business_hours,
timezone: timezone,
timezone_offset: timezone_offset,
group_by: group_by,
metric: metric,
since: (current_time - 1.week).beginning_of_day.to_i.to_s,
until: current_time.end_of_day.to_i.to_s,
id: filter_id
}
end
let(:timezone) { nil }
let(:timezone_offset) { nil }
let(:group_by) { 'day' }
let(:metric) { 'avg_first_response_time' }
let(:business_hours) { false }
let(:filter_type) { :account }
let(:filter_id) { '' }
before do
travel_to current_time
conversation.label_list.add(label.title)
conversation.save!
create(:reporting_event, name: 'first_response', value: 80, value_in_business_hours: 10, account: account, created_at: Time.zone.now,
conversation: conversation, inbox: inbox)
create(:reporting_event, name: 'first_response', value: 100, value_in_business_hours: 20, account: account, created_at: 1.hour.ago)
create(:reporting_event, name: 'first_response', value: 93, value_in_business_hours: 30, account: account, created_at: 1.week.ago)
end
describe '#timeseries' do
it 'returns the correct values' do
timeseries_values = subject.timeseries
expect(timeseries_values).to eq(
[
{ count: 1, timestamp: 1_603_065_600, value: 93.0 },
{ count: 0, timestamp: 1_603_152_000, value: 0 },
{ count: 0, timestamp: 1_603_238_400, value: 0 },
{ count: 0, timestamp: 1_603_324_800, value: 0 },
{ count: 0, timestamp: 1_603_411_200, value: 0 },
{ count: 0, timestamp: 1_603_497_600, value: 0 },
{ count: 0, timestamp: 1_603_584_000, value: 0 },
{ count: 2, timestamp: 1_603_670_400, value: 90.0 }
]
)
end
context 'when business hours is provided' do
let(:business_hours) { true }
it 'returns correct timeseries' do
timeseries_values = subject.timeseries
expect(timeseries_values).to eq(
[
{ count: 1, timestamp: 1_603_065_600, value: 30.0 },
{ count: 0, timestamp: 1_603_152_000, value: 0 },
{ count: 0, timestamp: 1_603_238_400, value: 0 },
{ count: 0, timestamp: 1_603_324_800, value: 0 },
{ count: 0, timestamp: 1_603_411_200, value: 0 },
{ count: 0, timestamp: 1_603_497_600, value: 0 },
{ count: 0, timestamp: 1_603_584_000, value: 0 },
{ count: 2, timestamp: 1_603_670_400, value: 15.0 }
]
)
end
end
context 'when rollups are enabled' do
let(:timezone_offset) { '5.5' }
before do
account.update!(reporting_timezone: 'Chennai')
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(true)
create(:reporting_events_rollup,
account: account,
date: (current_time - 1.week).to_date,
dimension_type: 'account',
dimension_id: account.id,
metric: 'first_response',
count: 1,
sum_value: 93.0,
sum_value_business_hours: 30.0)
create(:reporting_events_rollup,
account: account,
date: current_time.to_date,
dimension_type: 'account',
dimension_id: account.id,
metric: 'first_response',
count: 2,
sum_value: 180.0,
sum_value_business_hours: 30.0)
end
it 'preserves empty buckets in the timeseries' do
rollup_timezone = ActiveSupport::TimeZone['Chennai']
rollup_start_date = DateTime.strptime(params[:since], '%s').in_time_zone(rollup_timezone).to_date
rollup_end_date = (DateTime.strptime(params[:until], '%s') - 1.second).in_time_zone(rollup_timezone).to_date
rollup_dates = rollup_start_date..rollup_end_date
expected_timeseries = rollup_dates.map do |date|
value = if date == (current_time - 1.week).to_date
93.0
elsif date == current_time.to_date
90.0
else
0
end
count = if date == (current_time - 1.week).to_date
1
elsif date == current_time.to_date
2
else
0
end
{ count: count, timestamp: date.in_time_zone('Chennai').to_i, value: value }
end
expect(subject.timeseries).to eq(expected_timeseries)
end
end
context 'when group_by is provided' do
let(:group_by) { 'week' }
it 'returns correct timeseries' do
timeseries_values = subject.timeseries
expect(timeseries_values).to eq(
[
{ count: 1, timestamp: (current_time - 1.week).beginning_of_week(:sunday).to_i, value: 93.0 },
{ count: 2, timestamp: current_time.beginning_of_week(:sunday).to_i, value: 90.0 }
]
)
end
end
context 'when timezone offset is provided' do
let(:timezone_offset) { '5.5' }
let(:group_by) { 'week' }
it 'returns correct timeseries' do
timeseries_values = subject.timeseries
expect(timeseries_values).to eq(
[
{ count: 1, timestamp: (current_time - 1.week).in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 93.0 },
{ count: 2, timestamp: current_time.in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 90.0 }
]
)
end
end
context 'when timezone is provided' do
let(:timezone) { 'Asia/Kolkata' }
let(:timezone_offset) { '0' }
let(:group_by) { 'week' }
it 'uses the timezone name instead of the offset for timestamps' do
expect(subject.timeseries).to eq(
[
{ count: 1, timestamp: (current_time - 1.week).in_time_zone('Asia/Kolkata').beginning_of_week(:sunday).to_i, value: 93.0 },
{ count: 2, timestamp: current_time.in_time_zone('Asia/Kolkata').beginning_of_week(:sunday).to_i, value: 90.0 }
]
)
end
end
context 'when weekly rollups are enabled' do
let(:group_by) { 'week' }
let(:timezone_offset) { '5.5' }
before do
account.update!(reporting_timezone: 'Chennai')
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(true)
create(:reporting_events_rollup,
account: account,
date: current_time.to_date - 1.day,
dimension_type: 'account',
dimension_id: account.id,
metric: 'first_response',
count: 1,
sum_value: 80.0,
sum_value_business_hours: 10.0)
create(:reporting_events_rollup,
account: account,
date: current_time.to_date,
dimension_type: 'account',
dimension_id: account.id,
metric: 'first_response',
count: 1,
sum_value: 100.0,
sum_value_business_hours: 20.0)
end
it 'groups weeks using sunday boundaries' do
expect(subject.timeseries).to eq(
[
{ count: 0, timestamp: (current_time - 1.week).in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 0 },
{ count: 2, timestamp: current_time.in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 90.0 }
]
)
end
end
context 'when the label filter is applied' do
let(:group_by) { 'week' }
let(:filter_type) { 'label' }
let(:filter_id) { label.id }
it 'returns correct timeseries' do
timeseries_values = subject.timeseries
start_of_the_week = current_time.beginning_of_week(:sunday).to_i
last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i
expect(timeseries_values).to eq(
[
{ count: 0, timestamp: last_week_start_of_the_week, value: 0 },
{ count: 1, timestamp: start_of_the_week, value: 80.0 }
]
)
end
end
context 'when the inbox filter is applied' do
let(:group_by) { 'week' }
let(:filter_type) { 'inbox' }
let(:filter_id) { inbox.id }
it 'returns correct timeseries' do
timeseries_values = subject.timeseries
start_of_the_week = current_time.beginning_of_week(:sunday).to_i
last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i
expect(timeseries_values).to eq(
[
{ count: 0, timestamp: last_week_start_of_the_week, value: 0 },
{ count: 1, timestamp: start_of_the_week, value: 80.0 }
]
)
end
end
context 'when the team filter is applied' do
let(:group_by) { 'week' }
let(:filter_type) { 'team' }
let(:filter_id) { team.id }
it 'returns correct timeseries' do
timeseries_values = subject.timeseries
start_of_the_week = current_time.beginning_of_week(:sunday).to_i
last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i
expect(timeseries_values).to eq(
[
{ count: 0, timestamp: last_week_start_of_the_week, value: 0 },
{ count: 1, timestamp: start_of_the_week, value: 80.0 }
]
)
end
end
end
describe '#aggregate_value' do
context 'when there is no filter applied' do
it 'returns the correct average value' do
expect(subject.aggregate_value).to eq 91.0
end
end
context 'when rollups are enabled and the agent does not exist' do
let(:filter_type) { :agent }
let(:filter_id) { '999999' }
let(:timezone_offset) { '0' }
before do
account.update!(reporting_timezone: 'Etc/UTC')
allow(account).to receive(:feature_enabled?).with(:report_rollup).and_return(true)
end
it 'raises record not found to preserve raw path behavior' do
expect { subject.aggregate_value }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
end
describe 'count metrics' do
subject { described_class.new(account, params) }
let(:account) { create(:account) }
let(:account2) { create(:account) }
let(:user) { create(:user, email: 'agent1@example.com') }
let(:inbox) { create(:inbox, account: account) }
let(:inbox2) { create(:inbox, account: account2) }
let(:current_time) { Time.current }
let(:params) do
{
type: 'agent',
metric: 'resolutions_count',
since: since_time.beginning_of_day.to_i.to_s,
until: current_time.end_of_day.to_i.to_s,
timezone: timezone,
timezone_offset: timezone_offset,
group_by: group_by,
id: user.id.to_s
}
end
let(:group_by) { 'day' }
let(:since_time) { current_time - 1.day }
let(:timezone) { nil }
let(:timezone_offset) { nil }
before do
travel_to current_time
create(:account_user, account: account, user: user)
create(:account_user, account: account2, user: user)
conversation1 = create(:conversation, account: account, inbox: inbox, assignee: user)
conversation2 = create(:conversation, account: account, inbox: inbox, assignee: user)
conversation3 = create(:conversation, account: account2, inbox: inbox2, assignee: user)
conversation4 = create(:conversation, account: account2, inbox: inbox2, assignee: user)
create(:reporting_event,
name: 'conversation_resolved',
account: account,
user: user,
conversation: conversation1,
created_at: current_time - 12.hours)
create(:reporting_event,
name: 'conversation_resolved',
account: account,
user: user,
conversation: conversation2,
created_at: current_time - 6.hours)
create(:reporting_event,
name: 'conversation_resolved',
account: account2,
user: user,
conversation: conversation3,
created_at: current_time - 8.hours)
create(:reporting_event,
name: 'conversation_resolved',
account: account2,
user: user,
conversation: conversation4,
created_at: current_time - 4.hours)
conversation5 = create(:conversation, account: account2, inbox: inbox2, assignee: user)
create(:reporting_event,
name: 'conversation_resolved',
account: account2,
user: user,
conversation: conversation5,
created_at: current_time - 2.hours)
end
describe '#aggregate_value' do
it 'returns only resolutions performed by the user in the specified account' do
expect(subject.aggregate_value).to eq(2)
end
context 'when rollups are enabled and the agent does not exist' do
let(:timezone_offset) { '0' }
let(:params) do
super().merge(id: '999999')
end
before do
account.update!(reporting_timezone: 'Etc/UTC')
allow(account).to receive(:feature_enabled?).with(:report_rollup).and_return(true)
end
it 'raises record not found to preserve raw path behavior' do
expect { subject.aggregate_value }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'when querying account2' do
subject { described_class.new(account2, params) }
it 'returns only resolutions for account2' do
expect(subject.aggregate_value).to eq(3)
end
end
end
describe '#timeseries' do
it 'filters resolutions by account' do
result = subject.timeseries
total_count = result.sum { |row| row[:value] }
expect(total_count).to eq(2)
end
context 'when rollups are enabled and grouped by week' do
let(:group_by) { 'week' }
let(:current_time) { Time.zone.parse('2020-10-26 10:00:00 UTC') }
let(:since_time) { current_time - 1.week }
let(:timezone_offset) { '5.5' }
before do
account.update!(reporting_timezone: 'Chennai')
allow(account).to receive(:feature_enabled?).with(:report_rollup).and_return(true)
create(:reporting_events_rollup,
account: account,
date: current_time.to_date - 1.day,
dimension_type: 'agent',
dimension_id: user.id,
metric: 'resolutions_count',
count: 1,
sum_value: 0.0,
sum_value_business_hours: 0.0)
create(:reporting_events_rollup,
account: account,
date: current_time.to_date,
dimension_type: 'agent',
dimension_id: user.id,
metric: 'resolutions_count',
count: 1,
sum_value: 0.0,
sum_value_business_hours: 0.0)
end
it 'groups weeks using sunday boundaries' do
expect(subject.timeseries).to eq(
[
{ value: 0, timestamp: (current_time - 1.week).in_time_zone('Chennai').beginning_of_week(:sunday).to_i },
{ value: 2, timestamp: current_time.in_time_zone('Chennai').beginning_of_week(:sunday).to_i }
]
)
end
end
end
describe 'account isolation' do
it 'does not leak data between accounts' do
account1_count = described_class.new(account, params).aggregate_value
account2_count = described_class.new(account2, params).aggregate_value
expect(account1_count).to eq(2)
expect(account2_count).to eq(3)
end
end
end
end
@@ -45,7 +45,10 @@ RSpec.describe 'Summary Reports API', type: :request do
headers: admin.create_new_auth_token,
as: :json
expect(V2::Reports::AgentSummaryBuilder).to have_received(:new).with(account: account, params: params)
expect(V2::Reports::AgentSummaryBuilder).to have_received(:new).with(
account: account,
params: params.merge(type: :agent)
)
expect(agent_summary_builder).to have_received(:build)
expect(response).to have_http_status(:success)
@@ -96,7 +99,10 @@ RSpec.describe 'Summary Reports API', type: :request do
headers: admin.create_new_auth_token,
as: :json
expect(V2::Reports::InboxSummaryBuilder).to have_received(:new).with(account: account, params: params)
expect(V2::Reports::InboxSummaryBuilder).to have_received(:new).with(
account: account,
params: params.merge(type: :inbox)
)
expect(inbox_summary_builder).to have_received(:build)
expect(response).to have_http_status(:success)
@@ -147,7 +153,10 @@ RSpec.describe 'Summary Reports API', type: :request do
headers: admin.create_new_auth_token,
as: :json
expect(V2::Reports::TeamSummaryBuilder).to have_received(:new).with(account: account, params: params)
expect(V2::Reports::TeamSummaryBuilder).to have_received(:new).with(
account: account,
params: params.merge(type: :team)
)
expect(team_summary_builder).to have_received(:build)
expect(response).to have_http_status(:success)
+12
View File
@@ -0,0 +1,12 @@
FactoryBot.define do
factory :reporting_events_rollup do
account
date { Date.current }
dimension_type { 'account' }
dimension_id { 1 }
metric { 'first_response' }
count { 1 }
sum_value { 100.0 }
sum_value_business_hours { 50.0 }
end
end
@@ -0,0 +1,71 @@
require 'rails_helper'
require 'rake'
Rails.application.load_tasks unless Object.const_defined?(:ReportingEventsRollupBackfill)
describe ReportingEventsRollupBackfill do
let(:service) { described_class.new }
let(:account) { create(:account, reporting_timezone: 'America/New_York') }
let(:date) { Date.new(2026, 2, 11) }
describe '#execute_backfill' do
before do
allow(ReportingEvents::BackfillService).to receive(:backfill_date)
allow($stdout).to receive(:flush)
end
it 'prompts to enable the read path only after a successful backfill' do
allow(service).to receive(:print_success)
expect(service).to receive(:prompt_enable_rollup_read_path).with(account)
service.send(:execute_backfill, account, date, date, 1)
end
it 'does not prompt to enable the read path when backfill fails' do
allow(ReportingEvents::BackfillService).to receive(:backfill_date).and_raise(StandardError, 'boom')
expect(service).not_to receive(:prompt_enable_rollup_read_path)
expect do
service.send(:execute_backfill, account, date, date, 1)
end.to raise_error(SystemExit)
end
it 'does not report the backfill as failed when enabling the read path fails' do
allow(service).to receive(:print_success)
allow(service).to receive(:print_failure)
allow(service).to receive(:prompt_enable_rollup_read_path).and_raise(StandardError, 'toggle failed')
expect do
service.send(:execute_backfill, account, date, date, 1)
end.to raise_error(StandardError, 'toggle failed')
end
end
describe '#prompt_enable_rollup_read_path' do
it 'enables the feature flag when the user confirms' do
allow($stdin).to receive(:gets).and_return("y\n")
expect(account).to receive(:enable_features!).with(:report_rollup)
service.send(:prompt_enable_rollup_read_path, account)
end
it 'does not enable the feature flag when the user declines' do
allow($stdin).to receive(:gets).and_return("n\n")
expect(account).not_to receive(:enable_features!)
service.send(:prompt_enable_rollup_read_path, account)
end
it 'skips the prompt when the feature is already enabled' do
allow(account).to receive(:feature_enabled?).with(:report_rollup).and_return(true)
expect($stdin).not_to receive(:gets)
service.send(:prompt_enable_rollup_read_path, account)
end
end
end
@@ -18,6 +18,23 @@ describe ReportingEventListener do
expect(account.reporting_events.where(name: 'conversation_resolved').count).to be 1
end
context 'when rollup creation fails' do
let(:event) { Events::Base.new('conversation.resolved', Time.zone.now, conversation: conversation) }
let(:error) { StandardError.new('rollup failed') }
let(:exception_tracker) { instance_double(ChatwootExceptionTracker, capture_exception: true) }
before do
allow(ReportingEvents::RollupService).to receive(:perform).and_raise(error)
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
end
it 'captures the error without interrupting raw event creation' do
expect { listener.conversation_resolved(event) }.not_to raise_error
expect(ChatwootExceptionTracker).to have_received(:new).with(error, account: account)
expect(account.reporting_events.where(name: 'conversation_resolved').count).to be 1
end
end
context 'when business hours enabled for inbox' do
let(:created_at) { Time.zone.parse('March 20, 2022 00:00') }
let(:updated_at) { Time.zone.parse('March 26, 2022 23:59') }
+15
View File
@@ -255,6 +255,21 @@ RSpec.describe Account do
expect(described_class.with_auto_resolve.pluck(:id)).not_to include(account.id)
end
end
context 'when reporting_timezone is set' do
it 'allows valid timezone names' do
account.reporting_timezone = 'America/New_York'
expect(account).to be_valid
end
it 'rejects invalid timezone names' do
account.reporting_timezone = 'Invalid/Timezone'
expect(account).not_to be_valid
expect(account.errors[:reporting_timezone]).to include(I18n.t('errors.account.reporting_timezone.invalid'))
end
end
end
describe 'captain_preferences' do
+182
View File
@@ -0,0 +1,182 @@
require 'rails_helper'
RSpec.describe ReportingEventsRollup do
describe 'associations' do
it { is_expected.to belong_to(:account) }
end
describe 'enums' do
describe 'dimension_type enum' do
it 'stores dimension_type as string values' do
rollup = build(:reporting_events_rollup, dimension_type: 'account')
expect(rollup.dimension_type).to eq('account')
end
it 'has account dimension type' do
rollup = build(:reporting_events_rollup, dimension_type: 'account')
expect(rollup.account?).to be true
end
it 'has agent dimension type' do
rollup = build(:reporting_events_rollup, dimension_type: 'agent')
expect(rollup.agent?).to be true
end
it 'has inbox dimension type' do
rollup = build(:reporting_events_rollup, dimension_type: 'inbox')
expect(rollup.inbox?).to be true
end
it 'has team dimension type' do
rollup = build(:reporting_events_rollup, dimension_type: 'team')
expect(rollup.team?).to be true
end
end
describe 'metric enum' do
it 'stores metric as string values' do
rollup = build(:reporting_events_rollup, metric: 'first_response')
expect(rollup.metric).to eq('first_response')
end
it 'has resolutions_count metric' do
rollup = build(:reporting_events_rollup, metric: 'resolutions_count')
expect(rollup.resolutions_count?).to be true
end
it 'has first_response metric' do
rollup = build(:reporting_events_rollup, metric: 'first_response')
expect(rollup.first_response?).to be true
end
it 'has resolution_time metric' do
rollup = build(:reporting_events_rollup, metric: 'resolution_time')
expect(rollup.resolution_time?).to be true
end
it 'has reply_time metric' do
rollup = build(:reporting_events_rollup, metric: 'reply_time')
expect(rollup.reply_time?).to be true
end
it 'has bot_resolutions_count metric' do
rollup = build(:reporting_events_rollup, metric: 'bot_resolutions_count')
expect(rollup.bot_resolutions_count?).to be true
end
it 'has bot_handoffs_count metric' do
rollup = build(:reporting_events_rollup, metric: 'bot_handoffs_count')
expect(rollup.bot_handoffs_count?).to be true
end
end
end
describe 'validations' do
it { is_expected.to validate_presence_of(:account_id) }
it { is_expected.to validate_presence_of(:date) }
it { is_expected.to validate_presence_of(:dimension_type) }
it { is_expected.to validate_presence_of(:dimension_id) }
it { is_expected.to validate_presence_of(:metric) }
it { is_expected.to validate_numericality_of(:count).is_greater_than_or_equal_to(0) }
end
describe 'scopes' do
let(:account) { create(:account) }
let(:other_account) { create(:account) }
describe '.for_date_range' do
it 'filters by date range' do
create(:reporting_events_rollup, account: account, date: '2026-02-08'.to_date)
create(:reporting_events_rollup, account: account, date: '2026-02-10'.to_date)
create(:reporting_events_rollup, account: account, date: '2026-02-12'.to_date)
results = described_class.for_date_range('2026-02-10'.to_date, '2026-02-11'.to_date)
expect(results.count).to eq(1)
expect(results.first.date).to eq('2026-02-10'.to_date)
end
it 'returns empty array when no records match' do
create(:reporting_events_rollup, account: account, date: '2026-02-08'.to_date)
results = described_class.for_date_range('2026-02-20'.to_date, '2026-02-25'.to_date)
expect(results).to be_empty
end
end
describe '.for_dimension' do
it 'filters by dimension type and id' do
create(:reporting_events_rollup, account: account, dimension_type: 'account', dimension_id: 1)
create(:reporting_events_rollup, account: account, dimension_type: 'agent', dimension_id: 1)
create(:reporting_events_rollup, account: account, dimension_type: 'agent', dimension_id: 2)
results = described_class.for_dimension('agent', 1)
expect(results.count).to eq(1)
expect(results.first.dimension_type).to eq('agent')
expect(results.first.dimension_id).to eq(1)
end
it 'returns empty array when no records match' do
create(:reporting_events_rollup, account: account, dimension_type: 'account', dimension_id: 1)
results = described_class.for_dimension('agent', 1)
expect(results).to be_empty
end
end
describe '.for_metric' do
it 'filters by metric' do
create(:reporting_events_rollup, account: account, metric: 'first_response', dimension_id: 1)
create(:reporting_events_rollup, account: account, metric: 'resolution_time', dimension_id: 2)
create(:reporting_events_rollup, account: account, metric: 'first_response', dimension_id: 3)
results = described_class.for_metric('first_response')
expect(results.count).to eq(2)
expect(results.all? { |r| r.metric == 'first_response' }).to be true
end
it 'returns empty array when no records match' do
create(:reporting_events_rollup, account: account, metric: 'first_response')
results = described_class.for_metric('resolution_time')
expect(results).to be_empty
end
end
end
describe 'database schema' do
let(:account) { create(:account) }
let(:rollup) do
create(:reporting_events_rollup,
account: account,
date: '2026-02-10'.to_date,
dimension_type: 'account',
metric: 'first_response')
end
it 'has all required columns' do
expect(rollup).to have_attributes(
account_id: account.id,
date: '2026-02-10'.to_date,
dimension_type: 'account',
dimension_id: 1,
metric: 'first_response',
count: 1,
sum_value: 100.0,
sum_value_business_hours: 50.0
)
end
it 'stores enum values as strings in database' do
rollup
db_record = described_class.find(rollup.id)
expect(db_record.dimension_type).to eq('account')
expect(db_record.metric).to eq('first_response')
end
end
end
@@ -0,0 +1,207 @@
require 'rails_helper'
describe ReportingEvents::BackfillService do
describe '.backfill_date' do
let(:account) { create(:account, reporting_timezone: 'America/New_York') }
let(:date) { Date.new(2026, 2, 11) }
let(:user) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
it 'treats nil metric values as zero during backfill' do
reporting_event = create_backfill_event(
name: 'first_response', value: 100, value_in_business_hours: 50,
user: user, inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 11, 15)
)
# Simulate a legacy row that already exists in the database with nil metrics.
# rubocop:disable Rails/SkipsModelValidations
reporting_event.update_columns(value: nil, value_in_business_hours: nil)
# rubocop:enable Rails/SkipsModelValidations
expect { described_class.backfill_date(account, date) }.not_to raise_error
rollup = find_rollup('account', account.id, 'first_response')
expect(rollup.count).to eq(1)
expect(rollup.sum_value).to eq(0)
expect(rollup.sum_value_business_hours).to eq(0)
end
context 'when replacing rows fails atomically' do
before do
create(
:reporting_events_rollup,
account: account, date: date, dimension_type: 'account', dimension_id: account.id,
metric: 'first_response', count: 7, sum_value: 700, sum_value_business_hours: 350
)
end
it 'preserves existing rollups when building replacement rows fails' do
service = described_class.new(account, date)
allow(service).to receive(:build_rollup_rows).and_raise(StandardError, 'boom')
expect { service.perform }.to raise_error(StandardError, 'boom')
rollup = find_rollup('account', account.id, 'first_response')
expect(rollup.count).to eq(7)
expect(rollup.sum_value).to eq(700)
expect(rollup.sum_value_business_hours).to eq(350)
end
it 'preserves existing rollups when bulk insert fails' do
create_backfill_event(
name: 'first_response', value: 100, value_in_business_hours: 50,
user: user, inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 11, 15)
)
service = described_class.new(account, date)
allow(service).to receive(:bulk_insert_rollups).and_raise(StandardError, 'boom')
expect { service.perform }.to raise_error(StandardError, 'boom')
rollup = find_rollup('account', account.id, 'first_response')
expect(rollup.count).to eq(7)
expect(rollup.sum_value).to eq(700)
expect(rollup.sum_value_business_hours).to eq(350)
end
end
context 'when aggregating grouped rows' do
let(:second_user) { create(:user, account: account) }
let(:second_inbox) { create(:inbox, account: account) }
let(:second_conversation) { create(:conversation, account: account, inbox: second_inbox, assignee: second_user) }
before do
create_backfill_event(name: 'first_response', value: 100, value_in_business_hours: 60, user: user,
inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 11, 14))
create_backfill_event(name: 'first_response', value: 40, value_in_business_hours: 20, user: user,
inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 11, 15))
create_backfill_event(name: 'conversation_resolved', value: 200, value_in_business_hours: 80, user: second_user,
inbox: second_inbox, conversation: second_conversation, created_at: Time.utc(2026, 2, 11, 16))
create_backfill_event(name: 'reply_time', value: 500, value_in_business_hours: 300, user: user,
inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 12, 5))
described_class.backfill_date(account, date)
end
it 'does not instantiate reporting events' do
reporting_event_instantiations = count_reporting_event_instantiations do
described_class.backfill_date(account, date)
end
expect(reporting_event_instantiations).to eq(0)
end
it 'creates the expected number of rollup rows' do
rollups = ReportingEventsRollup.where(account_id: account.id, date: date)
# 3 dimensions × first_response + 3 dimensions × resolutions_count + 3 dimensions × resolution_time
expect(rollups.count).to eq(9)
end
it 'aggregates first_response at the account dimension' do
account_first_response = find_rollup('account', account.id, 'first_response')
expect(account_first_response.count).to eq(2)
expect(account_first_response.sum_value).to eq(140)
expect(account_first_response.sum_value_business_hours).to eq(80)
end
it 'aggregates first_response at the agent dimension' do
agent_first_response = find_rollup('agent', user.id, 'first_response')
expect(agent_first_response.count).to eq(2)
expect(agent_first_response.sum_value).to eq(140)
expect(agent_first_response.sum_value_business_hours).to eq(80)
end
it 'aggregates resolution_time at the agent dimension' do
agent_resolution_time = find_rollup('agent', second_user.id, 'resolution_time')
expect(agent_resolution_time.count).to eq(1)
expect(agent_resolution_time.sum_value).to eq(200)
expect(agent_resolution_time.sum_value_business_hours).to eq(80)
end
it 'aggregates first_response at the inbox dimension' do
inbox_first_response = find_rollup('inbox', inbox.id, 'first_response')
expect(inbox_first_response.count).to eq(2)
expect(inbox_first_response.sum_value).to eq(140)
expect(inbox_first_response.sum_value_business_hours).to eq(80)
end
it 'aggregates resolution_time at the inbox dimension' do
inbox_resolution_time = find_rollup('inbox', second_inbox.id, 'resolution_time')
expect(inbox_resolution_time.count).to eq(1)
expect(inbox_resolution_time.sum_value).to eq(200)
expect(inbox_resolution_time.sum_value_business_hours).to eq(80)
end
end
context 'when deduplicating distinct-count events' do
let(:second_user) { create(:user, account: account) }
let(:second_inbox) { create(:inbox, account: account) }
let(:conversation_b) { create(:conversation, account: account, inbox: inbox, assignee: user) }
let(:conversation_c) { create(:conversation, account: account, inbox: second_inbox, assignee: second_user) }
before do
# Two events for the same conversation — should count as 1
create_backfill_event(name: 'conversation_bot_handoff', value: 0, value_in_business_hours: 0, user: user,
inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 11, 14))
create_backfill_event(name: 'conversation_bot_handoff', value: 0, value_in_business_hours: 0, user: user,
inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 11, 15))
# Different conversation, same agent/inbox
create_backfill_event(name: 'conversation_bot_handoff', value: 0, value_in_business_hours: 0, user: user,
inbox: inbox, conversation: conversation_b, created_at: Time.utc(2026, 2, 11, 16))
# Different agent/inbox
create_backfill_event(name: 'conversation_bot_handoff', value: 0, value_in_business_hours: 0, user: second_user,
inbox: second_inbox, conversation: conversation_c, created_at: Time.utc(2026, 2, 11, 17))
described_class.backfill_date(account, date)
end
it 'creates the expected number of rollup rows' do
rollups = ReportingEventsRollup.where(account_id: account.id, date: date)
expect(rollups.count).to eq(5)
end
it 'counts 3 distinct conversations at the account dimension' do
expect(find_rollup('account', account.id, 'bot_handoffs_count').count).to eq(3)
end
it 'counts distinct conversations per agent' do
expect(find_rollup('agent', user.id, 'bot_handoffs_count').count).to eq(2)
expect(find_rollup('agent', second_user.id, 'bot_handoffs_count').count).to eq(1)
end
it 'counts distinct conversations per inbox' do
expect(find_rollup('inbox', inbox.id, 'bot_handoffs_count').count).to eq(2)
expect(find_rollup('inbox', second_inbox.id, 'bot_handoffs_count').count).to eq(1)
end
end
def create_backfill_event(**attributes)
create(
:reporting_event,
account: account,
**attributes
)
end
def find_rollup(dimension_type, dimension_id, metric)
ReportingEventsRollup.find_by!(
account_id: account.id,
date: date,
dimension_type: dimension_type,
dimension_id: dimension_id,
metric: metric
)
end
def count_reporting_event_instantiations(&)
instantiation_count = 0
subscriber = lambda do |_name, _start, _finish, _id, payload|
next unless payload[:class_name] == 'ReportingEvent'
instantiation_count += payload[:record_count]
end
ActiveSupport::Notifications.subscribed(subscriber, 'instantiation.active_record', &)
instantiation_count
end
end
end
@@ -0,0 +1,125 @@
require 'rails_helper'
RSpec.describe ReportingEvents::EventMetricRegistry do
describe '.event_names' do
it 'returns the supported raw event names' do
expect(described_class.event_names).to eq(
%w[
conversation_resolved
first_response
reply_time
conversation_bot_resolved
conversation_bot_handoff
]
)
end
end
describe '.metrics_for' do
it 'returns the emitted rollup metrics for conversation_resolved' do
event = instance_double(ReportingEvent, name: 'conversation_resolved', value: 120, value_in_business_hours: 45)
expect(described_class.metrics_for(event)).to eq(
resolutions_count: {
count: 1,
sum_value: 0,
sum_value_business_hours: 0
},
resolution_time: {
count: 1,
sum_value: 120.0,
sum_value_business_hours: 45.0
}
)
end
it 'returns the emitted rollup metrics for first_response' do
event = instance_double(ReportingEvent, name: 'first_response', value: 80, value_in_business_hours: 20)
expect(described_class.metrics_for(event)).to eq(
first_response: {
count: 1,
sum_value: 80.0,
sum_value_business_hours: 20.0
}
)
end
it 'returns the emitted rollup metrics for reply_time' do
event = instance_double(ReportingEvent, name: 'reply_time', value: 40, value_in_business_hours: 15)
expect(described_class.metrics_for(event)).to eq(
reply_time: {
count: 1,
sum_value: 40.0,
sum_value_business_hours: 15.0
}
)
end
it 'returns the emitted rollup metrics for conversation_bot_resolved' do
event = instance_double(ReportingEvent, name: 'conversation_bot_resolved')
expect(described_class.metrics_for(event)).to eq(
bot_resolutions_count: {
count: 1,
sum_value: 0,
sum_value_business_hours: 0
}
)
end
it 'returns the emitted rollup metrics for conversation_bot_handoff' do
event = instance_double(ReportingEvent, name: 'conversation_bot_handoff')
expect(described_class.metrics_for(event)).to eq(
bot_handoffs_count: {
count: 1,
sum_value: 0,
sum_value_business_hours: 0
}
)
end
it 'returns an empty hash for unsupported events' do
event = instance_double(ReportingEvent, name: 'conversation_created')
expect(described_class.metrics_for(event)).to eq({})
end
end
describe '.metrics_for_aggregate' do
it 'returns aggregated rollup metrics for conversation_resolved groups' do
expect(
described_class.metrics_for_aggregate(
'conversation_resolved',
count: 3,
sum_value: 420,
sum_value_business_hours: 210
)
).to eq(
resolutions_count: {
count: 3,
sum_value: 0,
sum_value_business_hours: 0
},
resolution_time: {
count: 3,
sum_value: 420.0,
sum_value_business_hours: 210.0
}
)
end
it 'returns an empty hash for unsupported grouped events' do
expect(
described_class.metrics_for_aggregate(
'conversation_created',
count: 2,
sum_value: 100,
sum_value_business_hours: 50
)
).to eq({})
end
end
end
@@ -0,0 +1,446 @@
require 'rails_helper'
describe ReportingEvents::RollupService do
describe '.perform' do
let(:account) { create(:account, reporting_timezone: 'America/New_York') }
let(:user) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
context 'when reporting_timezone is not set' do
before { account.update!(reporting_timezone: nil) }
it 'skips rollup creation' do
reporting_event = create(:reporting_event,
account: account,
name: 'conversation_resolved',
conversation: conversation)
expect do
described_class.perform(reporting_event)
end.not_to change(ReportingEventsRollup, :count)
end
end
context 'when reporting_timezone is invalid' do
it 'skips rollup creation' do
reporting_event = create(:reporting_event,
account: account,
name: 'conversation_resolved',
conversation: conversation)
allow(account).to receive(:reporting_timezone).and_return('Invalid/Zone')
expect do
described_class.perform(reporting_event)
end.not_to change(ReportingEventsRollup, :count)
end
end
context 'when reporting_timezone is set' do
describe 'conversation_resolved event' do
let(:rollup_event_created_at) { Time.utc(2026, 2, 12, 4, 0, 0) }
let(:rollup_write_time) { Time.utc(2026, 2, 12, 10, 0, 0) }
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'conversation_resolved',
value: 1000,
value_in_business_hours: 500,
user: user,
inbox: inbox,
conversation: conversation)
end
let(:expected_upsert_options) do
{
unique_by: %i[account_id date dimension_type dimension_id metric],
on_duplicate: 'count = reporting_events_rollups.count + EXCLUDED.count, ' \
'sum_value = reporting_events_rollups.sum_value + EXCLUDED.sum_value, ' \
'sum_value_business_hours = reporting_events_rollups.sum_value_business_hours + EXCLUDED.sum_value_business_hours, ' \
'updated_at = EXCLUDED.updated_at'
}
end
it 'creates rollup rows for account, agent, and inbox' do
described_class.perform(reporting_event)
# Account dimension
account_row = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
dimension_id: account.id,
metric: 'resolutions_count'
)
expect(account_row).to be_present
# Agent dimension
agent_row = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'agent',
dimension_id: user.id,
metric: 'resolutions_count'
)
expect(agent_row).to be_present
# Inbox dimension
inbox_row = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'inbox',
dimension_id: inbox.id,
metric: 'resolutions_count'
)
expect(inbox_row).to be_present
end
it 'creates correct metrics for conversation_resolved' do
described_class.perform(reporting_event)
resolutions_count = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: 'resolutions_count'
)
expect(resolutions_count.count).to eq(1)
expect(resolutions_count.sum_value).to eq(0)
expect(resolutions_count.sum_value_business_hours).to eq(0)
resolution_time = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: 'resolution_time'
)
expect(resolution_time.count).to eq(1)
expect(resolution_time.sum_value).to eq(1000)
expect(resolution_time.sum_value_business_hours).to eq(500)
end
it 'batches all rollup rows in a single upsert_all call' do
reporting_event.update!(created_at: rollup_event_created_at)
travel_to(rollup_write_time) do
captured_upsert = capture_upsert_all_call
described_class.perform(reporting_event)
expect(ReportingEventsRollup).to have_received(:upsert_all).once
expect(captured_upsert[:options][:unique_by]).to eq(expected_upsert_options[:unique_by])
expect(captured_upsert[:options][:on_duplicate].to_s.squish).to eq(expected_upsert_options[:on_duplicate])
expect(captured_upsert[:rows]).to match_array(expected_rollup_rows)
end
end
it 'respects account timezone for date bucketing' do
# Event created at 2026-02-11 22:00 UTC
# In EST (UTC-5) that's 2026-02-11 17:00 (same day)
reporting_event.update!(created_at: '2026-02-11 22:00:00 UTC')
described_class.perform(reporting_event)
rollup = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account'
)
expect(rollup.date).to eq('2026-02-11'.to_date)
end
it 'handles timezone boundary crossing' do
# Event created at 2026-02-12 04:00 UTC
# In EST (UTC-5) that's 2026-02-11 23:00 (previous day)
reporting_event.update!(created_at: '2026-02-12 04:00:00 UTC')
described_class.perform(reporting_event)
rollup = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account'
)
expect(rollup.date).to eq('2026-02-11'.to_date)
end
def capture_upsert_all_call
{}.tap do |captured_upsert|
allow(ReportingEventsRollup).to receive(:upsert_all) do |rows, **options|
captured_upsert[:rows] = rows
captured_upsert[:options] = options
end
end
end
def expected_rollup_rows
{
account: account.id,
agent: user.id,
inbox: inbox.id
}.flat_map do |dimension_type, dimension_id|
[
rollup_row(dimension_type, dimension_id, :resolutions_count, 0.0, 0.0),
rollup_row(dimension_type, dimension_id, :resolution_time, 1000.0, 500.0)
]
end
end
def rollup_row(dimension_type, dimension_id, metric, sum_value, sum_value_business_hours)
{
account_id: account.id,
date: Date.new(2026, 2, 11),
dimension_type: dimension_type,
dimension_id: dimension_id,
metric: metric,
count: 1,
sum_value: sum_value,
sum_value_business_hours: sum_value_business_hours,
created_at: rollup_write_time,
updated_at: rollup_write_time
}
end
end
describe 'first_response event' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'first_response',
value: 500,
value_in_business_hours: 300,
user: user,
inbox: inbox,
conversation: conversation)
end
it 'creates first_response metric' do
described_class.perform(reporting_event)
first_response = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: 'first_response'
)
expect(first_response).to be_present
expect(first_response.count).to eq(1)
expect(first_response.sum_value).to eq(500)
expect(first_response.sum_value_business_hours).to eq(300)
end
end
describe 'reply_time event' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'reply_time',
value: 200,
value_in_business_hours: 100,
user: user,
inbox: inbox,
conversation: conversation)
end
it 'creates reply_time metric' do
described_class.perform(reporting_event)
reply_time = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: 'reply_time'
)
expect(reply_time).to be_present
expect(reply_time.count).to eq(1)
expect(reply_time.sum_value).to eq(200)
expect(reply_time.sum_value_business_hours).to eq(100)
end
end
describe 'when metric values are nil' do
[
%w[conversation_resolved resolution_time],
%w[first_response first_response],
%w[reply_time reply_time]
].each do |event_name, metric|
it "treats nil values as zero for #{event_name}" do
reporting_event = create(:reporting_event,
account: account,
name: event_name,
value: 100,
value_in_business_hours: 50,
user: user,
inbox: inbox,
conversation: conversation)
reporting_event.assign_attributes(value: nil, value_in_business_hours: nil)
expect do
described_class.perform(reporting_event)
end.not_to raise_error
rollup = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: metric
)
expect(rollup).to be_present
expect(rollup.count).to eq(1)
expect(rollup.sum_value).to eq(0)
expect(rollup.sum_value_business_hours).to eq(0)
end
end
end
describe 'conversation_bot_resolved event' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'conversation_bot_resolved',
user: user,
inbox: inbox,
conversation: conversation)
end
it 'creates bot_resolutions_count metric' do
described_class.perform(reporting_event)
bot_resolutions = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: 'bot_resolutions_count'
)
expect(bot_resolutions).to be_present
expect(bot_resolutions.count).to eq(1)
expect(bot_resolutions.sum_value).to eq(0)
end
end
describe 'conversation_bot_handoff event' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'conversation_bot_handoff',
user: user,
inbox: inbox,
conversation: conversation)
end
it 'creates bot_handoffs_count metric' do
described_class.perform(reporting_event)
bot_handoffs = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: 'bot_handoffs_count'
)
expect(bot_handoffs).to be_present
expect(bot_handoffs.count).to eq(1)
expect(bot_handoffs.sum_value).to eq(0)
end
end
describe 'dimension handling' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'conversation_resolved',
value: 1000,
value_in_business_hours: 500,
user: user,
inbox: inbox,
conversation: conversation)
end
it 'skips dimensions with nil IDs' do
# Create event without user (user_id will be nil)
reporting_event.update!(user_id: nil)
described_class.perform(reporting_event)
# Agent dimension should not be created
agent_rows = ReportingEventsRollup.where(
account_id: account.id,
dimension_type: 'agent'
)
expect(agent_rows).to be_empty
end
end
describe 'upsert behavior' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'conversation_resolved',
value: 1000,
value_in_business_hours: 500,
user: user,
inbox: inbox,
conversation: conversation)
end
it 'increments count and sums on duplicate entries' do
# First call
described_class.perform(reporting_event)
resolution_time = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: 'resolution_time'
)
expect(resolution_time.count).to eq(1)
expect(resolution_time.sum_value).to eq(1000)
expect(resolution_time.sum_value_business_hours).to eq(500)
# Second call with same event
reporting_event2 = create(:reporting_event,
account: account,
name: 'conversation_resolved',
value: 500,
value_in_business_hours: 250,
user: user,
inbox: inbox,
conversation: conversation,
created_at: reporting_event.created_at)
described_class.perform(reporting_event2)
# Total should be incremented
resolution_time.reload
expect(resolution_time.count).to eq(2)
expect(resolution_time.sum_value).to eq(1500)
expect(resolution_time.sum_value_business_hours).to eq(750)
end
it 'does not create duplicate rollup rows' do
described_class.perform(reporting_event)
initial_count = ReportingEventsRollup.count
# Create another event with same date and dimensions
reporting_event2 = create(:reporting_event,
account: account,
name: 'conversation_resolved',
value: 500,
value_in_business_hours: 250,
user: user,
inbox: inbox,
conversation: conversation,
created_at: reporting_event.created_at)
described_class.perform(reporting_event2)
# Row count should remain the same
expect(ReportingEventsRollup.count).to eq(initial_count)
end
end
end
context 'when event name in unknown' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'unknown_event',
user: user,
inbox: inbox,
conversation: conversation)
end
it 'does not create any rollup rows' do
expect do
described_class.perform(reporting_event)
end.not_to change(ReportingEventsRollup, :count)
end
end
end
end
+398
View File
@@ -0,0 +1,398 @@
require 'rails_helper'
RSpec.describe Reports::DataSource do
describe '.for' do
let(:account) { create(:account, reporting_timezone: 'America/New_York') }
let(:current_offset) { ActiveSupport::TimeZone['America/New_York'].now.utc_offset / 3600.0 }
let(:params) do
{
account: account,
metric: 'avg_resolution_time',
dimension_type: 'account',
dimension_id: nil,
scope: account,
range: 1.day.ago.beginning_of_day...Time.current.end_of_day,
group_by: 'day',
timezone: 'America/New_York',
timezone_offset: current_offset,
business_hours: false
}
end
before do
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(true)
end
it 'returns the rollup adapter when the request is eligible' do
expect(described_class.for(**params)).to be_a(Reports::RollupDataSource)
end
it 'falls back to the raw adapter when the feature flag is disabled' do
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(false)
expect(described_class.for(**params)).to be_a(Reports::RawDataSource)
end
it 'falls back to the raw adapter when the reporting timezone is missing' do
account.update!(reporting_timezone: nil)
expect(described_class.for(**params)).to be_a(Reports::RawDataSource)
end
it 'matches rollups using the requested timezone identifier' do
account.update!(reporting_timezone: 'Chennai')
expect(
described_class.for(**params, timezone: 'Asia/Kolkata', timezone_offset: 0)
).to be_a(Reports::RollupDataSource)
end
it 'falls back to the raw adapter when the requested timezone differs from the account' do
account.update!(reporting_timezone: 'Chennai')
expect(
described_class.for(**params, timezone: 'Asia/Colombo', timezone_offset: 5.5)
).to be_a(Reports::RawDataSource)
end
it 'falls back to the raw adapter when the timezone offset does not match the account' do
expect(described_class.for(**params, timezone: nil, timezone_offset: 0)).to be_a(Reports::RawDataSource)
end
it 'falls back to the raw adapter for hourly groupings' do
expect(described_class.for(**params, group_by: 'hour')).to be_a(Reports::RawDataSource)
end
it 'falls back to the raw adapter for unsupported dimensions' do
expect(described_class.for(**params, dimension_type: 'team')).to be_a(Reports::RawDataSource)
end
it 'returns the rollup adapter for summary queries without a metric' do
expect(described_class.for(**params, metric: nil, dimension_type: 'agent')).to be_a(Reports::RollupDataSource)
end
it 'falls back to the raw adapter for raw-only metrics' do
expect(described_class.for(**params, metric: 'conversations_count')).to be_a(Reports::RawDataSource)
end
end
describe 'summary select fields' do
let(:account) { create(:account, reporting_timezone: 'Etc/UTC') }
let(:context) do
{
account: account,
metric: nil,
dimension_type: 'inbox',
dimension_id: nil,
scope: nil,
range: 1.day.ago.beginning_of_day...Time.current.end_of_day,
group_by: 'day',
timezone: 'UTC',
timezone_offset: '0',
business_hours: false
}
end
it 'derives raw summary selects from registry summary metrics' do
source = Reports::RawDataSource.new(**context)
expect(source.send(:summary_select_fields)).to eq(
[
'inbox_id as inbox_id',
"COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_conversations_count",
"AVG(CASE WHEN name = 'conversation_resolved' THEN value END) as avg_resolution_time",
"AVG(CASE WHEN name = 'first_response' THEN value END) as avg_first_response_time",
"AVG(CASE WHEN name = 'reply_time' THEN value END) as avg_reply_time"
]
)
end
it 'derives rollup summary selects from registry summary metrics' do
source = Reports::RollupDataSource.new(**context)
expect(source.send(:summary_select_fields)).to eq(
[
'dimension_id',
"SUM(CASE WHEN metric = 'resolutions_count' THEN count ELSE 0 END) as resolved_conversations_count",
"SUM(CASE WHEN metric = 'resolution_time' THEN count ELSE 0 END) as avg_resolution_time_count",
"SUM(CASE WHEN metric = 'resolution_time' THEN sum_value ELSE 0 END) as avg_resolution_time_sum_value",
"SUM(CASE WHEN metric = 'first_response' THEN count ELSE 0 END) as avg_first_response_time_count",
"SUM(CASE WHEN metric = 'first_response' THEN sum_value ELSE 0 END) as avg_first_response_time_sum_value",
"SUM(CASE WHEN metric = 'reply_time' THEN count ELSE 0 END) as avg_reply_time_count",
"SUM(CASE WHEN metric = 'reply_time' THEN sum_value ELSE 0 END) as avg_reply_time_sum_value"
]
)
end
end
describe 'adapter contract' do
let(:account) { create(:account, reporting_timezone: 'Etc/UTC') }
let(:user1) { create(:user) }
let(:user2) { create(:user) }
let(:inbox1) { create(:inbox, account: account) }
let(:inbox2) { create(:inbox, account: account) }
let(:timezone) { 'UTC' }
let(:timezone_offset) { '0' }
let(:current_time) { Time.zone.parse('2026-01-15 10:00:00 UTC') }
let(:full_range) { Time.zone.parse('2026-01-05 00:00:00 UTC')...Time.zone.parse('2026-01-16 00:00:00 UTC') }
let(:day_range) { Time.zone.parse('2026-01-14 00:00:00 UTC')...Time.zone.parse('2026-01-16 00:00:00 UTC') }
before do
travel_to current_time
create(:account_user, account: account, user: user1)
create(:account_user, account: account, user: user2)
conversation_one = create(
:conversation,
account: account,
inbox: inbox1,
assignee: user1,
created_at: Time.zone.parse('2026-01-06 09:00:00 UTC')
)
conversation_two = create(
:conversation,
account: account,
inbox: inbox1,
assignee: user1,
created_at: Time.zone.parse('2026-01-14 09:00:00 UTC')
)
conversation_three = create(
:conversation,
account: account,
inbox: inbox2,
assignee: user2,
created_at: Time.zone.parse('2026-01-15 09:00:00 UTC')
)
create(:reporting_event,
name: 'first_response',
account: account,
user: user1,
inbox: inbox1,
conversation: conversation_one,
value: 20,
value_in_business_hours: 10,
created_at: Time.zone.parse('2026-01-06 10:00:00 UTC'))
create(:reporting_event,
name: 'reply_time',
account: account,
user: user1,
inbox: inbox1,
conversation: conversation_one,
value: 10,
value_in_business_hours: 5,
created_at: Time.zone.parse('2026-01-06 11:00:00 UTC'))
create(:reporting_event,
name: 'conversation_resolved',
account: account,
user: user1,
inbox: inbox1,
conversation: conversation_one,
value: 100,
value_in_business_hours: 80,
created_at: Time.zone.parse('2026-01-06 12:00:00 UTC'))
create(:reporting_event,
name: 'first_response',
account: account,
user: user1,
inbox: inbox1,
conversation: conversation_two,
value: 40,
value_in_business_hours: 30,
created_at: Time.zone.parse('2026-01-14 10:00:00 UTC'))
create(:reporting_event,
name: 'reply_time',
account: account,
user: user1,
inbox: inbox1,
conversation: conversation_two,
value: 30,
value_in_business_hours: 20,
created_at: Time.zone.parse('2026-01-14 11:00:00 UTC'))
create(:reporting_event,
name: 'conversation_resolved',
account: account,
user: user1,
inbox: inbox1,
conversation: conversation_two,
value: 200,
value_in_business_hours: 150,
created_at: Time.zone.parse('2026-01-14 12:00:00 UTC'))
create(:reporting_event,
name: 'first_response',
account: account,
user: user2,
inbox: inbox2,
conversation: conversation_three,
value: 60,
value_in_business_hours: 50,
created_at: Time.zone.parse('2026-01-15 10:00:00 UTC'))
create(:reporting_event,
name: 'reply_time',
account: account,
user: user2,
inbox: inbox2,
conversation: conversation_three,
value: 50,
value_in_business_hours: 40,
created_at: Time.zone.parse('2026-01-15 11:00:00 UTC'))
create(:reporting_event,
name: 'conversation_resolved',
account: account,
user: user2,
inbox: inbox2,
conversation: conversation_three,
value: 300,
value_in_business_hours: 250,
created_at: Time.zone.parse('2026-01-15 12:00:00 UTC'))
[Date.new(2026, 1, 6), Date.new(2026, 1, 14), Date.new(2026, 1, 15)].each do |date|
ReportingEvents::BackfillService.backfill_date(account, date)
end
end
shared_examples 'report adapter contract' do |adapter_class|
context 'with average metrics' do
subject(:source) do
adapter_class.new(
account: account,
metric: 'avg_first_response_time',
dimension_type: 'account',
dimension_id: nil,
scope: account,
range: day_range,
group_by: 'day',
timezone: timezone,
timezone_offset: timezone_offset,
business_hours: business_hours
)
end
let(:business_hours) { false }
it 'returns the expected aggregate' do
expect(source.aggregate).to eq(50.0)
end
it 'returns the expected day timeseries' do
expect(source.timeseries).to eq(
[
{ count: 1, timestamp: Date.new(2026, 1, 14).in_time_zone(timezone).to_i, value: 40.0 },
{ count: 1, timestamp: Date.new(2026, 1, 15).in_time_zone(timezone).to_i, value: 60.0 }
]
)
end
context 'when business hours are requested' do
let(:business_hours) { true }
it 'returns the business-hours aggregate and timeseries' do
expect(source.aggregate).to eq(40.0)
expect(source.timeseries).to eq(
[
{ count: 1, timestamp: Date.new(2026, 1, 14).in_time_zone(timezone).to_i, value: 30.0 },
{ count: 1, timestamp: Date.new(2026, 1, 15).in_time_zone(timezone).to_i, value: 50.0 }
]
)
end
end
end
context 'with count metrics' do
subject(:source) do
adapter_class.new(
account: account,
metric: 'resolutions_count',
dimension_type: 'agent',
dimension_id: user1.id,
scope: user1,
range: full_range,
group_by: 'week',
timezone: timezone,
timezone_offset: timezone_offset,
business_hours: false
)
end
it 'returns the expected aggregate' do
expect(source.aggregate).to eq(2)
end
it 'returns the expected week timeseries' do
expect(source.timeseries).to eq(
[
{ value: 1, timestamp: Date.new(2026, 1, 4).in_time_zone(timezone).to_i },
{ value: 1, timestamp: Date.new(2026, 1, 11).in_time_zone(timezone).to_i }
]
)
end
end
context 'with summary metrics' do
subject(:source) do
adapter_class.new(
account: account,
metric: nil,
dimension_type: 'inbox',
dimension_id: nil,
scope: nil,
range: full_range,
group_by: 'day',
timezone: timezone,
timezone_offset: timezone_offset,
business_hours: business_hours
)
end
let(:business_hours) { false }
it 'returns the expected summary shape and values' do
expect(source.summary).to eq(
inbox1.id => {
conversations_count: 2,
resolved_conversations_count: 2,
avg_resolution_time: 150.0,
avg_first_response_time: 30.0,
avg_reply_time: 20.0
},
inbox2.id => {
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 300.0,
avg_first_response_time: 60.0,
avg_reply_time: 50.0
}
)
end
context 'when business hours are requested' do
let(:business_hours) { true }
it 'returns the business-hours summary values' do
expect(source.summary).to eq(
inbox1.id => {
conversations_count: 2,
resolved_conversations_count: 2,
avg_resolution_time: 115.0,
avg_first_response_time: 20.0,
avg_reply_time: 12.5
},
inbox2.id => {
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 250.0,
avg_first_response_time: 50.0,
avg_reply_time: 40.0
}
)
end
end
end
end
it_behaves_like 'report adapter contract', Reports::RawDataSource
it_behaves_like 'report adapter contract', Reports::RollupDataSource
end
end
@@ -0,0 +1,74 @@
require 'rails_helper'
RSpec.describe Reports::ReportMetricRegistry do
describe '.fetch' do
it 'returns the definition for raw-only count metrics' do
metric = described_class.fetch(:conversations_count)
expect(metric.name).to eq(:conversations_count)
expect(metric.count?).to be(true)
expect(metric.rollup_supported?).to be(false)
expect(metric.raw_event_name).to be_nil
end
it 'returns the definition for avg_resolution_time' do
metric = described_class.fetch(:avg_resolution_time)
expect(metric.name).to eq(:avg_resolution_time)
expect(metric.average?).to be(true)
expect(metric.raw_event_name).to eq(:conversation_resolved)
expect(metric.rollup_metric).to eq(:resolution_time)
expect(metric.summary_key).to eq(:avg_resolution_time)
end
it 'locks the distinct conversation strategy for bot_handoffs_count' do
metric = described_class.fetch(:bot_handoffs_count)
expect(metric.count?).to be(true)
expect(metric.raw_event_name).to eq(:conversation_bot_handoff)
expect(metric.rollup_metric).to eq(:bot_handoffs_count)
expect(metric.raw_count_strategy).to eq(:distinct_conversation)
end
it 'returns nil for unsupported metrics' do
expect(described_class.fetch(:unknown_metric)).to be_nil
end
end
describe '.supported?' do
it 'returns true for supported raw-only metrics' do
expect(described_class.supported?(:conversations_count)).to be(true)
end
it 'returns false for unsupported metrics' do
expect(described_class.supported?(:unknown_metric)).to be(false)
end
end
describe '.rollup_supported?' do
it 'returns true for rollup-backed metrics' do
expect(described_class.rollup_supported?(:reply_time)).to be(true)
end
it 'returns false for raw-only metrics' do
expect(described_class.rollup_supported?(:conversations_count)).to be(false)
end
end
describe '.summary_metrics' do
it 'returns the summary metric definitions in registry order' do
expect(
described_class.summary_metrics.map do |metric|
[metric.name, metric.summary_key, metric.aggregate, metric.raw_event_name, metric.rollup_metric]
end
).to eq(
[
[:resolutions_count, :resolved_conversations_count, :count, :conversation_resolved, :resolutions_count],
[:avg_resolution_time, :avg_resolution_time, :average, :conversation_resolved, :resolution_time],
[:avg_first_response_time, :avg_first_response_time, :average, :first_response, :first_response],
[:reply_time, :avg_reply_time, :average, :reply_time, :reply_time]
]
)
end
end
end