Compare commits

...
21 changed files with 1453 additions and 104 deletions
@@ -0,0 +1,147 @@
# frozen_string_literal: true
class Api::V1::Accounts::LeavesController < Api::V1::Accounts::BaseController
before_action :fetch_leave, only: [:show, :update, :destroy, :approve, :reject]
before_action -> { check_authorization(Leave) }, only: [:index, :create]
before_action :authorize_leave, only: [:show, :update, :destroy]
before_action :authorize_approval, only: [:approve, :reject]
def index
@leaves = leave_service.list(filter_params)
render json: { leaves: serialize_leaves(@leaves) }
end
def show
render json: { leave: serialize_leave(@leave) }
end
def create
user = find_or_authorize_user
service = Leaves::LeaveService.new(
account: Current.account,
user: user,
current_user: Current.user
)
result = service.create(leave_params)
if result[:success]
render json: { leave: serialize_leave(result[:leave]) }, status: :created
else
render json: { errors: result[:errors] }, status: :unprocessable_entity
end
end
def update
result = leave_service.update(@leave, leave_params)
if result[:success]
render json: { leave: serialize_leave(result[:leave]) }
else
render json: { errors: result[:errors] }, status: :unprocessable_entity
end
end
def destroy
if @leave.destroy
head :ok
else
render json: { errors: @leave.errors.full_messages }, status: :unprocessable_entity
end
end
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
render json: { errors: result[:errors] }, status: :unprocessable_entity
end
end
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
render json: { errors: result[:errors] }, status: :unprocessable_entity
end
end
private
def fetch_leave
@leave = Current.account.leaves.find(params[:id])
end
def authorize_leave
authorize @leave
end
def authorize_approval
authorize @leave, :approve?
end
def find_or_authorize_user
if params[:user_id].present? && Current.account_user.administrator?
Current.account.users.find(params[:user_id])
else
Current.user
end
end
def leave_service
@leave_service ||= if @leave
Leaves::LeaveService.new(
account: Current.account,
user: @leave.user,
current_user: Current.user
)
else
# For index action, we don't have a specific leave
Leaves::LeaveService.new(
account: Current.account,
user: nil,
current_user: Current.user
)
end
end
def leave_params
params.require(:leave).permit(:start_date, :end_date, :leave_type, :reason)
end
def filter_params
params.permit(:status, :leave_type, :start_date, :end_date, :user_id)
end
def serialize_leave(leave)
{
id: leave.id,
start_date: leave.start_date,
end_date: leave.end_date,
leave_type: leave.leave_type,
status: leave.status,
reason: leave.reason,
days_count: leave.days_count,
approved_by: leave.approved_by&.name,
approved_at: leave.approved_at,
user: {
id: leave.user.id,
name: leave.user.name,
email: leave.user.email,
avatar_url: leave.user.avatar_url
},
created_at: leave.created_at,
updated_at: leave.updated_at
}
end
def serialize_leaves(leaves)
leaves.map { |leave| serialize_leave(leave) }
end
end
+48
View File
@@ -0,0 +1,48 @@
# frozen_string_literal: true
class ReassignConversationsJob < ApplicationJob
queue_as :low
def perform(account_user)
return unless account_user
user = account_user.user
account = account_user.account
# Find all open conversations assigned to this user
conversations = account.conversations
.open
.where(assignee: user)
Rails.logger.info "Reassigning #{conversations.count} conversations for user #{user.name} (#{user.id}) on leave"
conversations.find_each do |conversation|
reassign_conversation(conversation)
end
end
private
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
# Fallback to auto assignment
AutoAssignmentService.new(
conversation: conversation,
allowed_agent_ids: inbox.assignable_agents.map(&:id) - [conversation.assignee_id]
).perform
end
rescue StandardError => e
Rails.logger.error "Failed to reassign conversation #{conversation.id}: #{e.message}"
end
end
+20 -15
View File
@@ -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
@@ -28,6 +30,9 @@ class AccountUser < ApplicationRecord
belongs_to :account
belongs_to :user
belongs_to :inviter, class_name: 'User', optional: true
belongs_to :agent_capacity_policy, class_name: 'Enterprise::AgentCapacityPolicy', optional: true
has_many :leaves, dependent: :destroy, class_name: 'Leave'
enum role: { agent: 0, administrator: 1 }
enum availability: { online: 0, offline: 1, busy: 2 }
+151 -86
View File
@@ -44,6 +44,11 @@ class Inbox < ApplicationRecord
include Avatarable
include OutOfOffisable
include AccountCacheRevalidator
include AssignmentV2FeatureFlag
include InboxAgentAvailability
include InboxChannelTypes
include InboxWebhooks
include InboxNameSanitization
# Not allowing characters:
validates :name, presence: true
@@ -72,6 +77,10 @@ class Inbox < ApplicationRecord
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
enum sender_name_type: { friendly: 0, professional: 1 }
after_destroy :delete_round_robin_agents
@@ -97,109 +106,165 @@ 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?
# Assignment V2 methods
def assignment_v2_enabled?
account.assignment_v2_enabled? && assignment_policy.present? && assignment_policy.enabled?
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}"
def auto_assignment_enabled?
if assignment_v2_enabled?
assignment_policy.present? && assignment_policy.enabled?
else
enable_auto_assignment?
end
end
def member_ids_with_assignment_capacity
members.ids
# 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
# 3. Rate limiting - Agents who haven't exceeded rate limits (when implemented)
# 4. User exclusions - Specific users can be excluded (e.g., for reassignment)
#
# @param options [Hash] Additional filter options
# @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
# inbox.available_agents
#
# @example Get available agents excluding specific users
# inbox.available_agents(exclude_user_ids: [1, 2, 3])
#
# @example Get available agents without capacity check (faster but less accurate)
# 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 = build_online_agents_scope(online_agent_ids)
# Apply filters
apply_agent_filters(scope, options)
end
private
def default_name_for_blank_name
email? ? display_name_from_email : ''
def build_online_agents_scope(online_agent_ids)
inbox_members
.joins(:user)
.where(users: { id: online_agent_ids })
.includes(:user)
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
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 display_name_from_email
channel.email.split('@').first.parameterize.titleize
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?
# Get IDs of inbox members within rate limits
valid_inbox_member_ids = inbox_members_scope.find_each.filter_map do |inbox_member|
rate_limiter = AssignmentV2::RateLimiter.new(inbox: self, user: inbox_member.user)
inbox_member.id if rate_limiter.within_limits?
end
# Return filtered scope maintaining ActiveRecord relation
inbox_members_scope.where(id: valid_inbox_member_ids)
end
def filter_agents_on_leave(inbox_members_scope)
# Get users on active leave
user_ids_on_leave = account.leaves
.where(status: :approved)
.where('start_date <= ? AND end_date >= ?', Date.current, Date.current)
.pluck(:user_id)
return inbox_members_scope if user_ids_on_leave.empty?
# Exclude inbox members whose users are on leave
inbox_members_scope.where.not(user_id: user_ids_on_leave)
end
def dispatch_create_event
+106
View File
@@ -0,0 +1,106 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: leaves
#
# id :bigint not null, primary key
# approved_at :datetime
# end_date :date not null
# leave_type :integer default("vacation"), not null
# reason :text
# start_date :date not null
# status :integer default("pending"), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# account_user_id :bigint not null
# approved_by_id :bigint
#
# 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)
#
class Leave < ApplicationRecord
belongs_to :account
belongs_to :user
belongs_to :approved_by, class_name: 'User', optional: true
enum leave_type: {
vacation: 0,
sick: 1,
personal: 2,
maternity: 3,
paternity: 4,
bereavement: 5,
unpaid: 6
}
enum status: {
pending: 0,
approved: 1,
rejected: 2,
cancelled: 3
}
validates :start_date, presence: true
validates :end_date, presence: true
validates :leave_type, presence: true
validates :status, presence: true
validate :end_date_after_start_date
validate :no_overlapping_leaves, if: :approved?
scope :active, -> { approved.where('start_date <= ? AND end_date >= ?', Date.current, Date.current) }
scope :upcoming, -> { approved.where('start_date > ?', Date.current) }
scope :past, -> { where('end_date < ?', Date.current) }
scope :by_date_range, ->(start_date, end_date) { where('start_date <= ? AND end_date >= ?', end_date, start_date) }
before_update :set_approved_at, if: -> { status_changed? && approved? }
def active?
approved? && start_date <= Date.current && end_date >= Date.current
end
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
private
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
def no_overlapping_leaves
overlapping_leaves = user.leaves
.approved
.where.not(id: id)
.by_date_range(start_date, end_date)
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
+57
View File
@@ -0,0 +1,57 @@
# frozen_string_literal: true
class LeavePolicy < ApplicationPolicy
def index?
true
end
def show?
# Users can view their own leaves or admins can view all
record.user_id == user.id || @account_user.administrator?
end
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.user_id == user.id
end
def update?
# Users can update their own pending/rejected leaves
# Admins can update any leave
@account_user.administrator? || (record.user_id == user.id && record.pending?)
end
def destroy?
# Users can delete their own pending leaves
# Admins can delete any non-approved leave
if @account_user.administrator?
!record.approved?
else
record.user_id == user.id && record.pending?
end
end
def approve?
# Only admins can approve/reject leaves
@account_user.administrator? && record.pending?
end
def reject?
approve?
end
class Scope < ApplicationPolicy::Scope
def resolve
if @account_user.administrator?
# Admins can see all leaves in the account
scope.where(account: account)
else
# Regular users can only see their own leaves
scope.joins(:account_user).where(account: account, account_users: { user_id: user.id })
end
end
end
end
@@ -0,0 +1,106 @@
# frozen_string_literal: true
class Leaves::LeaveApprovalService
pattr_initialize [:leave!, :approver!]
def approve(comments = nil)
return error_response('Leave is not pending') unless leave.pending?
return error_response('You are not authorized to approve this leave') unless can_approve?
ActiveRecord::Base.transaction do
leave.update!(
status: 'approved',
approved_by: approver,
approved_at: Time.current
)
create_approval_note(comments) if comments.present?
notify_approval
reassign_conversations_if_needed
end
{ success: true, leave: leave }
rescue ActiveRecord::RecordInvalid => e
error_response(e.record.errors.full_messages.join(', '))
rescue StandardError => e
Rails.logger.error "LeaveApprovalService error: #{e.message}"
error_response('An error occurred while approving the leave')
end
def reject(reason)
return error_response('Leave is not pending') unless leave.pending?
return error_response('You are not authorized to reject this leave') unless can_approve?
return error_response('Rejection reason is required') if reason.blank?
ActiveRecord::Base.transaction do
leave.update!(
status: 'rejected',
approved_by: approver,
approved_at: Time.current
)
create_rejection_note(reason)
notify_rejection
end
{ success: true, leave: leave }
rescue ActiveRecord::RecordInvalid => e
error_response(e.record.errors.full_messages.join(', '))
rescue StandardError => e
Rails.logger.error "LeaveApprovalService error: #{e.message}"
error_response('An error occurred while rejecting the leave')
end
private
def can_approve?
account_user = leave.account.account_users.find_by(user: approver)
account_user&.administrator?
end
def error_response(message)
{ success: false, errors: [message] }
end
def create_approval_note(comments)
# This would create a note/activity log if such system exists
# For now, we'll just log it
Rails.logger.info "Leave #{leave.id} approved by #{approver.name} with comments: #{comments}"
end
def create_rejection_note(reason)
# This would create a note/activity log if such system exists
# For now, we'll just log it
Rails.logger.info "Leave #{leave.id} rejected by #{approver.name} with reason: #{reason}"
end
def notify_approval
Rails.configuration.dispatcher.dispatch(
LEAVE_APPROVED,
Time.zone.now,
leave: leave,
approved_by: approver,
account: leave.account,
user: leave.user
)
end
def notify_rejection
Rails.configuration.dispatcher.dispatch(
LEAVE_REJECTED,
Time.zone.now,
leave: leave,
rejected_by: approver,
account: leave.account,
user: leave.user
)
end
def reassign_conversations_if_needed
# If the leave starts today or is already active, reassign conversations
return unless leave.start_date <= Date.current
account_user = leave.account.account_users.find_by(user_id: leave.user_id)
ReassignConversationsJob.perform_later(account_user)
end
end
+106
View File
@@ -0,0 +1,106 @@
# frozen_string_literal: true
class Leaves::LeaveService
pattr_initialize [:account!, :user!, :current_user!]
def create(params)
leave = user.leaves.build(filtered_params(params))
leave.account = account
if leave.save
notify_leave_creation(leave)
{ success: true, leave: leave }
else
{ success: false, errors: leave.errors.full_messages }
end
end
def update(leave, params)
if leave.update(filtered_params(params))
notify_leave_update(leave)
{ success: true, leave: leave }
else
{ success: false, errors: leave.errors.full_messages }
end
end
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 }
else
{ success: false, errors: leave.errors.full_messages }
end
end
def list(filters = {})
scope = policy_scope
# 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?
scope = scope.by_date_range(filters[:start_date], filters[:end_date]) if filters[:start_date].present? && filters[:end_date].present?
scope = scope.where(user_id: filters[:user_id]) if filters[:user_id].present? && current_user_admin?
scope.includes(:user, :approved_by).order(start_date: :desc)
end
private
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
def policy_scope
Pundit.policy_scope(user_context, Leave)
end
def user_context
{
user: current_user,
account: account,
account_user: account.account_users.find_by(user: current_user)
}
end
def current_user_admin?
@current_user_admin ||= account.account_users.find_by(user: current_user)&.administrator?
end
def notify_leave_creation(leave)
Rails.configuration.dispatcher.dispatch(
LEAVE_CREATED,
Time.zone.now,
leave: leave,
account: account,
user: leave.user
)
end
def notify_leave_update(leave)
Rails.configuration.dispatcher.dispatch(
LEAVE_UPDATED,
Time.zone.now,
leave: leave,
account: account,
user: leave.user
)
end
def notify_leave_cancellation(leave)
Rails.configuration.dispatcher.dispatch(
LEAVE_CANCELLED,
Time.zone.now,
leave: leave,
account: account,
user: leave.user
)
end
end
+8
View File
@@ -0,0 +1,8 @@
# frozen_string_literal: true
# Leave Management Events
LEAVE_CREATED = 'leave.created'
LEAVE_UPDATED = 'leave.updated'
LEAVE_APPROVED = 'leave.approved'
LEAVE_REJECTED = 'leave.rejected'
LEAVE_CANCELLED = 'leave.cancelled'
+27 -2
View File
@@ -217,6 +217,33 @@ Rails.application.routes.draw do
end
end
resources :leaves do
member do
post :approve
post :reject
end
end
# 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
post 'inbox_limits/:inbox_id', to: 'agent_capacity_policies#set_inbox_limit'
delete 'inbox_limits/:inbox_id', to: 'agent_capacity_policies#remove_inbox_limit'
post 'users', to: 'agent_capacity_policies#assign_user'
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'
namespace :twitter do
resource :authorization, only: [:create]
end
@@ -290,8 +317,6 @@ Rails.application.routes.draw do
member do
patch :archive
delete :logo
post :send_instructions
get :ssl_status
end
resources :categories
resources :articles do
@@ -0,0 +1,21 @@
# frozen_string_literal: true
class CreateAssignmentPolicies < ActiveRecord::Migration[7.1]
def change
create_table :assignment_policies do |t|
t.references :account, null: false, index: true
t.string :name, null: false, limit: 255
t.text :description
t.integer :assignment_order, null: false, default: 0 # 0: round_robin, 1: balanced
t.integer :conversation_priority, null: false, default: 0 # 0: earliest_created, 1: longest_waiting
t.integer :fair_distribution_limit, null: false, default: 100
t.integer :fair_distribution_window, null: false, default: 3600 # seconds
t.boolean :enabled, null: false, default: true
t.timestamps
end
add_index :assignment_policies, [:account_id, :name], unique: true
add_index :assignment_policies, :enabled
end
end
@@ -0,0 +1,12 @@
# frozen_string_literal: true
class CreateInboxAssignmentPolicies < ActiveRecord::Migration[7.1]
def change
create_table :inbox_assignment_policies do |t|
t.references :inbox, null: false, index: true
t.references :assignment_policy, null: false, index: true
t.timestamps
end
end
end
@@ -0,0 +1,14 @@
# frozen_string_literal: true
class CreateAgentCapacityPolicies < ActiveRecord::Migration[7.1]
def change
create_table :agent_capacity_policies do |t|
t.references :account, null: false, index: true
t.string :name, null: false, limit: 255
t.text :description
t.jsonb :exclusion_rules, default: {}, null: false
t.timestamps
end
end
end
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class CreateInboxCapacityLimits < ActiveRecord::Migration[7.1]
def change
create_table :inbox_capacity_limits do |t|
t.references :agent_capacity_policy, null: false, index: true
t.references :inbox, null: false, index: true
t.integer :conversation_limit, null: false
t.timestamps
end
add_index :inbox_capacity_limits, [:agent_capacity_policy_id, :inbox_id], unique: true
end
end
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddAgentCapacityPolicyToAccountUsers < ActiveRecord::Migration[7.1]
def change
add_reference :account_users, :agent_capacity_policy, null: true, index: true
end
end
@@ -0,0 +1,21 @@
# frozen_string_literal: true
class CreateLeaves < ActiveRecord::Migration[7.1]
def change
create_table :leaves do |t|
t.references :account, null: false
t.references :user, null: false
t.date :start_date, null: false
t.date :end_date, null: false
t.integer :leave_type, null: false, default: 0
t.integer :status, null: false, default: 0
t.text :reason
t.references :approved_by
t.datetime :approved_at
t.timestamps
end
add_index :leaves, [:account_id, :status]
end
end
+67 -1
View File
@@ -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_08_05_160307) do
ActiveRecord::Schema[7.1].define(version: 2025_08_06_140005) 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_08_05_160307) 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
@@ -120,6 +122,16 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_05_160307) do
t.index ["account_id"], name: "index_agent_bots_on_account_id"
end
create_table "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: "index_agent_capacity_policies_on_account_id"
end
create_table "applied_slas", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "sla_policy_id", null: false
@@ -169,6 +181,22 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_05_160307) 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: 100, 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: "index_assignment_policies_on_account_id_and_name", 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"
@@ -728,6 +756,26 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_05_160307) 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"
end
create_table "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: "idx_on_agent_capacity_policy_id_inbox_id_71c7ec4caf", unique: true
t.index ["agent_capacity_policy_id"], name: "index_inbox_capacity_limits_on_agent_capacity_policy_id"
t.index ["inbox_id"], name: "index_inbox_capacity_limits_on_inbox_id"
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 +848,24 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_05_160307) 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 "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_id_and_status"
t.index ["account_id"], name: "index_leaves_on_account_id"
t.index ["approved_by_id"], name: "index_leaves_on_approved_by_id"
t.index ["user_id"], name: "index_leaves_on_user_id"
end
create_table "macros", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "name", null: false
@@ -0,0 +1,220 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Leaves API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account) }
let(:agent_account_user) { account.account_users.find_by(user: agent) }
let(:another_agent) { create(:user, account: account) }
describe 'GET /api/v1/accounts/:account_id/leaves' do
context 'when authenticated as an agent' do
it 'returns only their own leaves' do
leave1 = create(:leave, user: agent, account: account)
create(:leave, user: another_agent, account: account)
get "/api/v1/accounts/#{account.id}/leaves",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['leaves'].size).to eq(1)
expect(json_response['leaves'].first['id']).to eq(leave1.id)
end
end
context 'when authenticated as an admin' do
it 'returns all leaves in the account' do
create(:leave, user: agent, account: account)
create(:leave, user: another_agent, account: account)
get "/api/v1/accounts/#{account.id}/leaves",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['leaves'].size).to eq(2)
end
end
end
describe 'POST /api/v1/accounts/:account_id/leaves' do
context 'when authenticated as an agent' do
it 'creates a leave request for themselves' do
leave_params = {
leave: {
start_date: Date.current + 1.day,
end_date: Date.current + 7.days,
leave_type: 'vacation',
reason: 'Annual vacation'
}
}
post "/api/v1/accounts/#{account.id}/leaves",
params: leave_params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:created)
json_response = response.parsed_body
expect(json_response['leave']['status']).to eq('pending')
expect(json_response['leave']['leave_type']).to eq('vacation')
end
it 'validates date order' do
leave_params = {
leave: {
start_date: Date.current + 7.days,
end_date: Date.current + 1.day,
leave_type: 'vacation',
reason: 'Invalid dates'
}
}
post "/api/v1/accounts/#{account.id}/leaves",
params: leave_params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['errors']).to include('End date must be after or equal to start date')
end
end
end
describe 'PUT /api/v1/accounts/:account_id/leaves/:id' do
let(:leave) { create(:leave, user: agent, account: account) }
context 'when authenticated as the leave owner' do
it 'updates pending leave' do
update_params = {
leave: {
end_date: Date.current + 10.days,
reason: 'Extended vacation'
}
}
put "/api/v1/accounts/#{account.id}/leaves/#{leave.id}",
params: update_params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['leave']['reason']).to eq('Extended vacation')
end
it 'cannot update approved leave' do
leave.update!(status: 'approved')
update_params = {
leave: {
end_date: Date.current + 10.days
}
}
put "/api/v1/accounts/#{account.id}/leaves/#{leave.id}",
params: update_params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
json_response = response.parsed_body
expect(json_response['error']).to eq('You are not authorized to do this action')
end
end
end
describe 'POST /api/v1/accounts/:account_id/leaves/:id/approve' do
let(:leave) { create(:leave, user: agent, account: account) }
context 'when authenticated as an admin' do
it 'approves the leave' do
post "/api/v1/accounts/#{account.id}/leaves/#{leave.id}/approve",
params: { comments: 'Approved for vacation' },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['leave']['status']).to eq('approved')
expect(json_response['leave']['approved_by']).to eq(admin.name)
end
end
context 'when authenticated as a regular agent' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/leaves/#{leave.id}/approve",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
json_response = response.parsed_body
expect(json_response['error']).to eq('You are not authorized to do this action')
end
end
end
describe 'POST /api/v1/accounts/:account_id/leaves/:id/reject' do
let(:leave) { create(:leave, user: agent, account: account) }
context 'when authenticated as an admin' do
it 'rejects the leave with reason' do
post "/api/v1/accounts/#{account.id}/leaves/#{leave.id}/reject",
params: { reason: 'Not enough coverage' },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['leave']['status']).to eq('rejected')
end
it 'requires rejection reason' do
post "/api/v1/accounts/#{account.id}/leaves/#{leave.id}/reject",
params: { reason: '' },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['errors']).to include('Rejection reason is required')
end
end
end
describe 'DELETE /api/v1/accounts/:account_id/leaves/:id' do
context 'when deleting own pending leave' do
let(:leave) { create(:leave, user: agent, account: account) }
it 'deletes the leave' do
delete "/api/v1/accounts/#{account.id}/leaves/#{leave.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(Leave.find_by(id: leave.id)).to be_nil
end
end
context 'when trying to delete approved leave' do
let(:leave) { create(:leave, :approved, user: agent, account: account) }
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/leaves/#{leave.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
json_response = response.parsed_body
expect(json_response['error']).to eq('You are not authorized to do this action')
expect(Leave.find_by(id: leave.id)).to be_present
end
end
end
end
+57
View File
@@ -0,0 +1,57 @@
# frozen_string_literal: true
FactoryBot.define do
factory :leave do
account
user
start_date { Date.current + 1.day }
end_date { Date.current + 7.days }
leave_type { 'vacation' }
status { 'pending' }
reason { 'Annual vacation' }
trait :approved do
status { 'approved' }
approved_by { create(:user) }
approved_at { Time.current }
end
trait :rejected do
status { 'rejected' }
approved_by { create(:user) }
approved_at { Time.current }
end
trait :cancelled do
status { 'cancelled' }
end
trait :active do
approved
start_date { Date.current }
end_date { Date.current + 7.days }
end
trait :past do
approved
start_date { Date.current - 14.days }
end_date { Date.current - 7.days }
end
trait :future do
approved
start_date { Date.current + 7.days }
end_date { Date.current + 14.days }
end
trait :sick_leave do
leave_type { 'sick' }
reason { 'Medical reasons' }
end
trait :personal_leave do
leave_type { 'personal' }
reason { 'Personal matters' }
end
end
end
+108
View File
@@ -0,0 +1,108 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Inbox Leave Integration', skip: 'Enterprise feature', type: :model do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:user1) { create(:user) }
let(:user2) { create(:user) }
let(:user3) { create(:user) }
let(:account_user1) { create(:account_user, account: account, user: user1) }
let(:account_user2) { create(:account_user, account: account, user: user2) }
let(:account_user3) { create(:account_user, account: account, user: user3) }
before do
# Create inbox members
create(:inbox_member, inbox: inbox, user: user1)
create(:inbox_member, inbox: inbox, user: user2)
create(:inbox_member, inbox: inbox, user: user3)
# Set all users as online
OnlineStatusTracker.set_status(account.id, user1.id, 'online')
OnlineStatusTracker.set_status(account.id, user2.id, 'online')
OnlineStatusTracker.set_status(account.id, user3.id, 'online')
end
describe '#available_agents' do
context 'when no one is on leave' do
it 'returns all online agents' do
available = inbox.available_agents
expect(available.map(&:user_id)).to contain_exactly(user1.id, user2.id, user3.id)
end
end
context 'when an agent is on approved leave' do
before do
create(:leave, :active, user: user1, account: account)
end
it 'excludes the agent on leave' do
available = inbox.available_agents
expect(available.map(&:user_id)).to contain_exactly(user2.id, user3.id)
expect(available.map(&:user_id)).not_to include(user1.id)
end
end
context 'when multiple agents are on leave' do
before do
create(:leave, :active, user: user1, account: account)
create(:leave, :active, user: user2, account: account)
end
it 'excludes all agents on leave' do
available = inbox.available_agents
expect(available.map(&:user_id)).to contain_exactly(user3.id)
end
end
context 'when an agent has pending leave' do
before do
create(:leave, user: user1, account: account, status: 'pending')
end
it 'does not exclude agents with pending leave' do
available = inbox.available_agents
expect(available.map(&:user_id)).to contain_exactly(user1.id, user2.id, user3.id)
end
end
context 'when an agent has future approved leave' do
before do
create(:leave, :future, user: user1, account: account)
end
it 'does not exclude agents with future leave' do
available = inbox.available_agents
expect(available.map(&:user_id)).to contain_exactly(user1.id, user2.id, user3.id)
end
end
context 'when an agent has past leave' do
before do
create(:leave, :past, user: user1, account: account)
end
it 'does not exclude agents with past leave' do
available = inbox.available_agents
expect(available.map(&:user_id)).to contain_exactly(user1.id, user2.id, user3.id)
end
end
context 'with exclude_on_leave option' do
before do
create(:leave, :active, user: user1, account: account)
end
it 'excludes agents on leave by default' do
available = inbox.available_agents
expect(available.map(&:user_id)).not_to include(user1.id)
end
it 'includes agents on leave when exclude_on_leave is false' do
available = inbox.available_agents(exclude_on_leave: false)
expect(available.map(&:user_id)).to include(user1.id)
end
end
end
end
+135
View File
@@ -0,0 +1,135 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Leave, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:approved_by).class_name('User').optional }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:start_date) }
it { is_expected.to validate_presence_of(:end_date) }
it { is_expected.to validate_presence_of(:leave_type) }
it { is_expected.to validate_presence_of(:status) }
describe 'end_date_after_start_date' do
let(:leave) { build(:leave, start_date: Date.current, end_date: Date.current - 1.day) }
it 'validates end date is after start date' do
expect(leave).not_to be_valid
expect(leave.errors[:end_date]).to include('must be after or equal to start date')
end
end
describe 'no_overlapping_leaves' do
let(:account_user) { create(:account_user) }
let(:new_leave) do
build(:leave, account_user: account_user, start_date: Date.current + 3.days, end_date: Date.current + 10.days, status: 'approved')
end
before { create(:leave, :approved, account_user: account_user, start_date: Date.current, end_date: Date.current + 7.days) }
it 'prevents overlapping approved leaves' do
expect(new_leave).not_to be_valid
expect(new_leave.errors[:base]).to include('Leave dates overlap with an existing approved leave')
end
it 'allows overlapping pending leaves' do
new_leave.status = 'pending'
expect(new_leave).to be_valid
end
end
end
describe 'enums' do
it {
expect(subject).to define_enum_for(:leave_type).with_values(vacation: 0, sick: 1, personal: 2, maternity: 3, paternity: 4, bereavement: 5,
unpaid: 6)
}
it { is_expected.to define_enum_for(:status).with_values(pending: 0, approved: 1, rejected: 2, cancelled: 3) }
end
describe 'scopes' do
let!(:active_leave) { create(:leave, :active) }
let!(:upcoming_leave) { create(:leave, :future) }
let!(:past_leave) { create(:leave, :past) }
let!(:pending_leave) { create(:leave) }
describe '.active' do
it 'returns leaves that are currently active' do
expect(described_class.active).to include(active_leave)
expect(described_class.active).not_to include(upcoming_leave, past_leave, pending_leave)
end
end
describe '.upcoming' do
it 'returns approved leaves starting in the future' do
expect(described_class.upcoming).to include(upcoming_leave)
expect(described_class.upcoming).not_to include(active_leave, past_leave, pending_leave)
end
end
describe '.past' do
it 'returns leaves that have ended' do
expect(described_class.past).to include(past_leave)
expect(described_class.past).not_to include(active_leave, upcoming_leave, pending_leave)
end
end
end
describe '#active?' do
it 'returns true for approved leaves within current date' do
leave = build(:leave, :active)
expect(leave.active?).to be true
end
it 'returns false for pending leaves' do
leave = build(:leave)
expect(leave.active?).to be false
end
it 'returns false for future leaves' do
leave = build(:leave, :future)
expect(leave.active?).to be false
end
end
describe '#days_count' do
it 'calculates the number of days' do
leave = build(:leave, start_date: Date.current, end_date: Date.current + 6.days)
expect(leave.days_count).to eq(7)
end
end
describe '#overlaps_with?' do
let(:leave1) { build(:leave, start_date: Date.current, end_date: Date.current + 7.days) }
let(:leave2) { build(:leave, start_date: Date.current + 3.days, end_date: Date.current + 10.days) }
let(:leave3) { build(:leave, start_date: Date.current + 8.days, end_date: Date.current + 15.days) }
it 'returns true for overlapping leaves' do
expect(leave1.overlaps_with?(leave2)).to be true
expect(leave2.overlaps_with?(leave1)).to be true
end
it 'returns false for non-overlapping leaves' do
expect(leave1.overlaps_with?(leave3)).to be false
expect(leave3.overlaps_with?(leave1)).to be false
end
end
describe 'callbacks' do
describe 'set_approved_at' do
let(:leave) { create(:leave) }
it 'sets approved_at when status changes to approved' do
expect(leave.approved_at).to be_nil
leave.update!(status: 'approved')
expect(leave.approved_at).to be_present
end
end
end
end