From 2de8db62129de90a89a68454ef672935f56f9669 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 10 Oct 2025 11:34:39 -0400 Subject: [PATCH 01/17] refactor(timesheets): dried findAll methods to centralize shareable methods --- .../services/expenses-query.service.ts | 4 +- .../shared/selects/employees.select.ts | 0 src/modules/shared/selects/expenses.select.ts | 11 ++ .../shared/selects/pay-periods.select.ts | 4 + src/modules/shared/selects/shifts.select.ts | 12 +++ .../shared/selects/timesheets.select.ts | 0 .../services/timesheets-command.service.ts | 2 +- .../services/timesheets-query.service.ts | 101 ++++-------------- .../{utils => }/timesheet.helpers.ts | 16 ++- .../{mappers => }/timesheet.mappers.ts | 46 +++++++- src/modules/timesheets/timesheet.selectors.ts | 35 ++++++ .../timesheets/{types => }/timesheet.types.ts | 0 .../timesheets/{utils => }/timesheet.utils.ts | 6 +- 13 files changed, 145 insertions(+), 92 deletions(-) create mode 100644 src/modules/shared/selects/employees.select.ts create mode 100644 src/modules/shared/selects/expenses.select.ts create mode 100644 src/modules/shared/selects/pay-periods.select.ts create mode 100644 src/modules/shared/selects/shifts.select.ts create mode 100644 src/modules/shared/selects/timesheets.select.ts rename src/modules/timesheets/{utils => }/timesheet.helpers.ts (76%) rename src/modules/timesheets/{mappers => }/timesheet.mappers.ts (54%) create mode 100644 src/modules/timesheets/timesheet.selectors.ts rename src/modules/timesheets/{types => }/timesheet.types.ts (100%) rename src/modules/timesheets/{utils => }/timesheet.utils.ts (97%) diff --git a/src/modules/expenses/services/expenses-query.service.ts b/src/modules/expenses/services/expenses-query.service.ts index 35cdde5..18be585 100644 --- a/src/modules/expenses/services/expenses-query.service.ts +++ b/src/modules/expenses/services/expenses-query.service.ts @@ -1,8 +1,8 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; import { DayExpensesDto as ExpenseListResponseDto, ExpenseDto } from "src/modules/timesheets/dtos/timesheet-period.dto"; -import { round2, toUTCDateOnly } from "src/modules/timesheets/utils/timesheet.helpers"; -import { EXPENSE_TYPES } from "src/modules/timesheets/types/timesheet.types"; +import { round2, toUTCDateOnly } from "src/modules/timesheets/timesheet.helpers"; +import { EXPENSE_TYPES } from "src/modules/timesheets/timesheet.types"; import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; @Injectable() diff --git a/src/modules/shared/selects/employees.select.ts b/src/modules/shared/selects/employees.select.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/shared/selects/expenses.select.ts b/src/modules/shared/selects/expenses.select.ts new file mode 100644 index 0000000..540d98f --- /dev/null +++ b/src/modules/shared/selects/expenses.select.ts @@ -0,0 +1,11 @@ +export const EXPENSE_SELECT = { + date: true, + amount: true, + mileage: true, + comment: true, + is_approved: true, + supervisor_comment: true, + bank_code: { select: { type: true } }, +} as const; + +export const EXPENSE_ASC_ORDER = { date: 'asc' as const }; \ No newline at end of file diff --git a/src/modules/shared/selects/pay-periods.select.ts b/src/modules/shared/selects/pay-periods.select.ts new file mode 100644 index 0000000..a76f09b --- /dev/null +++ b/src/modules/shared/selects/pay-periods.select.ts @@ -0,0 +1,4 @@ +export const PAY_PERIOD_SELECT = { + period_start: true, + period_end: true, +} as const; \ No newline at end of file diff --git a/src/modules/shared/selects/shifts.select.ts b/src/modules/shared/selects/shifts.select.ts new file mode 100644 index 0000000..8c738e1 --- /dev/null +++ b/src/modules/shared/selects/shifts.select.ts @@ -0,0 +1,12 @@ +export const SHIFT_SELECT = { + date: true, + start_time: true, + end_time: true, + comment: true, + is_approved: true, + is_remote: true, + bank_code: {select: { type: true } }, +} as const; + +export const SHIFT_ASC_ORDER = [{date: 'asc' as const}, {start_time: 'asc' as const}]; + diff --git a/src/modules/shared/selects/timesheets.select.ts b/src/modules/shared/selects/timesheets.select.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/timesheets/services/timesheets-command.service.ts b/src/modules/timesheets/services/timesheets-command.service.ts index b6de4cb..2db914e 100644 --- a/src/modules/timesheets/services/timesheets-command.service.ts +++ b/src/modules/timesheets/services/timesheets-command.service.ts @@ -5,7 +5,7 @@ import { PrismaService } from "src/prisma/prisma.service"; import { TimesheetsQueryService } from "./timesheets-query.service"; import { CreateTimesheetDto } from "../dtos/create-timesheet.dto"; import { getWeekEnd, getWeekStart } from "src/common/utils/date-utils"; -import { parseISODate, parseHHmm } from "../utils/timesheet.helpers"; +import { parseISODate, parseHHmm } from "../timesheet.helpers"; import { TimesheetDto } from "../dtos/timesheet-period.dto"; import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-employee-timesheet.utils"; diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 1092c3b..58dfd72 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -1,12 +1,14 @@ -import { endOfDayUTC, toHHmm, toUTCDateOnly } from '../utils/timesheet.helpers'; +import { endOfDayUTC, toHHmm, toNum, toRangeFromPeriod, toUTCDateOnly } from '../timesheet.helpers'; import { Injectable, NotFoundException } from '@nestjs/common'; import { formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils'; import { PrismaService } from 'src/prisma/prisma.service'; import { TimesheetDto, TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; -import { ShiftRow, ExpenseRow } from '../types/timesheet.types'; -import { buildPeriod } from '../utils/timesheet.utils'; +import { ShiftRow, ExpenseRow } from '../timesheet.types'; +import { buildPeriod } from '../timesheet.utils'; import { EmailToIdResolver } from 'src/modules/shared/utils/resolve-email-id.utils'; import { FullNameResolver } from 'src/modules/shared/utils/resolve-full-name.utils'; +import { TimesheetSelectors } from '../timesheet.selectors'; +import { mapExpenseRow, mapShiftRow } from '../timesheet.mappers'; @Injectable() @@ -14,93 +16,32 @@ export class TimesheetsQueryService { constructor( private readonly prisma: PrismaService, private readonly emailResolver: EmailToIdResolver, - private readonly fullNameResolver: FullNameResolver + private readonly fullNameResolver: FullNameResolver, + private readonly selectors: TimesheetSelectors, ) {} async findAll(year: number, period_no: number, email: string): Promise { //finds the employee using email const employee_id = await this.emailResolver.findIdByEmail(email); - if(!employee_id) throw new NotFoundException(`employee with email : ${email} not found`); - + //finds the employee full name using employee_id const full_name = await this.fullNameResolver.resolveFullName(employee_id); - if(!full_name) throw new NotFoundException(`employee with id: ${employee_id} not found`) - - //finds the period - const period = await this.prisma.payPeriods.findFirst({ - where: { - pay_year: year, - pay_period_no: period_no - }, - select: { - period_start: true, - period_end: true - }, - }); - if(!period) throw new NotFoundException(`Period ${year}-${period_no} not found`); - - const from = toUTCDateOnly(period.period_start); - const to = endOfDayUTC(period.period_end); - - const raw_shifts = await this.prisma.shifts.findMany({ - where: { - timesheet: { is: { employee_id: employee_id } }, - date: { gte: from, lte: to }, - }, - select: { - date: true, - start_time: true, - end_time: true, - comment: true, - is_approved: true, - is_remote: true, - bank_code: { select: { type: true } }, - }, - orderBy:[ { date:'asc'}, { start_time: 'asc'} ], - }); - - const raw_expenses = await this.prisma.expenses.findMany({ - where: { - timesheet: { is: { employee_id: employee_id } }, - date: { gte: from, lte: to }, - }, - select: { - date: true, - amount: true, - mileage: true, - comment: true, - is_approved: true, - supervisor_comment: true, - bank_code: { select: { type: true } }, - }, - orderBy: { date: 'asc' }, - }); - const toNum = (value: any) => - value && typeof value.toNumber === 'function' ? value.toNumber() : - typeof value === 'number' ? value : - value ? Number(value) : 0; + //finds the pay period using year and period_no + const period = await this.selectors.getPayPeriod(year, period_no); + //finds start and end dates + const{ from, to } = toRangeFromPeriod(period); + + //finds all shifts from selected period + const [raw_shifts, raw_expenses] = await Promise.all([ + this.selectors.getShifts(employee_id, from, to), + this.selectors.getExpenses(employee_id, from, to), + ]); + // data mapping - const shifts: ShiftRow[] = raw_shifts.map(shift => ({ - date: shift.date, - start_time: shift.start_time, - end_time: shift.end_time, - comment: shift.comment ?? '', - is_approved: shift.is_approved ?? true, - is_remote: shift.is_remote ?? true, - type: String(shift.bank_code?.type ?? '').toUpperCase(), - })); - - const expenses: ExpenseRow[] = raw_expenses.map(expense => ({ - type: String(expense.bank_code?.type ?? '').toUpperCase(), - date: expense.date, - amount: toNum(expense.amount), - mileage: toNum(expense.mileage), - comment: expense.comment ?? '', - is_approved: expense.is_approved ?? true, - supervisor_comment: expense.supervisor_comment ?? '', - })); + const shifts = raw_shifts.map(mapShiftRow); + const expenses = raw_expenses.map(mapExpenseRow); return buildPeriod(period.period_start, period.period_end, shifts , expenses, full_name); } diff --git a/src/modules/timesheets/utils/timesheet.helpers.ts b/src/modules/timesheets/timesheet.helpers.ts similarity index 76% rename from src/modules/timesheets/utils/timesheet.helpers.ts rename to src/modules/timesheets/timesheet.helpers.ts index 3ba862d..249b3a0 100644 --- a/src/modules/timesheets/utils/timesheet.helpers.ts +++ b/src/modules/timesheets/timesheet.helpers.ts @@ -1,5 +1,4 @@ -import { MS_PER_DAY, DayKey, DAY_KEYS } from "../types/timesheet.types"; - +import { MS_PER_DAY, DayKey, DAY_KEYS } from "./timesheet.types"; export function toUTCDateOnly(date: Date | string): Date { const d = new Date(date); @@ -43,7 +42,6 @@ export function dayKeyFromDate(date: Date, useUTC = true): DayKey { export const toHHmm = (date: Date) => date.toISOString().slice(11, 16); -//create shifts within timesheet's week - employee overview functions export function parseISODate(iso: string): Date { const [ y, m, d ] = iso.split('-').map(Number); return new Date(y, (m ?? 1) - 1, d ?? 1); @@ -54,3 +52,15 @@ export function parseHHmm(t: string): Date { return new Date(1970, 0, 1, hh || 0, mm || 0, 0, 0); } +export const toNum = (value: any) => + value && typeof value.toNumber === 'function' ? value.toNumber() : + typeof value === 'number' ? value : + value ? Number(value) : 0; + + +export const upper = (s?: string | null) => String(s ?? '').toUpperCase(); + +export const toRangeFromPeriod = (period: { period_start: Date; period_end: Date }) => ({ + from: toUTCDateOnly(period.period_start), + to: endOfDayUTC(period.period_end), +}); \ No newline at end of file diff --git a/src/modules/timesheets/mappers/timesheet.mappers.ts b/src/modules/timesheets/timesheet.mappers.ts similarity index 54% rename from src/modules/timesheets/mappers/timesheet.mappers.ts rename to src/modules/timesheets/timesheet.mappers.ts index c9f04c4..5807863 100644 --- a/src/modules/timesheets/mappers/timesheet.mappers.ts +++ b/src/modules/timesheets/timesheet.mappers.ts @@ -1,6 +1,46 @@ -import { DayExpensesDto, WeekDto, DetailedShifts, TimesheetPeriodDto } from "../dtos/timesheet-period.dto"; -import { ExpensesAmount } from "../types/timesheet.types"; -import { addDays, shortDate } from "../utils/timesheet.helpers"; +import { DayExpensesDto, WeekDto, DetailedShifts, TimesheetPeriodDto } from "./dtos/timesheet-period.dto"; +import { ExpenseRow, ExpensesAmount, ShiftRow } from "./timesheet.types"; +import { addDays, shortDate, toNum, upper } from "./timesheet.helpers"; +import { Prisma } from "@prisma/client"; + + +//mappers +export const mapShiftRow = (shift: { + date: Date; + start_time: Date; + end_time: Date; + comment?: string | null; + is_approved: boolean; + is_remote: boolean; + bank_code: { type: string }; +}): ShiftRow => ({ + date: shift.date, + start_time: shift.start_time, + end_time: shift.end_time, + comment: shift.comment ?? '', + is_approved: shift.is_approved, + is_remote: shift.is_remote, + type: upper(shift.bank_code.type), +}); + +export const mapExpenseRow = (expense: { + date: Date; + amount: Prisma.Decimal | number | null; + mileage: Prisma.Decimal | number | null; + comment?: string | null; + is_approved: boolean; + supervisor_comment?: string|null; + bank_code: { type: string }, +}): ExpenseRow => ({ + date: expense.date, + amount: toNum(expense.amount), + mileage: toNum(expense.mileage), + comment: expense.comment ?? '', + is_approved: expense.is_approved, + supervisor_comment: expense.supervisor_comment ?? '', + type: upper(expense.bank_code.type), +}); + // Factories export function makeEmptyDayExpenses(): DayExpensesDto { diff --git a/src/modules/timesheets/timesheet.selectors.ts b/src/modules/timesheets/timesheet.selectors.ts new file mode 100644 index 0000000..1c309d4 --- /dev/null +++ b/src/modules/timesheets/timesheet.selectors.ts @@ -0,0 +1,35 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { PrismaService } from "src/prisma/prisma.service"; +import { SHIFT_ASC_ORDER, SHIFT_SELECT } from "../shared/selects/shifts.select"; +import { PAY_PERIOD_SELECT } from "../shared/selects/pay-periods.select"; +import { EXPENSE_ASC_ORDER, EXPENSE_SELECT } from "../shared/selects/expenses.select"; + +@Injectable() +export class TimesheetSelectors { + constructor(readonly prisma: PrismaService){} + + async getPayPeriod(pay_year: number, pay_period_no: number) { + const period = await this.prisma.payPeriods.findFirst({ + where: { pay_year, pay_period_no }, + select: PAY_PERIOD_SELECT , + }); + if(!period) throw new NotFoundException(`period ${pay_year}-${pay_period_no} not found`); + return period; + } + + async getShifts(employee_id: number, from: Date, to: Date) { + return this.prisma.shifts.findMany({ + where: {timesheet: { is: { employee_id } }, date: { gte: from, lte: to } }, + select: SHIFT_SELECT, + orderBy: SHIFT_ASC_ORDER, + }); + } + + async getExpenses(employee_id: number, from: Date, to: Date) { + return this.prisma.expenses.findMany({ + where: { timesheet: {is: { employee_id } }, date: { gte: from, lte: to } }, + select: EXPENSE_SELECT, + orderBy: EXPENSE_ASC_ORDER, + }); + } +} \ No newline at end of file diff --git a/src/modules/timesheets/types/timesheet.types.ts b/src/modules/timesheets/timesheet.types.ts similarity index 100% rename from src/modules/timesheets/types/timesheet.types.ts rename to src/modules/timesheets/timesheet.types.ts diff --git a/src/modules/timesheets/utils/timesheet.utils.ts b/src/modules/timesheets/timesheet.utils.ts similarity index 97% rename from src/modules/timesheets/utils/timesheet.utils.ts rename to src/modules/timesheets/timesheet.utils.ts index fa09eac..3d762c1 100644 --- a/src/modules/timesheets/utils/timesheet.utils.ts +++ b/src/modules/timesheets/timesheet.utils.ts @@ -1,13 +1,13 @@ import { DayKey, DAY_KEYS, EXPENSE_TYPES, ExpenseRow, MS_PER_HOUR, SHIFT_TYPES, ShiftRow, make_hours, ShiftsHours, ExpensesAmount -} from "../types/timesheet.types"; +} from "./timesheet.types"; import { isBetweenUTC, dayKeyFromDate, toTimeString, round2, toUTCDateOnly, endOfDayUTC, addDays } from "./timesheet.helpers"; -import { WeekDto, ShiftDto, TimesheetPeriodDto, DayExpensesDto, ExpenseDto } from "../dtos/timesheet-period.dto"; -import { makeAmounts, makeEmptyWeek } from "../mappers/timesheet.mappers"; +import { WeekDto, ShiftDto, TimesheetPeriodDto, DayExpensesDto, ExpenseDto } from "./dtos/timesheet-period.dto"; +import { makeAmounts, makeEmptyWeek } from "./timesheet.mappers"; import { toDateString } from "src/modules/pay-periods/utils/pay-year.util"; export function buildWeek( From 408e52b4f5c0ab3a3ec9bff46e56ac86926fdaac Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 10 Oct 2025 13:29:26 -0400 Subject: [PATCH 02/17] refactor(timesheets): refacatored query-service --- src/modules/employees/employees.module.ts | 3 +- .../services/expenses-query.service.ts | 4 +- .../shared/constants/date-time.constant.ts | 2 + .../shared/selects/employees.select.ts | 0 .../shared/selects/timesheets.select.ts | 0 .../controllers/timesheets.controller.ts | 36 +--- .../services/timesheet-archive.service.ts | 5 +- .../services/timesheets-command.service.ts | 20 +-- .../services/timesheets-query.service.ts | 160 +++--------------- src/modules/timesheets/timesheets.module.ts | 6 +- .../timesheet.helpers.ts | 3 +- .../timesheet.mappers.ts | 24 ++- .../timesheet.selectors.ts | 21 ++- .../timesheet.types.ts | 11 +- .../timesheet.utils.ts | 21 ++- 15 files changed, 114 insertions(+), 202 deletions(-) create mode 100644 src/modules/shared/constants/date-time.constant.ts delete mode 100644 src/modules/shared/selects/employees.select.ts delete mode 100644 src/modules/shared/selects/timesheets.select.ts rename src/modules/timesheets/{ => utils-helpers-others}/timesheet.helpers.ts (94%) rename src/modules/timesheets/{ => utils-helpers-others}/timesheet.mappers.ts (85%) rename src/modules/timesheets/{ => utils-helpers-others}/timesheet.selectors.ts (57%) rename src/modules/timesheets/{ => utils-helpers-others}/timesheet.types.ts (89%) rename src/modules/timesheets/{ => utils-helpers-others}/timesheet.utils.ts (87%) diff --git a/src/modules/employees/employees.module.ts b/src/modules/employees/employees.module.ts index 66a14a7..0f0be93 100644 --- a/src/modules/employees/employees.module.ts +++ b/src/modules/employees/employees.module.ts @@ -5,7 +5,8 @@ import { EmployeesArchivalService } from './services/employees-archival.service' import { SharedModule } from '../shared/shared.module'; @Module({ - controllers: [EmployeesController, SharedModule], + imports: [SharedModule], + controllers: [EmployeesController], providers: [EmployeesService, EmployeesArchivalService], exports: [EmployeesService, EmployeesArchivalService], }) diff --git a/src/modules/expenses/services/expenses-query.service.ts b/src/modules/expenses/services/expenses-query.service.ts index 18be585..9a56fa8 100644 --- a/src/modules/expenses/services/expenses-query.service.ts +++ b/src/modules/expenses/services/expenses-query.service.ts @@ -1,8 +1,8 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; import { DayExpensesDto as ExpenseListResponseDto, ExpenseDto } from "src/modules/timesheets/dtos/timesheet-period.dto"; -import { round2, toUTCDateOnly } from "src/modules/timesheets/timesheet.helpers"; -import { EXPENSE_TYPES } from "src/modules/timesheets/timesheet.types"; +import { round2, toUTCDateOnly } from "src/modules/timesheets/utils-helpers-others/timesheet.helpers"; +import { EXPENSE_TYPES } from "src/modules/timesheets/utils-helpers-others/timesheet.types"; import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; @Injectable() diff --git a/src/modules/shared/constants/date-time.constant.ts b/src/modules/shared/constants/date-time.constant.ts new file mode 100644 index 0000000..9cf4f96 --- /dev/null +++ b/src/modules/shared/constants/date-time.constant.ts @@ -0,0 +1,2 @@ +export const MS_PER_DAY = 86_400_000; +export const MS_PER_HOUR = 3_600_000; \ No newline at end of file diff --git a/src/modules/shared/selects/employees.select.ts b/src/modules/shared/selects/employees.select.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/modules/shared/selects/timesheets.select.ts b/src/modules/shared/selects/timesheets.select.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/modules/timesheets/controllers/timesheets.controller.ts b/src/modules/timesheets/controllers/timesheets.controller.ts index 98350ab..4abca29 100644 --- a/src/modules/timesheets/controllers/timesheets.controller.ts +++ b/src/modules/timesheets/controllers/timesheets.controller.ts @@ -5,7 +5,8 @@ import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { Roles as RoleEnum } from '.prisma/client'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { TimesheetsCommandService } from '../services/timesheets-command.service'; -import { TimesheetDto, TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; +import { TimesheetMap } from '../utils-helpers-others/timesheet.types'; +import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; @ApiTags('Timesheets') @@ -33,7 +34,7 @@ export class TimesheetsController { async getByEmail( @Param('email') email: string, @Query('offset') offset?: string, - ): Promise { + ): Promise { const week_offset = Number.isFinite(Number(offset)) ? Number(offset) : 0; return this.timesheetsQuery.getTimesheetByEmail(email, week_offset); } @@ -43,37 +44,8 @@ export class TimesheetsController { @Param('email') email: string, @Body() dto: CreateWeekShiftsDto, @Query('offset') offset?: string, - ): Promise { + ): Promise { const week_offset = Number.isFinite(Number(offset)) ? Number(offset) : 0; return this.timesheetsCommand.createWeekShiftsAndReturnOverview(email, dto.shifts, week_offset); } - - //_____________________________________________________________________________________________ - // Deprecated or unused methods - //_____________________________________________________________________________________________ - - // @Patch('approval/:id') - // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) - // async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) { - // return this.timesheetsCommand.updateApproval(id, isApproved); - // } - - // @Get(':id') - // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - // @ApiOperation({ summary: 'Find timesheet' }) - // @ApiResponse({ status: 201, description: 'Timesheet found', type: CreateTimesheetDto }) - // @ApiResponse({ status: 400, description: 'Timesheet not found' }) - // findOne(@Param('id', ParseIntPipe) id: number): Promise { - // return this.timesheetsQuery.findOne(id); - // } - - // @Delete(':id') - // // @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) - // @ApiOperation({ summary: 'Delete timesheet' }) - // @ApiResponse({ status: 201, description: 'Timesheet deleted', type: CreateTimesheetDto }) - // @ApiResponse({ status: 400, description: 'Timesheet not found' }) - // remove(@Param('id', ParseIntPipe) id: number): Promise { - // return this.timesheetsQuery.remove(id); - // } - } diff --git a/src/modules/timesheets/services/timesheet-archive.service.ts b/src/modules/timesheets/services/timesheet-archive.service.ts index 4988c75..c75bdc3 100644 --- a/src/modules/timesheets/services/timesheet-archive.service.ts +++ b/src/modules/timesheets/services/timesheet-archive.service.ts @@ -1,7 +1,6 @@ import { TimesheetsArchive } from "@prisma/client"; import { PrismaService } from "src/prisma/prisma.service"; - export class TimesheetArchiveService { constructor(private readonly prisma: PrismaService){} @@ -21,9 +20,7 @@ export class TimesheetArchiveService { is_approved: true, }, }); - if( oldSheets.length === 0) { - return; - } + if( oldSheets.length === 0) return; //preping data for archivation const archiveDate = oldSheets.map(sheet => ({ diff --git a/src/modules/timesheets/services/timesheets-command.service.ts b/src/modules/timesheets/services/timesheets-command.service.ts index 2db914e..045b7b5 100644 --- a/src/modules/timesheets/services/timesheets-command.service.ts +++ b/src/modules/timesheets/services/timesheets-command.service.ts @@ -1,15 +1,15 @@ import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; -import { Prisma, Timesheets } from "@prisma/client"; -import { BaseApprovalService } from "src/common/shared/base-approval.service"; -import { PrismaService } from "src/prisma/prisma.service"; -import { TimesheetsQueryService } from "./timesheets-query.service"; -import { CreateTimesheetDto } from "../dtos/create-timesheet.dto"; -import { getWeekEnd, getWeekStart } from "src/common/utils/date-utils"; -import { parseISODate, parseHHmm } from "../timesheet.helpers"; -import { TimesheetDto } from "../dtos/timesheet-period.dto"; -import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-employee-timesheet.utils"; +import { getWeekEnd, getWeekStart } from "src/common/utils/date-utils"; +import { parseISODate, parseHHmm } from "../utils-helpers-others/timesheet.helpers"; +import { TimesheetsQueryService } from "./timesheets-query.service"; +import { BaseApprovalService } from "src/common/shared/base-approval.service"; +import { Prisma, Timesheets } from "@prisma/client"; +import { CreateTimesheetDto } from "../dtos/create-timesheet.dto"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; +import { PrismaService } from "src/prisma/prisma.service"; +import { TimesheetMap } from "../utils-helpers-others/timesheet.types"; @Injectable() export class TimesheetsCommandService extends BaseApprovalService{ @@ -58,7 +58,7 @@ export class TimesheetsCommandService extends BaseApprovalService{ email:string, shifts: CreateTimesheetDto[], week_offset = 0, - ): Promise { + ): Promise { //fetchs employee matchint user's email const employee_id = await this.emailResolver.findIdByEmail(email); if(!employee_id) throw new NotFoundException(`employee for ${ email } not found`); diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 58dfd72..67149ef 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -1,14 +1,13 @@ -import { endOfDayUTC, toHHmm, toNum, toRangeFromPeriod, toUTCDateOnly } from '../timesheet.helpers'; -import { Injectable, NotFoundException } from '@nestjs/common'; -import { formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils'; -import { PrismaService } from 'src/prisma/prisma.service'; -import { TimesheetDto, TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; -import { ShiftRow, ExpenseRow } from '../timesheet.types'; -import { buildPeriod } from '../timesheet.utils'; +import { makeEmptyTimesheet, mapExpenseRow, mapShiftRow } from '../utils-helpers-others/timesheet.mappers'; +import { buildPeriod, computeWeekRange } from '../utils-helpers-others/timesheet.utils'; +import { TimesheetSelectorsService } from '../utils-helpers-others/timesheet.selectors'; +import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; +import { toRangeFromPeriod } from '../utils-helpers-others/timesheet.helpers'; import { EmailToIdResolver } from 'src/modules/shared/utils/resolve-email-id.utils'; import { FullNameResolver } from 'src/modules/shared/utils/resolve-full-name.utils'; -import { TimesheetSelectors } from '../timesheet.selectors'; -import { mapExpenseRow, mapShiftRow } from '../timesheet.mappers'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { TimesheetMap } from '../utils-helpers-others/timesheet.types'; +import { Injectable } from '@nestjs/common'; @Injectable() @@ -17,150 +16,39 @@ export class TimesheetsQueryService { private readonly prisma: PrismaService, private readonly emailResolver: EmailToIdResolver, private readonly fullNameResolver: FullNameResolver, - private readonly selectors: TimesheetSelectors, + private readonly selectors: TimesheetSelectorsService, ) {} async findAll(year: number, period_no: number, email: string): Promise { - //finds the employee using email - const employee_id = await this.emailResolver.findIdByEmail(email); - - //finds the employee full name using employee_id - const full_name = await this.fullNameResolver.resolveFullName(employee_id); - - //finds the pay period using year and period_no - const period = await this.selectors.getPayPeriod(year, period_no); - - //finds start and end dates - const{ from, to } = toRangeFromPeriod(period); - + const employee_id = await this.emailResolver.findIdByEmail(email); //finds the employee using email + const full_name = await this.fullNameResolver.resolveFullName(employee_id); //finds the employee full name using employee_id + const period = await this.selectors.getPayPeriod(year, period_no);//finds the pay period using year and period_no + const{ from, to } = toRangeFromPeriod(period); //finds start and end dates //finds all shifts from selected period const [raw_shifts, raw_expenses] = await Promise.all([ this.selectors.getShifts(employee_id, from, to), this.selectors.getExpenses(employee_id, from, to), - ]); - + ]); // data mapping - const shifts = raw_shifts.map(mapShiftRow); + const shifts = raw_shifts.map(mapShiftRow); const expenses = raw_expenses.map(mapExpenseRow); return buildPeriod(period.period_start, period.period_end, shifts , expenses, full_name); } - async getTimesheetByEmail(email: string, week_offset = 0): Promise { - const employee_id = await this.emailResolver.findIdByEmail(email); - if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`); - //sets current week Sunday -> Saturday - const base = new Date(); - const offset = new Date(base); - offset.setDate(offset.getDate() + (week_offset * 7)); - const start_date_week = getWeekStart(offset, 0); - const end_date_week = getWeekEnd(start_date_week); - const start_day = formatDateISO(start_date_week); - const end_day = formatDateISO(end_date_week); - - //build the label MM/DD/YYYY.MM/DD.YYYY - const mm_dd = (date: Date) => `${String(date.getFullYear())}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2,'0')}`; - const label = `${mm_dd(start_date_week)}.${mm_dd(end_date_week)}`; - - //fetch timesheet shifts and expenses - const timesheet = await this.prisma.timesheets.findUnique({ - where: { - employee_id_start_date: { - employee_id: employee_id, - start_date: start_date_week, - }, - }, - include: { - shift: { - include: { bank_code: true }, - orderBy: [{ date: 'asc'}, { start_time: 'asc'}], - }, - expense: { - include: { bank_code: true }, - orderBy: [{date: 'asc'}], - }, - }, - }); - - //returns an empty timesheet if not found - if(!timesheet) { - return { - is_approved: false, - start_day, - end_day, - label, - shifts:[], - expenses: [], - } as TimesheetDto; - } + async getTimesheetByEmail(email: string, week_offset = 0): Promise { + const employee_id = await this.emailResolver.findIdByEmail(email); //finds the employee using email + const { start, start_day, end_day, label } = computeWeekRange(week_offset); + const timesheet = await this.selectors.getTimesheetWithShiftsAndExpenses(employee_id, start); //fetch timesheet shifts and expenses + if(!timesheet) return makeEmptyTimesheet({ start_day, end_day, label}); //maps all shifts of selected timesheet - const shifts = timesheet.shift.map((shift_row) => ({ - type: shift_row.bank_code?.type ?? '', - date: formatDateISO(shift_row.date), - start_time: toHHmm(shift_row.start_time), - end_time: toHHmm(shift_row.end_time), - comment: shift_row.comment ?? '', - is_approved: shift_row.is_approved ?? false, - is_remote: shift_row.is_remote ?? false, - })); + const shifts = timesheet.shift.map(mapShiftRow); + const expenses = timesheet.expense.map(mapExpenseRow); - //maps all expenses of selected timsheet - const expenses = timesheet.expense.map((exp) => ({ - type: exp.bank_code?.type ?? '', - date: formatDateISO(exp.date), - amount: Number(exp.amount) || 0, - mileage: exp.mileage != null ? Number(exp.mileage) : 0, - comment: exp.comment ?? '', - is_approved: exp.is_approved ?? false, - supervisor_comment: exp.supervisor_comment ?? '', - })); - return { - start_day, - end_day, - label, - shifts, - expenses, - is_approved: timesheet.is_approved, - } as TimesheetDto; - } - //_____________________________________________________________________________________________ - // Deprecated or unused methods - //_____________________________________________________________________________________________ - - // async findOne(id: number): Promise { - // const timesheet = await this.prisma.timesheets.findUnique({ - // where: { id }, - // include: { - // shift: { include: { bank_code: true } }, - // expense: { include: { bank_code: true } }, - // employee: { include: { user: true } }, - // }, - // }); - // if(!timesheet) { - // throw new NotFoundException(`Timesheet #${id} not found`); - // } - - // const detailedShifts = timesheet.shift.map( s => { - // const hours = computeHours(s.start_time, s.end_time); - // const regularHours = Math.min(8, hours); - // const dailyOvertime = this.overtime.getDailyOvertimeHours(s.start_time, s.end_time); - // const payRegular = regularHours * s.bank_code.modifier; - // const payOvertime = this.overtime.calculateOvertimePay(dailyOvertime, s.bank_code.modifier); - // return { ...s, hours, payRegular, payOvertime }; - // }); - // const weeklyOvertimeHours = detailedShifts.length - // ? await this.overtime.getWeeklyOvertimeHours( - // timesheet.employee_id, - // timesheet.shift[0].date): 0; - // return { ...timesheet, shift: detailedShifts, weeklyOvertimeHours }; - // } - - // async remove(id: number): Promise { - // await this.findOne(id); - // return this.prisma.timesheets.delete({ where: { id } }); - // } + return { start_day, end_day, label, shifts, expenses, is_approved: timesheet.is_approved}; + } } diff --git a/src/modules/timesheets/timesheets.module.ts b/src/modules/timesheets/timesheets.module.ts index 7824aa4..bbffd71 100644 --- a/src/modules/timesheets/timesheets.module.ts +++ b/src/modules/timesheets/timesheets.module.ts @@ -7,11 +7,12 @@ import { ExpensesCommandService } from '../expenses/services/expenses-command. import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module'; import { SharedModule } from '../shared/shared.module'; import { Module } from '@nestjs/common'; +import { TimesheetSelectorsService } from './utils-helpers-others/timesheet.selectors'; @Module({ imports: [ BusinessLogicsModule, - SharedModule + SharedModule, ], controllers: [TimesheetsController], providers: [ @@ -19,7 +20,8 @@ import { Module } from '@nestjs/common'; TimesheetsCommandService, ShiftsCommandService, ExpensesCommandService, - TimesheetArchiveService, + TimesheetArchiveService, + TimesheetSelectorsService, ], exports: [ TimesheetsQueryService, diff --git a/src/modules/timesheets/timesheet.helpers.ts b/src/modules/timesheets/utils-helpers-others/timesheet.helpers.ts similarity index 94% rename from src/modules/timesheets/timesheet.helpers.ts rename to src/modules/timesheets/utils-helpers-others/timesheet.helpers.ts index 249b3a0..9e0ca82 100644 --- a/src/modules/timesheets/timesheet.helpers.ts +++ b/src/modules/timesheets/utils-helpers-others/timesheet.helpers.ts @@ -1,4 +1,5 @@ -import { MS_PER_DAY, DayKey, DAY_KEYS } from "./timesheet.types"; +import { MS_PER_DAY } from "src/modules/shared/constants/date-time.constant"; +import { DAY_KEYS, DayKey } from "././timesheet.types"; export function toUTCDateOnly(date: Date | string): Date { const d = new Date(date); diff --git a/src/modules/timesheets/timesheet.mappers.ts b/src/modules/timesheets/utils-helpers-others/timesheet.mappers.ts similarity index 85% rename from src/modules/timesheets/timesheet.mappers.ts rename to src/modules/timesheets/utils-helpers-others/timesheet.mappers.ts index 5807863..6a0b928 100644 --- a/src/modules/timesheets/timesheet.mappers.ts +++ b/src/modules/timesheets/utils-helpers-others/timesheet.mappers.ts @@ -1,5 +1,5 @@ -import { DayExpensesDto, WeekDto, DetailedShifts, TimesheetPeriodDto } from "./dtos/timesheet-period.dto"; -import { ExpenseRow, ExpensesAmount, ShiftRow } from "./timesheet.types"; +import { DayExpensesDto, WeekDto, DetailedShifts, TimesheetPeriodDto } from "../dtos/timesheet-period.dto"; +import { ShiftRow, ExpenseRow, ExpensesAmount, TimesheetMap } from "./timesheet.types"; import { addDays, shortDate, toNum, upper } from "./timesheet.helpers"; import { Prisma } from "@prisma/client"; @@ -41,7 +41,6 @@ export const mapExpenseRow = (expense: { type: upper(expense.bank_code.type), }); - // Factories export function makeEmptyDayExpenses(): DayExpensesDto { return { @@ -92,4 +91,21 @@ export function makeEmptyPeriod(): TimesheetPeriodDto { export const makeAmounts = (): ExpensesAmount => ({ expense: 0, mileage: 0, -}); \ No newline at end of file +}); + +export function makeEmptyTimesheet(params: { + start_day: string; + end_day: string; + label: string; + is_approved?: boolean; +}): TimesheetMap { + const { start_day, end_day, label, is_approved = false } = params; + return { + start_day, + end_day, + label, + shifts: [], + expenses: [], + is_approved, + }; +} \ No newline at end of file diff --git a/src/modules/timesheets/timesheet.selectors.ts b/src/modules/timesheets/utils-helpers-others/timesheet.selectors.ts similarity index 57% rename from src/modules/timesheets/timesheet.selectors.ts rename to src/modules/timesheets/utils-helpers-others/timesheet.selectors.ts index 1c309d4..ec082ad 100644 --- a/src/modules/timesheets/timesheet.selectors.ts +++ b/src/modules/timesheets/utils-helpers-others/timesheet.selectors.ts @@ -1,11 +1,11 @@ +import { EXPENSE_ASC_ORDER, EXPENSE_SELECT } from "../../shared/selects/expenses.select"; import { Injectable, NotFoundException } from "@nestjs/common"; +import { SHIFT_ASC_ORDER, SHIFT_SELECT } from "../../shared/selects/shifts.select"; +import { PAY_PERIOD_SELECT } from "../../shared/selects/pay-periods.select"; import { PrismaService } from "src/prisma/prisma.service"; -import { SHIFT_ASC_ORDER, SHIFT_SELECT } from "../shared/selects/shifts.select"; -import { PAY_PERIOD_SELECT } from "../shared/selects/pay-periods.select"; -import { EXPENSE_ASC_ORDER, EXPENSE_SELECT } from "../shared/selects/expenses.select"; @Injectable() -export class TimesheetSelectors { +export class TimesheetSelectorsService { constructor(readonly prisma: PrismaService){} async getPayPeriod(pay_year: number, pay_period_no: number) { @@ -20,7 +20,7 @@ export class TimesheetSelectors { async getShifts(employee_id: number, from: Date, to: Date) { return this.prisma.shifts.findMany({ where: {timesheet: { is: { employee_id } }, date: { gte: from, lte: to } }, - select: SHIFT_SELECT, + select: SHIFT_SELECT, orderBy: SHIFT_ASC_ORDER, }); } @@ -32,4 +32,15 @@ export class TimesheetSelectors { orderBy: EXPENSE_ASC_ORDER, }); } + + async getTimesheetWithShiftsAndExpenses(employee_id: number, start_date_week: Date) { + return this.prisma.timesheets.findUnique({ + where: { employee_id_start_date: { employee_id, start_date: start_date_week } }, + select: { + is_approved: true, + shift: { select: SHIFT_SELECT, orderBy: SHIFT_ASC_ORDER }, + expense: { select: EXPENSE_SELECT, orderBy: EXPENSE_ASC_ORDER }, + }, + }); + } } \ No newline at end of file diff --git a/src/modules/timesheets/timesheet.types.ts b/src/modules/timesheets/utils-helpers-others/timesheet.types.ts similarity index 89% rename from src/modules/timesheets/timesheet.types.ts rename to src/modules/timesheets/utils-helpers-others/timesheet.types.ts index e33f86b..6fcc951 100644 --- a/src/modules/timesheets/timesheet.types.ts +++ b/src/modules/timesheets/utils-helpers-others/timesheet.types.ts @@ -17,9 +17,14 @@ export type ExpenseRow = { supervisor_comment: string; }; -//Date & Format -export const MS_PER_DAY = 86_400_000; -export const MS_PER_HOUR = 3_600_000; +export type TimesheetMap = { + start_day: string; + end_day: string; + label: string; + shifts: ShiftRow[]; + expenses: ExpenseRow[] + is_approved: boolean; +} // Types export const SHIFT_TYPES = { diff --git a/src/modules/timesheets/timesheet.utils.ts b/src/modules/timesheets/utils-helpers-others/timesheet.utils.ts similarity index 87% rename from src/modules/timesheets/timesheet.utils.ts rename to src/modules/timesheets/utils-helpers-others/timesheet.utils.ts index 3d762c1..d6e691f 100644 --- a/src/modules/timesheets/timesheet.utils.ts +++ b/src/modules/timesheets/utils-helpers-others/timesheet.utils.ts @@ -1,14 +1,31 @@ import { - DayKey, DAY_KEYS, EXPENSE_TYPES, ExpenseRow, MS_PER_HOUR, + DayKey, DAY_KEYS, EXPENSE_TYPES, ExpenseRow, SHIFT_TYPES, ShiftRow, make_hours, ShiftsHours, ExpensesAmount } from "./timesheet.types"; import { isBetweenUTC, dayKeyFromDate, toTimeString, round2, toUTCDateOnly, endOfDayUTC, addDays } from "./timesheet.helpers"; -import { WeekDto, ShiftDto, TimesheetPeriodDto, DayExpensesDto, ExpenseDto } from "./dtos/timesheet-period.dto"; +import { WeekDto, ShiftDto, TimesheetPeriodDto, DayExpensesDto, ExpenseDto } from "../dtos/timesheet-period.dto"; +import { getWeekStart, getWeekEnd, formatDateISO } from "src/common/utils/date-utils"; import { makeAmounts, makeEmptyWeek } from "./timesheet.mappers"; import { toDateString } from "src/modules/pay-periods/utils/pay-year.util"; +import { MS_PER_HOUR } from "src/modules/shared/constants/date-time.constant"; + + export function computeWeekRange(week_offset = 0){ + //sets current week Sunday -> Saturday + const base = new Date(); + const offset = new Date(base); + offset.setDate(offset.getDate() + (week_offset * 7)); + + const start = getWeekStart(offset, 0); + const end = getWeekEnd(start); + const start_day = formatDateISO(start); + const end_day = formatDateISO(end); + const label = `${(start_day)}.${(end_day)}`; + + return { start, end, start_day, end_day, label } + }; export function buildWeek( week_start: Date, From 4ff512a207d522a3cd6573d28292179284c144b9 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 10 Oct 2025 16:44:44 -0400 Subject: [PATCH 03/17] refactor(shifts): refactored shiftCommandService and moved helpers to shifthelpersService --- .../utils/leave-request.util.ts | 7 +- src/modules/pay-periods/pay-periods.module.ts | 2 + .../shifts/controllers/shifts.controller.ts | 56 +-- src/modules/shifts/helpers/shifts.helpers.ts | 136 +++++++ .../shifts/services/shifts-command.service.ts | 379 ++++++++---------- src/modules/shifts/shifts.module.ts | 6 +- .../shifts-upsert.types.ts | 2 - src/modules/timesheets/timesheets.module.ts | 4 +- 8 files changed, 324 insertions(+), 268 deletions(-) create mode 100644 src/modules/shifts/helpers/shifts.helpers.ts diff --git a/src/modules/leave-requests/utils/leave-request.util.ts b/src/modules/leave-requests/utils/leave-request.util.ts index 11e0c9b..53b1ba4 100644 --- a/src/modules/leave-requests/utils/leave-request.util.ts +++ b/src/modules/leave-requests/utils/leave-request.util.ts @@ -3,6 +3,7 @@ import { BadRequestException, Injectable } from "@nestjs/common"; import { ShiftsCommandService } from "src/modules/shifts/services/shifts-command.service"; import { PrismaService } from "src/prisma/prisma.service"; import { LeaveTypes } from "@prisma/client"; +import { UpsertAction } from "src/modules/shared/types/upsert-actions.types"; @Injectable() export class LeaveRequestsUtils { @@ -44,7 +45,9 @@ export class LeaveRequestsUtils { include: { bank_code: true }, }); - await this.shiftsCommand.upsertShiftsByDate(email, { + const action: UpsertAction = existing ? 'update' : 'create'; + + await this.shiftsCommand.upsertShifts(email, action, { old_shift: existing ? { date: yyyy_mm_dd, @@ -86,7 +89,7 @@ export class LeaveRequestsUtils { }); if (!existing) return; - await this.shiftsCommand.upsertShiftsByDate(email, { + await this.shiftsCommand.upsertShifts(email, 'delete', { old_shift: { date: yyyy_mm_dd, start_time: hhmmFromLocal(existing.start_time), diff --git a/src/modules/pay-periods/pay-periods.module.ts b/src/modules/pay-periods/pay-periods.module.ts index c5606db..f85d416 100644 --- a/src/modules/pay-periods/pay-periods.module.ts +++ b/src/modules/pay-periods/pay-periods.module.ts @@ -10,6 +10,7 @@ import { ShiftsCommandService } from "../shifts/services/shifts-command.service" import { SharedModule } from "../shared/shared.module"; import { PrismaService } from "src/prisma/prisma.service"; import { BusinessLogicsModule } from "../business-logics/business-logics.module"; +import { ShiftsHelpersService } from "../shifts/helpers/shifts.helpers"; @Module({ imports: [PrismaModule, TimesheetsModule, SharedModule, BusinessLogicsModule], @@ -20,6 +21,7 @@ import { BusinessLogicsModule } from "../business-logics/business-logics.module" ExpensesCommandService, ShiftsCommandService, PrismaService, + ShiftsHelpersService, ], controllers: [PayPeriodsController], exports: [ diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index 45545dd..ba6dead 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -7,6 +7,7 @@ import { ShiftsQueryService } from "../services/shifts-query.service"; import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto"; import { UpsertShiftDto } from "../dtos/upsert-shift.dto"; import { OverviewRow } from "../types-and-interfaces/shifts-overview-row.interface"; +import { UpsertAction } from "src/modules/shared/types/upsert-actions.types"; @ApiTags('Shifts') @ApiBearerAuth('access-token') @@ -21,9 +22,9 @@ export class ShiftsController { @Put('upsert/:email') async upsert_by_date( @Param('email') email_param: string, - @Body() payload: UpsertShiftDto, + @Body() payload: UpsertShiftDto, action: UpsertAction, ) { - return this.shiftsCommandService.upsertShiftsByDate(email_param, payload); + return this.shiftsCommandService.upsertShifts(email_param, action, payload); } @Patch('approval/:id') @@ -72,55 +73,4 @@ export class ShiftsController { return Buffer.from('\uFEFF' + header + body, 'utf8'); } - - //_____________________________________________________________________________________________ - // Deprecated or unused methods - //_____________________________________________________________________________________________ - - // @Post() - // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - // @ApiOperation({ summary: 'Create shift' }) - // @ApiResponse({ status: 201, description: 'Shift created',type: CreateShiftDto }) - // @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) - // create(@Body() dto: CreateShiftDto): Promise { - // return this.shiftsService.create(dto); - // } - - // @Get() - // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - // @ApiOperation({ summary: 'Find all shifts' }) - // @ApiResponse({ status: 201, description: 'List of shifts found',type: CreateShiftDto, isArray: true }) - // @ApiResponse({ status: 400, description: 'List of shifts not found' }) - // @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) - // findAll(@Query() filters: SearchShiftsDto) { - // return this.shiftsService.findAll(filters); - // } - - // @Get(':id') - // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - // @ApiOperation({ summary: 'Find shift' }) - // @ApiResponse({ status: 201, description: 'Shift found',type: CreateShiftDto }) - // @ApiResponse({ status: 400, description: 'Shift not found' }) - // findOne(@Param('id', ParseIntPipe) id: number): Promise { - // return this.shiftsService.findOne(id); - // } - - // @Patch(':id') - // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - // @ApiOperation({ summary: 'Update shift' }) - // @ApiResponse({ status: 201, description: 'Shift updated',type: CreateShiftDto }) - // @ApiResponse({ status: 400, description: 'Shift not found' }) - // update(@Param('id', ParseIntPipe) id: number,@Body() dto: UpdateShiftsDto): Promise { - // return this.shiftsService.update(id, dto); - // } - - // @Delete(':id') - // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - // @ApiOperation({ summary: 'Delete shift' }) - // @ApiResponse({ status: 201, description: 'Shift deleted',type: CreateShiftDto }) - // @ApiResponse({ status: 400, description: 'Shift not found' }) - // remove(@Param('id', ParseIntPipe) id: number): Promise { - // return this.shiftsService.remove(id); - // } - } \ No newline at end of file diff --git a/src/modules/shifts/helpers/shifts.helpers.ts b/src/modules/shifts/helpers/shifts.helpers.ts new file mode 100644 index 0000000..bfab937 --- /dev/null +++ b/src/modules/shifts/helpers/shifts.helpers.ts @@ -0,0 +1,136 @@ +import { BadRequestException, UnprocessableEntityException, NotFoundException, ConflictException } from "@nestjs/common"; +import { Prisma, Shifts } from "@prisma/client"; +import { UpsertShiftDto } from "../dtos/upsert-shift.dto"; +import { DayShiftResponse } from "../types-and-interfaces/shifts-upsert.types"; +import { normalizeShiftPayload, overlaps } from "../utils/shifts.utils"; +import { weekStartSunday, formatHHmm } from "./shifts-date-time-helpers"; +import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; +import { OvertimeService } from "src/modules/business-logics/services/overtime.service"; +import { UpsertAction } from "src/modules/shared/types/upsert-actions.types"; + + +export type Tx = Prisma.TransactionClient; +export type Normalized = Awaited>; + +export class ShiftsHelpersService { + + constructor( + private readonly bankTypeResolver: BankCodesResolver, + private readonly overtimeService: OvertimeService, + ) { } + + async ensureTimesheet(tx: Tx, employee_id: number, date_only: Date) { + const start_of_week = weekStartSunday(date_only); + return tx.timesheets.upsert({ + where: { employee_id_start_date: { employee_id, start_date: start_of_week } }, + update: {}, + create: { employee_id, start_date: start_of_week }, + select: { id: true }, + }); + } + async normalizeRequired( + raw: UpsertShiftDto['new_shift'] | UpsertShiftDto['old_shift'] | undefined | null, + label: 'old_shift' | 'new_shift' = 'new_shift', + ): Promise { + if (!raw) throw new BadRequestException(`${label} is required`); + const norm = await normalizeShiftPayload(raw); + if (norm.end_time.getTime() <= norm.start_time.getTime()) { + throw new UnprocessableEntityException(` ${label}.end_time must be > ${label}.start_time`); + } + return norm; + } + + async resolveBankIdRequired(tx: Tx, type: string, label: 'old_shift' | 'new_shift'): Promise { + const found = await this.bankTypeResolver.findByType(type, tx); + const id = found?.id; + if (typeof id !== 'number') { + throw new NotFoundException(`bank code not found for ${label}.type: ${type ?? ''}`); + } + return id; + } + + async getDayShifts(tx: Tx, timesheet_id: number, dateIso: string) { + return tx.shifts.findMany({ + where: { timesheet_id, date: dateIso }, + include: { bank_code: true }, + orderBy: { start_time: 'asc' }, + }); + } + + async assertNoOverlap( + day_shifts: Array, + new_norm: Normalized | undefined, + exclude_id?: number, + ) { + if (!new_norm) return; + const conflicts = day_shifts.filter((s) => { + if (exclude_id && s.id === exclude_id) return false; + return overlaps( + new_norm.start_time.getTime(), + new_norm.end_time.getTime(), + s.start_time.getTime(), + s.end_time.getTime(), + ); + }); + if (conflicts.length) { + const payload = conflicts.map((s) => ({ + start_time: formatHHmm(s.start_time), + end_time: formatHHmm(s.end_time), + type: s.bank_code?.type ?? 'UNKNOWN', + })); + throw new ConflictException({ + error_code: 'SHIFT_OVERLAP', + message: 'New shift overlaps with existing shift(s)', + conflicts: payload, + }); + } + } + + + async findExactOldShift( + tx: Tx, + params: { + timesheet_id: number; + date_only: Date; + norm: Normalized; + bank_code_id: number; + }, + ) { + const { timesheet_id, date_only, norm, bank_code_id } = params; + return tx.shifts.findFirst({ + where: { + timesheet_id, + date: date_only, + start_time: norm.start_time, + end_time: norm.end_time, + is_remote: norm.is_remote, + is_approved: norm.is_approved, + comment: norm.comment ?? null, + bank_code_id, + }, + select: { id: true }, + }); + } + + async afterWriteOvertimeAndLog(tx: Tx, employee_id: number, date_only: Date, action: UpsertAction) { + // Switch regular → weekly overtime si > 40h + await this.overtimeService.transformRegularHoursToWeeklyOvertime(employee_id, date_only, tx); + const [daily, weekly] = await Promise.all([ + this.overtimeService.getDailyOvertimeHoursForDay(employee_id, date_only), + this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only), + ]); + } + + async mapDay( + fresh: Array, + ): Promise { + return fresh.map((s) => ({ + start_time: formatHHmm(s.start_time), + end_time: formatHHmm(s.end_time), + type: s.bank_code?.type ?? 'UNKNOWN', + is_remote: s.is_remote, + comment: s.comment ?? null, + })); + } +} + diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index 85e79a1..fff2d91 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -1,233 +1,196 @@ -import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, UnprocessableEntityException } from "@nestjs/common"; -import { normalizeShiftPayload, overlaps } from "../utils/shifts.utils"; -import { DayShiftResponse, UpsertAction } from "../types-and-interfaces/shifts-upsert.types"; -import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; -import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; -import { Prisma, Shifts } from "@prisma/client"; -import { UpsertShiftDto } from "../dtos/upsert-shift.dto"; +import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { normalizeShiftPayload } from "../utils/shifts.utils"; +import { DayShiftResponse } from "../types-and-interfaces/shifts-upsert.types"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { Prisma, Shifts } from "@prisma/client"; +import { UpsertShiftDto } from "../dtos/upsert-shift.dto"; import { BaseApprovalService } from "src/common/shared/base-approval.service"; -import { PrismaService } from "src/prisma/prisma.service"; -import { OvertimeService } from "src/modules/business-logics/services/overtime.service"; -import { formatHHmm, toDateOnly, weekStartSunday } from "../helpers/shifts-date-time-helpers"; +import { PrismaService } from "src/prisma/prisma.service"; +import { toDateOnly } from "../helpers/shifts-date-time-helpers"; +import { UpsertAction } from "src/modules/shared/types/upsert-actions.types"; +import { ShiftsHelpersService } from "../helpers/shifts.helpers"; @Injectable() export class ShiftsCommandService extends BaseApprovalService { - private readonly logger = new Logger(ShiftsCommandService.name); + private readonly logger = new Logger(ShiftsCommandService.name); - constructor( - prisma: PrismaService, - private readonly emailResolver: EmailToIdResolver, - private readonly bankTypeResolver: BankCodesResolver, - private readonly overtimeService: OvertimeService, - ) { super(prisma); } + constructor( + prisma: PrismaService, + private readonly emailResolver: EmailToIdResolver, + private readonly helpersService: ShiftsHelpersService, + ) { super(prisma); } -//_____________________________________________________________________________________________ -// APPROVAL AND DELEGATE METHODS -//_____________________________________________________________________________________________ - protected get delegate() { - return this.prisma.shifts; - } + //_____________________________________________________________________________________________ + // APPROVAL AND DELEGATE METHODS + //_____________________________________________________________________________________________ + protected get delegate() { + return this.prisma.shifts; + } - protected delegateFor(transaction: Prisma.TransactionClient) { - return transaction.shifts; - } + protected delegateFor(transaction: Prisma.TransactionClient) { + return transaction.shifts; + } - async updateApproval(id: number, is_approved: boolean): Promise { - return this.prisma.$transaction((transaction) => - this.updateApprovalWithTransaction(transaction, id, is_approved), - ); - } + async updateApproval(id: number, is_approved: boolean): Promise { + return this.prisma.$transaction((transaction) => + this.updateApprovalWithTransaction(transaction, id, is_approved), + ); + } -//_____________________________________________________________________________________________ -// MASTER CRUD METHOD -//_____________________________________________________________________________________________ - async upsertShiftsByDate(email:string, dto: UpsertShiftDto): - Promise<{ action: UpsertAction; day: DayShiftResponse[] }> { - const { old_shift, new_shift } = dto; + //_____________________________________________________________________________________________ + // MASTER CRUD METHOD + //_____________________________________________________________________________________________ + async upsertShifts( + email: string, + action: UpsertAction, + dto: UpsertShiftDto, + ): Promise<{ + action: UpsertAction; + day: DayShiftResponse[]; + }> { + if (!dto.old_shift && !dto.new_shift) throw new BadRequestException('At least one of old or new shift must be provided'); - if(!dto.old_shift && !dto.new_shift) { - throw new BadRequestException('At least one of old or new shift must be provided'); - } + const date = dto.new_shift?.date ?? dto.old_shift?.date; + if (!date) throw new BadRequestException("A date (YYYY-MM-DD) must be provided in old_shift or new_shift"); + if (dto.old_shift?.date && dto.new_shift?.date && dto.old_shift.date !== dto.new_shift.date) { + throw new BadRequestException('old_shift.date and new_shift.date must be identical'); + } - const date = new_shift?.date ?? old_shift?.date; - if (!date) throw new BadRequestException("A date (YYYY-MM-DD) must be provided in old_shift or new_shift"); - if (old_shift?.date - && new_shift?.date - && old_shift.date - !== new_shift.date) throw new BadRequestException("old_shift.date and new_shift.date must be identical"); + const employee_id = await this.emailResolver.findIdByEmail(email);//resolve employee id using email - const date_only = toDateOnly(date); - const employee_id = await this.emailResolver.findIdByEmail(email); + if(action === 'create') { + if(!dto.new_shift || dto.old_shift) { + throw new BadRequestException(`Only new_shift must be provided for create`); + } + return this.createShift(employee_id, date, dto); + } + if(action === 'update'){ + if(!dto.old_shift || !dto.new_shift) { + throw new BadRequestException(`Both new_shift and old_shift must be provided for update`); + } + return this.updateShift(employee_id, date, dto); + } + if(action === 'delete'){ + if(!dto.old_shift || dto.new_shift) { + throw new BadRequestException('Only old_shift must be provided for delete'); + } + return this.deleteShift(employee_id, date, dto); + } + throw new BadRequestException(`Unknown action: ${action}`); + } - return this.prisma.$transaction(async (tx) => { - const start_of_week = weekStartSunday(date_only); + //_________________________________________________________________ + // CREATE + //_________________________________________________________________ + private async createShift( + employee_id: number, + date_iso: string, + dto: UpsertShiftDto, + ): Promise<{action: UpsertAction; day: DayShiftResponse[]}> { + return this.prisma.$transaction(async (tx) => { + const date_only = toDateOnly(date_iso); + const { id: timesheet_id } = await this.helpersService.ensureTimesheet(tx, employee_id, date_only); - const timesheet = await tx.timesheets.upsert({ - where: { employee_id_start_date: { employee_id, start_date: start_of_week } }, - update: {}, - create: { employee_id, start_date: start_of_week }, - select: { id: true }, - }); + const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift); + const new_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, new_norm_shift.type, 'new_shift'); - //validation/sanitation - //resolve bank_code_id using type - const old_norm_shift = old_shift ? await normalizeShiftPayload(old_shift) : undefined; - if (old_norm_shift && old_norm_shift.end_time.getTime() <= old_norm_shift.start_time.getTime()) { - throw new UnprocessableEntityException(' old_shift.end_time must be > old_shift.start_time'); - } - const old_bank_code_id: number | undefined = old_norm_shift ? (await this.bankTypeResolver.findByType(old_norm_shift.type, tx))?.id : undefined; - + const day_shifts = await this.helpersService.getDayShifts(tx, timesheet_id, date_iso); - const new_norm_shift = new_shift ? await normalizeShiftPayload(new_shift) : undefined; - if (new_norm_shift && new_norm_shift.end_time.getTime() <= new_norm_shift.start_time.getTime()) { - throw new UnprocessableEntityException(' new_shift.end_time must be > new_shift.start_time'); - } - const new_bank_code_id: number | undefined = new_norm_shift ? (await this.bankTypeResolver.findByType(new_norm_shift.type, tx))?.id : undefined; + await this.helpersService.assertNoOverlap(day_shifts, new_norm_shift); + await tx.shifts.create({ + data: { + timesheet_id, + date: date_only, + start_time: new_norm_shift.start_time, + end_time: new_norm_shift.end_time, + is_remote: new_norm_shift.is_remote, + is_approved: new_norm_shift.is_approved, + comment: new_norm_shift.comment ?? '', + bank_code_id: new_bank_code_id, + }, + }); + await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only,'create'); + const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet_id, date_iso); + return { action: 'create', day: await this.helpersService.mapDay(fresh_shift)}; + }); + } - //fetch all shifts in a single day and verify possible overlaps - const day_shifts = await tx.shifts.findMany({ - where: { timesheet_id: timesheet.id, date: date_only }, - include: { bank_code: true }, - orderBy: { start_time: 'asc'}, - }); + //_________________________________________________________________ + // UPDATE + //_________________________________________________________________ + private async updateShift( + employee_id: number, + date_iso: string, + dto: UpsertShiftDto, + ): Promise<{ action: UpsertAction; day: DayShiftResponse[];}>{ + return this.prisma.$transaction(async (tx) => { + const date_only = toDateOnly(date_iso); + const { id: timesheet_id } = await this.helpersService.ensureTimesheet(tx, employee_id, date_only); - - const findExactOldShift = async ()=> { - if(!old_norm_shift || old_bank_code_id === undefined) return undefined; - const old_comment = old_norm_shift.comment ?? null; + const old_norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift'); + const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift, 'new_shift'); - return await tx.shifts.findFirst({ - where: { - timesheet_id: timesheet.id, - date: date_only, - start_time: old_norm_shift.start_time, - end_time: old_norm_shift.end_time, - is_remote: old_norm_shift.is_remote, - is_approved: old_norm_shift.is_approved, - comment: old_comment, - bank_code_id: old_bank_code_id, - }, - select: { id: true }, - }); - }; - - //checks for overlaping shifts - const assertNoOverlap = (exclude_shift_id?: number)=> { - if (!new_norm_shift) return; - const overlap_with = day_shifts.filter((shift)=> { - if(exclude_shift_id && shift.id === exclude_shift_id) return false; - return overlaps( - new_norm_shift.start_time.getTime(), - new_norm_shift.end_time.getTime(), - shift.start_time.getTime(), - shift.end_time.getTime(), - ); - }); - - if(overlap_with.length > 0) { - const conflicts = overlap_with.map((shift)=> ({ - start_time: formatHHmm(shift.start_time), - end_time: formatHHmm(shift.end_time), - type: shift.bank_code?.type ?? 'UNKNOWN', - })); - throw new ConflictException({ error_code: 'SHIFT_OVERLAP', message: 'New shift overlaps with existing shift(s)', conflicts}); - } - }; - let action: UpsertAction; - //_____________________________________________________________________________________________ - // DELETE - //_____________________________________________________________________________________________ - if ( old_shift && !new_shift ) { - if (old_bank_code_id === undefined) throw new NotFoundException(`bank code not found for old_shift.type: ${old_norm_shift?.type ?? ''}`); - const existing = await findExactOldShift(); - if(!existing) { - throw new NotFoundException({ - error_code: 'SHIFT_STALE', - message: 'The shift was modified or deleted by someone else', - }); - } - await tx.shifts.delete({ where: { id: existing.id } } ); - action = 'deleted'; - } - //_____________________________________________________________________________________________ - // CREATE - //_____________________________________________________________________________________________ - else if (!old_shift && new_shift) { - if (new_bank_code_id === undefined) throw new NotFoundException(`bank code not found for new_shift.type: ${new_norm_shift?.type ?? ''}`); - assertNoOverlap(); - await tx.shifts.create({ - data: { - timesheet_id: timesheet.id, - date: date_only, - start_time: new_norm_shift!.start_time, - end_time: new_norm_shift!.end_time, - is_remote: new_norm_shift!.is_remote, - comment: new_norm_shift!.comment ?? null, - bank_code_id: new_bank_code_id!, - }, - }); - action = 'created'; - } - //_____________________________________________________________________________________________ - // UPDATE - //_____________________________________________________________________________________________ - else if (old_shift && new_shift){ - if (old_bank_code_id === undefined) throw new NotFoundException(`bank code not found for old_shift.type: ${old_norm_shift?.type ?? ''}`); - if (new_bank_code_id === undefined) throw new NotFoundException(`bank code not found for new_shift.type: ${new_norm_shift?.type ?? ''}`); - const existing = await findExactOldShift(); - if(!existing) throw new NotFoundException({ error_code: 'SHIFT_STALE', message: 'The shift was modified or deleted by someone else'}); - assertNoOverlap(existing.id); + const old_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, old_norm_shift.type, 'old_shift'); + const new_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, new_norm_shift.type, 'new_shift'); - await tx.shifts.update({ - where: { - id: existing.id - }, - data: { - start_time: new_norm_shift!.start_time, - end_time: new_norm_shift!.end_time, - is_remote: new_norm_shift!.is_remote, - comment: new_norm_shift!.comment ?? null, - bank_code_id: new_bank_code_id, - }, - }); - action = 'updated'; - } else throw new BadRequestException('At least one of old_shift or new_shift must be provided'); + const day_shifts = await this.helpersService.getDayShifts(tx, timesheet_id, date_iso); + const existing = await this.helpersService.findExactOldShift(tx, { + timesheet_id, + date_only, + norm: old_norm_shift, + bank_code_id: old_bank_code_id, + }); + if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else'); - //switches regular hours to overtime hours when exceeds 40hrs per week. - await this.overtimeService.transformRegularHoursToWeeklyOvertime(employee_id, date_only, tx); + await this.helpersService.assertNoOverlap(day_shifts, new_norm_shift, existing.id); - //Reload the day (truth source) - const fresh_day = await tx.shifts.findMany({ - where: { - date: date_only, - timesheet_id: timesheet.id, - }, - include: { bank_code: true }, - orderBy: { start_time: 'asc' }, - }); + await tx.shifts.update({ + where: { id: existing.id }, + data: { + start_time: new_norm_shift.start_time, + end_time: new_norm_shift.end_time, + is_remote: new_norm_shift.is_remote, + comment: new_norm_shift.comment ?? '', + bank_code_id: new_bank_code_id, + }, + }); + await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only, 'update'); + const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet_id, date_iso); + return { action: 'update', day: await this.helpersService.mapDay(fresh_shift)}; + }); - try { - const [ daily_overtime, weekly_overtime ] = await Promise.all([ - this.overtimeService.getDailyOvertimeHoursForDay(employee_id, date_only), - this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only), - ]); - this.logger.debug(`[OVERTIME] employee_id= ${employee_id}, date=${date_only.toISOString().slice(0,10)} - | daily= ${daily_overtime.toFixed(2)}h, weekly: ${weekly_overtime.toFixed(2)}h, (action:${action})`); - } catch (error) { - this.logger.warn(`Failed to compute overtime after ${action} : ${(error as Error).message}`); - } + } + + //_________________________________________________________________ + // DELETE + //_________________________________________________________________ + private async deleteShift( + employee_id: number, + date_iso: string, + dto: UpsertShiftDto, + ): Promise<{ action: UpsertAction; day: DayShiftResponse[]; }>{ + return this.prisma.$transaction(async (tx) => { + const date_only = toDateOnly(date_iso); + const { id: timesheet_id } = await this.helpersService.ensureTimesheet(tx, employee_id, date_only); + + const old_norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift'); + const old_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, old_norm_shift.type, 'old_shift'); + + const existing = await this.helpersService.findExactOldShift(tx, { + timesheet_id, + date_only, + norm: old_norm_shift, + bank_code_id: old_bank_code_id, + }); + if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else'); + + await tx.shifts.delete({ where: { id: existing.id } }); + + await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only, 'delete'); + const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet_id, date_iso); + return { action: 'delete', day: await this.helpersService.mapDay(fresh_shift)}; + }); + } +} - return { - action, - day: fresh_day.map((shift) => ({ - start_time: formatHHmm(shift.start_time), - end_time: formatHHmm(shift.end_time), - type: shift.bank_code?.type ?? 'UNKNOWN', - is_remote: shift.is_remote, - comment: shift.comment ?? null, - })), - }; - }); - } -} \ No newline at end of file diff --git a/src/modules/shifts/shifts.module.ts b/src/modules/shifts/shifts.module.ts index 8d1346c..d6df6c8 100644 --- a/src/modules/shifts/shifts.module.ts +++ b/src/modules/shifts/shifts.module.ts @@ -6,18 +6,20 @@ import { NotificationsModule } from '../notifications/notifications.module'; import { ShiftsQueryService } from './services/shifts-query.service'; import { ShiftsArchivalService } from './services/shifts-archival.service'; import { SharedModule } from '../shared/shared.module'; +import { ShiftsHelpersService } from './helpers/shifts.helpers'; @Module({ imports: [ BusinessLogicsModule, NotificationsModule, - SharedModule + SharedModule, ], controllers: [ShiftsController], providers: [ ShiftsQueryService, ShiftsCommandService, - ShiftsArchivalService, + ShiftsArchivalService, + ShiftsHelpersService, ], exports: [ ShiftsQueryService, diff --git a/src/modules/shifts/types-and-interfaces/shifts-upsert.types.ts b/src/modules/shifts/types-and-interfaces/shifts-upsert.types.ts index 85e6212..99f140d 100644 --- a/src/modules/shifts/types-and-interfaces/shifts-upsert.types.ts +++ b/src/modules/shifts/types-and-interfaces/shifts-upsert.types.ts @@ -5,5 +5,3 @@ export type DayShiftResponse = { is_remote: boolean; comment: string | null; } - -export type UpsertAction = 'created' | 'updated' | 'deleted'; \ No newline at end of file diff --git a/src/modules/timesheets/timesheets.module.ts b/src/modules/timesheets/timesheets.module.ts index bbffd71..e51c30f 100644 --- a/src/modules/timesheets/timesheets.module.ts +++ b/src/modules/timesheets/timesheets.module.ts @@ -8,6 +8,7 @@ import { BusinessLogicsModule } from 'src/modules/business-logics/business-l import { SharedModule } from '../shared/shared.module'; import { Module } from '@nestjs/common'; import { TimesheetSelectorsService } from './utils-helpers-others/timesheet.selectors'; +import { ShiftsHelpersService } from '../shifts/helpers/shifts.helpers'; @Module({ imports: [ @@ -21,7 +22,8 @@ import { TimesheetSelectorsService } from './utils-helpers-others/timesheet.sele ShiftsCommandService, ExpensesCommandService, TimesheetArchiveService, - TimesheetSelectorsService, + TimesheetSelectorsService, + ShiftsHelpersService, ], exports: [ TimesheetsQueryService, From 2f0982c95263aa25f658a933e5596e2bfbb753f2 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 10 Oct 2025 17:01:05 -0400 Subject: [PATCH 04/17] refactor(shifts): modified shifts.utils to use ISO format instead of UTC --- .../shifts-upsert.types.ts | 10 +++ src/modules/shifts/utils/shifts.utils.ts | 77 ++++++++++++------- 2 files changed, 59 insertions(+), 28 deletions(-) diff --git a/src/modules/shifts/types-and-interfaces/shifts-upsert.types.ts b/src/modules/shifts/types-and-interfaces/shifts-upsert.types.ts index 99f140d..733ea72 100644 --- a/src/modules/shifts/types-and-interfaces/shifts-upsert.types.ts +++ b/src/modules/shifts/types-and-interfaces/shifts-upsert.types.ts @@ -5,3 +5,13 @@ export type DayShiftResponse = { is_remote: boolean; comment: string | null; } + +export type ShiftPayload = { + date: string; + start_time: string; + end_time: string; + type: string; + is_remote: boolean; + is_approved: boolean; + comment?: string | null; +} \ No newline at end of file diff --git a/src/modules/shifts/utils/shifts.utils.ts b/src/modules/shifts/utils/shifts.utils.ts index 43c569f..a0fb13e 100644 --- a/src/modules/shifts/utils/shifts.utils.ts +++ b/src/modules/shifts/utils/shifts.utils.ts @@ -3,37 +3,58 @@ import { ShiftPayloadDto } from "../dtos/upsert-shift.dto"; import { timeFromHHMM } from "../helpers/shifts-date-time-helpers"; export function overlaps( - a_start_ms: number, - a_end_ms: number, - b_start_ms: number, - b_end_ms: number, - ): boolean { - return a_start_ms < b_end_ms && b_start_ms < a_end_ms; + a_start_ms: number, + a_end_ms: number, + b_start_ms: number, + b_end_ms: number, +): boolean { + return a_start_ms < b_end_ms && b_start_ms < a_end_ms; } export function resolveBankCodeByType(type: string): Promise { - const bank = this.prisma.bankCodes.findFirst({ - where: { type }, - select: { id: true }, - }); - if (!bank) { - throw new NotFoundException({ error_code: 'SHIFT_TYPE_UNKNOWN', message: `unknown shift type: ${type}` }); - } - return bank.id; + const bank = this.prisma.bankCodes.findFirst({ + where: { type }, + select: { id: true }, + }); + if (!bank) { + throw new NotFoundException({ error_code: 'SHIFT_TYPE_UNKNOWN', message: `unknown shift type: ${type}` }); + } + return bank.id; } - export function normalizeShiftPayload(payload: ShiftPayloadDto) { - //normalize shift's infos - const date = payload.date; - const start_time = timeFromHHMM(payload.start_time); - const end_time = timeFromHHMM(payload.end_time ); - const type = (payload.type || '').trim().toUpperCase(); - const is_remote = payload.is_remote === true; - const is_approved = payload.is_approved === false; - //normalize comment - const raw_comment = payload.comment ?? null; - const trimmed = typeof raw_comment === 'string' ? raw_comment.trim() : null; - const comment = trimmed && trimmed.length > 0 ? trimmed: null; +export function normalizeShiftPayload(payload: { + date: string, + start_time: string, + end_time: string, + type: string, + is_remote: boolean, + is_approved: boolean, + comment?: string | null, +}) { + //normalize shift's infos + const date = payload.date?.trim(); + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date ?? ''); + if (!m) throw new Error(`Invalid date format (expected YYYY-MM-DD): "${payload.date}"`); + const year = Number(m[1]), mo = Number(m[2]), d = Number(m[3]); - return { date, start_time, end_time, type, is_remote, is_approved, comment }; - } \ No newline at end of file + const asLocalDateOn = (input: string): Date => { + // HH:mm ? + const hm = /^(\d{2}):(\d{2})$/.exec((input ?? '').trim()); + if (hm) return new Date(year, mo - 1, d, Number(hm[1]), Number(hm[2]), 0, 0); + const iso = new Date(input); + if (isNaN(iso.getTime())) throw new Error(`Invalid time: "${input}"`); + return new Date(year, mo - 1, d, iso.getHours(), iso.getMinutes(), iso.getSeconds(), iso.getMilliseconds()); + }; + + const start_time = asLocalDateOn(payload.start_time); + const end_time = asLocalDateOn(payload.end_time); + + const type = (payload.type || '').trim().toUpperCase(); + const is_remote = payload.is_remote === true; + const is_approved = payload.is_approved === false; + //normalize comment + const trimmed = typeof payload.comment === 'string' ? payload.comment.trim() : null; + const comment = trimmed && trimmed.length > 0 ? trimmed : null; + + return { date, start_time, end_time, type, is_remote, is_approved, comment }; +} \ No newline at end of file From 5292f1af11d4b3755fd31bf472ac0299c9a60984 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 14 Oct 2025 09:33:29 -0400 Subject: [PATCH 05/17] feat(preferences): added display options to preferences table --- .../migration.sql | 4 ++++ prisma/schema.prisma | 4 ++++ src/modules/preferences/dtos/preferences.dto.ts | 9 +++++++++ src/modules/preferences/services/preferences.service.ts | 5 ++++- 4 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20251014124848_added_list_displays_to_preferences_table/migration.sql diff --git a/prisma/migrations/20251014124848_added_list_displays_to_preferences_table/migration.sql b/prisma/migrations/20251014124848_added_list_displays_to_preferences_table/migration.sql new file mode 100644 index 0000000..e3e39c1 --- /dev/null +++ b/prisma/migrations/20251014124848_added_list_displays_to_preferences_table/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "preferences" ADD COLUMN "employee_list_display" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "timesheet_display" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "validation_display" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index abb8fae..a5e8734 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -381,6 +381,10 @@ model Preferences { lang_switch Int @default(0) lefty_mode Int @default(0) + employee_list_display Int @default(0) + validation_display Int @default(0) + timesheet_display Int @default(0) + @@map("preferences") } diff --git a/src/modules/preferences/dtos/preferences.dto.ts b/src/modules/preferences/dtos/preferences.dto.ts index 5b1377e..6a41b33 100644 --- a/src/modules/preferences/dtos/preferences.dto.ts +++ b/src/modules/preferences/dtos/preferences.dto.ts @@ -13,4 +13,13 @@ export class PreferencesDto { @IsInt() lefty_mode: number; + + @IsInt() + employee_list_display: number; + + @IsInt() + validation_display: number; + + @IsInt() + timesheet_display: number; } \ No newline at end of file diff --git a/src/modules/preferences/services/preferences.service.ts b/src/modules/preferences/services/preferences.service.ts index 89d6484..6e4f169 100644 --- a/src/modules/preferences/services/preferences.service.ts +++ b/src/modules/preferences/services/preferences.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; import { Preferences } from "@prisma/client"; import { PrismaService } from "src/prisma/prisma.service"; import { PreferencesDto } from "../dtos/preferences.dto"; @@ -20,6 +20,9 @@ export class PreferencesService { dark_mode: dto.dark_mode, lang_switch: dto.lang_switch, lefty_mode: dto.lefty_mode, + employee_list_display: dto.employee_list_display, + validation_display: dto.validation_display, + timesheet_display: dto.timesheet_display, }, include: { user: true }, }); From d95b6471cd85100b392704ebf48306fc1c75c1f5 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 14 Oct 2025 10:04:48 -0400 Subject: [PATCH 06/17] fix(shifts): modified controller to accept a query action. clean up module --- docs/swagger/swagger-spec.json | 8 ++ .../shifts/controllers/shifts.controller.ts | 3 +- src/modules/shifts/dtos/create-shift.dto.ts | 52 ----------- src/modules/shifts/dtos/search-shift.dto.ts | 33 ------- src/modules/shifts/dtos/update-shift.dto.ts | 4 - .../shifts/services/shifts-command.service.ts | 1 - .../shifts/services/shifts-query.service.ts | 91 ------------------- src/modules/shifts/utils/shifts.utils.ts | 2 - 8 files changed, 10 insertions(+), 184 deletions(-) delete mode 100644 src/modules/shifts/dtos/create-shift.dto.ts delete mode 100644 src/modules/shifts/dtos/search-shift.dto.ts delete mode 100644 src/modules/shifts/dtos/update-shift.dto.ts diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 0cf1209..07055ed 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -448,6 +448,14 @@ "schema": { "type": "string" } + }, + { + "name": "action", + "required": true, + "in": "query", + "schema": { + "type": "string" + } } ], "requestBody": { diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index ba6dead..c8b7bbf 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -22,7 +22,8 @@ export class ShiftsController { @Put('upsert/:email') async upsert_by_date( @Param('email') email_param: string, - @Body() payload: UpsertShiftDto, action: UpsertAction, + @Query('action') action: UpsertAction, + @Body() payload: UpsertShiftDto, ) { return this.shiftsCommandService.upsertShifts(email_param, action, payload); } diff --git a/src/modules/shifts/dtos/create-shift.dto.ts b/src/modules/shifts/dtos/create-shift.dto.ts deleted file mode 100644 index 0fa93ab..0000000 --- a/src/modules/shifts/dtos/create-shift.dto.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { Type } from "class-transformer"; -import { Allow, IsDateString, IsInt, IsString } from "class-validator"; - -export class CreateShiftDto { - @ApiProperty({ - example: 1, - description: 'Unique ID of the shift (auto-generated)', - }) - @Allow() - id: number; - - @ApiProperty({ - example: 101, - description: 'ID number for a set timesheet', - }) - @Type(() => Number) - @IsInt() - timesheet_id: number; - - @ApiProperty({ - example: 7, - description: 'ID number of a shift code (link with bank-codes)', - }) - @Type(() => Number) - @IsInt() - bank_code_id: number; - - @ApiProperty({ - example: '3018-10-20T00:00:00.000Z', - description: 'Date where the shift takes place', - }) - @IsDateString() - date: string; - - @ApiProperty({ - example: '3018-10-20T08:00:00.000Z', - description: 'Start time of the said shift', - }) - @IsDateString() - start_time: string; - - @ApiProperty({ - example: '3018-10-20T17:00:00.000Z', - description: 'End time of the said shift', - }) - @IsDateString() - end_time: string; - - @IsString() - comment: string; -} diff --git a/src/modules/shifts/dtos/search-shift.dto.ts b/src/modules/shifts/dtos/search-shift.dto.ts deleted file mode 100644 index 233a9b6..0000000 --- a/src/modules/shifts/dtos/search-shift.dto.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Type } from "class-transformer"; -import { IsDateString, IsInt, IsOptional, IsString } from "class-validator"; - -export class SearchShiftsDto { - @IsOptional() - @Type(()=> Number) - @IsInt() - employee_id?: number; - - @IsOptional() - @Type(()=> Number) - @IsInt() - bank_code_id?: number; - - @IsOptional() - @IsString() - comment_contains?: string; - - @IsOptional() - @IsDateString() - start_date?: string; - - @IsOptional() - @IsDateString() - end_date?: string; - - @IsOptional() - @Type(()=> Number) - @IsInt() - timesheet_id?: number; - - -} \ No newline at end of file diff --git a/src/modules/shifts/dtos/update-shift.dto.ts b/src/modules/shifts/dtos/update-shift.dto.ts deleted file mode 100644 index 53f033f..0000000 --- a/src/modules/shifts/dtos/update-shift.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from "@nestjs/swagger"; -import { CreateShiftDto } from "./create-shift.dto"; - -export class UpdateShiftsDto extends PartialType(CreateShiftDto){} \ No newline at end of file diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index fff2d91..a54f9e9 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -1,5 +1,4 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common"; -import { normalizeShiftPayload } from "../utils/shifts.utils"; import { DayShiftResponse } from "../types-and-interfaces/shifts-upsert.types"; import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; import { Prisma, Shifts } from "@prisma/client"; diff --git a/src/modules/shifts/services/shifts-query.service.ts b/src/modules/shifts/services/shifts-query.service.ts index bfe3fe8..68006df 100644 --- a/src/modules/shifts/services/shifts-query.service.ts +++ b/src/modules/shifts/services/shifts-query.service.ts @@ -111,95 +111,4 @@ export class ShiftsQueryService { //return by default the list of employee in ascending alphabetical order return Array.from(mapRow.values()).sort((a,b) => a.full_name.localeCompare(b.full_name)); } - - //_____________________________________________________________________________________________ - // Deprecated or unused methods - //_____________________________________________________________________________________________ - - // async update(id: number, dto: UpdateShiftsDto): Promise { - // await this.findOne(id); - // const { timesheet_id, bank_code_id, date,start_time,end_time, comment} = 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 }), - // ...(comment !== undefined && { comment }), - // }, - // 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 create(dto: CreateShiftDto): Promise { -// const { timesheet_id, bank_code_id, date, start_time, end_time, comment } = dto; - -// //shift creation -// const shift = await this.prisma.shifts.create({ -// data: { timesheet_id, bank_code_id, date, start_time, end_time, comment }, -// include: { timesheet: { include: { employee: { include: { user: true } } } }, -// bank_code: true, -// }, -// }); - -// //fetches all shifts of the same day to check for daily overtime -// const same_day_shifts = 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 total_hours = same_day_shifts.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(total_hours > DAILY_LIMIT_HOURS ) { -// const user_id = String(shift.timesheet.employee.user.id); -// const date_label = new Date(date).toLocaleDateString('fr-CA'); -// this.notifs.notify(user_id, { -// type: 'shift.overtime.daily', -// severity: 'warn', -// message: `Tu viens de dépasser ${DAILY_LIMIT_HOURS.toFixed(2)}h pour la journée du ${date_label} -// (total: ${total_hours.toFixed(2)}h).`, -// ts: new Date().toISOString(), -// meta: { -// timesheet_id, -// date: new Date(date).toISOString(), -// total_hours, -// threshold: DAILY_LIMIT_HOURS, -// last_shift_id: 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; -// } - } \ No newline at end of file diff --git a/src/modules/shifts/utils/shifts.utils.ts b/src/modules/shifts/utils/shifts.utils.ts index a0fb13e..b2bd6e8 100644 --- a/src/modules/shifts/utils/shifts.utils.ts +++ b/src/modules/shifts/utils/shifts.utils.ts @@ -1,6 +1,4 @@ import { NotFoundException } from "@nestjs/common"; -import { ShiftPayloadDto } from "../dtos/upsert-shift.dto"; -import { timeFromHHMM } from "../helpers/shifts-date-time-helpers"; export function overlaps( a_start_ms: number, From 99e9f1f3bfa65e32c927ccc8c018d4ac0e4abd63 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 14 Oct 2025 10:35:30 -0400 Subject: [PATCH 07/17] feat(shifts): extract delete logics outside of master upsert method and create a new route for delete requests --- docs/swagger/swagger-spec.json | 46 +++++++++++++++++++ .../shifts/controllers/shifts.controller.ts | 14 +++++- .../helpers/shifts-date-time-helpers.ts | 4 +- .../shifts/services/shifts-command.service.ts | 17 +++---- 4 files changed, 66 insertions(+), 15 deletions(-) diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 07055ed..6429d37 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -483,6 +483,52 @@ ] } }, + "/shifts/{email}/{date}": { + "delete": { + "operationId": "ShiftsController_remove", + "parameters": [ + { + "name": "email", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "date", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertShiftDto" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "access-token": [] + } + ], + "tags": [ + "Shifts" + ] + } + }, "/shifts/approval/{id}": { "patch": { "operationId": "ShiftsController_approve", diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index c8b7bbf..7884dfb 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -1,11 +1,11 @@ -import { Body, Controller, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Put, Query, } from "@nestjs/common"; +import { Body, Controller, Delete, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Put, Query, } from "@nestjs/common"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { Roles as RoleEnum } from '.prisma/client'; import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; import { ShiftsCommandService } from "../services/shifts-command.service"; import { ShiftsQueryService } from "../services/shifts-query.service"; import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto"; -import { UpsertShiftDto } from "../dtos/upsert-shift.dto"; +import { ShiftPayloadDto, UpsertShiftDto } from "../dtos/upsert-shift.dto"; import { OverviewRow } from "../types-and-interfaces/shifts-overview-row.interface"; import { UpsertAction } from "src/modules/shared/types/upsert-actions.types"; @@ -28,6 +28,15 @@ export class ShiftsController { return this.shiftsCommandService.upsertShifts(email_param, action, payload); } + @Delete(':email/:date') + async remove( + @Param('email') email: string, + @Param('date') date: string, + @Body() payload: UpsertShiftDto, + ) { + return this.shiftsCommandService.deleteShift(email, date, payload); + } + @Patch('approval/:id') //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) { @@ -74,4 +83,5 @@ export class ShiftsController { return Buffer.from('\uFEFF' + header + body, 'utf8'); } + } \ No newline at end of file diff --git a/src/modules/shifts/helpers/shifts-date-time-helpers.ts b/src/modules/shifts/helpers/shifts-date-time-helpers.ts index d5ba369..cf3f26b 100644 --- a/src/modules/shifts/helpers/shifts-date-time-helpers.ts +++ b/src/modules/shifts/helpers/shifts-date-time-helpers.ts @@ -10,8 +10,8 @@ export function toDateOnly(ymd: string): Date { return new Date(y, m, d, 0, 0, 0, 0); } -export function weekStartSunday(dateLocal: Date): Date { - const start = new Date(dateLocal.getFullYear(), dateLocal.getMonth(), dateLocal.getDate()); +export function weekStartSunday(date_local: Date): Date { + const start = new Date(date_local.getFullYear(), date_local.getMonth(), date_local.getDate()); const dow = start.getDay(); // 0 = dimanche start.setDate(start.getDate() - dow); start.setHours(0, 0, 0, 0); diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index a54f9e9..8870a64 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -69,12 +69,6 @@ export class ShiftsCommandService extends BaseApprovalService { } return this.updateShift(employee_id, date, dto); } - if(action === 'delete'){ - if(!dto.old_shift || dto.new_shift) { - throw new BadRequestException('Only old_shift must be provided for delete'); - } - return this.deleteShift(employee_id, date, dto); - } throw new BadRequestException(`Unknown action: ${action}`); } @@ -164,13 +158,14 @@ export class ShiftsCommandService extends BaseApprovalService { //_________________________________________________________________ // DELETE //_________________________________________________________________ - private async deleteShift( - employee_id: number, + async deleteShift( + email: string, date_iso: string, dto: UpsertShiftDto, - ): Promise<{ action: UpsertAction; day: DayShiftResponse[]; }>{ + ): Promise<{ day: DayShiftResponse[]; }>{ return this.prisma.$transaction(async (tx) => { - const date_only = toDateOnly(date_iso); + const date_only = toDateOnly(date_iso); + const employee_id = await this.emailResolver.findIdByEmail(email); const { id: timesheet_id } = await this.helpersService.ensureTimesheet(tx, employee_id, date_only); const old_norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift'); @@ -188,7 +183,7 @@ export class ShiftsCommandService extends BaseApprovalService { await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only, 'delete'); const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet_id, date_iso); - return { action: 'delete', day: await this.helpersService.mapDay(fresh_shift)}; + return { day: await this.helpersService.mapDay(fresh_shift)}; }); } } From feeb19bbf0d948081e7c36c0221f552989648ee2 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 14 Oct 2025 10:38:24 -0400 Subject: [PATCH 08/17] fix(shfits): small route fix --- docs/swagger/swagger-spec.json | 2 +- src/modules/shifts/controllers/shifts.controller.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 6429d37..d478a65 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -483,7 +483,7 @@ ] } }, - "/shifts/{email}/{date}": { + "/shifts/delete/{email}/{date}": { "delete": { "operationId": "ShiftsController_remove", "parameters": [ diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index 7884dfb..26918dc 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -28,7 +28,7 @@ export class ShiftsController { return this.shiftsCommandService.upsertShifts(email_param, action, payload); } - @Delete(':email/:date') + @Delete('delete/:email/:date') async remove( @Param('email') email: string, @Param('date') date: string, From 8ad8076f4dbbe579ffc7b9ccad29467bf18db85f Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 14 Oct 2025 10:58:43 -0400 Subject: [PATCH 09/17] fix(shifts): fix bank_code_id by type --- src/modules/shifts/helpers/shifts.helpers.ts | 1 + src/modules/shifts/services/shifts-command.service.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/modules/shifts/helpers/shifts.helpers.ts b/src/modules/shifts/helpers/shifts.helpers.ts index bfab937..62da22d 100644 --- a/src/modules/shifts/helpers/shifts.helpers.ts +++ b/src/modules/shifts/helpers/shifts.helpers.ts @@ -94,6 +94,7 @@ export class ShiftsHelpersService { date_only: Date; norm: Normalized; bank_code_id: number; + comment?: string; }, ) { const { timesheet_id, date_only, norm, bank_code_id } = params; diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index 8870a64..1592233 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -8,6 +8,7 @@ import { PrismaService } from "src/prisma/prisma.service"; import { toDateOnly } from "../helpers/shifts-date-time-helpers"; import { UpsertAction } from "src/modules/shared/types/upsert-actions.types"; import { ShiftsHelpersService } from "../helpers/shifts.helpers"; +import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; @Injectable() export class ShiftsCommandService extends BaseApprovalService { @@ -16,6 +17,7 @@ export class ShiftsCommandService extends BaseApprovalService { constructor( prisma: PrismaService, private readonly emailResolver: EmailToIdResolver, + private readonly typeResolver: BankCodesResolver, private readonly helpersService: ShiftsHelpersService, ) { super(prisma); } @@ -99,7 +101,7 @@ export class ShiftsCommandService extends BaseApprovalService { end_time: new_norm_shift.end_time, is_remote: new_norm_shift.is_remote, is_approved: new_norm_shift.is_approved, - comment: new_norm_shift.comment ?? '', + comment: new_norm_shift.comment ?? null, bank_code_id: new_bank_code_id, }, }); @@ -144,7 +146,7 @@ export class ShiftsCommandService extends BaseApprovalService { start_time: new_norm_shift.start_time, end_time: new_norm_shift.end_time, is_remote: new_norm_shift.is_remote, - comment: new_norm_shift.comment ?? '', + comment: new_norm_shift.comment ?? null, bank_code_id: new_bank_code_id, }, }); @@ -169,13 +171,13 @@ export class ShiftsCommandService extends BaseApprovalService { const { id: timesheet_id } = await this.helpersService.ensureTimesheet(tx, employee_id, date_only); const old_norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift'); - const old_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, old_norm_shift.type, 'old_shift'); + const old_bank_code_id = await this.typeResolver.findByType(old_norm_shift.type); const existing = await this.helpersService.findExactOldShift(tx, { timesheet_id, date_only, norm: old_norm_shift, - bank_code_id: old_bank_code_id, + bank_code_id: old_bank_code_id.id, }); if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else'); From 1fe2c1291cf5953e46a07d3c9a49c2d5272afd8a Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 14 Oct 2025 11:14:43 -0400 Subject: [PATCH 10/17] fix(shift): changed default value of is_approved in asLocalDateOn --- src/modules/shifts/helpers/shifts.helpers.ts | 4 ++-- .../shifts/services/shifts-command.service.ts | 20 +++++++++---------- src/modules/shifts/utils/shifts.utils.ts | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/modules/shifts/helpers/shifts.helpers.ts b/src/modules/shifts/helpers/shifts.helpers.ts index 62da22d..2770457 100644 --- a/src/modules/shifts/helpers/shifts.helpers.ts +++ b/src/modules/shifts/helpers/shifts.helpers.ts @@ -49,9 +49,9 @@ export class ShiftsHelpersService { return id; } - async getDayShifts(tx: Tx, timesheet_id: number, dateIso: string) { + async getDayShifts(tx: Tx, timesheet_id: number, date_only: Date) { return tx.shifts.findMany({ - where: { timesheet_id, date: dateIso }, + where: { timesheet_id, date: date_only }, include: { bank_code: true }, orderBy: { start_time: 'asc' }, }); diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index 1592233..dc098cd 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -89,7 +89,7 @@ export class ShiftsCommandService extends BaseApprovalService { const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift); const new_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, new_norm_shift.type, 'new_shift'); - const day_shifts = await this.helpersService.getDayShifts(tx, timesheet_id, date_iso); + const day_shifts = await this.helpersService.getDayShifts(tx, timesheet_id, date_only); await this.helpersService.assertNoOverlap(day_shifts, new_norm_shift); @@ -106,7 +106,7 @@ export class ShiftsCommandService extends BaseApprovalService { }, }); await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only,'create'); - const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet_id, date_iso); + const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet_id, date_only); return { action: 'create', day: await this.helpersService.mapDay(fresh_shift)}; }); } @@ -129,7 +129,7 @@ export class ShiftsCommandService extends BaseApprovalService { const old_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, old_norm_shift.type, 'old_shift'); const new_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, new_norm_shift.type, 'new_shift'); - const day_shifts = await this.helpersService.getDayShifts(tx, timesheet_id, date_iso); + const day_shifts = await this.helpersService.getDayShifts(tx, timesheet_id, date_only); const existing = await this.helpersService.findExactOldShift(tx, { timesheet_id, date_only, @@ -151,7 +151,7 @@ export class ShiftsCommandService extends BaseApprovalService { }, }); await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only, 'update'); - const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet_id, date_iso); + const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet_id, date_only); return { action: 'update', day: await this.helpersService.mapDay(fresh_shift)}; }); @@ -168,23 +168,23 @@ export class ShiftsCommandService extends BaseApprovalService { return this.prisma.$transaction(async (tx) => { const date_only = toDateOnly(date_iso); const employee_id = await this.emailResolver.findIdByEmail(email); + const { id: timesheet_id } = await this.helpersService.ensureTimesheet(tx, employee_id, date_only); - - const old_norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift'); - const old_bank_code_id = await this.typeResolver.findByType(old_norm_shift.type); + const norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift'); + const bank_code_id = await this.typeResolver.findByType(norm_shift.type); const existing = await this.helpersService.findExactOldShift(tx, { timesheet_id, date_only, - norm: old_norm_shift, - bank_code_id: old_bank_code_id.id, + norm: norm_shift, + bank_code_id: bank_code_id.id, }); if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else'); await tx.shifts.delete({ where: { id: existing.id } }); await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only, 'delete'); - const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet_id, date_iso); + const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet_id, date_only); return { day: await this.helpersService.mapDay(fresh_shift)}; }); } diff --git a/src/modules/shifts/utils/shifts.utils.ts b/src/modules/shifts/utils/shifts.utils.ts index b2bd6e8..60bc11f 100644 --- a/src/modules/shifts/utils/shifts.utils.ts +++ b/src/modules/shifts/utils/shifts.utils.ts @@ -49,7 +49,7 @@ export function normalizeShiftPayload(payload: { const type = (payload.type || '').trim().toUpperCase(); const is_remote = payload.is_remote === true; - const is_approved = payload.is_approved === false; + const is_approved = payload.is_approved; //normalize comment const trimmed = typeof payload.comment === 'string' ? payload.comment.trim() : null; const comment = trimmed && trimmed.length > 0 ? trimmed : null; From 5ec131c863776a70a3e51e66d2b7a09bd077dbd1 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 14 Oct 2025 11:16:08 -0400 Subject: [PATCH 11/17] fix(shfits):small fix --- src/modules/shifts/utils/shifts.utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/shifts/utils/shifts.utils.ts b/src/modules/shifts/utils/shifts.utils.ts index 60bc11f..5262850 100644 --- a/src/modules/shifts/utils/shifts.utils.ts +++ b/src/modules/shifts/utils/shifts.utils.ts @@ -48,7 +48,7 @@ export function normalizeShiftPayload(payload: { const end_time = asLocalDateOn(payload.end_time); const type = (payload.type || '').trim().toUpperCase(); - const is_remote = payload.is_remote === true; + const is_remote = payload.is_remote; const is_approved = payload.is_approved; //normalize comment const trimmed = typeof payload.comment === 'string' ? payload.comment.trim() : null; From a88aaf34f0b84816d31ec3439fbb4d8e5272603f Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 14 Oct 2025 11:59:26 -0400 Subject: [PATCH 12/17] fix(shifts): modified ensureTimesheet --- src/modules/shifts/helpers/shifts.helpers.ts | 14 +++++++++++--- .../shifts/services/shifts-command.service.ts | 19 ++++++++++--------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/modules/shifts/helpers/shifts.helpers.ts b/src/modules/shifts/helpers/shifts.helpers.ts index 2770457..c5da538 100644 --- a/src/modules/shifts/helpers/shifts.helpers.ts +++ b/src/modules/shifts/helpers/shifts.helpers.ts @@ -6,7 +6,6 @@ import { normalizeShiftPayload, overlaps } from "../utils/shifts.utils"; import { weekStartSunday, formatHHmm } from "./shifts-date-time-helpers"; import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; import { OvertimeService } from "src/modules/business-logics/services/overtime.service"; -import { UpsertAction } from "src/modules/shared/types/upsert-actions.types"; export type Tx = Prisma.TransactionClient; @@ -19,7 +18,7 @@ export class ShiftsHelpersService { private readonly overtimeService: OvertimeService, ) { } - async ensureTimesheet(tx: Tx, employee_id: number, date_only: Date) { + async findOrUpsertTimesheet(tx: Tx, employee_id: number, date_only: Date) { const start_of_week = weekStartSunday(date_only); return tx.timesheets.upsert({ where: { employee_id_start_date: { employee_id, start_date: start_of_week } }, @@ -28,6 +27,15 @@ export class ShiftsHelpersService { select: { id: true }, }); } + + async ensureTimesheet(tx: Tx, employee_id: number, date_only: Date) { + const start_of_week = weekStartSunday(date_only); + return tx.timesheets.findUnique({ + where: { employee_id_start_date: { employee_id, start_date: start_of_week } }, + select: { id: true }, + }); + } + async normalizeRequired( raw: UpsertShiftDto['new_shift'] | UpsertShiftDto['old_shift'] | undefined | null, label: 'old_shift' | 'new_shift' = 'new_shift', @@ -113,7 +121,7 @@ export class ShiftsHelpersService { }); } - async afterWriteOvertimeAndLog(tx: Tx, employee_id: number, date_only: Date, action: UpsertAction) { + async afterWriteOvertimeAndLog(tx: Tx, employee_id: number, date_only: Date) { // Switch regular → weekly overtime si > 40h await this.overtimeService.transformRegularHoursToWeeklyOvertime(employee_id, date_only, tx); const [daily, weekly] = await Promise.all([ diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index dc098cd..06eeece 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -84,7 +84,7 @@ export class ShiftsCommandService extends BaseApprovalService { ): Promise<{action: UpsertAction; day: DayShiftResponse[]}> { return this.prisma.$transaction(async (tx) => { const date_only = toDateOnly(date_iso); - const { id: timesheet_id } = await this.helpersService.ensureTimesheet(tx, employee_id, date_only); + const { id: timesheet_id } = await this.helpersService.findOrUpsertTimesheet(tx, employee_id, date_only); const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift); const new_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, new_norm_shift.type, 'new_shift'); @@ -105,7 +105,7 @@ export class ShiftsCommandService extends BaseApprovalService { bank_code_id: new_bank_code_id, }, }); - await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only,'create'); + await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only); const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet_id, date_only); return { action: 'create', day: await this.helpersService.mapDay(fresh_shift)}; }); @@ -121,7 +121,7 @@ export class ShiftsCommandService extends BaseApprovalService { ): Promise<{ action: UpsertAction; day: DayShiftResponse[];}>{ return this.prisma.$transaction(async (tx) => { const date_only = toDateOnly(date_iso); - const { id: timesheet_id } = await this.helpersService.ensureTimesheet(tx, employee_id, date_only); + const { id: timesheet_id } = await this.helpersService.findOrUpsertTimesheet(tx, employee_id, date_only); const old_norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift'); const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift, 'new_shift'); @@ -150,7 +150,7 @@ export class ShiftsCommandService extends BaseApprovalService { bank_code_id: new_bank_code_id, }, }); - await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only, 'update'); + await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only); const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet_id, date_only); return { action: 'update', day: await this.helpersService.mapDay(fresh_shift)}; }); @@ -166,15 +166,16 @@ export class ShiftsCommandService extends BaseApprovalService { dto: UpsertShiftDto, ): Promise<{ day: DayShiftResponse[]; }>{ return this.prisma.$transaction(async (tx) => { - const date_only = toDateOnly(date_iso); + const date_only = toDateOnly(date_iso); //converts to Date format const employee_id = await this.emailResolver.findIdByEmail(email); - const { id: timesheet_id } = await this.helpersService.ensureTimesheet(tx, employee_id, date_only); + const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only); + if(!timesheet) throw new NotFoundException('Timesheet not found') const norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift'); const bank_code_id = await this.typeResolver.findByType(norm_shift.type); const existing = await this.helpersService.findExactOldShift(tx, { - timesheet_id, + timesheet_id: timesheet.id, date_only, norm: norm_shift, bank_code_id: bank_code_id.id, @@ -183,8 +184,8 @@ export class ShiftsCommandService extends BaseApprovalService { await tx.shifts.delete({ where: { id: existing.id } }); - await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only, 'delete'); - const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet_id, date_only); + await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only); + const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet.id, date_only); return { day: await this.helpersService.mapDay(fresh_shift)}; }); } From d1c41ea1bddfda76352c8b8ed9d10a75dedce8a6 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 14 Oct 2025 12:25:09 -0400 Subject: [PATCH 13/17] fix(seeders): fix holiday bank_code and week_start logics to match Sunday to Saturday --- prisma/mock-seeds-scripts/09-timesheets.ts | 30 ++++++++++++---------- prisma/mock-seeds-scripts/10-shifts.ts | 4 +-- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/prisma/mock-seeds-scripts/09-timesheets.ts b/prisma/mock-seeds-scripts/09-timesheets.ts index f926fb2..00523f8 100644 --- a/prisma/mock-seeds-scripts/09-timesheets.ts +++ b/prisma/mock-seeds-scripts/09-timesheets.ts @@ -6,17 +6,19 @@ const prisma = new PrismaClient(); const PREVIOUS_WEEKS = 16; // nombre de semaines à créer (passé) const INCLUDE_CURRENT = false; // true si tu veux aussi la semaine courante -// Lundi (UTC) de la semaine courante -function mondayOfThisWeekUTC(now = new Date()) { +// Dimanche (UTC) de la semaine courante +function sundayOfThisWeekUTC(now = new Date()) { + // normalise à minuit UTC du jour courant const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); - const day = d.getUTCDay(); // 0=Dim, 1=Lun, ... - const diffToMonday = (day + 6) % 7; // 0 si lundi - d.setUTCDate(d.getUTCDate() - diffToMonday); + const day = d.getUTCDay(); // 0=Dim, 1=Lun, ... 6=Sam + // recule jusqu'au dimanche de cette semaine + d.setUTCDate(d.getUTCDate() - day); d.setUTCHours(0, 0, 0, 0); return d; } -function mondayNWeeksBefore(monday: Date, n: number) { - const d = new Date(monday); + +function sundayNWeeksBefore(sunday: Date, n: number) { + const d = new Date(sunday); d.setUTCDate(d.getUTCDate() - n * 7); return d; } @@ -28,21 +30,21 @@ async function main() { return; } - // Construit la liste des lundis (1 par semaine) - const mondays: Date[] = []; - const mondayThisWeek = mondayOfThisWeekUTC(); - if (INCLUDE_CURRENT) mondays.push(mondayThisWeek); + // Construit la liste des dimanches (1 par semaine) + const sundays: Date[] = []; + const sundayThisWeek = sundayOfThisWeekUTC(); + if (INCLUDE_CURRENT) sundays.push(sundayThisWeek); for (let n = 1; n <= PREVIOUS_WEEKS; n++) { - mondays.push(mondayNWeeksBefore(mondayThisWeek, n)); + sundays.push(sundayNWeeksBefore(sundayThisWeek, n)); } // Prépare les lignes (1 timesheet / employé / semaine) const rows: Prisma.TimesheetsCreateManyInput[] = []; for (const e of employees) { - for (const monday of mondays) { + for (const sunday of sundays) { rows.push({ employee_id: e.id, - start_date: monday, + start_date: sunday, is_approved: Math.random() < 0.3, } as Prisma.TimesheetsCreateManyInput); } diff --git a/prisma/mock-seeds-scripts/10-shifts.ts b/prisma/mock-seeds-scripts/10-shifts.ts index 878bc43..9d752b0 100644 --- a/prisma/mock-seeds-scripts/10-shifts.ts +++ b/prisma/mock-seeds-scripts/10-shifts.ts @@ -71,7 +71,7 @@ async function getOrCreateTimesheet(employee_id: number, start_date: Date) { async function main() { // --- Bank codes (pondérés: surtout G1 = régulier) --- - const BANKS = ['G1', 'G56', 'G48', 'G700', 'G105', 'G305'] as const; + const BANKS = ['G1', 'G56', 'G48','G105','G104', 'G305'] as const; const WEIGHTED_CODES = [ 'G1','G1','G1','G1','G1','G1','G1','G1','G1','G1','G1','G1', 'G56','G48','G104','G105','G305','G1','G1','G1','G1','G1','G1' @@ -181,7 +181,7 @@ async function main() { data: { timesheet_id: ts.id, bank_code_id: bcMorningId, - comment: `Matin J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — ${bcMorningCode}`, + comment: `Matin J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} - ${bcMorningCode}`, date, start_time: timeAt(startH, startM), end_time: timeAt(lunchStartHM.h, lunchStartHM.m), From 77e10c67c7f3a1d1dcc92118337069226fc53b58 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 14 Oct 2025 13:28:42 -0400 Subject: [PATCH 14/17] fix(seeders): ajusted shifts and expenses seeders --- prisma/mock-seeds-scripts/09-timesheets.ts | 2 +- prisma/mock-seeds-scripts/10-shifts.ts | 275 ++++++++++----------- prisma/mock-seeds-scripts/12-expenses.ts | 71 +++--- 3 files changed, 170 insertions(+), 178 deletions(-) diff --git a/prisma/mock-seeds-scripts/09-timesheets.ts b/prisma/mock-seeds-scripts/09-timesheets.ts index 00523f8..4160de9 100644 --- a/prisma/mock-seeds-scripts/09-timesheets.ts +++ b/prisma/mock-seeds-scripts/09-timesheets.ts @@ -4,7 +4,7 @@ const prisma = new PrismaClient(); // ====== Config ====== const PREVIOUS_WEEKS = 16; // nombre de semaines à créer (passé) -const INCLUDE_CURRENT = false; // true si tu veux aussi la semaine courante +const INCLUDE_CURRENT = true; // true si tu veux aussi la semaine courante // Dimanche (UTC) de la semaine courante function sundayOfThisWeekUTC(now = new Date()) { diff --git a/prisma/mock-seeds-scripts/10-shifts.ts b/prisma/mock-seeds-scripts/10-shifts.ts index 9d752b0..8c0e095 100644 --- a/prisma/mock-seeds-scripts/10-shifts.ts +++ b/prisma/mock-seeds-scripts/10-shifts.ts @@ -14,26 +14,29 @@ const HARD_END = 19 * 60 + 30; // 19:30 function timeAt(hour: number, minute: number) { return new Date(Date.UTC(1970, 0, 1, hour, minute, 0)); } -function mondayOfThisWeekUTC(now = new Date()) { + +// Ancre SEMAINE = DIMANCHE (UTC) +function sundayOfThisWeekUTC(now = new Date()) { const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); - const day = d.getUTCDay(); - const diffToMonday = (day + 6) % 7; - d.setUTCDate(d.getUTCDate() - diffToMonday); + const day = d.getUTCDay(); // 0 = Dim + d.setUTCDate(d.getUTCDate() - day); // recule jusqu'au dimanche d.setUTCHours(0, 0, 0, 0); return d; } -function weekDatesFromMonday(monday: Date) { - return Array.from({ length: 5 }, (_, i) => { - const d = new Date(monday); - d.setUTCDate(monday.getUTCDate() + i); - return d; - }); -} -function mondayNWeeksBefore(monday: Date, n: number) { - const d = new Date(monday); +function sundayNWeeksBefore(sunday: Date, n: number) { + const d = new Date(sunday); d.setUTCDate(d.getUTCDate() - n * 7); return d; } +// Génère L→V à partir du dimanche (Lundi = dimanche + 1) +function weekDatesMonToFriFromSunday(sunday: Date) { + return Array.from({ length: 5 }, (_, i) => { + const d = new Date(sunday); + d.setUTCDate(sunday.getUTCDate() + (i + 1)); // +1..+5 + return d; + }); +} + function rndInt(min: number, max: number) { return Math.floor(Math.random() * (max - min + 1)) + min; } @@ -46,12 +49,9 @@ function addMinutes(h: number, m: number, delta: number) { const mm = ((total % 60) + 60) % 60; return { h: hh, m: mm }; } -// Aligne vers le multiple de INCR le plus proche function quantize(mins: number): number { - const q = Math.round(mins / INCR) * INCR; - return q; + return Math.round(mins / INCR) * INCR; } -// Tire un multiple de INCR dans [min,max] (inclus), supposés entiers minutes function rndQuantized(min: number, max: number): number { const qmin = Math.ceil(min / INCR); const qmax = Math.floor(max / INCR); @@ -59,19 +59,9 @@ function rndQuantized(min: number, max: number): number { return q * INCR; } -// Helper: garantit le timesheet de la semaine (upsert) -async function getOrCreateTimesheet(employee_id: number, start_date: Date) { - return prisma.timesheets.upsert({ - where: { employee_id_start_date: { employee_id, start_date } }, - update: {}, - create: { employee_id, start_date, is_approved: Math.random() < 0.3 }, - select: { id: true }, - }); -} - async function main() { // --- Bank codes (pondérés: surtout G1 = régulier) --- - const BANKS = ['G1', 'G56', 'G48','G105','G104', 'G305'] as const; + const BANKS = ['G1', 'G56', 'G48', 'G105', 'G104', 'G305'] as const; const WEIGHTED_CODES = [ 'G1','G1','G1','G1','G1','G1','G1','G1','G1','G1','G1','G1', 'G56','G48','G104','G105','G305','G1','G1','G1','G1','G1','G1' @@ -85,150 +75,155 @@ async function main() { for (const c of BANKS) { if (!bcMap.has(c)) throw new Error(`Bank code manquant: ${c}`); } + for (const c of Array.from(new Set(WEIGHTED_CODES))) { + if (!bcMap.has(c)) throw new Error(`Bank code manquant dans WEIGHTED_CODES: ${c}`); + } - const employees = await prisma.employees.findMany({ select: { id: true } }); - if (!employees.length) { - console.log('Aucun employé — rien à insérer.'); + // ====== Fenêtre de semaines à remplir (d'après les timesheets existants) ====== + const sundayThisWeek = sundayOfThisWeekUTC(); + const minSunday = sundayNWeeksBefore(sundayThisWeek, PREVIOUS_WEEKS); + const maxSunday = sundayThisWeek; + + // Récupère les timesheets existants dans la fenêtre (sans en créer) + const timesheets = await prisma.timesheets.findMany({ + where: { + start_date: { + gte: minSunday, + lte: INCLUDE_CURRENT ? maxSunday : sundayNWeeksBefore(maxSunday, 1), // exclut la semaine courante si demandé + }, + }, + select: { id: true, employee_id: true, start_date: true }, + orderBy: [{ start_date: 'desc' }, { employee_id: 'asc' }], + }); + + if (!timesheets.length) { + console.log('Aucun timesheet existant trouvé dans la fenêtre demandée — aucun shift créé.'); return; } - const mondayThisWeek = mondayOfThisWeekUTC(); - const mondays: Date[] = []; - if (INCLUDE_CURRENT) mondays.push(mondayThisWeek); - for (let n = 1; n <= PREVIOUS_WEEKS; n++) mondays.push(mondayNWeeksBefore(mondayThisWeek, n)); - let created = 0; - for (let wi = 0; wi < mondays.length; wi++) { - const monday = mondays[wi]; - const days = weekDatesFromMonday(monday); + // Pour chaque timesheet existant, on génère les shifts L→V rattachés à son id + for (const ts of timesheets) { + const sunday = new Date(ts.start_date); // ancre = dimanche + const days = weekDatesMonToFriFromSunday(sunday); // L→V - for (let ei = 0; ei < employees.length; ei++) { - const e = employees[ei]; + // Optionnel : si tu veux éviter de dupliquer des shifts, décommente : + // const existingCount = await prisma.shifts.count({ where: { timesheet_id: ts.id } }); + // if (existingCount > 0) continue; - // Cible hebdo 35–45h, multiple de 15 min - const weeklyTargetMin = rndQuantized(35 * 60, 45 * 60); + // On paramètre le pattern à partir de l'employee_id pour varier un peu + const baseStartH = 7 + (ts.employee_id % 3); // 7,8,9 + const baseStartM = ((ts.employee_id * 15) % 60); // 0,15,30,45 + const weeklyTargetMin = rndQuantized(35 * 60, 45 * 60); - // Start de base (7:00, 7:15, 7:30, 7:45, 8:00, 8:15, 8:30, 8:45, 9:00 ...) - const baseStartH = 7 + (ei % 3); // 7,8,9 - const baseStartM = ( (ei * 15) % 60 ); // aligné 15 min - - // Planification journalière (5 jours) ~8h ± 45 min, quantisée 15 min - const plannedDaily: number[] = []; - for (let d = 0; d < 5; d++) { - const jitter = rndInt(-3, 3) * INCR; // -45..+45 par pas de 15 - const base = 8 * 60 + jitter; - plannedDaily.push(quantize(clamp(base, DAY_MIN, DAY_MAX))); - } - - // Ajuster le 5e jour pour atteindre la cible hebdo exactement (par pas de 15) - const sumFirst4 = plannedDaily.slice(0, 4).reduce((a, b) => a + b, 0); - plannedDaily[4] = quantize(clamp(weeklyTargetMin - sumFirst4, DAY_MIN, DAY_MAX)); - - // Corriger le petit écart restant (devrait être multiple de 15) en redistribuant ±15 - let diff = weeklyTargetMin - plannedDaily.reduce((a, b) => a + b, 0); - const step = diff > 0 ? INCR : -INCR; - let guard = 100; // anti-boucle - while (diff !== 0 && guard-- > 0) { - for (let d = 0; d < 5 && diff !== 0; d++) { - const next = plannedDaily[d] + step; - if (next >= DAY_MIN && next <= DAY_MAX) { - plannedDaily[d] = next; - diff -= step; - } + // Planification journalière (5 jours) ~8h ± 45 min + const plannedDaily: number[] = []; + for (let d = 0; d < 5; d++) { + const jitter = rndInt(-3, 3) * INCR; // -45..+45 + const base = 8 * 60 + jitter; + plannedDaily.push(quantize(clamp(base, DAY_MIN, DAY_MAX))); + } + // Ajuste le 5e jour pour matcher la cible hebdo + const sumFirst4 = plannedDaily.slice(0, 4).reduce((a, b) => a + b, 0); + plannedDaily[4] = quantize(clamp(weeklyTargetMin - sumFirst4, DAY_MIN, DAY_MAX)); + // Fine tuning ±15 + let diff = weeklyTargetMin - plannedDaily.reduce((a, b) => a + b, 0); + const step = diff > 0 ? INCR : -INCR; + let guard = 100; + while (diff !== 0 && guard-- > 0) { + for (let d = 0; d < 5 && diff !== 0; d++) { + const next = plannedDaily[d] + step; + if (next >= DAY_MIN && next <= DAY_MAX) { + plannedDaily[d] = next; + diff -= step; } } + } - // Upsert du timesheet (semaine) - const ts = await getOrCreateTimesheet(e.id, mondayOfThisWeekUTC(days[0])); + for (let di = 0; di < 5; di++) { + const date = days[di]; // Lundi..Vendredi + const targetWorkMin = plannedDaily[di]; - for (let di = 0; di < 5; di++) { - const date = days[di]; - const targetWorkMin = plannedDaily[di]; // multiple de 15 + // Départ ~ base + jitter + const startJitter = rndInt(-1, 2) * INCR; // -15,0,+15,+30 + const { h: startH, m: startM } = addMinutes(baseStartH, baseStartM, startJitter); - // Départ ~ base + jitter (par pas de 15 min aussi) - const startJitter = rndInt(-1, 2) * INCR; // -15,0,+15,+30 - const { h: startH, m: startM } = addMinutes(baseStartH, baseStartM, startJitter); + // Pause: entre 11:00 et 14:00, bornée par start+3h .. start+6h + const earliestLunch = Math.max((startH * 60 + startM) + 3 * 60, 11 * 60); + const latestLunch = Math.min((startH * 60 + startM) + 6 * 60, 14 * 60); + const lunchStartMin = rndQuantized(earliestLunch, latestLunch); + const lunchDur = rndQuantized(30, 120); + const lunchEndMin = lunchStartMin + lunchDur; - // Pause: entre 11:00 et 14:00, mais pas avant start+3h ni après start+6h (le tout quantisé 15) - const earliestLunch = Math.max((startH * 60 + startM) + 3 * 60, 11 * 60); - const latestLunch = Math.min((startH * 60 + startM) + 6 * 60, 14 * 60); - const lunchStartMin = rndQuantized(earliestLunch, latestLunch); - const lunchDur = rndQuantized(30, 120); // 30..120 min en pas de 15 - const lunchEndMin = lunchStartMin + lunchDur; + // Travail = (lunchStart - start) + (end - lunchEnd) + const morningWork = Math.max(0, lunchStartMin - (startH * 60 + startM)); + let afternoonWork = Math.max(60, targetWorkMin - morningWork); + if (afternoonWork % INCR !== 0) afternoonWork = quantize(afternoonWork); - // Travail = (lunchStart - start) + (end - lunchEnd) - const morningWork = Math.max(0, lunchStartMin - (startH * 60 + startM)); // multiple de 15 - let afternoonWork = Math.max(60, targetWorkMin - morningWork); // multiple de 15 (diff de deux multiples de 15) - if (afternoonWork % INCR !== 0) { - // sécurité (ne devrait pas arriver) - afternoonWork = quantize(afternoonWork); - } + // Fin quantisée + borne max + const endMinRaw = lunchEndMin + afternoonWork; + const endMin = Math.min(endMinRaw, HARD_END); - // Fin de journée (quantisée par construction) - const endMinRaw = lunchEndMin + afternoonWork; - const endMin = Math.min(endMinRaw, HARD_END); + // Bank codes variés + const bcMorningCode = WEIGHTED_CODES[rndInt(0, WEIGHTED_CODES.length - 1)]; + const bcAfternoonCode= WEIGHTED_CODES[rndInt(0, WEIGHTED_CODES.length - 1)]; + const bcMorningId = bcMap.get(bcMorningCode)!; + const bcAfternoonId = bcMap.get(bcAfternoonCode)!; - // Bank codes variés - const bcMorningCode = WEIGHTED_CODES[rndInt(0, WEIGHTED_CODES.length - 1)]; - const bcAfternoonCode = WEIGHTED_CODES[rndInt(0, WEIGHTED_CODES.length - 1)]; - const bcMorningId = bcMap.get(bcMorningCode)!; - const bcAfternoonId = bcMap.get(bcAfternoonCode)!; + // Shift matin + const lunchStartHM = { h: Math.floor(lunchStartMin / 60), m: lunchStartMin % 60 }; + await prisma.shifts.create({ + data: { + timesheet_id: ts.id, + bank_code_id: bcMorningId, + comment: `Matin J${di + 1} (sem ${sunday.toISOString().slice(0, 10)}) emp ${ts.employee_id} - ${bcMorningCode}`, + date, + start_time: timeAt(startH, startM), + end_time: timeAt(lunchStartHM.h, lunchStartHM.m), + is_approved: Math.random() < 0.6, + }, + }); + created++; - // Shift matin - const lunchStartHM = { h: Math.floor(lunchStartMin / 60), m: lunchStartMin % 60 }; + // Shift après-midi (si >= 30 min) + const pmDuration = endMin - lunchEndMin; + if (pmDuration >= 30) { + const lunchEndHM = { h: Math.floor(lunchEndMin / 60), m: lunchEndMin % 60 }; + const finalEndHM = { h: Math.floor(endMin / 60), m: endMin % 60 }; await prisma.shifts.create({ data: { timesheet_id: ts.id, - bank_code_id: bcMorningId, - comment: `Matin J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} - ${bcMorningCode}`, + bank_code_id: bcAfternoonId, + comment: `Après-midi J${di + 1} (sem ${sunday.toISOString().slice(0, 10)}) emp ${ts.employee_id} — ${bcAfternoonCode}`, date, - start_time: timeAt(startH, startM), - end_time: timeAt(lunchStartHM.h, lunchStartHM.m), + start_time: timeAt(lunchEndHM.h, lunchEndHM.m), + end_time: timeAt(finalEndHM.h, finalEndHM.m), + is_approved: Math.random() < 0.6, + }, + }); + created++; + } else { + // Fallback: un seul shift couvrant la journée + const fallbackEnd = addMinutes(startH, startM, targetWorkMin + lunchDur); + await prisma.shifts.create({ + data: { + timesheet_id: ts.id, + bank_code_id: bcMap.get('G1')!, + comment: `Fallback J${di + 1} (sem ${sunday.toISOString().slice(0, 10)}) emp ${ts.employee_id} — G1`, + date, + start_time: timeAt(startH, startM), + end_time: timeAt(fallbackEnd.h, fallbackEnd.m), is_approved: Math.random() < 0.6, }, }); created++; - - // Shift après-midi (si >= 30 min — sera de toute façon multiple de 15) - const pmDuration = endMin - lunchEndMin; - if (pmDuration >= 30) { - const lunchEndHM = { h: Math.floor(lunchEndMin / 60), m: lunchEndMin % 60 }; - const finalEndHM = { h: Math.floor(endMin / 60), m: endMin % 60 }; - await prisma.shifts.create({ - data: { - timesheet_id: ts.id, - bank_code_id: bcAfternoonId, - comment: `Après-midi J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — ${bcAfternoonCode}`, - date, - start_time: timeAt(lunchEndHM.h, lunchEndHM.m), - end_time: timeAt(finalEndHM.h, finalEndHM.m), - is_approved: Math.random() < 0.6, - }, - }); - created++; - } else { - // Fallback très rare : un seul shift couvrant la journée (tout en multiples de 15) - const fallbackEnd = addMinutes(startH, startM, targetWorkMin + lunchDur); - await prisma.shifts.create({ - data: { - timesheet_id: ts.id, - bank_code_id: bcMap.get('G1')!, - comment: `Fallback J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — G1`, - date, - start_time: timeAt(startH, startM), - end_time: timeAt(fallbackEnd.h, fallbackEnd.m), - is_approved: Math.random() < 0.6, - }, - }); - created++; - } } } } const total = await prisma.shifts.count(); - console.log(`✓ Shifts créés: ${created} | total en DB: ${total} (${INCLUDE_CURRENT ? 'inclut semaine courante, ' : ''}${PREVIOUS_WEEKS} sem passées, L→V, 2 shifts/jour, pas de décimaux foireux})`); + console.log(`✓ Shifts créés: ${created} | total en DB: ${total} (${INCLUDE_CURRENT ? 'inclut semaine courante, ' : ''}${PREVIOUS_WEEKS} sem passées, Dim ancre + L→V, 2 shifts/jour, **aucun timesheet créé**})`); } main().finally(() => prisma.$disconnect()); diff --git a/prisma/mock-seeds-scripts/12-expenses.ts b/prisma/mock-seeds-scripts/12-expenses.ts index 926a52f..9b7bf22 100644 --- a/prisma/mock-seeds-scripts/12-expenses.ts +++ b/prisma/mock-seeds-scripts/12-expenses.ts @@ -1,3 +1,4 @@ +import { NotFoundException } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); @@ -7,25 +8,24 @@ const WEEKS_BACK = 4; // 4 semaines avant + semaine courante const INCLUDE_CURRENT = true; // inclure la semaine courante const STEP_CENTS = 25; // montants en quarts de dollar (.00/.25/.50/.75) -// ====== Helpers dates ====== -function mondayOfThisWeekUTC(now = new Date()) { +// ====== Helpers dates (ancre DIMANCHE UTC) ====== +function sundayOfThisWeekUTC(now = new Date()) { const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); - const day = d.getUTCDay(); - const diffToMonday = (day + 6) % 7; - d.setUTCDate(d.getUTCDate() - diffToMonday); + const day = d.getUTCDay(); // 0=Dim, 1=Lun, ... + d.setUTCDate(d.getUTCDate() - day); // recule jusqu'au dimanche d.setUTCHours(0, 0, 0, 0); return d; } -function mondayNWeeksBefore(monday: Date, n: number) { - const d = new Date(monday); - d.setUTCDate(monday.getUTCDate() - n * 7); +function sundayNWeeksBefore(sunday: Date, n: number) { + const d = new Date(sunday); + d.setUTCDate(d.getUTCDate() - n * 7); return d; } -// L→V (UTC minuit) -function weekDatesMonToFri(monday: Date) { +// Génère L→V à partir du dimanche (Lundi = dimanche + 1) +function weekDatesMonToFriFromSunday(sunday: Date) { return Array.from({ length: 5 }, (_, i) => { - const d = new Date(monday); - d.setUTCDate(monday.getUTCDate() + i); + const d = new Date(sunday); + d.setUTCDate(sunday.getUTCDate() + (i + 1)); // +1..+5 return d; }); } @@ -34,7 +34,7 @@ function weekDatesMonToFri(monday: Date) { function rndInt(min: number, max: number) { return Math.floor(Math.random() * (max - min + 1)) + min; } -// String "xx.yy" à partir de cents ENTiers (jamais de float) +// String "xx.yy" à partir de cents entiers (pas de float binaire en DB) function centsToAmountString(cents: number): string { const sign = cents < 0 ? '-' : ''; const abs = Math.abs(cents); @@ -42,12 +42,9 @@ function centsToAmountString(cents: number): string { const c = abs % 100; return `${sign}${dollars}.${c.toString().padStart(2, '0')}`; } - function to2(value: string): string { - // normalise au cas où (sécurité) return (Math.round(parseFloat(value) * 100) / 100).toFixed(2); } - // Tire un multiple de STEP_CENTS entre minCents et maxCents (inclus) function rndQuantizedCents(minCents: number, maxCents: number, step = STEP_CENTS): number { const qmin = Math.ceil(minCents / step); @@ -59,12 +56,10 @@ function rndAmount(minCents: number, maxCents: number): string { return centsToAmountString(rndQuantizedCents(minCents, maxCents)); } -// ====== Timesheet upsert ====== -async function getOrCreateTimesheet(employee_id: number, start_date: Date) { - return prisma.timesheets.upsert({ +// ====== Lookup timesheet (AUCUNE création ici) ====== +async function findTimesheet(employee_id: number, start_date: Date) { + return prisma.timesheets.findUnique({ where: { employee_id_start_date: { employee_id, start_date } }, - update: {}, - create: { employee_id, start_date, is_approved: Math.random() < 0.3 }, select: { id: true }, }); } @@ -90,21 +85,23 @@ async function main() { return; } - // Liste des lundis (courant + 4 précédents) - const mondayThisWeek = mondayOfThisWeekUTC(); - const mondays: Date[] = []; - if (INCLUDE_CURRENT) mondays.push(mondayThisWeek); - for (let n = 1; n <= WEEKS_BACK; n++) mondays.push(mondayNWeeksBefore(mondayThisWeek, n)); + // Fenêtre de semaines ancrées au DIMANCHE + const sundayThisWeek = sundayOfThisWeekUTC(); + const sundays: Date[] = []; + if (INCLUDE_CURRENT) sundays.push(sundayThisWeek); + for (let n = 1; n <= WEEKS_BACK; n++) sundays.push(sundayNWeeksBefore(sundayThisWeek, n)); let created = 0; - for (const monday of mondays) { - const weekDays = weekDatesMonToFri(monday); + for (const sunday of sundays) { + const weekDays = weekDatesMonToFriFromSunday(sunday); // L→V + const monday = weekDays[0]; const friday = weekDays[4]; for (const e of employees) { - // Upsert timesheet pour CETTE semaine/employee - const ts = await getOrCreateTimesheet(e.id, monday); + // Utiliser le timesheet EXISTANT (ancré au DIMANCHE) + const ts = await findTimesheet(e.id, sunday); + if (!ts) throw new NotFoundException(`Timesheet manquant pour emp ${e.id} @ ${sunday.toISOString().slice(0,10)}`); // Idempotence: si déjà au moins une expense L→V, on skip la semaine const already = await prisma.expenses.findFirst({ @@ -122,22 +119,22 @@ async function main() { const code = BANKS[rndInt(0, BANKS.length - 1)]; const bank_code_id = bcMap.get(code)!; - // Montants (cents) quantisés à 25¢ => aucun flottant binaire plus tard + // Montants (cents) quantisés à 25¢ => aucun flottant binaire en DB let amount: string = '0.00'; let mileage: string = '0.00'; switch (code) { case 'G503': // kilométrage - mileage = to2(rndAmount(1000, 7500)); // 10.00 à 75.00 + mileage = to2(rndAmount(1000, 7500)); // 10.00 à 75.00 break; case 'G502': // per_diem - amount = to2(rndAmount(1500, 3000)); // 15.00 à 30.00 + amount = to2(rndAmount(1500, 3000)); // 15.00 à 30.00 break; - case 'G202': // on_call /prime de garde - amount = to2(rndAmount(2000, 15000)); // 20.00 à 150.00 + case 'G202': // on_call / prime de garde + amount = to2(rndAmount(2000, 15000)); // 20.00 à 150.00 break; case 'G517': // expenses default: - amount = to2(rndAmount(500, 5000)); // 5.00 à 50.00 + amount = to2(rndAmount(500, 5000)); // 5.00 à 50.00 break; } @@ -160,7 +157,7 @@ async function main() { } const total = await prisma.expenses.count(); - console.log(`✓ Expenses: ${created} nouvelles lignes, ${total} total rows (sem courante + ${WEEKS_BACK} précédentes, L→V uniquement, montants en quarts de dollar)`); + console.log(`✓ Expenses: ${created} nouvelles lignes, ${total} total rows (ancre dimanche, L→V, sem courante ${INCLUDE_CURRENT ? 'incluse' : 'exclue'} + ${WEEKS_BACK} précédentes)`); } main().finally(() => prisma.$disconnect()); From 9ad4e63485d8ff6a300bce75e2f2d345ad1cd013 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 14 Oct 2025 14:18:31 -0400 Subject: [PATCH 15/17] refactor(shifts): removed return statement of Delete shift function --- package.json | 2 +- src/modules/shifts/services/shifts-command.service.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 76aac8f..635a791 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "seed:12": "tsx prisma/mock-seeds-scripts/12-expenses.ts", "seed:13": "tsx prisma/mock-seeds-scripts/13-expenses-archive.ts", "seed:14": "tsx prisma/mock-seeds-scripts/14-oauth-sessions.ts", - "seed:all": "npm run seed:01 && npm run seed:02 && npm run seed:03 && npm run seed:04 && npm run seed:05 && npm run seed:06 && npm run seed:07 && npm run seed:08 && npm run seed:09 && npm run seed:10 && npm run seed:11 && npm run seed:12 && npm run seed:13 && npm run seed:14", + "seed:all": "npm run seed:01 && npm run seed:02 && npm run seed:03 && npm run seed:09 && npm run seed:10 && npm run seed:12 && npm run seed:14", "db:reseed": "npm run db:reset && npm run seed:all" }, "dependencies": { diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index 06eeece..91fe30d 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -164,7 +164,7 @@ export class ShiftsCommandService extends BaseApprovalService { email: string, date_iso: string, dto: UpsertShiftDto, - ): Promise<{ day: DayShiftResponse[]; }>{ + ){ return this.prisma.$transaction(async (tx) => { const date_only = toDateOnly(date_iso); //converts to Date format const employee_id = await this.emailResolver.findIdByEmail(email); @@ -185,8 +185,6 @@ export class ShiftsCommandService extends BaseApprovalService { await tx.shifts.delete({ where: { id: existing.id } }); await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only); - const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet.id, date_only); - return { day: await this.helpersService.mapDay(fresh_shift)}; }); } } From cefba7a2dd0090350461110e6edd234b24c4298d Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 14 Oct 2025 14:29:37 -0400 Subject: [PATCH 16/17] fix(shifts): fixed findTimesheet to findUnique instead of upsert --- src/modules/shifts/helpers/shifts.helpers.ts | 10 ------- .../shifts/services/shifts-command.service.ts | 27 ++++++++++--------- 2 files changed, 14 insertions(+), 23 deletions(-) diff --git a/src/modules/shifts/helpers/shifts.helpers.ts b/src/modules/shifts/helpers/shifts.helpers.ts index c5da538..6038db7 100644 --- a/src/modules/shifts/helpers/shifts.helpers.ts +++ b/src/modules/shifts/helpers/shifts.helpers.ts @@ -18,16 +18,6 @@ export class ShiftsHelpersService { private readonly overtimeService: OvertimeService, ) { } - async findOrUpsertTimesheet(tx: Tx, employee_id: number, date_only: Date) { - const start_of_week = weekStartSunday(date_only); - return tx.timesheets.upsert({ - where: { employee_id_start_date: { employee_id, start_date: start_of_week } }, - update: {}, - create: { employee_id, start_date: start_of_week }, - select: { id: true }, - }); - } - async ensureTimesheet(tx: Tx, employee_id: number, date_only: Date) { const start_of_week = weekStartSunday(date_only); return tx.timesheets.findUnique({ diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index 91fe30d..a7e265a 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -84,18 +84,18 @@ export class ShiftsCommandService extends BaseApprovalService { ): Promise<{action: UpsertAction; day: DayShiftResponse[]}> { return this.prisma.$transaction(async (tx) => { const date_only = toDateOnly(date_iso); - const { id: timesheet_id } = await this.helpersService.findOrUpsertTimesheet(tx, employee_id, date_only); - + const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only); + if(!timesheet) throw new NotFoundException('Timesheet not found') const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift); const new_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, new_norm_shift.type, 'new_shift'); - const day_shifts = await this.helpersService.getDayShifts(tx, timesheet_id, date_only); + const day_shifts = await this.helpersService.getDayShifts(tx, timesheet.id, date_only); await this.helpersService.assertNoOverlap(day_shifts, new_norm_shift); await tx.shifts.create({ data: { - timesheet_id, + timesheet_id: timesheet.id, date: date_only, start_time: new_norm_shift.start_time, end_time: new_norm_shift.end_time, @@ -106,7 +106,7 @@ export class ShiftsCommandService extends BaseApprovalService { }, }); await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only); - const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet_id, date_only); + const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet.id, date_only); return { action: 'create', day: await this.helpersService.mapDay(fresh_shift)}; }); } @@ -121,20 +121,21 @@ export class ShiftsCommandService extends BaseApprovalService { ): Promise<{ action: UpsertAction; day: DayShiftResponse[];}>{ return this.prisma.$transaction(async (tx) => { const date_only = toDateOnly(date_iso); - const { id: timesheet_id } = await this.helpersService.findOrUpsertTimesheet(tx, employee_id, date_only); + const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only); + if(!timesheet) throw new NotFoundException('Timesheet not found') const old_norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift'); const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift, 'new_shift'); - const old_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, old_norm_shift.type, 'old_shift'); - const new_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, new_norm_shift.type, 'new_shift'); + const old_bank_code = await this.typeResolver.findByType(old_norm_shift.type); + const new_bank_code = await this.typeResolver.findByType(new_norm_shift.type); - const day_shifts = await this.helpersService.getDayShifts(tx, timesheet_id, date_only); + const day_shifts = await this.helpersService.getDayShifts(tx, timesheet.id, date_only); const existing = await this.helpersService.findExactOldShift(tx, { - timesheet_id, + timesheet_id: timesheet.id, date_only, norm: old_norm_shift, - bank_code_id: old_bank_code_id, + bank_code_id: old_bank_code.id, }); if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else'); @@ -147,11 +148,11 @@ export class ShiftsCommandService extends BaseApprovalService { end_time: new_norm_shift.end_time, is_remote: new_norm_shift.is_remote, comment: new_norm_shift.comment ?? null, - bank_code_id: new_bank_code_id, + bank_code_id: new_bank_code.id, }, }); await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only); - const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet_id, date_only); + const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet.id, date_only); return { action: 'update', day: await this.helpersService.mapDay(fresh_shift)}; }); From 06ad34a4c89750a548d5c97d87a69d8bc557509c Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 14 Oct 2025 16:43:18 -0400 Subject: [PATCH 17/17] refactor(shared): centralized some small logics --- .../services/expenses-command.service.ts | 4 +- .../shared/interfaces/shifts.interface.ts | 9 ++++ src/modules/shared/shared.module.ts | 2 +- .../utils/resolve-employee-timesheet.utils.ts | 42 ------------------- .../shared/utils/resolve-shifts-id.utils.ts | 29 +++++++++++++ .../shared/utils/resolve-timesheet.utils.ts | 28 +++++++++++++ .../helpers/shifts-date-time-helpers.ts | 2 +- src/modules/shifts/helpers/shifts.helpers.ts | 11 +++-- .../services/timesheets-command.service.ts | 4 +- 9 files changed, 79 insertions(+), 52 deletions(-) create mode 100644 src/modules/shared/interfaces/shifts.interface.ts delete mode 100644 src/modules/shared/utils/resolve-employee-timesheet.utils.ts create mode 100644 src/modules/shared/utils/resolve-shifts-id.utils.ts create mode 100644 src/modules/shared/utils/resolve-timesheet.utils.ts diff --git a/src/modules/expenses/services/expenses-command.service.ts b/src/modules/expenses/services/expenses-command.service.ts index bda3c90..1511eb0 100644 --- a/src/modules/expenses/services/expenses-command.service.ts +++ b/src/modules/expenses/services/expenses-command.service.ts @@ -5,7 +5,7 @@ import { UpsertExpenseDto } from "../dtos/upsert-expense.dto"; import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces"; import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; -import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-employee-timesheet.utils"; +import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-timesheet.utils"; import { BadRequestException, Injectable, @@ -66,7 +66,7 @@ export class ExpensesCommandService extends BaseApprovalService { const employee_id = await this.emailResolver.findIdByEmail(email); //make sure a timesheet existes - const timesheet_id = await this.timesheetsResolver.ensureForDate(employee_id, date_only); + const timesheet_id = await this.timesheetsResolver.findTimesheetIdByEmail(email, date_only); if(!timesheet_id) throw new NotFoundException(`no timesheet found for employee #${employee_id}`) const {id} = timesheet_id; diff --git a/src/modules/shared/interfaces/shifts.interface.ts b/src/modules/shared/interfaces/shifts.interface.ts new file mode 100644 index 0000000..40f897e --- /dev/null +++ b/src/modules/shared/interfaces/shifts.interface.ts @@ -0,0 +1,9 @@ +export interface ShiftKey { + timesheet_id: number; + date: Date; + start_time: Date; + end_time: Date; + bank_code_id: number; + is_remote: boolean; + comment?: string | null; +} \ No newline at end of file diff --git a/src/modules/shared/shared.module.ts b/src/modules/shared/shared.module.ts index adf0b68..8d4aa95 100644 --- a/src/modules/shared/shared.module.ts +++ b/src/modules/shared/shared.module.ts @@ -1,6 +1,6 @@ import { Module } from "@nestjs/common"; import { EmailToIdResolver } from "./utils/resolve-email-id.utils"; -import { EmployeeTimesheetResolver } from "./utils/resolve-employee-timesheet.utils"; +import { EmployeeTimesheetResolver } from "./utils/resolve-timesheet.utils"; import { FullNameResolver } from "./utils/resolve-full-name.utils"; import { BankCodesResolver } from "./utils/resolve-bank-type-id.utils"; import { PrismaModule } from "src/prisma/prisma.module"; diff --git a/src/modules/shared/utils/resolve-employee-timesheet.utils.ts b/src/modules/shared/utils/resolve-employee-timesheet.utils.ts deleted file mode 100644 index eb3b305..0000000 --- a/src/modules/shared/utils/resolve-employee-timesheet.utils.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { Prisma, PrismaClient } from "@prisma/client"; -import { weekStartSunday } from "src/modules/shifts/helpers/shifts-date-time-helpers"; -import { PrismaService } from "src/prisma/prisma.service"; - - -type Tx = Prisma.TransactionClient | PrismaClient; - -@Injectable() -export class EmployeeTimesheetResolver { - constructor(private readonly prisma: PrismaService) {} - - //find an existing timesheet linked to the employee - readonly ensureForDate = async (employee_id: number, date: Date, client?: Tx, - ): Promise<{id: number; start_date: Date }> => { - const db = client ?? this.prisma; - const startOfWeek = weekStartSunday(date); - const existing = await db.timesheets.findFirst({ - where: { - employee_id: employee_id, - start_date: startOfWeek, - }, - select: { - id: true, - start_date: true, - }, - }); - if(existing) return existing; - - const created = await db.timesheets.create({ - data: { - employee_id: employee_id, - start_date: startOfWeek, - }, - select: { - id: true, - start_date: true, - }, - }); - return created; - } -} \ No newline at end of file diff --git a/src/modules/shared/utils/resolve-shifts-id.utils.ts b/src/modules/shared/utils/resolve-shifts-id.utils.ts new file mode 100644 index 0000000..4d9d313 --- /dev/null +++ b/src/modules/shared/utils/resolve-shifts-id.utils.ts @@ -0,0 +1,29 @@ +import { Prisma, PrismaClient } from "@prisma/client"; +import { NotFoundException } from "@nestjs/common"; +import { PrismaService } from "src/prisma/prisma.service"; +import { ShiftKey } from "../interfaces/shifts.interface"; + +type Tx = Prisma.TransactionClient | PrismaClient; + +export class ShiftIdResolver { + constructor(private readonly prisma: PrismaService) {} + + readonly findShiftIdByData = async ( key: ShiftKey, client?: Tx ): Promise<{id:number}> => { + const db = client ?? this.prisma; + const shift = await db.shifts.findFirst({ + where: { + timesheet_id: key.timesheet_id, + bank_code_id: key.bank_code_id, + date: key.date, + start_time: key.start_time, + end_time: key.end_time, + is_remote: key.is_remote, + comment: key.comment, + }, + select: { id: true }, + }); + + if(!shift) throw new NotFoundException(`shift not found`); + return { id: shift.id }; + }; +} \ No newline at end of file diff --git a/src/modules/shared/utils/resolve-timesheet.utils.ts b/src/modules/shared/utils/resolve-timesheet.utils.ts new file mode 100644 index 0000000..61f4ce6 --- /dev/null +++ b/src/modules/shared/utils/resolve-timesheet.utils.ts @@ -0,0 +1,28 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Prisma, PrismaClient } from "@prisma/client"; +import { weekStartSunday } from "src/modules/shifts/helpers/shifts-date-time-helpers"; +import { PrismaService } from "src/prisma/prisma.service"; +import { EmailToIdResolver } from "./resolve-email-id.utils"; + + +type Tx = Prisma.TransactionClient | PrismaClient; + +@Injectable() +export class EmployeeTimesheetResolver { + constructor( + private readonly prisma: PrismaService, + private readonly emailResolver: EmailToIdResolver, + ) {} + + readonly findTimesheetIdByEmail = async (email: string, date: Date, client?: Tx): Promise<{id: number}> => { + const db = client ?? this.prisma; + const employee_id = await this.emailResolver.findIdByEmail(email); + const start_date = weekStartSunday(date); + const timesheet = await db.timesheets.findFirst({ + where: { employee_id : employee_id, start_date: start_date }, + select: { id: true }, + }); + if(!timesheet) throw new NotFoundException(`timesheet not found`); + return { id: timesheet.id }; + } +} \ No newline at end of file diff --git a/src/modules/shifts/helpers/shifts-date-time-helpers.ts b/src/modules/shifts/helpers/shifts-date-time-helpers.ts index cf3f26b..8bcd610 100644 --- a/src/modules/shifts/helpers/shifts-date-time-helpers.ts +++ b/src/modules/shifts/helpers/shifts-date-time-helpers.ts @@ -11,7 +11,7 @@ export function toDateOnly(ymd: string): Date { } export function weekStartSunday(date_local: Date): Date { - const start = new Date(date_local.getFullYear(), date_local.getMonth(), date_local.getDate()); + const start = new Date(Date.UTC(date_local.getFullYear(), date_local.getMonth(), date_local.getDate())); const dow = start.getDay(); // 0 = dimanche start.setDate(start.getDate() - dow); start.setHours(0, 0, 0, 0); diff --git a/src/modules/shifts/helpers/shifts.helpers.ts b/src/modules/shifts/helpers/shifts.helpers.ts index 6038db7..09a17e1 100644 --- a/src/modules/shifts/helpers/shifts.helpers.ts +++ b/src/modules/shifts/helpers/shifts.helpers.ts @@ -114,10 +114,13 @@ export class ShiftsHelpersService { async afterWriteOvertimeAndLog(tx: Tx, employee_id: number, date_only: Date) { // Switch regular → weekly overtime si > 40h await this.overtimeService.transformRegularHoursToWeeklyOvertime(employee_id, date_only, tx); - const [daily, weekly] = await Promise.all([ - this.overtimeService.getDailyOvertimeHoursForDay(employee_id, date_only), - this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only), - ]); + const daily = await this.overtimeService.getDailyOvertimeHoursForDay(employee_id, date_only); + const weekly = await this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only); + // const [daily, weekly] = await Promise.all([ + // this.overtimeService.getDailyOvertimeHoursForDay(employee_id, date_only), + // this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only), + // ]); + return { daily, weekly }; } async mapDay( diff --git a/src/modules/timesheets/services/timesheets-command.service.ts b/src/modules/timesheets/services/timesheets-command.service.ts index 045b7b5..f8142b0 100644 --- a/src/modules/timesheets/services/timesheets-command.service.ts +++ b/src/modules/timesheets/services/timesheets-command.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; -import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-employee-timesheet.utils"; +import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-timesheet.utils"; import { getWeekEnd, getWeekStart } from "src/common/utils/date-utils"; import { parseISODate, parseHHmm } from "../utils-helpers-others/timesheet.helpers"; import { TimesheetsQueryService } from "./timesheets-query.service"; @@ -69,7 +69,7 @@ export class TimesheetsCommandService extends BaseApprovalService{ const start_week = getWeekStart(base, 0); const end_week = getWeekEnd(start_week); - const timesheet = await this.timesheetResolver.ensureForDate(employee_id, base) + const timesheet = await this.timesheetResolver.findTimesheetIdByEmail(email, base) if(!timesheet) throw new NotFoundException(`no timesheet found for employe ${employee_id}`); //validations and insertions