feat(leave-requests): implementation of vacation, sick and holiday leave-requests.

This commit is contained in:
Matthieu Haineault 2025-10-06 12:15:51 -04:00
parent d8bc05f6e2
commit 79153c6de3
14 changed files with 688 additions and 466 deletions

View File

@ -1232,16 +1232,16 @@
]
}
},
"/leave-requests/holiday": {
"/leave-requests/upsert": {
"post": {
"operationId": "LeaveRequestController_upsertHoliday",
"operationId": "LeaveRequestController_upsertLeaveRequest",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpsertHolidayDto"
"$ref": "#/components/schemas/UpsertLeaveRequestDto"
}
}
}
@ -2474,7 +2474,7 @@
}
}
},
"UpsertHolidayDto": {
"UpsertLeaveRequestDto": {
"type": "object",
"properties": {}
},

View File

@ -1,6 +1,6 @@
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../../../prisma/prisma.service";
import { computeHours, getWeekStart } from "src/common/utils/date-utils";
import { PrismaService } from "../../../prisma/prisma.service";
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;

View File

@ -1,6 +1,6 @@
import { getYearStart, roundToQuarterHour } from "src/common/utils/date-utils";
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../../../prisma/prisma.service";
import { getYearStart, roundToQuarterHour } from "src/common/utils/date-utils";
@Injectable()
export class SickLeaveService {
@ -9,8 +9,17 @@ export class SickLeaveService {
private readonly logger = new Logger(SickLeaveService.name);
//switch employeeId for email
async calculateSickLeavePay(employee_id: number, reference_date: Date, days_requested: number, modifier: number):
Promise<number> {
async calculateSickLeavePay(
employee_id: number,
reference_date: Date,
days_requested: number,
hours_per_day: number,
modifier: number,
): Promise<number> {
if (days_requested <= 0 || hours_per_day <= 0 || modifier <= 0) {
return 0;
}
//sets the year to jan 1st to dec 31st
const period_start = getYearStart(reference_date);
const period_end = reference_date;
@ -19,18 +28,19 @@ export class SickLeaveService {
const shifts = await this.prisma.shifts.findMany({
where: {
timesheet: { employee_id: employee_id },
date: { gte: period_start, lte: period_end},
date: { gte: period_start, lte: period_end },
},
select: { date: true },
});
//count the amount of worked days
const worked_dates = new Set(
shifts.map(shift => shift.date.toISOString().slice(0,10))
shifts.map((shift) => shift.date.toISOString().slice(0, 10)),
);
const days_worked = worked_dates.size;
this.logger.debug(`Sick leave: days worked= ${days_worked} in ${period_start.toDateString()}
-> ${period_end.toDateString()}`);
this.logger.debug(
`Sick leave: days worked= ${days_worked} in ${period_start.toDateString()} -> ${period_end.toDateString()}`,
);
//less than 30 worked days returns 0
if (days_worked < 30) {
@ -45,22 +55,31 @@ export class SickLeaveService {
const threshold_date = new Date(ordered_dates[29]); // index 29 is the 30th day
//calculate each completed month, starting the 1st of the next month
const first_bonus_date = new Date(threshold_date.getFullYear(), threshold_date.getMonth() +1, 1);
let months = (period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 +
(period_end.getMonth() - first_bonus_date.getMonth()) + 1;
if(months < 0) months = 0;
const first_bonus_date = new Date(
threshold_date.getFullYear(),
threshold_date.getMonth() + 1,
1,
);
let months =
(period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 +
(period_end.getMonth() - first_bonus_date.getMonth()) +
1;
if (months < 0) months = 0;
acquired_days += months;
//cap of 10 days
if (acquired_days > 10) acquired_days = 10;
this.logger.debug(`Sick leave: threshold Date = ${threshold_date.toDateString()}
, bonusMonths = ${months}, acquired Days = ${acquired_days}`);
this.logger.debug(
`Sick leave: threshold Date = ${threshold_date.toDateString()}, bonusMonths = ${months}, acquired Days = ${acquired_days}`,
);
const payable_days = Math.min(acquired_days, days_requested);
const raw_hours = payable_days * 8 * modifier;
const rounded = roundToQuarterHour(raw_hours)
this.logger.debug(`Sick leave pay: days= ${payable_days}, modifier= ${modifier}, hours= ${rounded}`);
const raw_hours = payable_days * hours_per_day * modifier;
const rounded = roundToQuarterHour(raw_hours);
this.logger.debug(
`Sick leave pay: days= ${payable_days}, hoursPerDay= ${hours_per_day}, modifier= ${modifier}, hours= ${rounded}`,
);
return rounded;
}
}

View File

@ -6,15 +6,7 @@ export class VacationService {
constructor(private readonly prisma: PrismaService) {}
private readonly logger = new Logger(VacationService.name);
/**
* Calculate the ammount allowed for vacation days.
*
* @param employee_id employee ID
* @param startDate first day of vacation
* @param daysRequested number of days requested
* @param modifier Coefficient of hours(1)
* @returns amount of payable hours
*/
//switch employeeId for email
async calculateVacationPay(employee_id: number, start_date: Date, days_requested: number, modifier: number): Promise<number> {
//fetch hiring date

View File

@ -1,21 +1,21 @@
import { Body, Controller, Post } from "@nestjs/common";
import { HolidayLeaveRequestsService } from "../services/holiday-leave-requests.service";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { UpsertHolidayDto } from "../dtos/upsert-holiday.dto";
import { LeaveRequestsService } from "../services/leave-request.service";
import { UpsertLeaveRequestDto } from "../dtos/upsert-leave-request.dto";
import { LeaveTypes } from "@prisma/client";
@ApiTags('Leave Requests')
@ApiBearerAuth('access-token')
// @UseGuards()
@Controller('leave-requests')
export class LeaveRequestController {
constructor(private readonly leave_service: HolidayLeaveRequestsService){}
constructor(private readonly leave_service: LeaveRequestsService){}
@Post('holiday')
async upsertHoliday(@Body() dto: UpsertHolidayDto) {
const { action, leave_requests } = await this.leave_service.handleHoliday(dto);
@Post('upsert')
async upsertLeaveRequest(@Body() dto: UpsertLeaveRequestDto) {
const { action, leave_requests } = await this.leave_service.handle(dto);
return { action, leave_requests };
}
}q
//TODO:
/*

View File

@ -1,52 +0,0 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
ArrayNotEmpty,
ArrayUnique,
IsArray,
IsEmail,
IsISO8601,
IsNumber,
IsOptional,
IsString,
Max,
Min,
} from "class-validator";
export class UpsertSickDto {
@ApiProperty({ example: "jane.doe@example.com" })
@IsEmail()
email!: string;
@ApiProperty({
type: [String],
example: ["2025-03-04"],
description: "ISO dates that represent the sick leave request.",
})
@IsArray()
@ArrayNotEmpty()
@ArrayUnique()
@IsISO8601({}, { each: true })
dates!: string[];
@ApiProperty({
required: false,
example: "Medical note provided",
description: "Optional comment applied to every date.",
})
@IsOptional()
@IsString()
comment?: string;
@ApiProperty({
required: false,
example: 8,
description: "Hours requested per day. Lets you keep the user input even if the calculation differs.",
})
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
@Max(24)
requested_hours?: number;
}

View File

@ -1,48 +0,0 @@
import { LeaveApprovalStatus } from "@prisma/client";
import { Type } from "class-transformer";
import {
ArrayNotEmpty,
ArrayUnique,
IsArray,
IsEmail,
IsEnum,
IsIn,
IsISO8601,
IsNumber,
IsOptional,
IsString,
Max,
Min,
} from "class-validator";
export const HOLIDAY_UPSERT_ACTIONS = ['create', 'update', 'delete'] as const;
export type HolidayUpsertAction = typeof HOLIDAY_UPSERT_ACTIONS[number];
export class UpsertHolidayDto {
@IsEmail()
email!: string;
@IsArray()
@ArrayNotEmpty()
@ArrayUnique()
@IsISO8601({}, { each: true })
dates!: string[];
@IsIn(HOLIDAY_UPSERT_ACTIONS)
action!: HolidayUpsertAction;
@IsOptional()
@IsString()
comment?: string;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
@Max(24)
requested_hours?: number;
@IsOptional()
@IsEnum(LeaveApprovalStatus)
approval_status?: LeaveApprovalStatus;
}

View File

@ -0,0 +1,51 @@
import { IsEmail, IsArray, ArrayNotEmpty, ArrayUnique, IsISO8601, IsIn, IsOptional, IsString, IsNumber, Min, Max, IsEnum } from "class-validator";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { LeaveRequestViewDto } from "./leave-request-view.dto";
import { Type } from "class-transformer";
//sets wich function to call
export const UPSERT_ACTIONS = ['create', 'update', 'delete'] as const;
export type UpsertAction = (typeof UPSERT_ACTIONS)[number];
//sets wich types to use
export const REQUEST_TYPES = Object.values(LeaveTypes) as readonly LeaveTypes[];
export type RequestTypes = (typeof REQUEST_TYPES)[number];
//filter requests by type and action
export interface UpsertResult {
action: UpsertAction;
leave_requests: LeaveRequestViewDto[];
}
export class UpsertLeaveRequestDto {
@IsEmail()
email!: string;
@IsArray()
@ArrayNotEmpty()
@ArrayUnique()
@IsISO8601({}, { each: true })
dates!: string[];
@IsOptional()
@IsEnum(LeaveTypes)
type!: string;
@IsIn(UPSERT_ACTIONS)
action!: UpsertAction;
@IsOptional()
@IsString()
comment?: string;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
@Max(24)
requested_hours?: number;
@IsOptional()
@IsEnum(LeaveApprovalStatus)
approval_status?: LeaveApprovalStatus
}

View File

@ -1,52 +0,0 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
ArrayNotEmpty,
ArrayUnique,
IsArray,
IsEmail,
IsISO8601,
IsNumber,
IsOptional,
IsString,
Max,
Min,
} from "class-validator";
export class UpsertVacationDto {
@ApiProperty({ example: "jane.doe@example.com" })
@IsEmail()
email!: string;
@ApiProperty({
type: [String],
example: ["2025-07-14", "2025-07-15"],
description: "ISO dates that represent the vacation request.",
})
@IsArray()
@ArrayNotEmpty()
@ArrayUnique()
@IsISO8601({}, { each: true })
dates!: string[];
@ApiProperty({
required: false,
example: "Summer break",
description: "Optional comment applied to every date.",
})
@IsOptional()
@IsString()
comment?: string;
@ApiProperty({
required: false,
example: 8,
description: "Hours requested per day. Used as default when creating shifts.",
})
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
@Max(24)
requested_hours?: number;
}

View File

@ -1,21 +1,26 @@
import { PrismaService } from "src/prisma/prisma.service";
import { HolidayService } from "../business-logics/services/holiday.service";
import { LeaveRequestController } from "./controllers/leave-requests.controller";
import { HolidayLeaveRequestsService } from "./services/holiday-leave-requests.service";
import { Module } from "@nestjs/common";
import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
import { ShiftsCommandService } from "../shifts/services/shifts-command.service";
import { VacationLeaveRequestsService } from "./services/vacation-leave-requests.service";
import { SickLeaveRequestsService } from "./services/sick-leave-requests.service";
import { LeaveRequestsService } from "./services/leave-request.service";
import { ShiftsModule } from "../shifts/shifts.module";
@Module({
imports: [BusinessLogicsModule],
imports: [BusinessLogicsModule, ShiftsModule],
controllers: [LeaveRequestController],
providers: [
HolidayService,
VacationLeaveRequestsService,
SickLeaveRequestsService,
HolidayLeaveRequestsService,
PrismaService,
ShiftsCommandService,
LeaveRequestsService,
PrismaService
],
exports: [
LeaveRequestsService,
],
exports: [HolidayLeaveRequestsService],
})
export class LeaveRequestsModule {}

View File

@ -1,69 +1,52 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client';
import { HolidayUpsertAction, UpsertHolidayDto } from '../dtos/upsert-holiday.dto';
import { HolidayService } from 'src/modules/business-logics/services/holiday.service';
import { ShiftsCommandService } from 'src/modules/shifts/services/shifts-command.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { LeaveRequestsService, normalizeDates, toDateOnly } from './leave-request.service';
import { UpsertLeaveRequestDto, UpsertResult } from '../dtos/upsert-leave-request.dto';
import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto';
import { BadRequestException, Inject, Injectable, forwardRef } from '@nestjs/common';
import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client';
import { HolidayService } from 'src/modules/business-logics/services/holiday.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { mapRowToView } from '../mappers/leave-requests.mapper';
import { leaveRequestsSelect } from '../utils/leave-requests.select';
interface HolidayUpsertResult {
action: HolidayUpsertAction;
leave_requests: LeaveRequestViewDto[];
}
@Injectable()
export class HolidayLeaveRequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly holidayService: HolidayService,
private readonly shiftsCommand: ShiftsCommandService,
@Inject(forwardRef(() => LeaveRequestsService)) private readonly leaveService: LeaveRequestsService,
private readonly prisma: PrismaService,
) {}
// ---------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------
async handleHoliday(dto: UpsertHolidayDto): Promise<HolidayUpsertResult> {
//handle distribution to the right service according to the selected action
async handle(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
switch (dto.action) {
case 'create':
return this.createHoliday(dto);
case 'update':
return this.updateHoliday(dto);
return this.leaveService.update(dto, LeaveTypes.HOLIDAY);
case 'delete':
return this.deleteHoliday(dto);
return this.leaveService.delete(dto, LeaveTypes.HOLIDAY);
default:
throw new BadRequestException(`Unknown action: ${dto.action}`);
}
}
// ---------------------------------------------------------------------
// Create
// ---------------------------------------------------------------------
private async createHoliday(dto: UpsertHolidayDto): Promise<HolidayUpsertResult> {
private async createHoliday(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
const email = dto.email.trim();
const employeeId = await this.resolveEmployeeIdByEmail(email);
const bankCode = await this.resolveHolidayBankCode();
const employee_id = await this.leaveService.resolveEmployeeIdByEmail(email);
const bank_code = await this.leaveService.resolveBankCodeByType(LeaveTypes.HOLIDAY);
const dates = normalizeDates(dto.dates);
if (!dates.length) {
throw new BadRequestException('Dates array must not be empty');
}
if (!dates.length) throw new BadRequestException('Dates array must not be empty');
const created: LeaveRequestViewDto[] = [];
for (const isoDate of dates) {
const date = toDateOnly(isoDate);
for (const iso_date of dates) {
const date = toDateOnly(iso_date);
const existing = await this.prisma.leaveRequests.findUnique({
where: {
leave_per_employee_date: {
employee_id: employeeId,
employee_id: employee_id,
leave_type: LeaveTypes.HOLIDAY,
date,
},
@ -71,14 +54,14 @@ export class HolidayLeaveRequestsService {
select: { id: true },
});
if (existing) {
throw new BadRequestException(`Holiday request already exists for ${isoDate}`);
throw new BadRequestException(`Holiday request already exists for ${iso_date}`);
}
const payable = await this.holidayService.calculateHolidayPay(email, date, bankCode.modifier);
const payable = await this.holidayService.calculateHolidayPay(email, date, bank_code.modifier);
const row = await this.prisma.leaveRequests.create({
data: {
employee_id: employeeId,
bank_code_id: bankCode.id,
employee_id: employee_id,
bank_code_id: bank_code.id,
leave_type: LeaveTypes.HOLIDAY,
date,
comment: dto.comment ?? '',
@ -91,7 +74,7 @@ export class HolidayLeaveRequestsService {
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
await this.syncHolidayShift(email, employeeId, isoDate, hours, row.comment);
await this.leaveService.syncShift(email, employee_id, iso_date, hours,LeaveTypes.HOLIDAY, row.comment);
}
created.push({ ...mapRowToView(row), action: 'create' });
@ -99,223 +82,5 @@ export class HolidayLeaveRequestsService {
return { action: 'create', leave_requests: created };
}
// ---------------------------------------------------------------------
// Update
// ---------------------------------------------------------------------
private async updateHoliday(dto: UpsertHolidayDto): Promise<HolidayUpsertResult> {
const email = dto.email.trim();
const employeeId = await this.resolveEmployeeIdByEmail(email);
const bankCode = await this.resolveHolidayBankCode();
const dates = normalizeDates(dto.dates);
if (!dates.length) {
throw new BadRequestException('Dates array must not be empty');
}
const updated: LeaveRequestViewDto[] = [];
for (const isoDate of dates) {
const date = toDateOnly(isoDate);
const existing = await this.prisma.leaveRequests.findUnique({
where: {
leave_per_employee_date: {
employee_id: employeeId,
leave_type: LeaveTypes.HOLIDAY,
date,
},
},
select: leaveRequestsSelect,
});
if (!existing) {
throw new NotFoundException(`No HOLIDAY request found for ${isoDate}`);
}
const payable = await this.holidayService.calculateHolidayPay(email, date, bankCode.modifier);
const previousStatus = existing.approval_status;
const row = await this.prisma.leaveRequests.update({
where: { id: existing.id },
data: {
comment: dto.comment ?? existing.comment,
requested_hours: dto.requested_hours ?? existing.requested_hours ?? 8,
payable_hours: payable,
bank_code_id: bankCode.id,
approval_status: dto.approval_status ?? existing.approval_status,
},
select: leaveRequestsSelect,
});
const wasApproved = previousStatus === LeaveApprovalStatus.APPROVED;
const isApproved = row.approval_status === LeaveApprovalStatus.APPROVED;
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (!wasApproved && isApproved) {
await this.syncHolidayShift(email, employeeId, isoDate, hours, row.comment);
} else if (wasApproved && !isApproved) {
await this.removeHolidayShift(email, employeeId, isoDate);
} else if (wasApproved && isApproved) {
await this.syncHolidayShift(email, employeeId, isoDate, hours, row.comment);
}
updated.push({ ...mapRowToView(row), action: 'update' });
}
return { action: 'update', leave_requests: updated };
}
// ---------------------------------------------------------------------
// Delete
// ---------------------------------------------------------------------
private async deleteHoliday(dto: UpsertHolidayDto): Promise<HolidayUpsertResult> {
const email = dto.email.trim();
const employeeId = await this.resolveEmployeeIdByEmail(email);
const dates = normalizeDates(dto.dates);
if (!dates.length) {
throw new BadRequestException('Dates array must not be empty');
}
const rows = await this.prisma.leaveRequests.findMany({
where: {
employee_id: employeeId,
leave_type: LeaveTypes.HOLIDAY,
date: { in: dates.map((d) => toDateOnly(d)) },
},
select: leaveRequestsSelect,
});
if (rows.length !== dates.length) {
const missing = dates.filter((isoDate) => !rows.some((row) => toISODateKey(row.date) === isoDate));
throw new NotFoundException(`No HOLIDAY request found for: ${missing.join(', ')}`);
}
for (const row of rows) {
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
const iso = toISODateKey(row.date);
await this.removeHolidayShift(email, employeeId, iso);
}
}
await this.prisma.leaveRequests.deleteMany({
where: { id: { in: rows.map((row) => row.id) } },
});
const deleted = rows.map((row) => ({ ...mapRowToView(row), action: 'delete' as const}));
return { action: 'delete', leave_requests: deleted };
}
// ---------------------------------------------------------------------
// Shift synchronisation
// ---------------------------------------------------------------------
private async syncHolidayShift(
email: string,
employeeId: number,
isoDate: string,
hours: number,
comment?: string,
) {
if (hours <= 0) return;
const durationMinutes = Math.round(hours * 60);
if (durationMinutes > 8 * 60) {
throw new BadRequestException('Holiday hours cannot exceed 8 hours.');
}
const startMinutes = 8 * 60;
const endMinutes = startMinutes + durationMinutes;
const toHHmm = (total: number) => `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
const existing = await this.prisma.shifts.findFirst({
where: {
date: new Date(isoDate),
bank_code: { type: 'HOLIDAY' },
timesheet: { employee_id: employeeId },
},
include: { bank_code: true },
});
await this.shiftsCommand.upsertShiftsByDate(email, isoDate, {
old_shift: existing
? {
start_time: existing.start_time.toISOString().slice(11, 16),
end_time: existing.end_time.toISOString().slice(11, 16),
type: existing.bank_code?.type ?? 'HOLIDAY',
is_remote: existing.is_remote,
comment: existing.comment ?? undefined,
}
: undefined,
new_shift: {
start_time: toHHmm(startMinutes),
end_time: toHHmm(endMinutes),
type: 'HOLIDAY',
is_remote: existing?.is_remote ?? false,
comment: comment ?? existing?.comment ?? '',
},
});
}
private async removeHolidayShift(email: string, employeeId: number, isoDate: string) {
const existing = await this.prisma.shifts.findFirst({
where: {
date: new Date(isoDate),
bank_code: { type: 'HOLIDAY' },
timesheet: { employee_id: employeeId },
},
include: { bank_code: true },
});
if (!existing) return;
await this.shiftsCommand.upsertShiftsByDate(email, isoDate, {
old_shift: {
start_time: existing.start_time.toISOString().slice(11, 16),
end_time: existing.end_time.toISOString().slice(11, 16),
type: existing.bank_code?.type ?? 'HOLIDAY',
is_remote: existing.is_remote,
comment: existing.comment ?? undefined,
},
});
}
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
private async resolveEmployeeIdByEmail(email: string): Promise<number> {
const employee = await this.prisma.employees.findFirst({
where: { user: { email } },
select: { id: true },
});
if (!employee) {
throw new NotFoundException(`Employee with email ${email} not found`);
}
return employee.id;
}
private async resolveHolidayBankCode() {
const bankCode = await this.prisma.bankCodes.findFirst({
where: { type: 'HOLIDAY' },
select: { id: true, bank_code: true, modifier: true },
});
if (!bankCode) {
throw new BadRequestException('Bank code type "HOLIDAY" not found');
}
return bankCode;
}
}
const toDateOnly = (iso: string): Date => {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException(`Invalid date: ${iso}`);
}
date.setHours(0, 0, 0, 0);
return date;
};
const toISODateKey = (date: Date): string => date.toISOString().slice(0, 10);
const normalizeDates = (dates: string[]): string[] =>
Array.from(new Set(dates.map((iso) => toISODateKey(toDateOnly(iso)))));

View File

@ -0,0 +1,339 @@
import { BadRequestException, Inject, Injectable, NotFoundException, forwardRef } from "@nestjs/common";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { roundToQuarterHour } from "src/common/utils/date-utils";
import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { mapRowToView } from "../mappers/leave-requests.mapper";
import { leaveRequestsSelect } from "../utils/leave-requests.select";
import { HolidayLeaveRequestsService } from "./holiday-leave-requests.service";
import { SickLeaveRequestsService } from "./sick-leave-requests.service";
import { VacationLeaveRequestsService } from "./vacation-leave-requests.service";
import { HolidayService } from "src/modules/business-logics/services/holiday.service";
import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
import { VacationService } from "src/modules/business-logics/services/vacation.service";
import { ShiftsCommandService } from "src/modules/shifts/services/shifts-command.service";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class LeaveRequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly holidayService: HolidayService,
private readonly sickLogic: SickLeaveService,
private readonly vacationLogic: VacationService,
@Inject(forwardRef(() => HolidayLeaveRequestsService)) private readonly holidayLeaveService: HolidayLeaveRequestsService,
@Inject(forwardRef(() => SickLeaveRequestsService)) private readonly sickLeaveService: SickLeaveRequestsService,
private readonly shiftsCommand: ShiftsCommandService,
@Inject(forwardRef(() => VacationLeaveRequestsService)) private readonly vacationLeaveService: VacationLeaveRequestsService,
) {}
async handle(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
switch (dto.type) {
case LeaveTypes.HOLIDAY:
return this.holidayLeaveService.handle(dto);
case LeaveTypes.VACATION:
return this.vacationLeaveService.handle(dto);
case LeaveTypes.SICK:
return this.sickLeaveService.handle(dto);
default:
throw new BadRequestException(`Unsupported leave type: ${dto.type}`);
}
}
async resolveEmployeeIdByEmail(email: string): Promise<number> {
const employee = await this.prisma.employees.findFirst({
where: { user: { email } },
select: { id: true },
});
if (!employee) {
throw new NotFoundException(`Employee with email ${email} not found`);
}
return employee.id;
}
async resolveBankCodeByType(type: LeaveTypes) {
const bankCode = await this.prisma.bankCodes.findFirst({
where: { type },
select: { id: true, bank_code: true, modifier: true },
});
if (!bankCode) {
throw new BadRequestException(`Bank code type "${type}" not found`);
}
return bankCode;
}
async syncShift(
email: string,
employee_id: number,
iso_date: string,
hours: number,
type: LeaveTypes,
comment?: string,
) {
if (hours <= 0) return;
const duration_minutes = Math.round(hours * 60);
if (duration_minutes > 8 * 60) {
throw new BadRequestException("Amount of hours cannot exceed 8 hours per day.");
}
const start_minutes = 8 * 60;
const end_minutes = start_minutes + duration_minutes;
const toHHmm = (total: number) =>
`${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`;
const existing = await this.prisma.shifts.findFirst({
where: {
date: new Date(iso_date),
bank_code: { type },
timesheet: { employee_id: employee_id },
},
include: { bank_code: true },
});
await this.shiftsCommand.upsertShiftsByDate(email, iso_date, {
old_shift: existing
? {
start_time: existing.start_time.toISOString().slice(11, 16),
end_time: existing.end_time.toISOString().slice(11, 16),
type: existing.bank_code?.type ?? type,
is_remote: existing.is_remote,
comment: existing.comment ?? undefined,
}
: undefined,
new_shift: {
start_time: toHHmm(start_minutes),
end_time: toHHmm(end_minutes),
is_remote: existing?.is_remote ?? false,
comment: comment ?? existing?.comment ?? "",
type: type,
},
});
}
async removeShift(
email: string,
employee_id: number,
iso_date: string,
type: LeaveTypes,
) {
const existing = await this.prisma.shifts.findFirst({
where: {
date: new Date(iso_date),
bank_code: { type },
timesheet: { employee_id: employee_id },
},
include: { bank_code: true },
});
if (!existing) return;
await this.shiftsCommand.upsertShiftsByDate(email, iso_date, {
old_shift: {
start_time: existing.start_time.toISOString().slice(11, 16),
end_time: existing.end_time.toISOString().slice(11, 16),
type: existing.bank_code?.type ?? type,
is_remote: existing.is_remote,
comment: existing.comment ?? undefined,
},
});
}
async delete(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> {
const email = dto.email.trim();
const employee_id = await this.resolveEmployeeIdByEmail(email);
const dates = normalizeDates(dto.dates);
if (!dates.length) throw new BadRequestException("Dates array must not be empty");
const rows = await this.prisma.leaveRequests.findMany({
where: {
employee_id: employee_id,
leave_type: type,
date: { in: dates.map((d) => toDateOnly(d)) },
},
select: leaveRequestsSelect,
});
if (rows.length !== dates.length) {
const missing = dates.filter((isoDate) => !rows.some((row) => toISODateKey(row.date) === isoDate));
throw new NotFoundException(`No Leave request found for: ${missing.join(", ")}`);
}
for (const row of rows) {
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
const iso = toISODateKey(row.date);
await this.removeShift(email, employee_id, iso, type);
}
}
await this.prisma.leaveRequests.deleteMany({
where: { id: { in: rows.map((row) => row.id) } },
});
const deleted = rows.map((row) => ({ ...mapRowToView(row), action: "delete" as const }));
return { action: "delete", leave_requests: deleted };
}
async update(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> {
const email = dto.email.trim();
const employee_id = await this.resolveEmployeeIdByEmail(email);
const bank_code = await this.resolveBankCodeByType(type);
const modifier = Number(bank_code.modifier ?? 1);
const dates = normalizeDates(dto.dates);
if (!dates.length) {
throw new BadRequestException("Dates array must not be empty");
}
const entries = await Promise.all(
dates.map(async (iso_date) => {
const date = toDateOnly(iso_date);
const existing = await this.prisma.leaveRequests.findUnique({
where: {
leave_per_employee_date: {
employee_id: employee_id,
leave_type: type,
date,
},
},
select: leaveRequestsSelect,
});
if (!existing) {
throw new NotFoundException(`No Leave request found for ${iso_date}`);
}
return { iso_date, date, existing };
}),
);
const updated: LeaveRequestViewDto[] = [];
if (type === LeaveTypes.SICK) {
const firstExisting = entries[0].existing;
const fallbackRequested =
firstExisting.requested_hours !== null && firstExisting.requested_hours !== undefined
? Number(firstExisting.requested_hours)
: 8;
const requested_hours_per_day = dto.requested_hours ?? fallbackRequested;
const reference_date = entries.reduce(
(latest, entry) => (entry.date > latest ? entry.date : latest),
entries[0].date,
);
const total_payable_hours = await this.sickLogic.calculateSickLeavePay(
employee_id,
reference_date,
entries.length,
requested_hours_per_day,
modifier,
);
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
for (const { iso_date, existing } of entries) {
const previous_status = existing.approval_status;
const payable = Math.min(remaining_payable_hours, daily_payable_cap);
const payable_rounded = roundToQuarterHour(Math.max(0, payable));
remaining_payable_hours = roundToQuarterHour(
Math.max(0, remaining_payable_hours - payable_rounded),
);
const row = await this.prisma.leaveRequests.update({
where: { id: existing.id },
data: {
comment: dto.comment ?? existing.comment,
requested_hours: requested_hours_per_day,
payable_hours: payable_rounded,
bank_code_id: bank_code.id,
approval_status: dto.approval_status ?? existing.approval_status,
},
select: leaveRequestsSelect,
});
const was_approved = previous_status === LeaveApprovalStatus.APPROVED;
const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED;
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (!was_approved && is_approved) {
await this.syncShift(email, employee_id, iso_date, hours, type, row.comment);
} else if (was_approved && !is_approved) {
await this.removeShift(email, employee_id, iso_date, type);
} else if (was_approved && is_approved) {
await this.syncShift(email, employee_id, iso_date, hours, type, row.comment);
}
updated.push({ ...mapRowToView(row), action: "update" });
}
return { action: "update", leave_requests: updated };
}
for (const { iso_date, date, existing } of entries) {
const previous_status = existing.approval_status;
const fallbackRequested =
existing.requested_hours !== null && existing.requested_hours !== undefined
? Number(existing.requested_hours)
: 8;
const requested_hours = dto.requested_hours ?? fallbackRequested;
let payable: number;
switch (type) {
case LeaveTypes.HOLIDAY:
payable = await this.holidayService.calculateHolidayPay(email, date, modifier);
break;
case LeaveTypes.VACATION: {
const days_requested = requested_hours / 8;
payable = await this.vacationLogic.calculateVacationPay(
employee_id,
date,
Math.max(0, days_requested),
modifier,
);
break;
}
default:
payable = existing.payable_hours !== null && existing.payable_hours !== undefined
? Number(existing.payable_hours)
: requested_hours;
}
const row = await this.prisma.leaveRequests.update({
where: { id: existing.id },
data: {
comment: dto.comment ?? existing.comment,
requested_hours,
payable_hours: payable,
bank_code_id: bank_code.id,
approval_status: dto.approval_status ?? existing.approval_status,
},
select: leaveRequestsSelect,
});
const was_approved = previous_status === LeaveApprovalStatus.APPROVED;
const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED;
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (!was_approved && is_approved) {
await this.syncShift(email, employee_id, iso_date, hours, type, row.comment);
} else if (was_approved && !is_approved) {
await this.removeShift(email, employee_id, iso_date, type);
} else if (was_approved && is_approved) {
await this.syncShift(email, employee_id, iso_date, hours, type, row.comment);
}
updated.push({ ...mapRowToView(row), action: "update" });
}
return { action: "update", leave_requests: updated };
}
}
export const toDateOnly = (iso: string): Date => {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException(`Invalid date: ${iso}`);
}
date.setHours(0, 0, 0, 0);
return date;
};
export const toISODateKey = (date: Date): string => date.toISOString().slice(0, 10);
export const normalizeDates = (dates: string[]): string[] =>
Array.from(new Set(dates.map((iso) => toISODateKey(toDateOnly(iso)))));

View File

@ -0,0 +1,105 @@
import { LeaveRequestsService, normalizeDates, toDateOnly } from "./leave-request.service";
import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { BadRequestException, Inject, Injectable, forwardRef } from "@nestjs/common";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { leaveRequestsSelect } from "../utils/leave-requests.select";
import { mapRowToView } from "../mappers/leave-requests.mapper";
import { PrismaService } from "src/prisma/prisma.service";
import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
import { roundToQuarterHour } from "src/common/utils/date-utils";
@Injectable()
export class SickLeaveRequestsService {
constructor(
private readonly prisma: PrismaService,
@Inject(forwardRef(() => LeaveRequestsService)) private readonly leaveService: LeaveRequestsService,
private readonly sickService: SickLeaveService,
) {}
//handle distribution to the right service according to the selected action
async handle(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
switch (dto.action) {
case "create":
return this.createSick(dto);
case "update":
return this.leaveService.update(dto, LeaveTypes.SICK);
case "delete":
return this.leaveService.delete(dto, LeaveTypes.SICK);
default:
throw new BadRequestException(`Unknown action: ${dto.action}`);
}
}
private async createSick(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
const email = dto.email.trim();
const employee_id = await this.leaveService.resolveEmployeeIdByEmail(email);
const bank_code = await this.leaveService.resolveBankCodeByType(LeaveTypes.SICK);
const modifier = bank_code.modifier ?? 1;
const dates = normalizeDates(dto.dates);
if (!dates.length) throw new BadRequestException("Dates array must not be empty");
const requested_hours_per_day = dto.requested_hours ?? 8;
const entries = dates.map((iso) => ({ iso, date: toDateOnly(iso) }));
const reference_date = entries.reduce(
(latest, entry) => (entry.date > latest ? entry.date : latest),
entries[0].date,
);
const total_payable_hours = await this.sickService.calculateSickLeavePay(
employee_id,
reference_date,
entries.length,
requested_hours_per_day,
modifier,
);
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
const created: LeaveRequestViewDto[] = [];
for (const { iso, date } of entries) {
const existing = await this.prisma.leaveRequests.findUnique({
where: {
leave_per_employee_date: {
employee_id: employee_id,
leave_type: LeaveTypes.SICK,
date,
},
},
select: { id: true },
});
if (existing) {
throw new BadRequestException(`Sick request already exists for ${iso}`);
}
const payable = Math.min(remaining_payable_hours, daily_payable_cap);
const payable_rounded = roundToQuarterHour(Math.max(0, payable));
remaining_payable_hours = roundToQuarterHour(
Math.max(0, remaining_payable_hours - payable_rounded),
);
const row = await this.prisma.leaveRequests.create({
data: {
employee_id: employee_id,
bank_code_id: bank_code.id,
leave_type: LeaveTypes.SICK,
comment: dto.comment ?? "",
requested_hours: requested_hours_per_day,
payable_hours: payable_rounded,
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
date,
},
select: leaveRequestsSelect,
});
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
await this.leaveService.syncShift(email, employee_id, iso, hours,LeaveTypes.SICK, row.comment);
}
created.push({ ...mapRowToView(row), action: "create" });
}
return { action: "create", leave_requests: created };
}
}

View File

@ -0,0 +1,98 @@
import { LeaveRequestsService, normalizeDates, toDateOnly } from "./leave-request.service";
import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { BadRequestException, Inject, Injectable, forwardRef } from "@nestjs/common";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { VacationService } from "src/modules/business-logics/services/vacation.service";
import { PrismaService } from "src/prisma/prisma.service";
import { mapRowToView } from "../mappers/leave-requests.mapper";
import { leaveRequestsSelect } from "../utils/leave-requests.select";
import { roundToQuarterHour } from "src/common/utils/date-utils";
@Injectable()
export class VacationLeaveRequestsService {
constructor(
private readonly prisma: PrismaService,
@Inject(forwardRef(() => LeaveRequestsService)) private readonly leaveService: LeaveRequestsService,
private readonly vacationService: VacationService,
) {}
async handle(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
switch (dto.action) {
case "create":
return this.createVacation(dto);
case "update":
return this.leaveService.update(dto, LeaveTypes.VACATION);
case "delete":
return this.leaveService.delete(dto, LeaveTypes.VACATION);
default:
throw new BadRequestException(`Unknown action: ${dto.action}`);
}
}
private async createVacation(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
const email = dto.email.trim();
const employee_id = await this.leaveService.resolveEmployeeIdByEmail(email);
const bank_code = await this.leaveService.resolveBankCodeByType(LeaveTypes.VACATION);
const modifier = bank_code.modifier ?? 1;
const dates = normalizeDates(dto.dates);
const requested_hours_per_day = dto.requested_hours ?? 8;
if (!dates.length) throw new BadRequestException("Dates array must not be empty");
const entries = dates
.map((iso) => ({ iso, date: toDateOnly(iso) }))
.sort((a, b) => a.date.getTime() - b.date.getTime());
const start_date = entries[0].date;
const total_payable_hours = await this.vacationService.calculateVacationPay(
employee_id,
start_date,
entries.length,
modifier,
);
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
const created: LeaveRequestViewDto[] = [];
for (const { iso, date } of entries) {
const existing = await this.prisma.leaveRequests.findUnique({
where: {
leave_per_employee_date: {
employee_id: employee_id,
leave_type: LeaveTypes.VACATION,
date,
},
},
select: { id: true },
});
if (existing) throw new BadRequestException(`Vacation request already exists for ${iso}`);
const payable = Math.min(remaining_payable_hours, daily_payable_cap);
const payable_rounded = roundToQuarterHour(Math.max(0, payable));
remaining_payable_hours = roundToQuarterHour(
Math.max(0, remaining_payable_hours - payable_rounded),
);
const row = await this.prisma.leaveRequests.create({
data: {
employee_id: employee_id,
bank_code_id: bank_code.id,
payable_hours: payable_rounded,
requested_hours: requested_hours_per_day,
leave_type: LeaveTypes.VACATION,
comment: dto.comment ?? "",
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
date,
},
select: leaveRequestsSelect,
});
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
await this.leaveService.syncShift(email, employee_id, iso, hours, LeaveTypes.VACATION, row.comment);
}
created.push({ ...mapRowToView(row), action: "create" });
}
return { action: "create", leave_requests: created };
}
}