Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c440df9b18 | ||
|
|
68da47753e | ||
|
|
d054a60a65 | ||
|
|
8b7def05dc | ||
|
|
f126230f73 | ||
|
|
e42b0be530 | ||
|
|
33b3a56d4c | ||
|
|
6693a4d6f7 | ||
|
|
69a44ccf4d | ||
|
|
95727bc608 | ||
|
|
2fab77d7c7 | ||
|
|
7ad8011a1b | ||
|
|
c83546e67a | ||
|
|
1154f9ccb4 | ||
|
|
e7d5924ca1 | ||
|
|
89e53e24db | ||
|
|
47ecd349cc | ||
|
|
b8543c09fb | ||
|
|
e37e9fd814 | ||
|
|
e6161d2878 | ||
|
|
4e2924538f | ||
|
|
e71bda7112 | ||
|
|
31e3e9c1ff | ||
|
|
10f1c23742 | ||
|
|
3fd14942f1 | ||
|
|
7661104262 | ||
|
|
38b5402623 | ||
|
|
3dd29080b5 | ||
|
|
e56b4b2539 | ||
|
|
5ede10c6df | ||
|
|
8a4c1ec07e | ||
|
|
ac7a02067e | ||
|
|
2e591afaf6 | ||
|
|
32e923dba0 | ||
|
|
b824f8ba47 | ||
|
|
57d40c373d | ||
|
|
3c5cd46d2e | ||
|
|
d34da0cbf8 | ||
|
|
32b1e16e74 | ||
|
|
7e14153784 | ||
|
|
914e70568f | ||
|
|
13860b9163 | ||
|
|
42d7f1768c | ||
|
|
911f211427 | ||
|
|
ea5779a712 | ||
|
|
9ecf5f2504 |
@@ -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)
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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') }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user