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 { 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 { 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 { 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 { 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)))));