import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; import { CreateShiftDto } from "../dtos/create-shift.dto"; import { Shifts, ShiftsArchive } from "@prisma/client"; import { UpdateShiftsDto } from "../dtos/update-shift.dto"; import { buildPrismaWhere } from "src/common/shared/build-prisma-where"; import { SearchShiftsDto } from "../dtos/search-shift.dto"; import { NotificationsService } from "src/modules/notifications/services/notifications.service"; import { computeHours, hoursBetweenSameDay } from "src/common/utils/date-utils"; const DAILY_LIMIT_HOURS = Number(process.env.DAILY_LIMIT_HOURS ?? 8); export interface OverviewRow { fullName: string; supervisor: string; totalRegularHrs: number; totalEveningHrs: number; totalOvertimeHrs: number; totalExpenses: number; totalMileage: number; isValidated: boolean; } @Injectable() export class ShiftsQueryService { constructor( private readonly prisma: PrismaService, private readonly notifs: NotificationsService, ) {} async create(dto: CreateShiftDto): Promise { const { timesheet_id, bank_code_id, date, start_time, end_time, description } = dto; //shift creation const shift = await this.prisma.shifts.create({ data: { timesheet_id, bank_code_id, date, start_time, end_time, description }, include: { timesheet: { include: { employee: { include: { user: true } } } }, bank_code: true, }, }); //fetches all shifts of the same day to check for daily overtime const sameDayShifts = await this.prisma.shifts.findMany({ where: { timesheet_id, date }, select: { id: true, date: true, start_time: true, end_time: true }, }); //sums hours of the day const totalHours = sameDayShifts.reduce((sum, s) => { return sum + hoursBetweenSameDay(s.date, s.start_time, s.end_time); }, 0 ); //Notify if total hours > 8 for a single day if(totalHours > DAILY_LIMIT_HOURS ) { const userId = String(shift.timesheet.employee.user.id); const dateLabel = new Date(date).toLocaleDateString('fr-CA'); this.notifs.notify(userId, { type: 'shift.overtime.daily', severity: 'warn', message: `Tu viens de dépasser ${DAILY_LIMIT_HOURS.toFixed(2)}h pour la journée du ${dateLabel} (total: ${totalHours.toFixed(2)}h).`, ts: new Date().toISOString(), meta: { timesheet_id, date: new Date(date).toISOString(), totalHours, threshold: DAILY_LIMIT_HOURS, lastShiftId: shift.id }, }); } return shift; } async findAll(filters: SearchShiftsDto): Promise { const where = buildPrismaWhere(filters); const shifts = await this.prisma.shifts.findMany({ where }) return shifts; } async findOne(id: number): Promise { const shift = await this.prisma.shifts.findUnique({ where: { id }, include: { timesheet: { include: { employee: { include: { user: true } } } }, bank_code: true, }, }); if(!shift) { throw new NotFoundException(`Shift #${id} not found`); } return shift; } async update(id: number, dto: UpdateShiftsDto): Promise { await this.findOne(id); const { timesheet_id, bank_code_id, date,start_time,end_time, description} = dto; return this.prisma.shifts.update({ where: { id }, data: { ...(timesheet_id !== undefined && { timesheet_id }), ...(bank_code_id !== undefined && { bank_code_id }), ...(date !== undefined && { date }), ...(start_time !== undefined && { start_time }), ...(end_time !== undefined && { end_time }), ...(description !== undefined && { description }), }, include: { timesheet: { include: { employee: { include: { user: true } } } }, bank_code: true, }, }); } async remove(id: number): Promise { await this.findOne(id); return this.prisma.shifts.delete({ where: { id } }); } async getSummary(period_id: number): Promise { //fetch pay-period to display const period = await this.prisma.payPeriods.findFirst({ where: { pay_period_no: period_id }, }); if(!period) { throw new NotFoundException(`pay-period ${period_id} not found`); } const { period_start, period_end } = period; //prepare shifts and expenses for display const shifts = await this.prisma.shifts.findMany({ where: { date: { gte: period_start, lte: period_end } }, include: { bank_code: true, timesheet: { include: { employee: { include: { user:true, supervisor: { include: { user: true } }, } }, } }, }, }); const expenses = await this.prisma.expenses.findMany({ where: { date: { gte: period_start, lte: period_end } }, include: { bank_code: true, timesheet: { include: { employee: { include: { user:true, supervisor: { include: { user:true } }, } }, } }, }, }); const mapRow = new Map(); for(const s of shifts) { const employeeId = s.timesheet.employee.user_id; const user = s.timesheet.employee.user; const sup = s.timesheet.employee.supervisor?.user; let row = mapRow.get(employeeId); if(!row) { row = { fullName: `${user.first_name} ${user.last_name}`, supervisor: sup? `${sup.first_name} ${sup.last_name }` : '', totalRegularHrs: 0, totalEveningHrs: 0, totalOvertimeHrs: 0, totalExpenses: 0, totalMileage: 0, isValidated: false, }; } const hours = computeHours(s.start_time, s.end_time); switch(s.bank_code.type) { case 'regular' : row.totalRegularHrs += hours; break; case 'evening' : row.totalEveningHrs += hours; break; case 'overtime' : row.totalOvertimeHrs += hours; break; default: row.totalRegularHrs += hours; } mapRow.set(employeeId, row); } for(const e of expenses) { const employeeId = e.timesheet.employee.user_id; const user = e.timesheet.employee.user; const sup = e.timesheet.employee.supervisor?.user; let row = mapRow.get(employeeId); if(!row) { row = { fullName: `${user.first_name} ${user.last_name}`, supervisor: sup? `${sup.first_name} ${sup.last_name }` : '', totalRegularHrs: 0, totalEveningHrs: 0, totalOvertimeHrs: 0, totalExpenses: 0, totalMileage: 0, isValidated: false, }; } const amount = Number(e.amount); row.totalExpenses += amount; if(e.bank_code.type === 'mileage') { row.totalMileage += amount; } mapRow.set(employeeId, row); } //return by default the list of employee in ascending alphabetical order return Array.from(mapRow.values()).sort((a,b) => a.fullName.localeCompare(b.fullName)); } //archivation functions ****************************************************** async archiveOld(): Promise { //fetches archived timesheet's Ids const archivedTimesheets = await this.prisma.timesheetsArchive.findMany({ select: { timesheet_id: true }, }); const timesheetIds = archivedTimesheets.map(sheet => sheet.timesheet_id); if(timesheetIds.length === 0) { return; } // copy/delete transaction await this.prisma.$transaction(async transaction => { //fetches shifts to move to archive const shiftsToArchive = await transaction.shifts.findMany({ where: { timesheet_id: { in: timesheetIds } }, }); if(shiftsToArchive.length === 0) { return; } //copies sent to archive table await transaction.shiftsArchive.createMany({ data: shiftsToArchive.map(shift => ({ shift_id: shift.id, timesheet_id: shift.timesheet_id, bank_code_id: shift.bank_code_id, description: shift.description ?? undefined, date: shift.date, start_time: shift.start_time, end_time: shift.end_time, })), }); //delete from shifts table await transaction.shifts.deleteMany({ where: { id: { in: shiftsToArchive.map(shift => shift.id) } }, }) }) } //fetches all archived timesheets async findAllArchived(): Promise { return this.prisma.shiftsArchive.findMany(); } //fetches an archived timesheet async findOneArchived(id: number): Promise { return this.prisma.shiftsArchive.findUniqueOrThrow({ where: { id } }); } }