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 { LeaveRequestsArchive, Roles as RoleEnum } from "@prisma/client";
import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { LeaveRequestViewDto } from "src/modules/leave-requests/dtos/leave-request.view.dto"; 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') @ApiTags('LeaveRequests Archives')
// @UseGuards() // @UseGuards()

View File

@ -1,7 +1,7 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { Cron } from "@nestjs/schedule"; import { Cron } from "@nestjs/schedule";
import { ExpensesQueryService } from "src/modules/expenses/services/expenses-query.service"; 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 { ShiftsQueryService } from "src/modules/shifts/services/shifts-query.service";
import { TimesheetsQueryService } from "src/modules/timesheets/services/timesheets-query.service"; import { TimesheetsQueryService } from "src/modules/timesheets/services/timesheets-query.service";

View File

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

View File

@ -1,13 +1,30 @@
import { Controller } from "@nestjs/common"; import { Body, Controller, Post } from "@nestjs/common";
import { LeaveRequestsService } from "../services/leave-requests.service"; import { HolidayLeaveRequestsService } from "../services/holiday-leave-requests.service";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { UpsertHolidayDto } from "../dtos/upsert-holiday.dto";
@ApiTags('Leave Requests') @ApiTags('Leave Requests')
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
// @UseGuards() // @UseGuards()
@Controller('leave-requests') @Controller('leave-requests')
export class LeaveRequestController { 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 { Type } from "class-transformer";
import { import {
ArrayNotEmpty, ArrayNotEmpty,
ArrayUnique, ArrayUnique,
IsArray, IsArray,
IsEmail, IsEmail,
IsEnum,
IsIn, IsIn,
IsISO8601, IsISO8601,
IsNumber, IsNumber,
@ -39,4 +41,8 @@ export class UpsertHolidayDto {
@Min(0) @Min(0)
@Max(24) @Max(24)
requested_hours?: number; 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 { 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 { Module } from "@nestjs/common";
import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module"; import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
@Module({ @Module({
imports: [BusinessLogicsModule], imports: [BusinessLogicsModule],
controllers: [LeaveRequestController], controllers: [LeaveRequestController],
providers: [LeaveRequestsService], providers: [
exports: [LeaveRequestsService], HolidayService,
HolidayLeaveRequestsService,
PrismaService,
],
exports: [HolidayLeaveRequestsService],
}) })
export class LeaveRequestsModule {} export class LeaveRequestsModule {}

View File

@ -1,46 +1,37 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import {
import { LeaveTypes, LeaveRequestsArchive } from "@prisma/client"; BadRequestException,
import { PrismaService } from "src/prisma/prisma.service"; Injectable,
import { HolidayService } from "src/modules/business-logics/services/holiday.service"; NotFoundException,
} from '@nestjs/common';
import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client';
import { UpsertHolidayDto, HolidayUpsertAction } from "../dtos/upsert-holiday.dto"; import { HolidayService } from 'src/modules/business-logics/services/holiday.service';
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; import { ShiftsCommandService } from 'src/modules/shifts/services/shifts-command.service';
import { mapRowToView } from "../mappers/leave-requests.mapper"; import { PrismaService } from 'src/prisma/prisma.service';
import { mapArchiveRowToViewWithDays } from "../utils/leave-request.transform";
import { LeaveRequestArchiveRow, leaveRequestsArchiveSelect } from "../utils/leave-requests-archive.select"; import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto';
import { leaveRequestsSelect } from "../utils/leave-requests.select"; 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() @Injectable()
export class LeaveRequestsService { export class HolidayLeaveRequestsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly holidayService: HolidayService, private readonly holidayService: HolidayService,
private readonly shiftsCommand: ShiftsCommandService,
) {} ) {}
//-------------------- helpers -------------------- // ---------------------------------------------------------------------
private async resolveEmployeeIdByEmail(email: string): Promise<number> { // Public API
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() { async handleHoliday(dto: UpsertHolidayDto): Promise<HolidayUpsertResult> {
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) { switch (dto.action) {
case 'create': case 'create':
return this.createHoliday(dto); 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 email = dto.email.trim();
const employeeId = await this.resolveEmployeeIdByEmail(email); const employeeId = await this.resolveEmployeeIdByEmail(email);
const bankCode = await this.resolveHolidayBankCode(); const bankCode = await this.resolveHolidayBankCode();
@ -63,8 +58,10 @@ export class LeaveRequestsService {
} }
const created: LeaveRequestViewDto[] = []; const created: LeaveRequestViewDto[] = [];
for (const isoDate of dates) { for (const isoDate of dates) {
const date = toDateOnly(isoDate); const date = toDateOnly(isoDate);
const existing = await this.prisma.leaveRequests.findUnique({ const existing = await this.prisma.leaveRequests.findUnique({
where: { where: {
leave_per_employee_date: { leave_per_employee_date: {
@ -76,7 +73,7 @@ export class LeaveRequestsService {
select: { id: true }, select: { id: true },
}); });
if (existing) { 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); const payable = await this.holidayService.calculateHolidayPay(email, date, bankCode.modifier);
@ -87,19 +84,29 @@ export class LeaveRequestsService {
leave_type: LeaveTypes.HOLIDAY, leave_type: LeaveTypes.HOLIDAY,
date, date,
comment: dto.comment ?? '', comment: dto.comment ?? '',
approval_status: undefined,
requested_hours: dto.requested_hours ?? 8, requested_hours: dto.requested_hours ?? 8,
payable_hours: payable, payable_hours: payable,
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
}, },
select: leaveRequestsSelect, 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' }); created.push({ ...mapRowToView(row), action: 'create' });
} }
return { action: 'create', leave_requests: created }; 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 email = dto.email.trim();
const employeeId = await this.resolveEmployeeIdByEmail(email); const employeeId = await this.resolveEmployeeIdByEmail(email);
const bankCode = await this.resolveHolidayBankCode(); const bankCode = await this.resolveHolidayBankCode();
@ -109,8 +116,10 @@ export class LeaveRequestsService {
} }
const updated: LeaveRequestViewDto[] = []; const updated: LeaveRequestViewDto[] = [];
for (const isoDate of dates) { for (const isoDate of dates) {
const date = toDateOnly(isoDate); const date = toDateOnly(isoDate);
const existing = await this.prisma.leaveRequests.findUnique({ const existing = await this.prisma.leaveRequests.findUnique({
where: { where: {
leave_per_employee_date: { leave_per_employee_date: {
@ -126,23 +135,43 @@ export class LeaveRequestsService {
} }
const payable = await this.holidayService.calculateHolidayPay(email, date, bankCode.modifier); const payable = await this.holidayService.calculateHolidayPay(email, date, bankCode.modifier);
const previousStatus = existing.approval_status;
const row = await this.prisma.leaveRequests.update({ const row = await this.prisma.leaveRequests.update({
where: { id: existing.id }, where: { id: existing.id },
data: { data: {
comment: dto.comment ?? existing.comment, comment: dto.comment ?? existing.comment,
requested_hours: dto.requested_hours ?? undefined, requested_hours: dto.requested_hours ?? existing.requested_hours ?? 8,
payable_hours: payable, payable_hours: payable,
bank_code_id: bankCode.id, bank_code_id: bankCode.id,
approval_status: dto.approval_status ?? existing.approval_status,
}, },
select: leaveRequestsSelect, 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' }); updated.push({ ...mapRowToView(row), action: 'update' });
} }
return { action: 'update', leave_requests: updated }; 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 email = dto.email.trim();
const employeeId = await this.resolveEmployeeIdByEmail(email); const employeeId = await this.resolveEmployeeIdByEmail(email);
const dates = normalizeDates(dto.dates); const dates = normalizeDates(dto.dates);
@ -164,48 +193,118 @@ export class LeaveRequestsService {
throw new NotFoundException(`No HOLIDAY request found for: ${missing.join(', ')}`); 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({ await this.prisma.leaveRequests.deleteMany({
where: { id: { in: rows.map((row) => row.id) } }, 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 }; return { action: 'delete', leave_requests: deleted };
} }
//-------------------- archival -------------------- // ---------------------------------------------------------------------
async archiveExpired(): Promise<void> { // Shift synchronisation
// TODO: adjust logic to the new LeaveRequests structure // ---------------------------------------------------------------------
}
async findAllArchived(): Promise<LeaveRequestsArchive[]> { private async syncHolidayShift(
return this.prisma.leaveRequestsArchive.findMany(); email: string,
} employeeId: number,
isoDate: string,
hours: number,
comment?: string,
) {
if (hours <= 0) return;
async findOneArchived(id: number): Promise<LeaveRequestViewDto> { const durationMinutes = Math.round(hours * 60);
const row: LeaveRequestArchiveRow | null = await this.prisma.leaveRequestsArchive.findUnique({ if (durationMinutes > 8 * 60) {
where: { id }, throw new BadRequestException('Holiday hours cannot exceed 8 hours.');
select: leaveRequestsArchiveSelect,
});
if (!row) {
throw new NotFoundException(`Archived Leave Request #${id} not found`);
} }
const emp = await this.prisma.employees.findUnique({ const startMinutes = 8 * 60;
where: { id: row.employee_id }, const endMinutes = startMinutes + durationMinutes;
select: { const toHHmm = (total: number) => `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
user: {
select: { const existing = await this.prisma.shifts.findFirst({
email: true, where: {
first_name: true, date: new Date(isoDate),
last_name: true, 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 ?? '',
}, },
}); });
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;
} }
} }