Compare commits

...
19 changed files with 666 additions and 8 deletions
+3
View File
@@ -98,6 +98,9 @@ en:
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
custom_attribute_definition:
key_conflict: The provided key is not allowed as it might conflict with default attributes.
leave_records:
cannot_update_non_pending: Cannot update non-pending leave record
cannot_delete: Cannot delete this leave record
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
+6
View File
@@ -96,6 +96,12 @@ Rails.application.routes.draw do
post :execute, on: :member
end
resources :sla_policies, only: [:index, :create, :show, :update, :destroy]
resources :leave_records, only: [:index, :create, :show, :update, :destroy] do
member do
patch :approve
patch :reject
end
end
resources :custom_roles, only: [:index, :create, :show, :update, :destroy]
resources :campaigns, only: [:index, :create, :show, :update, :destroy]
resources :dashboard_apps, only: [:index, :show, :create, :update, :destroy]
@@ -1,5 +1,3 @@
# frozen_string_literal: true
class CreateLeaves < ActiveRecord::Migration[7.1]
def change
create_table :leaves do |t|
@@ -0,0 +1,5 @@
class RenameLeaveTableToLeaveRecords < ActiveRecord::Migration[7.1]
def change
rename_table :leaves, :leave_records
end
end
+6 -6
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_08_123008) do
ActiveRecord::Schema[7.1].define(version: 2025_08_20_083315) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -848,7 +848,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do
t.index ["title", "account_id"], name: "index_labels_on_title_and_account_id", unique: true
end
create_table "leaves", force: :cascade do |t|
create_table "leave_records", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "user_id", null: false
t.date "start_date", null: false
@@ -860,10 +860,10 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do
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"
t.index ["account_id", "status"], name: "index_leave_records_on_account_id_and_status"
t.index ["account_id"], name: "index_leave_records_on_account_id"
t.index ["approved_by_id"], name: "index_leave_records_on_approved_by_id"
t.index ["user_id"], name: "index_leave_records_on_user_id"
end
create_table "macros", force: :cascade do |t|
@@ -0,0 +1,57 @@
class Api::V1::Accounts::LeaveRecordsController < Api::V1::Accounts::EnterpriseAccountsController
before_action :check_authorization
before_action :fetch_leave_record, only: [:show, :update, :destroy, :approve, :reject]
before_action :ensure_pending_status, only: [:update]
before_action :ensure_can_be_cancelled, only: [:destroy]
def index
@leave_records = policy_scope(Current.account.leave_records)
end
def show; end
def create
@leave_record = Current.account.leave_records.create!(permitted_params.merge(user: current_user))
end
def update
@leave_record.update!(permitted_params)
end
def destroy
@leave_record.destroy!
head :ok
end
def approve
@leave_record.approve!(current_user)
render :show
end
def reject
@leave_record.reject!(current_user)
render :show
end
private
def permitted_params
params.require(:leave_record).permit(:start_date, :end_date, :leave_type, :reason)
end
def fetch_leave_record
@leave_record = policy_scope(Current.account.leave_records).find(params[:id])
end
def ensure_pending_status
return if @leave_record.pending?
render_could_not_create_error(I18n.t('errors.leave_records.cannot_update_non_pending'))
end
def ensure_can_be_cancelled
return if @leave_record.can_be_cancelled?
render_could_not_create_error(I18n.t('errors.leave_records.cannot_delete'))
end
end
@@ -5,6 +5,7 @@ module Enterprise::Concerns::Account
has_many :sla_policies, dependent: :destroy_async
has_many :applied_slas, dependent: :destroy_async
has_many :custom_roles, dependent: :destroy_async
has_many :leave_records, dependent: :destroy_async
has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant'
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
@@ -6,6 +6,7 @@ module Enterprise::Concerns::User
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
has_many :copilot_threads, dependent: :destroy_async
has_many :leave_records, dependent: :destroy_async
end
def ensure_installation_pricing_plan_quantity
+105
View File
@@ -0,0 +1,105 @@
# == Schema Information
#
# Table name: leave_records
#
# id :bigint not null, primary key
# approved_at :datetime
# end_date :date not null
# leave_type :integer default("annual"), 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
# approved_by_id :bigint
# user_id :bigint not null
#
# Indexes
#
# index_leave_records_on_account_id (account_id)
# index_leave_records_on_account_id_and_status (account_id,status)
# index_leave_records_on_approved_by_id (approved_by_id)
# index_leave_records_on_user_id (user_id)
#
class LeaveRecord < ApplicationRecord
belongs_to :account
belongs_to :user
belongs_to :approved_by, class_name: 'User', optional: true
enum leave_type: {
annual: 0,
sick: 1,
personal: 2,
maternity: 3,
paternity: 4,
emergency: 5,
bereavement: 6,
study: 7,
other: 8
}
enum status: {
pending: 0,
approved: 1,
rejected: 2,
cancelled: 3
}
validates :start_date, :end_date, presence: true
validates :leave_type, :status, presence: true
validate :end_date_after_start_date
validate :future_dates_for_pending_leaves
validate :approved_by_is_admin
scope :for_account, ->(account_id) { where(account_id: account_id) }
scope :for_user, ->(user_id) { where(user_id: user_id) }
scope :by_status, ->(status) { where(status: status) }
scope :by_leave_type, ->(leave_type) { where(leave_type: leave_type) }
scope :in_date_range, ->(start_date, end_date) { where('start_date <= ? AND end_date >= ?', end_date, start_date) }
def approve!(approved_by_user)
update!(status: :approved, approved_by_id: approved_by_user.id, approved_at: Time.current)
end
def reject!(approved_by_user)
update!(status: :rejected, approved_by_id: approved_by_user.id, approved_at: Time.current)
end
def duration_in_days
return 0 unless start_date && end_date
(end_date - start_date).to_i + 1
end
def can_be_cancelled?
pending? || (approved? && start_date > Date.current)
end
def overlaps_with?(other_leave)
return false unless other_leave.is_a?(LeaveRecord)
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 start date') if end_date < start_date
end
def future_dates_for_pending_leaves
return unless pending?
errors.add(:start_date, 'must be in the future') if start_date && start_date <= Date.current
end
def approved_by_is_admin
return unless approved_by_id && approved_by
account_user = account.account_users.find_by(user: approved_by)
errors.add(:approved_by, 'must be an administrator') unless account_user&.administrator?
end
end
@@ -0,0 +1,49 @@
class LeaveRecordPolicy < ApplicationPolicy
def index?
@account_user.administrator? || @account_user.agent?
end
def show?
@account_user.administrator? || @account_user.agent?
end
def create?
@account_user.administrator? || @account_user.agent?
end
def update?
@account_user.administrator? || @account_user.agent?
end
def destroy?
@account_user.administrator? || @account_user.agent?
end
def approve?
@account_user.administrator?
end
def reject?
@account_user.administrator?
end
class Scope
attr_reader :user_context, :user, :scope, :account, :account_user
def initialize(user_context, scope)
@user_context = user_context
@user = user_context[:user]
@account = user_context[:account]
@account_user = user_context[:account_user]
@scope = scope
end
def resolve
if @account_user.administrator?
scope.includes(:user, :approved_by)
else
scope.where(user: @user).includes(:user, :approved_by)
end
end
end
end
@@ -0,0 +1 @@
json.partial! 'api/v1/models/leave_record', formats: [:json], leave_record: @leave_record
@@ -0,0 +1,3 @@
json.array! @leave_records do |leave_record|
json.partial! 'api/v1/models/leave_record', formats: [:json], leave_record: leave_record
end
@@ -0,0 +1 @@
json.partial! 'api/v1/models/leave_record', formats: [:json], leave_record: @leave_record
@@ -0,0 +1 @@
json.partial! 'api/v1/models/leave_record', formats: [:json], leave_record: @leave_record
@@ -0,0 +1,26 @@
json.id leave_record.id
json.start_date leave_record.start_date
json.end_date leave_record.end_date
json.leave_type leave_record.leave_type
json.status leave_record.status
json.reason leave_record.reason
json.duration_in_days leave_record.duration_in_days
json.approved_at leave_record.approved_at&.to_i
json.created_at leave_record.created_at.to_i
json.updated_at leave_record.updated_at.to_i
json.user do
json.id leave_record.user.id
json.name leave_record.user.name
json.email leave_record.user.email
end
if leave_record.approved_by.present?
json.approved_by do
json.id leave_record.approved_by.id
json.name leave_record.approved_by.name
json.email leave_record.approved_by.email
end
else
json.approved_by nil
end
@@ -0,0 +1,152 @@
require 'rails_helper'
RSpec.describe 'LeaveRecords API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:other_agent) { create(:user, account: account, role: :agent) }
describe 'GET /api/v1/accounts/:account_id/leave_records' do
before do
create(:leave_record, account: account, user: agent)
create(:leave_record, account: account, user: other_agent)
end
it 'allows admins to see all leave records' do
get "/api/v1/accounts/#{account.id}/leave_records",
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(json_response.length).to eq(2)
end
it 'allows agents to see only their own leave records' do
get "/api/v1/accounts/#{account.id}/leave_records",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:success)
expect(json_response.length).to eq(1)
expect(json_response.first['user']['id']).to eq(agent.id)
end
end
describe 'POST /api/v1/accounts/:account_id/leave_records' do
let(:valid_params) do
{
leave_record: {
start_date: 1.week.from_now.to_date,
end_date: 2.weeks.from_now.to_date,
leave_type: 'annual',
reason: 'Family vacation'
}
}
end
it 'creates a leave record for the current user' do
expect do
post "/api/v1/accounts/#{account.id}/leave_records",
params: valid_params,
headers: agent.create_new_auth_token
end.to change(LeaveRecord, :count).by(1)
expect(response).to have_http_status(:success)
expect(json_response['user']['id']).to eq(agent.id)
expect(json_response['status']).to eq('pending')
end
it 'fails with invalid params' do
post "/api/v1/accounts/#{account.id}/leave_records",
params: { leave_record: { start_date: nil } },
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
end
end
describe 'PATCH /api/v1/accounts/:account_id/leave_records/:id' do
let(:leave_record) { create(:leave_record, account: account, user: agent, status: :pending) }
let(:update_params) { { leave_record: { reason: 'Updated reason' } } }
it 'allows updating pending leave records' do
patch "/api/v1/accounts/#{account.id}/leave_records/#{leave_record.id}",
params: update_params,
headers: agent.create_new_auth_token
expect(response).to have_http_status(:success)
expect(json_response['reason']).to eq('Updated reason')
end
it 'prevents updating approved leave records' do
leave_record.update!(status: :approved)
patch "/api/v1/accounts/#{account.id}/leave_records/#{leave_record.id}",
params: update_params,
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
expect(json_response['error']).to eq(I18n.t('errors.leave_records.cannot_update_non_pending'))
end
end
describe 'DELETE /api/v1/accounts/:account_id/leave_records/:id' do
let!(:leave_record) { create(:leave_record, account: account, user: agent, status: :pending) }
it 'allows deleting cancellable leave records' do
expect do
delete "/api/v1/accounts/#{account.id}/leave_records/#{leave_record.id}",
headers: agent.create_new_auth_token
end.to change(LeaveRecord, :count).by(-1)
expect(response).to have_http_status(:ok)
end
it 'prevents deleting non-cancellable leave records' do
leave_record.update!(status: :approved, start_date: Date.current, end_date: Date.current + 2.days)
delete "/api/v1/accounts/#{account.id}/leave_records/#{leave_record.id}",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
expect(json_response['error']).to eq(I18n.t('errors.leave_records.cannot_delete'))
end
end
describe 'PATCH /api/v1/accounts/:account_id/leave_records/:id/approve' do
let(:leave_record) { create(:leave_record, account: account, user: agent, status: :pending) }
it 'allows admins to approve leave records' do
patch "/api/v1/accounts/#{account.id}/leave_records/#{leave_record.id}/approve",
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(json_response['status']).to eq('approved')
expect(json_response['approved_by']['id']).to eq(admin.id)
end
it 'denies agents from approving leave records' do
patch "/api/v1/accounts/#{account.id}/leave_records/#{leave_record.id}/approve",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unauthorized)
end
end
describe 'PATCH /api/v1/accounts/:account_id/leave_records/:id/reject' do
let(:leave_record) { create(:leave_record, account: account, user: agent, status: :pending) }
it 'allows admins to reject leave records' do
patch "/api/v1/accounts/#{account.id}/leave_records/#{leave_record.id}/reject",
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(json_response['status']).to eq('rejected')
expect(json_response['approved_by']['id']).to eq(admin.id)
end
end
private
def json_response
JSON.parse(response.body)
end
end
+130
View File
@@ -0,0 +1,130 @@
require 'rails_helper'
RSpec.describe LeaveRecord, type: :model do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:admin) { create(:user, account: account, role: :administrator) }
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
subject { build(:leave_record, account: account, user: user) }
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) }
it 'validates end_date is after start_date' do
leave = build(:leave_record, account: account, user: user,
start_date: Date.current + 2.days,
end_date: Date.current)
expect(leave).not_to be_valid
expect(leave.errors[:end_date]).to include('must be after start date')
end
it 'validates future dates for pending leaves' do
leave = build(:leave_record, account: account, user: user,
status: :pending,
start_date: Date.current)
expect(leave).not_to be_valid
expect(leave.errors[:start_date]).to include('must be in the future')
end
it 'validates approved_by is admin when present' do
agent = create(:user, account: account, role: :agent)
leave = build(:leave_record, account: account, user: user,
approved_by: agent,
status: :approved)
expect(leave).not_to be_valid
expect(leave.errors[:approved_by]).to include('must be an administrator')
end
end
describe '#duration_in_days' do
it 'calculates correct duration' do
leave = build(:leave_record,
start_date: Date.current + 1.day,
end_date: Date.current + 5.days)
expect(leave.duration_in_days).to eq(5)
end
end
describe '#can_be_cancelled?' do
it 'returns true for pending leaves' do
leave = build(:leave_record, status: :pending)
expect(leave.can_be_cancelled?).to be true
end
it 'returns true for approved future leaves' do
leave = build(:leave_record, status: :approved,
start_date: Date.current + 1.day)
expect(leave.can_be_cancelled?).to be true
end
it 'returns false for approved ongoing leaves' do
leave = build(:leave_record, status: :approved,
start_date: Date.current)
expect(leave.can_be_cancelled?).to be false
end
it 'returns false for rejected leaves' do
leave = build(:leave_record, status: :rejected)
expect(leave.can_be_cancelled?).to be false
end
end
describe '#approve!' do
let(:leave) { create(:leave_record, account: account, user: user, status: :pending) }
it 'approves the leave and sets approved_by' do
freeze_time do
leave.approve!(admin)
expect(leave.status).to eq('approved')
expect(leave.approved_by).to eq(admin)
expect(leave.approved_at).to eq(Time.current)
end
end
end
describe '#reject!' do
let(:leave) { create(:leave_record, account: account, user: user, status: :pending) }
it 'rejects the leave and sets approved_by' do
freeze_time do
leave.reject!(admin)
expect(leave.status).to eq('rejected')
expect(leave.approved_by).to eq(admin)
expect(leave.approved_at).to eq(Time.current)
end
end
end
describe '#overlaps_with?' do
let(:leave1) do
build(:leave_record,
start_date: Date.current + 5.days,
end_date: Date.current + 10.days)
end
it 'detects overlapping leaves' do
leave2 = build(:leave_record,
start_date: Date.current + 7.days,
end_date: Date.current + 12.days)
expect(leave1.overlaps_with?(leave2)).to be true
end
it 'returns false for non-overlapping leaves' do
leave2 = build(:leave_record,
start_date: Date.current + 11.days,
end_date: Date.current + 15.days)
expect(leave1.overlaps_with?(leave2)).to be false
end
end
end
@@ -0,0 +1,75 @@
require 'rails_helper'
RSpec.describe LeaveRecordPolicy, type: :policy do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:leave_record) { create(:leave_record, account: account, user: agent) }
describe 'permissions' do
context 'when user is administrator' do
let(:policy) { described_class.new(pundit_context(admin), leave_record) }
it 'permits all actions' do
expect(policy.index?).to be true
expect(policy.show?).to be true
expect(policy.create?).to be true
expect(policy.update?).to be true
expect(policy.destroy?).to be true
expect(policy.approve?).to be true
expect(policy.reject?).to be true
end
end
context 'when user is agent' do
let(:policy) { described_class.new(pundit_context(agent), leave_record) }
it 'permits basic actions' do
expect(policy.index?).to be true
expect(policy.show?).to be true
expect(policy.create?).to be true
expect(policy.update?).to be true
expect(policy.destroy?).to be true
end
it 'denies approval actions' do
expect(policy.approve?).to be false
expect(policy.reject?).to be false
end
end
end
describe 'scope' do
let!(:agent_leave) { create(:leave_record, account: account, user: agent) }
let!(:other_agent) { create(:user, account: account, role: :agent) }
let!(:other_leave) { create(:leave_record, account: account, user: other_agent) }
it 'returns all leaves for administrators' do
scope = described_class::Scope.new(pundit_context(admin), LeaveRecord).resolve
expect(scope).to include(agent_leave, other_leave)
expect(scope.count).to eq(2)
end
it 'returns only own leaves for agents' do
scope = described_class::Scope.new(pundit_context(agent), LeaveRecord).resolve
expect(scope).to include(agent_leave)
expect(scope).not_to include(other_leave)
expect(scope.count).to eq(1)
end
it 'includes associations for performance' do
scope = described_class::Scope.new(pundit_context(admin), LeaveRecord).resolve
expect(scope.includes_values).to include(:user, :approved_by)
end
end
private
def pundit_context(user)
{
user: user,
account: account,
account_user: user.account_users.find_by(account: account)
}
end
end
+44
View File
@@ -0,0 +1,44 @@
FactoryBot.define do
factory :leave_record do
account
user
start_date { 1.week.from_now.to_date }
end_date { 2.weeks.from_now.to_date }
leave_type { :annual }
status { :pending }
reason { 'Annual vacation leave' }
trait :sick do
leave_type { :sick }
reason { 'Sick leave for medical treatment' }
end
trait :emergency do
leave_type { :emergency }
reason { 'Emergency family matter' }
end
trait :other do
leave_type { :other }
reason { 'Other type of leave' }
end
trait :approved do
status { :approved }
approved_by { association(:user) }
approved_at { 1.day.ago }
end
trait :rejected do
status { :rejected }
approved_by { association(:user) }
approved_at { 1.day.ago }
end
trait :past_dates do
start_date { 2.weeks.ago.to_date }
end_date { 1.week.ago.to_date }
status { :approved } # Past dates should only be used with approved status
end
end
end