backend fixes for assignment v2
This commit is contained in:
@@ -6,7 +6,7 @@ class Api::V1::Accounts::AgentCapacityPoliciesController < Api::V1::Accounts::Ba
|
||||
before_action :check_authorization
|
||||
|
||||
def index
|
||||
@agent_capacity_policies = Current.account.agent_capacity_policies.includes(:users, :inbox_capacity_limits)
|
||||
@agent_capacity_policies = Enterprise::AgentCapacityPolicy.where(account_id: Current.account.id).includes(:users, :inbox_capacity_limits)
|
||||
render json: { agent_capacity_policies: serialize_agent_capacity_policies(@agent_capacity_policies) }
|
||||
end
|
||||
|
||||
@@ -15,7 +15,8 @@ class Api::V1::Accounts::AgentCapacityPoliciesController < Api::V1::Accounts::Ba
|
||||
end
|
||||
|
||||
def create
|
||||
@agent_capacity_policy = Current.account.agent_capacity_policies.build(agent_capacity_policy_params)
|
||||
@agent_capacity_policy = Enterprise::AgentCapacityPolicy.new(agent_capacity_policy_params)
|
||||
@agent_capacity_policy.account_id = Current.account.id
|
||||
|
||||
if @agent_capacity_policy.save
|
||||
render json: { agent_capacity_policy: serialize_agent_capacity_policy(@agent_capacity_policy) }, status: :created
|
||||
@@ -44,10 +45,10 @@ class Api::V1::Accounts::AgentCapacityPoliciesController < Api::V1::Accounts::Ba
|
||||
def set_inbox_limit
|
||||
inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
inbox_limit = @agent_capacity_policy.inbox_capacity_limits.find_or_initialize_by(inbox: inbox)
|
||||
|
||||
|
||||
if inbox_limit.update(conversation_limit: params[:conversation_limit])
|
||||
render json: {
|
||||
inbox_capacity_limit: serialize_inbox_capacity_limit(inbox_limit)
|
||||
render json: {
|
||||
inbox_capacity_limit: serialize_inbox_capacity_limit(inbox_limit)
|
||||
}
|
||||
else
|
||||
render json: { errors: inbox_limit.errors.full_messages }, status: :unprocessable_entity
|
||||
@@ -58,7 +59,7 @@ class Api::V1::Accounts::AgentCapacityPoliciesController < Api::V1::Accounts::Ba
|
||||
def remove_inbox_limit
|
||||
inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
inbox_limit = @agent_capacity_policy.inbox_capacity_limits.find_by(inbox: inbox)
|
||||
|
||||
|
||||
if inbox_limit
|
||||
if inbox_limit.destroy
|
||||
head :ok
|
||||
@@ -73,33 +74,30 @@ class Api::V1::Accounts::AgentCapacityPoliciesController < Api::V1::Accounts::Ba
|
||||
# Assign a user to a capacity policy
|
||||
def assign_user
|
||||
user = Current.account.users.find(params[:user_id])
|
||||
|
||||
# Remove user from any existing capacity policy
|
||||
Enterprise::AgentCapacityPolicyUser.where(user: user).destroy_all
|
||||
|
||||
# Assign to new policy
|
||||
policy_user = @agent_capacity_policy.agent_capacity_policy_users.build(user: user)
|
||||
|
||||
if policy_user.save
|
||||
render json: {
|
||||
account_user = Current.account.account_users.find_by!(user: user)
|
||||
|
||||
# Update the account_user to assign to this policy
|
||||
if account_user.update(agent_capacity_policy_id: @agent_capacity_policy.id)
|
||||
render json: {
|
||||
message: 'User assigned successfully',
|
||||
agent_capacity_policy_user: serialize_policy_user(policy_user)
|
||||
user_id: user.id,
|
||||
agent_capacity_policy_id: @agent_capacity_policy.id
|
||||
}
|
||||
else
|
||||
render json: { errors: policy_user.errors.full_messages }, status: :unprocessable_entity
|
||||
render json: { errors: account_user.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# Remove a user from a capacity policy
|
||||
def remove_user
|
||||
user = Current.account.users.find(params[:user_id])
|
||||
policy_user = @agent_capacity_policy.agent_capacity_policy_users.find_by(user: user)
|
||||
|
||||
if policy_user
|
||||
if policy_user.destroy
|
||||
account_user = Current.account.account_users.find_by!(user: user)
|
||||
|
||||
if account_user.agent_capacity_policy_id == @agent_capacity_policy.id
|
||||
if account_user.update(agent_capacity_policy_id: nil)
|
||||
head :ok
|
||||
else
|
||||
render json: { errors: policy_user.errors.full_messages }, status: :unprocessable_entity
|
||||
render json: { errors: account_user.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
else
|
||||
render json: { error: 'User not assigned to this policy' }, status: :not_found
|
||||
@@ -110,29 +108,30 @@ class Api::V1::Accounts::AgentCapacityPoliciesController < Api::V1::Accounts::Ba
|
||||
def agent_capacity
|
||||
user = Current.account.users.find(params[:agent_id])
|
||||
inbox = params[:inbox_id] ? Current.account.inboxes.find(params[:inbox_id]) : nil
|
||||
|
||||
|
||||
capacity_service = Enterprise::AssignmentV2::CapacityService.new
|
||||
capacity_data = if inbox
|
||||
capacity_service.get_agent_capacity(user, inbox)
|
||||
else
|
||||
capacity_service.get_agent_overall_capacity(user)
|
||||
end
|
||||
|
||||
|
||||
render json: { agent_capacity: capacity_data }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_enterprise_account
|
||||
unless Current.account.feature_enabled?(:enterprise_agent_capacity)
|
||||
render json: {
|
||||
error: 'Agent capacity policies are only available for enterprise accounts'
|
||||
}, status: :forbidden
|
||||
end
|
||||
return if Current.account.feature_enabled?(:enterprise_agent_capacity)
|
||||
|
||||
render json: {
|
||||
error: 'Agent capacity policies are only available for enterprise accounts'
|
||||
}, status: :forbidden
|
||||
end
|
||||
|
||||
def fetch_agent_capacity_policy
|
||||
@agent_capacity_policy = Current.account.agent_capacity_policies.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
|
||||
@@ -151,6 +150,8 @@ class Api::V1::Accounts::AgentCapacityPoliciesController < Api::V1::Accounts::Ba
|
||||
exclusion_rules: policy.exclusion_rules,
|
||||
user_count: policy.users.count,
|
||||
inbox_limit_count: policy.inbox_capacity_limits.count,
|
||||
inbox_limits: policy.inbox_capacity_limits.map { |limit| serialize_inbox_capacity_limit(limit) },
|
||||
users: policy.users.map { |user| { id: user.id, name: user.name, email: user.email, avatar_url: user.avatar_url } },
|
||||
created_at: policy.created_at,
|
||||
updated_at: policy.updated_at
|
||||
}
|
||||
@@ -170,14 +171,4 @@ class Api::V1::Accounts::AgentCapacityPoliciesController < Api::V1::Accounts::Ba
|
||||
updated_at: limit.updated_at
|
||||
}
|
||||
end
|
||||
|
||||
def serialize_policy_user(policy_user)
|
||||
{
|
||||
id: policy_user.id,
|
||||
user_id: policy_user.user_id,
|
||||
user_name: policy_user.user.name,
|
||||
user_email: policy_user.user.email,
|
||||
created_at: policy_user.created_at
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Accounts::AssignmentMetricsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
before_action :validate_date_range
|
||||
|
||||
def index
|
||||
@metrics = compute_assignment_metrics
|
||||
render json: { assignment_metrics: @metrics }
|
||||
end
|
||||
|
||||
def agent_history
|
||||
@agent = Current.account.users.find(params[:agent_id])
|
||||
@assignment_history = fetch_agent_assignment_history(@agent)
|
||||
|
||||
render json: {
|
||||
agent: serialize_agent(@agent),
|
||||
assignment_history: @assignment_history,
|
||||
meta: pagination_meta
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def compute_assignment_metrics
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if inbox_id.present?
|
||||
base_query = base_query.where(inbox_id: inbox_id)
|
||||
end
|
||||
|
||||
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)
|
||||
|
||||
if agent_id.present?
|
||||
base_query = base_query.where(assignee_id: agent_id)
|
||||
end
|
||||
|
||||
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])
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(Conversation, :index?)
|
||||
end
|
||||
end
|
||||
@@ -63,6 +63,7 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC
|
||||
fair_distribution_window: policy.fair_distribution_window,
|
||||
enabled: policy.enabled,
|
||||
inbox_count: policy.inboxes.count,
|
||||
inboxes: policy.inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
|
||||
created_at: policy.created_at,
|
||||
updated_at: policy.updated_at
|
||||
}
|
||||
@@ -75,4 +76,4 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC
|
||||
def check_authorization
|
||||
authorize(AssignmentPolicy)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
class Api::V1::Accounts::LeavesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_leave, only: [:show, :update, :destroy, :approve, :reject]
|
||||
before_action :check_authorization
|
||||
before_action -> { check_authorization(Leave) }, only: [:index, :create]
|
||||
before_action :authorize_leave, only: [:show, :update, :destroy]
|
||||
before_action :authorize_approval, only: [:approve, :reject]
|
||||
|
||||
@@ -22,9 +22,9 @@ class Api::V1::Accounts::LeavesController < Api::V1::Accounts::BaseController
|
||||
account_user: account_user,
|
||||
current_user: Current.user
|
||||
)
|
||||
|
||||
|
||||
result = service.create(leave_params)
|
||||
|
||||
|
||||
if result[:success]
|
||||
render json: { leave: serialize_leave(result[:leave]) }, status: :created
|
||||
else
|
||||
@@ -34,7 +34,7 @@ class Api::V1::Accounts::LeavesController < Api::V1::Accounts::BaseController
|
||||
|
||||
def update
|
||||
result = leave_service.update(@leave, leave_params)
|
||||
|
||||
|
||||
if result[:success]
|
||||
render json: { leave: serialize_leave(result[:leave]) }
|
||||
else
|
||||
@@ -53,7 +53,7 @@ class Api::V1::Accounts::LeavesController < Api::V1::Accounts::BaseController
|
||||
def approve
|
||||
service = Leaves::LeaveApprovalService.new(leave: @leave, approver: Current.user)
|
||||
result = service.approve(params[:comments])
|
||||
|
||||
|
||||
if result[:success]
|
||||
render json: { leave: serialize_leave(result[:leave]) }
|
||||
else
|
||||
@@ -64,7 +64,7 @@ class Api::V1::Accounts::LeavesController < Api::V1::Accounts::BaseController
|
||||
def reject
|
||||
service = Leaves::LeaveApprovalService.new(leave: @leave, approver: Current.user)
|
||||
result = service.reject(params[:reason])
|
||||
|
||||
|
||||
if result[:success]
|
||||
render json: { leave: serialize_leave(result[:leave]) }
|
||||
else
|
||||
@@ -96,11 +96,20 @@ class Api::V1::Accounts::LeavesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def leave_service
|
||||
@leave_service ||= Leaves::LeaveService.new(
|
||||
account: Current.account,
|
||||
account_user: @leave.account_user,
|
||||
current_user: Current.user
|
||||
)
|
||||
@leave_service ||= if @leave
|
||||
Leaves::LeaveService.new(
|
||||
account: Current.account,
|
||||
account_user: @leave.account_user,
|
||||
current_user: Current.user
|
||||
)
|
||||
else
|
||||
# For index action, we don't have a specific leave
|
||||
Leaves::LeaveService.new(
|
||||
account: Current.account,
|
||||
account_user: nil,
|
||||
current_user: Current.user
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def leave_params
|
||||
@@ -136,4 +145,4 @@ class Api::V1::Accounts::LeavesController < Api::V1::Accounts::BaseController
|
||||
def serialize_leaves(leaves)
|
||||
leaves.map { |leave| serialize_leave(leave) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Accounts::Reports::AssignmentMetricsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
before_action :validate_date_range
|
||||
|
||||
def index
|
||||
@metrics = compute_assignment_metrics
|
||||
render json: { assignment_metrics: @metrics }
|
||||
end
|
||||
|
||||
def agent_history
|
||||
if params[:agent_id].present?
|
||||
@agent = Current.account.users.find(params[:agent_id])
|
||||
@assignment_history = fetch_agent_assignment_history(@agent)
|
||||
|
||||
render json: {
|
||||
agent: serialize_agent(@agent),
|
||||
assignment_history: @assignment_history,
|
||||
meta: pagination_meta
|
||||
}
|
||||
else
|
||||
# Return all agents' history summary
|
||||
agents_summary = compute_all_agents_history
|
||||
render json: { agents_history: agents_summary }
|
||||
end
|
||||
end
|
||||
|
||||
def policy_performance
|
||||
@policies = Current.account.assignment_policies.includes(:inboxes)
|
||||
performance_data = @policies.map do |policy|
|
||||
compute_policy_performance(policy)
|
||||
end
|
||||
|
||||
render json: { policy_performance: performance_data }
|
||||
end
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
render json: { agent_utilization: utilization_data }
|
||||
end
|
||||
|
||||
def assignment_distribution
|
||||
distribution_data = {
|
||||
by_inbox: compute_distribution_by_inbox,
|
||||
by_team: compute_distribution_by_team,
|
||||
by_hour: compute_distribution_by_hour,
|
||||
by_day_of_week: compute_distribution_by_day_of_week
|
||||
}
|
||||
|
||||
render json: { assignment_distribution: distribution_data }
|
||||
end
|
||||
|
||||
def export
|
||||
type = params[:type] || 'csv'
|
||||
data = compute_assignment_metrics
|
||||
|
||||
case type
|
||||
when 'csv'
|
||||
send_data generate_csv(data), 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
|
||||
render json: { error: 'Unsupported export type' }, status: :bad_request
|
||||
end
|
||||
end
|
||||
|
||||
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])
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(Conversation, :index?)
|
||||
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
|
||||
end
|
||||
end
|
||||
@@ -98,8 +98,8 @@ class Account < ApplicationRecord
|
||||
has_many :webhooks, dependent: :destroy_async
|
||||
has_many :whatsapp_channels, dependent: :destroy_async, class_name: '::Channel::Whatsapp'
|
||||
has_many :working_hours, dependent: :destroy_async
|
||||
has_many :leaves, dependent: :destroy_async
|
||||
|
||||
has_many :leaves, dependent: :destroy_async, class_name: 'Leave'
|
||||
|
||||
# Assignment V2 associations
|
||||
has_many :assignment_policies, dependent: :destroy_async
|
||||
has_many :agent_capacity_policies, dependent: :destroy_async, class_name: 'Enterprise::AgentCapacityPolicy' if ChatwootApp.enterprise?
|
||||
@@ -164,7 +164,6 @@ class Account < ApplicationRecord
|
||||
ISO_639.find(account_locale)&.english_name&.downcase || 'english'
|
||||
end
|
||||
|
||||
|
||||
private
|
||||
|
||||
def notify_creation
|
||||
|
||||
+18
-16
@@ -2,24 +2,26 @@
|
||||
#
|
||||
# Table name: account_users
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# active_at :datetime
|
||||
# auto_offline :boolean default(TRUE), not null
|
||||
# availability :integer default("online"), not null
|
||||
# role :integer default("agent")
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint
|
||||
# custom_role_id :bigint
|
||||
# inviter_id :bigint
|
||||
# user_id :bigint
|
||||
# id :bigint not null, primary key
|
||||
# active_at :datetime
|
||||
# auto_offline :boolean default(TRUE), not null
|
||||
# availability :integer default("online"), not null
|
||||
# role :integer default("agent")
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint
|
||||
# agent_capacity_policy_id :bigint
|
||||
# custom_role_id :bigint
|
||||
# inviter_id :bigint
|
||||
# user_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_account_users_on_account_id (account_id)
|
||||
# index_account_users_on_custom_role_id (custom_role_id)
|
||||
# index_account_users_on_user_id (user_id)
|
||||
# uniq_user_id_per_account_id (account_id,user_id) UNIQUE
|
||||
# index_account_users_on_account_id (account_id)
|
||||
# index_account_users_on_agent_capacity_policy_id (agent_capacity_policy_id)
|
||||
# index_account_users_on_custom_role_id (custom_role_id)
|
||||
# index_account_users_on_user_id (user_id)
|
||||
# uniq_user_id_per_account_id (account_id,user_id) UNIQUE
|
||||
#
|
||||
|
||||
class AccountUser < ApplicationRecord
|
||||
@@ -30,7 +32,7 @@ class AccountUser < ApplicationRecord
|
||||
belongs_to :inviter, class_name: 'User', optional: true
|
||||
belongs_to :agent_capacity_policy, class_name: 'Enterprise::AgentCapacityPolicy', optional: true
|
||||
|
||||
has_many :leaves, dependent: :destroy
|
||||
has_many :leaves, dependent: :destroy, class_name: 'Leave'
|
||||
|
||||
enum role: { agent: 0, administrator: 1 }
|
||||
enum availability: { online: 0, offline: 1, busy: 2 }
|
||||
|
||||
@@ -4,26 +4,23 @@
|
||||
#
|
||||
# Table name: assignment_policies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# account_id :bigint not null
|
||||
# name :string(255) not null
|
||||
# description :text
|
||||
# assignment_order :integer not null, default: 0
|
||||
# conversation_priority :integer not null, default: 0
|
||||
# fair_distribution_limit :integer not null, default: 10
|
||||
# fair_distribution_window :integer not null, default: 3600
|
||||
# enabled :boolean not null, default: true
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# id :bigint not null, primary key
|
||||
# assignment_order :integer default("round_robin"), not null
|
||||
# conversation_priority :integer default("earliest_created"), not null
|
||||
# description :text
|
||||
# enabled :boolean default(TRUE), not null
|
||||
# fair_distribution_limit :integer default(10), not null
|
||||
# fair_distribution_window :integer default(3600), not null
|
||||
# name :string(255) not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_assignment_policies_on_account_id (account_id)
|
||||
# unique_assignment_policy_name_per_account (account_id,name) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
# index_assignment_policies_on_account_id (account_id)
|
||||
# index_assignment_policies_on_enabled (enabled)
|
||||
# unique_assignment_policy_name_per_account (account_id,name) UNIQUE
|
||||
#
|
||||
|
||||
class AssignmentPolicy < ApplicationRecord
|
||||
@@ -49,7 +46,7 @@ class AssignmentPolicy < ApplicationRecord
|
||||
|
||||
# Validate balanced assignment is only available for enterprise
|
||||
validate :validate_balanced_assignment_enterprise_only
|
||||
|
||||
|
||||
# Server-side validation to prevent bypass
|
||||
before_save :enforce_enterprise_features
|
||||
|
||||
@@ -89,11 +86,11 @@ class AssignmentPolicy < ApplicationRecord
|
||||
|
||||
def enforce_enterprise_features
|
||||
# Server-side enforcement to prevent API bypass
|
||||
if balanced? && !can_use_balanced_assignment?
|
||||
# Force to round_robin if enterprise not available
|
||||
self.assignment_order = 'round_robin'
|
||||
Rails.logger.warn("Assignment V2: Forced assignment_order to round_robin for non-enterprise account #{account_id}")
|
||||
end
|
||||
return unless balanced? && !can_use_balanced_assignment?
|
||||
|
||||
# Force to round_robin if enterprise not available
|
||||
self.assignment_order = 'round_robin'
|
||||
Rails.logger.warn("Assignment V2: Forced assignment_order to round_robin for non-enterprise account #{account_id}")
|
||||
end
|
||||
|
||||
def clear_assignment_caches
|
||||
@@ -103,4 +100,4 @@ class AssignmentPolicy < ApplicationRecord
|
||||
Rails.cache.delete("assignment_v2:inbox_policy:#{inbox.id}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,22 +5,17 @@
|
||||
# Table name: inbox_assignment_policies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# inbox_id :bigint not null
|
||||
# assignment_policy_id :bigint not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# assignment_policy_id :bigint not null
|
||||
# inbox_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_inbox_assignment_policies_on_assignment_policy_id (assignment_policy_id)
|
||||
# index_inbox_assignment_policies_on_inbox_id (inbox_id)
|
||||
# index_inbox_assignment_policies_on_inbox_id (inbox_id)
|
||||
# unique_inbox_assignment_policy (inbox_id) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (assignment_policy_id => assignment_policies.id)
|
||||
# fk_rails_... (inbox_id => inboxes.id)
|
||||
#
|
||||
|
||||
class InboxAssignmentPolicy < ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
@@ -35,11 +30,11 @@ class InboxAssignmentPolicy < ApplicationRecord
|
||||
|
||||
# Delegations
|
||||
delegate :account, to: :inbox
|
||||
delegate :name, :description, :assignment_order, :conversation_priority,
|
||||
delegate :name, :description, :assignment_order, :conversation_priority,
|
||||
:fair_distribution_limit, :fair_distribution_window, :enabled?,
|
||||
to: :assignment_policy, prefix: :policy
|
||||
|
||||
# Callbacks
|
||||
# Callbacks
|
||||
after_create_commit :clear_inbox_cache
|
||||
after_update_commit :clear_inbox_cache
|
||||
after_destroy_commit :clear_inbox_cache
|
||||
@@ -61,14 +56,14 @@ class InboxAssignmentPolicy < ApplicationRecord
|
||||
|
||||
def inbox_belongs_to_same_account
|
||||
return unless inbox && assignment_policy
|
||||
|
||||
if inbox.account_id != assignment_policy.account_id
|
||||
errors.add(:inbox, 'must belong to the same account as the assignment policy')
|
||||
end
|
||||
|
||||
return unless inbox.account_id != assignment_policy.account_id
|
||||
|
||||
errors.add(:inbox, 'must belong to the same account as the assignment policy')
|
||||
end
|
||||
|
||||
def clear_inbox_cache
|
||||
Rails.cache.delete("assignment_v2:inbox_policy:#{inbox_id}")
|
||||
update_account_cache
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+16
-19
@@ -19,20 +19,14 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_leaves_on_account_and_status (account_id,status)
|
||||
# index_leaves_on_account_id (account_id)
|
||||
# index_leaves_on_account_user_and_dates (account_user_id,start_date,end_date)
|
||||
# index_leaves_on_account_user_id (account_user_id)
|
||||
# index_leaves_on_approved_by_id (approved_by_id)
|
||||
# index_leaves_on_end_date (end_date)
|
||||
# index_leaves_on_start_date (start_date)
|
||||
# index_leaves_on_status (status)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
# fk_rails_... (account_user_id => account_users.id)
|
||||
# fk_rails_... (approved_by_id => users.id)
|
||||
# index_leaves_on_account_and_status (account_id,status)
|
||||
# index_leaves_on_account_id (account_id)
|
||||
# index_leaves_on_account_user_and_dates (account_user_id,start_date,end_date)
|
||||
# index_leaves_on_account_user_id (account_user_id)
|
||||
# index_leaves_on_approved_by_id (approved_by_id)
|
||||
# index_leaves_on_end_date (end_date)
|
||||
# index_leaves_on_start_date (start_date)
|
||||
# index_leaves_on_status (status)
|
||||
#
|
||||
|
||||
class Leave < ApplicationRecord
|
||||
@@ -79,11 +73,13 @@ class Leave < ApplicationRecord
|
||||
|
||||
def days_count
|
||||
return 0 unless start_date && end_date
|
||||
|
||||
(end_date - start_date).to_i + 1
|
||||
end
|
||||
|
||||
def overlaps_with?(other_leave)
|
||||
return false if other_leave == self
|
||||
|
||||
start_date <= other_leave.end_date && end_date >= other_leave.start_date
|
||||
end
|
||||
|
||||
@@ -91,6 +87,7 @@ class Leave < ApplicationRecord
|
||||
|
||||
def end_date_after_start_date
|
||||
return unless start_date && end_date
|
||||
|
||||
errors.add(:end_date, 'must be after or equal to start date') if end_date < start_date
|
||||
end
|
||||
|
||||
@@ -99,13 +96,13 @@ class Leave < ApplicationRecord
|
||||
.approved
|
||||
.where.not(id: id)
|
||||
.by_date_range(start_date, end_date)
|
||||
|
||||
if overlapping_leaves.exists?
|
||||
errors.add(:base, 'Leave dates overlap with an existing approved leave')
|
||||
end
|
||||
|
||||
return unless overlapping_leaves.exists?
|
||||
|
||||
errors.add(:base, 'Leave dates overlap with an existing approved leave')
|
||||
end
|
||||
|
||||
def set_approved_at
|
||||
self.approved_at = Time.current
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AgentCapacityPolicyPolicy < ApplicationPolicy
|
||||
def index?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).index?
|
||||
end
|
||||
|
||||
def show?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).show?
|
||||
end
|
||||
|
||||
def create?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).create?
|
||||
end
|
||||
|
||||
def update?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).update?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).destroy?
|
||||
end
|
||||
|
||||
def set_inbox_limit?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).set_inbox_limit?
|
||||
end
|
||||
|
||||
def remove_inbox_limit?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).remove_inbox_limit?
|
||||
end
|
||||
|
||||
def assign_user?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).assign_user?
|
||||
end
|
||||
|
||||
def remove_user?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).remove_user?
|
||||
end
|
||||
|
||||
def agent_capacity?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).agent_capacity?
|
||||
end
|
||||
end
|
||||
@@ -12,17 +12,16 @@ class LeavePolicy < ApplicationPolicy
|
||||
|
||||
def create?
|
||||
# Users can create their own leave requests
|
||||
# When authorizing the class (not instance), allow any authenticated user
|
||||
return true if record.is_a?(Class)
|
||||
|
||||
record.account_user.user_id == user.id
|
||||
end
|
||||
|
||||
def update?
|
||||
# Users can update their own pending/rejected leaves
|
||||
# Admins can update any leave
|
||||
if @account_user.administrator?
|
||||
true
|
||||
else
|
||||
record.account_user.user_id == user.id && record.pending?
|
||||
end
|
||||
@account_user.administrator? || (record.account_user.user_id == user.id && record.pending?)
|
||||
end
|
||||
|
||||
def destroy?
|
||||
@@ -55,4 +54,4 @@ class LeavePolicy < ApplicationPolicy
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -191,3 +191,7 @@
|
||||
display_name: CRM V2
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
- name: enterprise_agent_capacity
|
||||
display_name: Enterprise Agent Capacity
|
||||
enabled: true
|
||||
premium: true
|
||||
|
||||
+15
-6
@@ -226,11 +226,11 @@ Rails.application.routes.draw do
|
||||
|
||||
# Assignment V2 Routes
|
||||
resources :assignment_policies
|
||||
|
||||
|
||||
resources :inboxes, only: [] do
|
||||
resource :assignment_policy, only: [:show, :create, :destroy], controller: 'inbox_assignment_policies'
|
||||
end
|
||||
|
||||
|
||||
# Agent Capacity Management (Enterprise)
|
||||
resources :agent_capacity_policies do
|
||||
member do
|
||||
@@ -240,13 +240,22 @@ Rails.application.routes.draw do
|
||||
delete 'users/:user_id', to: 'agent_capacity_policies#remove_user'
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
# Agent capacity status
|
||||
get 'agents/:agent_id/capacity', to: 'agent_capacity_policies#agent_capacity'
|
||||
|
||||
|
||||
# Assignment Metrics
|
||||
get 'reports/assignment_metrics', to: 'assignment_metrics#index'
|
||||
get 'agents/:agent_id/assignment_history', to: 'assignment_metrics#agent_history'
|
||||
namespace :reports do
|
||||
resources :assignment_metrics, only: [:index] do
|
||||
collection do
|
||||
get 'agent_history'
|
||||
get 'policy_performance'
|
||||
get 'agent_utilization'
|
||||
get 'assignment_distribution'
|
||||
get 'export'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
namespace :twitter do
|
||||
resource :authorization, only: [:create]
|
||||
|
||||
+74
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_07_30_000000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -39,8 +39,10 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
t.integer "availability", default: 0, null: false
|
||||
t.boolean "auto_offline", default: true, null: false
|
||||
t.bigint "custom_role_id"
|
||||
t.bigint "agent_capacity_policy_id"
|
||||
t.index ["account_id", "user_id"], name: "uniq_user_id_per_account_id", unique: true
|
||||
t.index ["account_id"], name: "index_account_users_on_account_id"
|
||||
t.index ["agent_capacity_policy_id"], name: "index_account_users_on_agent_capacity_policy_id"
|
||||
t.index ["custom_role_id"], name: "index_account_users_on_custom_role_id"
|
||||
t.index ["user_id"], name: "index_account_users_on_user_id"
|
||||
end
|
||||
@@ -169,6 +171,22 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
t.index ["views"], name: "index_articles_on_views"
|
||||
end
|
||||
|
||||
create_table "assignment_policies", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "name", limit: 255, null: false
|
||||
t.text "description"
|
||||
t.integer "assignment_order", default: 0, null: false
|
||||
t.integer "conversation_priority", default: 0, null: false
|
||||
t.integer "fair_distribution_limit", default: 10, null: false
|
||||
t.integer "fair_distribution_window", default: 3600, null: false
|
||||
t.boolean "enabled", default: true, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "name"], name: "unique_assignment_policy_name_per_account", unique: true
|
||||
t.index ["account_id"], name: "index_assignment_policies_on_account_id"
|
||||
t.index ["enabled"], name: "index_assignment_policies_on_enabled"
|
||||
end
|
||||
|
||||
create_table "attachments", id: :serial, force: :cascade do |t|
|
||||
t.integer "file_type", default: 0
|
||||
t.string "external_url"
|
||||
@@ -720,6 +738,29 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
t.index ["name", "account_id"], name: "index_email_templates_on_name_and_account_id", unique: true
|
||||
end
|
||||
|
||||
create_table "enterprise_agent_capacity_policies", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "name", limit: 255, null: false
|
||||
t.text "description"
|
||||
t.jsonb "exclusion_rules", default: {}, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "name"], name: "unique_capacity_policy_name_per_account", unique: true
|
||||
t.index ["account_id"], name: "index_enterprise_agent_capacity_policies_on_account_id"
|
||||
t.index ["exclusion_rules"], name: "index_capacity_policies_on_exclusion_rules", using: :gin
|
||||
end
|
||||
|
||||
create_table "enterprise_inbox_capacity_limits", force: :cascade do |t|
|
||||
t.bigint "agent_capacity_policy_id", null: false
|
||||
t.bigint "inbox_id", null: false
|
||||
t.integer "conversation_limit", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["agent_capacity_policy_id", "inbox_id"], name: "unique_policy_inbox_limit", unique: true
|
||||
t.index ["agent_capacity_policy_id"], name: "index_inbox_limits_on_capacity_policy"
|
||||
t.index ["inbox_id"], name: "index_enterprise_inbox_capacity_limits_on_inbox_id"
|
||||
end
|
||||
|
||||
create_table "folders", force: :cascade do |t|
|
||||
t.integer "account_id", null: false
|
||||
t.integer "category_id", null: false
|
||||
@@ -728,6 +769,16 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "inbox_assignment_policies", force: :cascade do |t|
|
||||
t.bigint "inbox_id", null: false
|
||||
t.bigint "assignment_policy_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["assignment_policy_id"], name: "index_inbox_assignment_policies_on_assignment_policy_id"
|
||||
t.index ["inbox_id"], name: "index_inbox_assignment_policies_on_inbox_id"
|
||||
t.index ["inbox_id"], name: "unique_inbox_assignment_policy", unique: true
|
||||
end
|
||||
|
||||
create_table "inbox_members", id: :serial, force: :cascade do |t|
|
||||
t.integer "user_id", null: false
|
||||
t.integer "inbox_id", null: false
|
||||
@@ -800,6 +851,28 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
t.index ["title", "account_id"], name: "index_labels_on_title_and_account_id", unique: true
|
||||
end
|
||||
|
||||
create_table "leaves", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "account_user_id", null: false
|
||||
t.date "start_date", null: false
|
||||
t.date "end_date", null: false
|
||||
t.integer "leave_type", default: 0, null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.text "reason"
|
||||
t.bigint "approved_by_id"
|
||||
t.datetime "approved_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "status"], name: "index_leaves_on_account_and_status"
|
||||
t.index ["account_id"], name: "index_leaves_on_account_id"
|
||||
t.index ["account_user_id", "start_date", "end_date"], name: "index_leaves_on_account_user_and_dates"
|
||||
t.index ["account_user_id"], name: "index_leaves_on_account_user_id"
|
||||
t.index ["approved_by_id"], name: "index_leaves_on_approved_by_id"
|
||||
t.index ["end_date"], name: "index_leaves_on_end_date"
|
||||
t.index ["start_date"], name: "index_leaves_on_start_date"
|
||||
t.index ["status"], name: "index_leaves_on_status"
|
||||
end
|
||||
|
||||
create_table "macros", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "name", null: false
|
||||
|
||||
@@ -23,15 +23,15 @@
|
||||
#
|
||||
|
||||
module Enterprise
|
||||
class AgentCapacityPolicy < ApplicationRecord
|
||||
class AgentCapacityPolicy < ::ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
self.table_name = 'enterprise_agent_capacity_policies'
|
||||
|
||||
# Associations
|
||||
belongs_to :account
|
||||
has_many :account_users, dependent: :nullify, foreign_key: :agent_capacity_policy_id
|
||||
has_many :users, through: :account_users
|
||||
belongs_to :account, class_name: '::Account'
|
||||
has_many :account_users, class_name: '::AccountUser', dependent: :nullify
|
||||
has_many :users, through: :account_users, source: :user, class_name: '::User'
|
||||
has_many :inbox_capacity_limits, dependent: :destroy, class_name: 'Enterprise::InboxCapacityLimit'
|
||||
has_many :inboxes, through: :inbox_capacity_limits
|
||||
|
||||
@@ -39,7 +39,7 @@ module Enterprise
|
||||
validates :name, presence: true, uniqueness: { scope: :account_id }
|
||||
validates :name, length: { maximum: 255 }
|
||||
validates :description, length: { maximum: 1000 }
|
||||
validates :exclusion_rules, json: { schema: exclusion_rules_schema }
|
||||
validate :validate_exclusion_rules_schema
|
||||
|
||||
# Callbacks
|
||||
before_save :validate_inbox_access
|
||||
@@ -53,14 +53,14 @@ module Enterprise
|
||||
def add_user(user)
|
||||
# Find the account_user for this account and user
|
||||
account_user = account.account_users.find_by!(user: user)
|
||||
|
||||
|
||||
# Update the capacity policy reference
|
||||
account_user.update!(agent_capacity_policy_id: id)
|
||||
invalidate_user_capacity_cache(user)
|
||||
end
|
||||
|
||||
def remove_user(user)
|
||||
account_user = account.account_users.find_by(user: user)
|
||||
account_user = account.account_users.find_by(user: user, agent_capacity_policy_id: id)
|
||||
account_user&.update!(agent_capacity_policy_id: nil)
|
||||
invalidate_user_capacity_cache(user)
|
||||
end
|
||||
@@ -69,7 +69,7 @@ module Enterprise
|
||||
inbox_capacity_limit = inbox_capacity_limits.find_or_initialize_by(inbox: inbox)
|
||||
inbox_capacity_limit.conversation_limit = limit
|
||||
inbox_capacity_limit.save!
|
||||
|
||||
|
||||
invalidate_inbox_capacity_cache(inbox)
|
||||
end
|
||||
|
||||
@@ -95,15 +95,27 @@ module Enterprise
|
||||
|
||||
private
|
||||
|
||||
def validate_exclusion_rules_schema
|
||||
return if exclusion_rules.blank?
|
||||
|
||||
schema = self.class.exclusion_rules_schema
|
||||
schemer = JSONSchemer.schema(schema)
|
||||
validation_errors = schemer.validate(exclusion_rules)
|
||||
|
||||
validation_errors.each do |error|
|
||||
errors.add(:exclusion_rules, error['error'])
|
||||
end
|
||||
end
|
||||
|
||||
def validate_inbox_access
|
||||
# Ensure all specified inboxes belong to the same account
|
||||
invalid_inboxes = inbox_capacity_limits.joins(:inbox)
|
||||
.where.not(inboxes: { account_id: account_id })
|
||||
.where.not(inboxes: { account_id: account_id })
|
||||
|
||||
if invalid_inboxes.exists?
|
||||
errors.add(:inbox_capacity_limits, 'contains inboxes from different accounts')
|
||||
throw :abort
|
||||
end
|
||||
return unless invalid_inboxes.exists?
|
||||
|
||||
errors.add(:inbox_capacity_limits, 'contains inboxes from different accounts')
|
||||
throw :abort
|
||||
end
|
||||
|
||||
def invalidate_capacity_caches
|
||||
@@ -130,4 +142,4 @@ module Enterprise
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,12 +21,12 @@
|
||||
#
|
||||
|
||||
module Enterprise
|
||||
class AgentCapacityPolicyUser < ApplicationRecord
|
||||
class AgentCapacityPolicyUser < ::ApplicationRecord
|
||||
self.table_name = 'enterprise_agent_capacity_policy_users'
|
||||
|
||||
# Associations
|
||||
belongs_to :agent_capacity_policy, class_name: 'Enterprise::AgentCapacityPolicy'
|
||||
belongs_to :user
|
||||
belongs_to :user, class_name: '::User'
|
||||
|
||||
# Validations
|
||||
validates :user_id, uniqueness: true
|
||||
@@ -44,4 +44,4 @@ module Enterprise
|
||||
Rails.cache.delete_matched("assignment_v2:capacity:#{user_id}:*")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -24,14 +24,14 @@
|
||||
#
|
||||
|
||||
module Enterprise
|
||||
class InboxCapacityLimit < ApplicationRecord
|
||||
class InboxCapacityLimit < ::ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
self.table_name = 'enterprise_inbox_capacity_limits'
|
||||
|
||||
# Associations
|
||||
belongs_to :agent_capacity_policy, class_name: 'Enterprise::AgentCapacityPolicy'
|
||||
belongs_to :inbox
|
||||
belongs_to :inbox, class_name: '::Inbox'
|
||||
|
||||
# Validations
|
||||
validates :agent_capacity_policy_id, uniqueness: { scope: :inbox_id }
|
||||
@@ -67,4 +67,4 @@ module Enterprise
|
||||
update_account_cache
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -78,6 +78,11 @@ module Redis::Alfred
|
||||
$alfred.with { |conn| conn.hmget(key, *fields) }
|
||||
end
|
||||
|
||||
# get all fields and values from redis hash
|
||||
def hgetall(key)
|
||||
$alfred.with { |conn| conn.hgetall(key) }
|
||||
end
|
||||
|
||||
# sorted set operations
|
||||
|
||||
# add score and value for a key
|
||||
@@ -100,5 +105,15 @@ module Redis::Alfred
|
||||
def zremrangebyscore(key, range_start, range_end)
|
||||
$alfred.with { |conn| conn.zremrangebyscore(key, range_start, range_end) }
|
||||
end
|
||||
|
||||
# transaction operations
|
||||
|
||||
def multi(&)
|
||||
$alfred.with { |conn| conn.multi(&) }
|
||||
end
|
||||
|
||||
def expire(key, expiry)
|
||||
$alfred.with { |conn| conn.expire(key, expiry) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user