feat(leave-request): added holiday shift's creation and CRUD for holiday leave-requests.

This commit is contained in:
Matthieu Haineault 2025-10-03 09:37:42 -04:00
parent 77f065f37f
commit d36d2f922b
18 changed files with 461 additions and 379 deletions

78
package-lock.json generated
View File

@ -51,7 +51,7 @@
"globals": "^16.0.0",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"prisma": "^6.14.0",
"prisma": "^6.16.3",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
@ -3148,9 +3148,9 @@
}
},
"node_modules/@prisma/config": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.14.0.tgz",
"integrity": "sha512-IwC7o5KNNGhmblLs23swnfBjADkacBb7wvyDXUWLwuvUQciKJZqyecU0jw0d7JRkswrj+XTL8fdr0y2/VerKQQ==",
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.3.tgz",
"integrity": "sha512-VlsLnG4oOuKGGMToEeVaRhoTBZu5H3q51jTQXb/diRags3WV0+BQK5MolJTtP6G7COlzoXmWeS11rNBtvg+qFQ==",
"devOptional": true,
"dependencies": {
"c12": "3.1.0",
@ -3160,48 +3160,48 @@
}
},
"node_modules/@prisma/debug": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.14.0.tgz",
"integrity": "sha512-j4Lf+y+5QIJgQD4sJWSbkOD7geKx9CakaLp/TyTy/UDu9Wo0awvWCBH/BAxTHUaCpIl9USA5VS/KJhDqKJSwug==",
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.3.tgz",
"integrity": "sha512-89DdqWtdKd7qoc9/qJCKLTazj3W3zPEiz0hc7HfZdpjzm21c7orOUB5oHWJsG+4KbV4cWU5pefq3CuDVYF9vgA==",
"devOptional": true
},
"node_modules/@prisma/engines": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.14.0.tgz",
"integrity": "sha512-LhJjqsALFEcoAtF07nSaOkVguaxw/ZsgfROIYZ8bAZDobe7y8Wy+PkYQaPOK1iLSsFgV2MhCO/eNrI1gdSOj6w==",
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.3.tgz",
"integrity": "sha512-b+Rl4nzQDcoqe6RIpSHv8f5lLnwdDGvXhHjGDiokObguAAv/O1KaX1Oc69mBW/GFWKQpCkOraobLjU6s1h8HGg==",
"devOptional": true,
"hasInstallScript": true,
"dependencies": {
"@prisma/debug": "6.14.0",
"@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
"@prisma/fetch-engine": "6.14.0",
"@prisma/get-platform": "6.14.0"
"@prisma/debug": "6.16.3",
"@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a",
"@prisma/fetch-engine": "6.16.3",
"@prisma/get-platform": "6.16.3"
}
},
"node_modules/@prisma/engines-version": {
"version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49.tgz",
"integrity": "sha512-EgN9ODJpiX45yvwcngoStp3uQPJ3l+AEVoQ6dMMO2QvmwIlnxfApzKmJQExzdo7/hqQANrz5txHJdGYHzOnGHA==",
"version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a.tgz",
"integrity": "sha512-fftRmosBex48Ph1v2ll1FrPpirwtPZpNkE5CDCY1Lw2SD2ctyrLlVlHiuxDAAlALwWBOkPbAll4+EaqdGuMhJw==",
"devOptional": true
},
"node_modules/@prisma/fetch-engine": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.14.0.tgz",
"integrity": "sha512-MPzYPOKMENYOaY3AcAbaKrfvXVlvTc6iHmTXsp9RiwCX+bPyfDMqMFVUSVXPYrXnrvEzhGHfyiFy0PRLHPysNg==",
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.3.tgz",
"integrity": "sha512-bUoRIkVaI+CCaVGrSfcKev0/Mk4ateubqWqGZvQ9uCqFv2ENwWIR3OeNuGin96nZn5+SkebcD7RGgKr/+mJelw==",
"devOptional": true,
"dependencies": {
"@prisma/debug": "6.14.0",
"@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
"@prisma/get-platform": "6.14.0"
"@prisma/debug": "6.16.3",
"@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a",
"@prisma/get-platform": "6.16.3"
}
},
"node_modules/@prisma/get-platform": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.14.0.tgz",
"integrity": "sha512-7VjuxKNwjnBhKfqPpMeWiHEa2sVjYzmHdl1slW6STuUCe9QnOY0OY1ljGSvz6wpG4U8DfbDqkG1yofd/1GINww==",
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.3.tgz",
"integrity": "sha512-X1LxiFXinJ4iQehrodGp0f66Dv6cDL0GbRlcCoLtSu6f4Wi+hgo7eND/afIs5029GQLgNWKZ46vn8hjyXTsHLA==",
"devOptional": true,
"dependencies": {
"@prisma/debug": "6.14.0"
"@prisma/debug": "6.16.3"
}
},
"node_modules/@scarf/scarf": {
@ -9450,15 +9450,15 @@
}
},
"node_modules/nypm": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.1.tgz",
"integrity": "sha512-hlacBiRiv1k9hZFiphPUkfSQ/ZfQzZDzC+8z0wL3lvDAOUu/2NnChkKuMoMjNur/9OpKuz2QsIeiPVN0xM5Q0w==",
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
"integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==",
"devOptional": true,
"dependencies": {
"citty": "^0.1.6",
"consola": "^3.4.2",
"pathe": "^2.0.3",
"pkg-types": "^2.2.0",
"pkg-types": "^2.3.0",
"tinyexec": "^1.0.1"
},
"bin": {
@ -9967,9 +9967,9 @@
}
},
"node_modules/pkg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz",
"integrity": "sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
"devOptional": true,
"dependencies": {
"confbox": "^0.2.2",
@ -10049,14 +10049,14 @@
}
},
"node_modules/prisma": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.14.0.tgz",
"integrity": "sha512-QEuCwxu+Uq9BffFw7in8In+WfbSUN0ewnaSUKloLkbJd42w6EyFckux4M0f7VwwHlM3A8ssaz4OyniCXlsn0WA==",
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.3.tgz",
"integrity": "sha512-4tJq3KB9WRshH5+QmzOLV54YMkNlKOtLKaSdvraI5kC/axF47HuOw6zDM8xrxJ6s9o2WodY654On4XKkrobQdQ==",
"devOptional": true,
"hasInstallScript": true,
"dependencies": {
"@prisma/config": "6.14.0",
"@prisma/engines": "6.14.0"
"@prisma/config": "6.16.3",
"@prisma/engines": "6.16.3"
},
"bin": {
"prisma": "build/index.js"

View File

@ -82,7 +82,7 @@
"globals": "^16.0.0",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"prisma": "^6.14.0",
"prisma": "^6.16.3",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",

View File

@ -105,16 +105,19 @@ model LeaveRequests {
id Int @id @default(autoincrement())
employee Employees @relation("LeaveRequestEmployee", fields: [employee_id], references: [id])
employee_id Int
bank_code BankCodes? @relation("LeaveRequestBankCodes", fields: [bank_code_id], references: [id])
bank_code BankCodes @relation("LeaveRequestBankCodes", fields: [bank_code_id], references: [id])
bank_code_id Int
leave_type LeaveTypes
start_date_time DateTime @db.Date
end_date_time DateTime? @db.Date
date DateTime @db.Date
payable_hours Decimal? @db.Decimal(5,2)
requested_hours Decimal? @db.Decimal(5,2)
comment String
approval_status LeaveApprovalStatus @default(PENDING)
archive LeaveRequestsArchive[] @relation("LeaveRequestToArchive")
@@unique([employee_id, leave_type, date], name: "leave_per_employee_date")
@@index([employee_id, date])
@@map("leave_requests")
}
@ -125,11 +128,14 @@ model LeaveRequestsArchive {
archived_at DateTime @default(now())
employee_id Int
leave_type LeaveTypes
start_date_time DateTime @db.Date
end_date_time DateTime? @db.Date
date DateTime @db.Date
payable_hours Decimal? @db.Decimal(5,2)
requested_hours Decimal? @db.Decimal(5,2)
comment String
approval_status LeaveApprovalStatus
@@unique([leave_request_id])
@@index([employee_id, date])
@@map("leave_requests_archive")
}
@ -340,6 +346,7 @@ enum LeaveTypes {
PARENTAL // maternite/paternite/adoption
LEGAL // obligations legales comme devoir de juree
WEDDING // mariage
HOLIDAY // férier
@@map("leave_types")
}

View File

@ -2,6 +2,14 @@ import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../../../prisma/prisma.service";
import { computeHours, getWeekStart } from "src/common/utils/date-utils";
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
/*
le calcul est 1/20 des 4 dernières semaines, précédent la semaine incluant le férier.
Un maximum de 08h00 est allouable pour le férier
Un maximum de 40hrs par semaine est retenue pour faire le calcul.
*/
@Injectable()
export class HolidayService {
private readonly logger = new Logger(HolidayService.name);
@ -22,36 +30,49 @@ export class HolidayService {
private async computeHoursPrevious4WeeksByEmail(email: string, holiday_date: Date): Promise<number> {
const employee_id = await this.resolveEmployeeByEmail(email);
return this.computeHoursPrevious4Weeks(employee_id, holiday_date)
return this.computeHoursPrevious4Weeks(employee_id, holiday_date);
}
private async computeHoursPrevious4Weeks(employee_id: number, holiday_date: Date): Promise<number> {
//sets the end of the window to 1ms before the week with the holiday
const holiday_week_start = getWeekStart(holiday_date);
const window_start = new Date(holiday_week_start.getTime() - 4 * WEEK_IN_MS);
const window_end = new Date(holiday_week_start.getTime() - 1);
//sets the start of the window to 28 days ( 4 completed weeks ) before the week with the holiday
const window_start = new Date(window_end.getTime() - 28 * 24 * 60 * 60000 + 1 )
const valid_codes = ['G1', 'G43', 'G56', 'G104', 'G105', 'G700'];
//fetches all shift of the employee in said window ( 4 previous completed weeks )
const shifts = await this.prisma.shifts.findMany({
where: { timesheet: { employee_id: employee_id } ,
where: {
timesheet: { employee_id: employee_id },
date: { gte: window_start, lte: window_end },
bank_code: { bank_code: { in: valid_codes } },
},
select: { date: true, start_time: true, end_time: true },
});
const total_hours = shifts.map(s => computeHours(s.start_time, s.end_time)).reduce((sum, h)=> sum + h, 0);
const daily_hours = total_hours / 20;
const hours_by_week = new Map<number, number>();
for(const shift of shifts) {
const hours = computeHours(shift.start_time, shift.end_time);
if(hours <= 0) continue;
const shift_week_start = getWeekStart(shift.date);
const key = shift_week_start.getTime();
hours_by_week.set(key, (hours_by_week.get(key) ?? 0) + hours);
}
return daily_hours;
let capped_total = 0;
for(let offset = 1; offset <= 4; offset++) {
const week_start = new Date(holiday_week_start.getTime() - offset * WEEK_IN_MS);
const key = week_start.getTime();
const weekly_hours = hours_by_week.get(key) ?? 0;
capped_total += Math.min(weekly_hours, 40);
}
const average_daily_hours = capped_total / 20;
return average_daily_hours;
}
async calculateHolidayPay( email: string, holiday_date: Date, modifier: number): Promise<number> {
const hours = await this.computeHoursPrevious4WeeksByEmail(email, holiday_date);
const daily_rate = Math.min(hours, 8);
this.logger.debug(`Holiday pay calculation: hours= ${hours.toFixed(2)}`);
const average_daily_hours = await this.computeHoursPrevious4WeeksByEmail(email, holiday_date);
const daily_rate = Math.min(average_daily_hours, 8);
this.logger.debug(`Holiday pay calculation: cappedHoursPerDay= ${average_daily_hours.toFixed(2)}, appliedDailyRate= ${daily_rate.toFixed(2)}`);
return daily_rate * modifier;
}
}

View File

@ -1,76 +1,13 @@
import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
import { Controller } from "@nestjs/common";
import { LeaveRequestsService } from "../services/leave-requests.service";
import { CreateLeaveRequestsDto } from "../dtos/create-leave-request.dto";
import { LeaveRequests } from "@prisma/client";
import { UpdateLeaveRequestsDto } from "../dtos/update-leave-request.dto";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { LeaveApprovalStatus, Roles as RoleEnum } from '.prisma/client';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { SearchLeaveRequestsDto } from "../dtos/search-leave-request.dto";
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
@ApiTags('Leave Requests')
@ApiBearerAuth('access-token')
// @UseGuards()
@Controller('leave-requests')
export class LeaveRequestController {
constructor(private readonly leaveRequetsService: LeaveRequestsService){}
@Post()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({summary: 'Create leave request' })
@ApiResponse({ status: 201, description: 'Leave request created',type: CreateLeaveRequestsDto })
@ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
create(@Body() dto: CreateLeaveRequestsDto): Promise<LeaveRequestViewDto> {
return this. leaveRequetsService.create(dto);
}
@Get()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({summary: 'Find all leave request' })
@ApiResponse({ status: 201, description: 'List of Leave requests found',type: LeaveRequestViewDto, isArray: true })
@ApiResponse({ status: 400, description: 'List of leave request not found' })
@UsePipes(new ValidationPipe({transform: true, whitelist: true}))
findAll(@Query() filters: SearchLeaveRequestsDto): Promise<LeaveRequestViewDto[]> {
return this.leaveRequetsService.findAll(filters);
}
//remove emp_id and use email
@Get(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({summary: 'Find leave request' })
@ApiResponse({ status: 201, description: 'Leave request found',type: LeaveRequestViewDto })
@ApiResponse({ status: 400, description: 'Leave request not found' })
findOne(@Param('id', ParseIntPipe) id: number): Promise<LeaveRequestViewDto> {
return this.leaveRequetsService.findOne(id);
}
//remove emp_id and use email
@Patch(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({summary: 'Update leave request' })
@ApiResponse({ status: 201, description: 'Leave request updated',type: LeaveRequestViewDto })
@ApiResponse({ status: 400, description: 'Leave request not found' })
update(@Param('id', ParseIntPipe) id: number,@Body() dto: UpdateLeaveRequestsDto): Promise<LeaveRequestViewDto> {
return this.leaveRequetsService.update(id, dto);
}
//remove emp_id and use email
@Delete(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({summary: 'Delete leave request' })
@ApiResponse({ status: 201, description: 'Leave request deleted',type: CreateLeaveRequestsDto })
@ApiResponse({ status: 400, description: 'Leave request not found' })
remove(@Param('id', ParseIntPipe) id: number): Promise<LeaveRequestViewDto> {
return this.leaveRequetsService.remove(id);
}
//remove emp_id and use email
@Patch('approval/:id')
updateApproval( @Param('id', ParseIntPipe) id: number,
@Body('is_approved', ParseBoolPipe) is_approved: boolean): Promise<LeaveRequestViewDto> {
const approvalStatus = is_approved ?
LeaveApprovalStatus.APPROVED : LeaveApprovalStatus.DENIED;
return this.leaveRequetsService.update(id, { approval_status: approvalStatus });
}
constructor(private readonly leave_service: LeaveRequestsService){}
}

View File

@ -1,56 +0,0 @@
import { ApiProperty } from "@nestjs/swagger";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { Type } from "class-transformer";
import { IsDateString, IsEmail, IsEnum, IsInt, IsISO8601, IsNotEmpty, IsOptional, IsString } from "class-validator";
export class CreateLeaveRequestsDto {
@IsEmail()
email: string;
@ApiProperty({
example: 7,
description: 'ID number of a leave-request code (link with bank-codes)',
})
@Type(()=> Number)
@IsInt()
bank_code_id: number;
@ApiProperty({
example: 'Sick or Vacation or Unpaid or Bereavement or Parental or Legal',
description: 'type of leave request for an accounting perception',
})
@IsEnum(LeaveTypes)
leave_type: LeaveTypes;
@ApiProperty({
example: '22/06/2463',
description: 'Leave request`s start date',
})
@IsISO8601()
start_date_time:string;
@ApiProperty({
example: '25/03/3019',
description: 'Leave request`s end date',
})
@IsOptional()
@IsISO8601()
end_date_time?: string;
@ApiProperty({
example: 'My precious',
description: 'Leave request`s comment',
})
@IsString()
@IsNotEmpty()
comment: string;
@ApiProperty({
example: 'True or False or Pending or Denied or Cancelled or Escalated',
description: 'Leave request`s approval status',
})
@IsEnum(LeaveApprovalStatus)
@IsOptional()
approval_status?: LeaveApprovalStatus;
}

View File

@ -0,0 +1,14 @@
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
export class LeaveRequestViewDto {
id: number;
leave_type!: LeaveTypes;
date!: string;
comment!: string;
approval_status: LeaveApprovalStatus;
email!: string;
employee_full_name!: string;
payable_hours?: number;
requested_hours?: number;
action?: 'created' | 'updated' | 'deleted';
}

View File

@ -1,13 +0,0 @@
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
export class LeaveRequestViewDto {
id!: number;
leave_type!: LeaveTypes;
start_date_time!: string;
end_date_time!: string | null;
comment!: string | null;
approval_status: LeaveApprovalStatus;
email!: string;
employee_full_name: string;
days_requested?: number;
}

View File

@ -1,30 +0,0 @@
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { Type } from "class-transformer";
import { IsOptional, IsInt, IsEnum, IsDateString, IsEmail } from "class-validator";
export class SearchLeaveRequestsDto {
@IsEmail()
email: string;
@IsOptional()
@Type(()=> Number)
@IsInt()
bank_code_id?: number;
@IsOptional()
@IsEnum(LeaveApprovalStatus)
approval_status?: LeaveApprovalStatus
@IsOptional()
@IsDateString()
start_date?: string;
@IsOptional()
@IsDateString()
end_date?: string;
@IsOptional()
@IsEnum(LeaveTypes)
leave_type?: LeaveTypes;
}

View File

@ -0,0 +1,52 @@
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,4 +0,0 @@
import { PartialType } from "@nestjs/swagger";
import { CreateLeaveRequestsDto } from "./create-leave-request.dto";
export class UpdateLeaveRequestsDto extends PartialType(CreateLeaveRequestsDto){}

View File

@ -0,0 +1,42 @@
import { Type } from "class-transformer";
import {
ArrayNotEmpty,
ArrayUnique,
IsArray,
IsEmail,
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;
}

View File

@ -0,0 +1,52 @@
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,14 +1,20 @@
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto";
import { Prisma } from "@prisma/client";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { LeaveRequestArchiveRow } from "../utils/leave-requests-archive.select";
const toISO = (date: Date | null): string | null => (date ? date.toISOString().slice(0,10): null);
const toNum = (value?: Prisma.Decimal | null) => value ? Number(value) : undefined;
export function mapArchiveRowToView(row: LeaveRequestArchiveRow, email: string, employee_full_name:string): LeaveRequestViewDto {
const isoDate = row.date?.toISOString().slice(0, 10);
if (!isoDate) {
throw new Error(`Leave request #${row.id} has no date set.`);
}
return {
id: row.id,
leave_type: row.leave_type,
start_date_time: toISO(row.start_date_time)!,
end_date_time: toISO(row.end_date_time),
date: isoDate,
payable_hours: toNum(row.payable_hours),
requested_hours: toNum(row.requested_hours),
comment: row.comment,
approval_status: row.approval_status,
email,

View File

@ -1,19 +1,23 @@
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto";
import { Prisma } from "@prisma/client";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { LeaveRequestRow } from "../utils/leave-requests.select";
function toISODateString(date:Date | null): string | null {
return date ? date.toISOString().slice(0,10) : null;
}
const toNum = (value?: Prisma.Decimal | null) =>
value !== null && value !== undefined ? Number(value) : undefined;
export function mapRowToView(row: LeaveRequestRow): LeaveRequestViewDto {
const isoDate = row.date?.toISOString().slice(0, 10);
if (!isoDate) throw new Error(`Leave request #${row.id} has no date set.`);
return {
id: row.id,
leave_type: row.leave_type,
start_date_time: toISODateString(row.start_date_time)!,
end_date_time: toISODateString(row.end_date_time),
date: isoDate,
payable_hours: toNum(row.payable_hours),
requested_hours: toNum(row.requested_hours),
comment: row.comment,
approval_status: row.approval_status,
email: row.employee.user.email,
employee_full_name: `${row.employee.user.first_name} ${row.employee.user.last_name}`
}
employee_full_name: `${row.employee.user.first_name} ${row.employee.user.last_name}`,
};
}

View File

@ -1,175 +1,224 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { LeaveTypes, LeaveRequestsArchive } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
import { CreateLeaveRequestsDto } from "../dtos/create-leave-request.dto";
import { LeaveRequests, LeaveRequestsArchive } from "@prisma/client";
import { UpdateLeaveRequestsDto } from "../dtos/update-leave-request.dto";
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 { SearchLeaveRequestsDto } from "../dtos/search-leave-request.dto";
import { buildPrismaWhere } from "src/common/shared/build-prisma-where";
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto";
import { LeaveRequestRow, leaveRequestsSelect } from "../utils/leave-requests.select";
import { UpsertHolidayDto, HolidayUpsertAction } from "../dtos/upsert-holiday.dto";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { mapRowToView } from "../mappers/leave-requests.mapper";
import { LeaveRequestsArchiveController } from "src/modules/archival/controllers/leave-requests-archive.controller";
import { mapArchiveRowToViewWithDays } from "../utils/leave-request.transform";
import { LeaveRequestArchiveRow, leaveRequestsArchiveSelect } from "../utils/leave-requests-archive.select";
import { mapArchiveRowToView } from "../mappers/leave-requests-archive.mapper";
import { mapArchiveRowToViewWithDays, mapRowToViewWithDays } from "../utils/leave-request.transform";
import { leaveRequestsSelect } from "../utils/leave-requests.select";
@Injectable()
export class LeaveRequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly holidayService: HolidayService,
private readonly vacationService: VacationService,
private readonly sickLeaveService: SickLeaveService
) {}
//function to avoid using employee_id as identifier in the frontend.
//-------------------- 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`);
if (!employee) {
throw new NotFoundException(`Employee with email ${email} not found`);
}
return employee.id;
}
//create a leave-request without the use of employee_id
async create(dto: CreateLeaveRequestsDto): Promise<LeaveRequestViewDto> {
const employee_id = await this.resolveEmployeeIdByEmail(dto.email);
const row: LeaveRequestRow = await this.prisma.leaveRequests.create({
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;
}
async handleHoliday(dto: UpsertHolidayDto): Promise<{ action: HolidayUpsertAction; leave_requests: LeaveRequestViewDto[] }> {
switch (dto.action) {
case 'create':
return this.createHoliday(dto);
case 'update':
return this.updateHoliday(dto);
case 'delete':
return this.deleteHoliday(dto);
default:
throw new BadRequestException(`Unknown action: ${dto.action}`);
}
}
private async createHoliday(dto: UpsertHolidayDto): Promise<{ action: 'create'; leave_requests: LeaveRequestViewDto[] }> {
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 created: 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: { id: true },
});
if (existing) {
throw new BadRequestException(`A holiday request already exists for ${isoDate}`);
}
const payable = await this.holidayService.calculateHolidayPay(email, date, bankCode.modifier);
const row = await this.prisma.leaveRequests.create({
data: {
employee_id,
bank_code_id: dto.bank_code_id,
leave_type: dto.leave_type,
start_date_time: new Date(dto.start_date_time),
end_date_time: dto.end_date_time ? new Date(dto.end_date_time) : null,
comment: dto.comment,
approval_status: dto.approval_status ?? undefined,
employee_id: employeeId,
bank_code_id: bankCode.id,
leave_type: LeaveTypes.HOLIDAY,
date,
comment: dto.comment ?? '',
approval_status: undefined,
requested_hours: dto.requested_hours ?? 8,
payable_hours: payable,
},
select: leaveRequestsSelect,
});
return mapRowToViewWithDays(row);
created.push({ ...mapRowToView(row), action: 'create' });
}
//fetches all leave-requests using email
async findAll(filters: SearchLeaveRequestsDto): Promise<LeaveRequestViewDto[]> {
const {start_date, end_date,email, leave_type, approval_status, bank_code_id } = filters;
const where: any = {};
return { action: 'create', leave_requests: created };
}
if (start_date) where.start_date_time = { ...(where.start_date_time ?? {}), gte: new Date(start_date) };
if (end_date) where.end_date_time = { ...(where.end_date_time ?? {}), lte: new Date(end_date) };
if (email) where.employee = { user: { email } };
if (leave_type) where.leave_type = leave_type;
if (approval_status) where.approval_status = approval_status;
if (typeof bank_code_id === 'number') where.bank_code_id = bank_code_id;
private async updateHoliday(dto: UpsertHolidayDto): Promise<{ action: 'update'; leave_requests: LeaveRequestViewDto[] }> {
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 row = await this.prisma.leaveRequests.update({
where: { id: existing.id },
data: {
comment: dto.comment ?? existing.comment,
requested_hours: dto.requested_hours ?? undefined,
payable_hours: payable,
bank_code_id: bankCode.id,
},
select: leaveRequestsSelect,
});
updated.push({ ...mapRowToView(row), action: 'update' });
}
return { action: 'update', leave_requests: updated };
}
private async deleteHoliday(dto: UpsertHolidayDto): Promise<{ action: 'delete'; leave_requests: LeaveRequestViewDto[] }> {
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,
select: leaveRequestsSelect,
orderBy: { start_date_time: 'desc' },
});
return rows.map(mapRowToViewWithDays);
}
//fetch 1 leave-request using email
async findOne(id:number): Promise<LeaveRequestViewDto> {
const row: LeaveRequestRow | null = await this.prisma.leaveRequests.findUnique({
where: { id },
where: {
employee_id: employeeId,
leave_type: LeaveTypes.HOLIDAY,
date: { in: dates.map((d) => toDateOnly(d)) },
},
select: leaveRequestsSelect,
});
if(!row) throw new NotFoundException(`Leave Request #${id} not found`);
return mapRowToViewWithDays(row);
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(', ')}`);
}
//updates 1 leave-request using email
async update(id: number, dto: UpdateLeaveRequestsDto): Promise<LeaveRequestViewDto> {
await this.findOne(id);
const data: Record<string, any> = {};
if(dto.email !== undefined) data.employee_id = await this.resolveEmployeeIdByEmail(dto.email);
if(dto.leave_type !== undefined) data.bank_code_id = dto.bank_code_id;
if(dto.start_date_time !== undefined) data.start_date_time = new Date(dto.start_date_time);
if(dto.end_date_time !== undefined) data.end_date_time = new Date(dto.end_date_time);
if(dto.comment !== undefined) data.comment = dto.comment;
if(dto.approval_status !== undefined) data.approval_status = dto.approval_status;
const row: LeaveRequestRow = await this.prisma.leaveRequests.update({
where: { id },
data,
select: leaveRequestsSelect,
await this.prisma.leaveRequests.deleteMany({
where: { id: { in: rows.map((row) => row.id) } },
});
return mapRowToViewWithDays(row);
const deleted = rows.map((row) => ({ ...mapRowToView(row), action: 'delete' as const }));
return { action: 'delete', leave_requests: deleted };
}
//removes 1 leave-request using email
async remove(id:number): Promise<LeaveRequestViewDto> {
await this.findOne(id);
const row: LeaveRequestRow = await this.prisma.leaveRequests.delete({
where: { id },
select: leaveRequestsSelect,
});
return mapRowToViewWithDays(row);
}
//archivation functions ******************************************************
//-------------------- archival --------------------
async archiveExpired(): Promise<void> {
const now = new Date();
await this.prisma.$transaction(async transaction => {
//fetches expired leave requests
const expired = await transaction.leaveRequests.findMany({
where: { end_date_time: { lt: now } },
});
if(expired.length === 0) {
return;
}
//copy unto archive table
await transaction.leaveRequestsArchive.createMany({
data: expired.map(request => ({
leave_request_id: request.id,
employee_id: request.employee_id,
leave_type: request.leave_type,
start_date_time: request.start_date_time,
end_date_time: request.end_date_time,
comment: request.comment,
approval_status: request.approval_status,
})),
});
//delete from leave_requests table
await transaction.leaveRequests.deleteMany({
where: { id: { in: expired.map(request => request.id ) } },
});
});
// TODO: adjust logic to the new LeaveRequests structure
}
//fetches all archived leave-requests
async findAllArchived(): Promise<LeaveRequestsArchive[]> {
return this.prisma.leaveRequestsArchive.findMany();
}
//remove emp_id and use email
//fetches an archived employee
async findOneArchived(id: number): Promise<LeaveRequestViewDto> {
const row: LeaveRequestArchiveRow | null = await this.prisma.leaveRequestsArchive.findUnique({
where: { id },
select: leaveRequestsArchiveSelect,
});
if(!row) throw new NotFoundException(`Archived Leave Request #${id} not found`);
if (!row) {
throw new NotFoundException(`Archived Leave Request #${id} not found`);
}
const emp = await this.prisma.employees.findUnique({
where: { id: row.employee_id },
select: { user: {select: { email:true,
select: {
user: {
select: {
email: true,
first_name: true,
last_name: true,
}}},
},
},
},
});
const email = emp?.user.email ?? "";
const full_name = emp ? `${emp.user.first_name} ${emp.user.last_name}` : "";
const email = emp?.user.email ?? '';
const fullName = emp ? `${emp.user.first_name} ${emp.user.last_name}` : '';
return mapArchiveRowToViewWithDays(row, email, full_name);
return mapArchiveRowToViewWithDays(row, email, fullName);
}
}
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

@ -6,11 +6,11 @@ export const leaveRequestsArchiveSelect = {
archived_at: true,
employee_id: true,
leave_type: true,
start_date_time: true,
end_date_time: true,
date: true,
payable_hours: true,
requested_hours: true,
comment: true,
approval_status: true,
} satisfies Prisma.LeaveRequestsArchiveSelect;
export type LeaveRequestArchiveRow = Prisma.LeaveRequestsArchiveGetPayload<{ select: typeof leaveRequestsArchiveSelect}>;

View File

@ -5,8 +5,9 @@ export const leaveRequestsSelect = {
id: true,
bank_code_id: true,
leave_type: true,
start_date_time: true,
end_date_time: true,
date: true,
payable_hours: true,
requested_hours: true,
comment: true,
approval_status: true,
employee: { select: {