diff --git a/app/controllers/api/v1/accounts/leaves_controller.rb b/app/controllers/api/v1/accounts/leaves_controller.rb new file mode 100644 index 000000000..51cf4653e --- /dev/null +++ b/app/controllers/api/v1/accounts/leaves_controller.rb @@ -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 \ No newline at end of file diff --git a/app/jobs/reassign_conversations_job.rb b/app/jobs/reassign_conversations_job.rb new file mode 100644 index 000000000..dee4da9a3 --- /dev/null +++ b/app/jobs/reassign_conversations_job.rb @@ -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 \ No newline at end of file diff --git a/app/models/account.rb b/app/models/account.rb index 96c076ff0..e1271cf7f 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -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 diff --git a/app/models/account_user.rb b/app/models/account_user.rb index 060e5b38d..e85b7b1e4 100644 --- a/app/models/account_user.rb +++ b/app/models/account_user.rb @@ -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 } diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 2b12572bb..b52257467 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -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 diff --git a/app/models/leave.rb b/app/models/leave.rb new file mode 100644 index 000000000..cf1ca136c --- /dev/null +++ b/app/models/leave.rb @@ -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 \ No newline at end of file diff --git a/app/policies/leave_policy.rb b/app/policies/leave_policy.rb new file mode 100644 index 000000000..7678ec1db --- /dev/null +++ b/app/policies/leave_policy.rb @@ -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 \ No newline at end of file diff --git a/app/services/leaves/leave_approval_service.rb b/app/services/leaves/leave_approval_service.rb new file mode 100644 index 000000000..daf9e3ceb --- /dev/null +++ b/app/services/leaves/leave_approval_service.rb @@ -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 \ No newline at end of file diff --git a/app/services/leaves/leave_service.rb b/app/services/leaves/leave_service.rb new file mode 100644 index 000000000..0a1d89d3e --- /dev/null +++ b/app/services/leaves/leave_service.rb @@ -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 \ No newline at end of file diff --git a/config/initializers/leave_events.rb b/config/initializers/leave_events.rb new file mode 100644 index 000000000..a869adef0 --- /dev/null +++ b/config/initializers/leave_events.rb @@ -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' \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index c12aa670b..fd368d4f3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20250730000000_create_leaves.rb b/db/migrate/20250730000000_create_leaves.rb new file mode 100644 index 000000000..c394e3b6f --- /dev/null +++ b/db/migrate/20250730000000_create_leaves.rb @@ -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 \ No newline at end of file diff --git a/spec/controllers/api/v1/accounts/leaves_controller_spec.rb b/spec/controllers/api/v1/accounts/leaves_controller_spec.rb new file mode 100644 index 000000000..4cf422dd3 --- /dev/null +++ b/spec/controllers/api/v1/accounts/leaves_controller_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/factories/leaves.rb b/spec/factories/leaves.rb new file mode 100644 index 000000000..b8d1dc85c --- /dev/null +++ b/spec/factories/leaves.rb @@ -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 \ No newline at end of file diff --git a/spec/models/inbox_leave_integration_spec.rb b/spec/models/inbox_leave_integration_spec.rb new file mode 100644 index 000000000..c1927e716 --- /dev/null +++ b/spec/models/inbox_leave_integration_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/models/leave_spec.rb b/spec/models/leave_spec.rb new file mode 100644 index 000000000..2617c782f --- /dev/null +++ b/spec/models/leave_spec.rb @@ -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 \ No newline at end of file