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:38:09 -04:00
parent d36d2f922b
commit 10d4f11f76
8 changed files with 216 additions and 92 deletions

View File

@ -3,7 +3,7 @@ import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { LeaveRequestsArchive, Roles as RoleEnum } from "@prisma/client";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { LeaveRequestViewDto } from "src/modules/leave-requests/dtos/leave-request.view.dto";
import { LeaveRequestsService } from "src/modules/leave-requests/services/leave-requests.service";
import { LeaveRequestsService } from "src/modules/leave-requests/services/holiday-leave-requests.service";
@ApiTags('LeaveRequests Archives')
// @UseGuards()

View File

@ -1,7 +1,7 @@
import { Injectable, Logger } from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import { ExpensesQueryService } from "src/modules/expenses/services/expenses-query.service";
import { LeaveRequestsService } from "src/modules/leave-requests/services/leave-requests.service";
import { LeaveRequestsService } from "src/modules/leave-requests/services/holiday-leave-requests.service";
import { ShiftsQueryService } from "src/modules/shifts/services/shifts-query.service";
import { TimesheetsQueryService } from "src/modules/timesheets/services/timesheets-query.service";

View File

@ -11,10 +11,7 @@ import {
Injectable,
NotFoundException
} from "@nestjs/common";
import {
DayExpenseResponse,
UpsertAction
} from "../types and interfaces/expenses.types.interfaces";
import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces";
import {
assertAndTrimComment,
computeMileageAmount,
@ -47,7 +44,7 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
//-------------------- Master CRUD function --------------------
readonly upsertExpensesByDate = async (email: string, date: string, dto: UpsertExpenseDto,
): Promise<{ action:UpsertAction; day: DayExpenseResponse[] }> => {
): Promise<{ action:UpsertAction; day: ExpenseResponse[] }> => {
//validates if there is an existing expense, at least 1 old or new
const { old_expense, new_expense } = dto ?? {};
@ -68,7 +65,7 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
const timesheet_id = await this.ensureTimesheetForDate(employee_id, dateOnly);
return this.prisma.$transaction(async (tx) => {
const loadDay = async (): Promise<DayExpenseResponse[]> => {
const loadDay = async (): Promise<ExpenseResponse[]> => {
const rows = await tx.expenses.findMany({
where: {
timesheet_id: timesheet_id,
@ -186,7 +183,7 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
});
}
await tx.expenses.delete({where: { id: existing.id } });
action = 'deleted';
action = 'delete';
}
//-------------------- CREATE --------------------
else if (!old_expense && new_expense) {
@ -203,7 +200,7 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
is_approved: false,
},
});
action = 'created';
action = 'create';
}
//-------------------- UPDATE --------------------
else if(old_expense && new_expense) {
@ -227,7 +224,7 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
attachment: new_exp.attachment,
},
});
action = 'updated';
action = 'update';
}
else {
throw new BadRequestException('Invalid upsert combination');
@ -310,7 +307,7 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
comment: string;
is_approved: boolean;
bank_code: { type: string } | null;
}): DayExpenseResponse => mapDbExpenseToDayResponse(row);
}): ExpenseResponse => mapDbExpenseToDayResponse(row);
}

View File

