Merge branch 'feat/assignment_revamp' into feat/assignment_revamp_fe
This commit is contained in:
@@ -130,8 +130,10 @@ class Api::V1::Accounts::AgentCapacityPoliciesController < Api::V1::Accounts::Ba
|
||||
end
|
||||
|
||||
def fetch_agent_capacity_policy
|
||||
@agent_capacity_policy = Enterprise::AgentCapacityPolicy.where(account_id: Current.account.id).includes(:users,
|
||||
inbox_capacity_limits: :inbox).find(params[:id])
|
||||
@agent_capacity_policy = Enterprise::AgentCapacityPolicy
|
||||
.where(account_id: Current.account.id)
|
||||
.includes(:users, inbox_capacity_limits: :inbox)
|
||||
.find(params[:id])
|
||||
end
|
||||
|
||||
def agent_capacity_policy_params
|
||||
|
||||
@@ -6,13 +6,13 @@ class Api::V1::Accounts::InboxAssignmentPoliciesController < Api::V1::Accounts::
|
||||
|
||||
def show
|
||||
@inbox_assignment_policy = @inbox.inbox_assignment_policy
|
||||
|
||||
|
||||
if @inbox_assignment_policy
|
||||
render json: {
|
||||
inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy)
|
||||
render json: {
|
||||
inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy)
|
||||
}
|
||||
else
|
||||
render json: {
|
||||
render json: {
|
||||
inbox_assignment_policy: nil,
|
||||
message: 'No assignment policy assigned to this inbox'
|
||||
}
|
||||
@@ -27,8 +27,8 @@ class Api::V1::Accounts::InboxAssignmentPoliciesController < Api::V1::Accounts::
|
||||
@inbox_assignment_policy = @inbox.build_inbox_assignment_policy(assignment_policy: @assignment_policy)
|
||||
|
||||
if @inbox_assignment_policy.save
|
||||
render json: {
|
||||
inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy)
|
||||
render json: {
|
||||
inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy)
|
||||
}, status: :created
|
||||
else
|
||||
render json: { errors: @inbox_assignment_policy.errors.full_messages }, status: :unprocessable_entity
|
||||
@@ -78,4 +78,4 @@ class Api::V1::Accounts::InboxAssignmentPoliciesController < Api::V1::Accounts::
|
||||
updated_at: inbox_assignment_policy.updated_at
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Accounts::Reports::AssignmentMetricsController < Api::V1::Accounts::BaseController
|
||||
include AssignmentMetricsHelper
|
||||
include DistributionMetrics
|
||||
|
||||
before_action :check_authorization
|
||||
before_action :validate_date_range
|
||||
|
||||
def index
|
||||
@metrics = compute_assignment_metrics
|
||||
@metrics = metrics_service.compute_assignment_metrics
|
||||
render json: { assignment_metrics: @metrics }
|
||||
end
|
||||
|
||||
def agent_history
|
||||
history_service = Reports::AgentHistoryService.new(Current.account, params)
|
||||
|
||||
if params[:agent_id].present?
|
||||
@agent = Current.account.users.find(params[:agent_id])
|
||||
@assignment_history = fetch_agent_assignment_history(@agent)
|
||||
@assignment_history = history_service.fetch_agent_assignment_history(@agent)
|
||||
|
||||
render json: {
|
||||
agent: serialize_agent(@agent),
|
||||
@@ -20,8 +25,7 @@ class Api::V1::Accounts::Reports::AssignmentMetricsController < Api::V1::Account
|
||||
meta: pagination_meta
|
||||
}
|
||||
else
|
||||
# Return all agents' history summary
|
||||
agents_summary = compute_all_agents_history
|
||||
agents_summary = history_service.compute_all_agents_history
|
||||
render json: { agents_history: agents_summary }
|
||||
end
|
||||
end
|
||||
@@ -29,7 +33,7 @@ class Api::V1::Accounts::Reports::AssignmentMetricsController < Api::V1::Account
|
||||
def policy_performance
|
||||
@policies = Current.account.assignment_policies.includes(:inboxes)
|
||||
performance_data = @policies.map do |policy|
|
||||
compute_policy_performance(policy)
|
||||
metrics_service.compute_policy_performance(policy)
|
||||
end
|
||||
|
||||
render json: { policy_performance: performance_data }
|
||||
@@ -38,7 +42,7 @@ class Api::V1::Accounts::Reports::AssignmentMetricsController < Api::V1::Account
|
||||
def agent_utilization
|
||||
agents = Current.account.users.joins(:account_users).where(account_users: { role: %w[agent administrator] })
|
||||
utilization_data = agents.map do |agent|
|
||||
compute_agent_utilization(agent)
|
||||
metrics_service.compute_agent_utilization(agent)
|
||||
end
|
||||
|
||||
render json: { agent_utilization: utilization_data }
|
||||
@@ -46,9 +50,9 @@ class Api::V1::Accounts::Reports::AssignmentMetricsController < Api::V1::Account
|
||||
|
||||
def assignment_distribution
|
||||
distribution_data = {
|
||||
by_inbox: compute_distribution_by_inbox,
|
||||
by_team: compute_distribution_by_team,
|
||||
by_hour: compute_distribution_by_hour,
|
||||
by_inbox: metrics_service.compute_distribution_by_inbox,
|
||||
by_team: metrics_service.compute_distribution_by_team,
|
||||
by_hour: metrics_service.compute_distribution_by_hour,
|
||||
by_day_of_week: compute_distribution_by_day_of_week
|
||||
}
|
||||
|
||||
@@ -57,11 +61,12 @@ class Api::V1::Accounts::Reports::AssignmentMetricsController < Api::V1::Account
|
||||
|
||||
def export
|
||||
type = params[:type] || 'csv'
|
||||
data = compute_assignment_metrics
|
||||
data = metrics_service.compute_assignment_metrics
|
||||
|
||||
case type
|
||||
when 'csv'
|
||||
send_data generate_csv(data), filename: "assignment_metrics_#{Date.current}.csv", type: 'text/csv'
|
||||
export_service = Reports::AssignmentExportService.new(data)
|
||||
send_data export_service.generate_csv, filename: "assignment_metrics_#{Date.current}.csv", type: 'text/csv'
|
||||
when 'json'
|
||||
send_data data.to_json, filename: "assignment_metrics_#{Date.current}.json", type: 'application/json'
|
||||
else
|
||||
@@ -71,310 +76,26 @@ class Api::V1::Accounts::Reports::AssignmentMetricsController < Api::V1::Account
|
||||
|
||||
private
|
||||
|
||||
def compute_assignment_metrics
|
||||
{
|
||||
summary: compute_summary_metrics,
|
||||
by_period: compute_period_metrics,
|
||||
by_inbox: compute_inbox_metrics,
|
||||
by_agent: compute_agent_metrics,
|
||||
by_policy: compute_policy_metrics
|
||||
}
|
||||
end
|
||||
|
||||
def compute_summary_metrics
|
||||
conversations = filter_conversations_by_date_range
|
||||
|
||||
{
|
||||
total_assignments: conversations.count,
|
||||
average_assignment_time: calculate_average_assignment_time(conversations),
|
||||
assignments_per_agent: calculate_assignments_per_agent(conversations),
|
||||
unassigned_conversations: Current.account.conversations.open.unassigned.count,
|
||||
policies_active: Current.account.assignment_policies.enabled.count
|
||||
}
|
||||
end
|
||||
|
||||
def compute_period_metrics
|
||||
group_by = params[:group_by] || 'day'
|
||||
conversations = filter_conversations_by_date_range
|
||||
|
||||
case group_by
|
||||
when 'hour'
|
||||
group_by_hour(conversations)
|
||||
when 'day'
|
||||
group_by_day(conversations)
|
||||
when 'week'
|
||||
group_by_week(conversations)
|
||||
when 'month'
|
||||
group_by_month(conversations)
|
||||
else
|
||||
group_by_day(conversations)
|
||||
end
|
||||
end
|
||||
|
||||
def compute_inbox_metrics
|
||||
inbox_id = params[:inbox_id]
|
||||
base_query = filter_conversations_by_date_range
|
||||
|
||||
base_query = base_query.where(inbox_id: inbox_id) if inbox_id.present?
|
||||
|
||||
base_query.joins(:inbox)
|
||||
.group('inboxes.id', 'inboxes.name')
|
||||
.count
|
||||
.map { |k, v| { inbox_id: k[0], inbox_name: k[1], assignment_count: v } }
|
||||
end
|
||||
|
||||
def compute_agent_metrics
|
||||
agent_id = params[:agent_id]
|
||||
base_query = filter_conversations_by_date_range.where.not(assignee_id: nil)
|
||||
|
||||
base_query = base_query.where(assignee_id: agent_id) if agent_id.present?
|
||||
|
||||
base_query.joins(:assignee)
|
||||
.group('users.id', 'users.name', 'users.email')
|
||||
.count
|
||||
.map { |k, v| { agent_id: k[0], agent_name: k[1], agent_email: k[2], assignment_count: v } }
|
||||
.sort_by { |a| -a[:assignment_count] }
|
||||
end
|
||||
|
||||
def compute_policy_metrics
|
||||
# Get metrics grouped by assignment policy
|
||||
policy_metrics = {}
|
||||
|
||||
Current.account.assignment_policies.includes(:inboxes).each do |policy|
|
||||
inbox_ids = policy.inboxes.pluck(:id)
|
||||
next if inbox_ids.empty?
|
||||
|
||||
conversations = filter_conversations_by_date_range.where(inbox_id: inbox_ids)
|
||||
|
||||
policy_metrics[policy.id] = {
|
||||
policy_id: policy.id,
|
||||
policy_name: policy.name,
|
||||
assignment_order: policy.assignment_order,
|
||||
total_assignments: conversations.count,
|
||||
average_assignment_time: calculate_average_assignment_time(conversations),
|
||||
inbox_count: inbox_ids.count
|
||||
}
|
||||
end
|
||||
|
||||
policy_metrics.values
|
||||
end
|
||||
|
||||
def fetch_agent_assignment_history(agent)
|
||||
conversations = agent.assigned_conversations
|
||||
.includes(:inbox, :contact)
|
||||
.where(created_at: date_range)
|
||||
.order(created_at: :desc)
|
||||
.page(params[:page])
|
||||
.per(params[:per_page] || 50)
|
||||
|
||||
conversations.map do |conversation|
|
||||
{
|
||||
conversation_id: conversation.id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
inbox_name: conversation.inbox.name,
|
||||
contact_name: conversation.contact.name,
|
||||
assigned_at: conversation.assignee_last_seen_at || conversation.created_at,
|
||||
status: conversation.status,
|
||||
created_at: conversation.created_at
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def filter_conversations_by_date_range
|
||||
Current.account.conversations.where(created_at: date_range)
|
||||
end
|
||||
|
||||
def date_range
|
||||
start_date = params[:start_date] ? Date.parse(params[:start_date]).beginning_of_day : 30.days.ago
|
||||
end_date = params[:end_date] ? Date.parse(params[:end_date]).end_of_day : Time.current
|
||||
|
||||
start_date..end_date
|
||||
end
|
||||
|
||||
def validate_date_range
|
||||
if params[:start_date].present? && params[:end_date].present?
|
||||
start_date = Date.parse(params[:start_date])
|
||||
end_date = Date.parse(params[:end_date])
|
||||
return if params[:since].blank? || params[:until].blank?
|
||||
|
||||
if start_date > end_date
|
||||
render json: { error: 'Start date must be before end date' }, status: :bad_request
|
||||
elsif (end_date - start_date).to_i > 365
|
||||
render json: { error: 'Date range cannot exceed 365 days' }, status: :bad_request
|
||||
end
|
||||
start_date = Date.parse(params[:since])
|
||||
end_date = Date.parse(params[:until])
|
||||
|
||||
if start_date > end_date
|
||||
render json: { error: 'Start date must be before end date' }, status: :unprocessable_entity
|
||||
elsif (end_date - start_date).to_i > 365
|
||||
render json: { error: 'Date range cannot exceed 365 days' }, status: :unprocessable_entity
|
||||
end
|
||||
rescue Date::Error
|
||||
render json: { error: 'Invalid date format' }, status: :bad_request
|
||||
end
|
||||
|
||||
def calculate_average_assignment_time(conversations)
|
||||
assigned_conversations = conversations.where.not(assignee_id: nil)
|
||||
return 0 if assigned_conversations.empty?
|
||||
|
||||
total_time = assigned_conversations.sum do |conv|
|
||||
assignment_time = conv.assignee_last_seen_at || conv.updated_at
|
||||
(assignment_time - conv.created_at).to_i
|
||||
end
|
||||
|
||||
(total_time / assigned_conversations.count / 60).round(2) # Return in minutes
|
||||
end
|
||||
|
||||
def calculate_assignments_per_agent(conversations)
|
||||
assigned_count = conversations.where.not(assignee_id: nil).count
|
||||
agent_count = conversations.where.not(assignee_id: nil).distinct.count(:assignee_id)
|
||||
|
||||
return 0 if agent_count.zero?
|
||||
|
||||
(assigned_count.to_f / agent_count).round(2)
|
||||
end
|
||||
|
||||
def group_by_hour(conversations)
|
||||
conversations.group_by_hour(:created_at).count
|
||||
end
|
||||
|
||||
def group_by_day(conversations)
|
||||
conversations.group_by_day(:created_at).count
|
||||
end
|
||||
|
||||
def group_by_week(conversations)
|
||||
conversations.group_by_week(:created_at).count
|
||||
end
|
||||
|
||||
def group_by_month(conversations)
|
||||
conversations.group_by_month(:created_at).count
|
||||
end
|
||||
|
||||
def serialize_agent(agent)
|
||||
{
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
email: agent.email,
|
||||
avatar_url: agent.avatar_url
|
||||
}
|
||||
end
|
||||
|
||||
def pagination_meta
|
||||
{
|
||||
current_page: params[:page] || 1,
|
||||
per_page: params[:per_page] || 50
|
||||
}
|
||||
render json: { error: 'Invalid date format' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(Conversation, :index?)
|
||||
authorize Current.account, :show_metrics?
|
||||
end
|
||||
|
||||
def compute_all_agents_history
|
||||
agents = Current.account.users.joins(:account_users).where(account_users: { role: %w[agent administrator] })
|
||||
agents.map do |agent|
|
||||
conversations = agent.assigned_conversations.where(created_at: date_range)
|
||||
{
|
||||
agent: serialize_agent(agent),
|
||||
total_assignments: conversations.count,
|
||||
average_resolution_time: calculate_average_resolution_time(conversations),
|
||||
active_conversations: conversations.open.count
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def compute_policy_performance(policy)
|
||||
inbox_ids = policy.inboxes.pluck(:id)
|
||||
conversations = filter_conversations_by_date_range.where(inbox_id: inbox_ids)
|
||||
|
||||
{
|
||||
policy_id: policy.id,
|
||||
policy_name: policy.name,
|
||||
assignment_order: policy.assignment_order,
|
||||
total_assignments: conversations.count,
|
||||
average_assignment_time: calculate_average_assignment_time(conversations),
|
||||
success_rate: calculate_assignment_success_rate(conversations),
|
||||
agent_count: policy.account_users.count,
|
||||
inbox_count: inbox_ids.count
|
||||
}
|
||||
end
|
||||
|
||||
def compute_agent_utilization(agent)
|
||||
capacity_policy = agent.account_users.first&.agent_capacity_policy
|
||||
assigned_conversations = agent.assigned_conversations.open.count
|
||||
|
||||
utilization = if capacity_policy
|
||||
policy_limit = capacity_policy.inbox_capacity_limits.sum(:conversation_limit)
|
||||
policy_limit > 0 ? (assigned_conversations.to_f / policy_limit * 100).round(2) : 0
|
||||
else
|
||||
0
|
||||
end
|
||||
|
||||
{
|
||||
agent: serialize_agent(agent),
|
||||
assigned_conversations: assigned_conversations,
|
||||
capacity_policy: capacity_policy&.name,
|
||||
utilization_percentage: utilization,
|
||||
available_capacity: capacity_policy ? capacity_policy.inbox_capacity_limits.sum(:conversation_limit) - assigned_conversations : nil
|
||||
}
|
||||
end
|
||||
|
||||
def compute_distribution_by_inbox
|
||||
filter_conversations_by_date_range
|
||||
.joins(:inbox)
|
||||
.group('inboxes.id', 'inboxes.name')
|
||||
.count
|
||||
.map { |k, v| { inbox_id: k[0], inbox_name: k[1], count: v } }
|
||||
end
|
||||
|
||||
def compute_distribution_by_team
|
||||
filter_conversations_by_date_range
|
||||
.joins(assignee: { team_members: :team })
|
||||
.group('teams.id', 'teams.name')
|
||||
.count
|
||||
.map { |k, v| { team_id: k[0], team_name: k[1], count: v } }
|
||||
end
|
||||
|
||||
def compute_distribution_by_hour
|
||||
filter_conversations_by_date_range
|
||||
.group_by_hour_of_day(:created_at)
|
||||
.count
|
||||
end
|
||||
|
||||
def compute_distribution_by_day_of_week
|
||||
filter_conversations_by_date_range
|
||||
.group_by_day_of_week(:created_at)
|
||||
.count
|
||||
end
|
||||
|
||||
def calculate_average_resolution_time(conversations)
|
||||
resolved = conversations.resolved
|
||||
return 0 if resolved.empty?
|
||||
|
||||
total_time = resolved.sum { |c| (c.resolved_at - c.created_at).to_i }
|
||||
(total_time / resolved.count / 3600).round(2) # Return in hours
|
||||
end
|
||||
|
||||
def calculate_assignment_success_rate(conversations)
|
||||
total = conversations.count
|
||||
return 0 if total.zero?
|
||||
|
||||
assigned = conversations.where.not(assignee_id: nil).count
|
||||
(assigned.to_f / total * 100).round(2)
|
||||
end
|
||||
|
||||
def generate_csv(data)
|
||||
require 'csv'
|
||||
|
||||
CSV.generate(headers: true) do |csv|
|
||||
# Summary metrics
|
||||
csv << ['Summary Metrics']
|
||||
csv << %w[Metric Value]
|
||||
data[:summary].each do |key, value|
|
||||
csv << [key.to_s.humanize, value]
|
||||
end
|
||||
|
||||
csv << []
|
||||
|
||||
# Agent metrics
|
||||
csv << ['Agent Metrics']
|
||||
csv << ['Agent Name', 'Email', 'Assignment Count']
|
||||
data[:by_agent].each do |agent|
|
||||
csv << [agent[:agent_name], agent[:agent_email], agent[:assignment_count]]
|
||||
end
|
||||
end
|
||||
def metrics_service
|
||||
@metrics_service ||= Reports::AssignmentMetricsService.new(Current.account, params)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AssignmentMetricsHelper
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def compute_summary_metrics
|
||||
conversations = filter_conversations_by_date_range
|
||||
|
||||
{
|
||||
total_assignments: conversations.count,
|
||||
average_assignment_time: calculate_average_assignment_time(conversations),
|
||||
assignments_per_agent: calculate_assignments_per_agent(conversations),
|
||||
unassigned_conversations: Current.account.conversations.open.unassigned.count,
|
||||
policies_active: Current.account.assignment_policies.enabled.count
|
||||
}
|
||||
end
|
||||
|
||||
def compute_inbox_metrics
|
||||
inboxes = params[:inbox_id].present? ? Current.account.inboxes.where(id: params[:inbox_id]) : Current.account.inboxes
|
||||
|
||||
inboxes.map do |inbox|
|
||||
conversations = filter_conversations_by_date_range.where(inbox_id: inbox.id)
|
||||
{
|
||||
inbox_id: inbox.id,
|
||||
inbox_name: inbox.name,
|
||||
total_assignments: conversations.count,
|
||||
average_assignment_time: calculate_average_assignment_time(conversations),
|
||||
unique_agents: conversations.where.not(assignee_id: nil).distinct.count(:assignee_id),
|
||||
assignment_policy: inbox.assignment_policy&.name
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def compute_agent_metrics
|
||||
agents = Current.account.users.joins(:account_users).where(account_users: { role: %w[agent administrator] })
|
||||
|
||||
agents.map do |agent|
|
||||
conversations = filter_conversations_by_date_range.where(assignee_id: agent.id)
|
||||
{
|
||||
agent_id: agent.id,
|
||||
agent_name: agent.name,
|
||||
agent_email: agent.email,
|
||||
assignment_count: conversations.count,
|
||||
average_resolution_time: calculate_average_resolution_time(conversations),
|
||||
current_load: agent.assigned_conversations.open.count,
|
||||
capacity_utilization: compute_agent_capacity_utilization(agent)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def compute_policy_metrics
|
||||
Current.account.assignment_policies.enabled.map do |policy|
|
||||
compute_policy_performance(policy)
|
||||
end
|
||||
end
|
||||
|
||||
def filter_conversations_by_date_range
|
||||
Current.account.conversations.where(created_at: date_range)
|
||||
end
|
||||
|
||||
def date_range
|
||||
@date_range ||= params[:since]..params[:until]
|
||||
end
|
||||
|
||||
def calculate_average_assignment_time(_conversations)
|
||||
# Implementation
|
||||
0
|
||||
end
|
||||
|
||||
def calculate_assignments_per_agent(conversations)
|
||||
agents_count = conversations.where.not(assignee_id: nil).distinct.count(:assignee_id)
|
||||
return 0 if agents_count.zero?
|
||||
|
||||
(conversations.count.to_f / agents_count).round(2)
|
||||
end
|
||||
|
||||
def calculate_average_resolution_time(_conversations)
|
||||
# Implementation
|
||||
0
|
||||
end
|
||||
|
||||
def compute_agent_capacity_utilization(_agent)
|
||||
# Implementation
|
||||
0.0
|
||||
end
|
||||
|
||||
def calculate_assignment_success_rate(_conversations)
|
||||
# Implementation
|
||||
100.0
|
||||
end
|
||||
|
||||
def serialize_agent(agent)
|
||||
{
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
email: agent.email
|
||||
}
|
||||
end
|
||||
|
||||
def group_by_hour(conversations)
|
||||
conversations.group_by_hour(:created_at).count
|
||||
end
|
||||
|
||||
def group_by_day(conversations)
|
||||
conversations.group_by_day(:created_at).count
|
||||
end
|
||||
|
||||
def group_by_week(conversations)
|
||||
conversations.group_by_week(:created_at).count
|
||||
end
|
||||
|
||||
def group_by_month(conversations)
|
||||
conversations.group_by_month(:created_at).count
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,92 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DistributionMetrics
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def compute_distribution_by_day_of_week
|
||||
filter_conversations_by_date_range
|
||||
.group_by_day_of_week(:created_at)
|
||||
.count
|
||||
end
|
||||
|
||||
def compute_policy_performance(policy)
|
||||
inbox_ids = policy.inboxes.pluck(:id)
|
||||
conversations = filter_conversations_by_date_range.where(inbox_id: inbox_ids)
|
||||
|
||||
{
|
||||
policy_id: policy.id,
|
||||
policy_name: policy.name,
|
||||
assignment_order: policy.assignment_order,
|
||||
total_assignments: conversations.count,
|
||||
average_assignment_time: calculate_average_assignment_time(conversations),
|
||||
success_rate: calculate_assignment_success_rate(conversations),
|
||||
inbox_count: inbox_ids.count
|
||||
}
|
||||
end
|
||||
|
||||
def compute_agent_utilization(agent)
|
||||
capacity_policy = agent.account_users.first&.agent_capacity_policy
|
||||
assigned_conversations = agent.assigned_conversations.open.count
|
||||
|
||||
utilization = if capacity_policy
|
||||
policy_limit = capacity_policy.inbox_capacity_limits.sum(:conversation_limit)
|
||||
policy_limit.positive? ? (assigned_conversations.to_f / policy_limit * 100).round(2) : 0
|
||||
else
|
||||
0
|
||||
end
|
||||
|
||||
{
|
||||
agent: serialize_agent(agent),
|
||||
assigned_conversations: assigned_conversations,
|
||||
capacity_policy: capacity_policy&.name,
|
||||
utilization_percentage: utilization,
|
||||
available_capacity: capacity_policy ? capacity_policy.inbox_capacity_limits.sum(:conversation_limit) - assigned_conversations : nil
|
||||
}
|
||||
end
|
||||
|
||||
def compute_distribution_by_inbox
|
||||
filter_conversations_by_date_range
|
||||
.joins(:inbox)
|
||||
.group('inboxes.id', 'inboxes.name')
|
||||
.count
|
||||
.map { |k, v| { inbox_id: k[0], inbox_name: k[1], count: v } }
|
||||
end
|
||||
|
||||
def compute_distribution_by_team
|
||||
filter_conversations_by_date_range
|
||||
.joins(assignee: { team_members: :team })
|
||||
.group('teams.id', 'teams.name')
|
||||
.count
|
||||
.map { |k, v| { team_id: k[0], team_name: k[1], count: v } }
|
||||
end
|
||||
|
||||
def compute_distribution_by_hour
|
||||
filter_conversations_by_date_range
|
||||
.group_by_hour_of_day(:created_at)
|
||||
.count
|
||||
end
|
||||
|
||||
def calculate_average_resolution_time(conversations)
|
||||
resolved = conversations.resolved
|
||||
return 0 if resolved.empty?
|
||||
|
||||
total_time = resolved.sum { |c| (c.last_activity_at - c.created_at) / 1.hour }
|
||||
(total_time / resolved.count).round(2)
|
||||
end
|
||||
|
||||
def calculate_assignment_success_rate(conversations)
|
||||
total = conversations.count
|
||||
return 0.0 if total.zero?
|
||||
|
||||
successful = conversations.where(status: %w[resolved snoozed]).count
|
||||
(successful.to_f / total * 100).round(2)
|
||||
end
|
||||
|
||||
def pagination_meta
|
||||
{
|
||||
current_page: params[:page] || 1,
|
||||
per_page: params[:per_page] || 50,
|
||||
total_count: @assignment_history&.total_count || 0
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -33,4 +33,4 @@ class AssignmentV2::AssignmentJob < ApplicationJob
|
||||
|
||||
Rails.logger.info "AssignmentV2::AssignmentJob: Assigned #{assigned_count} conversations for inbox #{inbox_id}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,11 +8,11 @@ class ReassignConversationsJob < ApplicationJob
|
||||
|
||||
user = account_user.user
|
||||
account = account_user.account
|
||||
|
||||
|
||||
# Find all open conversations assigned to this user
|
||||
conversations = account.conversations
|
||||
.open
|
||||
.where(assignee: user)
|
||||
.open
|
||||
.where(assignee: user)
|
||||
|
||||
Rails.logger.info "Reassigning #{conversations.count} conversations for user #{user.name} (#{user.id}) on leave"
|
||||
|
||||
@@ -25,14 +25,14 @@ class ReassignConversationsJob < ApplicationJob
|
||||
|
||||
def reassign_conversation(conversation)
|
||||
inbox = conversation.inbox
|
||||
|
||||
|
||||
# Use Assignment V2 if enabled
|
||||
if inbox.assignment_v2_enabled?
|
||||
assignment_service = AssignmentV2::AssignmentService.new(inbox: inbox)
|
||||
|
||||
|
||||
# Mark conversation as unassigned first
|
||||
conversation.update!(assignee: nil)
|
||||
|
||||
|
||||
# Let the assignment service handle it
|
||||
assignment_service.perform_for_conversation(conversation)
|
||||
else
|
||||
@@ -45,4 +45,4 @@ class ReassignConversationsJob < ApplicationJob
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to reassign conversation #{conversation.id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,17 +21,17 @@ module AssignmentV2FeatureFlag
|
||||
return false unless config&.dig('enabled')
|
||||
|
||||
# If no account allowlist, enable for all
|
||||
allowed_accounts = config.dig('accounts')
|
||||
allowed_accounts = config['accounts']
|
||||
return true if allowed_accounts.blank?
|
||||
|
||||
# Check if account is in allowlist
|
||||
allowed_accounts.include?(self.id)
|
||||
allowed_accounts.include?(id)
|
||||
end
|
||||
|
||||
def inbox_level_override_disabled?
|
||||
return false unless respond_to?(:id)
|
||||
|
||||
|
||||
# Allow per-inbox disabling during migration
|
||||
GlobalConfig.get("assignment_v2_disabled_inboxes", []).include?(self.id)
|
||||
GlobalConfig.get('assignment_v2_disabled_inboxes', []).include?(id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module InboxAgentAvailability
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def available_agents(options = {})
|
||||
options = { check_capacity: true }.merge(options)
|
||||
|
||||
# Get online agent IDs
|
||||
online_agent_ids = fetch_online_agent_ids
|
||||
return inbox_members.none if online_agent_ids.empty?
|
||||
|
||||
# Base query - only online agents
|
||||
scope = build_online_agents_scope(online_agent_ids)
|
||||
|
||||
# Apply filters
|
||||
apply_agent_filters(scope, options)
|
||||
end
|
||||
|
||||
def member_ids_with_assignment_capacity
|
||||
return member_ids unless assignment_v2_enabled? && enterprise_capacity_enabled?
|
||||
|
||||
available_agents(check_capacity: true).pluck(:user_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_online_agents_scope(online_agent_ids)
|
||||
inbox_members
|
||||
.joins(:user)
|
||||
.where(users: { id: online_agent_ids })
|
||||
.includes(:user)
|
||||
end
|
||||
|
||||
def apply_agent_filters(scope, options)
|
||||
# Exclude specific users if requested
|
||||
scope = scope.where.not(users: { id: options[:exclude_user_ids] }) if options[:exclude_user_ids].present?
|
||||
|
||||
# Apply capacity filtering for enterprise accounts
|
||||
scope = filter_by_capacity(scope) if options[:check_capacity] && enterprise_capacity_enabled?
|
||||
|
||||
# Apply rate limiting if implemented
|
||||
scope = filter_by_rate_limits(scope) if options[:check_rate_limits] && defined?(AssignmentV2::RateLimiter)
|
||||
|
||||
# Exclude agents who are on leave
|
||||
scope = filter_agents_on_leave(scope) if options[:exclude_on_leave] != false
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
def fetch_online_agent_ids
|
||||
OnlineStatusTracker.get_available_users(account_id)
|
||||
.select { |_key, value| value.eql?('online') }
|
||||
.keys
|
||||
.map(&:to_i)
|
||||
end
|
||||
|
||||
def enterprise_capacity_enabled?
|
||||
defined?(Enterprise) &&
|
||||
account.custom_attributes&.dig('enterprise_features', 'capacity_management').present?
|
||||
end
|
||||
|
||||
def filter_by_capacity(inbox_members_scope)
|
||||
return inbox_members_scope unless capacity_check_required?
|
||||
|
||||
assignment_counts = fetch_assignment_counts
|
||||
|
||||
inbox_members_scope.select do |inbox_member|
|
||||
agent_has_capacity?(inbox_member, assignment_counts)
|
||||
end
|
||||
end
|
||||
|
||||
def capacity_check_required?
|
||||
defined?(Enterprise::InboxCapacityLimit) &&
|
||||
account.account_users.joins(:agent_capacity_policy).exists?
|
||||
end
|
||||
|
||||
def fetch_assignment_counts
|
||||
conversations
|
||||
.where(status: :open)
|
||||
.where.not(assignee_id: nil)
|
||||
.group(:assignee_id)
|
||||
.count
|
||||
end
|
||||
|
||||
def agent_has_capacity?(inbox_member, assignment_counts)
|
||||
user = inbox_member.user
|
||||
account_user = account.account_users.find_by(user: user)
|
||||
|
||||
return true unless account_user&.agent_capacity_policy_id
|
||||
|
||||
capacity_limit = fetch_capacity_limit(account_user.agent_capacity_policy_id)
|
||||
return true unless capacity_limit&.conversation_limit
|
||||
|
||||
current_count = assignment_counts[user.id] || 0
|
||||
current_count < capacity_limit.conversation_limit
|
||||
end
|
||||
|
||||
def fetch_capacity_limit(policy_id)
|
||||
Enterprise::InboxCapacityLimit
|
||||
.where(agent_capacity_policy_id: policy_id)
|
||||
.find_by(inbox_id: id)
|
||||
end
|
||||
|
||||
def filter_by_rate_limits(inbox_members_scope)
|
||||
# Filter out agents who have exceeded rate limits
|
||||
return inbox_members_scope unless assignment_policy&.enabled?
|
||||
|
||||
inbox_members_scope.select do |inbox_member|
|
||||
rate_limiter = AssignmentV2::RateLimiter.new(inbox: self, user: inbox_member.user)
|
||||
rate_limiter.within_limits?
|
||||
end
|
||||
end
|
||||
|
||||
def filter_agents_on_leave(inbox_members_scope)
|
||||
return inbox_members_scope unless defined?(Enterprise::AgentLeave)
|
||||
|
||||
# Filter out agents who are currently on leave
|
||||
on_leave_user_ids = Enterprise::AgentLeave
|
||||
.active
|
||||
.where(account_id: account_id)
|
||||
.pluck(:user_id)
|
||||
|
||||
return inbox_members_scope if on_leave_user_ids.empty?
|
||||
|
||||
inbox_members_scope.where.not(user_id: on_leave_user_ids)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module InboxChannelTypes
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def sms?
|
||||
channel_type == 'Channel::Sms'
|
||||
end
|
||||
|
||||
def facebook?
|
||||
channel_type == 'Channel::FacebookPage'
|
||||
end
|
||||
|
||||
def instagram?
|
||||
(facebook? || instagram_direct?) && channel.instagram_id.present?
|
||||
end
|
||||
|
||||
def instagram_direct?
|
||||
channel_type == 'Channel::Instagram'
|
||||
end
|
||||
|
||||
def web_widget?
|
||||
channel_type == 'Channel::WebWidget'
|
||||
end
|
||||
|
||||
def api?
|
||||
channel_type == 'Channel::Api'
|
||||
end
|
||||
|
||||
def email?
|
||||
channel_type == 'Channel::Email'
|
||||
end
|
||||
|
||||
def twilio?
|
||||
channel_type == 'Channel::TwilioSms'
|
||||
end
|
||||
|
||||
def twitter?
|
||||
channel_type == 'Channel::TwitterProfile'
|
||||
end
|
||||
|
||||
def whatsapp?
|
||||
channel_type == 'Channel::Whatsapp'
|
||||
end
|
||||
|
||||
def inbox_type
|
||||
channel.name
|
||||
end
|
||||
|
||||
def active_bot?
|
||||
agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow],
|
||||
status: 'enabled').count.positive?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module InboxNameSanitization
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_validation :sanitize_name
|
||||
end
|
||||
|
||||
# Sanitizes inbox name for balanced email provider compatibility
|
||||
# ALLOWS: /'._- and Unicode letters/numbers/emojis
|
||||
# REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
|
||||
def sanitized_name
|
||||
return default_name_for_blank_name if name.blank?
|
||||
|
||||
sanitized = apply_sanitization_rules(name)
|
||||
sanitized.blank? && email? ? display_name_from_email : sanitized
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sanitize_name
|
||||
self.name = default_name_for_blank_name if name.blank?
|
||||
self.name = apply_sanitization_rules(name) if name.present?
|
||||
end
|
||||
|
||||
def default_name_for_blank_name
|
||||
return channel.try(:bot_name) if web_widget?
|
||||
|
||||
readable_name = display_name_from_email if email?
|
||||
readable_name ||= 'Inbox'
|
||||
"#{readable_name} #{SecureRandom.hex(4)}"
|
||||
end
|
||||
|
||||
def apply_sanitization_rules(name)
|
||||
name_without_special_characters = name.gsub(/[^a-zA-Z0-9\s]/, ' ')
|
||||
name_without_special_characters.gsub(/\s+/, ' ').strip
|
||||
end
|
||||
|
||||
def display_name_from_email
|
||||
channel.try(:imap_email)&.split('@')&.first&.capitalize
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module InboxWebhooks
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def webhook_data
|
||||
{
|
||||
id: id,
|
||||
name: name
|
||||
}
|
||||
end
|
||||
|
||||
def callback_webhook_url
|
||||
case channel_type
|
||||
when 'Channel::TwilioSms'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/callback"
|
||||
when 'Channel::Sms'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}"
|
||||
when 'Channel::Line'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/line/#{channel.line_channel_id}"
|
||||
when 'Channel::Whatsapp'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
|
||||
end
|
||||
end
|
||||
end
|
||||
+69
-159
@@ -45,6 +45,10 @@ class Inbox < ApplicationRecord
|
||||
include OutOfOffisable
|
||||
include AccountCacheRevalidator
|
||||
include AssignmentV2FeatureFlag
|
||||
include InboxAgentAvailability
|
||||
include InboxChannelTypes
|
||||
include InboxWebhooks
|
||||
include InboxNameSanitization
|
||||
|
||||
# Not allowing characters:
|
||||
validates :name, presence: true
|
||||
@@ -72,7 +76,7 @@ class Inbox < ApplicationRecord
|
||||
has_one :agent_bot, through: :agent_bot_inbox
|
||||
has_many :webhooks, dependent: :destroy_async
|
||||
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
|
||||
|
||||
|
||||
# Assignment V2 associations
|
||||
has_one :inbox_assignment_policy, dependent: :destroy
|
||||
has_one :assignment_policy, through: :inbox_assignment_policy
|
||||
@@ -102,93 +106,10 @@ class Inbox < ApplicationRecord
|
||||
update_account_cache
|
||||
end
|
||||
|
||||
# Sanitizes inbox name for balanced email provider compatibility
|
||||
# ALLOWS: /'._- and Unicode letters/numbers/emojis
|
||||
# REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
|
||||
def sanitized_name
|
||||
return default_name_for_blank_name if name.blank?
|
||||
|
||||
sanitized = apply_sanitization_rules(name)
|
||||
sanitized.blank? && email? ? display_name_from_email : sanitized
|
||||
end
|
||||
|
||||
def sms?
|
||||
channel_type == 'Channel::Sms'
|
||||
end
|
||||
|
||||
def facebook?
|
||||
channel_type == 'Channel::FacebookPage'
|
||||
end
|
||||
|
||||
def instagram?
|
||||
(facebook? || instagram_direct?) && channel.instagram_id.present?
|
||||
end
|
||||
|
||||
def instagram_direct?
|
||||
channel_type == 'Channel::Instagram'
|
||||
end
|
||||
|
||||
def web_widget?
|
||||
channel_type == 'Channel::WebWidget'
|
||||
end
|
||||
|
||||
def api?
|
||||
channel_type == 'Channel::Api'
|
||||
end
|
||||
|
||||
def email?
|
||||
channel_type == 'Channel::Email'
|
||||
end
|
||||
|
||||
def twilio?
|
||||
channel_type == 'Channel::TwilioSms'
|
||||
end
|
||||
|
||||
def twitter?
|
||||
channel_type == 'Channel::TwitterProfile'
|
||||
end
|
||||
|
||||
def whatsapp?
|
||||
channel_type == 'Channel::Whatsapp'
|
||||
end
|
||||
|
||||
def assignable_agents
|
||||
(account.users.where(id: members.select(:user_id)) + account.administrators).uniq
|
||||
end
|
||||
|
||||
def active_bot?
|
||||
agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow],
|
||||
status: 'enabled').count.positive?
|
||||
end
|
||||
|
||||
def inbox_type
|
||||
channel.name
|
||||
end
|
||||
|
||||
def webhook_data
|
||||
{
|
||||
id: id,
|
||||
name: name
|
||||
}
|
||||
end
|
||||
|
||||
def callback_webhook_url
|
||||
case channel_type
|
||||
when 'Channel::TwilioSms'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/callback"
|
||||
when 'Channel::Sms'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}"
|
||||
when 'Channel::Line'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/line/#{channel.line_channel_id}"
|
||||
when 'Channel::Whatsapp'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
|
||||
end
|
||||
end
|
||||
|
||||
def member_ids_with_assignment_capacity
|
||||
members.ids
|
||||
end
|
||||
|
||||
# Assignment V2 methods
|
||||
def assignment_v2_enabled?
|
||||
account.assignment_v2_enabled? && assignment_policy.present? && assignment_policy.enabled?
|
||||
@@ -204,7 +125,7 @@ class Inbox < ApplicationRecord
|
||||
|
||||
# Returns inbox members who are available for assignment
|
||||
# This method performs all filtering upfront at the database level for optimal performance
|
||||
#
|
||||
#
|
||||
# Filters applied:
|
||||
# 1. Online status - Only agents marked as 'online' in OnlineStatusTracker
|
||||
# 2. Capacity limits (Enterprise) - Agents who haven't reached their conversation limit
|
||||
@@ -215,7 +136,7 @@ class Inbox < ApplicationRecord
|
||||
# @option options [Boolean] :check_capacity (true) Whether to check capacity limits
|
||||
# @option options [Boolean] :check_rate_limits (false) Whether to check rate limits
|
||||
# @option options [Array<Integer>] :exclude_user_ids Users to exclude from results
|
||||
#
|
||||
#
|
||||
# @return [ActiveRecord::Relation<InboxMember>] Available inbox members with preloaded users
|
||||
#
|
||||
# @example Get all available agents
|
||||
@@ -228,96 +149,101 @@ class Inbox < ApplicationRecord
|
||||
# inbox.available_agents(check_capacity: false)
|
||||
def available_agents(options = {})
|
||||
options = { check_capacity: true }.merge(options)
|
||||
|
||||
|
||||
# Get online agent IDs
|
||||
online_agent_ids = fetch_online_agent_ids
|
||||
return inbox_members.none if online_agent_ids.empty?
|
||||
|
||||
# Base query - only online agents
|
||||
scope = inbox_members
|
||||
.joins(:user)
|
||||
.where(users: { id: online_agent_ids })
|
||||
.includes(:user)
|
||||
scope = build_online_agents_scope(online_agent_ids)
|
||||
|
||||
# Apply filters
|
||||
apply_agent_filters(scope, options)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_online_agents_scope(online_agent_ids)
|
||||
inbox_members
|
||||
.joins(:user)
|
||||
.where(users: { id: online_agent_ids })
|
||||
.includes(:user)
|
||||
end
|
||||
|
||||
def apply_agent_filters(scope, options)
|
||||
# Exclude specific users if requested
|
||||
if options[:exclude_user_ids].present?
|
||||
scope = scope.where.not(users: { id: options[:exclude_user_ids] })
|
||||
end
|
||||
scope = scope.where.not(users: { id: options[:exclude_user_ids] }) if options[:exclude_user_ids].present?
|
||||
|
||||
# Apply capacity filtering for enterprise accounts
|
||||
if options[:check_capacity] && enterprise_capacity_enabled?
|
||||
scope = filter_by_capacity(scope)
|
||||
end
|
||||
scope = filter_by_capacity(scope) if options[:check_capacity] && enterprise_capacity_enabled?
|
||||
|
||||
# Apply rate limiting if implemented
|
||||
if options[:check_rate_limits] && defined?(AssignmentV2::RateLimiter)
|
||||
scope = filter_by_rate_limits(scope)
|
||||
end
|
||||
scope = filter_by_rate_limits(scope) if options[:check_rate_limits] && defined?(AssignmentV2::RateLimiter)
|
||||
|
||||
# Exclude agents who are on leave
|
||||
if options[:exclude_on_leave] != false
|
||||
scope = filter_agents_on_leave(scope)
|
||||
end
|
||||
scope = filter_agents_on_leave(scope) if options[:exclude_on_leave] != false
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
|
||||
private
|
||||
|
||||
def fetch_online_agent_ids
|
||||
OnlineStatusTracker.get_available_users(account_id)
|
||||
.select { |_key, value| value.eql?('online') }
|
||||
.keys
|
||||
.map(&:to_i)
|
||||
.select { |_key, value| value.eql?('online') }
|
||||
.keys
|
||||
.map(&:to_i)
|
||||
end
|
||||
|
||||
def enterprise_capacity_enabled?
|
||||
defined?(Enterprise) &&
|
||||
defined?(Enterprise) &&
|
||||
account.custom_attributes&.dig('enterprise_features', 'capacity_management').present?
|
||||
end
|
||||
|
||||
def filter_by_capacity(inbox_members_scope)
|
||||
return inbox_members_scope unless defined?(Enterprise::InboxCapacityLimit)
|
||||
return inbox_members_scope unless capacity_check_required?
|
||||
|
||||
# For simple cases without capacity policies, return all agents
|
||||
if !account.account_users.joins(:agent_capacity_policy).exists?
|
||||
return inbox_members_scope
|
||||
end
|
||||
assignment_counts = fetch_assignment_counts
|
||||
|
||||
# Get current assignment counts for all agents
|
||||
assignment_counts = conversations
|
||||
.where(status: :open)
|
||||
.where.not(assignee_id: nil)
|
||||
.group(:assignee_id)
|
||||
.count
|
||||
|
||||
# Filter agents based on capacity
|
||||
inbox_members_scope.select do |inbox_member|
|
||||
user = inbox_member.user
|
||||
account_user = account.account_users.find_by(user: user)
|
||||
|
||||
# If no capacity policy, allow assignment
|
||||
next true unless account_user&.agent_capacity_policy_id
|
||||
|
||||
# Check if there's a limit for this inbox
|
||||
capacity_limit = Enterprise::InboxCapacityLimit
|
||||
.where(agent_capacity_policy_id: account_user.agent_capacity_policy_id)
|
||||
.find_by(inbox_id: id)
|
||||
|
||||
# If no limit defined for this inbox, allow assignment
|
||||
next true unless capacity_limit&.conversation_limit
|
||||
|
||||
# Check current assignments against limit
|
||||
current_count = assignment_counts[user.id] || 0
|
||||
current_count < capacity_limit.conversation_limit
|
||||
agent_has_capacity?(inbox_member, assignment_counts)
|
||||
end
|
||||
end
|
||||
|
||||
def capacity_check_required?
|
||||
defined?(Enterprise::InboxCapacityLimit) &&
|
||||
account.account_users.joins(:agent_capacity_policy).exists?
|
||||
end
|
||||
|
||||
def fetch_assignment_counts
|
||||
conversations
|
||||
.where(status: :open)
|
||||
.where.not(assignee_id: nil)
|
||||
.group(:assignee_id)
|
||||
.count
|
||||
end
|
||||
|
||||
def agent_has_capacity?(inbox_member, assignment_counts)
|
||||
user = inbox_member.user
|
||||
account_user = account.account_users.find_by(user: user)
|
||||
|
||||
return true unless account_user&.agent_capacity_policy_id
|
||||
|
||||
capacity_limit = fetch_capacity_limit(account_user.agent_capacity_policy_id)
|
||||
return true unless capacity_limit&.conversation_limit
|
||||
|
||||
current_count = assignment_counts[user.id] || 0
|
||||
current_count < capacity_limit.conversation_limit
|
||||
end
|
||||
|
||||
def fetch_capacity_limit(policy_id)
|
||||
Enterprise::InboxCapacityLimit
|
||||
.where(agent_capacity_policy_id: policy_id)
|
||||
.find_by(inbox_id: id)
|
||||
end
|
||||
|
||||
def filter_by_rate_limits(inbox_members_scope)
|
||||
# Filter out agents who have exceeded rate limits
|
||||
return inbox_members_scope unless assignment_policy&.enabled?
|
||||
|
||||
|
||||
inbox_members_scope.select do |inbox_member|
|
||||
rate_limiter = AssignmentV2::RateLimiter.new(inbox: self, user: inbox_member.user)
|
||||
rate_limiter.within_limits?
|
||||
@@ -331,30 +257,14 @@ class Inbox < ApplicationRecord
|
||||
.where(leaves: { status: 'approved' })
|
||||
.where('leaves.start_date <= ? AND leaves.end_date >= ?', Date.current, Date.current)
|
||||
.pluck(:id)
|
||||
|
||||
|
||||
return inbox_members_scope if account_user_ids_on_leave.empty?
|
||||
|
||||
|
||||
# Exclude inbox members whose account_users are on leave
|
||||
user_ids_on_leave = account.account_users.where(id: account_user_ids_on_leave).pluck(:user_id)
|
||||
inbox_members_scope.where.not(user_id: user_ids_on_leave)
|
||||
end
|
||||
|
||||
def default_name_for_blank_name
|
||||
email? ? display_name_from_email : ''
|
||||
end
|
||||
|
||||
def apply_sanitization_rules(name)
|
||||
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;]/, '') # Remove forbidden chars
|
||||
.gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces
|
||||
.gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation
|
||||
.gsub(/\s+/, ' ') # Normalize spaces
|
||||
.strip
|
||||
end
|
||||
|
||||
def display_name_from_email
|
||||
channel.email.split('@').first.parameterize.titleize
|
||||
end
|
||||
|
||||
def dispatch_create_event
|
||||
return if ENV['ENABLE_INBOX_EVENTS'].blank?
|
||||
|
||||
|
||||
@@ -35,9 +35,7 @@ class InboxAssignmentPolicy < ApplicationRecord
|
||||
to: :assignment_policy, prefix: :policy
|
||||
|
||||
# Callbacks
|
||||
after_create_commit :clear_inbox_cache
|
||||
after_update_commit :clear_inbox_cache
|
||||
after_destroy_commit :clear_inbox_cache
|
||||
after_commit :clear_inbox_cache
|
||||
|
||||
# Scopes
|
||||
scope :enabled, -> { joins(:assignment_policy).where(assignment_policies: { enabled: true }) }
|
||||
@@ -57,7 +55,7 @@ class InboxAssignmentPolicy < ApplicationRecord
|
||||
def inbox_belongs_to_same_account
|
||||
return unless inbox && assignment_policy
|
||||
|
||||
return unless inbox.account_id != assignment_policy.account_id
|
||||
return if inbox.account_id == assignment_policy.account_id
|
||||
|
||||
errors.add(:inbox, 'must belong to the same account as the assignment policy')
|
||||
end
|
||||
|
||||
@@ -20,4 +20,4 @@ class AssignmentPolicyPolicy < ApplicationPolicy
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -36,14 +36,14 @@ class AssignmentV2::AssignmentService
|
||||
end
|
||||
|
||||
def can_assign?(conversation)
|
||||
assignment_enabled? &&
|
||||
conversation.status == 'open' &&
|
||||
assignment_enabled? &&
|
||||
conversation.status == 'open' &&
|
||||
conversation.assignee_id.nil?
|
||||
end
|
||||
|
||||
def find_agent_for_conversation(conversation)
|
||||
def find_agent_for_conversation(_conversation)
|
||||
available_agents = inbox.available_agents(check_rate_limits: true)
|
||||
|
||||
|
||||
if available_agents.empty?
|
||||
log_no_agents_available
|
||||
return nil
|
||||
@@ -53,21 +53,13 @@ class AssignmentV2::AssignmentService
|
||||
end
|
||||
|
||||
def selector_service
|
||||
@selector_service ||= case policy.assignment_order
|
||||
when 'round_robin'
|
||||
AssignmentV2::RoundRobinSelector.new(inbox: inbox)
|
||||
when 'balanced'
|
||||
if enterprise_enabled? && policy.can_use_balanced_assignment?
|
||||
Enterprise::AssignmentV2::BalancedSelector.new(inbox: inbox)
|
||||
else
|
||||
AssignmentV2::RoundRobinSelector.new(inbox: inbox)
|
||||
end
|
||||
@selector_service ||= if policy.assignment_order == 'balanced' && enterprise_enabled? && policy.can_use_balanced_assignment?
|
||||
Enterprise::AssignmentV2::BalancedSelector.new(inbox: inbox)
|
||||
else
|
||||
AssignmentV2::RoundRobinSelector.new(inbox: inbox)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
def unassigned_conversations(limit)
|
||||
scope = inbox.conversations
|
||||
.unassigned
|
||||
@@ -75,8 +67,6 @@ class AssignmentV2::AssignmentService
|
||||
|
||||
# Apply conversation priority ordering
|
||||
scope = case policy.conversation_priority
|
||||
when 'earliest_created'
|
||||
scope.order(created_at: :asc)
|
||||
when 'longest_waiting'
|
||||
scope.order(last_activity_at: :asc, created_at: :asc)
|
||||
else
|
||||
@@ -119,4 +109,4 @@ class AssignmentV2::AssignmentService
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "AssignmentV2: Failed to record assignment in rate limiter: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,15 +16,14 @@ class AssignmentV2::RateLimiter
|
||||
|
||||
# Record an assignment for rate limiting purposes
|
||||
# @param conversation [Conversation] The conversation being assigned
|
||||
def record_assignment(conversation)
|
||||
def record_assignment(_conversation)
|
||||
return unless policy_exists?
|
||||
|
||||
key = rate_limit_key
|
||||
$alfred.with do |redis|
|
||||
redis.multi do |multi|
|
||||
multi.incr(key)
|
||||
multi.expire(key, time_window)
|
||||
end
|
||||
redis = Redis.new(Redis::Config.app)
|
||||
redis.multi do |multi|
|
||||
multi.incr(key)
|
||||
multi.expire(key, time_window)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -36,7 +35,7 @@ class AssignmentV2::RateLimiter
|
||||
within_limits: within_limits?,
|
||||
current_count: current_count,
|
||||
limit: rate_limit,
|
||||
reset_at: Time.at(next_window_start)
|
||||
reset_at: Time.zone.at(next_window_start)
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -60,7 +59,8 @@ class AssignmentV2::RateLimiter
|
||||
|
||||
def current_count
|
||||
key = rate_limit_key
|
||||
$alfred.with { |redis| redis.get(key).to_i }
|
||||
redis = Redis.new(Redis::Config.app)
|
||||
redis.get(key).to_i
|
||||
end
|
||||
|
||||
def rate_limit
|
||||
@@ -82,4 +82,4 @@ class AssignmentV2::RateLimiter
|
||||
def next_window_start
|
||||
current_window + time_window
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,8 +7,8 @@ class AssignmentV2::RoundRobinSelector
|
||||
return nil if available_agents.empty?
|
||||
|
||||
# Extract user IDs from inbox members
|
||||
agent_user_ids = available_agents.map { |inbox_member| inbox_member.user_id }.map(&:to_s)
|
||||
|
||||
agent_user_ids = available_agents.map(&:user_id).map(&:to_s)
|
||||
|
||||
# Use Redis queue for round robin
|
||||
selected_user_id = round_robin_service.available_agent(allowed_agent_ids: agent_user_ids)
|
||||
return nil unless selected_user_id
|
||||
@@ -34,4 +34,4 @@ class AssignmentV2::RoundRobinSelector
|
||||
def round_robin_service
|
||||
@round_robin_service ||= AutoAssignment::InboxRoundRobinService.new(inbox: inbox)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -102,4 +102,4 @@ class Leaves::LeaveApprovalService
|
||||
|
||||
ReassignConversationsJob.perform_later(leave.account_user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,7 +6,7 @@ class Leaves::LeaveService
|
||||
def create(params)
|
||||
leave = account_user.leaves.build(filtered_params(params))
|
||||
leave.account = account
|
||||
|
||||
|
||||
if leave.save
|
||||
notify_leave_creation(leave)
|
||||
{ success: true, leave: leave }
|
||||
@@ -26,7 +26,7 @@ class Leaves::LeaveService
|
||||
|
||||
def cancel(leave)
|
||||
return { success: false, errors: ['Cannot cancel approved leave'] } if leave.approved?
|
||||
|
||||
|
||||
if leave.update(status: 'cancelled')
|
||||
notify_leave_cancellation(leave)
|
||||
{ success: true, leave: leave }
|
||||
@@ -41,14 +41,10 @@ class Leaves::LeaveService
|
||||
# Apply filters
|
||||
scope = scope.where(status: filters[:status]) if filters[:status].present?
|
||||
scope = scope.where(leave_type: filters[:leave_type]) if filters[:leave_type].present?
|
||||
|
||||
if filters[:start_date].present? && filters[:end_date].present?
|
||||
scope = scope.by_date_range(filters[:start_date], filters[:end_date])
|
||||
end
|
||||
|
||||
if filters[:user_id].present? && current_user_admin?
|
||||
scope = scope.joins(:account_user).where(account_users: { user_id: filters[:user_id] })
|
||||
end
|
||||
scope = scope.by_date_range(filters[:start_date], filters[:end_date]) if filters[:start_date].present? && filters[:end_date].present?
|
||||
|
||||
scope = scope.joins(:account_user).where(account_users: { user_id: filters[:user_id] }) if filters[:user_id].present? && current_user_admin?
|
||||
|
||||
scope.includes(:account_user, :user, :approved_by).order(start_date: :desc)
|
||||
end
|
||||
@@ -58,7 +54,7 @@ class Leaves::LeaveService
|
||||
def filtered_params(params)
|
||||
allowed_params = [:start_date, :end_date, :leave_type, :reason]
|
||||
allowed_params << :status if current_user_admin?
|
||||
|
||||
|
||||
params.slice(*allowed_params)
|
||||
end
|
||||
|
||||
@@ -107,4 +103,4 @@ class Leaves::LeaveService
|
||||
user: leave.user
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Reports::AgentHistoryService
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account, params = {})
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def fetch_agent_assignment_history(agent)
|
||||
conversations = agent.assigned_conversations
|
||||
.includes(:inbox, :contact)
|
||||
.where(created_at: date_range)
|
||||
.order(created_at: :desc)
|
||||
.page(params[:page])
|
||||
.per(params[:per_page] || 50)
|
||||
|
||||
conversations.map do |conversation|
|
||||
{
|
||||
conversation_id: conversation.id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
inbox_name: conversation.inbox.name,
|
||||
contact_name: conversation.contact.name,
|
||||
assigned_at: conversation.assignee_last_seen_at || conversation.created_at,
|
||||
status: conversation.status,
|
||||
created_at: conversation.created_at
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def compute_all_agents_history
|
||||
agents = account.users.joins(:account_users).where(account_users: { role: %w[agent administrator] })
|
||||
|
||||
agents.map do |agent|
|
||||
conversations = agent.assigned_conversations.where(created_at: date_range)
|
||||
{
|
||||
agent: serialize_agent(agent),
|
||||
total_assignments: conversations.count,
|
||||
resolved_count: conversations.resolved.count,
|
||||
open_count: conversations.open.count,
|
||||
average_resolution_time: calculate_average_resolution_time(conversations.resolved)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def date_range
|
||||
start_date = params[:start_date] ? Date.parse(params[:start_date]).beginning_of_day : 30.days.ago
|
||||
end_date = params[:end_date] ? Date.parse(params[:end_date]).end_of_day : Time.current
|
||||
start_date..end_date
|
||||
end
|
||||
|
||||
def serialize_agent(agent)
|
||||
{
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
email: agent.email,
|
||||
avatar_url: agent.avatar_url
|
||||
}
|
||||
end
|
||||
|
||||
def calculate_average_resolution_time(conversations)
|
||||
return 0 if conversations.empty?
|
||||
|
||||
total_time = conversations.sum { |c| (c.last_activity_at - c.created_at) / 1.hour }
|
||||
(total_time / conversations.count).round(2)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'csv'
|
||||
|
||||
class Reports::AssignmentExportService
|
||||
attr_reader :data
|
||||
|
||||
def initialize(data)
|
||||
@data = data
|
||||
end
|
||||
|
||||
def generate_csv
|
||||
CSV.generate(headers: true) do |csv|
|
||||
add_header(csv)
|
||||
add_summary_metrics(csv)
|
||||
add_inbox_metrics(csv)
|
||||
add_agent_metrics(csv)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def add_header(csv)
|
||||
csv << ['Assignment Metrics Report']
|
||||
csv << ['Generated at', Time.current]
|
||||
csv << []
|
||||
end
|
||||
|
||||
def add_summary_metrics(csv)
|
||||
csv << ['Summary Metrics']
|
||||
csv << %w[Metric Value]
|
||||
data[:summary].each do |key, value|
|
||||
csv << [key.to_s.humanize, value]
|
||||
end
|
||||
csv << []
|
||||
end
|
||||
|
||||
def add_inbox_metrics(csv)
|
||||
csv << ['Inbox Metrics']
|
||||
csv << ['Inbox Name', 'Total Assignments', 'Average Assignment Time', 'Unique Agents']
|
||||
data[:by_inbox].each do |inbox|
|
||||
csv << [inbox[:inbox_name], inbox[:total_assignments], inbox[:average_assignment_time], inbox[:unique_agents]]
|
||||
end
|
||||
csv << []
|
||||
end
|
||||
|
||||
def add_agent_metrics(csv)
|
||||
csv << ['Agent Metrics']
|
||||
csv << ['Agent Name', 'Email', 'Assignment Count']
|
||||
data[:by_agent].each do |agent|
|
||||
csv << [agent[:agent_name], agent[:agent_email], agent[:assignment_count]]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,163 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Reports::AssignmentMetricsService
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account, params = {})
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def compute_assignment_metrics
|
||||
{
|
||||
total_assigned: total_assigned_conversations,
|
||||
assignment_rate: calculate_assignment_rate,
|
||||
average_response_time: calculate_average_response_time,
|
||||
average_resolution_time: calculate_average_resolution_time,
|
||||
assignments_by_policy: assignments_by_policy,
|
||||
period_metrics: compute_period_metrics
|
||||
}
|
||||
end
|
||||
|
||||
def compute_policy_performance(policy)
|
||||
conversations = policy.assignment_logs
|
||||
.joins(:conversation)
|
||||
.where(conversations: { created_at: date_range })
|
||||
|
||||
{
|
||||
policy_id: policy.id,
|
||||
policy_name: policy.name,
|
||||
total_assignments: conversations.count,
|
||||
average_assignment_time: calculate_average_time(conversations, :assignment_time),
|
||||
successful_assignments: conversations.where(success: true).count,
|
||||
failed_assignments: conversations.where(success: false).count
|
||||
}
|
||||
end
|
||||
|
||||
def compute_agent_utilization(agent)
|
||||
conversations = agent.assigned_conversations.where(created_at: date_range)
|
||||
capacity_limit = fetch_agent_capacity_limit(agent)
|
||||
|
||||
{
|
||||
agent_id: agent.id,
|
||||
agent_name: agent.name,
|
||||
current_load: agent.assigned_conversations.open.count,
|
||||
capacity_limit: capacity_limit,
|
||||
utilization_percentage: calculate_utilization_percentage(agent, capacity_limit),
|
||||
total_handled: conversations.count,
|
||||
average_handling_time: calculate_average_handling_time(conversations)
|
||||
}
|
||||
end
|
||||
|
||||
def compute_distribution_by_inbox
|
||||
Conversation.joins(:inbox)
|
||||
.where(created_at: date_range, account_id: account.id)
|
||||
.where.not(assignee_id: nil)
|
||||
.group('inboxes.name')
|
||||
.count
|
||||
end
|
||||
|
||||
def compute_distribution_by_team
|
||||
Conversation.joins(assignee: { team_members: :team })
|
||||
.where(created_at: date_range, account_id: account.id)
|
||||
.group('teams.name')
|
||||
.count
|
||||
end
|
||||
|
||||
def compute_distribution_by_hour
|
||||
Conversation.where(created_at: date_range, account_id: account.id)
|
||||
.where.not(assignee_id: nil)
|
||||
.group_by_hour(:created_at, format: '%H')
|
||||
.count
|
||||
end
|
||||
|
||||
def compute_period_metrics
|
||||
group_by = params[:group_by] || 'day'
|
||||
conversations = filter_conversations_by_date_range
|
||||
|
||||
case group_by
|
||||
when 'hour'
|
||||
group_by_hour(conversations)
|
||||
when 'week'
|
||||
group_by_week(conversations)
|
||||
when 'month'
|
||||
group_by_month(conversations)
|
||||
else
|
||||
group_by_day(conversations)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def date_range
|
||||
@date_range ||= params[:since]..params[:until]
|
||||
end
|
||||
|
||||
def filter_conversations_by_date_range
|
||||
Conversation.where(account_id: account.id, created_at: date_range)
|
||||
end
|
||||
|
||||
def total_assigned_conversations
|
||||
filter_conversations_by_date_range.where.not(assignee_id: nil).count
|
||||
end
|
||||
|
||||
def calculate_assignment_rate
|
||||
total = filter_conversations_by_date_range.count
|
||||
return 0.0 if total.zero?
|
||||
|
||||
(total_assigned_conversations.to_f / total * 100).round(2)
|
||||
end
|
||||
|
||||
def calculate_average_response_time
|
||||
# Implementation for average response time
|
||||
0
|
||||
end
|
||||
|
||||
def calculate_average_resolution_time
|
||||
# Implementation for average resolution time
|
||||
0
|
||||
end
|
||||
|
||||
def assignments_by_policy
|
||||
# Implementation for assignments by policy
|
||||
{}
|
||||
end
|
||||
|
||||
def fetch_agent_capacity_limit(_agent)
|
||||
# Implementation to fetch agent capacity limit
|
||||
nil
|
||||
end
|
||||
|
||||
def calculate_utilization_percentage(agent, capacity_limit)
|
||||
return 0.0 unless capacity_limit&.positive?
|
||||
|
||||
current_load = agent.assigned_conversations.open.count
|
||||
(current_load.to_f / capacity_limit * 100).round(2)
|
||||
end
|
||||
|
||||
def calculate_average_handling_time(_conversations)
|
||||
# Implementation for average handling time
|
||||
0
|
||||
end
|
||||
|
||||
def calculate_average_time(_conversations, _field)
|
||||
# Implementation for average time calculation
|
||||
0
|
||||
end
|
||||
|
||||
def group_by_hour(conversations)
|
||||
conversations.group_by_hour(:created_at).count
|
||||
end
|
||||
|
||||
def group_by_day(conversations)
|
||||
conversations.group_by_day(:created_at).count
|
||||
end
|
||||
|
||||
def group_by_week(conversations)
|
||||
conversations.group_by_week(:created_at).count
|
||||
end
|
||||
|
||||
def group_by_month(conversations)
|
||||
conversations.group_by_month(:created_at).count
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user