Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
138
research/chatwoot/app/builders/v2/report_builder.rb
Normal file
138
research/chatwoot/app/builders/v2/report_builder.rb
Normal file
@@ -0,0 +1,138 @@
|
||||
class V2::ReportBuilder
|
||||
include DateRangeHelper
|
||||
include ReportHelper
|
||||
attr_reader :account, :params
|
||||
|
||||
DEFAULT_GROUP_BY = 'day'.freeze
|
||||
AGENT_RESULTS_PER_PAGE = 25
|
||||
|
||||
def initialize(account, params)
|
||||
@account = account
|
||||
@params = params
|
||||
|
||||
timezone_offset = (params[:timezone_offset] || 0).to_f
|
||||
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
|
||||
end
|
||||
|
||||
def timeseries
|
||||
return send(params[:metric]) if metric_valid?
|
||||
|
||||
Rails.logger.error "ReportBuilder: Invalid metric - #{params[:metric]}"
|
||||
{}
|
||||
end
|
||||
|
||||
# For backward compatible with old report
|
||||
def build
|
||||
if %w[avg_first_response_time avg_resolution_time reply_time].include?(params[:metric])
|
||||
timeseries.each_with_object([]) do |p, arr|
|
||||
arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i, count: @grouped_values.count[p[0]] }
|
||||
end
|
||||
else
|
||||
timeseries.each_with_object([]) do |p, arr|
|
||||
arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def summary
|
||||
{
|
||||
conversations_count: conversations.count,
|
||||
incoming_messages_count: incoming_messages.count,
|
||||
outgoing_messages_count: outgoing_messages.count,
|
||||
avg_first_response_time: avg_first_response_time_summary,
|
||||
avg_resolution_time: avg_resolution_time_summary,
|
||||
resolutions_count: resolutions.count,
|
||||
reply_time: reply_time_summary
|
||||
}
|
||||
end
|
||||
|
||||
def short_summary
|
||||
{
|
||||
conversations_count: conversations.count,
|
||||
avg_first_response_time: avg_first_response_time_summary,
|
||||
avg_resolution_time: avg_resolution_time_summary
|
||||
}
|
||||
end
|
||||
|
||||
def bot_summary
|
||||
{
|
||||
bot_resolutions_count: bot_resolutions.count,
|
||||
bot_handoffs_count: bot_handoffs.count
|
||||
}
|
||||
end
|
||||
|
||||
def conversation_metrics
|
||||
if params[:type].equal?(:account)
|
||||
live_conversations
|
||||
else
|
||||
agent_metrics.sort_by { |hash| hash[:metric][:open] }.reverse
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def metric_valid?
|
||||
%w[conversations_count
|
||||
incoming_messages_count
|
||||
outgoing_messages_count
|
||||
avg_first_response_time
|
||||
avg_resolution_time reply_time
|
||||
resolutions_count
|
||||
bot_resolutions_count
|
||||
bot_handoffs_count
|
||||
reply_time].include?(params[:metric])
|
||||
end
|
||||
|
||||
def inbox
|
||||
@inbox ||= account.inboxes.find(params[:id])
|
||||
end
|
||||
|
||||
def user
|
||||
@user ||= account.users.find(params[:id])
|
||||
end
|
||||
|
||||
def label
|
||||
@label ||= account.labels.find(params[:id])
|
||||
end
|
||||
|
||||
def team
|
||||
@team ||= account.teams.find(params[:id])
|
||||
end
|
||||
|
||||
def get_grouped_values(object_scope)
|
||||
@grouped_values = object_scope.group_by_period(
|
||||
params[:group_by] || DEFAULT_GROUP_BY,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
range: range,
|
||||
permit: %w[day week month year hour],
|
||||
time_zone: @timezone
|
||||
)
|
||||
end
|
||||
|
||||
def agent_metrics
|
||||
account_users = @account.account_users.page(params[:page]).per(AGENT_RESULTS_PER_PAGE)
|
||||
account_users.each_with_object([]) do |account_user, arr|
|
||||
@user = account_user.user
|
||||
arr << {
|
||||
id: @user.id,
|
||||
name: @user.name,
|
||||
email: @user.email,
|
||||
thumbnail: @user.avatar_url,
|
||||
availability: account_user.availability_status,
|
||||
metric: live_conversations
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def live_conversations
|
||||
@open_conversations = scope.conversations.where(account_id: @account.id).open
|
||||
metric = {
|
||||
open: @open_conversations.count,
|
||||
unattended: @open_conversations.unattended.count
|
||||
}
|
||||
metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account)
|
||||
metric[:pending] = @open_conversations.pending.count if params[:type].equal?(:account)
|
||||
metric
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
def build
|
||||
load_data
|
||||
prepare_report
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
def build_agent_stats(account_user)
|
||||
user_id = account_user.user_id
|
||||
{
|
||||
id: user_id,
|
||||
conversations_count: conversations_count[user_id] || 0,
|
||||
resolved_conversations_count: resolved_count[user_id] || 0,
|
||||
avg_resolution_time: avg_resolution_time[user_id],
|
||||
avg_first_response_time: avg_first_response_time[user_id],
|
||||
avg_reply_time: avg_reply_time[user_id]
|
||||
}
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
:user_id
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,56 @@
|
||||
class V2::Reports::BaseSummaryBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
def build
|
||||
load_data
|
||||
prepare_report
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
load_reporting_events_data
|
||||
end
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
class V2::Reports::BotMetricsBuilder
|
||||
include DateRangeHelper
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account, params)
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def metrics
|
||||
{
|
||||
conversation_count: bot_conversations.count,
|
||||
message_count: bot_messages.count,
|
||||
resolution_rate: bot_resolution_rate.to_i,
|
||||
handoff_rate: bot_handoff_rate.to_i
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def bot_activated_inbox_ids
|
||||
@bot_activated_inbox_ids ||= account.inboxes.filter(&:active_bot?).map(&:id)
|
||||
end
|
||||
|
||||
def bot_conversations
|
||||
@bot_conversations ||= account.conversations.where(inbox_id: bot_activated_inbox_ids).where(created_at: range)
|
||||
end
|
||||
|
||||
def bot_messages
|
||||
@bot_messages ||= account.messages.outgoing.where(conversation_id: bot_conversations.ids).where(created_at: range)
|
||||
end
|
||||
|
||||
def bot_resolutions_count
|
||||
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
|
||||
created_at: range).distinct.count
|
||||
end
|
||||
|
||||
def bot_handoffs_count
|
||||
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
|
||||
created_at: range).distinct.count
|
||||
end
|
||||
|
||||
def bot_resolution_rate
|
||||
return 0 if bot_conversations.count.zero?
|
||||
|
||||
bot_resolutions_count.to_f / bot_conversations.count * 100
|
||||
end
|
||||
|
||||
def bot_handoff_rate
|
||||
return 0 if bot_conversations.count.zero?
|
||||
|
||||
bot_handoffs_count.to_f / bot_conversations.count * 100
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,38 @@
|
||||
class V2::Reports::ChannelSummaryBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
def build
|
||||
conversations_by_channel_and_status.transform_values { |status_counts| build_channel_stats(status_counts) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def conversations_by_channel_and_status
|
||||
account.conversations
|
||||
.joins(:inbox)
|
||||
.where(created_at: range)
|
||||
.group('inboxes.channel_type', 'conversations.status')
|
||||
.count
|
||||
.each_with_object({}) do |((channel_type, status), count), grouped|
|
||||
grouped[channel_type] ||= {}
|
||||
grouped[channel_type][status] = count
|
||||
end
|
||||
end
|
||||
|
||||
def build_channel_stats(status_counts)
|
||||
open_count = status_counts['open'] || 0
|
||||
resolved_count = status_counts['resolved'] || 0
|
||||
pending_count = status_counts['pending'] || 0
|
||||
snoozed_count = status_counts['snoozed'] || 0
|
||||
|
||||
{
|
||||
open: open_count,
|
||||
resolved: resolved_count,
|
||||
pending: pending_count,
|
||||
snoozed: snoozed_count,
|
||||
total: open_count + resolved_count + pending_count + snoozed_count
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
class V2::Reports::Conversations::BaseReportBuilder
|
||||
pattr_initialize :account, :params
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
def log_invalid_metric
|
||||
Rails.logger.error "ReportBuilder: Invalid metric - #{params[:metric]}"
|
||||
|
||||
{}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
class V2::Reports::Conversations::MetricBuilder < V2::Reports::Conversations::BaseReportBuilder
|
||||
def summary
|
||||
{
|
||||
conversations_count: count('conversations_count'),
|
||||
incoming_messages_count: count('incoming_messages_count'),
|
||||
outgoing_messages_count: count('outgoing_messages_count'),
|
||||
avg_first_response_time: count('avg_first_response_time'),
|
||||
avg_resolution_time: count('avg_resolution_time'),
|
||||
resolutions_count: count('resolutions_count'),
|
||||
reply_time: count('reply_time')
|
||||
}
|
||||
end
|
||||
|
||||
def bot_summary
|
||||
{
|
||||
bot_resolutions_count: count('bot_resolutions_count'),
|
||||
bot_handoffs_count: count('bot_handoffs_count')
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def count(metric)
|
||||
builder_class(metric).new(account, builder_params(metric)).aggregate_value
|
||||
end
|
||||
|
||||
def builder_params(metric)
|
||||
params.merge({ metric: metric })
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
class V2::Reports::Conversations::ReportBuilder < V2::Reports::Conversations::BaseReportBuilder
|
||||
def timeseries
|
||||
perform_action(:timeseries)
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
perform_action(:aggregate_value)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def perform_action(method_name)
|
||||
return builder.new(account, params).public_send(method_name) if builder.present?
|
||||
|
||||
log_invalid_metric
|
||||
end
|
||||
|
||||
def builder
|
||||
builder_class(params[:metric])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,68 @@
|
||||
class V2::Reports::FirstResponseTimeDistributionBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account:, params:)
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def build
|
||||
build_distribution
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_distribution
|
||||
results = fetch_aggregated_counts
|
||||
map_to_channel_types(results)
|
||||
end
|
||||
|
||||
def fetch_aggregated_counts
|
||||
ReportingEvent
|
||||
.where(account_id: account.id, name: 'first_response')
|
||||
.where(range_condition)
|
||||
.group(:inbox_id)
|
||||
.select(
|
||||
:inbox_id,
|
||||
bucket_case_statements
|
||||
)
|
||||
end
|
||||
|
||||
def bucket_case_statements
|
||||
<<~SQL.squish
|
||||
COUNT(CASE WHEN value < 3600 THEN 1 END) AS bucket_0_1h,
|
||||
COUNT(CASE WHEN value >= 3600 AND value < 14400 THEN 1 END) AS bucket_1_4h,
|
||||
COUNT(CASE WHEN value >= 14400 AND value < 28800 THEN 1 END) AS bucket_4_8h,
|
||||
COUNT(CASE WHEN value >= 28800 AND value < 86400 THEN 1 END) AS bucket_8_24h,
|
||||
COUNT(CASE WHEN value >= 86400 THEN 1 END) AS bucket_24h_plus
|
||||
SQL
|
||||
end
|
||||
|
||||
def range_condition
|
||||
range.present? ? { created_at: range } : {}
|
||||
end
|
||||
|
||||
def inbox_channel_types
|
||||
@inbox_channel_types ||= account.inboxes.pluck(:id, :channel_type).to_h
|
||||
end
|
||||
|
||||
def map_to_channel_types(results)
|
||||
results.each_with_object({}) do |row, hash|
|
||||
channel_type = inbox_channel_types[row.inbox_id]
|
||||
next unless channel_type
|
||||
|
||||
hash[channel_type] ||= empty_buckets
|
||||
hash[channel_type]['0-1h'] += row.bucket_0_1h
|
||||
hash[channel_type]['1-4h'] += row.bucket_1_4h
|
||||
hash[channel_type]['4-8h'] += row.bucket_4_8h
|
||||
hash[channel_type]['8-24h'] += row.bucket_8_24h
|
||||
hash[channel_type]['24h+'] += row.bucket_24h_plus
|
||||
end
|
||||
end
|
||||
|
||||
def empty_buckets
|
||||
{ '0-1h' => 0, '1-4h' => 0, '4-8h' => 0, '8-24h' => 0, '24h+' => 0 }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,65 @@
|
||||
class V2::Reports::InboxLabelMatrixBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account:, params:)
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def build
|
||||
{
|
||||
inboxes: filtered_inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
|
||||
labels: filtered_labels.map { |label| { id: label.id, title: label.title } },
|
||||
matrix: build_matrix
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filtered_inboxes
|
||||
@filtered_inboxes ||= begin
|
||||
inboxes = account.inboxes
|
||||
inboxes = inboxes.where(id: params[:inbox_ids]) if params[:inbox_ids].present?
|
||||
inboxes.order(:name).to_a
|
||||
end
|
||||
end
|
||||
|
||||
def filtered_labels
|
||||
@filtered_labels ||= begin
|
||||
labels = account.labels
|
||||
labels = labels.where(id: params[:label_ids]) if params[:label_ids].present?
|
||||
labels.order(:title).to_a
|
||||
end
|
||||
end
|
||||
|
||||
def conversation_filter
|
||||
filter = { account_id: account.id }
|
||||
filter[:created_at] = range if range.present?
|
||||
filter[:inbox_id] = params[:inbox_ids] if params[:inbox_ids].present?
|
||||
filter
|
||||
end
|
||||
|
||||
def fetch_grouped_counts
|
||||
label_names = filtered_labels.map(&:title)
|
||||
return {} if label_names.empty?
|
||||
|
||||
ActsAsTaggableOn::Tagging
|
||||
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
|
||||
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
|
||||
.where(taggable_type: 'Conversation', context: 'labels', conversations: conversation_filter)
|
||||
.where(tags: { name: label_names })
|
||||
.group('conversations.inbox_id', 'tags.name')
|
||||
.count
|
||||
end
|
||||
|
||||
def build_matrix
|
||||
counts = fetch_grouped_counts
|
||||
filtered_inboxes.map do |inbox|
|
||||
filtered_labels.map do |label|
|
||||
counts[[inbox.id, label.title]] || 0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
def build
|
||||
load_data
|
||||
prepare_report
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
def build_inbox_stats(inbox)
|
||||
{
|
||||
id: inbox.id,
|
||||
conversations_count: conversations_count[inbox.id] || 0,
|
||||
resolved_conversations_count: resolved_count[inbox.id] || 0,
|
||||
avg_resolution_time: avg_resolution_time[inbox.id],
|
||||
avg_first_response_time: avg_first_response_time[inbox.id],
|
||||
avg_reply_time: avg_reply_time[inbox.id]
|
||||
}
|
||||
end
|
||||
|
||||
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
|
||||
@@ -0,0 +1,112 @@
|
||||
class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
attr_reader :account, :params
|
||||
|
||||
# rubocop:disable Lint/MissingSuper
|
||||
# the parent class has no initialize
|
||||
def initialize(account:, params:)
|
||||
@account = account
|
||||
@params = params
|
||||
|
||||
timezone_offset = (params[:timezone_offset] || 0).to_f
|
||||
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
|
||||
end
|
||||
# rubocop:enable Lint/MissingSuper
|
||||
|
||||
def build
|
||||
labels = account.labels.to_a
|
||||
return [] if labels.empty?
|
||||
|
||||
report_data = collect_report_data
|
||||
labels.map { |label| build_label_report(label, report_data) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def collect_report_data
|
||||
conversation_filter = build_conversation_filter
|
||||
use_business_hours = use_business_hours?
|
||||
|
||||
{
|
||||
conversation_counts: fetch_conversation_counts(conversation_filter),
|
||||
resolved_counts: fetch_resolved_counts,
|
||||
resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours),
|
||||
first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours),
|
||||
reply_metrics: fetch_metrics(conversation_filter, 'reply_time', use_business_hours)
|
||||
}
|
||||
end
|
||||
|
||||
def build_label_report(label, report_data)
|
||||
{
|
||||
id: label.id,
|
||||
name: label.title,
|
||||
conversations_count: report_data[:conversation_counts][label.title] || 0,
|
||||
avg_resolution_time: report_data[:resolution_metrics][label.title] || 0,
|
||||
avg_first_response_time: report_data[:first_response_metrics][label.title] || 0,
|
||||
avg_reply_time: report_data[:reply_metrics][label.title] || 0,
|
||||
resolved_conversations_count: report_data[:resolved_counts][label.title] || 0
|
||||
}
|
||||
end
|
||||
|
||||
def use_business_hours?
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
|
||||
end
|
||||
|
||||
def build_conversation_filter
|
||||
conversation_filter = { account_id: account.id }
|
||||
conversation_filter[:created_at] = range if range.present?
|
||||
|
||||
conversation_filter
|
||||
end
|
||||
|
||||
def fetch_conversation_counts(conversation_filter)
|
||||
fetch_counts(conversation_filter)
|
||||
end
|
||||
|
||||
def fetch_resolved_counts
|
||||
# Count resolution events, not conversations currently in resolved status
|
||||
# Filter by reporting_event.created_at, not conversation.created_at
|
||||
reporting_event_filter = { name: 'conversation_resolved', account_id: account.id }
|
||||
reporting_event_filter[:created_at] = range if range.present?
|
||||
|
||||
ReportingEvent
|
||||
.joins(conversation: { taggings: :tag })
|
||||
.where(
|
||||
reporting_event_filter.merge(
|
||||
taggings: { taggable_type: 'Conversation', context: 'labels' }
|
||||
)
|
||||
)
|
||||
.group('tags.name')
|
||||
.count
|
||||
end
|
||||
|
||||
def fetch_counts(conversation_filter)
|
||||
ActsAsTaggableOn::Tagging
|
||||
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
|
||||
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
|
||||
.where(
|
||||
taggable_type: 'Conversation',
|
||||
context: 'labels',
|
||||
conversations: conversation_filter
|
||||
)
|
||||
.select('tags.name, COUNT(taggings.*) AS count')
|
||||
.group('tags.name')
|
||||
.each_with_object({}) { |record, hash| hash[record.name] = record.count }
|
||||
end
|
||||
|
||||
def fetch_metrics(conversation_filter, event_name, use_business_hours)
|
||||
ReportingEvent
|
||||
.joins(conversation: { taggings: :tag })
|
||||
.where(
|
||||
conversations: conversation_filter,
|
||||
name: event_name,
|
||||
taggings: { taggable_type: 'Conversation', context: 'labels' }
|
||||
)
|
||||
.group('tags.name')
|
||||
.order('tags.name')
|
||||
.select(
|
||||
'tags.name',
|
||||
use_business_hours ? 'AVG(reporting_events.value_in_business_hours) as avg_value' : 'AVG(reporting_events.value) as avg_value'
|
||||
)
|
||||
.each_with_object({}) { |record, hash| hash[record.name] = record.avg_value.to_f }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,79 @@
|
||||
class V2::Reports::OutgoingMessagesCountBuilder
|
||||
include DateRangeHelper
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account, params)
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def build
|
||||
send("build_by_#{params[:group_by]}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def base_messages
|
||||
account.messages.outgoing.unscope(:order).where(created_at: range)
|
||||
end
|
||||
|
||||
def build_by_agent
|
||||
counts = base_messages
|
||||
.where(sender_type: 'User')
|
||||
.where.not(sender_id: nil)
|
||||
.group(:sender_id)
|
||||
.count
|
||||
|
||||
user_names = account.users.where(id: counts.keys).index_by(&:id)
|
||||
|
||||
counts.map do |user_id, count|
|
||||
user = user_names[user_id]
|
||||
{ id: user_id, name: user&.name, outgoing_messages_count: count }
|
||||
end
|
||||
end
|
||||
|
||||
def build_by_team
|
||||
counts = base_messages
|
||||
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
|
||||
.where.not(conversations: { team_id: nil })
|
||||
.group('conversations.team_id')
|
||||
.count
|
||||
|
||||
team_names = account.teams.where(id: counts.keys).index_by(&:id)
|
||||
|
||||
counts.map do |team_id, count|
|
||||
team = team_names[team_id]
|
||||
{ id: team_id, name: team&.name, outgoing_messages_count: count }
|
||||
end
|
||||
end
|
||||
|
||||
def build_by_inbox
|
||||
counts = base_messages
|
||||
.group(:inbox_id)
|
||||
.count
|
||||
|
||||
inbox_names = account.inboxes.where(id: counts.keys).index_by(&:id)
|
||||
|
||||
counts.map do |inbox_id, count|
|
||||
inbox = inbox_names[inbox_id]
|
||||
{ id: inbox_id, name: inbox&.name, outgoing_messages_count: count }
|
||||
end
|
||||
end
|
||||
|
||||
def build_by_label
|
||||
counts = base_messages
|
||||
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
|
||||
.joins("INNER JOIN taggings ON taggings.taggable_id = conversations.id
|
||||
AND taggings.taggable_type = 'Conversation' AND taggings.context = 'labels'")
|
||||
.joins('INNER JOIN tags ON tags.id = taggings.tag_id')
|
||||
.group('tags.name')
|
||||
.count
|
||||
|
||||
label_ids = account.labels.where(title: counts.keys).index_by(&:title)
|
||||
|
||||
counts.map do |label_name, count|
|
||||
label = label_ids[label_name]
|
||||
{ id: label&.id, name: label_name, outgoing_messages_count: count }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
private
|
||||
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
def build_team_stats(team)
|
||||
{
|
||||
id: team.id,
|
||||
conversations_count: conversations_count[team.id] || 0,
|
||||
resolved_conversations_count: resolved_count[team.id] || 0,
|
||||
avg_resolution_time: avg_resolution_time[team.id],
|
||||
avg_first_response_time: avg_first_response_time[team.id],
|
||||
avg_reply_time: avg_reply_time[team.id]
|
||||
}
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
'conversations.team_id'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
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
|
||||
@@ -0,0 +1,46 @@
|
||||
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
|
||||
when :account
|
||||
account
|
||||
when :inbox
|
||||
inbox
|
||||
when :agent
|
||||
user
|
||||
when :label
|
||||
label
|
||||
when :team
|
||||
team
|
||||
end
|
||||
end
|
||||
|
||||
def inbox
|
||||
@inbox ||= account.inboxes.find(params[:id])
|
||||
end
|
||||
|
||||
def user
|
||||
@user ||= account.users.find(params[:id])
|
||||
end
|
||||
|
||||
def label
|
||||
@label ||= account.labels.find(params[:id])
|
||||
end
|
||||
|
||||
def team
|
||||
@team ||= account.teams.find(params[:id])
|
||||
end
|
||||
|
||||
def group_by
|
||||
@group_by ||= %w[day week month year hour].include?(params[:group_by]) ? params[:group_by] : DEFAULT_GROUP_BY
|
||||
end
|
||||
|
||||
def timezone
|
||||
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
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
|
||||
Reference in New Issue
Block a user