Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c440df9b18 | ||
|
|
68da47753e | ||
|
|
d054a60a65 | ||
|
|
8b7def05dc | ||
|
|
f126230f73 | ||
|
|
e42b0be530 | ||
|
|
33b3a56d4c | ||
|
|
6693a4d6f7 | ||
|
|
69a44ccf4d | ||
|
|
95727bc608 | ||
|
|
a5c50354fc | ||
|
|
a452ce9e84 | ||
|
|
2fab77d7c7 | ||
|
|
7ad8011a1b | ||
|
|
c83546e67a | ||
|
|
1154f9ccb4 | ||
|
|
e7d5924ca1 | ||
|
|
89e53e24db | ||
|
|
47ecd349cc | ||
|
|
28bf9fa5f9 | ||
|
|
73a90f2841 | ||
|
|
a90ffe6264 | ||
|
|
b8543c09fb | ||
|
|
412b72db7c | ||
|
|
e37e9fd814 | ||
|
|
e6161d2878 | ||
|
|
4e2924538f | ||
|
|
e71bda7112 | ||
|
|
31e3e9c1ff | ||
|
|
8aa49f69d2 | ||
|
|
10f1c23742 | ||
|
|
3fd14942f1 | ||
|
|
7661104262 | ||
|
|
38b5402623 | ||
|
|
3dd29080b5 | ||
|
|
e56b4b2539 | ||
|
|
5ede10c6df | ||
|
|
8a4c1ec07e | ||
|
|
550b408656 | ||
|
|
ac7a02067e | ||
|
|
2e591afaf6 | ||
|
|
32e923dba0 | ||
|
|
b824f8ba47 | ||
|
|
57d40c373d | ||
|
|
3c5cd46d2e | ||
|
|
d34da0cbf8 | ||
|
|
32b1e16e74 | ||
|
|
7e14153784 | ||
|
|
914e70568f | ||
|
|
13860b9163 | ||
|
|
42d7f1768c | ||
|
|
911f211427 | ||
|
|
ea5779a712 | ||
|
|
9ecf5f2504 |
@@ -1,6 +1,7 @@
|
||||
class V2::ReportBuilder
|
||||
include DateRangeHelper
|
||||
include ReportHelper
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
DEFAULT_GROUP_BY = 'day'.freeze
|
||||
|
||||
@@ -11,10 +11,6 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group('assignee_id').count
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.account_users.map do |account_user|
|
||||
build_agent_stats(account_user)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class V2::Reports::BaseSummaryBuilder
|
||||
include DateRangeHelper
|
||||
include TimezoneHelper
|
||||
|
||||
def build
|
||||
load_data
|
||||
@@ -9,37 +10,13 @@ class V2::Reports::BaseSummaryBuilder
|
||||
private
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
load_reporting_events_data
|
||||
end
|
||||
results = data_source.summary
|
||||
|
||||
def load_reporting_events_data
|
||||
# Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id')
|
||||
index_key = group_by_key.to_s.split('.').last
|
||||
|
||||
results = reporting_events
|
||||
.select(
|
||||
"#{group_by_key} as #{index_key}",
|
||||
"COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count",
|
||||
"AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time",
|
||||
"AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time",
|
||||
"AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time"
|
||||
)
|
||||
.group(group_by_key)
|
||||
.index_by { |record| record.public_send(index_key) }
|
||||
|
||||
@resolved_count = results.transform_values(&:resolved_count)
|
||||
@avg_resolution_time = results.transform_values(&:avg_resolution_time)
|
||||
@avg_first_response_time = results.transform_values(&:avg_first_response_time)
|
||||
@avg_reply_time = results.transform_values(&:avg_reply_time)
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= account.reporting_events.where(created_at: range)
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
# Override this method
|
||||
@conversations_count = results.transform_values { |data| data[:conversations_count] }
|
||||
@resolved_count = results.transform_values { |data| data[:resolved_conversations_count] }
|
||||
@avg_resolution_time = results.transform_values { |data| data[:avg_resolution_time] }
|
||||
@avg_first_response_time = results.transform_values { |data| data[:avg_first_response_time] }
|
||||
@avg_reply_time = results.transform_values { |data| data[:avg_reply_time] }
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
@@ -50,7 +27,27 @@ class V2::Reports::BaseSummaryBuilder
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
|
||||
def data_source
|
||||
@data_source ||= Reports::DataSource.for(
|
||||
account: account,
|
||||
metric: nil,
|
||||
dimension_type: summary_dimension_type,
|
||||
dimension_id: nil,
|
||||
scope: nil,
|
||||
range: range,
|
||||
group_by: 'day',
|
||||
timezone: timezone_name_from_params(params[:timezone], params[:timezone_offset]),
|
||||
timezone_offset: params[:timezone_offset],
|
||||
business_hours: params[:business_hours]
|
||||
)
|
||||
end
|
||||
|
||||
def summary_dimension_type
|
||||
{
|
||||
'account_id' => 'account',
|
||||
'user_id' => 'agent',
|
||||
'inbox_id' => 'inbox',
|
||||
'conversations.team_id' => 'team'
|
||||
}.fetch(group_by_key.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,23 +3,10 @@ class V2::Reports::Conversations::BaseReportBuilder
|
||||
|
||||
private
|
||||
|
||||
AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze
|
||||
COUNT_METRICS = %w[
|
||||
conversations_count
|
||||
incoming_messages_count
|
||||
outgoing_messages_count
|
||||
resolutions_count
|
||||
bot_resolutions_count
|
||||
bot_handoffs_count
|
||||
].freeze
|
||||
|
||||
def builder_class(metric)
|
||||
case metric
|
||||
when *AVG_METRICS
|
||||
V2::Reports::Timeseries::AverageReportBuilder
|
||||
when *COUNT_METRICS
|
||||
V2::Reports::Timeseries::CountReportBuilder
|
||||
end
|
||||
return unless Reports::ReportMetricRegistry.supported?(metric)
|
||||
|
||||
V2::Reports::Timeseries::ReportBuilder
|
||||
end
|
||||
|
||||
def log_invalid_metric
|
||||
|
||||
@@ -11,15 +11,6 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
load_reporting_events_data
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group(group_by_key).count
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.inboxes.map do |inbox|
|
||||
build_inbox_stats(inbox)
|
||||
@@ -40,8 +31,4 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
def group_by_key
|
||||
:inbox_id
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,8 +7,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
@account = account
|
||||
@params = params
|
||||
|
||||
timezone_offset = (params[:timezone_offset] || 0).to_f
|
||||
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
|
||||
@timezone = timezone_name_from_params(params[:timezone], params[:timezone_offset])
|
||||
end
|
||||
# rubocop:enable Lint/MissingSuper
|
||||
|
||||
|
||||
@@ -6,14 +6,6 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group(:team_id).count
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation)
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.teams.map do |team|
|
||||
build_team_stats(team)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
grouped_average_time = reporting_events.average(average_value_key)
|
||||
grouped_event_count = reporting_events.count
|
||||
grouped_average_time.each_with_object([]) do |element, arr|
|
||||
event_date, average_time = element
|
||||
arr << {
|
||||
value: average_time,
|
||||
timestamp: event_date.in_time_zone(timezone).to_i,
|
||||
count: grouped_event_count[event_date]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
object_scope.average(average_value_key)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def event_name
|
||||
metric_to_event_name = {
|
||||
avg_first_response_time: :first_response,
|
||||
avg_resolution_time: :conversation_resolved,
|
||||
reply_time: :reply_time
|
||||
}
|
||||
metric_to_event_name[params[:metric].to_sym]
|
||||
end
|
||||
|
||||
def object_scope
|
||||
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id)
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@grouped_values = object_scope.group_by_period(
|
||||
group_by,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
range: range,
|
||||
permit: %w[day week month year hour],
|
||||
time_zone: timezone
|
||||
)
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
@average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
@@ -1,12 +1,13 @@
|
||||
class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
include TimezoneHelper
|
||||
include DateRangeHelper
|
||||
|
||||
DEFAULT_GROUP_BY = 'day'.freeze
|
||||
|
||||
pattr_initialize :account, :params
|
||||
|
||||
def scope
|
||||
case params[:type].to_sym
|
||||
case dimension_type.to_sym
|
||||
when :account
|
||||
account
|
||||
when :inbox
|
||||
@@ -20,6 +21,21 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
end
|
||||
end
|
||||
|
||||
def data_source
|
||||
@data_source ||= Reports::DataSource.for(
|
||||
account: account,
|
||||
metric: params[:metric],
|
||||
dimension_type: dimension_type,
|
||||
dimension_id: params[:id],
|
||||
scope: scope,
|
||||
range: range,
|
||||
group_by: group_by,
|
||||
timezone: timezone,
|
||||
timezone_offset: params[:timezone_offset],
|
||||
business_hours: params[:business_hours]
|
||||
)
|
||||
end
|
||||
|
||||
def inbox
|
||||
@inbox ||= account.inboxes.find(params[:id])
|
||||
end
|
||||
@@ -41,6 +57,12 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
end
|
||||
|
||||
def timezone
|
||||
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
|
||||
@timezone ||= timezone_name_from_params(params[:timezone], params[:timezone_offset])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def dimension_type
|
||||
(params[:type].presence || 'account').to_s
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
grouped_count.each_with_object([]) do |element, arr|
|
||||
event_date, event_count = element
|
||||
|
||||
# The `event_date` is in Date format (without time), such as "Wed, 15 May 2024".
|
||||
# We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i`
|
||||
# because it converts the date to 12:00 AM server timezone.
|
||||
# The desired output should be 12:00 AM in the specified timezone.
|
||||
arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
|
||||
end
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
object_scope.count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def metric
|
||||
@metric ||= params[:metric]
|
||||
end
|
||||
|
||||
def object_scope
|
||||
send("scope_for_#{metric}")
|
||||
end
|
||||
|
||||
def scope_for_conversations_count
|
||||
scope.conversations.where(account_id: account.id, created_at: range)
|
||||
end
|
||||
|
||||
def scope_for_incoming_messages_count
|
||||
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
|
||||
end
|
||||
|
||||
def scope_for_outgoing_messages_count
|
||||
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
|
||||
end
|
||||
|
||||
def scope_for_resolutions_count
|
||||
scope.reporting_events.where(
|
||||
name: :conversation_resolved,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
)
|
||||
end
|
||||
|
||||
def scope_for_bot_resolutions_count
|
||||
scope.reporting_events.where(
|
||||
name: :conversation_bot_resolved,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
)
|
||||
end
|
||||
|
||||
def scope_for_bot_handoffs_count
|
||||
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
|
||||
name: :conversation_bot_handoff,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
).distinct
|
||||
end
|
||||
|
||||
def grouped_count
|
||||
# IMPORTANT: time_zone parameter affects both data grouping AND output timestamps
|
||||
# It converts timestamps to the target timezone before grouping, which means
|
||||
# the same event can fall into different day buckets depending on timezone
|
||||
# Example: 2024-01-15 00:00 UTC becomes 2024-01-14 16:00 PST (falls on different day)
|
||||
@grouped_values = object_scope.group_by_period(
|
||||
group_by,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
range: range,
|
||||
permit: %w[day week month year hour],
|
||||
time_zone: timezone
|
||||
).count
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class V2::Reports::Timeseries::ReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
data_source.timeseries
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
data_source.aggregate
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
skip_before_action :check_authorization, only: [:health, :register_webhook]
|
||||
before_action :check_admin_authorization?, only: [:register_webhook]
|
||||
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
||||
|
||||
trigger_template_sync
|
||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||
rescue StandardError => e
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
end
|
||||
|
||||
def health
|
||||
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
||||
render json: health_data
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def register_webhook
|
||||
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
|
||||
|
||||
render json: { message: 'Webhook registered successfully' }, status: :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_whatsapp_cloud_channel
|
||||
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
||||
|
||||
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
||||
end
|
||||
|
||||
def whatsapp_channel?
|
||||
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
||||
end
|
||||
|
||||
def trigger_template_sync
|
||||
if @inbox.whatsapp?
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
elsif @inbox.twilio? && @inbox.channel.whatsapp?
|
||||
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,8 +4,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
||||
before_action :validate_limit, only: [:create]
|
||||
# we are already handling the authorization in fetch inbox
|
||||
before_action :check_authorization, except: [:show, :health]
|
||||
before_action :validate_whatsapp_cloud_channel, only: [:health]
|
||||
before_action :check_authorization, except: [:show]
|
||||
|
||||
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
|
||||
def index
|
||||
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
||||
@@ -70,23 +71,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
||||
|
||||
trigger_template_sync
|
||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||
rescue StandardError => e
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
end
|
||||
|
||||
def health
|
||||
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
||||
render json: health_data
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@@ -98,12 +82,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
||||
end
|
||||
|
||||
def validate_whatsapp_cloud_channel
|
||||
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
||||
|
||||
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
||||
end
|
||||
|
||||
def create_channel
|
||||
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
|
||||
|
||||
@@ -200,18 +178,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
def get_channel_attributes(channel_type)
|
||||
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
|
||||
end
|
||||
|
||||
def whatsapp_channel?
|
||||
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
||||
end
|
||||
|
||||
def trigger_template_sync
|
||||
if @inbox.whatsapp?
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
elsif @inbox.twilio? && @inbox.channel.whatsapp?
|
||||
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
||||
|
||||
@@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
||||
contact = @contact
|
||||
end
|
||||
|
||||
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
|
||||
@contact_inbox.update(hmac_verified: true) if should_verify_hmac?
|
||||
|
||||
identify_contact(contact)
|
||||
end
|
||||
|
||||
@@ -112,6 +112,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
common_params.merge({
|
||||
since: range[:current][:since],
|
||||
until: range[:current][:until],
|
||||
timezone: params[:timezone],
|
||||
timezone_offset: params[:timezone_offset]
|
||||
})
|
||||
end
|
||||
@@ -120,6 +121,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
common_params.merge({
|
||||
since: range[:previous][:since],
|
||||
until: range[:previous][:until],
|
||||
timezone: params[:timezone],
|
||||
timezone_offset: params[:timezone_offset]
|
||||
})
|
||||
end
|
||||
@@ -129,6 +131,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
metric: params[:metric],
|
||||
since: params[:since],
|
||||
until: params[:until],
|
||||
timezone: params[:timezone],
|
||||
timezone_offset: params[:timezone_offset]
|
||||
})
|
||||
end
|
||||
|
||||
@@ -3,15 +3,15 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
|
||||
|
||||
def agent
|
||||
render_report_with(V2::Reports::AgentSummaryBuilder)
|
||||
render_report_with(V2::Reports::AgentSummaryBuilder, type: :agent)
|
||||
end
|
||||
|
||||
def team
|
||||
render_report_with(V2::Reports::TeamSummaryBuilder)
|
||||
render_report_with(V2::Reports::TeamSummaryBuilder, type: :team)
|
||||
end
|
||||
|
||||
def inbox
|
||||
render_report_with(V2::Reports::InboxSummaryBuilder)
|
||||
render_report_with(V2::Reports::InboxSummaryBuilder, type: :inbox)
|
||||
end
|
||||
|
||||
def label
|
||||
@@ -36,15 +36,18 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
until: permitted_params[:until],
|
||||
business_hours: ActiveModel::Type::Boolean.new.cast(permitted_params[:business_hours])
|
||||
}
|
||||
@builder_params[:timezone] = permitted_params[:timezone] if permitted_params[:timezone].present?
|
||||
@builder_params[:timezone_offset] = permitted_params[:timezone_offset] if permitted_params[:timezone_offset].present?
|
||||
end
|
||||
|
||||
def render_report_with(builder_class)
|
||||
builder = builder_class.new(account: Current.account, params: @builder_params)
|
||||
def render_report_with(builder_class, type: nil)
|
||||
builder_params = type.present? ? @builder_params.merge(type: type) : @builder_params
|
||||
builder = builder_class.new(account: Current.account, params: builder_params)
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:since, :until, :business_hours)
|
||||
params.permit(:since, :until, :business_hours, :timezone, :timezone_offset)
|
||||
end
|
||||
|
||||
def date_range_too_long?
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
module TimezoneHelper
|
||||
def timezone_name_from_params(timezone, offset)
|
||||
return timezone if timezone.present? && ActiveSupport::TimeZone[timezone].present?
|
||||
|
||||
timezone_name_from_offset(offset)
|
||||
end
|
||||
|
||||
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
|
||||
# would return the timezone without considering day light savings. To get the correct timezone,
|
||||
# this method uses zone.now.utc_offset for comparison as referenced in the issues below
|
||||
|
||||
@@ -9,6 +9,10 @@ class InboxHealthAPI extends ApiClient {
|
||||
getHealthStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/health`);
|
||||
}
|
||||
|
||||
registerWebhook(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/register_webhook`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new InboxHealthAPI();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
const getTimeOffset = () => -new Date().getTimezoneOffset() / 60;
|
||||
const getTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
class ReportsAPI extends ApiClient {
|
||||
constructor() {
|
||||
@@ -26,6 +27,7 @@ class ReportsAPI extends ApiClient {
|
||||
id,
|
||||
group_by: groupBy,
|
||||
business_hours: businessHours,
|
||||
timezone: getTimeZone(),
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
@@ -41,6 +43,7 @@ class ReportsAPI extends ApiClient {
|
||||
id,
|
||||
group_by: groupBy,
|
||||
business_hours: businessHours,
|
||||
timezone: getTimeZone(),
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
@@ -105,6 +108,8 @@ class ReportsAPI extends ApiClient {
|
||||
type: 'account',
|
||||
group_by: groupBy,
|
||||
business_hours: businessHours,
|
||||
timezone: getTimeZone(),
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import reportsAPI from '../reports';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#Reports API', () => {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
it('creates correct instance', () => {
|
||||
expect(reportsAPI).toBeInstanceOf(ApiClient);
|
||||
expect(reportsAPI.apiVersion).toBe('/api/v2');
|
||||
@@ -46,6 +48,7 @@ describe('#Reports API', () => {
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
type: 'account',
|
||||
timezone,
|
||||
timezone_offset: -0,
|
||||
},
|
||||
});
|
||||
@@ -59,6 +62,7 @@ describe('#Reports API', () => {
|
||||
group_by: undefined,
|
||||
id: undefined,
|
||||
since: 1621103400,
|
||||
timezone,
|
||||
timezone_offset: -0,
|
||||
type: 'account',
|
||||
until: 1621621800,
|
||||
@@ -140,6 +144,8 @@ describe('#Reports API', () => {
|
||||
type: 'account',
|
||||
group_by: 'date',
|
||||
business_hours: true,
|
||||
timezone,
|
||||
timezone_offset: -0,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import summaryReportsAPI from '../summaryReports';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#Summary Reports API', () => {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
it('creates correct instance', () => {
|
||||
expect(summaryReportsAPI).toBeInstanceOf(ApiClient);
|
||||
expect(summaryReportsAPI.apiVersion).toBe('/api/v2');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
get: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('includes timezone data in summary report requests', () => {
|
||||
summaryReportsAPI.getAgentReports({
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
businessHours: true,
|
||||
});
|
||||
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v2/summary_reports/agent',
|
||||
{
|
||||
params: {
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
business_hours: true,
|
||||
timezone,
|
||||
timezone_offset: -0,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
const getTimeOffset = () => -new Date().getTimezoneOffset() / 60;
|
||||
const getTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
class SummaryReportsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('summary_reports', { accountScoped: true, apiVersion: 'v2' });
|
||||
@@ -12,6 +15,8 @@ class SummaryReportsAPI extends ApiClient {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
timezone: getTimeZone(),
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -22,6 +27,8 @@ class SummaryReportsAPI extends ApiClient {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
timezone: getTimeZone(),
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -32,6 +39,8 @@ class SummaryReportsAPI extends ApiClient {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
timezone: getTimeZone(),
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -42,6 +51,8 @@ class SummaryReportsAPI extends ApiClient {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
timezone: getTimeZone(),
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+1
@@ -26,6 +26,7 @@ const onPortalCreate = ({ slug: portalSlug, locale }) => {
|
||||
<EmptyStateLayout
|
||||
:title="$t('HELP_CENTER.TITLE')"
|
||||
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
|
||||
class="bg-n-surface-1"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-2 gap-4 p-px">
|
||||
|
||||
@@ -685,6 +685,16 @@
|
||||
"SANDBOX": "Sandbox",
|
||||
"LIVE": "Live"
|
||||
}
|
||||
},
|
||||
"WEBHOOK": {
|
||||
"TITLE": "Webhook Configuration",
|
||||
"DESCRIPTION": "Webhook URL is required for your WhatsApp Business Account to receive messages from customers",
|
||||
"ACTION_REQUIRED": "Webhook not configured",
|
||||
"REGISTER_BUTTON": "Register Webhook",
|
||||
"REGISTER_SUCCESS": "Webhook registered successfully",
|
||||
"REGISTER_ERROR": "Failed to register webhook. Please try again.",
|
||||
"CONFIGURED_SUCCESS": "Webhook configured successfully",
|
||||
"URL_MISMATCH": "Webhook URL mismatch"
|
||||
}
|
||||
},
|
||||
"SETTINGS": "Settings",
|
||||
|
||||
@@ -99,6 +99,7 @@ export default {
|
||||
healthData: null,
|
||||
isLoadingHealth: false,
|
||||
healthError: null,
|
||||
isRegisteringWebhook: false,
|
||||
widgetBubblePosition: 'right',
|
||||
widgetBubbleType: 'standard',
|
||||
widgetBubbleLauncherTitle: '',
|
||||
@@ -424,6 +425,23 @@ export default {
|
||||
this.isLoadingHealth = false;
|
||||
}
|
||||
},
|
||||
async registerWebhook() {
|
||||
if (!this.inbox) return;
|
||||
|
||||
try {
|
||||
this.isRegisteringWebhook = true;
|
||||
await InboxHealthAPI.registerWebhook(this.inbox.id);
|
||||
useAlert(this.$t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.REGISTER_SUCCESS'));
|
||||
await this.fetchHealthData();
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.message ||
|
||||
this.$t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.REGISTER_ERROR')
|
||||
);
|
||||
} finally {
|
||||
this.isRegisteringWebhook = false;
|
||||
}
|
||||
},
|
||||
handleFeatureFlag(e) {
|
||||
this.selectedFeatureFlags = this.toggleInput(
|
||||
this.selectedFeatureFlags,
|
||||
@@ -1162,7 +1180,11 @@ export default {
|
||||
<BotConfiguration :inbox="inbox" />
|
||||
</div>
|
||||
<div v-if="selectedTabKey === 'whatsapp-health'">
|
||||
<AccountHealth :health-data="healthData" />
|
||||
<AccountHealth
|
||||
:health-data="healthData"
|
||||
:is-registering-webhook="isRegisteringWebhook"
|
||||
@register-webhook="registerWebhook"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -10,8 +10,14 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isRegisteringWebhook: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['registerWebhook']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const QUALITY_COLORS = {
|
||||
@@ -133,6 +139,28 @@ const formatModeDisplay = mode =>
|
||||
const getModeStatusTextColor = mode => MODE_COLORS[mode] || 'text-n-slate-12';
|
||||
|
||||
const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
|
||||
|
||||
const showWebhookSection = computed(
|
||||
() => props.healthData?.webhook_configuration !== undefined
|
||||
);
|
||||
|
||||
const webhookUrl = computed(
|
||||
() =>
|
||||
props.healthData?.webhook_configuration?.whatsapp_business_account ||
|
||||
props.healthData?.webhook_configuration?.application
|
||||
);
|
||||
|
||||
const webhookConfigured = computed(() => !!webhookUrl.value);
|
||||
|
||||
const webhookUrlMismatch = computed(
|
||||
() =>
|
||||
webhookConfigured.value &&
|
||||
webhookUrl.value !== props.healthData?.expected_webhook_url
|
||||
);
|
||||
|
||||
const handleRegisterWebhook = () => {
|
||||
emit('registerWebhook');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -211,6 +239,55 @@ const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Webhook configuration card -->
|
||||
<div
|
||||
v-if="showWebhookSection"
|
||||
class="flex flex-col gap-2 p-4 rounded-lg border border-n-weak bg-n-solid-1"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-body-main font-medium text-n-slate-11">
|
||||
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.TITLE') }}
|
||||
</span>
|
||||
<Icon
|
||||
v-tooltip.top="t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.DESCRIPTION')"
|
||||
icon="i-lucide-info"
|
||||
class="flex-shrink-0 w-4 h-4 cursor-help text-n-slate-9"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span
|
||||
v-if="webhookConfigured && !webhookUrlMismatch"
|
||||
class="inline-flex items-center gap-1.5 px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2 text-n-teal-11"
|
||||
>
|
||||
<Icon icon="i-lucide-check-circle" class="w-3.5 h-3.5" />
|
||||
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.CONFIGURED_SUCCESS') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center gap-1.5 px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2 text-n-amber-11"
|
||||
>
|
||||
<Icon icon="i-lucide-alert-triangle" class="w-3.5 h-3.5" />
|
||||
{{
|
||||
webhookUrlMismatch
|
||||
? t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.URL_MISMATCH')
|
||||
: t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.ACTION_REQUIRED')
|
||||
}}
|
||||
</span>
|
||||
<ButtonV4
|
||||
v-if="!webhookConfigured || webhookUrlMismatch"
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
:loading="isRegisteringWebhook"
|
||||
:disabled="isRegisteringWebhook"
|
||||
class="flex-shrink-0"
|
||||
@click="handleRegisterWebhook"
|
||||
>
|
||||
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.REGISTER_BUTTON') }}
|
||||
</ButtonV4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="pt-8">
|
||||
|
||||
@@ -21,6 +21,7 @@ class ReportingEventListener < BaseListener
|
||||
|
||||
create_bot_resolved_event(conversation, reporting_event)
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event)
|
||||
end
|
||||
|
||||
def first_reply_created(event)
|
||||
@@ -42,6 +43,7 @@ class ReportingEventListener < BaseListener
|
||||
)
|
||||
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event)
|
||||
end
|
||||
|
||||
def reply_created(event)
|
||||
@@ -66,13 +68,16 @@ class ReportingEventListener < BaseListener
|
||||
event_end_time: message.created_at
|
||||
)
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event)
|
||||
end
|
||||
|
||||
def conversation_bot_handoff(event)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
event_end_time = event.timestamp
|
||||
|
||||
# check if a conversation_bot_handoff event exists for this conversation
|
||||
# Best-effort guard: raw report reads count bot handoffs with DISTINCT conversation_id,
|
||||
# while rollup counts assume one conversation_bot_handoff event per conversation.
|
||||
# That uniqueness is not currently enforced at the database level.
|
||||
bot_handoff_event = ReportingEvent.find_by(conversation_id: conversation.id, name: 'conversation_bot_handoff')
|
||||
return if bot_handoff_event.present?
|
||||
|
||||
@@ -90,6 +95,7 @@ class ReportingEventListener < BaseListener
|
||||
event_end_time: event_end_time
|
||||
)
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event)
|
||||
end
|
||||
|
||||
def conversation_captain_inference_resolved(event)
|
||||
@@ -166,5 +172,16 @@ class ReportingEventListener < BaseListener
|
||||
bot_resolved_event = reporting_event.dup
|
||||
bot_resolved_event.name = 'conversation_bot_resolved'
|
||||
bot_resolved_event.save!
|
||||
safe_rollup(bot_resolved_event)
|
||||
end
|
||||
|
||||
def safe_rollup(reporting_event)
|
||||
# Rollups are derived from the raw reporting event. If a transient rollup write
|
||||
# failure bubbles out here, Sidekiq retries the dispatcher job and can insert the
|
||||
# same raw event again. That can temporarily under-report rollups, but the source
|
||||
# event is preserved and rollup data can be rebuilt or re-applied later.
|
||||
ReportingEvents::RollupService.perform(reporting_event)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: reporting_event.account).capture_exception
|
||||
end
|
||||
end
|
||||
|
||||
+11
-2
@@ -41,7 +41,7 @@ class Account < ApplicationRecord
|
||||
'audio_transcriptions': { 'type': %w[boolean null] },
|
||||
'auto_resolve_label': { 'type': %w[string null] },
|
||||
'keep_pending_on_bot_failure': { 'type': %w[boolean null] },
|
||||
'captain_disable_auto_resolve': { 'type': %w[boolean null] },
|
||||
'captain_auto_resolve_mode': { 'type': %w[string null], 'enum': ['evaluated', 'legacy', 'disabled', nil] },
|
||||
'conversation_required_attributes': {
|
||||
'type': %w[array null],
|
||||
'items': { 'type': 'string' }
|
||||
@@ -85,13 +85,16 @@ 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
|
||||
store_accessor :settings, :captain_auto_resolve_mode
|
||||
include AccountCaptainAutoResolve
|
||||
|
||||
has_many :account_users, dependent: :destroy_async
|
||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||
@@ -214,6 +217,12 @@ class Account < ApplicationRecord
|
||||
# method overridden in enterprise module
|
||||
end
|
||||
|
||||
def validate_reporting_timezone
|
||||
return if reporting_timezone.blank? || ActiveSupport::TimeZone[reporting_timezone].present?
|
||||
|
||||
errors.add(:reporting_timezone, I18n.t('errors.account.reporting_timezone.invalid'))
|
||||
end
|
||||
|
||||
def remove_account_sequences
|
||||
ActiveRecord::Base.connection.exec_query("drop sequence IF EXISTS camp_dpid_seq_#{id}")
|
||||
ActiveRecord::Base.connection.exec_query("drop sequence IF EXISTS conv_dpid_seq_#{id}")
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
module AccountCaptainAutoResolve
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
VALID_CAPTAIN_AUTO_RESOLVE_MODES = %w[evaluated legacy disabled].freeze
|
||||
|
||||
included do
|
||||
VALID_CAPTAIN_AUTO_RESOLVE_MODES.each do |mode|
|
||||
define_method("captain_auto_resolve_#{mode}?") do
|
||||
captain_auto_resolve_mode == mode
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def captain_auto_resolve_mode
|
||||
mode = settings&.[]('captain_auto_resolve_mode')
|
||||
return mode if VALID_CAPTAIN_AUTO_RESOLVE_MODES.include?(mode)
|
||||
return 'disabled' if settings&.[]('captain_disable_auto_resolve') == true
|
||||
|
||||
feature_enabled?('captain_tasks') ? 'evaluated' : 'legacy'
|
||||
end
|
||||
end
|
||||
@@ -9,9 +9,9 @@ module AutoAssignmentHandler
|
||||
private
|
||||
|
||||
def run_auto_assignment
|
||||
# Round robin kicks in on conversation create & update
|
||||
# run it only when conversation status changes to open
|
||||
return unless conversation_status_changed_to_open?
|
||||
# Assignment V2: Also trigger assignment when conversation is resolved or snoozed,
|
||||
# bypassing the open-only condition so the AssignmentJob can redistribute capacity.
|
||||
return unless conversation_status_changed_to_open? || conversation_status_changed_to_resolved_or_snoozed?
|
||||
return unless should_run_auto_assignment?
|
||||
|
||||
if inbox.auto_assignment_v2_enabled?
|
||||
@@ -25,6 +25,10 @@ module AutoAssignmentHandler
|
||||
end
|
||||
end
|
||||
|
||||
def conversation_status_changed_to_resolved_or_snoozed?
|
||||
inbox.auto_assignment_v2_enabled? && saved_change_to_status? && (resolved? || snoozed?)
|
||||
end
|
||||
|
||||
def team_member_ids_with_capacity
|
||||
return [] if team.blank? || team.allow_auto_assign.blank?
|
||||
|
||||
@@ -33,6 +37,9 @@ module AutoAssignmentHandler
|
||||
|
||||
def should_run_auto_assignment?
|
||||
return false unless inbox.enable_auto_assignment?
|
||||
# Assignment V2: Resolved/snoozed conversations still have an assignee, so bypass the
|
||||
# assignee-blank check below. The AssignmentJob needs to run to rebalance assignments.
|
||||
return true if conversation_status_changed_to_resolved_or_snoozed?
|
||||
|
||||
# run only if assignee is blank or doesn't have access to inbox
|
||||
assignee.blank? || inbox.members.exclude?(assignee)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: reporting_events_rollups
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# count :bigint default(0), not null
|
||||
# date :date not null
|
||||
# dimension_id :bigint not null
|
||||
# dimension_type :string not null
|
||||
# metric :string not null
|
||||
# sum_value :float default(0.0), not null
|
||||
# sum_value_business_hours :float default(0.0), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_rollup_summary (account_id,dimension_type,date)
|
||||
# index_rollup_timeseries (account_id,metric,date)
|
||||
# index_rollup_unique_key (account_id,date,dimension_type,dimension_id,metric) UNIQUE
|
||||
#
|
||||
|
||||
class ReportingEventsRollup < ApplicationRecord
|
||||
belongs_to :account
|
||||
|
||||
# Store string values directly in the database for better readability and debugging
|
||||
enum :dimension_type, %w[account agent inbox team].index_by(&:itself)
|
||||
enum :metric, %w[
|
||||
resolutions_count
|
||||
first_response
|
||||
resolution_time
|
||||
reply_time
|
||||
bot_resolutions_count
|
||||
bot_handoffs_count
|
||||
].index_by(&:itself)
|
||||
|
||||
validates :account_id, presence: true
|
||||
validates :date, presence: true
|
||||
validates :dimension_type, presence: true
|
||||
validates :dimension_id, presence: true
|
||||
validates :metric, presence: true
|
||||
validates :count, numericality: { greater_than_or_equal_to: 0 }
|
||||
|
||||
scope :for_date_range, ->(start_date, end_date) { where(date: start_date..end_date) }
|
||||
scope :for_dimension, ->(type, id) { where(dimension_type: type, dimension_id: id) }
|
||||
scope :for_metric, ->(metric) { where(metric: metric) }
|
||||
end
|
||||
@@ -0,0 +1,142 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ReportingEvents::BackfillService
|
||||
DIMENSIONS = [
|
||||
{ type: 'account', group_column: nil },
|
||||
{ type: 'agent', group_column: :user_id },
|
||||
{ type: 'inbox', group_column: :inbox_id }
|
||||
].freeze
|
||||
|
||||
# TODO: Move this to EventMetricRegistry when we expand distinct-counting support.
|
||||
# The live path already guards uniqueness in ReportingEventListener#conversation_bot_handoff,
|
||||
# but historical duplicates can exist since it's not enforced at the DB level.
|
||||
# These events are queried per-dimension (not group-then-sum) because COUNT(DISTINCT) is not additive.
|
||||
DISTINCT_COUNT_EVENTS = %w[conversation_bot_handoff].freeze
|
||||
|
||||
DISTINCT_COUNT_SQL = Arel.sql('COUNT(DISTINCT conversation_id)')
|
||||
|
||||
def self.backfill_date(account, date)
|
||||
new(account, date).perform
|
||||
end
|
||||
|
||||
def initialize(account, date)
|
||||
@account = account
|
||||
@date = date
|
||||
end
|
||||
|
||||
def perform
|
||||
start_utc, end_utc = date_boundaries_in_utc
|
||||
rollup_rows = build_rollup_rows(start_utc, end_utc)
|
||||
|
||||
ReportingEventsRollup.transaction do
|
||||
delete_existing_rollups
|
||||
bulk_insert_rollups(rollup_rows) if rollup_rows.any?
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def delete_existing_rollups
|
||||
ReportingEventsRollup.where(account_id: @account.id, date: @date).delete_all
|
||||
end
|
||||
|
||||
def date_boundaries_in_utc
|
||||
tz = ActiveSupport::TimeZone[@account.reporting_timezone]
|
||||
start_in_tz = tz.parse(@date.to_s)
|
||||
end_in_tz = start_in_tz + 1.day
|
||||
[start_in_tz.utc, end_in_tz.utc]
|
||||
end
|
||||
|
||||
def build_rollup_rows(start_utc, end_utc)
|
||||
aggregates = build_aggregates(start_utc, end_utc)
|
||||
|
||||
aggregates.map do |(dimension_type, dimension_id, metric), data|
|
||||
{
|
||||
account_id: @account.id,
|
||||
date: @date,
|
||||
dimension_type: dimension_type,
|
||||
dimension_id: dimension_id,
|
||||
metric: metric,
|
||||
count: data[:count],
|
||||
sum_value: data[:sum_value],
|
||||
sum_value_business_hours: data[:sum_value_business_hours],
|
||||
created_at: Time.current,
|
||||
updated_at: Time.current
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def build_aggregates(start_utc, end_utc)
|
||||
aggregates = Hash.new { |h, k| h[k] = { count: 0, sum_value: 0.0, sum_value_business_hours: 0.0 } }
|
||||
standard_names = ReportingEvents::EventMetricRegistry.event_names - DISTINCT_COUNT_EVENTS
|
||||
base = @account.reporting_events.where(created_at: start_utc...end_utc)
|
||||
|
||||
DIMENSIONS.each do |dimension|
|
||||
aggregate_standard_events(aggregates, base.where(name: standard_names), dimension)
|
||||
aggregate_distinct_events(aggregates, base.where(name: DISTINCT_COUNT_EVENTS), dimension)
|
||||
end
|
||||
|
||||
aggregates
|
||||
end
|
||||
|
||||
def aggregate_standard_events(aggregates, scope, dimension)
|
||||
group_cols, selects = dimension_groups_and_selects(dimension)
|
||||
|
||||
scope.group(*group_cols).pluck(*selects).each do |row|
|
||||
event_name, dimension_id, count, sum_value, sum_value_business_hours = unpack_row(row, dimension)
|
||||
next if dimension_id.nil?
|
||||
|
||||
accumulate_metrics(aggregates, dimension[:type], dimension_id, event_name,
|
||||
{ count: count, sum_value: sum_value, sum_value_business_hours: sum_value_business_hours })
|
||||
end
|
||||
end
|
||||
|
||||
def accumulate_metrics(aggregates, dimension_type, dimension_id, event_name, values)
|
||||
ReportingEvents::EventMetricRegistry.metrics_for_aggregate(event_name, **values).each do |metric, metric_data|
|
||||
key = [dimension_type, dimension_id, metric]
|
||||
aggregates[key][:count] += metric_data[:count]
|
||||
aggregates[key][:sum_value] += metric_data[:sum_value].to_f
|
||||
aggregates[key][:sum_value_business_hours] += metric_data[:sum_value_business_hours].to_f
|
||||
end
|
||||
end
|
||||
|
||||
def aggregate_distinct_events(aggregates, scope, dimension)
|
||||
return if DISTINCT_COUNT_EVENTS.empty?
|
||||
|
||||
group_cols = dimension[:group_column] ? [:name, dimension[:group_column]] : [:name]
|
||||
|
||||
scope.group(*group_cols).pluck(*group_cols, DISTINCT_COUNT_SQL).each do |row|
|
||||
event_name, dimension_id, count = dimension[:group_column] ? row : [row[0], @account.id, row[1]]
|
||||
next if dimension_id.nil?
|
||||
|
||||
accumulate_metrics(aggregates, dimension[:type], dimension_id, event_name,
|
||||
{ count: count, sum_value: 0, sum_value_business_hours: 0 })
|
||||
end
|
||||
end
|
||||
|
||||
def dimension_groups_and_selects(dimension)
|
||||
agg_selects = [Arel.sql('COUNT(*)'), Arel.sql('COALESCE(SUM(value), 0)'), Arel.sql('COALESCE(SUM(value_in_business_hours), 0)')]
|
||||
|
||||
if dimension[:group_column]
|
||||
[[:name, dimension[:group_column]], [:name, dimension[:group_column], *agg_selects]]
|
||||
else
|
||||
[[:name], [:name, *agg_selects]]
|
||||
end
|
||||
end
|
||||
|
||||
def unpack_row(row, dimension)
|
||||
if dimension[:group_column]
|
||||
# [name, dimension_id, count, sum_value, sum_value_business_hours]
|
||||
row
|
||||
else
|
||||
# [name, count, sum_value, sum_value_business_hours] → inject account id
|
||||
[row[0], @account.id, row[1], row[2], row[3]]
|
||||
end
|
||||
end
|
||||
|
||||
def bulk_insert_rollups(rollup_rows)
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
ReportingEventsRollup.insert_all(rollup_rows)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,79 @@
|
||||
module ReportingEvents::EventMetricRegistry
|
||||
# Describes one rollup metric emitted by a raw reporting event.
|
||||
# rollup_metric: metric name stored in reporting_events_rollups.
|
||||
# payload_kind: whether the emitted row carries only a count or a duration payload.
|
||||
Metric = Data.define(:rollup_metric, :payload_kind)
|
||||
|
||||
EVENTS = {
|
||||
conversation_resolved: [
|
||||
Metric.new(rollup_metric: :resolutions_count, payload_kind: :count),
|
||||
Metric.new(rollup_metric: :resolution_time, payload_kind: :duration)
|
||||
].freeze,
|
||||
first_response: [
|
||||
Metric.new(rollup_metric: :first_response, payload_kind: :duration)
|
||||
].freeze,
|
||||
reply_time: [
|
||||
Metric.new(rollup_metric: :reply_time, payload_kind: :duration)
|
||||
].freeze,
|
||||
conversation_bot_resolved: [
|
||||
Metric.new(rollup_metric: :bot_resolutions_count, payload_kind: :count)
|
||||
].freeze,
|
||||
conversation_bot_handoff: [
|
||||
Metric.new(rollup_metric: :bot_handoffs_count, payload_kind: :count)
|
||||
].freeze
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def event_names
|
||||
EVENTS.keys.map(&:to_s)
|
||||
end
|
||||
|
||||
def metrics_for(event)
|
||||
return {} if event.blank?
|
||||
|
||||
metrics_for_aggregate(
|
||||
event.name,
|
||||
count: 1,
|
||||
sum_value: event.try(:value),
|
||||
sum_value_business_hours: event.try(:value_in_business_hours)
|
||||
)
|
||||
end
|
||||
|
||||
def metrics_for_aggregate(event_name, count:, sum_value:, sum_value_business_hours:)
|
||||
return {} if event_name.blank?
|
||||
|
||||
values = {
|
||||
count: count.to_i,
|
||||
sum_value: sum_value.to_f,
|
||||
sum_value_business_hours: sum_value_business_hours.to_f
|
||||
}
|
||||
|
||||
EVENTS.fetch(event_name.to_sym, []).to_h do |metric|
|
||||
[metric.rollup_metric, metric_values(metric.payload_kind, values)]
|
||||
end
|
||||
end
|
||||
|
||||
private_class_method def metric_values(payload_kind, values)
|
||||
case payload_kind
|
||||
when :count
|
||||
count_values(values[:count])
|
||||
when :duration
|
||||
duration_values(values)
|
||||
else
|
||||
raise ArgumentError, "Unknown metric payload kind: #{payload_kind.inspect}"
|
||||
end
|
||||
end
|
||||
|
||||
private_class_method def count_values(count)
|
||||
{ count: count, sum_value: 0, sum_value_business_hours: 0 }
|
||||
end
|
||||
|
||||
private_class_method def duration_values(values)
|
||||
{
|
||||
count: values[:count],
|
||||
sum_value: values[:sum_value],
|
||||
sum_value_business_hours: values[:sum_value_business_hours]
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,107 @@
|
||||
# Raw reporting events and rollup rows do not share a single metric namespace; this registry keeps write and read paths aligned.
|
||||
# TODO: Split this into separate registries for raw event mappings and report metric definitions.
|
||||
module ReportingEvents::MetricRegistry
|
||||
# Maps report summary response keys to the metric definitions they read from.
|
||||
SUMMARY_METRICS = {
|
||||
resolutions_count: :resolved_conversations_count,
|
||||
avg_resolution_time: :avg_resolution_time,
|
||||
avg_first_response_time: :avg_first_response_time,
|
||||
reply_time: :avg_reply_time
|
||||
}.freeze
|
||||
|
||||
# Expands each raw reporting event into the rollup metric payloads persisted for aggregation.
|
||||
EVENT_METRICS = {
|
||||
'conversation_resolved' => lambda do |values|
|
||||
{
|
||||
resolutions_count: count_metric(values[:count]),
|
||||
resolution_time: duration_metric(values)
|
||||
}
|
||||
end,
|
||||
'first_response' => ->(values) { { first_response: duration_metric(values) } },
|
||||
'reply_time' => ->(values) { { reply_time: duration_metric(values) } },
|
||||
'conversation_bot_resolved' => ->(values) { { bot_resolutions_count: count_metric(values[:count]) } },
|
||||
'conversation_bot_handoff' => ->(values) { { bot_handoffs_count: count_metric(values[:count]) } }
|
||||
}.freeze
|
||||
|
||||
# Describes which report metrics are supported and how each one is sourced and aggregated.
|
||||
REPORT_METRICS = {
|
||||
conversations_count: { aggregate: :count }.freeze,
|
||||
incoming_messages_count: { aggregate: :count }.freeze,
|
||||
outgoing_messages_count: { aggregate: :count }.freeze,
|
||||
avg_first_response_time: { raw_event_name: :first_response, rollup_metric: :first_response, aggregate: :average }.freeze,
|
||||
avg_resolution_time: { raw_event_name: :conversation_resolved, rollup_metric: :resolution_time, aggregate: :average }.freeze,
|
||||
reply_time: { raw_event_name: :reply_time, rollup_metric: :reply_time, aggregate: :average }.freeze,
|
||||
resolutions_count: { raw_event_name: :conversation_resolved, rollup_metric: :resolutions_count, aggregate: :count }.freeze,
|
||||
bot_resolutions_count: { raw_event_name: :conversation_bot_resolved, rollup_metric: :bot_resolutions_count, aggregate: :count }.freeze,
|
||||
bot_handoffs_count: { raw_event_name: :conversation_bot_handoff, rollup_metric: :bot_handoffs_count, aggregate: :count,
|
||||
raw_count_strategy: :distinct_conversation }.freeze
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def event_metrics_for(event)
|
||||
return {} if event.blank?
|
||||
return {} unless EVENT_METRICS.key?(event.name.to_s)
|
||||
|
||||
event_metrics_for_aggregate(
|
||||
event.name,
|
||||
count: 1,
|
||||
sum_value: event.try(:value),
|
||||
sum_value_business_hours: event.try(:value_in_business_hours)
|
||||
)
|
||||
end
|
||||
|
||||
def event_metrics_for_aggregate(event_name, count:, sum_value:, sum_value_business_hours:)
|
||||
values = {
|
||||
count: count.to_i,
|
||||
sum_value: sum_value.to_f,
|
||||
sum_value_business_hours: sum_value_business_hours.to_f
|
||||
}
|
||||
|
||||
EVENT_METRICS[event_name.to_s]&.call(values) || {}
|
||||
end
|
||||
|
||||
def report_metric(metric)
|
||||
return if metric.blank?
|
||||
|
||||
REPORT_METRICS[metric.to_sym]
|
||||
end
|
||||
|
||||
def supported_metric?(metric)
|
||||
report_metric(metric).present?
|
||||
end
|
||||
|
||||
def aggregate_for(metric)
|
||||
report_metric(metric)&.dig(:aggregate)
|
||||
end
|
||||
|
||||
def rollup_supported_metric?(metric)
|
||||
rollup_metric_for(metric).present?
|
||||
end
|
||||
|
||||
def rollup_metric_for(metric)
|
||||
report_metric(metric)&.dig(:rollup_metric)
|
||||
end
|
||||
|
||||
def raw_event_name_for(metric)
|
||||
report_metric(metric)&.dig(:raw_event_name)
|
||||
end
|
||||
|
||||
def summary_metrics
|
||||
SUMMARY_METRICS.map do |metric_name, summary_key|
|
||||
report_metric(metric_name).merge(metric_name: metric_name, summary_key: summary_key)
|
||||
end
|
||||
end
|
||||
|
||||
private_class_method def count_metric(count)
|
||||
{ count: count, sum_value: 0, sum_value_business_hours: 0 }
|
||||
end
|
||||
|
||||
private_class_method def duration_metric(values)
|
||||
{
|
||||
count: values[:count],
|
||||
sum_value: values[:sum_value],
|
||||
sum_value_business_hours: values[:sum_value_business_hours]
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
class ReportingEvents::RollupService
|
||||
def self.perform(reporting_event)
|
||||
new(reporting_event).perform
|
||||
end
|
||||
|
||||
def initialize(reporting_event)
|
||||
@reporting_event = reporting_event
|
||||
@account = reporting_event.account
|
||||
end
|
||||
|
||||
def perform
|
||||
return unless rollup_enabled?
|
||||
|
||||
rows = build_rollup_rows
|
||||
return if rows.empty?
|
||||
|
||||
upsert_rollups(rows)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# NOTE: This is intentionally not gated by the reporting_events_rollup feature flag.
|
||||
# Rollup data is collected for all accounts with a valid reporting timezone (soft toggle).
|
||||
# The feature flag only controls the read path — whether reports query rollups or raw events.
|
||||
def rollup_enabled?
|
||||
@account.reporting_timezone.present? && ActiveSupport::TimeZone[@account.reporting_timezone].present?
|
||||
end
|
||||
|
||||
def event_date
|
||||
@event_date ||= @reporting_event.created_at.in_time_zone(@account.reporting_timezone).to_date
|
||||
end
|
||||
|
||||
def dimensions
|
||||
{
|
||||
account: @account.id,
|
||||
agent: @reporting_event.user_id,
|
||||
inbox: @reporting_event.inbox_id
|
||||
}
|
||||
end
|
||||
|
||||
def build_rollup_rows
|
||||
event_metrics = ReportingEvents::EventMetricRegistry.metrics_for(@reporting_event)
|
||||
|
||||
dimensions.each_with_object([]) do |(dimension_type, dimension_id), rows|
|
||||
next if dimension_id.nil?
|
||||
|
||||
event_metrics.each do |metric, metric_data|
|
||||
rows << rollup_attributes(dimension_type, dimension_id, metric, metric_data)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def upsert_rollups(rows)
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
ReportingEventsRollup.upsert_all(
|
||||
rows,
|
||||
unique_by: [:account_id, :date, :dimension_type, :dimension_id, :metric],
|
||||
on_duplicate: upsert_on_duplicate_sql
|
||||
)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
def rollup_attributes(dimension_type, dimension_id, metric, metric_data)
|
||||
{
|
||||
account_id: @account.id, date: event_date,
|
||||
dimension_type: dimension_type, dimension_id: dimension_id, metric: metric,
|
||||
count: metric_data[:count], sum_value: metric_data[:sum_value].to_f,
|
||||
sum_value_business_hours: metric_data[:sum_value_business_hours].to_f,
|
||||
created_at: Time.current, updated_at: Time.current
|
||||
}
|
||||
end
|
||||
|
||||
def upsert_on_duplicate_sql
|
||||
Arel.sql(
|
||||
'count = reporting_events_rollups.count + EXCLUDED.count, ' \
|
||||
'sum_value = reporting_events_rollups.sum_value + EXCLUDED.sum_value, ' \
|
||||
'sum_value_business_hours = reporting_events_rollups.sum_value_business_hours + EXCLUDED.sum_value_business_hours, ' \
|
||||
'updated_at = EXCLUDED.updated_at'
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,106 @@
|
||||
class Reports::DataSource
|
||||
SUPPORTED_ROLLUP_DIMENSIONS = %w[account agent inbox].freeze
|
||||
|
||||
attr_reader :account, :metric, :dimension_type, :dimension_id,
|
||||
:scope, :range, :group_by, :timezone,
|
||||
:timezone_offset, :business_hours
|
||||
|
||||
class << self
|
||||
def for(**context)
|
||||
adapter_class_for(**context).new(**context)
|
||||
end
|
||||
|
||||
def rollup_eligible?(**context)
|
||||
account = context[:account]
|
||||
|
||||
rollup_enabled_for_account?(account) &&
|
||||
!hourly_grouping?(context[:group_by]) &&
|
||||
supported_dimension?(context[:dimension_type]) &&
|
||||
timezone_matches_account?(account, context[:timezone], context[:timezone_offset]) &&
|
||||
supported_metric?(context[:metric])
|
||||
end
|
||||
|
||||
def timezone_matches_account?(account, timezone, timezone_offset)
|
||||
return normalized_timezone_identifier(timezone) == normalized_timezone_identifier(account.reporting_timezone) if timezone.present?
|
||||
|
||||
return false if timezone_offset.blank?
|
||||
|
||||
offset_in_seconds = timezone_offset.to_f * 3600
|
||||
account_zone = ActiveSupport::TimeZone[account.reporting_timezone]
|
||||
account_zone&.now&.utc_offset == offset_in_seconds
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def adapter_class_for(**context)
|
||||
rollup_eligible?(**context) ? Reports::RollupDataSource : Reports::RawDataSource
|
||||
end
|
||||
|
||||
def rollup_enabled_for_account?(account)
|
||||
account.reporting_timezone.present? && account.feature_enabled?(:report_rollup)
|
||||
end
|
||||
|
||||
def hourly_grouping?(group_by)
|
||||
group_by.to_s == 'hour'
|
||||
end
|
||||
|
||||
def supported_dimension?(dimension_type)
|
||||
SUPPORTED_ROLLUP_DIMENSIONS.include?((dimension_type.presence || 'account').to_s)
|
||||
end
|
||||
|
||||
def supported_metric?(metric)
|
||||
metric.blank? || Reports::ReportMetricRegistry.rollup_supported?(metric)
|
||||
end
|
||||
|
||||
def normalized_timezone_identifier(timezone)
|
||||
ActiveSupport::TimeZone[timezone]&.tzinfo&.name
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(**context)
|
||||
@account = context[:account]
|
||||
@metric = context[:metric]
|
||||
@dimension_type = (context[:dimension_type].presence || 'account').to_s
|
||||
@dimension_id = context[:dimension_id]
|
||||
@scope = context[:scope]
|
||||
@range = context[:range]
|
||||
@group_by = context[:group_by].to_s.presence || 'day'
|
||||
@timezone = context[:timezone]
|
||||
@timezone_offset = context[:timezone_offset]
|
||||
@business_hours = context[:business_hours]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def report_metric
|
||||
@report_metric ||= Reports::ReportMetricRegistry.fetch(metric)
|
||||
end
|
||||
|
||||
def average_metric?
|
||||
report_metric&.average?
|
||||
end
|
||||
|
||||
def count_metric?
|
||||
!average_metric?
|
||||
end
|
||||
|
||||
def rollup_metric
|
||||
report_metric&.rollup_metric
|
||||
end
|
||||
|
||||
def raw_event_name
|
||||
report_metric&.raw_event_name
|
||||
end
|
||||
|
||||
def raw_count_strategy
|
||||
report_metric&.raw_count_strategy
|
||||
end
|
||||
|
||||
def summary_metrics
|
||||
@summary_metrics ||= Reports::ReportMetricRegistry.summary_metrics
|
||||
end
|
||||
|
||||
def use_business_hours?
|
||||
ActiveModel::Type::Boolean.new.cast(business_hours)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,156 @@
|
||||
class Reports::RawDataSource < Reports::DataSource
|
||||
def timeseries
|
||||
average_metric? ? average_timeseries : count_timeseries
|
||||
end
|
||||
|
||||
def aggregate
|
||||
average_metric? ? average_scope.average(average_value_key) : count_scope.count
|
||||
end
|
||||
|
||||
def summary
|
||||
metric_results = summary_scope
|
||||
.select(*summary_select_fields)
|
||||
.group(summary_group_by_key)
|
||||
.index_by { |record| record.public_send(summary_index_key) }
|
||||
|
||||
merge_summary_results(metric_results, summary_conversation_counts)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def count_timeseries
|
||||
grouped_count.map do |event_date, event_count|
|
||||
{ value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
|
||||
end
|
||||
end
|
||||
|
||||
def average_timeseries
|
||||
grouped_average_time = grouped_average_scope.average(average_value_key)
|
||||
grouped_event_count = grouped_average_scope.count
|
||||
|
||||
grouped_average_time.each_with_object([]) do |(event_date, average_time), results|
|
||||
results << {
|
||||
value: average_time,
|
||||
timestamp: event_date.in_time_zone(timezone).to_i,
|
||||
count: grouped_event_count[event_date]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def grouped_average_scope
|
||||
average_scope.group_by_period(
|
||||
group_by,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
range: range,
|
||||
permit: %w[day week month year hour],
|
||||
time_zone: timezone
|
||||
)
|
||||
end
|
||||
|
||||
def grouped_count
|
||||
count_scope.group_by_period(
|
||||
group_by,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
range: range,
|
||||
permit: %w[day week month year hour],
|
||||
time_zone: timezone
|
||||
).count
|
||||
end
|
||||
|
||||
def average_scope
|
||||
scope.reporting_events.where(name: raw_event_name, created_at: range, account_id: account.id)
|
||||
end
|
||||
|
||||
def count_scope
|
||||
case metric.to_s
|
||||
when 'conversations_count'
|
||||
scope.conversations.where(account_id: account.id, created_at: range)
|
||||
when 'incoming_messages_count'
|
||||
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
|
||||
when 'outgoing_messages_count'
|
||||
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
|
||||
else
|
||||
reporting_event_count_scope
|
||||
end
|
||||
end
|
||||
|
||||
def reporting_event_count_scope
|
||||
events = scope.reporting_events.where(
|
||||
name: raw_event_name,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
)
|
||||
|
||||
return events unless raw_count_strategy == :distinct_conversation
|
||||
|
||||
events.joins(:conversation).select(:conversation_id).distinct
|
||||
end
|
||||
|
||||
def summary_scope
|
||||
scope = account.reporting_events.where(created_at: range)
|
||||
return scope.joins(:conversation) if dimension_type == 'team'
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
def summary_conversation_counts
|
||||
account.conversations
|
||||
.where(created_at: range)
|
||||
.group(summary_conversation_group_by_key)
|
||||
.count
|
||||
end
|
||||
|
||||
def merge_summary_results(metric_results, conversation_counts)
|
||||
(metric_results.keys | conversation_counts.keys).each_with_object({}) do |dimension_id, results|
|
||||
record = metric_results[dimension_id]
|
||||
results[dimension_id] = summary_attributes_for(record, conversation_counts[dimension_id])
|
||||
end
|
||||
end
|
||||
|
||||
def summary_select_fields
|
||||
["#{summary_group_by_key} as #{summary_index_key}"] + summary_metrics.map { |definition| summary_select_field(definition) }
|
||||
end
|
||||
|
||||
def summary_select_field(definition)
|
||||
if definition.count?
|
||||
"COUNT(CASE WHEN name = '#{definition.raw_event_name}' THEN 1 END) as #{definition.summary_key}"
|
||||
else
|
||||
"AVG(CASE WHEN name = '#{definition.raw_event_name}' THEN #{average_value_key} END) as #{definition.summary_key}"
|
||||
end
|
||||
end
|
||||
|
||||
def summary_attributes_for(record, conversations_count = 0)
|
||||
summary_metrics.each_with_object({ conversations_count: conversations_count.to_i }) do |definition, attributes|
|
||||
value = record&.public_send(definition.summary_key)
|
||||
attributes[definition.summary_key] = definition.count? ? value.to_i : value
|
||||
end
|
||||
end
|
||||
|
||||
def summary_group_by_key
|
||||
{
|
||||
'account' => :account_id,
|
||||
'agent' => :user_id,
|
||||
'inbox' => :inbox_id,
|
||||
'team' => 'conversations.team_id'
|
||||
}[dimension_type]
|
||||
end
|
||||
|
||||
def summary_conversation_group_by_key
|
||||
{
|
||||
'account' => :account_id,
|
||||
'agent' => :assignee_id,
|
||||
'inbox' => :inbox_id,
|
||||
'team' => :team_id
|
||||
}[dimension_type]
|
||||
end
|
||||
|
||||
def summary_index_key
|
||||
summary_group_by_key.to_s.split('.').last
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
use_business_hours? ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,120 @@
|
||||
module Reports::ReportMetricRegistry
|
||||
# Describes one public report metric.
|
||||
# name: API-facing metric name requested by reports.
|
||||
# aggregate: whether the metric is a count or average.
|
||||
# raw_event_name: source reporting_events name for raw queries.
|
||||
# rollup_metric: source reporting_events_rollups metric for rollup queries.
|
||||
# summary_key: key used when this metric appears in grouped summary responses.
|
||||
# raw_count_strategy: optional raw-query counting rule, such as distinct conversations.
|
||||
Metric = Data.define(
|
||||
:name,
|
||||
:aggregate,
|
||||
:raw_event_name,
|
||||
:rollup_metric,
|
||||
:summary_key,
|
||||
:raw_count_strategy
|
||||
) do
|
||||
def initialize(name:, aggregate:, raw_event_name: nil, rollup_metric: nil, summary_key: nil, raw_count_strategy: nil)
|
||||
super
|
||||
end
|
||||
|
||||
def average?
|
||||
aggregate == :average
|
||||
end
|
||||
|
||||
def count?
|
||||
aggregate == :count
|
||||
end
|
||||
|
||||
def rollup_supported?
|
||||
rollup_metric.present?
|
||||
end
|
||||
|
||||
def summary?
|
||||
summary_key.present?
|
||||
end
|
||||
end
|
||||
|
||||
METRICS = {
|
||||
conversations_count: Metric.new(
|
||||
name: :conversations_count,
|
||||
aggregate: :count
|
||||
),
|
||||
incoming_messages_count: Metric.new(
|
||||
name: :incoming_messages_count,
|
||||
aggregate: :count
|
||||
),
|
||||
outgoing_messages_count: Metric.new(
|
||||
name: :outgoing_messages_count,
|
||||
aggregate: :count
|
||||
),
|
||||
avg_first_response_time: Metric.new(
|
||||
name: :avg_first_response_time,
|
||||
aggregate: :average,
|
||||
raw_event_name: :first_response,
|
||||
rollup_metric: :first_response,
|
||||
summary_key: :avg_first_response_time
|
||||
),
|
||||
avg_resolution_time: Metric.new(
|
||||
name: :avg_resolution_time,
|
||||
aggregate: :average,
|
||||
raw_event_name: :conversation_resolved,
|
||||
rollup_metric: :resolution_time,
|
||||
summary_key: :avg_resolution_time
|
||||
),
|
||||
reply_time: Metric.new(
|
||||
name: :reply_time,
|
||||
aggregate: :average,
|
||||
raw_event_name: :reply_time,
|
||||
rollup_metric: :reply_time,
|
||||
summary_key: :avg_reply_time
|
||||
),
|
||||
resolutions_count: Metric.new(
|
||||
name: :resolutions_count,
|
||||
aggregate: :count,
|
||||
raw_event_name: :conversation_resolved,
|
||||
rollup_metric: :resolutions_count,
|
||||
summary_key: :resolved_conversations_count
|
||||
),
|
||||
bot_resolutions_count: Metric.new(
|
||||
name: :bot_resolutions_count,
|
||||
aggregate: :count,
|
||||
raw_event_name: :conversation_bot_resolved,
|
||||
rollup_metric: :bot_resolutions_count
|
||||
),
|
||||
bot_handoffs_count: Metric.new(
|
||||
name: :bot_handoffs_count,
|
||||
aggregate: :count,
|
||||
raw_event_name: :conversation_bot_handoff,
|
||||
rollup_metric: :bot_handoffs_count,
|
||||
raw_count_strategy: :distinct_conversation
|
||||
)
|
||||
}.freeze
|
||||
|
||||
SUMMARY_METRIC_NAMES = %i[
|
||||
resolutions_count
|
||||
avg_resolution_time
|
||||
avg_first_response_time
|
||||
reply_time
|
||||
].freeze
|
||||
|
||||
module_function
|
||||
|
||||
def fetch(name)
|
||||
return if name.blank?
|
||||
|
||||
METRICS[name.to_sym]
|
||||
end
|
||||
|
||||
def supported?(name)
|
||||
fetch(name).present?
|
||||
end
|
||||
|
||||
def rollup_supported?(name)
|
||||
fetch(name)&.rollup_supported? || false
|
||||
end
|
||||
|
||||
def summary_metrics
|
||||
SUMMARY_METRIC_NAMES.map { |metric_name| METRICS.fetch(metric_name) }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,199 @@
|
||||
class Reports::RollupDataSource < Reports::DataSource
|
||||
def timeseries
|
||||
count_metric? ? count_timeseries : average_timeseries
|
||||
end
|
||||
|
||||
def aggregate
|
||||
count_metric? ? count_aggregate : average_aggregate
|
||||
end
|
||||
|
||||
def summary
|
||||
metric_results = summary_rows.index_by(&:dimension_id)
|
||||
|
||||
merge_summary_results(metric_results, summary_conversation_counts)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def count_timeseries
|
||||
grouped_data = all_periods_in_range.index_with { 0 }
|
||||
|
||||
rollup_scope.each do |row|
|
||||
date_key = normalized_period_key(row.date)
|
||||
grouped_data[date_key] ||= 0
|
||||
grouped_data[date_key] += row.count
|
||||
end
|
||||
|
||||
results = grouped_data.map do |date_key, count|
|
||||
{ value: count, timestamp: date_key.in_time_zone(timezone).to_i }
|
||||
end
|
||||
|
||||
results.sort_by { |result| result[:timestamp] }
|
||||
end
|
||||
|
||||
def average_timeseries
|
||||
grouped_data = all_periods_in_range.index_with { { count: 0, sum_value: 0.0 } }
|
||||
|
||||
rollup_scope.each { |row| accumulate_average_row(grouped_data, row) }
|
||||
|
||||
results = grouped_data.map do |date_key, data|
|
||||
{
|
||||
value: data[:count].zero? ? 0 : data[:sum_value] / data[:count],
|
||||
timestamp: date_key.in_time_zone(timezone).to_i,
|
||||
count: data[:count]
|
||||
}
|
||||
end
|
||||
|
||||
results.sort_by { |result| result[:timestamp] }
|
||||
end
|
||||
|
||||
def count_aggregate
|
||||
rollup_scope.sum(:count).to_i
|
||||
end
|
||||
|
||||
def average_aggregate
|
||||
result = rollup_scope.pick(Arel.sql("SUM(count), SUM(#{rollup_value_column})"))
|
||||
return nil if result.blank? || result[0].to_i.zero?
|
||||
|
||||
result[1].to_f / result[0].to_i
|
||||
end
|
||||
|
||||
def rollup_scope
|
||||
ReportingEventsRollup.where(
|
||||
account_id: account.id,
|
||||
metric: rollup_metric,
|
||||
dimension_type: dimension_type,
|
||||
dimension_id: dimension_id_for_rollup,
|
||||
date: rollup_date_range
|
||||
)
|
||||
end
|
||||
|
||||
def summary_rows
|
||||
ReportingEventsRollup.where(
|
||||
account_id: account.id,
|
||||
dimension_type: dimension_type,
|
||||
date: rollup_date_range
|
||||
).group(:dimension_id).select(*summary_select_fields)
|
||||
end
|
||||
|
||||
def summary_conversation_counts
|
||||
account.conversations
|
||||
.where(created_at: range)
|
||||
.group(summary_conversation_group_by_key)
|
||||
.count
|
||||
end
|
||||
|
||||
def merge_summary_results(metric_results, conversation_counts)
|
||||
(metric_results.keys | conversation_counts.keys).index_with do |dimension_id|
|
||||
summary_attributes_for(metric_results[dimension_id], conversation_counts[dimension_id])
|
||||
end
|
||||
end
|
||||
|
||||
def summary_select_fields
|
||||
['dimension_id'] + summary_metrics.flat_map { |definition| summary_select_fields_for_metric(definition) }
|
||||
end
|
||||
|
||||
def summary_select_fields_for_metric(definition)
|
||||
return [sum_count_select(definition.rollup_metric, definition.summary_key)] if definition.count?
|
||||
|
||||
[
|
||||
sum_count_select(definition.rollup_metric, summary_count_alias(definition)),
|
||||
sum_value_select(definition.rollup_metric, summary_sum_alias(definition))
|
||||
]
|
||||
end
|
||||
|
||||
def sum_count_select(rollup_metric_name, alias_name)
|
||||
"SUM(CASE WHEN metric = '#{rollup_metric_name}' THEN count ELSE 0 END) as #{alias_name}"
|
||||
end
|
||||
|
||||
def sum_value_select(rollup_metric_name, alias_name)
|
||||
"SUM(CASE WHEN metric = '#{rollup_metric_name}' THEN #{rollup_value_column} ELSE 0 END) as #{alias_name}"
|
||||
end
|
||||
|
||||
def summary_attributes_for(row, conversations_count = 0)
|
||||
summary_metrics.each_with_object({ conversations_count: conversations_count.to_i }) do |definition, attributes|
|
||||
attributes[definition.summary_key] = summary_value_for(row, definition)
|
||||
end
|
||||
end
|
||||
|
||||
def summary_value_for(row, definition)
|
||||
return row&.public_send(definition.summary_key).to_i if definition.count?
|
||||
|
||||
average_from(row&.public_send(summary_sum_alias(definition)), row&.public_send(summary_count_alias(definition)))
|
||||
end
|
||||
|
||||
def summary_count_alias(definition)
|
||||
"#{definition.summary_key}_count"
|
||||
end
|
||||
|
||||
def summary_sum_alias(definition)
|
||||
"#{definition.summary_key}_sum_value"
|
||||
end
|
||||
|
||||
def dimension_id_for_rollup
|
||||
dimension_type == 'account' ? account.id : scope.id
|
||||
end
|
||||
|
||||
def summary_conversation_group_by_key
|
||||
{
|
||||
'account' => :account_id,
|
||||
'agent' => :assignee_id,
|
||||
'inbox' => :inbox_id,
|
||||
'team' => :team_id
|
||||
}[dimension_type]
|
||||
end
|
||||
|
||||
def rollup_value_column
|
||||
use_business_hours? ? :sum_value_business_hours : :sum_value
|
||||
end
|
||||
|
||||
def rollup_date_range
|
||||
tz = ActiveSupport::TimeZone[account.reporting_timezone]
|
||||
start_date = range.first.in_time_zone(tz).to_date
|
||||
end_date = (range.last - 1.second).in_time_zone(tz).to_date
|
||||
start_date..end_date
|
||||
end
|
||||
|
||||
def all_periods_in_range
|
||||
current = normalized_period_key(rollup_date_range.first)
|
||||
periods = []
|
||||
|
||||
while current <= rollup_date_range.last
|
||||
periods << current
|
||||
current = advance_period(current)
|
||||
end
|
||||
|
||||
periods
|
||||
end
|
||||
|
||||
def accumulate_average_row(grouped_data, row)
|
||||
date_key = normalized_period_key(row.date)
|
||||
grouped_data[date_key] ||= { count: 0, sum_value: 0.0 }
|
||||
grouped_data[date_key][:count] += row.count
|
||||
grouped_data[date_key][:sum_value] += row.public_send(rollup_value_column)
|
||||
end
|
||||
|
||||
def normalized_period_key(date)
|
||||
case group_by
|
||||
when 'week' then date.beginning_of_week(:sunday)
|
||||
when 'month' then date.beginning_of_month
|
||||
when 'year' then date.beginning_of_year
|
||||
else date
|
||||
end
|
||||
end
|
||||
|
||||
def advance_period(date)
|
||||
case group_by
|
||||
when 'week' then date + 1.week
|
||||
when 'month' then date + 1.month
|
||||
when 'year' then date + 1.year
|
||||
else date + 1.day
|
||||
end
|
||||
end
|
||||
|
||||
def average_from(sum_value, count)
|
||||
return nil if count.to_i.zero?
|
||||
|
||||
sum_value.to_f / count.to_i
|
||||
end
|
||||
end
|
||||
@@ -39,11 +39,11 @@ class Whatsapp::HealthService
|
||||
|
||||
def health_fields
|
||||
%w[
|
||||
id
|
||||
quality_rating
|
||||
messaging_limit_tier
|
||||
code_verification_status
|
||||
account_mode
|
||||
id
|
||||
display_phone_number
|
||||
name_status
|
||||
verified_name
|
||||
@@ -68,6 +68,7 @@ class Whatsapp::HealthService
|
||||
|
||||
def format_health_response(response)
|
||||
{
|
||||
id: response['id'],
|
||||
display_phone_number: response['display_phone_number'],
|
||||
verified_name: response['verified_name'],
|
||||
name_status: response['name_status'],
|
||||
@@ -75,10 +76,20 @@ class Whatsapp::HealthService
|
||||
messaging_limit_tier: response['messaging_limit_tier'],
|
||||
account_mode: response['account_mode'],
|
||||
code_verification_status: response['code_verification_status'],
|
||||
webhook_configuration: response['webhook_configuration'],
|
||||
expected_webhook_url: build_expected_webhook_url,
|
||||
throughput: response['throughput'],
|
||||
last_onboarded_time: response['last_onboarded_time'],
|
||||
platform_type: response['platform_type'],
|
||||
certificate: response['certificate'],
|
||||
business_id: @channel.provider_config['business_account_id']
|
||||
}
|
||||
end
|
||||
|
||||
def build_expected_webhook_url
|
||||
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||
return nil if frontend_url.blank?
|
||||
|
||||
"#{frontend_url}/webhooks/whatsapp/#{@channel.phone_number}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
class Whatsapp::WebhookSetupService
|
||||
def initialize(channel, waba_id, access_token)
|
||||
def initialize(channel, waba_id = nil, access_token = nil)
|
||||
@channel = channel
|
||||
@waba_id = waba_id
|
||||
@access_token = access_token
|
||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
@waba_id = waba_id || channel.provider_config['business_account_id']
|
||||
@access_token = access_token || channel.provider_config['api_key']
|
||||
@api_client = Whatsapp::FacebookApiClient.new(@access_token)
|
||||
end
|
||||
|
||||
def perform
|
||||
@@ -17,6 +17,11 @@ class Whatsapp::WebhookSetupService
|
||||
setup_webhook
|
||||
end
|
||||
|
||||
def register_callback
|
||||
validate_parameters!
|
||||
setup_webhook
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
@@ -33,8 +38,6 @@ class Whatsapp::WebhookSetupService
|
||||
store_pin(pin)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[WHATSAPP] Phone registration failed but continuing: #{e.message}")
|
||||
# Continue with webhook setup even if registration fails
|
||||
# This is just a warning, not a blocking error
|
||||
end
|
||||
|
||||
def fetch_or_create_pin
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -104,11 +104,11 @@ wistia:
|
||||
</div>
|
||||
|
||||
bunny:
|
||||
regex: 'https?://iframe\.mediadelivery\.net/play/(?<library_id>\d+)/(?<video_id>[^&/?]+)'
|
||||
regex: 'https?://(?:iframe|player)\.mediadelivery\.net/(?:play|embed)/(?<library_id>\d+)/(?<video_id>[^&/?]+)'
|
||||
template: |
|
||||
<div style="position: relative; padding-top: 56.25%;">
|
||||
<iframe
|
||||
src="https://iframe.mediadelivery.net/embed/%{library_id}/%{video_id}?autoplay=false&loop=false&muted=false&preload=true&responsive=true"
|
||||
src="https://player.mediadelivery.net/embed/%{library_id}/%{video_id}?autoplay=false&loop=false&muted=false&preload=true&responsive=true"
|
||||
title="Bunny video player"
|
||||
loading="lazy"
|
||||
style="border: 0; position: absolute; top: 0; height: 100%; width: 100%;"
|
||||
|
||||
@@ -218,6 +218,7 @@ Rails.application.routes.draw do
|
||||
delete :avatar, on: :member
|
||||
post :sync_templates, on: :member
|
||||
get :health, on: :member
|
||||
post :register_webhook, on: :member
|
||||
if ChatwootApp.enterprise?
|
||||
resource :conference, only: %i[create destroy], controller: 'conference' do
|
||||
get :token, on: :member
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class SamlUserBuilder
|
||||
class AuthenticationFailed < StandardError; end
|
||||
|
||||
def initialize(auth_hash, account_id)
|
||||
@auth_hash = auth_hash
|
||||
@account_id = account_id
|
||||
@@ -16,13 +18,20 @@ class SamlUserBuilder
|
||||
def find_or_create_user
|
||||
user = User.from_email(auth_attribute('email'))
|
||||
|
||||
if user
|
||||
confirm_user_if_required(user)
|
||||
convert_existing_user_to_saml(user)
|
||||
return user
|
||||
end
|
||||
return create_user unless user
|
||||
return existing_user_for_account(user) if user_belongs_to_account?(user)
|
||||
|
||||
create_user
|
||||
raise AuthenticationFailed, I18n.t('auth.saml.authentication_failed')
|
||||
end
|
||||
|
||||
def existing_user_for_account(user)
|
||||
confirm_user_if_required(user)
|
||||
convert_existing_user_to_saml(user)
|
||||
user
|
||||
end
|
||||
|
||||
def user_belongs_to_account?(user)
|
||||
user.account_users.exists?(account_id: @account_id)
|
||||
end
|
||||
|
||||
def confirm_user_if_required(user)
|
||||
|
||||
+18
-14
@@ -39,7 +39,7 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
||||
error = params[:message] || 'authentication-failed'
|
||||
|
||||
if for_mobile?(relay_state)
|
||||
redirect_to_mobile_error(error, relay_state)
|
||||
redirect_to_mobile_error(error)
|
||||
else
|
||||
redirect_to login_page_url(error: "saml-#{error}")
|
||||
end
|
||||
@@ -51,23 +51,15 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
||||
account_id = extract_saml_account_id
|
||||
relay_state = saml_relay_state
|
||||
|
||||
unless saml_enabled_for_account?(account_id)
|
||||
return redirect_to_mobile_error('saml-not-enabled') if for_mobile?(relay_state)
|
||||
|
||||
return redirect_to login_page_url(error: 'saml-not-enabled')
|
||||
end
|
||||
return handle_saml_auth_error(relay_state, 'saml-not-enabled') unless saml_enabled_for_account?(account_id)
|
||||
|
||||
@resource = SamlUserBuilder.new(auth_hash, account_id).perform
|
||||
|
||||
if @resource.persisted?
|
||||
return sign_in_user_on_mobile if for_mobile?(relay_state)
|
||||
return sign_in_saml_user(relay_state) if @resource.persisted?
|
||||
|
||||
sign_in_user
|
||||
else
|
||||
return redirect_to_mobile_error('saml-authentication-failed') if for_mobile?(relay_state)
|
||||
|
||||
redirect_to login_page_url(error: 'saml-authentication-failed')
|
||||
end
|
||||
handle_saml_auth_error(relay_state, 'saml-authentication-failed')
|
||||
rescue SamlUserBuilder::AuthenticationFailed
|
||||
handle_saml_auth_error(relay_state, 'saml-authentication-failed')
|
||||
end
|
||||
|
||||
def extract_saml_account_id
|
||||
@@ -82,6 +74,18 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
||||
relay_state.to_s.casecmp('mobile').zero?
|
||||
end
|
||||
|
||||
def sign_in_saml_user(relay_state)
|
||||
return sign_in_user_on_mobile if for_mobile?(relay_state)
|
||||
|
||||
sign_in_user
|
||||
end
|
||||
|
||||
def handle_saml_auth_error(relay_state, error)
|
||||
return redirect_to_mobile_error(error) if for_mobile?(relay_state)
|
||||
|
||||
redirect_to login_page_url(error: error)
|
||||
end
|
||||
|
||||
def redirect_to_mobile_error(error)
|
||||
mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
|
||||
redirect_to "#{mobile_deep_link_base}://auth/saml?error=#{ERB::Util.url_encode(error)}", allow_other_host: true
|
||||
|
||||
@@ -5,9 +5,9 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(inbox)
|
||||
return if inbox.account.captain_disable_auto_resolve
|
||||
return if inbox.account.captain_auto_resolve_disabled?
|
||||
|
||||
if inbox.account.feature_enabled?('captain_tasks')
|
||||
if evaluate_conversation_completion?(inbox.account)
|
||||
perform_with_evaluation(inbox)
|
||||
else
|
||||
perform_time_based(inbox)
|
||||
@@ -18,6 +18,10 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
def evaluate_conversation_completion?(account)
|
||||
account.feature_enabled?('captain_tasks') && account.captain_auto_resolve_evaluated?
|
||||
end
|
||||
|
||||
def perform_time_based(inbox)
|
||||
Current.executed_by = inbox.captain_assistant
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ module Enterprise::Account::ConversationsResolutionSchedulerJob
|
||||
inbox = captain_inbox.inbox
|
||||
|
||||
next if inbox.email?
|
||||
next if inbox.account.captain_disable_auto_resolve
|
||||
next if inbox.account.captain_auto_resolve_disabled?
|
||||
|
||||
Captain::InboxPendingConversationsResolutionJob.perform_later(
|
||||
inbox
|
||||
|
||||
@@ -56,6 +56,14 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService
|
||||
{ complete: false, reason: reason }
|
||||
end
|
||||
|
||||
# Prefer the system API key over the account's OpenAI hook key.
|
||||
# This is an internal operational evaluation, not a customer-triggered feature,
|
||||
# so it should not consume the customer's OpenAI credits on hosted platforms.
|
||||
# Falls back to the account hook for self-hosted deployments without a system key.
|
||||
def api_key
|
||||
@api_key ||= system_api_key.presence || openai_hook&.settings&.dig('api_key')
|
||||
end
|
||||
|
||||
def event_name
|
||||
'captain.conversation_completion'
|
||||
end
|
||||
|
||||
@@ -6,7 +6,7 @@ class Captain::Tools::ResolveConversationTool < Captain::Tools::BasePublicTool
|
||||
conversation = find_conversation(tool_context.state)
|
||||
return 'Conversation not found' unless conversation
|
||||
return "Conversation ##{conversation.display_id} is already resolved" if conversation.resolved?
|
||||
return 'Auto-resolve is disabled for this account' if conversation.account.captain_disable_auto_resolve
|
||||
return 'Auto-resolve is disabled for this account' if conversation.account.captain_auto_resolve_disabled?
|
||||
|
||||
log_tool_usage('resolve_conversation', { conversation_id: conversation.id, reason: reason })
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
namespace :reporting_events_rollup do
|
||||
desc 'Backfill rollup table from historical reporting events'
|
||||
task backfill: :environment do
|
||||
ReportingEventsRollupBackfill.new.run
|
||||
end
|
||||
end
|
||||
|
||||
class ReportingEventsRollupBackfill # rubocop:disable Metrics/ClassLength
|
||||
def run
|
||||
print_header
|
||||
account = prompt_account
|
||||
timezone = resolve_timezone(account)
|
||||
first_event, last_event = discover_events(account)
|
||||
start_date, end_date, total_days = resolve_date_range(account, timezone, first_event, last_event)
|
||||
dry_run = prompt_dry_run?
|
||||
print_plan(account, timezone, start_date, end_date, total_days, first_event, last_event, dry_run)
|
||||
return if dry_run
|
||||
|
||||
confirm_and_execute(account, start_date, end_date, total_days)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def print_header
|
||||
puts ''
|
||||
puts color('=' * 70, :cyan)
|
||||
puts color('Reporting Events Rollup Backfill', :bold, :cyan)
|
||||
puts color('=' * 70, :cyan)
|
||||
puts color('Plan:', :bold, :yellow)
|
||||
puts '1. Ensure account.reporting_timezone is set before running this task.'
|
||||
puts '2. Wait for the current day to end in that account timezone.'
|
||||
puts '3. Run backfill for closed days only (today is skipped by default).'
|
||||
puts '4. Verify parity, then enable reporting_events_rollup read path.'
|
||||
puts ''
|
||||
puts color('Note:', :bold, :yellow)
|
||||
puts '- This task always uses account.reporting_timezone.'
|
||||
puts '- Default range is first event day -> yesterday (in account timezone).'
|
||||
puts ''
|
||||
end
|
||||
|
||||
def prompt_account
|
||||
print 'Enter Account ID: '
|
||||
account_id = $stdin.gets.chomp
|
||||
abort color('Error: Account ID is required', :red, :bold) if account_id.blank?
|
||||
|
||||
account = Account.find_by(id: account_id)
|
||||
abort color("Error: Account with ID #{account_id} not found", :red, :bold) unless account
|
||||
|
||||
puts color("Found account: #{account.name}", :gray)
|
||||
puts ''
|
||||
account
|
||||
end
|
||||
|
||||
def resolve_timezone(account)
|
||||
timezone = account.reporting_timezone
|
||||
abort color("Error: Account #{account.id} must have reporting_timezone set", :red, :bold) if timezone.blank?
|
||||
abort color("Error: Account #{account.id} has invalid reporting_timezone '#{timezone}'", :red, :bold) if ActiveSupport::TimeZone[timezone].blank?
|
||||
|
||||
puts color("Using account reporting timezone: #{timezone}", :gray)
|
||||
puts ''
|
||||
timezone
|
||||
end
|
||||
|
||||
def discover_events(account)
|
||||
first_event = account.reporting_events.order(:created_at).first
|
||||
last_event = account.reporting_events.order(:created_at).last
|
||||
|
||||
if first_event.nil?
|
||||
puts ''
|
||||
puts "No reporting events found for account #{account.id}"
|
||||
puts 'Nothing to backfill.'
|
||||
exit(0)
|
||||
end
|
||||
|
||||
[first_event, last_event]
|
||||
end
|
||||
|
||||
def resolve_date_range(account, timezone, first_event, last_event)
|
||||
dates = discovered_dates(timezone, first_event, last_event)
|
||||
print_discovered_date_range(account, dates)
|
||||
build_date_range(dates)
|
||||
end
|
||||
|
||||
def prompt_dry_run?
|
||||
print 'Dry run? (y/N): '
|
||||
input = $stdin.gets.chomp.downcase
|
||||
puts ''
|
||||
%w[y yes].include?(input)
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/ParameterLists
|
||||
def print_plan(account, timezone, start_date, end_date, total_days, first_event, last_event, dry_run)
|
||||
zone = ActiveSupport::TimeZone[timezone]
|
||||
print_plan_summary(account, timezone, start_date, end_date, total_days, zone, first_event, last_event, dry_run)
|
||||
|
||||
return unless dry_run
|
||||
|
||||
puts color("DRY RUN MODE: Would process #{total_days} days", :yellow, :bold)
|
||||
puts "Would use account reporting_timezone '#{timezone}'"
|
||||
puts 'Run without dry run to execute backfill'
|
||||
end
|
||||
# rubocop:enable Metrics/ParameterLists
|
||||
|
||||
def print_plan_summary(account, timezone, start_date, end_date, total_days, zone, first_event, last_event, dry_run) # rubocop:disable Metrics/ParameterLists
|
||||
puts color('=' * 70, :cyan)
|
||||
puts color('Backfill Plan Summary', :bold, :cyan)
|
||||
puts color('=' * 70, :cyan)
|
||||
puts "Account: #{account.name} (ID: #{account.id})"
|
||||
puts "Timezone: #{timezone}"
|
||||
puts "Date Range: #{start_date} to #{end_date} (#{total_days} days)"
|
||||
puts "First Event: #{format_event_time(first_event, zone)}"
|
||||
puts "Last Event: #{format_event_time(last_event, zone)}"
|
||||
puts "Dry Run: #{dry_run ? 'YES (no data will be written)' : 'NO'}"
|
||||
puts color('=' * 70, :cyan)
|
||||
puts ''
|
||||
end
|
||||
|
||||
def format_event_time(event, zone)
|
||||
event.created_at.in_time_zone(zone).strftime('%Y-%m-%d %H:%M:%S %Z')
|
||||
end
|
||||
|
||||
def discovered_dates(timezone, first_event, last_event)
|
||||
tz = ActiveSupport::TimeZone[timezone]
|
||||
discovered_start = first_event.created_at.in_time_zone(tz).to_date
|
||||
discovered_end = last_event.created_at.in_time_zone(tz).to_date
|
||||
|
||||
{
|
||||
discovered_start: discovered_start,
|
||||
discovered_end: discovered_end,
|
||||
discovered_days: (discovered_end - discovered_start).to_i + 1,
|
||||
default_end: [discovered_end, Time.current.in_time_zone(tz).to_date - 1.day].min
|
||||
}
|
||||
end
|
||||
|
||||
def print_discovered_date_range(account, dates)
|
||||
message = "Discovered date range: #{dates[:discovered_start]} to #{dates[:discovered_end]} " \
|
||||
"(#{dates[:discovered_days]} days) [Account: #{account.name}]"
|
||||
puts color(message, :gray)
|
||||
puts color("Default end date (excluding today): #{dates[:default_end]}", :gray)
|
||||
puts ''
|
||||
end
|
||||
|
||||
def build_date_range(dates)
|
||||
start_date = dates[:discovered_start]
|
||||
end_date = dates[:default_end]
|
||||
total_days = (end_date - start_date).to_i + 1
|
||||
|
||||
abort_no_closed_days if total_days <= 0
|
||||
|
||||
[start_date, end_date, total_days]
|
||||
end
|
||||
|
||||
def abort_no_closed_days
|
||||
puts 'No closed days available to backfill in the default range.'
|
||||
exit(0)
|
||||
end
|
||||
|
||||
def confirm_and_execute(account, start_date, end_date, total_days)
|
||||
if total_days > 730
|
||||
puts color("WARNING: Large backfill detected (#{total_days} days / #{(total_days / 365.0).round(1)} years)", :yellow, :bold)
|
||||
puts ''
|
||||
end
|
||||
|
||||
print 'Proceed with backfill? (y/N): '
|
||||
confirm = $stdin.gets.chomp.downcase
|
||||
abort 'Backfill cancelled' unless %w[y yes].include?(confirm)
|
||||
|
||||
puts ''
|
||||
execute_backfill(account, start_date, end_date, total_days)
|
||||
end
|
||||
|
||||
def execute_backfill(account, start_date, end_date, total_days)
|
||||
puts 'Processing dates...'
|
||||
puts ''
|
||||
|
||||
start_time = Time.current
|
||||
days_processed = 0
|
||||
|
||||
(start_date..end_date).each do |date|
|
||||
ReportingEvents::BackfillService.backfill_date(account, date)
|
||||
days_processed += 1
|
||||
percentage = (days_processed.to_f / total_days * 100).round(1)
|
||||
print "\r#{date} | #{days_processed}/#{total_days} days | #{percentage}% "
|
||||
$stdout.flush
|
||||
end
|
||||
|
||||
print_success(account, days_processed, total_days, Time.current - start_time)
|
||||
rescue StandardError => e
|
||||
print_failure(e, days_processed, total_days)
|
||||
else
|
||||
prompt_enable_rollup_read_path(account)
|
||||
end
|
||||
|
||||
def print_success(account, days_processed, _total_days, elapsed_time)
|
||||
puts "\n\n"
|
||||
puts color('=' * 70, :green)
|
||||
puts color('BACKFILL COMPLETE', :bold, :green)
|
||||
puts color('=' * 70, :green)
|
||||
puts "Total Days Processed: #{days_processed}"
|
||||
puts "Total Time: #{elapsed_time.round(2)} seconds"
|
||||
puts "Average per Day: #{(elapsed_time / days_processed).round(3)} seconds"
|
||||
puts ''
|
||||
puts 'Next steps:'
|
||||
puts '1. Verify parity before enabling the reporting_events_rollup read path.'
|
||||
puts '2. Verify rollups in database:'
|
||||
puts " ReportingEventsRollup.where(account_id: #{account.id}).count"
|
||||
puts '3. Test reports to compare rollup vs raw performance'
|
||||
puts color('=' * 70, :green)
|
||||
end
|
||||
|
||||
def prompt_enable_rollup_read_path(account)
|
||||
if account.feature_enabled?(:report_rollup)
|
||||
puts color('report_rollup is already enabled for this account.', :yellow, :bold)
|
||||
return
|
||||
end
|
||||
|
||||
print 'Enable report_rollup read path now? Only do this after parity verification. (y/N): '
|
||||
confirm = $stdin.gets.to_s.chomp.downcase
|
||||
puts ''
|
||||
return unless %w[y yes].include?(confirm)
|
||||
|
||||
account.enable_features!('report_rollup')
|
||||
puts color("Enabled report_rollup for account #{account.id}", :green, :bold)
|
||||
end
|
||||
|
||||
def print_failure(error, days_processed, total_days)
|
||||
puts "\n\n"
|
||||
puts color('=' * 70, :red)
|
||||
puts color('BACKFILL FAILED', :bold, :red)
|
||||
puts color('=' * 70, :red)
|
||||
print_error_details(error)
|
||||
print_progress(days_processed, total_days)
|
||||
exit(1)
|
||||
end
|
||||
|
||||
def print_error_details(error)
|
||||
puts color("Error: #{error.class.name} - #{error.message}", :red, :bold)
|
||||
puts ''
|
||||
puts 'Stack trace:'
|
||||
puts error.backtrace.first(10).map { |line| " #{line}" }.join("\n")
|
||||
puts ''
|
||||
end
|
||||
|
||||
def print_progress(days_processed, total_days)
|
||||
percentage = (days_processed.to_f / total_days * 100).round(1)
|
||||
puts "Processed: #{days_processed}/#{total_days} days (#{percentage}%)"
|
||||
puts color('=' * 70, :red)
|
||||
end
|
||||
|
||||
ANSI_COLORS = {
|
||||
reset: "\e[0m",
|
||||
bold: "\e[1m",
|
||||
red: "\e[31m",
|
||||
green: "\e[32m",
|
||||
yellow: "\e[33m",
|
||||
cyan: "\e[36m",
|
||||
gray: "\e[90m"
|
||||
}.freeze
|
||||
|
||||
def color(text, *styles)
|
||||
return text unless $stdout.tty?
|
||||
|
||||
codes = styles.filter_map { |style| ANSI_COLORS[style] }.join
|
||||
"#{codes}#{text}#{ANSI_COLORS[:reset]}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,196 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
namespace :reporting_events_rollup do
|
||||
desc 'Interactively set account.reporting_timezone and show recommended backfill run times'
|
||||
task set_timezone: :environment do
|
||||
ReportingEventsRollupTimezoneSetup.new.run
|
||||
end
|
||||
end
|
||||
|
||||
class ReportingEventsRollupTimezoneSetup
|
||||
def run
|
||||
print_header
|
||||
account = prompt_account
|
||||
print_current_timezone(account)
|
||||
timezone = prompt_timezone
|
||||
confirm_and_update(account, timezone)
|
||||
print_next_steps(account, timezone)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def print_header
|
||||
puts ''
|
||||
puts color('=' * 70, :cyan)
|
||||
puts color('Reporting Events Rollup Timezone Setup', :bold, :cyan)
|
||||
puts color('=' * 70, :cyan)
|
||||
puts color('Help:', :bold, :yellow)
|
||||
puts '1. This task writes a valid account.reporting_timezone.'
|
||||
puts '2. Backfill uses this timezone and skips today by default.'
|
||||
puts '3. Run backfill only after the account timezone day closes.'
|
||||
puts ''
|
||||
end
|
||||
|
||||
def prompt_account
|
||||
print 'Enter Account ID: '
|
||||
account_id = $stdin.gets.chomp
|
||||
abort color('Error: Account ID is required', :red, :bold) if account_id.blank?
|
||||
|
||||
account = Account.find_by(id: account_id)
|
||||
abort color("Error: Account with ID #{account_id} not found", :red, :bold) unless account
|
||||
|
||||
puts color("Found account: #{account.name}", :gray)
|
||||
puts ''
|
||||
account
|
||||
end
|
||||
|
||||
def print_current_timezone(account)
|
||||
current_timezone = account.reporting_timezone.presence || '(not set)'
|
||||
puts color("Current reporting_timezone: #{current_timezone}", :gray)
|
||||
puts ''
|
||||
end
|
||||
|
||||
def prompt_timezone
|
||||
loop do
|
||||
print 'Enter UTC offset to pick timezone (e.g., +5:30, -8, 0): '
|
||||
offset_input = $stdin.gets.chomp
|
||||
abort color('Error: UTC offset is required', :red, :bold) if offset_input.blank?
|
||||
|
||||
matching_zones = find_matching_zones(offset_input)
|
||||
abort color("Error: No timezones found for offset '#{offset_input}'", :red, :bold) if matching_zones.empty?
|
||||
|
||||
display_matching_zones(matching_zones, offset_input)
|
||||
timezone = select_timezone(matching_zones)
|
||||
return timezone if timezone.present?
|
||||
end
|
||||
end
|
||||
|
||||
def find_matching_zones(offset_input)
|
||||
total_seconds = utc_offset_in_seconds(offset_input)
|
||||
return [] unless total_seconds
|
||||
|
||||
ActiveSupport::TimeZone.all.select { |tz| tz.utc_offset == total_seconds }
|
||||
end
|
||||
|
||||
def utc_offset_in_seconds(offset_input)
|
||||
normalized = offset_input.strip
|
||||
return unless normalized.match?(/\A[+-]?\d{1,2}(:\d{2})?\z/)
|
||||
|
||||
sign = normalized.start_with?('-') ? -1 : 1
|
||||
raw = normalized.delete_prefix('+').delete_prefix('-')
|
||||
hours_part, minutes_part = raw.split(':', 2)
|
||||
|
||||
hours = Integer(hours_part, 10)
|
||||
minutes = Integer(minutes_part || '0', 10)
|
||||
return unless minutes.between?(0, 59)
|
||||
|
||||
total_minutes = (hours * 60) + minutes
|
||||
return if total_minutes > max_utc_offset_minutes(sign)
|
||||
|
||||
sign * total_minutes * 60
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
def max_utc_offset_minutes(sign)
|
||||
sign.negative? ? 12 * 60 : 14 * 60
|
||||
end
|
||||
|
||||
def display_matching_zones(zones, offset_input)
|
||||
puts ''
|
||||
puts color("Timezones matching UTC#{offset_input}:", :yellow, :bold)
|
||||
puts ''
|
||||
zones.each_with_index do |tz, index|
|
||||
puts " #{index + 1}. #{tz.name} (#{tz.tzinfo.identifier})"
|
||||
end
|
||||
puts ' 0. Re-enter UTC offset'
|
||||
puts ''
|
||||
end
|
||||
|
||||
def select_timezone(zones)
|
||||
print "Select timezone (1-#{zones.size}, 0 to go back): "
|
||||
selection = $stdin.gets.chomp.to_i
|
||||
return if selection.zero?
|
||||
|
||||
abort color('Error: Invalid selection', :red, :bold) if selection < 1 || selection > zones.size
|
||||
|
||||
timezone = zones[selection - 1].tzinfo.identifier
|
||||
puts color("Selected timezone: #{timezone}", :gray)
|
||||
puts ''
|
||||
timezone
|
||||
end
|
||||
|
||||
def confirm_and_update(account, timezone)
|
||||
print "Update account #{account.id} reporting_timezone to '#{timezone}'? (y/N): "
|
||||
confirm = $stdin.gets.chomp.downcase
|
||||
abort 'Timezone setup cancelled' unless %w[y yes].include?(confirm)
|
||||
|
||||
account.update!(reporting_timezone: timezone)
|
||||
puts ''
|
||||
puts color("Updated reporting_timezone for account '#{account.name}' to '#{timezone}'", :green, :bold)
|
||||
puts ''
|
||||
end
|
||||
|
||||
def print_next_steps(account, timezone)
|
||||
run_times = recommended_run_times(timezone)
|
||||
print_next_steps_header
|
||||
print_next_steps_schedule(timezone, run_times)
|
||||
print_next_steps_backfill(account)
|
||||
puts color('=' * 70, :green)
|
||||
end
|
||||
|
||||
def print_next_steps_header
|
||||
puts color('=' * 70, :green)
|
||||
puts color('Next Steps', :bold, :green)
|
||||
puts color('=' * 70, :green)
|
||||
end
|
||||
|
||||
def print_next_steps_schedule(timezone, run_times)
|
||||
puts "1. Wait for today's day-boundary to pass in #{timezone}."
|
||||
puts '2. Recommended earliest backfill start time:'
|
||||
puts " - #{timezone}: #{format_time(run_times[:account_tz])}"
|
||||
puts " - UTC: #{format_time(run_times[:utc])}"
|
||||
puts " - IST: #{format_time(run_times[:ist])}"
|
||||
puts " - PCT/PT: #{format_time(run_times[:pct])}"
|
||||
end
|
||||
|
||||
def print_next_steps_backfill(account)
|
||||
puts '3. Run backfill:'
|
||||
puts ' bundle exec rake reporting_events_rollup:backfill'
|
||||
puts "4. Backfill will use account.reporting_timezone and skip today by default for account #{account.id}."
|
||||
end
|
||||
|
||||
def recommended_run_times(timezone)
|
||||
account_zone = ActiveSupport::TimeZone[timezone]
|
||||
next_day = Time.current.in_time_zone(account_zone).to_date + 1.day
|
||||
account_time = account_zone.parse(next_day.to_s) + 30.minutes
|
||||
|
||||
{
|
||||
account_tz: account_time,
|
||||
utc: account_time.in_time_zone('UTC'),
|
||||
ist: account_time.in_time_zone('Asia/Kolkata'),
|
||||
pct: account_time.in_time_zone('Pacific Time (US & Canada)')
|
||||
}
|
||||
end
|
||||
|
||||
def format_time(time)
|
||||
time.strftime('%Y-%m-%d %H:%M:%S %Z')
|
||||
end
|
||||
|
||||
ANSI_COLORS = {
|
||||
reset: "\e[0m",
|
||||
bold: "\e[1m",
|
||||
red: "\e[31m",
|
||||
green: "\e[32m",
|
||||
yellow: "\e[33m",
|
||||
cyan: "\e[36m",
|
||||
gray: "\e[90m"
|
||||
}.freeze
|
||||
|
||||
def color(text, *styles)
|
||||
return text unless $stdout.tty?
|
||||
|
||||
codes = styles.filter_map { |style| ANSI_COLORS[style] }.join
|
||||
"#{codes}#{text}#{ANSI_COLORS[:reset]}"
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -79,7 +79,7 @@
|
||||
"json-logic-js": "^2.0.5",
|
||||
"lettersanitizer": "^1.0.6",
|
||||
"libphonenumber-js": "^1.11.9",
|
||||
"markdown-it": "^13.0.2",
|
||||
"markdown-it": "^14.1.1",
|
||||
"markdown-it-link-attributes": "^4.0.1",
|
||||
"md5": "^2.3.0",
|
||||
"mitt": "^3.0.1",
|
||||
|
||||
Generated
+6
-31
@@ -160,8 +160,8 @@ importers:
|
||||
specifier: ^1.11.9
|
||||
version: 1.11.9
|
||||
markdown-it:
|
||||
specifier: ^13.0.2
|
||||
version: 13.0.2
|
||||
specifier: ^14.1.1
|
||||
version: 14.1.1
|
||||
markdown-it-link-attributes:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
@@ -2206,10 +2206,6 @@ packages:
|
||||
entities@2.1.0:
|
||||
resolution: {integrity: sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==}
|
||||
|
||||
entities@3.0.1:
|
||||
resolution: {integrity: sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@4.5.0:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
@@ -3087,9 +3083,6 @@ packages:
|
||||
linkify-it@3.0.3:
|
||||
resolution: {integrity: sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==}
|
||||
|
||||
linkify-it@4.0.1:
|
||||
resolution: {integrity: sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==}
|
||||
|
||||
linkify-it@5.0.0:
|
||||
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||
|
||||
@@ -3210,12 +3203,8 @@ packages:
|
||||
resolution: {integrity: sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==}
|
||||
hasBin: true
|
||||
|
||||
markdown-it@13.0.2:
|
||||
resolution: {integrity: sha512-FtwnEuuK+2yVU7goGn/MJ0WBZMM9ZPgU9spqlFs7/A/pDIUNSOQZhUgOqYCficIuR2QaFnrt8LHqBWsbTAoI5w==}
|
||||
hasBin: true
|
||||
|
||||
markdown-it@14.1.0:
|
||||
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
|
||||
markdown-it@14.1.1:
|
||||
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
|
||||
hasBin: true
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
@@ -6841,8 +6830,6 @@ snapshots:
|
||||
|
||||
entities@2.1.0: {}
|
||||
|
||||
entities@3.0.1: {}
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
entities@6.0.1: {}
|
||||
@@ -7950,10 +7937,6 @@ snapshots:
|
||||
dependencies:
|
||||
uc.micro: 1.0.6
|
||||
|
||||
linkify-it@4.0.1:
|
||||
dependencies:
|
||||
uc.micro: 1.0.6
|
||||
|
||||
linkify-it@5.0.0:
|
||||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
@@ -8091,15 +8074,7 @@ snapshots:
|
||||
mdurl: 1.0.1
|
||||
uc.micro: 1.0.6
|
||||
|
||||
markdown-it@13.0.2:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
entities: 3.0.1
|
||||
linkify-it: 4.0.1
|
||||
mdurl: 1.0.1
|
||||
uc.micro: 1.0.6
|
||||
|
||||
markdown-it@14.1.0:
|
||||
markdown-it@14.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
entities: 4.5.0
|
||||
@@ -8738,7 +8713,7 @@ snapshots:
|
||||
|
||||
prosemirror-markdown@1.13.0:
|
||||
dependencies:
|
||||
markdown-it: 14.1.0
|
||||
markdown-it: 14.1.1
|
||||
prosemirror-model: 1.22.3
|
||||
|
||||
prosemirror-menu@1.2.4:
|
||||
|
||||
@@ -5,12 +5,10 @@ RSpec.describe V2::Reports::Conversations::MetricBuilder, type: :model do
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:params) { { since: '2023-01-01', until: '2024-01-01' } }
|
||||
let(:count_builder_instance) { instance_double(V2::Reports::Timeseries::CountReportBuilder, aggregate_value: 42) }
|
||||
let(:avg_builder_instance) { instance_double(V2::Reports::Timeseries::AverageReportBuilder, aggregate_value: 42) }
|
||||
let(:builder_instance) { instance_double(V2::Reports::Timeseries::ReportBuilder, aggregate_value: 42) }
|
||||
|
||||
before do
|
||||
allow(V2::Reports::Timeseries::CountReportBuilder).to receive(:new).and_return(count_builder_instance)
|
||||
allow(V2::Reports::Timeseries::AverageReportBuilder).to receive(:new).and_return(avg_builder_instance)
|
||||
allow(V2::Reports::Timeseries::ReportBuilder).to receive(:new).and_return(builder_instance)
|
||||
end
|
||||
|
||||
describe '#summary' do
|
||||
@@ -31,8 +29,8 @@ RSpec.describe V2::Reports::Conversations::MetricBuilder, type: :model do
|
||||
|
||||
it 'creates builders with proper params' do
|
||||
subject.summary
|
||||
expect(V2::Reports::Timeseries::CountReportBuilder).to have_received(:new).with(account, params.merge(metric: 'conversations_count'))
|
||||
expect(V2::Reports::Timeseries::AverageReportBuilder).to have_received(:new).with(account, params.merge(metric: 'avg_first_response_time'))
|
||||
expect(V2::Reports::Timeseries::ReportBuilder).to have_received(:new).with(account, params.merge(metric: 'conversations_count'))
|
||||
expect(V2::Reports::Timeseries::ReportBuilder).to have_received(:new).with(account, params.merge(metric: 'avg_first_response_time'))
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -4,19 +4,19 @@ describe V2::Reports::Conversations::ReportBuilder do
|
||||
subject { described_class.new(account, params) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:average_builder) { V2::Reports::Timeseries::AverageReportBuilder }
|
||||
let(:count_builder) { V2::Reports::Timeseries::CountReportBuilder }
|
||||
let(:builder) { V2::Reports::Timeseries::ReportBuilder }
|
||||
|
||||
shared_examples 'valid metric handler' do |metric, method, builder|
|
||||
shared_examples 'valid metric handler' do |metric, method|
|
||||
context 'when a valid metric is given' do
|
||||
let(:params) { { metric: metric } }
|
||||
|
||||
it "calls the correct #{method} builder for #{metric}" do
|
||||
it "calls the shared #{method} builder for #{metric}" do
|
||||
builder_instance = instance_double(builder)
|
||||
allow(builder).to receive(:new).and_return(builder_instance)
|
||||
allow(builder_instance).to receive(method)
|
||||
allow(builder_instance).to receive(method).and_return(:result)
|
||||
|
||||
builder_instance.public_send(method)
|
||||
expect(subject.public_send(method)).to eq(:result)
|
||||
expect(builder).to have_received(:new).with(account, params)
|
||||
expect(builder_instance).to have_received(method)
|
||||
end
|
||||
end
|
||||
@@ -33,12 +33,12 @@ describe V2::Reports::Conversations::ReportBuilder do
|
||||
end
|
||||
|
||||
describe '#timeseries' do
|
||||
it_behaves_like 'valid metric handler', 'avg_first_response_time', :timeseries, V2::Reports::Timeseries::AverageReportBuilder
|
||||
it_behaves_like 'valid metric handler', 'conversations_count', :timeseries, V2::Reports::Timeseries::CountReportBuilder
|
||||
it_behaves_like 'valid metric handler', 'avg_first_response_time', :timeseries
|
||||
it_behaves_like 'valid metric handler', 'conversations_count', :timeseries
|
||||
end
|
||||
|
||||
describe '#aggregate_value' do
|
||||
it_behaves_like 'valid metric handler', 'avg_first_response_time', :aggregate_value, V2::Reports::Timeseries::AverageReportBuilder
|
||||
it_behaves_like 'valid metric handler', 'conversations_count', :aggregate_value, V2::Reports::Timeseries::CountReportBuilder
|
||||
it_behaves_like 'valid metric handler', 'avg_first_response_time', :aggregate_value
|
||||
it_behaves_like 'valid metric handler', 'conversations_count', :aggregate_value
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,7 +33,13 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
|
||||
|
||||
it 'sets timezone from timezone_offset' do
|
||||
builder_with_offset = described_class.new(account: account, params: { timezone_offset: -8 })
|
||||
expect(builder_with_offset.instance_variable_get(:@timezone)).to eq('Pacific Time (US & Canada)')
|
||||
expected_timezone = ActiveSupport::TimeZone.all.find { |zone| zone.now.utc_offset == -8.hours }.name
|
||||
expect(builder_with_offset.instance_variable_get(:@timezone)).to eq(expected_timezone)
|
||||
end
|
||||
|
||||
it 'prefers timezone when it is provided' do
|
||||
builder_with_timezone = described_class.new(account: account, params: { timezone: 'Asia/Kolkata', timezone_offset: -8 })
|
||||
expect(builder_with_timezone.instance_variable_get(:@timezone)).to eq('Asia/Kolkata')
|
||||
end
|
||||
|
||||
it 'defaults timezone when timezone_offset is not provided' do
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe V2::Reports::Timeseries::AverageReportBuilder do
|
||||
subject { described_class.new(account, params) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:team) { create(:team, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:label) { create(:label, title: 'spec-billing', account: account) }
|
||||
let!(:conversation) { create(:conversation, account: account, inbox: inbox, team: team) }
|
||||
let(:current_time) { '26.10.2020 10:00'.to_datetime }
|
||||
|
||||
let(:params) do
|
||||
{
|
||||
type: filter_type,
|
||||
business_hours: business_hours,
|
||||
timezone_offset: timezone_offset,
|
||||
group_by: group_by,
|
||||
metric: metric,
|
||||
since: (current_time - 1.week).beginning_of_day.to_i.to_s,
|
||||
until: current_time.end_of_day.to_i.to_s,
|
||||
id: filter_id
|
||||
}
|
||||
end
|
||||
let(:timezone_offset) { nil }
|
||||
let(:group_by) { 'day' }
|
||||
let(:metric) { 'avg_first_response_time' }
|
||||
let(:business_hours) { false }
|
||||
let(:filter_type) { :account }
|
||||
let(:filter_id) { '' }
|
||||
|
||||
before do
|
||||
travel_to current_time
|
||||
conversation.label_list.add(label.title)
|
||||
conversation.save!
|
||||
create(:reporting_event, name: 'first_response', value: 80, value_in_business_hours: 10, account: account, created_at: Time.zone.now,
|
||||
conversation: conversation, inbox: inbox)
|
||||
create(:reporting_event, name: 'first_response', value: 100, value_in_business_hours: 20, account: account, created_at: 1.hour.ago)
|
||||
create(:reporting_event, name: 'first_response', value: 93, value_in_business_hours: 30, account: account, created_at: 1.week.ago)
|
||||
end
|
||||
|
||||
describe '#timeseries' do
|
||||
context 'when there is no filter applied' do
|
||||
it 'returns the correct values' do
|
||||
timeseries_values = subject.timeseries
|
||||
|
||||
expect(timeseries_values).to eq(
|
||||
[
|
||||
{ count: 1, timestamp: 1_603_065_600, value: 93.0 },
|
||||
{ count: 0, timestamp: 1_603_152_000, value: 0 },
|
||||
{ count: 0, timestamp: 1_603_238_400, value: 0 },
|
||||
{ count: 0, timestamp: 1_603_324_800, value: 0 },
|
||||
{ count: 0, timestamp: 1_603_411_200, value: 0 },
|
||||
{ count: 0, timestamp: 1_603_497_600, value: 0 },
|
||||
{ count: 0, timestamp: 1_603_584_000, value: 0 },
|
||||
{ count: 2, timestamp: 1_603_670_400, value: 90.0 }
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
context 'when business hours is provided' do
|
||||
let(:business_hours) { true }
|
||||
|
||||
it 'returns correct timeseries' do
|
||||
timeseries_values = subject.timeseries
|
||||
|
||||
expect(timeseries_values).to eq(
|
||||
[
|
||||
{ count: 1, timestamp: 1_603_065_600, value: 30.0 },
|
||||
{ count: 0, timestamp: 1_603_152_000, value: 0 },
|
||||
{ count: 0, timestamp: 1_603_238_400, value: 0 },
|
||||
{ count: 0, timestamp: 1_603_324_800, value: 0 },
|
||||
{ count: 0, timestamp: 1_603_411_200, value: 0 },
|
||||
{ count: 0, timestamp: 1_603_497_600, value: 0 },
|
||||
{ count: 0, timestamp: 1_603_584_000, value: 0 },
|
||||
{ count: 2, timestamp: 1_603_670_400, value: 15.0 }
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when group_by is provided' do
|
||||
let(:group_by) { 'week' }
|
||||
|
||||
it 'returns correct timeseries' do
|
||||
timeseries_values = subject.timeseries
|
||||
expect(timeseries_values).to eq(
|
||||
[
|
||||
{ count: 1, timestamp: (current_time - 1.week).beginning_of_week(:sunday).to_i, value: 93.0 },
|
||||
{ count: 2, timestamp: current_time.beginning_of_week(:sunday).to_i, value: 90.0 }
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when timezone offset is provided' do
|
||||
let(:timezone_offset) { '5.5' }
|
||||
let(:group_by) { 'week' }
|
||||
|
||||
it 'returns correct timeseries' do
|
||||
timeseries_values = subject.timeseries
|
||||
expect(timeseries_values).to eq(
|
||||
[
|
||||
{ count: 1, timestamp: (current_time - 1.week).in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 93.0 },
|
||||
{ count: 2, timestamp: current_time.in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 90.0 }
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the label filter is applied' do
|
||||
let(:group_by) { 'week' }
|
||||
let(:filter_type) { 'label' }
|
||||
let(:filter_id) { label.id }
|
||||
|
||||
it 'returns correct timeseries' do
|
||||
timeseries_values = subject.timeseries
|
||||
start_of_the_week = current_time.beginning_of_week(:sunday).to_i
|
||||
last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i
|
||||
expect(timeseries_values).to eq(
|
||||
[
|
||||
{ count: 0, timestamp: last_week_start_of_the_week, value: 0 },
|
||||
{ count: 1, timestamp: start_of_the_week, value: 80.0 }
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the inbox filter is applied' do
|
||||
let(:group_by) { 'week' }
|
||||
let(:filter_type) { 'inbox' }
|
||||
let(:filter_id) { inbox.id }
|
||||
|
||||
it 'returns correct timeseries' do
|
||||
timeseries_values = subject.timeseries
|
||||
start_of_the_week = current_time.beginning_of_week(:sunday).to_i
|
||||
last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i
|
||||
expect(timeseries_values).to eq(
|
||||
[
|
||||
{ count: 0, timestamp: last_week_start_of_the_week, value: 0 },
|
||||
{ count: 1, timestamp: start_of_the_week, value: 80.0 }
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the team filter is applied' do
|
||||
let(:group_by) { 'week' }
|
||||
let(:filter_type) { 'team' }
|
||||
let(:filter_id) { team.id }
|
||||
|
||||
it 'returns correct timeseries' do
|
||||
timeseries_values = subject.timeseries
|
||||
start_of_the_week = current_time.beginning_of_week(:sunday).to_i
|
||||
last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i
|
||||
expect(timeseries_values).to eq(
|
||||
[
|
||||
{ count: 0, timestamp: last_week_start_of_the_week, value: 0 },
|
||||
{ count: 1, timestamp: start_of_the_week, value: 80.0 }
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#aggregate_value' do
|
||||
context 'when there is no filter applied' do
|
||||
it 'returns the correct average value' do
|
||||
expect(subject.aggregate_value).to eq 91.0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,113 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe V2::Reports::Timeseries::CountReportBuilder do
|
||||
subject { described_class.new(account, params) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:account2) { create(:account) }
|
||||
let(:user) { create(:user, email: 'agent1@example.com') }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:inbox2) { create(:inbox, account: account2) }
|
||||
let(:current_time) { Time.current }
|
||||
|
||||
let(:params) do
|
||||
{
|
||||
type: 'agent',
|
||||
metric: 'resolutions_count',
|
||||
since: (current_time - 1.day).beginning_of_day.to_i.to_s,
|
||||
until: current_time.end_of_day.to_i.to_s,
|
||||
id: user.id.to_s
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
travel_to current_time
|
||||
|
||||
# Add the same user to both accounts
|
||||
create(:account_user, account: account, user: user)
|
||||
create(:account_user, account: account2, user: user)
|
||||
|
||||
# Create conversations in account1
|
||||
conversation1 = create(:conversation, account: account, inbox: inbox, assignee: user)
|
||||
conversation2 = create(:conversation, account: account, inbox: inbox, assignee: user)
|
||||
|
||||
# Create conversations in account2
|
||||
conversation3 = create(:conversation, account: account2, inbox: inbox2, assignee: user)
|
||||
conversation4 = create(:conversation, account: account2, inbox: inbox2, assignee: user)
|
||||
|
||||
# User resolves 2 conversations in account1
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account: account,
|
||||
user: user,
|
||||
conversation: conversation1,
|
||||
created_at: current_time - 12.hours)
|
||||
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account: account,
|
||||
user: user,
|
||||
conversation: conversation2,
|
||||
created_at: current_time - 6.hours)
|
||||
|
||||
# Same user resolves 3 conversations in account2 - these should NOT be counted for account1
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account: account2,
|
||||
user: user,
|
||||
conversation: conversation3,
|
||||
created_at: current_time - 8.hours)
|
||||
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account: account2,
|
||||
user: user,
|
||||
conversation: conversation4,
|
||||
created_at: current_time - 4.hours)
|
||||
|
||||
# Create another conversation in account2 for testing
|
||||
conversation5 = create(:conversation, account: account2, inbox: inbox2, assignee: user)
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account: account2,
|
||||
user: user,
|
||||
conversation: conversation5,
|
||||
created_at: current_time - 2.hours)
|
||||
end
|
||||
|
||||
describe '#aggregate_value' do
|
||||
it 'returns only resolutions performed by the user in the specified account' do
|
||||
# User should have 2 resolutions in account1, not 5 (total across both accounts)
|
||||
expect(subject.aggregate_value).to eq(2)
|
||||
end
|
||||
|
||||
context 'when querying account2' do
|
||||
subject { described_class.new(account2, params) }
|
||||
|
||||
it 'returns only resolutions for account2' do
|
||||
# User should have 3 resolutions in account2
|
||||
expect(subject.aggregate_value).to eq(3)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#timeseries' do
|
||||
it 'filters resolutions by account' do
|
||||
result = subject.timeseries
|
||||
# Should only count the 2 resolutions from account1
|
||||
total_count = result.sum { |r| r[:value] }
|
||||
expect(total_count).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'account isolation' do
|
||||
it 'does not leak data between accounts' do
|
||||
# If account isolation works correctly, the counts should be different
|
||||
account1_count = described_class.new(account, params).aggregate_value
|
||||
account2_count = described_class.new(account2, params).aggregate_value
|
||||
|
||||
expect(account1_count).to eq(2)
|
||||
expect(account2_count).to eq(3)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,470 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe V2::Reports::Timeseries::ReportBuilder do
|
||||
describe 'average metrics' do
|
||||
subject { described_class.new(account, params) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:team) { create(:team, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:label) { create(:label, title: 'spec-billing', account: account) }
|
||||
let!(:conversation) { create(:conversation, account: account, inbox: inbox, team: team) }
|
||||
let(:current_time) { '26.10.2020 10:00'.to_datetime }
|
||||
|
||||
let(:params) do
|
||||
{
|
||||
type: filter_type,
|
||||
business_hours: business_hours,
|
||||
timezone: timezone,
|
||||
timezone_offset: timezone_offset,
|
||||
group_by: group_by,
|
||||
metric: metric,
|
||||
since: (current_time - 1.week).beginning_of_day.to_i.to_s,
|
||||
until: current_time.end_of_day.to_i.to_s,
|
||||
id: filter_id
|
||||
}
|
||||
end
|
||||
let(:timezone) { nil }
|
||||
let(:timezone_offset) { nil }
|
||||
let(:group_by) { 'day' }
|
||||
let(:metric) { 'avg_first_response_time' }
|
||||
let(:business_hours) { false }
|
||||
let(:filter_type) { :account }
|
||||
let(:filter_id) { '' }
|
||||
|
||||
before do
|
||||
travel_to current_time
|
||||
conversation.label_list.add(label.title)
|
||||
conversation.save!
|
||||
create(:reporting_event, name: 'first_response', value: 80, value_in_business_hours: 10, account: account, created_at: Time.zone.now,
|
||||
conversation: conversation, inbox: inbox)
|
||||
create(:reporting_event, name: 'first_response', value: 100, value_in_business_hours: 20, account: account, created_at: 1.hour.ago)
|
||||
create(:reporting_event, name: 'first_response', value: 93, value_in_business_hours: 30, account: account, created_at: 1.week.ago)
|
||||
end
|
||||
|
||||
describe '#timeseries' do
|
||||
it 'returns the correct values' do
|
||||
timeseries_values = subject.timeseries
|
||||
|
||||
expect(timeseries_values).to eq(
|
||||
[
|
||||
{ count: 1, timestamp: 1_603_065_600, value: 93.0 },
|
||||
{ count: 0, timestamp: 1_603_152_000, value: 0 },
|
||||
{ count: 0, timestamp: 1_603_238_400, value: 0 },
|
||||
{ count: 0, timestamp: 1_603_324_800, value: 0 },
|
||||
{ count: 0, timestamp: 1_603_411_200, value: 0 },
|
||||
{ count: 0, timestamp: 1_603_497_600, value: 0 },
|
||||
{ count: 0, timestamp: 1_603_584_000, value: 0 },
|
||||
{ count: 2, timestamp: 1_603_670_400, value: 90.0 }
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
context 'when business hours is provided' do
|
||||
let(:business_hours) { true }
|
||||
|
||||
it 'returns correct timeseries' do
|
||||
timeseries_values = subject.timeseries
|
||||
|
||||
expect(timeseries_values).to eq(
|
||||
[
|
||||
{ count: 1, timestamp: 1_603_065_600, value: 30.0 },
|
||||
{ count: 0, timestamp: 1_603_152_000, value: 0 },
|
||||
{ count: 0, timestamp: 1_603_238_400, value: 0 },
|
||||
{ count: 0, timestamp: 1_603_324_800, value: 0 },
|
||||
{ count: 0, timestamp: 1_603_411_200, value: 0 },
|
||||
{ count: 0, timestamp: 1_603_497_600, value: 0 },
|
||||
{ count: 0, timestamp: 1_603_584_000, value: 0 },
|
||||
{ count: 2, timestamp: 1_603_670_400, value: 15.0 }
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when rollups are enabled' do
|
||||
let(:timezone_offset) { '5.5' }
|
||||
|
||||
before do
|
||||
account.update!(reporting_timezone: 'Chennai')
|
||||
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(true)
|
||||
|
||||
create(:reporting_events_rollup,
|
||||
account: account,
|
||||
date: (current_time - 1.week).to_date,
|
||||
dimension_type: 'account',
|
||||
dimension_id: account.id,
|
||||
metric: 'first_response',
|
||||
count: 1,
|
||||
sum_value: 93.0,
|
||||
sum_value_business_hours: 30.0)
|
||||
|
||||
create(:reporting_events_rollup,
|
||||
account: account,
|
||||
date: current_time.to_date,
|
||||
dimension_type: 'account',
|
||||
dimension_id: account.id,
|
||||
metric: 'first_response',
|
||||
count: 2,
|
||||
sum_value: 180.0,
|
||||
sum_value_business_hours: 30.0)
|
||||
end
|
||||
|
||||
it 'preserves empty buckets in the timeseries' do
|
||||
rollup_timezone = ActiveSupport::TimeZone['Chennai']
|
||||
rollup_start_date = DateTime.strptime(params[:since], '%s').in_time_zone(rollup_timezone).to_date
|
||||
rollup_end_date = (DateTime.strptime(params[:until], '%s') - 1.second).in_time_zone(rollup_timezone).to_date
|
||||
rollup_dates = rollup_start_date..rollup_end_date
|
||||
|
||||
expected_timeseries = rollup_dates.map do |date|
|
||||
value = if date == (current_time - 1.week).to_date
|
||||
93.0
|
||||
elsif date == current_time.to_date
|
||||
90.0
|
||||
else
|
||||
0
|
||||
end
|
||||
|
||||
count = if date == (current_time - 1.week).to_date
|
||||
1
|
||||
elsif date == current_time.to_date
|
||||
2
|
||||
else
|
||||
0
|
||||
end
|
||||
|
||||
{ count: count, timestamp: date.in_time_zone('Chennai').to_i, value: value }
|
||||
end
|
||||
|
||||
expect(subject.timeseries).to eq(expected_timeseries)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when group_by is provided' do
|
||||
let(:group_by) { 'week' }
|
||||
|
||||
it 'returns correct timeseries' do
|
||||
timeseries_values = subject.timeseries
|
||||
expect(timeseries_values).to eq(
|
||||
[
|
||||
{ count: 1, timestamp: (current_time - 1.week).beginning_of_week(:sunday).to_i, value: 93.0 },
|
||||
{ count: 2, timestamp: current_time.beginning_of_week(:sunday).to_i, value: 90.0 }
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when timezone offset is provided' do
|
||||
let(:timezone_offset) { '5.5' }
|
||||
let(:group_by) { 'week' }
|
||||
|
||||
it 'returns correct timeseries' do
|
||||
timeseries_values = subject.timeseries
|
||||
expect(timeseries_values).to eq(
|
||||
[
|
||||
{ count: 1, timestamp: (current_time - 1.week).in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 93.0 },
|
||||
{ count: 2, timestamp: current_time.in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 90.0 }
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when timezone is provided' do
|
||||
let(:timezone) { 'Asia/Kolkata' }
|
||||
let(:timezone_offset) { '0' }
|
||||
let(:group_by) { 'week' }
|
||||
|
||||
it 'uses the timezone name instead of the offset for timestamps' do
|
||||
expect(subject.timeseries).to eq(
|
||||
[
|
||||
{ count: 1, timestamp: (current_time - 1.week).in_time_zone('Asia/Kolkata').beginning_of_week(:sunday).to_i, value: 93.0 },
|
||||
{ count: 2, timestamp: current_time.in_time_zone('Asia/Kolkata').beginning_of_week(:sunday).to_i, value: 90.0 }
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when weekly rollups are enabled' do
|
||||
let(:group_by) { 'week' }
|
||||
let(:timezone_offset) { '5.5' }
|
||||
|
||||
before do
|
||||
account.update!(reporting_timezone: 'Chennai')
|
||||
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(true)
|
||||
|
||||
create(:reporting_events_rollup,
|
||||
account: account,
|
||||
date: current_time.to_date - 1.day,
|
||||
dimension_type: 'account',
|
||||
dimension_id: account.id,
|
||||
metric: 'first_response',
|
||||
count: 1,
|
||||
sum_value: 80.0,
|
||||
sum_value_business_hours: 10.0)
|
||||
|
||||
create(:reporting_events_rollup,
|
||||
account: account,
|
||||
date: current_time.to_date,
|
||||
dimension_type: 'account',
|
||||
dimension_id: account.id,
|
||||
metric: 'first_response',
|
||||
count: 1,
|
||||
sum_value: 100.0,
|
||||
sum_value_business_hours: 20.0)
|
||||
end
|
||||
|
||||
it 'groups weeks using sunday boundaries' do
|
||||
expect(subject.timeseries).to eq(
|
||||
[
|
||||
{ count: 0, timestamp: (current_time - 1.week).in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 0 },
|
||||
{ count: 2, timestamp: current_time.in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 90.0 }
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the label filter is applied' do
|
||||
let(:group_by) { 'week' }
|
||||
let(:filter_type) { 'label' }
|
||||
let(:filter_id) { label.id }
|
||||
|
||||
it 'returns correct timeseries' do
|
||||
timeseries_values = subject.timeseries
|
||||
start_of_the_week = current_time.beginning_of_week(:sunday).to_i
|
||||
last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i
|
||||
expect(timeseries_values).to eq(
|
||||
[
|
||||
{ count: 0, timestamp: last_week_start_of_the_week, value: 0 },
|
||||
{ count: 1, timestamp: start_of_the_week, value: 80.0 }
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the inbox filter is applied' do
|
||||
let(:group_by) { 'week' }
|
||||
let(:filter_type) { 'inbox' }
|
||||
let(:filter_id) { inbox.id }
|
||||
|
||||
it 'returns correct timeseries' do
|
||||
timeseries_values = subject.timeseries
|
||||
start_of_the_week = current_time.beginning_of_week(:sunday).to_i
|
||||
last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i
|
||||
expect(timeseries_values).to eq(
|
||||
[
|
||||
{ count: 0, timestamp: last_week_start_of_the_week, value: 0 },
|
||||
{ count: 1, timestamp: start_of_the_week, value: 80.0 }
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the team filter is applied' do
|
||||
let(:group_by) { 'week' }
|
||||
let(:filter_type) { 'team' }
|
||||
let(:filter_id) { team.id }
|
||||
|
||||
it 'returns correct timeseries' do
|
||||
timeseries_values = subject.timeseries
|
||||
start_of_the_week = current_time.beginning_of_week(:sunday).to_i
|
||||
last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i
|
||||
expect(timeseries_values).to eq(
|
||||
[
|
||||
{ count: 0, timestamp: last_week_start_of_the_week, value: 0 },
|
||||
{ count: 1, timestamp: start_of_the_week, value: 80.0 }
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#aggregate_value' do
|
||||
context 'when there is no filter applied' do
|
||||
it 'returns the correct average value' do
|
||||
expect(subject.aggregate_value).to eq 91.0
|
||||
end
|
||||
end
|
||||
|
||||
context 'when rollups are enabled and the agent does not exist' do
|
||||
let(:filter_type) { :agent }
|
||||
let(:filter_id) { '999999' }
|
||||
let(:timezone_offset) { '0' }
|
||||
|
||||
before do
|
||||
account.update!(reporting_timezone: 'Etc/UTC')
|
||||
allow(account).to receive(:feature_enabled?).with(:report_rollup).and_return(true)
|
||||
end
|
||||
|
||||
it 'raises record not found to preserve raw path behavior' do
|
||||
expect { subject.aggregate_value }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'count metrics' do
|
||||
subject { described_class.new(account, params) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:account2) { create(:account) }
|
||||
let(:user) { create(:user, email: 'agent1@example.com') }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:inbox2) { create(:inbox, account: account2) }
|
||||
let(:current_time) { Time.current }
|
||||
|
||||
let(:params) do
|
||||
{
|
||||
type: 'agent',
|
||||
metric: 'resolutions_count',
|
||||
since: since_time.beginning_of_day.to_i.to_s,
|
||||
until: current_time.end_of_day.to_i.to_s,
|
||||
timezone: timezone,
|
||||
timezone_offset: timezone_offset,
|
||||
group_by: group_by,
|
||||
id: user.id.to_s
|
||||
}
|
||||
end
|
||||
let(:group_by) { 'day' }
|
||||
let(:since_time) { current_time - 1.day }
|
||||
let(:timezone) { nil }
|
||||
let(:timezone_offset) { nil }
|
||||
|
||||
before do
|
||||
travel_to current_time
|
||||
|
||||
create(:account_user, account: account, user: user)
|
||||
create(:account_user, account: account2, user: user)
|
||||
|
||||
conversation1 = create(:conversation, account: account, inbox: inbox, assignee: user)
|
||||
conversation2 = create(:conversation, account: account, inbox: inbox, assignee: user)
|
||||
|
||||
conversation3 = create(:conversation, account: account2, inbox: inbox2, assignee: user)
|
||||
conversation4 = create(:conversation, account: account2, inbox: inbox2, assignee: user)
|
||||
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account: account,
|
||||
user: user,
|
||||
conversation: conversation1,
|
||||
created_at: current_time - 12.hours)
|
||||
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account: account,
|
||||
user: user,
|
||||
conversation: conversation2,
|
||||
created_at: current_time - 6.hours)
|
||||
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account: account2,
|
||||
user: user,
|
||||
conversation: conversation3,
|
||||
created_at: current_time - 8.hours)
|
||||
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account: account2,
|
||||
user: user,
|
||||
conversation: conversation4,
|
||||
created_at: current_time - 4.hours)
|
||||
|
||||
conversation5 = create(:conversation, account: account2, inbox: inbox2, assignee: user)
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account: account2,
|
||||
user: user,
|
||||
conversation: conversation5,
|
||||
created_at: current_time - 2.hours)
|
||||
end
|
||||
|
||||
describe '#aggregate_value' do
|
||||
it 'returns only resolutions performed by the user in the specified account' do
|
||||
expect(subject.aggregate_value).to eq(2)
|
||||
end
|
||||
|
||||
context 'when rollups are enabled and the agent does not exist' do
|
||||
let(:timezone_offset) { '0' }
|
||||
|
||||
let(:params) do
|
||||
super().merge(id: '999999')
|
||||
end
|
||||
|
||||
before do
|
||||
account.update!(reporting_timezone: 'Etc/UTC')
|
||||
allow(account).to receive(:feature_enabled?).with(:report_rollup).and_return(true)
|
||||
end
|
||||
|
||||
it 'raises record not found to preserve raw path behavior' do
|
||||
expect { subject.aggregate_value }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when querying account2' do
|
||||
subject { described_class.new(account2, params) }
|
||||
|
||||
it 'returns only resolutions for account2' do
|
||||
expect(subject.aggregate_value).to eq(3)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#timeseries' do
|
||||
it 'filters resolutions by account' do
|
||||
result = subject.timeseries
|
||||
total_count = result.sum { |row| row[:value] }
|
||||
expect(total_count).to eq(2)
|
||||
end
|
||||
|
||||
context 'when rollups are enabled and grouped by week' do
|
||||
let(:group_by) { 'week' }
|
||||
let(:current_time) { Time.zone.parse('2020-10-26 10:00:00 UTC') }
|
||||
let(:since_time) { current_time - 1.week }
|
||||
let(:timezone_offset) { '5.5' }
|
||||
|
||||
before do
|
||||
account.update!(reporting_timezone: 'Chennai')
|
||||
allow(account).to receive(:feature_enabled?).with(:report_rollup).and_return(true)
|
||||
|
||||
create(:reporting_events_rollup,
|
||||
account: account,
|
||||
date: current_time.to_date - 1.day,
|
||||
dimension_type: 'agent',
|
||||
dimension_id: user.id,
|
||||
metric: 'resolutions_count',
|
||||
count: 1,
|
||||
sum_value: 0.0,
|
||||
sum_value_business_hours: 0.0)
|
||||
|
||||
create(:reporting_events_rollup,
|
||||
account: account,
|
||||
date: current_time.to_date,
|
||||
dimension_type: 'agent',
|
||||
dimension_id: user.id,
|
||||
metric: 'resolutions_count',
|
||||
count: 1,
|
||||
sum_value: 0.0,
|
||||
sum_value_business_hours: 0.0)
|
||||
end
|
||||
|
||||
it 'groups weeks using sunday boundaries' do
|
||||
expect(subject.timeseries).to eq(
|
||||
[
|
||||
{ value: 0, timestamp: (current_time - 1.week).in_time_zone('Chennai').beginning_of_week(:sunday).to_i },
|
||||
{ value: 2, timestamp: current_time.in_time_zone('Chennai').beginning_of_week(:sunday).to_i }
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'account isolation' do
|
||||
it 'does not leak data between accounts' do
|
||||
account1_count = described_class.new(account, params).aggregate_value
|
||||
account2_count = described_class.new(account2, params).aggregate_value
|
||||
|
||||
expect(account1_count).to eq(2)
|
||||
expect(account2_count).to eq(3)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -63,7 +63,11 @@ describe 'Markdown Embeds Configuration' do
|
||||
'bunny' => [
|
||||
{ url: 'https://iframe.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c',
|
||||
expected: { 'library_id' => '431789', 'video_id' => '1f105841-cad9-46fe-a70e-b7623c60797c' } },
|
||||
{ url: 'https://iframe.mediadelivery.net/play/12345/abcdef-ghijkl', expected: { 'library_id' => '12345', 'video_id' => 'abcdef-ghijkl' } }
|
||||
{ url: 'https://iframe.mediadelivery.net/play/12345/abcdef-ghijkl', expected: { 'library_id' => '12345', 'video_id' => 'abcdef-ghijkl' } },
|
||||
{ url: 'https://player.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c',
|
||||
expected: { 'library_id' => '431789', 'video_id' => '1f105841-cad9-46fe-a70e-b7623c60797c' } },
|
||||
{ url: 'https://iframe.mediadelivery.net/embed/256380/d9d9ab1f-fc9f-4488-9c26-4ffc653c0024',
|
||||
expected: { 'library_id' => '256380', 'video_id' => 'd9d9ab1f-fc9f-4488-9c26-4ffc653c0024' } }
|
||||
],
|
||||
'codepen' => [
|
||||
{ url: 'https://codepen.io/username/pen/abcdef', expected: { 'user' => 'username', 'pen_id' => 'abcdef' } },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -74,7 +74,7 @@ RSpec.describe SamlUserBuilder do
|
||||
end
|
||||
|
||||
context 'when user already exists' do
|
||||
let!(:existing_user) { create(:user, email: email) }
|
||||
let!(:existing_user) { create(:user, email: email, account: account) }
|
||||
|
||||
it 'does not create a new user' do
|
||||
expect { builder.perform }.not_to change(User, :count)
|
||||
@@ -85,7 +85,7 @@ RSpec.describe SamlUserBuilder do
|
||||
expect(user).to eq(existing_user)
|
||||
end
|
||||
|
||||
it 'adds existing user to the account if not already added' do
|
||||
it 'keeps the existing user in the account' do
|
||||
user = builder.perform
|
||||
expect(user.accounts).to include(account)
|
||||
end
|
||||
@@ -105,11 +105,38 @@ RSpec.describe SamlUserBuilder do
|
||||
end
|
||||
|
||||
it 'does not duplicate account association' do
|
||||
existing_user.account_users.create!(account: account, role: 'agent')
|
||||
|
||||
expect { builder.perform }.not_to change(AccountUser, :count)
|
||||
end
|
||||
|
||||
context 'when the user does not belong to the target account' do
|
||||
let!(:other_account) { create(:account) }
|
||||
let!(:existing_user) { create(:user, email: email, account: other_account) }
|
||||
|
||||
it 'raises an authentication failure' do
|
||||
expect { builder.perform }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SamlUserBuilder::AuthenticationFailed')
|
||||
expect(error.message).to eq(I18n.t('auth.saml.authentication_failed'))
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not add the user to the target account' do
|
||||
expect do
|
||||
builder.perform
|
||||
rescue SamlUserBuilder::AuthenticationFailed
|
||||
nil
|
||||
end.not_to change(AccountUser, :count)
|
||||
expect(existing_user.reload.accounts).not_to include(account)
|
||||
end
|
||||
|
||||
it 'does not convert the user provider to saml' do
|
||||
expect do
|
||||
builder.perform
|
||||
rescue SamlUserBuilder::AuthenticationFailed
|
||||
nil
|
||||
end.not_to(change { existing_user.reload.provider })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not confirmed' do
|
||||
let(:unconfirmed_email) { 'unconfirmed_saml_user@example.com' }
|
||||
let(:unconfirmed_auth_hash) do
|
||||
@@ -131,7 +158,7 @@ RSpec.describe SamlUserBuilder do
|
||||
end
|
||||
let(:unconfirmed_builder) { described_class.new(unconfirmed_auth_hash, account.id) }
|
||||
let!(:existing_user) do
|
||||
user = build(:user, email: unconfirmed_email)
|
||||
user = build(:user, email: unconfirmed_email, account: account)
|
||||
user.confirmed_at = nil
|
||||
user.save!(validate: false)
|
||||
user
|
||||
@@ -147,7 +174,7 @@ RSpec.describe SamlUserBuilder do
|
||||
end
|
||||
|
||||
context 'when user is already confirmed' do
|
||||
let!(:existing_user) { create(:user, email: email, confirmed_at: Time.current) }
|
||||
let!(:existing_user) { create(:user, email: email, account: account, confirmed_at: Time.current) }
|
||||
|
||||
it 'keeps already confirmed user confirmed' do
|
||||
expect(existing_user.confirmed?).to be true
|
||||
|
||||
+17
@@ -57,5 +57,22 @@ RSpec.describe 'Enterprise SAML OmniAuth Callbacks', type: :request do
|
||||
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects an existing user from another account' do
|
||||
with_modified_env FRONTEND_URL: 'http://www.example.com' do
|
||||
other_account = create(:account)
|
||||
existing_user = create(:user, email: 'existing@example.com', account: other_account, provider: 'email')
|
||||
set_saml_config('existing@example.com')
|
||||
|
||||
get "/omniauth/saml/callback?account_id=#{account.id}"
|
||||
|
||||
expect(response).to redirect_to('http://www.example.com/auth/saml/callback')
|
||||
follow_redirect!
|
||||
|
||||
expect(response).to redirect_to('http://www.example.com/app/login?error=saml-authentication-failed')
|
||||
expect(existing_user.reload.provider).to eq('email')
|
||||
expect(existing_user.accounts).not_to include(account)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -84,6 +84,16 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('pending')
|
||||
expect(resolvable_pending_conversation.messages.outgoing).to be_empty
|
||||
end
|
||||
|
||||
it 'falls back to legacy time-based resolve when legacy auto-resolve is forced' do
|
||||
inbox.account.update!(captain_auto_resolve_mode: 'legacy')
|
||||
allow(Captain::ConversationCompletionService).to receive(:new)
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(Captain::ConversationCompletionService).not_to have_received(:new)
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when LLM evaluation returns complete' do
|
||||
@@ -322,7 +332,7 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
|
||||
end
|
||||
|
||||
it 'does not resolve conversations when auto-resolve is disabled at execution time' do
|
||||
inbox.account.update!(captain_disable_auto_resolve: true)
|
||||
inbox.account.update!(captain_auto_resolve_mode: 'disabled')
|
||||
|
||||
expect do
|
||||
described_class.perform_now(inbox)
|
||||
@@ -331,4 +341,14 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('pending')
|
||||
expect(resolvable_pending_conversation.messages.outgoing).to be_empty
|
||||
end
|
||||
|
||||
it 'falls back to disabled mode from legacy settings key' do
|
||||
inbox.account.update!(settings: inbox.account.settings.merge('captain_disable_auto_resolve' => true))
|
||||
|
||||
expect do
|
||||
described_class.perform_now(inbox)
|
||||
end.not_to(change { resolvable_pending_conversation.reload.status })
|
||||
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('pending')
|
||||
end
|
||||
end
|
||||
|
||||
+18
-2
@@ -30,12 +30,28 @@ RSpec.describe Account::ConversationsResolutionSchedulerJob, type: :job do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account has captain_disable_auto_resolve enabled' do
|
||||
context 'when account has captain auto resolve disabled' do
|
||||
let!(:regular_inbox) { create(:inbox, account: account) }
|
||||
|
||||
before do
|
||||
create(:captain_inbox, captain_assistant: assistant, inbox: regular_inbox)
|
||||
account.update!(captain_disable_auto_resolve: true)
|
||||
account.update!(captain_auto_resolve_mode: 'disabled')
|
||||
end
|
||||
|
||||
it 'does not enqueue resolution jobs' do
|
||||
expect do
|
||||
described_class.perform_now
|
||||
end.not_to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob)
|
||||
.with(regular_inbox)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account uses legacy disabled settings key' do
|
||||
let!(:regular_inbox) { create(:inbox, account: account) }
|
||||
|
||||
before do
|
||||
create(:captain_inbox, captain_assistant: assistant, inbox: regular_inbox)
|
||||
account.update!(settings: account.settings.merge('captain_disable_auto_resolve' => true))
|
||||
end
|
||||
|
||||
it 'does not enqueue resolution jobs' do
|
||||
|
||||
@@ -126,6 +126,33 @@ RSpec.describe Captain::ConversationCompletionService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account has its own OpenAI hook' do
|
||||
before do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Hello')
|
||||
create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'customer-own-key' })
|
||||
end
|
||||
|
||||
it 'uses the system API key instead of the account hook key' do
|
||||
expect(Llm::Config).to receive(:with_api_key).with('test-key', api_base: anything).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:ask).and_return(
|
||||
instance_double(RubyLLM::Message, content: { 'complete' => true, 'reason' => 'Done' }, input_tokens: 10, output_tokens: 5)
|
||||
)
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'falls back to the account hook key when no system key exists' do
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY').update!(value: nil)
|
||||
|
||||
expect(Llm::Config).to receive(:with_api_key).with('customer-own-key', api_base: anything).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:ask).and_return(
|
||||
instance_double(RubyLLM::Message, content: { 'complete' => true, 'reason' => 'Done' }, input_tokens: 10, output_tokens: 5)
|
||||
)
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when customer quota is exhausted' do
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
|
||||
@@ -41,7 +41,18 @@ RSpec.describe Captain::Tools::ResolveConversationTool do
|
||||
end
|
||||
|
||||
describe 'when auto-resolve is disabled for the account' do
|
||||
before { account.update!(captain_disable_auto_resolve: true) }
|
||||
before { account.update!(captain_auto_resolve_mode: 'disabled') }
|
||||
|
||||
it 'does not resolve and returns a disabled message' do
|
||||
result = tool.perform(tool_context, reason: 'Possible spam')
|
||||
|
||||
expect(result).to eq('Auto-resolve is disabled for this account')
|
||||
expect(conversation.reload).not_to be_resolved
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when auto-resolve is disabled via legacy settings key' do
|
||||
before { account.update!(settings: account.settings.merge('captain_disable_auto_resolve' => true)) }
|
||||
|
||||
it 'does not resolve and returns a disabled message' do
|
||||
result = tool.perform(tool_context, reason: 'Possible spam')
|
||||
|
||||
@@ -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
|
||||
@@ -184,12 +184,12 @@ describe CustomMarkdownRenderer do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link is a Bunny.net URL' do
|
||||
context 'when link is a Bunny.net iframe URL' do
|
||||
let(:bunny_url) { 'https://iframe.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c' }
|
||||
|
||||
it 'renders an iframe with Bunny embed code' do
|
||||
output = render_markdown_link(bunny_url)
|
||||
expect(output).to include('src="https://iframe.mediadelivery.net/embed/431789/1f105841-cad9-46fe-a70e-b7623c60797c?autoplay=false&loop=false&muted=false&preload=true&responsive=true"')
|
||||
expect(output).to include('src="https://player.mediadelivery.net/embed/431789/1f105841-cad9-46fe-a70e-b7623c60797c?autoplay=false&loop=false&muted=false&preload=true&responsive=true"')
|
||||
expect(output).to include('allowfullscreen')
|
||||
expect(output).to include('allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"')
|
||||
end
|
||||
@@ -200,5 +200,17 @@ describe CustomMarkdownRenderer do
|
||||
expect(output).to include('position: absolute; top: 0; height: 100%; width: 100%;')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link is a Bunny.net player URL' do
|
||||
let(:bunny_url) { 'https://player.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c' }
|
||||
|
||||
it 'renders an iframe with Bunny embed code' do
|
||||
output = render_markdown_link(bunny_url)
|
||||
expect(output).to include('embed/431789/1f105841-cad9-46fe-a70e-b7623c60797c')
|
||||
expect(output).to include('autoplay=false&loop=false&muted=false&preload=true&responsive=true')
|
||||
expect(output).to include('allowfullscreen')
|
||||
expect(output).to include('allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
require 'rails_helper'
|
||||
require 'rake'
|
||||
|
||||
Rails.application.load_tasks unless Object.const_defined?(:ReportingEventsRollupBackfill)
|
||||
|
||||
describe ReportingEventsRollupBackfill do
|
||||
let(:service) { described_class.new }
|
||||
let(:account) { create(:account, reporting_timezone: 'America/New_York') }
|
||||
let(:date) { Date.new(2026, 2, 11) }
|
||||
|
||||
describe '#execute_backfill' do
|
||||
before do
|
||||
allow(ReportingEvents::BackfillService).to receive(:backfill_date)
|
||||
allow($stdout).to receive(:flush)
|
||||
end
|
||||
|
||||
it 'prompts to enable the read path only after a successful backfill' do
|
||||
allow(service).to receive(:print_success)
|
||||
|
||||
expect(service).to receive(:prompt_enable_rollup_read_path).with(account)
|
||||
|
||||
service.send(:execute_backfill, account, date, date, 1)
|
||||
end
|
||||
|
||||
it 'does not prompt to enable the read path when backfill fails' do
|
||||
allow(ReportingEvents::BackfillService).to receive(:backfill_date).and_raise(StandardError, 'boom')
|
||||
|
||||
expect(service).not_to receive(:prompt_enable_rollup_read_path)
|
||||
|
||||
expect do
|
||||
service.send(:execute_backfill, account, date, date, 1)
|
||||
end.to raise_error(SystemExit)
|
||||
end
|
||||
|
||||
it 'does not report the backfill as failed when enabling the read path fails' do
|
||||
allow(service).to receive(:print_success)
|
||||
allow(service).to receive(:print_failure)
|
||||
allow(service).to receive(:prompt_enable_rollup_read_path).and_raise(StandardError, 'toggle failed')
|
||||
|
||||
expect do
|
||||
service.send(:execute_backfill, account, date, date, 1)
|
||||
end.to raise_error(StandardError, 'toggle failed')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#prompt_enable_rollup_read_path' do
|
||||
it 'enables the feature flag when the user confirms' do
|
||||
allow($stdin).to receive(:gets).and_return("y\n")
|
||||
|
||||
expect(account).to receive(:enable_features!).with(:report_rollup)
|
||||
|
||||
service.send(:prompt_enable_rollup_read_path, account)
|
||||
end
|
||||
|
||||
it 'does not enable the feature flag when the user declines' do
|
||||
allow($stdin).to receive(:gets).and_return("n\n")
|
||||
|
||||
expect(account).not_to receive(:enable_features!)
|
||||
|
||||
service.send(:prompt_enable_rollup_read_path, account)
|
||||
end
|
||||
|
||||
it 'skips the prompt when the feature is already enabled' do
|
||||
allow(account).to receive(:feature_enabled?).with(:report_rollup).and_return(true)
|
||||
|
||||
expect($stdin).not_to receive(:gets)
|
||||
|
||||
service.send(:prompt_enable_rollup_read_path, account)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -18,6 +18,23 @@ describe ReportingEventListener do
|
||||
expect(account.reporting_events.where(name: 'conversation_resolved').count).to be 1
|
||||
end
|
||||
|
||||
context 'when rollup creation fails' do
|
||||
let(:event) { Events::Base.new('conversation.resolved', Time.zone.now, conversation: conversation) }
|
||||
let(:error) { StandardError.new('rollup failed') }
|
||||
let(:exception_tracker) { instance_double(ChatwootExceptionTracker, capture_exception: true) }
|
||||
|
||||
before do
|
||||
allow(ReportingEvents::RollupService).to receive(:perform).and_raise(error)
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
|
||||
end
|
||||
|
||||
it 'captures the error without interrupting raw event creation' do
|
||||
expect { listener.conversation_resolved(event) }.not_to raise_error
|
||||
expect(ChatwootExceptionTracker).to have_received(:new).with(error, account: account)
|
||||
expect(account.reporting_events.where(name: 'conversation_resolved').count).to be 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'when business hours enabled for inbox' do
|
||||
let(:created_at) { Time.zone.parse('March 20, 2022 00:00') }
|
||||
let(:updated_at) { Time.zone.parse('March 26, 2022 23:59') }
|
||||
|
||||
@@ -198,6 +198,44 @@ RSpec.describe Account do
|
||||
expect(account.settings['auto_resolve_message']).to eq(message)
|
||||
end
|
||||
|
||||
it 'defaults captain_auto_resolve_mode to legacy when captain_tasks is disabled' do
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
|
||||
|
||||
expect(account.captain_auto_resolve_mode).to eq('legacy')
|
||||
expect(account).to be_captain_auto_resolve_legacy
|
||||
end
|
||||
|
||||
it 'defaults captain_auto_resolve_mode to evaluated when captain_tasks is enabled' do
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
|
||||
expect(account.captain_auto_resolve_mode).to eq('evaluated')
|
||||
expect(account).to be_captain_auto_resolve_evaluated
|
||||
end
|
||||
|
||||
it 'correctly gets and sets captain_auto_resolve_mode' do
|
||||
account.captain_auto_resolve_mode = 'legacy'
|
||||
|
||||
expect(account.captain_auto_resolve_mode).to eq('legacy')
|
||||
expect(account.settings['captain_auto_resolve_mode']).to eq('legacy')
|
||||
expect(account).to be_captain_auto_resolve_legacy
|
||||
end
|
||||
|
||||
it 'allows clearing captain_auto_resolve_mode to fall back to feature defaults' do
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
|
||||
account.captain_auto_resolve_mode = nil
|
||||
|
||||
expect(account).to be_valid
|
||||
expect(account.captain_auto_resolve_mode).to eq('legacy')
|
||||
expect(account.settings['captain_auto_resolve_mode']).to be_nil
|
||||
end
|
||||
|
||||
it 'falls back to disabled mode from legacy settings key' do
|
||||
account.settings = { 'captain_disable_auto_resolve' => true }
|
||||
|
||||
expect(account.captain_auto_resolve_mode).to eq('disabled')
|
||||
expect(account).to be_captain_auto_resolve_disabled
|
||||
end
|
||||
|
||||
it 'handles nil values correctly' do
|
||||
account.auto_resolve_after = nil
|
||||
account.auto_resolve_message = nil
|
||||
@@ -217,6 +255,21 @@ RSpec.describe Account do
|
||||
expect(described_class.with_auto_resolve.pluck(:id)).not_to include(account.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when reporting_timezone is set' do
|
||||
it 'allows valid timezone names' do
|
||||
account.reporting_timezone = 'America/New_York'
|
||||
|
||||
expect(account).to be_valid
|
||||
end
|
||||
|
||||
it 'rejects invalid timezone names' do
|
||||
account.reporting_timezone = 'Invalid/Timezone'
|
||||
|
||||
expect(account).not_to be_valid
|
||||
expect(account.errors[:reporting_timezone]).to include(I18n.t('errors.account.reporting_timezone.invalid'))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'captain_preferences' do
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ReportingEventsRollup do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
end
|
||||
|
||||
describe 'enums' do
|
||||
describe 'dimension_type enum' do
|
||||
it 'stores dimension_type as string values' do
|
||||
rollup = build(:reporting_events_rollup, dimension_type: 'account')
|
||||
expect(rollup.dimension_type).to eq('account')
|
||||
end
|
||||
|
||||
it 'has account dimension type' do
|
||||
rollup = build(:reporting_events_rollup, dimension_type: 'account')
|
||||
expect(rollup.account?).to be true
|
||||
end
|
||||
|
||||
it 'has agent dimension type' do
|
||||
rollup = build(:reporting_events_rollup, dimension_type: 'agent')
|
||||
expect(rollup.agent?).to be true
|
||||
end
|
||||
|
||||
it 'has inbox dimension type' do
|
||||
rollup = build(:reporting_events_rollup, dimension_type: 'inbox')
|
||||
expect(rollup.inbox?).to be true
|
||||
end
|
||||
|
||||
it 'has team dimension type' do
|
||||
rollup = build(:reporting_events_rollup, dimension_type: 'team')
|
||||
expect(rollup.team?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'metric enum' do
|
||||
it 'stores metric as string values' do
|
||||
rollup = build(:reporting_events_rollup, metric: 'first_response')
|
||||
expect(rollup.metric).to eq('first_response')
|
||||
end
|
||||
|
||||
it 'has resolutions_count metric' do
|
||||
rollup = build(:reporting_events_rollup, metric: 'resolutions_count')
|
||||
expect(rollup.resolutions_count?).to be true
|
||||
end
|
||||
|
||||
it 'has first_response metric' do
|
||||
rollup = build(:reporting_events_rollup, metric: 'first_response')
|
||||
expect(rollup.first_response?).to be true
|
||||
end
|
||||
|
||||
it 'has resolution_time metric' do
|
||||
rollup = build(:reporting_events_rollup, metric: 'resolution_time')
|
||||
expect(rollup.resolution_time?).to be true
|
||||
end
|
||||
|
||||
it 'has reply_time metric' do
|
||||
rollup = build(:reporting_events_rollup, metric: 'reply_time')
|
||||
expect(rollup.reply_time?).to be true
|
||||
end
|
||||
|
||||
it 'has bot_resolutions_count metric' do
|
||||
rollup = build(:reporting_events_rollup, metric: 'bot_resolutions_count')
|
||||
expect(rollup.bot_resolutions_count?).to be true
|
||||
end
|
||||
|
||||
it 'has bot_handoffs_count metric' do
|
||||
rollup = build(:reporting_events_rollup, metric: 'bot_handoffs_count')
|
||||
expect(rollup.bot_handoffs_count?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:account_id) }
|
||||
it { is_expected.to validate_presence_of(:date) }
|
||||
it { is_expected.to validate_presence_of(:dimension_type) }
|
||||
it { is_expected.to validate_presence_of(:dimension_id) }
|
||||
it { is_expected.to validate_presence_of(:metric) }
|
||||
it { is_expected.to validate_numericality_of(:count).is_greater_than_or_equal_to(0) }
|
||||
end
|
||||
|
||||
describe 'scopes' do
|
||||
let(:account) { create(:account) }
|
||||
let(:other_account) { create(:account) }
|
||||
|
||||
describe '.for_date_range' do
|
||||
it 'filters by date range' do
|
||||
create(:reporting_events_rollup, account: account, date: '2026-02-08'.to_date)
|
||||
create(:reporting_events_rollup, account: account, date: '2026-02-10'.to_date)
|
||||
create(:reporting_events_rollup, account: account, date: '2026-02-12'.to_date)
|
||||
|
||||
results = described_class.for_date_range('2026-02-10'.to_date, '2026-02-11'.to_date)
|
||||
|
||||
expect(results.count).to eq(1)
|
||||
expect(results.first.date).to eq('2026-02-10'.to_date)
|
||||
end
|
||||
|
||||
it 'returns empty array when no records match' do
|
||||
create(:reporting_events_rollup, account: account, date: '2026-02-08'.to_date)
|
||||
|
||||
results = described_class.for_date_range('2026-02-20'.to_date, '2026-02-25'.to_date)
|
||||
|
||||
expect(results).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
describe '.for_dimension' do
|
||||
it 'filters by dimension type and id' do
|
||||
create(:reporting_events_rollup, account: account, dimension_type: 'account', dimension_id: 1)
|
||||
create(:reporting_events_rollup, account: account, dimension_type: 'agent', dimension_id: 1)
|
||||
create(:reporting_events_rollup, account: account, dimension_type: 'agent', dimension_id: 2)
|
||||
|
||||
results = described_class.for_dimension('agent', 1)
|
||||
|
||||
expect(results.count).to eq(1)
|
||||
expect(results.first.dimension_type).to eq('agent')
|
||||
expect(results.first.dimension_id).to eq(1)
|
||||
end
|
||||
|
||||
it 'returns empty array when no records match' do
|
||||
create(:reporting_events_rollup, account: account, dimension_type: 'account', dimension_id: 1)
|
||||
|
||||
results = described_class.for_dimension('agent', 1)
|
||||
|
||||
expect(results).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
describe '.for_metric' do
|
||||
it 'filters by metric' do
|
||||
create(:reporting_events_rollup, account: account, metric: 'first_response', dimension_id: 1)
|
||||
create(:reporting_events_rollup, account: account, metric: 'resolution_time', dimension_id: 2)
|
||||
create(:reporting_events_rollup, account: account, metric: 'first_response', dimension_id: 3)
|
||||
|
||||
results = described_class.for_metric('first_response')
|
||||
|
||||
expect(results.count).to eq(2)
|
||||
expect(results.all? { |r| r.metric == 'first_response' }).to be true
|
||||
end
|
||||
|
||||
it 'returns empty array when no records match' do
|
||||
create(:reporting_events_rollup, account: account, metric: 'first_response')
|
||||
|
||||
results = described_class.for_metric('resolution_time')
|
||||
|
||||
expect(results).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'database schema' do
|
||||
let(:account) { create(:account) }
|
||||
let(:rollup) do
|
||||
create(:reporting_events_rollup,
|
||||
account: account,
|
||||
date: '2026-02-10'.to_date,
|
||||
dimension_type: 'account',
|
||||
metric: 'first_response')
|
||||
end
|
||||
|
||||
it 'has all required columns' do
|
||||
expect(rollup).to have_attributes(
|
||||
account_id: account.id,
|
||||
date: '2026-02-10'.to_date,
|
||||
dimension_type: 'account',
|
||||
dimension_id: 1,
|
||||
metric: 'first_response',
|
||||
count: 1,
|
||||
sum_value: 100.0,
|
||||
sum_value_business_hours: 50.0
|
||||
)
|
||||
end
|
||||
|
||||
it 'stores enum values as strings in database' do
|
||||
rollup
|
||||
db_record = described_class.find(rollup.id)
|
||||
expect(db_record.dimension_type).to eq('account')
|
||||
expect(db_record.metric).to eq('first_response')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,207 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe ReportingEvents::BackfillService do
|
||||
describe '.backfill_date' do
|
||||
let(:account) { create(:account, reporting_timezone: 'America/New_York') }
|
||||
let(:date) { Date.new(2026, 2, 11) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
|
||||
|
||||
it 'treats nil metric values as zero during backfill' do
|
||||
reporting_event = create_backfill_event(
|
||||
name: 'first_response', value: 100, value_in_business_hours: 50,
|
||||
user: user, inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 11, 15)
|
||||
)
|
||||
# Simulate a legacy row that already exists in the database with nil metrics.
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
reporting_event.update_columns(value: nil, value_in_business_hours: nil)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
|
||||
expect { described_class.backfill_date(account, date) }.not_to raise_error
|
||||
|
||||
rollup = find_rollup('account', account.id, 'first_response')
|
||||
expect(rollup.count).to eq(1)
|
||||
expect(rollup.sum_value).to eq(0)
|
||||
expect(rollup.sum_value_business_hours).to eq(0)
|
||||
end
|
||||
|
||||
context 'when replacing rows fails atomically' do
|
||||
before do
|
||||
create(
|
||||
:reporting_events_rollup,
|
||||
account: account, date: date, dimension_type: 'account', dimension_id: account.id,
|
||||
metric: 'first_response', count: 7, sum_value: 700, sum_value_business_hours: 350
|
||||
)
|
||||
end
|
||||
|
||||
it 'preserves existing rollups when building replacement rows fails' do
|
||||
service = described_class.new(account, date)
|
||||
allow(service).to receive(:build_rollup_rows).and_raise(StandardError, 'boom')
|
||||
|
||||
expect { service.perform }.to raise_error(StandardError, 'boom')
|
||||
|
||||
rollup = find_rollup('account', account.id, 'first_response')
|
||||
expect(rollup.count).to eq(7)
|
||||
expect(rollup.sum_value).to eq(700)
|
||||
expect(rollup.sum_value_business_hours).to eq(350)
|
||||
end
|
||||
|
||||
it 'preserves existing rollups when bulk insert fails' do
|
||||
create_backfill_event(
|
||||
name: 'first_response', value: 100, value_in_business_hours: 50,
|
||||
user: user, inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 11, 15)
|
||||
)
|
||||
|
||||
service = described_class.new(account, date)
|
||||
allow(service).to receive(:bulk_insert_rollups).and_raise(StandardError, 'boom')
|
||||
|
||||
expect { service.perform }.to raise_error(StandardError, 'boom')
|
||||
|
||||
rollup = find_rollup('account', account.id, 'first_response')
|
||||
expect(rollup.count).to eq(7)
|
||||
expect(rollup.sum_value).to eq(700)
|
||||
expect(rollup.sum_value_business_hours).to eq(350)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when aggregating grouped rows' do
|
||||
let(:second_user) { create(:user, account: account) }
|
||||
let(:second_inbox) { create(:inbox, account: account) }
|
||||
let(:second_conversation) { create(:conversation, account: account, inbox: second_inbox, assignee: second_user) }
|
||||
|
||||
before do
|
||||
create_backfill_event(name: 'first_response', value: 100, value_in_business_hours: 60, user: user,
|
||||
inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 11, 14))
|
||||
create_backfill_event(name: 'first_response', value: 40, value_in_business_hours: 20, user: user,
|
||||
inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 11, 15))
|
||||
create_backfill_event(name: 'conversation_resolved', value: 200, value_in_business_hours: 80, user: second_user,
|
||||
inbox: second_inbox, conversation: second_conversation, created_at: Time.utc(2026, 2, 11, 16))
|
||||
create_backfill_event(name: 'reply_time', value: 500, value_in_business_hours: 300, user: user,
|
||||
inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 12, 5))
|
||||
described_class.backfill_date(account, date)
|
||||
end
|
||||
|
||||
it 'does not instantiate reporting events' do
|
||||
reporting_event_instantiations = count_reporting_event_instantiations do
|
||||
described_class.backfill_date(account, date)
|
||||
end
|
||||
|
||||
expect(reporting_event_instantiations).to eq(0)
|
||||
end
|
||||
|
||||
it 'creates the expected number of rollup rows' do
|
||||
rollups = ReportingEventsRollup.where(account_id: account.id, date: date)
|
||||
# 3 dimensions × first_response + 3 dimensions × resolutions_count + 3 dimensions × resolution_time
|
||||
expect(rollups.count).to eq(9)
|
||||
end
|
||||
|
||||
it 'aggregates first_response at the account dimension' do
|
||||
account_first_response = find_rollup('account', account.id, 'first_response')
|
||||
expect(account_first_response.count).to eq(2)
|
||||
expect(account_first_response.sum_value).to eq(140)
|
||||
expect(account_first_response.sum_value_business_hours).to eq(80)
|
||||
end
|
||||
|
||||
it 'aggregates first_response at the agent dimension' do
|
||||
agent_first_response = find_rollup('agent', user.id, 'first_response')
|
||||
expect(agent_first_response.count).to eq(2)
|
||||
expect(agent_first_response.sum_value).to eq(140)
|
||||
expect(agent_first_response.sum_value_business_hours).to eq(80)
|
||||
end
|
||||
|
||||
it 'aggregates resolution_time at the agent dimension' do
|
||||
agent_resolution_time = find_rollup('agent', second_user.id, 'resolution_time')
|
||||
expect(agent_resolution_time.count).to eq(1)
|
||||
expect(agent_resolution_time.sum_value).to eq(200)
|
||||
expect(agent_resolution_time.sum_value_business_hours).to eq(80)
|
||||
end
|
||||
|
||||
it 'aggregates first_response at the inbox dimension' do
|
||||
inbox_first_response = find_rollup('inbox', inbox.id, 'first_response')
|
||||
expect(inbox_first_response.count).to eq(2)
|
||||
expect(inbox_first_response.sum_value).to eq(140)
|
||||
expect(inbox_first_response.sum_value_business_hours).to eq(80)
|
||||
end
|
||||
|
||||
it 'aggregates resolution_time at the inbox dimension' do
|
||||
inbox_resolution_time = find_rollup('inbox', second_inbox.id, 'resolution_time')
|
||||
expect(inbox_resolution_time.count).to eq(1)
|
||||
expect(inbox_resolution_time.sum_value).to eq(200)
|
||||
expect(inbox_resolution_time.sum_value_business_hours).to eq(80)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when deduplicating distinct-count events' do
|
||||
let(:second_user) { create(:user, account: account) }
|
||||
let(:second_inbox) { create(:inbox, account: account) }
|
||||
let(:conversation_b) { create(:conversation, account: account, inbox: inbox, assignee: user) }
|
||||
let(:conversation_c) { create(:conversation, account: account, inbox: second_inbox, assignee: second_user) }
|
||||
|
||||
before do
|
||||
# Two events for the same conversation — should count as 1
|
||||
create_backfill_event(name: 'conversation_bot_handoff', value: 0, value_in_business_hours: 0, user: user,
|
||||
inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 11, 14))
|
||||
create_backfill_event(name: 'conversation_bot_handoff', value: 0, value_in_business_hours: 0, user: user,
|
||||
inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 11, 15))
|
||||
# Different conversation, same agent/inbox
|
||||
create_backfill_event(name: 'conversation_bot_handoff', value: 0, value_in_business_hours: 0, user: user,
|
||||
inbox: inbox, conversation: conversation_b, created_at: Time.utc(2026, 2, 11, 16))
|
||||
# Different agent/inbox
|
||||
create_backfill_event(name: 'conversation_bot_handoff', value: 0, value_in_business_hours: 0, user: second_user,
|
||||
inbox: second_inbox, conversation: conversation_c, created_at: Time.utc(2026, 2, 11, 17))
|
||||
described_class.backfill_date(account, date)
|
||||
end
|
||||
|
||||
it 'creates the expected number of rollup rows' do
|
||||
rollups = ReportingEventsRollup.where(account_id: account.id, date: date)
|
||||
expect(rollups.count).to eq(5)
|
||||
end
|
||||
|
||||
it 'counts 3 distinct conversations at the account dimension' do
|
||||
expect(find_rollup('account', account.id, 'bot_handoffs_count').count).to eq(3)
|
||||
end
|
||||
|
||||
it 'counts distinct conversations per agent' do
|
||||
expect(find_rollup('agent', user.id, 'bot_handoffs_count').count).to eq(2)
|
||||
expect(find_rollup('agent', second_user.id, 'bot_handoffs_count').count).to eq(1)
|
||||
end
|
||||
|
||||
it 'counts distinct conversations per inbox' do
|
||||
expect(find_rollup('inbox', inbox.id, 'bot_handoffs_count').count).to eq(2)
|
||||
expect(find_rollup('inbox', second_inbox.id, 'bot_handoffs_count').count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
def create_backfill_event(**attributes)
|
||||
create(
|
||||
:reporting_event,
|
||||
account: account,
|
||||
**attributes
|
||||
)
|
||||
end
|
||||
|
||||
def find_rollup(dimension_type, dimension_id, metric)
|
||||
ReportingEventsRollup.find_by!(
|
||||
account_id: account.id,
|
||||
date: date,
|
||||
dimension_type: dimension_type,
|
||||
dimension_id: dimension_id,
|
||||
metric: metric
|
||||
)
|
||||
end
|
||||
|
||||
def count_reporting_event_instantiations(&)
|
||||
instantiation_count = 0
|
||||
subscriber = lambda do |_name, _start, _finish, _id, payload|
|
||||
next unless payload[:class_name] == 'ReportingEvent'
|
||||
|
||||
instantiation_count += payload[:record_count]
|
||||
end
|
||||
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'instantiation.active_record', &)
|
||||
|
||||
instantiation_count
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,125 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ReportingEvents::EventMetricRegistry do
|
||||
describe '.event_names' do
|
||||
it 'returns the supported raw event names' do
|
||||
expect(described_class.event_names).to eq(
|
||||
%w[
|
||||
conversation_resolved
|
||||
first_response
|
||||
reply_time
|
||||
conversation_bot_resolved
|
||||
conversation_bot_handoff
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.metrics_for' do
|
||||
it 'returns the emitted rollup metrics for conversation_resolved' do
|
||||
event = instance_double(ReportingEvent, name: 'conversation_resolved', value: 120, value_in_business_hours: 45)
|
||||
|
||||
expect(described_class.metrics_for(event)).to eq(
|
||||
resolutions_count: {
|
||||
count: 1,
|
||||
sum_value: 0,
|
||||
sum_value_business_hours: 0
|
||||
},
|
||||
resolution_time: {
|
||||
count: 1,
|
||||
sum_value: 120.0,
|
||||
sum_value_business_hours: 45.0
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the emitted rollup metrics for first_response' do
|
||||
event = instance_double(ReportingEvent, name: 'first_response', value: 80, value_in_business_hours: 20)
|
||||
|
||||
expect(described_class.metrics_for(event)).to eq(
|
||||
first_response: {
|
||||
count: 1,
|
||||
sum_value: 80.0,
|
||||
sum_value_business_hours: 20.0
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the emitted rollup metrics for reply_time' do
|
||||
event = instance_double(ReportingEvent, name: 'reply_time', value: 40, value_in_business_hours: 15)
|
||||
|
||||
expect(described_class.metrics_for(event)).to eq(
|
||||
reply_time: {
|
||||
count: 1,
|
||||
sum_value: 40.0,
|
||||
sum_value_business_hours: 15.0
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the emitted rollup metrics for conversation_bot_resolved' do
|
||||
event = instance_double(ReportingEvent, name: 'conversation_bot_resolved')
|
||||
|
||||
expect(described_class.metrics_for(event)).to eq(
|
||||
bot_resolutions_count: {
|
||||
count: 1,
|
||||
sum_value: 0,
|
||||
sum_value_business_hours: 0
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the emitted rollup metrics for conversation_bot_handoff' do
|
||||
event = instance_double(ReportingEvent, name: 'conversation_bot_handoff')
|
||||
|
||||
expect(described_class.metrics_for(event)).to eq(
|
||||
bot_handoffs_count: {
|
||||
count: 1,
|
||||
sum_value: 0,
|
||||
sum_value_business_hours: 0
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns an empty hash for unsupported events' do
|
||||
event = instance_double(ReportingEvent, name: 'conversation_created')
|
||||
|
||||
expect(described_class.metrics_for(event)).to eq({})
|
||||
end
|
||||
end
|
||||
|
||||
describe '.metrics_for_aggregate' do
|
||||
it 'returns aggregated rollup metrics for conversation_resolved groups' do
|
||||
expect(
|
||||
described_class.metrics_for_aggregate(
|
||||
'conversation_resolved',
|
||||
count: 3,
|
||||
sum_value: 420,
|
||||
sum_value_business_hours: 210
|
||||
)
|
||||
).to eq(
|
||||
resolutions_count: {
|
||||
count: 3,
|
||||
sum_value: 0,
|
||||
sum_value_business_hours: 0
|
||||
},
|
||||
resolution_time: {
|
||||
count: 3,
|
||||
sum_value: 420.0,
|
||||
sum_value_business_hours: 210.0
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns an empty hash for unsupported grouped events' do
|
||||
expect(
|
||||
described_class.metrics_for_aggregate(
|
||||
'conversation_created',
|
||||
count: 2,
|
||||
sum_value: 100,
|
||||
sum_value_business_hours: 50
|
||||
)
|
||||
).to eq({})
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,446 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe ReportingEvents::RollupService do
|
||||
describe '.perform' do
|
||||
let(:account) { create(:account, reporting_timezone: 'America/New_York') }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
|
||||
|
||||
context 'when reporting_timezone is not set' do
|
||||
before { account.update!(reporting_timezone: nil) }
|
||||
|
||||
it 'skips rollup creation' do
|
||||
reporting_event = create(:reporting_event,
|
||||
account: account,
|
||||
name: 'conversation_resolved',
|
||||
conversation: conversation)
|
||||
|
||||
expect do
|
||||
described_class.perform(reporting_event)
|
||||
end.not_to change(ReportingEventsRollup, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when reporting_timezone is invalid' do
|
||||
it 'skips rollup creation' do
|
||||
reporting_event = create(:reporting_event,
|
||||
account: account,
|
||||
name: 'conversation_resolved',
|
||||
conversation: conversation)
|
||||
allow(account).to receive(:reporting_timezone).and_return('Invalid/Zone')
|
||||
|
||||
expect do
|
||||
described_class.perform(reporting_event)
|
||||
end.not_to change(ReportingEventsRollup, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when reporting_timezone is set' do
|
||||
describe 'conversation_resolved event' do
|
||||
let(:rollup_event_created_at) { Time.utc(2026, 2, 12, 4, 0, 0) }
|
||||
let(:rollup_write_time) { Time.utc(2026, 2, 12, 10, 0, 0) }
|
||||
let(:reporting_event) do
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
name: 'conversation_resolved',
|
||||
value: 1000,
|
||||
value_in_business_hours: 500,
|
||||
user: user,
|
||||
inbox: inbox,
|
||||
conversation: conversation)
|
||||
end
|
||||
let(:expected_upsert_options) do
|
||||
{
|
||||
unique_by: %i[account_id date dimension_type dimension_id metric],
|
||||
on_duplicate: 'count = reporting_events_rollups.count + EXCLUDED.count, ' \
|
||||
'sum_value = reporting_events_rollups.sum_value + EXCLUDED.sum_value, ' \
|
||||
'sum_value_business_hours = reporting_events_rollups.sum_value_business_hours + EXCLUDED.sum_value_business_hours, ' \
|
||||
'updated_at = EXCLUDED.updated_at'
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates rollup rows for account, agent, and inbox' do
|
||||
described_class.perform(reporting_event)
|
||||
|
||||
# Account dimension
|
||||
account_row = ReportingEventsRollup.find_by(
|
||||
account_id: account.id,
|
||||
dimension_type: 'account',
|
||||
dimension_id: account.id,
|
||||
metric: 'resolutions_count'
|
||||
)
|
||||
expect(account_row).to be_present
|
||||
|
||||
# Agent dimension
|
||||
agent_row = ReportingEventsRollup.find_by(
|
||||
account_id: account.id,
|
||||
dimension_type: 'agent',
|
||||
dimension_id: user.id,
|
||||
metric: 'resolutions_count'
|
||||
)
|
||||
expect(agent_row).to be_present
|
||||
|
||||
# Inbox dimension
|
||||
inbox_row = ReportingEventsRollup.find_by(
|
||||
account_id: account.id,
|
||||
dimension_type: 'inbox',
|
||||
dimension_id: inbox.id,
|
||||
metric: 'resolutions_count'
|
||||
)
|
||||
expect(inbox_row).to be_present
|
||||
end
|
||||
|
||||
it 'creates correct metrics for conversation_resolved' do
|
||||
described_class.perform(reporting_event)
|
||||
|
||||
resolutions_count = ReportingEventsRollup.find_by(
|
||||
account_id: account.id,
|
||||
dimension_type: 'account',
|
||||
metric: 'resolutions_count'
|
||||
)
|
||||
expect(resolutions_count.count).to eq(1)
|
||||
expect(resolutions_count.sum_value).to eq(0)
|
||||
expect(resolutions_count.sum_value_business_hours).to eq(0)
|
||||
|
||||
resolution_time = ReportingEventsRollup.find_by(
|
||||
account_id: account.id,
|
||||
dimension_type: 'account',
|
||||
metric: 'resolution_time'
|
||||
)
|
||||
expect(resolution_time.count).to eq(1)
|
||||
expect(resolution_time.sum_value).to eq(1000)
|
||||
expect(resolution_time.sum_value_business_hours).to eq(500)
|
||||
end
|
||||
|
||||
it 'batches all rollup rows in a single upsert_all call' do
|
||||
reporting_event.update!(created_at: rollup_event_created_at)
|
||||
|
||||
travel_to(rollup_write_time) do
|
||||
captured_upsert = capture_upsert_all_call
|
||||
|
||||
described_class.perform(reporting_event)
|
||||
|
||||
expect(ReportingEventsRollup).to have_received(:upsert_all).once
|
||||
expect(captured_upsert[:options][:unique_by]).to eq(expected_upsert_options[:unique_by])
|
||||
expect(captured_upsert[:options][:on_duplicate].to_s.squish).to eq(expected_upsert_options[:on_duplicate])
|
||||
expect(captured_upsert[:rows]).to match_array(expected_rollup_rows)
|
||||
end
|
||||
end
|
||||
|
||||
it 'respects account timezone for date bucketing' do
|
||||
# Event created at 2026-02-11 22:00 UTC
|
||||
# In EST (UTC-5) that's 2026-02-11 17:00 (same day)
|
||||
reporting_event.update!(created_at: '2026-02-11 22:00:00 UTC')
|
||||
|
||||
described_class.perform(reporting_event)
|
||||
|
||||
rollup = ReportingEventsRollup.find_by(
|
||||
account_id: account.id,
|
||||
dimension_type: 'account'
|
||||
)
|
||||
expect(rollup.date).to eq('2026-02-11'.to_date)
|
||||
end
|
||||
|
||||
it 'handles timezone boundary crossing' do
|
||||
# Event created at 2026-02-12 04:00 UTC
|
||||
# In EST (UTC-5) that's 2026-02-11 23:00 (previous day)
|
||||
reporting_event.update!(created_at: '2026-02-12 04:00:00 UTC')
|
||||
|
||||
described_class.perform(reporting_event)
|
||||
|
||||
rollup = ReportingEventsRollup.find_by(
|
||||
account_id: account.id,
|
||||
dimension_type: 'account'
|
||||
)
|
||||
expect(rollup.date).to eq('2026-02-11'.to_date)
|
||||
end
|
||||
|
||||
def capture_upsert_all_call
|
||||
{}.tap do |captured_upsert|
|
||||
allow(ReportingEventsRollup).to receive(:upsert_all) do |rows, **options|
|
||||
captured_upsert[:rows] = rows
|
||||
captured_upsert[:options] = options
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def expected_rollup_rows
|
||||
{
|
||||
account: account.id,
|
||||
agent: user.id,
|
||||
inbox: inbox.id
|
||||
}.flat_map do |dimension_type, dimension_id|
|
||||
[
|
||||
rollup_row(dimension_type, dimension_id, :resolutions_count, 0.0, 0.0),
|
||||
rollup_row(dimension_type, dimension_id, :resolution_time, 1000.0, 500.0)
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
def rollup_row(dimension_type, dimension_id, metric, sum_value, sum_value_business_hours)
|
||||
{
|
||||
account_id: account.id,
|
||||
date: Date.new(2026, 2, 11),
|
||||
dimension_type: dimension_type,
|
||||
dimension_id: dimension_id,
|
||||
metric: metric,
|
||||
count: 1,
|
||||
sum_value: sum_value,
|
||||
sum_value_business_hours: sum_value_business_hours,
|
||||
created_at: rollup_write_time,
|
||||
updated_at: rollup_write_time
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
describe 'first_response event' do
|
||||
let(:reporting_event) do
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
name: 'first_response',
|
||||
value: 500,
|
||||
value_in_business_hours: 300,
|
||||
user: user,
|
||||
inbox: inbox,
|
||||
conversation: conversation)
|
||||
end
|
||||
|
||||
it 'creates first_response metric' do
|
||||
described_class.perform(reporting_event)
|
||||
|
||||
first_response = ReportingEventsRollup.find_by(
|
||||
account_id: account.id,
|
||||
dimension_type: 'account',
|
||||
metric: 'first_response'
|
||||
)
|
||||
expect(first_response).to be_present
|
||||
expect(first_response.count).to eq(1)
|
||||
expect(first_response.sum_value).to eq(500)
|
||||
expect(first_response.sum_value_business_hours).to eq(300)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'reply_time event' do
|
||||
let(:reporting_event) do
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
name: 'reply_time',
|
||||
value: 200,
|
||||
value_in_business_hours: 100,
|
||||
user: user,
|
||||
inbox: inbox,
|
||||
conversation: conversation)
|
||||
end
|
||||
|
||||
it 'creates reply_time metric' do
|
||||
described_class.perform(reporting_event)
|
||||
|
||||
reply_time = ReportingEventsRollup.find_by(
|
||||
account_id: account.id,
|
||||
dimension_type: 'account',
|
||||
metric: 'reply_time'
|
||||
)
|
||||
expect(reply_time).to be_present
|
||||
expect(reply_time.count).to eq(1)
|
||||
expect(reply_time.sum_value).to eq(200)
|
||||
expect(reply_time.sum_value_business_hours).to eq(100)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when metric values are nil' do
|
||||
[
|
||||
%w[conversation_resolved resolution_time],
|
||||
%w[first_response first_response],
|
||||
%w[reply_time reply_time]
|
||||
].each do |event_name, metric|
|
||||
it "treats nil values as zero for #{event_name}" do
|
||||
reporting_event = create(:reporting_event,
|
||||
account: account,
|
||||
name: event_name,
|
||||
value: 100,
|
||||
value_in_business_hours: 50,
|
||||
user: user,
|
||||
inbox: inbox,
|
||||
conversation: conversation)
|
||||
reporting_event.assign_attributes(value: nil, value_in_business_hours: nil)
|
||||
|
||||
expect do
|
||||
described_class.perform(reporting_event)
|
||||
end.not_to raise_error
|
||||
|
||||
rollup = ReportingEventsRollup.find_by(
|
||||
account_id: account.id,
|
||||
dimension_type: 'account',
|
||||
metric: metric
|
||||
)
|
||||
|
||||
expect(rollup).to be_present
|
||||
expect(rollup.count).to eq(1)
|
||||
expect(rollup.sum_value).to eq(0)
|
||||
expect(rollup.sum_value_business_hours).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'conversation_bot_resolved event' do
|
||||
let(:reporting_event) do
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
name: 'conversation_bot_resolved',
|
||||
user: user,
|
||||
inbox: inbox,
|
||||
conversation: conversation)
|
||||
end
|
||||
|
||||
it 'creates bot_resolutions_count metric' do
|
||||
described_class.perform(reporting_event)
|
||||
|
||||
bot_resolutions = ReportingEventsRollup.find_by(
|
||||
account_id: account.id,
|
||||
dimension_type: 'account',
|
||||
metric: 'bot_resolutions_count'
|
||||
)
|
||||
expect(bot_resolutions).to be_present
|
||||
expect(bot_resolutions.count).to eq(1)
|
||||
expect(bot_resolutions.sum_value).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'conversation_bot_handoff event' do
|
||||
let(:reporting_event) do
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
name: 'conversation_bot_handoff',
|
||||
user: user,
|
||||
inbox: inbox,
|
||||
conversation: conversation)
|
||||
end
|
||||
|
||||
it 'creates bot_handoffs_count metric' do
|
||||
described_class.perform(reporting_event)
|
||||
|
||||
bot_handoffs = ReportingEventsRollup.find_by(
|
||||
account_id: account.id,
|
||||
dimension_type: 'account',
|
||||
metric: 'bot_handoffs_count'
|
||||
)
|
||||
expect(bot_handoffs).to be_present
|
||||
expect(bot_handoffs.count).to eq(1)
|
||||
expect(bot_handoffs.sum_value).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'dimension handling' do
|
||||
let(:reporting_event) do
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
name: 'conversation_resolved',
|
||||
value: 1000,
|
||||
value_in_business_hours: 500,
|
||||
user: user,
|
||||
inbox: inbox,
|
||||
conversation: conversation)
|
||||
end
|
||||
|
||||
it 'skips dimensions with nil IDs' do
|
||||
# Create event without user (user_id will be nil)
|
||||
reporting_event.update!(user_id: nil)
|
||||
|
||||
described_class.perform(reporting_event)
|
||||
|
||||
# Agent dimension should not be created
|
||||
agent_rows = ReportingEventsRollup.where(
|
||||
account_id: account.id,
|
||||
dimension_type: 'agent'
|
||||
)
|
||||
expect(agent_rows).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
describe 'upsert behavior' do
|
||||
let(:reporting_event) do
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
name: 'conversation_resolved',
|
||||
value: 1000,
|
||||
value_in_business_hours: 500,
|
||||
user: user,
|
||||
inbox: inbox,
|
||||
conversation: conversation)
|
||||
end
|
||||
|
||||
it 'increments count and sums on duplicate entries' do
|
||||
# First call
|
||||
described_class.perform(reporting_event)
|
||||
|
||||
resolution_time = ReportingEventsRollup.find_by(
|
||||
account_id: account.id,
|
||||
dimension_type: 'account',
|
||||
metric: 'resolution_time'
|
||||
)
|
||||
expect(resolution_time.count).to eq(1)
|
||||
expect(resolution_time.sum_value).to eq(1000)
|
||||
expect(resolution_time.sum_value_business_hours).to eq(500)
|
||||
|
||||
# Second call with same event
|
||||
reporting_event2 = create(:reporting_event,
|
||||
account: account,
|
||||
name: 'conversation_resolved',
|
||||
value: 500,
|
||||
value_in_business_hours: 250,
|
||||
user: user,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
created_at: reporting_event.created_at)
|
||||
|
||||
described_class.perform(reporting_event2)
|
||||
|
||||
# Total should be incremented
|
||||
resolution_time.reload
|
||||
expect(resolution_time.count).to eq(2)
|
||||
expect(resolution_time.sum_value).to eq(1500)
|
||||
expect(resolution_time.sum_value_business_hours).to eq(750)
|
||||
end
|
||||
|
||||
it 'does not create duplicate rollup rows' do
|
||||
described_class.perform(reporting_event)
|
||||
initial_count = ReportingEventsRollup.count
|
||||
|
||||
# Create another event with same date and dimensions
|
||||
reporting_event2 = create(:reporting_event,
|
||||
account: account,
|
||||
name: 'conversation_resolved',
|
||||
value: 500,
|
||||
value_in_business_hours: 250,
|
||||
user: user,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
created_at: reporting_event.created_at)
|
||||
|
||||
described_class.perform(reporting_event2)
|
||||
|
||||
# Row count should remain the same
|
||||
expect(ReportingEventsRollup.count).to eq(initial_count)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when event name in unknown' do
|
||||
let(:reporting_event) do
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
name: 'unknown_event',
|
||||
user: user,
|
||||
inbox: inbox,
|
||||
conversation: conversation)
|
||||
end
|
||||
|
||||
it 'does not create any rollup rows' do
|
||||
expect do
|
||||
described_class.perform(reporting_event)
|
||||
end.not_to change(ReportingEventsRollup, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,398 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Reports::DataSource do
|
||||
describe '.for' do
|
||||
let(:account) { create(:account, reporting_timezone: 'America/New_York') }
|
||||
let(:current_offset) { ActiveSupport::TimeZone['America/New_York'].now.utc_offset / 3600.0 }
|
||||
let(:params) do
|
||||
{
|
||||
account: account,
|
||||
metric: 'avg_resolution_time',
|
||||
dimension_type: 'account',
|
||||
dimension_id: nil,
|
||||
scope: account,
|
||||
range: 1.day.ago.beginning_of_day...Time.current.end_of_day,
|
||||
group_by: 'day',
|
||||
timezone: 'America/New_York',
|
||||
timezone_offset: current_offset,
|
||||
business_hours: false
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(true)
|
||||
end
|
||||
|
||||
it 'returns the rollup adapter when the request is eligible' do
|
||||
expect(described_class.for(**params)).to be_a(Reports::RollupDataSource)
|
||||
end
|
||||
|
||||
it 'falls back to the raw adapter when the feature flag is disabled' do
|
||||
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(false)
|
||||
|
||||
expect(described_class.for(**params)).to be_a(Reports::RawDataSource)
|
||||
end
|
||||
|
||||
it 'falls back to the raw adapter when the reporting timezone is missing' do
|
||||
account.update!(reporting_timezone: nil)
|
||||
|
||||
expect(described_class.for(**params)).to be_a(Reports::RawDataSource)
|
||||
end
|
||||
|
||||
it 'matches rollups using the requested timezone identifier' do
|
||||
account.update!(reporting_timezone: 'Chennai')
|
||||
|
||||
expect(
|
||||
described_class.for(**params, timezone: 'Asia/Kolkata', timezone_offset: 0)
|
||||
).to be_a(Reports::RollupDataSource)
|
||||
end
|
||||
|
||||
it 'falls back to the raw adapter when the requested timezone differs from the account' do
|
||||
account.update!(reporting_timezone: 'Chennai')
|
||||
|
||||
expect(
|
||||
described_class.for(**params, timezone: 'Asia/Colombo', timezone_offset: 5.5)
|
||||
).to be_a(Reports::RawDataSource)
|
||||
end
|
||||
|
||||
it 'falls back to the raw adapter when the timezone offset does not match the account' do
|
||||
expect(described_class.for(**params, timezone: nil, timezone_offset: 0)).to be_a(Reports::RawDataSource)
|
||||
end
|
||||
|
||||
it 'falls back to the raw adapter for hourly groupings' do
|
||||
expect(described_class.for(**params, group_by: 'hour')).to be_a(Reports::RawDataSource)
|
||||
end
|
||||
|
||||
it 'falls back to the raw adapter for unsupported dimensions' do
|
||||
expect(described_class.for(**params, dimension_type: 'team')).to be_a(Reports::RawDataSource)
|
||||
end
|
||||
|
||||
it 'returns the rollup adapter for summary queries without a metric' do
|
||||
expect(described_class.for(**params, metric: nil, dimension_type: 'agent')).to be_a(Reports::RollupDataSource)
|
||||
end
|
||||
|
||||
it 'falls back to the raw adapter for raw-only metrics' do
|
||||
expect(described_class.for(**params, metric: 'conversations_count')).to be_a(Reports::RawDataSource)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'summary select fields' do
|
||||
let(:account) { create(:account, reporting_timezone: 'Etc/UTC') }
|
||||
let(:context) do
|
||||
{
|
||||
account: account,
|
||||
metric: nil,
|
||||
dimension_type: 'inbox',
|
||||
dimension_id: nil,
|
||||
scope: nil,
|
||||
range: 1.day.ago.beginning_of_day...Time.current.end_of_day,
|
||||
group_by: 'day',
|
||||
timezone: 'UTC',
|
||||
timezone_offset: '0',
|
||||
business_hours: false
|
||||
}
|
||||
end
|
||||
|
||||
it 'derives raw summary selects from registry summary metrics' do
|
||||
source = Reports::RawDataSource.new(**context)
|
||||
|
||||
expect(source.send(:summary_select_fields)).to eq(
|
||||
[
|
||||
'inbox_id as inbox_id',
|
||||
"COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_conversations_count",
|
||||
"AVG(CASE WHEN name = 'conversation_resolved' THEN value END) as avg_resolution_time",
|
||||
"AVG(CASE WHEN name = 'first_response' THEN value END) as avg_first_response_time",
|
||||
"AVG(CASE WHEN name = 'reply_time' THEN value END) as avg_reply_time"
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
it 'derives rollup summary selects from registry summary metrics' do
|
||||
source = Reports::RollupDataSource.new(**context)
|
||||
|
||||
expect(source.send(:summary_select_fields)).to eq(
|
||||
[
|
||||
'dimension_id',
|
||||
"SUM(CASE WHEN metric = 'resolutions_count' THEN count ELSE 0 END) as resolved_conversations_count",
|
||||
"SUM(CASE WHEN metric = 'resolution_time' THEN count ELSE 0 END) as avg_resolution_time_count",
|
||||
"SUM(CASE WHEN metric = 'resolution_time' THEN sum_value ELSE 0 END) as avg_resolution_time_sum_value",
|
||||
"SUM(CASE WHEN metric = 'first_response' THEN count ELSE 0 END) as avg_first_response_time_count",
|
||||
"SUM(CASE WHEN metric = 'first_response' THEN sum_value ELSE 0 END) as avg_first_response_time_sum_value",
|
||||
"SUM(CASE WHEN metric = 'reply_time' THEN count ELSE 0 END) as avg_reply_time_count",
|
||||
"SUM(CASE WHEN metric = 'reply_time' THEN sum_value ELSE 0 END) as avg_reply_time_sum_value"
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'adapter contract' do
|
||||
let(:account) { create(:account, reporting_timezone: 'Etc/UTC') }
|
||||
let(:user1) { create(:user) }
|
||||
let(:user2) { create(:user) }
|
||||
let(:inbox1) { create(:inbox, account: account) }
|
||||
let(:inbox2) { create(:inbox, account: account) }
|
||||
let(:timezone) { 'UTC' }
|
||||
let(:timezone_offset) { '0' }
|
||||
let(:current_time) { Time.zone.parse('2026-01-15 10:00:00 UTC') }
|
||||
let(:full_range) { Time.zone.parse('2026-01-05 00:00:00 UTC')...Time.zone.parse('2026-01-16 00:00:00 UTC') }
|
||||
let(:day_range) { Time.zone.parse('2026-01-14 00:00:00 UTC')...Time.zone.parse('2026-01-16 00:00:00 UTC') }
|
||||
|
||||
before do
|
||||
travel_to current_time
|
||||
create(:account_user, account: account, user: user1)
|
||||
create(:account_user, account: account, user: user2)
|
||||
|
||||
conversation_one = create(
|
||||
:conversation,
|
||||
account: account,
|
||||
inbox: inbox1,
|
||||
assignee: user1,
|
||||
created_at: Time.zone.parse('2026-01-06 09:00:00 UTC')
|
||||
)
|
||||
conversation_two = create(
|
||||
:conversation,
|
||||
account: account,
|
||||
inbox: inbox1,
|
||||
assignee: user1,
|
||||
created_at: Time.zone.parse('2026-01-14 09:00:00 UTC')
|
||||
)
|
||||
conversation_three = create(
|
||||
:conversation,
|
||||
account: account,
|
||||
inbox: inbox2,
|
||||
assignee: user2,
|
||||
created_at: Time.zone.parse('2026-01-15 09:00:00 UTC')
|
||||
)
|
||||
|
||||
create(:reporting_event,
|
||||
name: 'first_response',
|
||||
account: account,
|
||||
user: user1,
|
||||
inbox: inbox1,
|
||||
conversation: conversation_one,
|
||||
value: 20,
|
||||
value_in_business_hours: 10,
|
||||
created_at: Time.zone.parse('2026-01-06 10:00:00 UTC'))
|
||||
create(:reporting_event,
|
||||
name: 'reply_time',
|
||||
account: account,
|
||||
user: user1,
|
||||
inbox: inbox1,
|
||||
conversation: conversation_one,
|
||||
value: 10,
|
||||
value_in_business_hours: 5,
|
||||
created_at: Time.zone.parse('2026-01-06 11:00:00 UTC'))
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account: account,
|
||||
user: user1,
|
||||
inbox: inbox1,
|
||||
conversation: conversation_one,
|
||||
value: 100,
|
||||
value_in_business_hours: 80,
|
||||
created_at: Time.zone.parse('2026-01-06 12:00:00 UTC'))
|
||||
|
||||
create(:reporting_event,
|
||||
name: 'first_response',
|
||||
account: account,
|
||||
user: user1,
|
||||
inbox: inbox1,
|
||||
conversation: conversation_two,
|
||||
value: 40,
|
||||
value_in_business_hours: 30,
|
||||
created_at: Time.zone.parse('2026-01-14 10:00:00 UTC'))
|
||||
create(:reporting_event,
|
||||
name: 'reply_time',
|
||||
account: account,
|
||||
user: user1,
|
||||
inbox: inbox1,
|
||||
conversation: conversation_two,
|
||||
value: 30,
|
||||
value_in_business_hours: 20,
|
||||
created_at: Time.zone.parse('2026-01-14 11:00:00 UTC'))
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account: account,
|
||||
user: user1,
|
||||
inbox: inbox1,
|
||||
conversation: conversation_two,
|
||||
value: 200,
|
||||
value_in_business_hours: 150,
|
||||
created_at: Time.zone.parse('2026-01-14 12:00:00 UTC'))
|
||||
|
||||
create(:reporting_event,
|
||||
name: 'first_response',
|
||||
account: account,
|
||||
user: user2,
|
||||
inbox: inbox2,
|
||||
conversation: conversation_three,
|
||||
value: 60,
|
||||
value_in_business_hours: 50,
|
||||
created_at: Time.zone.parse('2026-01-15 10:00:00 UTC'))
|
||||
create(:reporting_event,
|
||||
name: 'reply_time',
|
||||
account: account,
|
||||
user: user2,
|
||||
inbox: inbox2,
|
||||
conversation: conversation_three,
|
||||
value: 50,
|
||||
value_in_business_hours: 40,
|
||||
created_at: Time.zone.parse('2026-01-15 11:00:00 UTC'))
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account: account,
|
||||
user: user2,
|
||||
inbox: inbox2,
|
||||
conversation: conversation_three,
|
||||
value: 300,
|
||||
value_in_business_hours: 250,
|
||||
created_at: Time.zone.parse('2026-01-15 12:00:00 UTC'))
|
||||
|
||||
[Date.new(2026, 1, 6), Date.new(2026, 1, 14), Date.new(2026, 1, 15)].each do |date|
|
||||
ReportingEvents::BackfillService.backfill_date(account, date)
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'report adapter contract' do |adapter_class|
|
||||
context 'with average metrics' do
|
||||
subject(:source) do
|
||||
adapter_class.new(
|
||||
account: account,
|
||||
metric: 'avg_first_response_time',
|
||||
dimension_type: 'account',
|
||||
dimension_id: nil,
|
||||
scope: account,
|
||||
range: day_range,
|
||||
group_by: 'day',
|
||||
timezone: timezone,
|
||||
timezone_offset: timezone_offset,
|
||||
business_hours: business_hours
|
||||
)
|
||||
end
|
||||
|
||||
let(:business_hours) { false }
|
||||
|
||||
it 'returns the expected aggregate' do
|
||||
expect(source.aggregate).to eq(50.0)
|
||||
end
|
||||
|
||||
it 'returns the expected day timeseries' do
|
||||
expect(source.timeseries).to eq(
|
||||
[
|
||||
{ count: 1, timestamp: Date.new(2026, 1, 14).in_time_zone(timezone).to_i, value: 40.0 },
|
||||
{ count: 1, timestamp: Date.new(2026, 1, 15).in_time_zone(timezone).to_i, value: 60.0 }
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
context 'when business hours are requested' do
|
||||
let(:business_hours) { true }
|
||||
|
||||
it 'returns the business-hours aggregate and timeseries' do
|
||||
expect(source.aggregate).to eq(40.0)
|
||||
expect(source.timeseries).to eq(
|
||||
[
|
||||
{ count: 1, timestamp: Date.new(2026, 1, 14).in_time_zone(timezone).to_i, value: 30.0 },
|
||||
{ count: 1, timestamp: Date.new(2026, 1, 15).in_time_zone(timezone).to_i, value: 50.0 }
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with count metrics' do
|
||||
subject(:source) do
|
||||
adapter_class.new(
|
||||
account: account,
|
||||
metric: 'resolutions_count',
|
||||
dimension_type: 'agent',
|
||||
dimension_id: user1.id,
|
||||
scope: user1,
|
||||
range: full_range,
|
||||
group_by: 'week',
|
||||
timezone: timezone,
|
||||
timezone_offset: timezone_offset,
|
||||
business_hours: false
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the expected aggregate' do
|
||||
expect(source.aggregate).to eq(2)
|
||||
end
|
||||
|
||||
it 'returns the expected week timeseries' do
|
||||
expect(source.timeseries).to eq(
|
||||
[
|
||||
{ value: 1, timestamp: Date.new(2026, 1, 4).in_time_zone(timezone).to_i },
|
||||
{ value: 1, timestamp: Date.new(2026, 1, 11).in_time_zone(timezone).to_i }
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with summary metrics' do
|
||||
subject(:source) do
|
||||
adapter_class.new(
|
||||
account: account,
|
||||
metric: nil,
|
||||
dimension_type: 'inbox',
|
||||
dimension_id: nil,
|
||||
scope: nil,
|
||||
range: full_range,
|
||||
group_by: 'day',
|
||||
timezone: timezone,
|
||||
timezone_offset: timezone_offset,
|
||||
business_hours: business_hours
|
||||
)
|
||||
end
|
||||
|
||||
let(:business_hours) { false }
|
||||
|
||||
it 'returns the expected summary shape and values' do
|
||||
expect(source.summary).to eq(
|
||||
inbox1.id => {
|
||||
conversations_count: 2,
|
||||
resolved_conversations_count: 2,
|
||||
avg_resolution_time: 150.0,
|
||||
avg_first_response_time: 30.0,
|
||||
avg_reply_time: 20.0
|
||||
},
|
||||
inbox2.id => {
|
||||
conversations_count: 1,
|
||||
resolved_conversations_count: 1,
|
||||
avg_resolution_time: 300.0,
|
||||
avg_first_response_time: 60.0,
|
||||
avg_reply_time: 50.0
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
context 'when business hours are requested' do
|
||||
let(:business_hours) { true }
|
||||
|
||||
it 'returns the business-hours summary values' do
|
||||
expect(source.summary).to eq(
|
||||
inbox1.id => {
|
||||
conversations_count: 2,
|
||||
resolved_conversations_count: 2,
|
||||
avg_resolution_time: 115.0,
|
||||
avg_first_response_time: 20.0,
|
||||
avg_reply_time: 12.5
|
||||
},
|
||||
inbox2.id => {
|
||||
conversations_count: 1,
|
||||
resolved_conversations_count: 1,
|
||||
avg_resolution_time: 250.0,
|
||||
avg_first_response_time: 50.0,
|
||||
avg_reply_time: 40.0
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'report adapter contract', Reports::RawDataSource
|
||||
it_behaves_like 'report adapter contract', Reports::RollupDataSource
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,74 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Reports::ReportMetricRegistry do
|
||||
describe '.fetch' do
|
||||
it 'returns the definition for raw-only count metrics' do
|
||||
metric = described_class.fetch(:conversations_count)
|
||||
|
||||
expect(metric.name).to eq(:conversations_count)
|
||||
expect(metric.count?).to be(true)
|
||||
expect(metric.rollup_supported?).to be(false)
|
||||
expect(metric.raw_event_name).to be_nil
|
||||
end
|
||||
|
||||
it 'returns the definition for avg_resolution_time' do
|
||||
metric = described_class.fetch(:avg_resolution_time)
|
||||
|
||||
expect(metric.name).to eq(:avg_resolution_time)
|
||||
expect(metric.average?).to be(true)
|
||||
expect(metric.raw_event_name).to eq(:conversation_resolved)
|
||||
expect(metric.rollup_metric).to eq(:resolution_time)
|
||||
expect(metric.summary_key).to eq(:avg_resolution_time)
|
||||
end
|
||||
|
||||
it 'locks the distinct conversation strategy for bot_handoffs_count' do
|
||||
metric = described_class.fetch(:bot_handoffs_count)
|
||||
|
||||
expect(metric.count?).to be(true)
|
||||
expect(metric.raw_event_name).to eq(:conversation_bot_handoff)
|
||||
expect(metric.rollup_metric).to eq(:bot_handoffs_count)
|
||||
expect(metric.raw_count_strategy).to eq(:distinct_conversation)
|
||||
end
|
||||
|
||||
it 'returns nil for unsupported metrics' do
|
||||
expect(described_class.fetch(:unknown_metric)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '.supported?' do
|
||||
it 'returns true for supported raw-only metrics' do
|
||||
expect(described_class.supported?(:conversations_count)).to be(true)
|
||||
end
|
||||
|
||||
it 'returns false for unsupported metrics' do
|
||||
expect(described_class.supported?(:unknown_metric)).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.rollup_supported?' do
|
||||
it 'returns true for rollup-backed metrics' do
|
||||
expect(described_class.rollup_supported?(:reply_time)).to be(true)
|
||||
end
|
||||
|
||||
it 'returns false for raw-only metrics' do
|
||||
expect(described_class.rollup_supported?(:conversations_count)).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.summary_metrics' do
|
||||
it 'returns the summary metric definitions in registry order' do
|
||||
expect(
|
||||
described_class.summary_metrics.map do |metric|
|
||||
[metric.name, metric.summary_key, metric.aggregate, metric.raw_event_name, metric.rollup_metric]
|
||||
end
|
||||
).to eq(
|
||||
[
|
||||
[:resolutions_count, :resolved_conversations_count, :count, :conversation_resolved, :resolutions_count],
|
||||
[:avg_resolution_time, :avg_resolution_time, :average, :conversation_resolved, :resolution_time],
|
||||
[:avg_first_response_time, :avg_first_response_time, :average, :first_response, :first_response],
|
||||
[:reply_time, :avg_reply_time, :average, :reply_time, :reply_time]
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user