Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a94e252fdc | ||
|
|
9e918e0a9f | ||
|
|
f56b0c9f2e | ||
|
|
c194f10f49 | ||
|
|
63172eb08c | ||
|
|
275a76a1a0 | ||
|
|
6f127d86b9 | ||
|
|
014393297b | ||
|
|
3d67ef02ef | ||
|
|
d86c8d1ede | ||
|
|
8324d84fbf | ||
|
|
0bc80bf95f | ||
|
|
e230e97320 | ||
|
|
8f331ea7be | ||
|
|
7feb0ecaef | ||
|
|
5a7c615efa | ||
|
|
dcb2198147 | ||
|
|
d0f07c8f5f | ||
|
|
a7274bc8e8 | ||
|
|
7c6097e4c8 | ||
|
|
35d936ae04 | ||
|
|
65ccf8bae2 | ||
|
|
0d27e76c3f | ||
|
|
8c98b619bd | ||
|
|
47fc8072ad | ||
|
|
dfd92acd4a | ||
|
|
ea5a46ee0c | ||
|
|
9534d7157d | ||
|
|
51f298c129 | ||
|
|
2e18cb3b15 | ||
|
|
c8637bf0de | ||
|
|
a15dffeadb | ||
|
|
22ba4a6dfe | ||
|
|
e840419029 | ||
|
|
b56765d9ba | ||
|
|
63dce7778e | ||
|
|
8e74ee6716 | ||
|
|
325afc5ab1 | ||
|
|
addc179c01 | ||
|
|
4b9b1ed7c6 | ||
|
|
4992cb379a | ||
|
|
98035d5f62 | ||
|
|
164b916670 | ||
|
|
308accab6d | ||
|
|
254271a37a | ||
|
|
b339bf128a | ||
|
|
0313cd87c2 | ||
|
|
eb08245b3d | ||
|
|
d5d9d3ad7d | ||
|
|
17f5112015 | ||
|
|
649f58a57a | ||
|
|
69a75e538e | ||
|
|
4f124f64d4 | ||
|
|
f24b931527 | ||
|
|
b6a84af227 | ||
|
|
2380218b05 | ||
|
|
af59acd6a9 | ||
|
|
f29f9517d8 | ||
|
|
ab013b9e2b | ||
|
|
9a17ae6a76 | ||
|
|
d0713408a9 | ||
|
|
dcfb1599c8 | ||
|
|
de0d6ca8a5 | ||
|
|
a89cdd57cf | ||
|
|
67abd78568 | ||
|
|
d28f11e119 | ||
|
|
d080407a66 | ||
|
|
2234a6707c | ||
|
|
be540a08ec | ||
|
|
4f53d32924 | ||
|
|
078d880a0e | ||
|
|
46ab6c3946 | ||
|
|
0e35067bc8 | ||
|
|
8474295a5d | ||
|
|
1fa461327a |
@@ -1,6 +1,7 @@
|
||||
class V2::ReportBuilder
|
||||
include DateRangeHelper
|
||||
include ReportHelper
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
DEFAULT_GROUP_BY = 'day'.freeze
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class V2::Reports::BaseSummaryBuilder
|
||||
include DateRangeHelper
|
||||
include V2::Reports::Concerns::RollupConditions
|
||||
|
||||
def build
|
||||
load_data
|
||||
@@ -8,9 +9,15 @@ class V2::Reports::BaseSummaryBuilder
|
||||
|
||||
private
|
||||
|
||||
# Summary builders fetch all metrics in a single query, so the per-metric
|
||||
# check from RollupConditions does not apply.
|
||||
def metric_covered?
|
||||
true
|
||||
end
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
load_reporting_events_data
|
||||
use_rollup? ? load_rollup_data : load_reporting_events_data
|
||||
end
|
||||
|
||||
def load_reporting_events_data
|
||||
@@ -34,6 +41,46 @@ class V2::Reports::BaseSummaryBuilder
|
||||
@avg_reply_time = results.transform_values(&:avg_reply_time)
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
||||
def load_rollup_data
|
||||
dimension_type = dimension_type_to_rollup
|
||||
value_col = rollup_value_column
|
||||
|
||||
# Fetch rollup data grouped by dimension_id
|
||||
rollup_rows = ReportingEventsRollup.where(
|
||||
account_id: account.id,
|
||||
dimension_type: dimension_type,
|
||||
date: rollup_date_range
|
||||
).group(:dimension_id).select(
|
||||
'dimension_id',
|
||||
'SUM(CASE WHEN metric = \'resolutions_count\' THEN count ELSE 0 END) as resolved_count',
|
||||
"SUM(CASE WHEN metric = 'resolution_time' THEN count ELSE 0 END) as resolution_count",
|
||||
"SUM(CASE WHEN metric = 'resolution_time' THEN #{value_col} ELSE 0 END) as resolution_sum_value",
|
||||
"SUM(CASE WHEN metric = 'first_response' THEN count ELSE 0 END) as first_response_count",
|
||||
"SUM(CASE WHEN metric = 'first_response' THEN #{value_col} ELSE 0 END) as first_response_sum_value",
|
||||
"SUM(CASE WHEN metric = 'reply_time' THEN count ELSE 0 END) as reply_count",
|
||||
"SUM(CASE WHEN metric = 'reply_time' THEN #{value_col} ELSE 0 END) as reply_sum_value"
|
||||
)
|
||||
|
||||
# Convert to the expected format
|
||||
results = {}
|
||||
rollup_rows.each do |row|
|
||||
results[row.dimension_id] = row
|
||||
end
|
||||
|
||||
@resolved_count = results.transform_values { |r| r.resolved_count.to_i }
|
||||
@avg_resolution_time = results.transform_values do |r|
|
||||
r.resolution_count.to_i.zero? ? nil : (r.resolution_sum_value.to_f / r.resolution_count.to_i)
|
||||
end
|
||||
@avg_first_response_time = results.transform_values do |r|
|
||||
r.first_response_count.to_i.zero? ? nil : (r.first_response_sum_value.to_f / r.first_response_count.to_i)
|
||||
end
|
||||
@avg_reply_time = results.transform_values do |r|
|
||||
r.reply_count.to_i.zero? ? nil : (r.reply_sum_value.to_f / r.reply_count.to_i)
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= account.reporting_events.where(created_at: range)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
module V2::Reports::Concerns::RollupConditions
|
||||
COVERED_METRICS = {
|
||||
avg_first_response_time: :first_response,
|
||||
avg_resolution_time: :resolution_time,
|
||||
reply_time: :reply_time,
|
||||
resolutions_count: :resolutions_count,
|
||||
bot_resolutions_count: :bot_resolutions_count,
|
||||
bot_handoffs_count: :bot_handoffs_count
|
||||
}.freeze
|
||||
|
||||
SUPPORTED_DIMENSIONS = %w[account agent inbox].freeze
|
||||
|
||||
def use_rollup?
|
||||
# Condition 0: reporting_timezone must be configured on account
|
||||
return false if account.reporting_timezone.blank?
|
||||
|
||||
# Condition 1: Feature flag must be enabled
|
||||
return false unless account.feature_enabled?('reporting_events_rollup')
|
||||
|
||||
# Condition 2: Metric must be covered by rollup
|
||||
return false unless metric_covered?
|
||||
|
||||
# Condition 3: Not hourly granularity
|
||||
return false if params[:group_by] == 'hour'
|
||||
|
||||
# Condition 4: Dimension must be supported
|
||||
return false unless dimension_supported?
|
||||
|
||||
# Condition 5: Timezone must match
|
||||
timezone_matches_account?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def metric_covered?
|
||||
return false if params[:metric].blank?
|
||||
|
||||
COVERED_METRICS.key?(params[:metric].to_sym)
|
||||
end
|
||||
|
||||
def dimension_supported?
|
||||
return true if params[:type].blank? # account dimension (no specific ID)
|
||||
|
||||
SUPPORTED_DIMENSIONS.include?(params[:type].to_s)
|
||||
end
|
||||
|
||||
def timezone_matches_account?
|
||||
return false if params[:timezone_offset].blank?
|
||||
|
||||
offset_in_seconds = params[:timezone_offset].to_f * 3600
|
||||
account_zone = ActiveSupport::TimeZone[account.reporting_timezone]
|
||||
account_zone&.now&.utc_offset == offset_in_seconds
|
||||
end
|
||||
|
||||
def metric_to_rollup_metric(metric)
|
||||
COVERED_METRICS[metric.to_sym]
|
||||
end
|
||||
|
||||
def rollup_value_column
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :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
|
||||
# range is exclusive (since...until), so subtract a second to avoid
|
||||
# including the until boundary day when it falls exactly at midnight.
|
||||
end_date = (range.last - 1.second).in_time_zone(tz).to_date
|
||||
start_date..end_date
|
||||
end
|
||||
|
||||
def dimension_type_to_rollup
|
||||
{ 'account' => :account, 'agent' => :agent, 'inbox' => :inbox }[params[:type].to_s]
|
||||
end
|
||||
end
|
||||
@@ -11,11 +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
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
use_rollup? ? rollup_timeseries : raw_timeseries
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
use_rollup? ? rollup_aggregate_value : raw_aggregate_value
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def raw_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|
|
||||
@@ -12,11 +22,98 @@ class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::B
|
||||
end
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
def rollup_timeseries
|
||||
dimension_type = dimension_type_to_rollup
|
||||
dimension_id = dimension_id_for_rollup
|
||||
metric = metric_to_rollup_metric(params[:metric])
|
||||
|
||||
rollup_rows = ReportingEventsRollup.where(
|
||||
account_id: account.id,
|
||||
metric: metric,
|
||||
dimension_type: dimension_type,
|
||||
dimension_id: dimension_id,
|
||||
date: rollup_date_range
|
||||
)
|
||||
|
||||
group_and_aggregate_rollup(rollup_rows)
|
||||
end
|
||||
|
||||
def raw_aggregate_value
|
||||
object_scope.average(average_value_key)
|
||||
end
|
||||
|
||||
private
|
||||
def rollup_aggregate_value
|
||||
dimension_type = dimension_type_to_rollup
|
||||
dimension_id = dimension_id_for_rollup
|
||||
metric = metric_to_rollup_metric(params[:metric])
|
||||
|
||||
value_col = rollup_value_column
|
||||
|
||||
result = ReportingEventsRollup.where(
|
||||
account_id: account.id,
|
||||
metric: metric,
|
||||
dimension_type: dimension_type,
|
||||
dimension_id: dimension_id,
|
||||
date: rollup_date_range
|
||||
).pick(Arel.sql("SUM(count), SUM(#{value_col})"))
|
||||
|
||||
return nil if result.blank? || result[0].to_i.zero?
|
||||
|
||||
result[1].to_f / result[0].to_i
|
||||
end
|
||||
|
||||
def dimension_id_for_rollup
|
||||
params[:type].to_s == 'account' ? account.id : scope.id
|
||||
end
|
||||
|
||||
def group_and_aggregate_rollup(rollup_rows)
|
||||
grouped_data = aggregate_rollup_rows(rollup_rows, all_periods_in_range.index_with { { count: 0, sum_value: 0.0 } })
|
||||
|
||||
results = grouped_data.map do |date_key, data|
|
||||
value = data[:count].zero? ? 0 : data[:sum_value] / data[:count]
|
||||
{ value: value, timestamp: date_key.in_time_zone(timezone).to_i, count: data[:count] }
|
||||
end
|
||||
results.sort_by { |h| h[:timestamp] }
|
||||
end
|
||||
|
||||
def aggregate_rollup_rows(rollup_rows, grouped_data = {})
|
||||
value_col = rollup_value_column
|
||||
rollup_rows.each_with_object(grouped_data) do |row, all_grouped_data|
|
||||
date_key = date_key_for_group(row.date)
|
||||
all_grouped_data[date_key] ||= { count: 0, sum_value: 0.0 }
|
||||
all_grouped_data[date_key][:count] += row.count
|
||||
all_grouped_data[date_key][:sum_value] += row.public_send(value_col)
|
||||
end
|
||||
end
|
||||
|
||||
def date_key_for_group(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 all_periods_in_range
|
||||
date_range = rollup_date_range
|
||||
dates = []
|
||||
current = date_key_for_group(date_range.first)
|
||||
while current <= date_range.last
|
||||
dates << current
|
||||
current = advance_period(current)
|
||||
end
|
||||
dates
|
||||
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 event_name
|
||||
metric_to_event_name = {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
include TimezoneHelper
|
||||
include DateRangeHelper
|
||||
include V2::Reports::Concerns::RollupConditions
|
||||
|
||||
DEFAULT_GROUP_BY = 'day'.freeze
|
||||
|
||||
pattr_initialize :account, :params
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
use_rollup? ? rollup_timeseries : raw_timeseries
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
use_rollup? ? rollup_aggregate_value : raw_aggregate_value
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def raw_timeseries
|
||||
grouped_count.each_with_object([]) do |element, arr|
|
||||
event_date, event_count = element
|
||||
|
||||
@@ -11,11 +21,92 @@ class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::Bas
|
||||
end
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
def rollup_timeseries
|
||||
metric = metric_to_rollup_metric(params[:metric])
|
||||
return [] if metric.blank?
|
||||
|
||||
dimension_type = dimension_type_to_rollup
|
||||
dimension_id = dimension_id_for_rollup
|
||||
|
||||
rollup_rows = ReportingEventsRollup.where(
|
||||
account_id: account.id,
|
||||
metric: metric,
|
||||
dimension_type: dimension_type,
|
||||
dimension_id: dimension_id,
|
||||
date: rollup_date_range
|
||||
)
|
||||
|
||||
group_and_aggregate_rollup_counts(rollup_rows)
|
||||
end
|
||||
|
||||
def raw_aggregate_value
|
||||
object_scope.count
|
||||
end
|
||||
|
||||
private
|
||||
def rollup_aggregate_value
|
||||
metric = metric_to_rollup_metric(params[:metric])
|
||||
return 0 if metric.blank?
|
||||
|
||||
dimension_type = dimension_type_to_rollup
|
||||
dimension_id = dimension_id_for_rollup
|
||||
|
||||
ReportingEventsRollup.where(
|
||||
account_id: account.id,
|
||||
metric: metric,
|
||||
dimension_type: dimension_type,
|
||||
dimension_id: dimension_id,
|
||||
date: rollup_date_range
|
||||
).sum(:count).to_i
|
||||
end
|
||||
|
||||
def dimension_id_for_rollup
|
||||
params[:type].to_s == 'account' ? account.id : scope.id
|
||||
end
|
||||
|
||||
def group_and_aggregate_rollup_counts(rollup_rows)
|
||||
# Pre-fill all periods with zero to match raw path's default_value: 0 behavior
|
||||
grouped_data = all_periods_in_range.index_with { 0 }
|
||||
|
||||
rollup_rows.each do |row|
|
||||
date_key = date_key_for_period(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 { |h| h[:timestamp] }
|
||||
end
|
||||
|
||||
def date_key_for_period(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 all_periods_in_range
|
||||
date_range = rollup_date_range
|
||||
dates = []
|
||||
current = date_key_for_period(date_range.first)
|
||||
while current <= date_range.last
|
||||
dates << current
|
||||
current = advance_period(current)
|
||||
end
|
||||
dates
|
||||
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 metric
|
||||
@metric ||= params[:metric]
|
||||
|
||||
@@ -100,7 +100,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def check_signup_enabled
|
||||
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
|
||||
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
|
||||
end
|
||||
|
||||
def validate_captcha
|
||||
|
||||
@@ -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,17 @@ 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_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_offset)
|
||||
end
|
||||
|
||||
def date_range_too_long?
|
||||
|
||||
@@ -58,7 +58,7 @@ class Api::V2::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def check_signup_enabled
|
||||
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
|
||||
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
|
||||
end
|
||||
|
||||
def validate_captcha
|
||||
|
||||
@@ -51,8 +51,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
end
|
||||
|
||||
def account_signup_allowed?
|
||||
# set it to true by default, this is the behaviour across the app
|
||||
GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false'
|
||||
GlobalConfigService.account_signup_enabled?
|
||||
end
|
||||
|
||||
def resource_class(_mapping = nil)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
const getTimeOffset = () => -new Date().getTimezoneOffset() / 60;
|
||||
|
||||
class SummaryReportsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('summary_reports', { accountScoped: true, apiVersion: 'v2' });
|
||||
@@ -12,6 +14,7 @@ class SummaryReportsAPI extends ApiClient {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -22,6 +25,7 @@ class SummaryReportsAPI extends ApiClient {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -32,6 +36,7 @@ class SummaryReportsAPI extends ApiClient {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -42,6 +47,7 @@ class SummaryReportsAPI extends ApiClient {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ class ReportingEventListener < BaseListener
|
||||
|
||||
create_bot_resolved_event(conversation, reporting_event)
|
||||
reporting_event.save!
|
||||
ReportingEvents::RollupService.perform(reporting_event)
|
||||
end
|
||||
|
||||
def first_reply_created(event)
|
||||
@@ -41,6 +42,7 @@ class ReportingEventListener < BaseListener
|
||||
)
|
||||
|
||||
reporting_event.save!
|
||||
ReportingEvents::RollupService.perform(reporting_event)
|
||||
end
|
||||
|
||||
def reply_created(event)
|
||||
@@ -65,6 +67,7 @@ class ReportingEventListener < BaseListener
|
||||
event_end_time: message.created_at
|
||||
)
|
||||
reporting_event.save!
|
||||
ReportingEvents::RollupService.perform(reporting_event)
|
||||
end
|
||||
|
||||
def conversation_bot_handoff(event)
|
||||
@@ -88,6 +91,7 @@ class ReportingEventListener < BaseListener
|
||||
event_end_time: conversation.updated_at
|
||||
)
|
||||
reporting_event.save!
|
||||
ReportingEvents::RollupService.perform(reporting_event)
|
||||
end
|
||||
|
||||
def conversation_opened(event)
|
||||
@@ -139,5 +143,6 @@ class ReportingEventListener < BaseListener
|
||||
bot_resolved_event = reporting_event.dup
|
||||
bot_resolved_event.name = 'conversation_bot_resolved'
|
||||
bot_resolved_event.save!
|
||||
ReportingEvents::RollupService.new(bot_resolved_event).perform
|
||||
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_disable_auto_resolve
|
||||
|
||||
@@ -214,6 +216,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,130 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ReportingEvents::BackfillService
|
||||
def self.backfill_date(account, date)
|
||||
new(account, date).perform
|
||||
end
|
||||
|
||||
def initialize(account, date)
|
||||
@account = account
|
||||
@date = date
|
||||
end
|
||||
|
||||
def perform
|
||||
# 1. Delete existing rollups for this date (idempotency)
|
||||
delete_existing_rollups
|
||||
|
||||
# 2. Convert date in account timezone to UTC boundaries
|
||||
start_utc, end_utc = date_boundaries_in_utc
|
||||
|
||||
# 3. Process each event type with SQL aggregation
|
||||
rollup_rows = []
|
||||
rollup_rows.concat(aggregate_event_type('conversation_resolved', start_utc, end_utc))
|
||||
rollup_rows.concat(aggregate_event_type('first_response', start_utc, end_utc))
|
||||
rollup_rows.concat(aggregate_event_type('reply_time', start_utc, end_utc))
|
||||
rollup_rows.concat(aggregate_event_type('conversation_bot_resolved', start_utc, end_utc))
|
||||
rollup_rows.concat(aggregate_event_type('conversation_bot_handoff', start_utc, end_utc))
|
||||
|
||||
# 4. Bulk insert all rollups at once
|
||||
bulk_insert_rollups(rollup_rows) if rollup_rows.any?
|
||||
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 aggregate_event_type(event_name, start_utc, end_utc)
|
||||
events = @account.reporting_events
|
||||
.where(name: event_name, created_at: start_utc...end_utc)
|
||||
.includes(:conversation)
|
||||
|
||||
return [] if events.empty?
|
||||
|
||||
# Build in-memory aggregates by dimension
|
||||
aggregates = build_aggregates(events, event_name)
|
||||
|
||||
# Convert to rollup row hashes
|
||||
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(events, event_name)
|
||||
aggregates = Hash.new { |h, k| h[k] = { count: 0, sum_value: 0.0, sum_value_business_hours: 0.0 } }
|
||||
|
||||
events.each do |event|
|
||||
metrics = metrics_for_event(event_name, event)
|
||||
dimensions = dimensions_for_event(event)
|
||||
|
||||
metrics.each do |metric, metric_data|
|
||||
dimensions.each do |dimension_type, dimension_id|
|
||||
next if dimension_id.nil?
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
aggregates
|
||||
end
|
||||
|
||||
def dimensions_for_event(event)
|
||||
{
|
||||
'account' => @account.id,
|
||||
'agent' => event.user_id,
|
||||
'inbox' => event.inbox_id,
|
||||
# Team rollups are still collected for validation/future use, but team reports
|
||||
# currently stay on the raw path because reassignment breaks rollup parity.
|
||||
'team' => event.conversation&.team_id
|
||||
}
|
||||
end
|
||||
|
||||
def metrics_for_event(event_name, event)
|
||||
case event_name
|
||||
when 'conversation_resolved'
|
||||
{
|
||||
'resolutions_count' => { count: 1, sum_value: 0, sum_value_business_hours: 0 },
|
||||
'resolution_time' => { count: 1, sum_value: event.value, sum_value_business_hours: event.value_in_business_hours }
|
||||
}
|
||||
when 'first_response'
|
||||
{ 'first_response' => { count: 1, sum_value: event.value, sum_value_business_hours: event.value_in_business_hours } }
|
||||
when 'reply_time'
|
||||
{ 'reply_time' => { count: 1, sum_value: event.value, sum_value_business_hours: event.value_in_business_hours } }
|
||||
when 'conversation_bot_resolved'
|
||||
{ 'bot_resolutions_count' => { count: 1, sum_value: 0, sum_value_business_hours: 0 } }
|
||||
when 'conversation_bot_handoff'
|
||||
{ 'bot_handoffs_count' => { count: 1, sum_value: 0, sum_value_business_hours: 0 } }
|
||||
else
|
||||
{}
|
||||
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,149 @@
|
||||
module ReportingEvents::RollupService
|
||||
def self.perform(reporting_event)
|
||||
new(reporting_event).perform
|
||||
end
|
||||
|
||||
def self.new(reporting_event)
|
||||
Performer.new(reporting_event)
|
||||
end
|
||||
|
||||
class Performer
|
||||
def initialize(reporting_event)
|
||||
@reporting_event = reporting_event
|
||||
@account = reporting_event.account
|
||||
end
|
||||
|
||||
def perform
|
||||
return unless rollup_enabled?
|
||||
|
||||
# NOTE: Each event produces individual upserts per dimension x metric (up to 8 calls).
|
||||
# If this becomes a bottleneck, batch into a single upsert_all call.
|
||||
dimensions.each do |dimension_type, dimension_id|
|
||||
next if dimension_id.nil?
|
||||
|
||||
metrics_for_event.each do |metric, metric_data|
|
||||
upsert_rollup(dimension_type, dimension_id, metric, metric_data)
|
||||
end
|
||||
end
|
||||
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,
|
||||
# Team rollups are still collected for validation/future use, but team reports
|
||||
# currently stay on the raw path because reassignment breaks rollup parity.
|
||||
team: team_id
|
||||
}
|
||||
end
|
||||
|
||||
def team_id
|
||||
return nil if @reporting_event.conversation_id.blank?
|
||||
|
||||
@team_id ||= @reporting_event.conversation&.team_id
|
||||
end
|
||||
|
||||
def metrics_for_event
|
||||
case @reporting_event.name
|
||||
when 'conversation_resolved'
|
||||
conversation_resolved_metrics
|
||||
when 'first_response'
|
||||
first_response_metrics
|
||||
when 'reply_time'
|
||||
reply_time_metrics
|
||||
when 'conversation_bot_resolved'
|
||||
bot_resolutions_metrics
|
||||
when 'conversation_bot_handoff'
|
||||
bot_handoffs_metrics
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
def conversation_resolved_metrics
|
||||
{
|
||||
resolutions_count: { count: 1, sum_value: 0, sum_value_business_hours: 0 },
|
||||
resolution_time: {
|
||||
count: 1,
|
||||
sum_value: @reporting_event.value,
|
||||
sum_value_business_hours: @reporting_event.value_in_business_hours
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def first_response_metrics
|
||||
{
|
||||
first_response: {
|
||||
count: 1,
|
||||
sum_value: @reporting_event.value,
|
||||
sum_value_business_hours: @reporting_event.value_in_business_hours
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def reply_time_metrics
|
||||
{
|
||||
reply_time: {
|
||||
count: 1,
|
||||
sum_value: @reporting_event.value,
|
||||
sum_value_business_hours: @reporting_event.value_in_business_hours
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def bot_resolutions_metrics
|
||||
{
|
||||
bot_resolutions_count: { count: 1, sum_value: 0, sum_value_business_hours: 0 }
|
||||
}
|
||||
end
|
||||
|
||||
def bot_handoffs_metrics
|
||||
{
|
||||
bot_handoffs_count: { count: 1, sum_value: 0, sum_value_business_hours: 0 }
|
||||
}
|
||||
end
|
||||
|
||||
def upsert_rollup(dimension_type, dimension_id, metric, metric_data)
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
ReportingEventsRollup.upsert(
|
||||
rollup_attributes(dimension_type, dimension_id, metric, metric_data),
|
||||
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
|
||||
end
|
||||
@@ -242,3 +242,6 @@
|
||||
display_name: Advanced Assignment
|
||||
enabled: false
|
||||
premium: true
|
||||
- name: reporting_events_rollup
|
||||
display_name: Reporting Events Rollup
|
||||
enabled: false
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,4 +14,8 @@ class GlobalConfigService
|
||||
GlobalConfig.clear_cache
|
||||
i.value
|
||||
end
|
||||
|
||||
def self.account_signup_enabled?
|
||||
load('ENABLE_ACCOUNT_SIGNUP', 'false').to_s != 'false'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
# 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)
|
||||
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. Enable feature flag: Account.find(#{account.id}).enable_features!('reporting_events_rollup')"
|
||||
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 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,175 @@
|
||||
# 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)
|
||||
normalized = offset_input.gsub(/^(?!\+|-)/, '+')
|
||||
parts = normalized.split(':')
|
||||
hours = parts[0].to_i
|
||||
minutes = (parts[1] || '0').to_i
|
||||
total_seconds = (hours * 3600) + (hours.negative? ? -minutes * 60 : minutes * 60)
|
||||
|
||||
ActiveSupport::TimeZone.all.select { |tz| tz.utc_offset == total_seconds }
|
||||
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
|
||||
@@ -0,0 +1,327 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe V2::Reports::Concerns::RollupConditions do
|
||||
let(:dummy_class) do
|
||||
Class.new do
|
||||
include V2::Reports::Concerns::RollupConditions
|
||||
|
||||
attr_accessor :account, :params
|
||||
|
||||
# Make private methods public for testing
|
||||
public :metric_to_rollup_metric, :dimension_type_to_rollup, :timezone_matches_account?
|
||||
end
|
||||
end
|
||||
|
||||
let(:account) { create(:account, reporting_timezone: 'America/New_York') }
|
||||
let(:builder) { dummy_class.new }
|
||||
# Compute the current offset dynamically so tests pass in both EST and EDT
|
||||
let(:current_ny_offset) { ActiveSupport::TimeZone['America/New_York'].now.utc_offset / 3600.0 }
|
||||
|
||||
before do
|
||||
builder.account = account
|
||||
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(true)
|
||||
end
|
||||
|
||||
describe '#use_rollup?' do
|
||||
let(:valid_params) do
|
||||
{
|
||||
metric: 'avg_resolution_time',
|
||||
type: 'account',
|
||||
group_by: 'day',
|
||||
timezone_offset: current_ny_offset
|
||||
}
|
||||
end
|
||||
|
||||
context 'when all conditions pass' do
|
||||
it 'returns true' do
|
||||
builder.params = valid_params
|
||||
expect(builder.use_rollup?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when reporting_timezone is blank' do
|
||||
it 'returns false' do
|
||||
account.update!(reporting_timezone: nil)
|
||||
builder.params = valid_params
|
||||
expect(builder.use_rollup?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when feature flag is disabled' do
|
||||
it 'returns false' do
|
||||
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(false)
|
||||
builder.params = valid_params
|
||||
expect(builder.use_rollup?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when metric is not covered' do
|
||||
it 'returns false for conversations_count' do
|
||||
builder.params = valid_params.merge(metric: 'conversations_count')
|
||||
|
||||
expect(builder.use_rollup?).to be false
|
||||
end
|
||||
|
||||
it 'returns false for incoming_messages_count' do
|
||||
builder.params = valid_params.merge(metric: 'incoming_messages_count')
|
||||
|
||||
expect(builder.use_rollup?).to be false
|
||||
end
|
||||
|
||||
it 'returns false for outgoing_messages_count' do
|
||||
builder.params = valid_params.merge(metric: 'outgoing_messages_count')
|
||||
|
||||
expect(builder.use_rollup?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when metric is covered' do
|
||||
it 'returns true for avg_first_response_time' do
|
||||
builder.params = valid_params.merge(metric: 'avg_first_response_time')
|
||||
|
||||
expect(builder.use_rollup?).to be true
|
||||
end
|
||||
|
||||
it 'returns true for avg_resolution_time' do
|
||||
builder.params = valid_params
|
||||
expect(builder.use_rollup?).to be true
|
||||
end
|
||||
|
||||
it 'returns true for reply_time' do
|
||||
builder.params = valid_params.merge(metric: 'reply_time')
|
||||
|
||||
expect(builder.use_rollup?).to be true
|
||||
end
|
||||
|
||||
it 'returns true for resolutions_count' do
|
||||
builder.params = valid_params.merge(metric: 'resolutions_count')
|
||||
|
||||
expect(builder.use_rollup?).to be true
|
||||
end
|
||||
|
||||
it 'returns true for bot_resolutions_count' do
|
||||
builder.params = valid_params.merge(metric: 'bot_resolutions_count')
|
||||
|
||||
expect(builder.use_rollup?).to be true
|
||||
end
|
||||
|
||||
it 'returns true for bot_handoffs_count' do
|
||||
builder.params = valid_params.merge(metric: 'bot_handoffs_count')
|
||||
|
||||
expect(builder.use_rollup?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when group_by is hourly' do
|
||||
it 'returns false when group_by is hour' do
|
||||
builder.params = valid_params.merge(group_by: 'hour')
|
||||
expect(builder.use_rollup?).to be false
|
||||
end
|
||||
|
||||
it 'returns true for day granularity' do
|
||||
builder.params = valid_params
|
||||
expect(builder.use_rollup?).to be true
|
||||
end
|
||||
|
||||
it 'returns true for week granularity' do
|
||||
builder.params = valid_params.merge(group_by: 'week')
|
||||
expect(builder.use_rollup?).to be true
|
||||
end
|
||||
|
||||
it 'returns true for month granularity' do
|
||||
builder.params = valid_params.merge(group_by: 'month')
|
||||
expect(builder.use_rollup?).to be true
|
||||
end
|
||||
|
||||
it 'returns true for year granularity' do
|
||||
builder.params = valid_params.merge(group_by: 'year')
|
||||
expect(builder.use_rollup?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when dimension is not supported' do
|
||||
it 'returns false for label dimension' do
|
||||
builder.params = valid_params.merge(type: 'label')
|
||||
|
||||
expect(builder.use_rollup?).to be false
|
||||
end
|
||||
|
||||
it 'returns true for account dimension' do
|
||||
builder.params = valid_params.merge(type: 'account')
|
||||
|
||||
expect(builder.use_rollup?).to be true
|
||||
end
|
||||
|
||||
it 'returns true for agent dimension' do
|
||||
builder.params = valid_params.merge(type: 'agent')
|
||||
|
||||
expect(builder.use_rollup?).to be true
|
||||
end
|
||||
|
||||
it 'returns true for inbox dimension' do
|
||||
builder.params = valid_params.merge(type: 'inbox')
|
||||
|
||||
expect(builder.use_rollup?).to be true
|
||||
end
|
||||
|
||||
it 'returns false for team dimension' do
|
||||
builder.params = valid_params.merge(type: 'team')
|
||||
|
||||
expect(builder.use_rollup?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when timezone_offset does not match' do
|
||||
it 'returns false when timezone_offset is UTC' do
|
||||
builder.params = valid_params.merge(timezone_offset: 0)
|
||||
|
||||
expect(builder.use_rollup?).to be false
|
||||
end
|
||||
|
||||
it 'returns false when timezone_offset is UTC+5:30' do
|
||||
builder.params = valid_params.merge(timezone_offset: 5.5)
|
||||
|
||||
expect(builder.use_rollup?).to be false
|
||||
end
|
||||
|
||||
it 'returns true when timezone_offset matches account timezone' do
|
||||
builder.params = valid_params.merge(timezone_offset: current_ny_offset)
|
||||
|
||||
expect(builder.use_rollup?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when timezone_offset is blank' do
|
||||
builder.params = valid_params.merge(timezone_offset: nil)
|
||||
|
||||
expect(builder.use_rollup?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when used from BaseSummaryBuilder (no params[:metric])' do
|
||||
it 'returns true because summary builders override metric_covered?' do
|
||||
builder.params = { type: 'agent', timezone_offset: current_ny_offset }
|
||||
|
||||
# Without the override, this would return false since params[:metric] is blank
|
||||
expect(builder.use_rollup?).to be false
|
||||
|
||||
# BaseSummaryBuilder overrides metric_covered? to always return true
|
||||
summary_builder_class = Class.new(dummy_class) do
|
||||
def metric_covered?
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
summary_builder = summary_builder_class.new
|
||||
summary_builder.account = account
|
||||
summary_builder.params = { type: 'agent', timezone_offset: current_ny_offset, group_by: 'day' }
|
||||
expect(summary_builder.use_rollup?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#metric_to_rollup_metric' do
|
||||
it 'maps avg_resolution_time to resolution_time' do
|
||||
builder.params = { metric: 'avg_resolution_time' }
|
||||
expect(builder.metric_to_rollup_metric('avg_resolution_time')).to eq(:resolution_time)
|
||||
end
|
||||
|
||||
it 'maps avg_first_response_time to first_response' do
|
||||
builder.params = { metric: 'avg_first_response_time' }
|
||||
expect(builder.metric_to_rollup_metric('avg_first_response_time')).to eq(:first_response)
|
||||
end
|
||||
|
||||
it 'maps reply_time to reply_time' do
|
||||
builder.params = { metric: 'reply_time' }
|
||||
expect(builder.metric_to_rollup_metric('reply_time')).to eq(:reply_time)
|
||||
end
|
||||
|
||||
it 'maps resolutions_count to resolutions_count' do
|
||||
builder.params = { metric: 'resolutions_count' }
|
||||
expect(builder.metric_to_rollup_metric('resolutions_count')).to eq(:resolutions_count)
|
||||
end
|
||||
|
||||
it 'maps bot_resolutions_count to bot_resolutions_count' do
|
||||
builder.params = { metric: 'bot_resolutions_count' }
|
||||
expect(builder.metric_to_rollup_metric('bot_resolutions_count')).to eq(:bot_resolutions_count)
|
||||
end
|
||||
|
||||
it 'maps bot_handoffs_count to bot_handoffs_count' do
|
||||
builder.params = { metric: 'bot_handoffs_count' }
|
||||
expect(builder.metric_to_rollup_metric('bot_handoffs_count')).to eq(:bot_handoffs_count)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#dimension_type_to_rollup' do
|
||||
it 'maps account type to account' do
|
||||
builder.params = { type: 'account' }
|
||||
expect(builder.dimension_type_to_rollup).to eq(:account)
|
||||
end
|
||||
|
||||
it 'maps agent type to agent' do
|
||||
builder.params = { type: 'agent' }
|
||||
expect(builder.dimension_type_to_rollup).to eq(:agent)
|
||||
end
|
||||
|
||||
it 'maps inbox type to inbox' do
|
||||
builder.params = { type: 'inbox' }
|
||||
expect(builder.dimension_type_to_rollup).to eq(:inbox)
|
||||
end
|
||||
|
||||
it 'returns nil for team type' do
|
||||
builder.params = { type: 'team' }
|
||||
expect(builder.dimension_type_to_rollup).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil for unsupported type' do
|
||||
builder.params = { type: 'label' }
|
||||
expect(builder.dimension_type_to_rollup).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil when type is blank' do
|
||||
builder.params = { type: nil }
|
||||
expect(builder.dimension_type_to_rollup).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#timezone_matches_account?' do
|
||||
it 'returns false when timezone_offset is blank' do
|
||||
builder.params = { timezone_offset: nil }
|
||||
expect(builder.timezone_matches_account?).to be false
|
||||
end
|
||||
|
||||
it 'returns true when offset matches account timezone current offset' do
|
||||
builder.params = { timezone_offset: current_ny_offset }
|
||||
expect(builder.timezone_matches_account?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when offset does not match account timezone' do
|
||||
# UTC+5:30 (IST) will never match America/New_York
|
||||
builder.params = { timezone_offset: 5.5 }
|
||||
expect(builder.timezone_matches_account?).to be false
|
||||
end
|
||||
|
||||
it 'returns false when account timezone is not recognized' do
|
||||
allow(account).to receive(:reporting_timezone).and_return('Invalid/Zone')
|
||||
builder.params = { timezone_offset: current_ny_offset }
|
||||
expect(builder.timezone_matches_account?).to be false
|
||||
end
|
||||
|
||||
it 'works with fractional offsets like UTC+5:45' do
|
||||
account.update!(reporting_timezone: 'Asia/Kathmandu')
|
||||
kathmandu_offset = ActiveSupport::TimeZone['Asia/Kathmandu'].now.utc_offset / 3600.0
|
||||
builder.params = { timezone_offset: kathmandu_offset }
|
||||
expect(builder.timezone_matches_account?).to be true
|
||||
end
|
||||
|
||||
it 'handles DST-aware comparison correctly' do
|
||||
# Use a timezone with no DST (UTC) to have a stable control
|
||||
account.update!(reporting_timezone: 'Etc/UTC')
|
||||
builder.params = { timezone_offset: 0 }
|
||||
expect(builder.timezone_matches_account?).to be true
|
||||
|
||||
# A non-zero offset should not match UTC
|
||||
builder.params = { timezone_offset: -5 }
|
||||
expect(builder.timezone_matches_account?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -79,6 +79,59 @@ describe V2::Reports::Timeseries::AverageReportBuilder do
|
||||
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
|
||||
expected_timeseries = subject.send(:rollup_date_range).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' }
|
||||
|
||||
@@ -107,6 +160,45 @@ describe V2::Reports::Timeseries::AverageReportBuilder do
|
||||
)
|
||||
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
|
||||
end
|
||||
|
||||
context 'when the label filter is applied' do
|
||||
@@ -170,5 +262,20 @@ describe V2::Reports::Timeseries::AverageReportBuilder 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('reporting_events_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
|
||||
|
||||
@@ -14,11 +14,16 @@ describe V2::Reports::Timeseries::CountReportBuilder do
|
||||
{
|
||||
type: 'agent',
|
||||
metric: 'resolutions_count',
|
||||
since: (current_time - 1.day).beginning_of_day.to_i.to_s,
|
||||
since: since_time.beginning_of_day.to_i.to_s,
|
||||
until: current_time.end_of_day.to_i.to_s,
|
||||
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_offset) { nil }
|
||||
|
||||
before do
|
||||
travel_to current_time
|
||||
@@ -81,6 +86,23 @@ describe V2::Reports::Timeseries::CountReportBuilder 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('reporting_events_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) }
|
||||
|
||||
@@ -98,6 +120,47 @@ describe V2::Reports::Timeseries::CountReportBuilder do
|
||||
total_count = result.sum { |r| r[: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('reporting_events_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
|
||||
|
||||
@@ -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
|
||||
@@ -217,6 +217,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,74 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Signup enforcement', type: :request do
|
||||
let(:signup_config_name) { 'ENABLE_ACCOUNT_SIGNUP' }
|
||||
|
||||
before do
|
||||
GlobalConfig.clear_cache
|
||||
InstallationConfig.where(name: signup_config_name).delete_all
|
||||
InstallationConfig.create!(name: signup_config_name, value: false, locked: false)
|
||||
end
|
||||
|
||||
after do
|
||||
InstallationConfig.where(name: signup_config_name).delete_all
|
||||
GlobalConfig.clear_cache
|
||||
OmniAuth.config.mock_auth[:google_oauth2] = nil
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts' do
|
||||
it 'blocks signup when the config is stored as boolean false' do
|
||||
post api_v1_accounts_url,
|
||||
params: {
|
||||
account_name: 'verify',
|
||||
user_full_name: 'Verify User',
|
||||
email: "verify-#{SecureRandom.hex(4)}@example.com",
|
||||
password: 'Password1!'
|
||||
},
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v2/accounts' do
|
||||
it 'blocks signup when the config is stored as boolean false' do
|
||||
post api_v2_accounts_url,
|
||||
params: {
|
||||
email: "verify-#{SecureRandom.hex(4)}@example.com",
|
||||
password: 'Password1!'
|
||||
},
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /omniauth/google_oauth2/callback' do
|
||||
let(:email_validation_service) { instance_double(Account::SignUpEmailValidationService, perform: true) }
|
||||
|
||||
before do
|
||||
OmniAuth.config.test_mode = true
|
||||
allow(Account::SignUpEmailValidationService).to receive(:new).and_return(email_validation_service)
|
||||
end
|
||||
|
||||
it 'redirects to no-account-found when the config is stored as boolean false' do
|
||||
OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new(
|
||||
provider: 'google',
|
||||
uid: '123545',
|
||||
info: {
|
||||
name: 'test',
|
||||
email: "verify-#{SecureRandom.hex(4)}@example.com",
|
||||
image: 'https://example.com/image.jpg'
|
||||
}
|
||||
)
|
||||
|
||||
with_modified_env FRONTEND_URL: 'http://www.example.com' do
|
||||
get '/omniauth/google_oauth2/callback'
|
||||
|
||||
expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback')
|
||||
follow_redirect!
|
||||
expect(response).to redirect_to(%r{/app/login\?error=no-account-found$})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
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(
|
||||
:reporting_event,
|
||||
account: account,
|
||||
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 do
|
||||
described_class.backfill_date(account, date)
|
||||
end.not_to raise_error
|
||||
|
||||
rollup = ReportingEventsRollup.find_by!(
|
||||
account_id: account.id,
|
||||
date: date,
|
||||
dimension_type: 'account',
|
||||
dimension_id: account.id,
|
||||
metric: '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
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,397 @@
|
||||
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) }
|
||||
let(:team) { create(:team, account: account) }
|
||||
|
||||
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(: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 'creates rollup rows for all dimensions' 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 '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
|
||||
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
|
||||
|
||||
it 'loads team_id from conversation' do
|
||||
conversation.update!(team_id: team.id)
|
||||
|
||||
described_class.perform(reporting_event)
|
||||
|
||||
team_row = ReportingEventsRollup.find_by(
|
||||
account_id: account.id,
|
||||
dimension_type: 'team',
|
||||
dimension_id: team.id
|
||||
)
|
||||
expect(team_row).to be_present
|
||||
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
|
||||
Reference in New Issue
Block a user