From 7c7edea768cb5536b59591bfb6a123d8d0cebb36 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Thu, 9 Oct 2025 08:40:20 -0400 Subject: [PATCH] feat(schedule-presets): created an apply service to auto-create shifts using presets --- .../controller/schedule-presets.controller.ts | 15 +- .../mappers/schedule-presets.mappers.ts | 3 + .../schedule-presets.module.ts | 5 +- .../schedule-presets-apply.service.ts | 155 ++++++++++++++---- 4 files changed, 140 insertions(+), 38 deletions(-) create mode 100644 src/modules/schedule-presets/mappers/schedule-presets.mappers.ts diff --git a/src/modules/schedule-presets/controller/schedule-presets.controller.ts b/src/modules/schedule-presets/controller/schedule-presets.controller.ts index a71f757..b031c67 100644 --- a/src/modules/schedule-presets/controller/schedule-presets.controller.ts +++ b/src/modules/schedule-presets/controller/schedule-presets.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, NotFoundException, Param, Put, Query } from "@nestjs/common"; +import { BadRequestException, Body, Controller, Get, NotFoundException, Param, Post, Put, Query } from "@nestjs/common"; import { SchedulePresetsDto } from "../dtos/create-schedule-presets.dto"; import { SchedulePresetsCommandService } from "../services/schedule-presets-command.service"; import { UpsertAction } from "src/modules/shared/types/upsert-actions.types"; @@ -11,6 +11,7 @@ export class SchedulePresetsController { private readonly queryService: SchedulePresetsQueryService, ){} + //used to create, update or delete a schedule preset @Put(':email') async upsert( @Param('email') email: string, @@ -22,10 +23,22 @@ export class SchedulePresetsController { return this.commandService.upsertSchedulePreset(email, action, dto); } + //used to show the list of available schedule presets @Get(':email') async findListByEmail( @Param('email') email: string, ) { return this.queryService.findSchedulePresetsByEmail(email); } + //used to apply a preset to a timesheet + @Post('/apply-presets/:email') + async applyPresets( + @Param('email') email: string, + @Query('preset') preset_name: string, + @Query('start') start_date: string, + ) { + if(!preset_name?.trim()) throw new BadRequestException('Query "preset" is required'); + if(!start_date?.trim()) throw new BadRequestException('Query "start" is required YYYY-MM-DD'); + return this.applyPresets(email, preset_name, start_date); + } } \ No newline at end of file diff --git a/src/modules/schedule-presets/mappers/schedule-presets.mappers.ts b/src/modules/schedule-presets/mappers/schedule-presets.mappers.ts new file mode 100644 index 0000000..10a9faf --- /dev/null +++ b/src/modules/schedule-presets/mappers/schedule-presets.mappers.ts @@ -0,0 +1,3 @@ +import { Weekday } from "@prisma/client"; + +export const WEEKDAY: Weekday[] = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; \ No newline at end of file diff --git a/src/modules/schedule-presets/schedule-presets.module.ts b/src/modules/schedule-presets/schedule-presets.module.ts index 3bfb06d..1973e1a 100644 --- a/src/modules/schedule-presets/schedule-presets.module.ts +++ b/src/modules/schedule-presets/schedule-presets.module.ts @@ -5,6 +5,7 @@ import { SchedulePresetsController } from "./controller/schedule-presets.control import { EmployeeIdEmailResolver } from "../shared/utils/resolve-email-id.utils"; import { BankCodesResolver } from "../shared/utils/resolve-bank-type-id.utils"; import { PrismaService } from "src/prisma/prisma.service"; +import { SchedulePresetsApplyService } from "./services/schedule-presets-apply.service"; @Module({ imports: [], @@ -13,11 +14,13 @@ import { PrismaService } from "src/prisma/prisma.service"; PrismaService, SchedulePresetsCommandService, SchedulePresetsQueryService, + SchedulePresetsApplyService, EmployeeIdEmailResolver, BankCodesResolver, ], exports:[ SchedulePresetsCommandService, - SchedulePresetsQueryService + SchedulePresetsQueryService, + SchedulePresetsApplyService, ], }) export class SchedulePresetsModule {} \ No newline at end of file diff --git a/src/modules/schedule-presets/services/schedule-presets-apply.service.ts b/src/modules/schedule-presets/services/schedule-presets-apply.service.ts index 63aee30..99ef799 100644 --- a/src/modules/schedule-presets/services/schedule-presets-apply.service.ts +++ b/src/modules/schedule-presets/services/schedule-presets-apply.service.ts @@ -1,45 +1,128 @@ -import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"; import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; import { PrismaService } from "src/prisma/prisma.service"; import { ApplyResult } from "../types/schedule-presets.types"; +import { Prisma, Weekday } from "@prisma/client"; +import { WEEKDAY } from "../mappers/schedule-presets.mappers"; -// @Injectable() -// export class SchedulePresetsApplyService { -// constructor( -// private readonly prisma: PrismaService, -// private readonly emailResolver: EmployeeIdEmailResolver, -// ) {} +@Injectable() +export class SchedulePresetsApplyService { + constructor( + private readonly prisma: PrismaService, + private readonly emailResolver: EmployeeIdEmailResolver, + ) {} -// async applyToTimesheet( -// email: string, -// preset_name: string, -// start_date_iso: string, -// ): Promise { -// if(!preset_name?.trim()) throw new BadRequestException('A preset_name is required'); -// if(!DATE_ISO_FORMAT.test(start_date_iso)) throw new BadRequestException('start_date must be of format :YYYY-MM-DD'); + async applyToTimesheet( + email: string, + preset_name: string, + start_date_iso: string, + ): Promise { + if(!preset_name?.trim()) throw new BadRequestException('A preset_name is required'); + if(!DATE_ISO_FORMAT.test(start_date_iso)) throw new BadRequestException('start_date must be of format :YYYY-MM-DD'); -// const employee_id = await this.emailResolver.findIdByEmail(email); -// if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`); + const employee_id = await this.emailResolver.findIdByEmail(email); + if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`); -// const preset = await this.prisma.schedulePresets.findFirst({ -// where: { employee_id, name: preset_name }, -// include: { -// shifts: { -// orderBy: [{ week_day: 'asc'}, { sort_order: 'asc'}], -// select: { -// week_day: true, -// sort_order: true, -// start_time: true, -// end_time: true, -// is_remote: true, -// bank_code_id: true, -// }, -// }, -// }, -// }); -// if(!preset) throw new NotFoundException(`Preset ${preset} not found`); + const preset = await this.prisma.schedulePresets.findFirst({ + where: { employee_id, name: preset_name }, + include: { + shifts: { + orderBy: [{ week_day: 'asc'}, { sort_order: 'asc'}], + select: { + week_day: true, + sort_order: true, + start_time: true, + end_time: true, + is_remote: true, + bank_code_id: true, + }, + }, + }, + }); + if(!preset) throw new NotFoundException(`Preset ${preset} not found`); -// const start_date = new Date(`${start_date_iso}T00:00:00.000Z`) + const start_date = new Date(`${start_date_iso}T00:00:00.000Z`); + const timesheet = await this.prisma.timesheets.upsert({ + where: { employee_id_start_date: { employee_id, start_date: start_date} }, + update: {}, + create: { employee_id, start_date: start_date }, + select: { id: true }, + }); -// } -// } \ No newline at end of file + //index shifts by weekday + const index_by_day = new Map(); + for (const shift of preset.shifts) { + const list = index_by_day.get(shift.week_day) ?? []; + list.push(shift); + index_by_day.set(shift.week_day, list); + } + + const addDays = (date: Date, days: number) => + new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days)); + + const overlaps = (aStart: Date, aEnd: Date, bStart: Date, bEnd: Date) => + aStart.getTime() < bEnd.getTime() && aEnd.getTime() > bStart.getTime(); + + let created = 0; + let skipped = 0; + + await this.prisma.$transaction(async (tx) => { + for(let i = 0; i < 7; i++) { + const date = addDays(start_date, i); + const week_day = WEEKDAY[date.getUTCDay()]; + const shifts = index_by_day.get(week_day) ?? []; + + if(shifts.length === 0) continue; + + const existing = await tx.shifts.findMany({ + where: { timesheet_id: timesheet.id, date: date }, + orderBy: { start_time: 'asc' }, + select: { + start_time: true, + end_time: true, + bank_code_id: true, + is_remote: true, + comment: true, + }, + }); + + const payload: Prisma.ShiftsCreateManyInput[] = []; + + for(const shift of shifts) { + if(shift.end_time.getTime() <= shift.start_time.getTime()) { + throw new ConflictException(`Invalid time range in preset day: ${week_day}, order: ${shift.sort_order}`); + } + const conflict = existing.find((existe)=> overlaps( + shift.start_time, shift.end_time , + existe.start_time, existe.end_time, + )); + if(conflict) { + throw new ConflictException({ + error_code: 'SHIFT_OVERLAP_WITH_EXISTING', + mesage: `Preset shift overlaps existing shift on ${start_date} + ${i}(week day ${week_day})`, + conflict: { + existing_start: conflict.start_time.toISOString().slice(11,16), + existing_end: conflict.end_time.toISOString().slice(11,16), + }, + }); + } + payload.push({ + timesheet_id: timesheet.id, + date: date, + start_time: shift.start_time, + end_time: shift.end_time, + is_remote: shift.is_remote, + comment: null, + bank_code_id: shift.bank_code_id, + }); + } + if(payload.length) { + const response = await tx.shifts.createMany({ data: payload, skipDuplicates: true }); + created += response.count; + skipped += payload.length - response.count; + } + } + }); + return { timesheet_id: timesheet.id, created, skipped }; + } +} \ No newline at end of file