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,