From ef4f6340d26487c0b2a9154169eaedc0aec53a82 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Thu, 11 Sep 2025 15:22:57 -0400 Subject: [PATCH] feat(shifts): added a master function to create/update/delete a single shift --- .../shifts/controllers/shifts.controller.ts | 18 +- src/modules/shifts/dtos/upsert-shift.dto.ts | 29 ++ .../helpers/shifts-date-time-helpers.ts | 19 ++ .../shifts/services/shifts-command.service.ts | 283 +++++++++++++++++- 4 files changed, 345 insertions(+), 4 deletions(-) create mode 100644 src/modules/shifts/dtos/upsert-shift.dto.ts create mode 100644 src/modules/shifts/helpers/shifts-date-time-helpers.ts diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index 3a33170..b245a82 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; +import { Body, Controller, Delete, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Put, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; import { Shifts } from "@prisma/client"; import { CreateShiftDto } from "../dtos/create-shift.dto"; import { UpdateShiftsDto } from "../dtos/update-shift.dto"; @@ -9,6 +9,7 @@ import { ShiftsCommandService } from "../services/shifts-command.service"; import { SearchShiftsDto } from "../dtos/search-shift.dto"; import { OverviewRow, ShiftsQueryService } from "../services/shifts-query.service"; import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto"; +import { UpsertShiftDto } from "../dtos/upsert-shift.dto"; @ApiTags('Shifts') @ApiBearerAuth('access-token') @@ -17,9 +18,20 @@ import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto"; export class ShiftsController { constructor( private readonly shiftsService: ShiftsQueryService, - private readonly shiftsApprovalService: ShiftsCommandService, + private readonly shiftsCommandService: ShiftsCommandService, private readonly shiftsValidationService: ShiftsQueryService, ){} + + @Put('upsert/:email/:date') + async upsert_by_date( + @Param('email') email_param: string, + @Param('date') date_param: string, + @Body() payload: UpsertShiftDto, + ) { + const email = decodeURIComponent(email_param); + const date = date_param; + return this.shiftsCommandService.upsertShfitsByDate(email, date, payload); + } @Post() //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) @@ -70,7 +82,7 @@ export class ShiftsController { @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.shiftsApprovalService.updateApproval(id, isApproved); + return this.shiftsCommandService.updateApproval(id, isApproved); } @Get('summary') diff --git a/src/modules/shifts/dtos/upsert-shift.dto.ts b/src/modules/shifts/dtos/upsert-shift.dto.ts new file mode 100644 index 0000000..4af789c --- /dev/null +++ b/src/modules/shifts/dtos/upsert-shift.dto.ts @@ -0,0 +1,29 @@ +import { IsBoolean, IsOptional, IsString, Matches, MaxLength } from "class-validator"; + +export const COMMENT_MAX_LENGTH = 512; + +export class ShiftPayloadDto { + + @Matches(/^\d{2}:\d{2}$/, {message: 'start_time must be HH:mm' }) + start_time!: string; + + @Matches(/^\d{2}:\d{2}$/, {message: 'start_time must be HH:mm' }) + end_time!: string; + + @IsString() + type!: string; + + @IsBoolean() + is_remote!: boolean; + + @IsOptional() + @IsString() + @MaxLength(COMMENT_MAX_LENGTH) + comment?: string; +}; + +export class UpsertShiftDto { + + old_shift?: ShiftPayloadDto; + new_shift?: ShiftPayloadDto; +}; \ 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 new file mode 100644 index 0000000..3cf3683 --- /dev/null +++ b/src/modules/shifts/helpers/shifts-date-time-helpers.ts @@ -0,0 +1,19 @@ +export function timeFromHHMMUTC(hhmm: string): Date { + const [hour, min] = hhmm.split(':').map(Number); + return new Date(Date.UTC(1970,0,1,hour, min,0)); +} + +export function weekStartMondayUTC(date: Date): Date { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + const day = d.getUTCDay(); + const diff = (day + 6) % 7; + d.setUTCDate(d.getUTCDate() - diff); + d.setUTCHours(0,0,0,0); + return d; +} + +export function toDateOnlyUTC(input: string | Date): Date { + const date = new Date(input); + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index 0de643c..a864ace 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -1,12 +1,280 @@ -import { Injectable } from "@nestjs/common"; +import { BadRequestException, ConflictException, Injectable, NotFoundException, ParseUUIDPipe, UnprocessableEntityException } from "@nestjs/common"; import { Prisma, Shifts } from "@prisma/client"; import { BaseApprovalService } from "src/common/shared/base-approval.service"; import { PrismaService } from "src/prisma/prisma.service"; +import { ShiftPayloadDto, UpsertShiftDto } from "../dtos/upsert-shift.dto"; +import { timeFromHHMMUTC, toDateOnlyUTC, weekStartMondayUTC } from "../helpers/shifts-date-time-helpers"; +import { error, time } from "console"; + +type DayShiftResponse = { + start_time: string; + end_time: string; + type: string; + is_remote: boolean; + comment: string | null; +} + +type UpsertAction = 'created' | 'updated' | 'deleted'; + + + @Injectable() export class ShiftsCommandService extends BaseApprovalService { constructor(prisma: PrismaService) { super(prisma); } + +//create/update/delete master method +async upsertShfitsByDate(email:string, date_string: string, 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'); + } + + const date_only = toDateOnlyUTC(date_string); + + //Resolve employee by email + const employee = await this.prisma.employees.findFirst({ + where: { user: {email } }, + select: { id: true }, + }); + if(!employee) throw new NotFoundException(`Employee not found for email : ${ email }`); + + //making sure a timesheet exist in selected week + const start_of_week = weekStartMondayUTC(date_only); + let timesheet = await this.prisma.timesheets.findFirst({ + where: { + employee_id: employee.id, + start_date: start_of_week + }, + select: { + id: true + }, + }); + if(!timesheet) { + timesheet = await this.prisma.timesheets.create({ + data: { + employee_id: employee.id, + start_date: start_of_week + }, + select: { + id: true + }, + }); + } + + //normalization of data to ensure a valid comparison between DB and payload + const old_norm = dto.old_shift + ? this.normalize_shift_payload(dto.old_shift) + : undefined; + const new_norm = dto.new_shift + ? this.normalize_shift_payload(dto.new_shift) + : undefined; + + if (old_norm && old_norm.end_time.getTime() <= old_norm.start_time.getTime()) { + throw new UnprocessableEntityException(' old_shift.end_time must be > old_shift.start_time'); + } + if (new_norm && new_norm.end_time.getTime() <= new_norm.start_time.getTime()) { + throw new UnprocessableEntityException(' new_shift.end_time must be > new_shift.start_time'); + } + + //Resolve bank_code_id with type + const old_bank_code_id = old_norm + ? await this.lookup_bank_code_id_or_throw(old_norm.type) + : undefined; + const new_bank_code_id = new_norm + ? await this.lookup_bank_code_id_or_throw(new_norm.type) + : undefined; + + //fetch all shifts in a single day + const day_shifts = await this.prisma.shifts.findMany({ + where: { + timesheet_id: timesheet.id, + date: date_only + }, + include: { + bank_code: true + }, + orderBy: { + start_time: 'asc' + }, + }); + + const result = await this.prisma.$transaction(async (transaction)=> { + let action: UpsertAction; + + const find_exact_old_shift = async ()=> { + if(!old_norm || old_bank_code_id === undefined) return undefined; + const old_comment = old_norm.comment ?? null; + + return transaction.shifts.findFirst({ + where: { + timesheet_id: timesheet.id, + date: date_only, + start_time: old_norm.start_time, + end_time: old_norm.end_time, + is_remote: old_norm.is_remote, + comment: old_comment, + bank_code_id: old_bank_code_id, + }, + select: { id: true }, + }); + }; + + //checks for overlaping shifts + const assert_no_overlap = (exclude_shift_id?: number)=> { + if (!new_norm) return; + + const overlap_with = day_shifts.filter((shift)=> { + if(exclude_shift_id && shift.id === exclude_shift_id) return false; + return this.overlaps( + new_norm.start_time.getTime(), + new_norm.end_time.getTime(), + shift.start_time.getTime(), + shift.end_time.getTime(), + ); + }); + + if(overlap_with.length > 0) { + const conflicts = overlap_with.map((shift)=> ({ + start_time: this.format_hhmm(shift.start_time), + end_time: this.format_hhmm(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, + }); + } + }; + + // DELETE + if ( dto.old_shift && !dto.new_shift ) { + const existing = await find_exact_old_shift(); + if(!existing) { + throw new NotFoundException({ + error_code: 'SHIFT_STALE', + message: 'The shift was modified or deleted by someone else', + }); + } + await transaction.shifts.delete({ where: { id: existing.id } } ); + action = 'deleted'; + } + // CREATE + else if (!dto.old_shift && dto.new_shift) { + assert_no_overlap(); + await transaction.shifts.create({ + data: { + timesheet_id: timesheet.id, + date: date_only, + start_time: new_norm!.start_time, + end_time: new_norm!.end_time, + is_remote: new_norm!.is_remote, + comment: new_norm!.comment ?? null, + bank_code_id: new_bank_code_id!, + }, + }); + action = 'created'; + } + //UPDATE + else { + const existing = await find_exact_old_shift(); + if(!existing) { + throw new NotFoundException({ + error_code: 'SHIFT_STALE', + message: 'The shift was modified or deleted by someone else', + }); + } + assert_no_overlap(existing.id); + await transaction.shifts.update({ + where: { + id: existing.id + }, + data: { + start_time: new_norm!.start_time, + end_time: new_norm!.end_time, + is_remote: new_norm!.is_remote, + comment: new_norm!.comment ?? null, + bank_code_id: new_bank_code_id, + }, + }); + action = 'updated'; + } + + //Reload the day (truth source) + const fresh_day = await transaction.shifts.findMany({ + where: { + timesheet_id: timesheet.id, + date: date_only, + }, + include: { + bank_code: true + }, + orderBy: { + start_time: 'asc' + }, + }); + + return { + action, + day: fresh_day.map((shift)=> ({ + start_time: this.format_hhmm(shift.start_time), + end_time: this.format_hhmm(shift.end_time), + type: shift.bank_code?.type ?? 'UNKNOWN', + is_remote: shift.is_remote, + comment: shift.comment ?? null, + })), + }; + }); + return result; + } + + + private normalize_shift_payload(payload: ShiftPayloadDto) { + //normalize shift's infos + const start_time = timeFromHHMMUTC(payload.start_time); + const end_time = timeFromHHMMUTC(payload.end_time ); + const type = (payload.type || '').trim().toUpperCase(); + const is_remote = payload.is_remote === true; + //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; + + return { start_time, end_time, type, is_remote, comment }; + } + + private async lookup_bank_code_id_or_throw(type: string): Promise { + const bank = await 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; + } + + private 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; + } + + private format_hhmm(time: Date): string { + const hh = String(time.getUTCHours()).padStart(2,'0'); + const mm = String(time.getUTCMinutes()).padStart(2,'0'); + return `${hh}:${mm}`; + } + + + + //approval methods + protected get delegate() { return this.prisma.shifts; } @@ -20,4 +288,17 @@ export class ShiftsCommandService extends BaseApprovalService { this.updateApprovalWithTransaction(transaction, id, is_approved), ); } + + + + + + /* + old without new = delete + new without old = post + old with new = patch old with new + */ + async upsertShift(old_shift?: UpsertShiftDto, new_shift?: UpsertShiftDto) { + + } } \ No newline at end of file