add leave control
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
# 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
|
||||
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
|
||||
account_user = find_or_authorize_account_user
|
||||
service = Leaves::LeaveService.new(
|
||||
account: Current.account,
|
||||
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
|
||||
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_account_user
|
||||
if params[:user_id].present? && Current.account_user.administrator?
|
||||
user = Current.account.users.find(params[:user_id])
|
||||
Current.account.account_users.find_by!(user: user)
|
||||
else
|
||||
Current.account.account_users.find_by!(user: Current.user)
|
||||
end
|
||||
end
|
||||
|
||||
def leave_service
|
||||
@leave_service ||= Leaves::LeaveService.new(
|
||||
account: Current.account,
|
||||
account_user: @leave.account_user,
|
||||
current_user: Current.user
|
||||
)
|
||||
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
|
||||
@@ -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
|
||||
@@ -98,6 +98,7 @@ 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
|
||||
|
||||
# Assignment V2 associations
|
||||
has_many :assignment_policies, dependent: :destroy_async
|
||||
|
||||
@@ -30,6 +30,8 @@ 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
|
||||
|
||||
enum role: { agent: 0, administrator: 1 }
|
||||
enum availability: { online: 0, offline: 1, busy: 2 }
|
||||
|
||||
|
||||
@@ -254,6 +254,11 @@ class Inbox < ApplicationRecord
|
||||
scope = filter_by_rate_limits(scope)
|
||||
end
|
||||
|
||||
# Exclude agents who are on leave
|
||||
if options[:exclude_on_leave] != false
|
||||
scope = filter_agents_on_leave(scope)
|
||||
end
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
@@ -319,6 +324,21 @@ class Inbox < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def filter_agents_on_leave(inbox_members_scope)
|
||||
# Get account users on active leave
|
||||
account_user_ids_on_leave = account.account_users
|
||||
.joins(:leaves)
|
||||
.where(leaves: { status: 'approved' })
|
||||
.where('leaves.start_date <= ? AND leaves.end_date >= ?', Date.current, Date.current)
|
||||
.pluck(:id)
|
||||
|
||||
return inbox_members_scope if account_user_ids_on_leave.empty?
|
||||
|
||||
# Exclude inbox members whose account_users are on leave
|
||||
user_ids_on_leave = account.account_users.where(id: account_user_ids_on_leave).pluck(:user_id)
|
||||
inbox_members_scope.where.not(user_id: user_ids_on_leave)
|
||||
end
|
||||
|
||||
def default_name_for_blank_name
|
||||
email? ? display_name_from_email : ''
|
||||
end
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# 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)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
# fk_rails_... (account_user_id => account_users.id)
|
||||
# fk_rails_... (approved_by_id => users.id)
|
||||
#
|
||||
|
||||
class Leave < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :account_user
|
||||
belongs_to :approved_by, class_name: 'User', optional: true
|
||||
|
||||
has_one :user, through: :account_user
|
||||
|
||||
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 = account_user.leaves
|
||||
.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
|
||||
end
|
||||
|
||||
def set_approved_at
|
||||
self.approved_at = Time.current
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
# 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.account_user.user_id == user.id || @account_user.administrator?
|
||||
end
|
||||
|
||||
def create?
|
||||
# Users can create their own leave requests
|
||||
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
|
||||
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.account_user.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,105 @@
|
||||
# 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
|
||||
|
||||
ReassignConversationsJob.perform_later(leave.account_user)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,110 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Leaves::LeaveService
|
||||
pattr_initialize [:account!, :account_user!, :current_user!]
|
||||
|
||||
def create(params)
|
||||
leave = account_user.leaves.build(filtered_params(params))
|
||||
leave.account = account
|
||||
|
||||
if leave.save
|
||||
notify_leave_creation(leave)
|
||||
{ success: true, leave: leave }
|
||||
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?
|
||||
|
||||
if filters[:start_date].present? && filters[:end_date].present?
|
||||
scope = scope.by_date_range(filters[:start_date], filters[:end_date])
|
||||
end
|
||||
|
||||
if filters[:user_id].present? && current_user_admin?
|
||||
scope = scope.joins(:account_user).where(account_users: { user_id: filters[:user_id] })
|
||||
end
|
||||
|
||||
scope.includes(:account_user, :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
|
||||
@@ -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'
|
||||
@@ -217,6 +217,13 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
resources :leaves do
|
||||
member do
|
||||
post :approve
|
||||
post :reject
|
||||
end
|
||||
end
|
||||
|
||||
namespace :twitter do
|
||||
resource :authorization, only: [:create]
|
||||
end
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateLeaves < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :leaves do |t|
|
||||
t.references :account, null: false
|
||||
t.references :account_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, :start_date
|
||||
add_index :leaves, :end_date
|
||||
add_index :leaves, :status
|
||||
add_index :leaves, [:account_user_id, :start_date, :end_date], name: 'index_leaves_on_account_user_and_dates'
|
||||
add_index :leaves, [:account_id, :status], name: 'index_leaves_on_account_and_status'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,213 @@
|
||||
# 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
|
||||
create(:leave, account_user: agent_account_user)
|
||||
create(:leave, account_user: account.account_users.find_by(user: another_agent))
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/leaves",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['leaves'].size).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as an admin' do
|
||||
it 'returns all leaves in the account' do
|
||||
create(:leave, account_user: agent_account_user)
|
||||
create(:leave, account_user: account.account_users.find_by(user: another_agent))
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/leaves",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = JSON.parse(response.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 = JSON.parse(response.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 = JSON.parse(response.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, account_user: agent_account_user) }
|
||||
|
||||
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 = JSON.parse(response.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(:forbidden)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/leaves/:id/approve' do
|
||||
let(:leave) { create(:leave, account_user: agent_account_user) }
|
||||
|
||||
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 = JSON.parse(response.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 forbidden' 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(:forbidden)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/leaves/:id/reject' do
|
||||
let(:leave) { create(:leave, account_user: agent_account_user) }
|
||||
|
||||
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 = JSON.parse(response.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 = JSON.parse(response.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, account_user: agent_account_user) }
|
||||
|
||||
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, account_user: agent_account_user) }
|
||||
|
||||
it 'returns forbidden' do
|
||||
delete "/api/v1/accounts/#{account.id}/leaves/#{leave.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(Leave.find_by(id: leave.id)).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :leave do
|
||||
account
|
||||
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
|
||||
@@ -0,0 +1,108 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Inbox Leave Integration', 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, account_user: account_user1)
|
||||
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, account_user: account_user1)
|
||||
create(:leave, :active, account_user: account_user2)
|
||||
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, account_user: account_user1, 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, account_user: account_user1)
|
||||
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, account_user: account_user1)
|
||||
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, account_user: account_user1)
|
||||
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
|
||||
@@ -0,0 +1,129 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Leave, type: :model do
|
||||
describe 'associations' do
|
||||
it { should belong_to(:account) }
|
||||
it { should belong_to(:account_user) }
|
||||
it { should belong_to(:approved_by).class_name('User').optional }
|
||||
it { should have_one(:user).through(:account_user) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it { should validate_presence_of(:start_date) }
|
||||
it { should validate_presence_of(:end_date) }
|
||||
it { should validate_presence_of(:leave_type) }
|
||||
it { should 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!(:existing_leave) { create(:leave, :approved, account_user: account_user, start_date: Date.current, end_date: Date.current + 7.days) }
|
||||
let(:new_leave) { build(:leave, account_user: account_user, start_date: Date.current + 3.days, end_date: Date.current + 10.days, status: 'approved') }
|
||||
|
||||
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 { should define_enum_for(:leave_type).with_values(vacation: 0, sick: 1, personal: 2, maternity: 3, paternity: 4, bereavement: 5, unpaid: 6) }
|
||||
it { should 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(Leave.active).to include(active_leave)
|
||||
expect(Leave.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(Leave.upcoming).to include(upcoming_leave)
|
||||
expect(Leave.upcoming).not_to include(active_leave, past_leave, pending_leave)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.past' do
|
||||
it 'returns leaves that have ended' do
|
||||
expect(Leave.past).to include(past_leave)
|
||||
expect(Leave.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
|
||||
Reference in New Issue
Block a user