@ -1,6 +1,6 @@
export type UpsertAction = 'created' | 'updated' | 'deleted';
export type UpsertAction = 'create' | 'update' | 'delete';
export interface DayExpenseResponse {
export interface ExpenseResponse {
date: string;
type: string;
amount: number;
@ -9,6 +9,5 @@ export interface DayExpenseResponse {
};
export type UpsertExpenseResult = {
action: UpsertAction;
day: DayExpenseResponse[]
expenses: ExpenseResponse[]
};

View File

@ -1,13 +1,30 @@
import { Controller } from "@nestjs/common";
import { LeaveRequestsService } from "../services/leave-requests.service";
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";
@ApiTags('Leave Requests')
@ApiBearerAuth('access-token')
// @UseGuards()
@Controller('leave-requests')
export class LeaveRequestController {
constructor(private readonly leave_service: LeaveRequestsService){}
constructor(private readonly leave_service: HolidayLeaveRequestsService){}
@Post('holiday')
async upsertHoliday(@Body() dto: UpsertHolidayDto) {
const { action, leave_requests } = await this.leave_service.handleHoliday(dto);
return { action, leave_requests };
}
//TODO:
/*
@Get('archive')
findAllArchived(){...}
@Get('archive/:id')
findOneArchived(id){...}
*/
}

View File

@ -1,9 +1,11 @@
import { LeaveApprovalStatus } from "@prisma/client";
import { Type } from "class-transformer";
import {
ArrayNotEmpty,
ArrayUnique,
IsArray,
IsEmail,
IsEnum,
IsIn,
IsISO8601,
IsNumber,
@ -39,4 +41,8 @@ export class UpsertHolidayDto {
@Min(0)
@Max(24)
requested_hours?: number;
@IsOptional()
@IsEnum(LeaveApprovalStatus)
approval_status?: LeaveApprovalStatus;
}

View File

@ -1,13 +1,19 @@
import { PrismaService } from "src/prisma/prisma.service";
import { HolidayService } from "../business-logics/services/holiday.service";
import { LeaveRequestController } from "./controllers/leave-requests.controller";
import { LeaveRequestsService } from "./services/leave-requests.service";
import { HolidayLeaveRequestsService } from "./services/holiday-leave-requests.service";
import { Module } from "@nestjs/common";
import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
@Module({
imports: [BusinessLogicsModule],
controllers: [LeaveRequestController],
providers: [LeaveRequestsService],
exports: [LeaveRequestsService],
providers: [
HolidayService,
HolidayLeaveRequestsService,
PrismaService,
],
exports: [HolidayLeaveRequestsService],
})
export class LeaveRequestsModule {}

View File

@ -1,46 +1,37 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { LeaveTypes, LeaveRequestsArchive } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
import { HolidayService } from "src/modules/business-logics/services/holiday.service";
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client';
import { UpsertHolidayDto, HolidayUpsertAction } from "../dtos/upsert-holiday.dto";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { mapRowToView } from "../mappers/leave-requests.mapper";
import { mapArchiveRowToViewWithDays } from "../utils/leave-request.transform";
import { LeaveRequestArchiveRow, leaveRequestsArchiveSelect } from "../utils/leave-requests-archive.select";
import { leaveRequestsSelect } from "../utils/leave-requests.select";
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 { LeaveRequestViewDto } from '../dtos/leave-request-view.dto';
import { HolidayUpsertAction, UpsertHolidayDto } from '../dtos/upsert-holiday.dto';
import { mapRowToView } from '../mappers/leave-requests.mapper';
import { leaveRequestsSelect } from '../utils/leave-requests.select';
interface HolidayUpsertResult {
action: HolidayUpsertAction;
leave_requests: LeaveRequestViewDto[];
}
@Injectable()
export class LeaveRequestsService {
export class HolidayLeaveRequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly holidayService: HolidayService,
private readonly shiftsCommand: ShiftsCommandService,
) {}
//-------------------- 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;
}
// ---------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------
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[] }> {
async handleHoliday(dto: UpsertHolidayDto): Promise<HolidayUpsertResult> {
switch (dto.action) {
case 'create':
return this.createHoliday(dto);
@ -53,7 +44,11 @@ export class LeaveRequestsService {
}
}
private async createHoliday(dto: UpsertHolidayDto): Promise<{ action: 'create'; leave_requests: LeaveRequestViewDto[] }> {
// ---------------------------------------------------------------------
// Create
// ---------------------------------------------------------------------
private async createHoliday(dto: UpsertHolidayDto): Promise<HolidayUpsertResult> {
const email = dto.email.trim();
const employeeId = await this.resolveEmployeeIdByEmail(email);
const bankCode = await this.resolveHolidayBankCode();
@ -63,8 +58,10 @@ export class LeaveRequestsService {
}
const created: LeaveRequestViewDto[] = [];
for (const isoDate of dates) {
const date = toDateOnly(isoDate);
const existing = await this.prisma.leaveRequests.findUnique({
where: {
leave_per_employee_date: {
@ -76,7 +73,7 @@ export class LeaveRequestsService {
select: { id: true },
});
if (existing) {
throw new BadRequestException(`A holiday request already exists for ${isoDate}`);
throw new BadRequestException(`Holiday request already exists for ${isoDate}`);
}
const payable = await this.holidayService.calculateHolidayPay(email, date, bankCode.modifier);
@ -87,19 +84,29 @@ export class LeaveRequestsService {
leave_type: LeaveTypes.HOLIDAY,
date,
comment: dto.comment ?? '',
approval_status: undefined,
requested_hours: dto.requested_hours ?? 8,
payable_hours: payable,
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
},
select: leaveRequestsSelect,
});
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);
}
created.push({ ...mapRowToView(row), action: 'create' });
}
return { action: 'create', leave_requests: created };
}
private async updateHoliday(dto: UpsertHolidayDto): Promise<{ action: 'update'; leave_requests: LeaveRequestViewDto[] }> {
// ---------------------------------------------------------------------
// Update
// ---------------------------------------------------------------------
private async updateHoliday(dto: UpsertHolidayDto): Promise<HolidayUpsertResult> {
const email = dto.email.trim();
const employeeId = await this.resolveEmployeeIdByEmail(email);
const bankCode = await this.resolveHolidayBankCode();
@ -109,8 +116,10 @@ export class LeaveRequestsService {
}
const updated: LeaveRequestViewDto[] = [];
for (const isoDate of dates) {
const date = toDateOnly(isoDate);
const existing = await this.prisma.leaveRequests.findUnique({
where: {
leave_per_employee_date: {
@ -126,23 +135,43 @@ export class LeaveRequestsService {
}
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 ?? undefined,
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 };
}
private async deleteHoliday(dto: UpsertHolidayDto): Promise<{ action: 'delete'; leave_requests: LeaveRequestViewDto[] }> {
// ---------------------------------------------------------------------
// Delete
// ---------------------------------------------------------------------
private async deleteHoliday(dto: UpsertHolidayDto): Promise<HolidayUpsertResult> {
const email = dto.email.trim();
const employeeId = await this.resolveEmployeeIdByEmail(email);
const dates = normalizeDates(dto.dates);
@ -164,48 +193,118 @@ export class LeaveRequestsService {
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 }));
const deleted = rows.map((row) => ({ ...mapRowToView(row), action: 'delete' }));
return { action: 'delete', leave_requests: deleted };
}
//-------------------- archival --------------------
async archiveExpired(): Promise<void> {
// TODO: adjust logic to the new LeaveRequests structure
// ---------------------------------------------------------------------
// 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.');
}
async findAllArchived(): Promise<LeaveRequestsArchive[]> {
return this.prisma.leaveRequestsArchive.findMany();
}
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')}`;
async findOneArchived(id: number): Promise<LeaveRequestViewDto> {
const row: LeaveRequestArchiveRow | null = await this.prisma.leaveRequestsArchive.findUnique({
where: { id },
select: leaveRequestsArchiveSelect,
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 (!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,
first_name: true,
last_name: 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 ?? '',
},
});
const email = emp?.user.email ?? '';
const fullName = emp ? `${emp.user.first_name} ${emp.user.last_name}` : '';
}
return mapArchiveRowToViewWithDays(row, email, fullName);
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;
}
}