import { BadRequestException, ConflictException, Injectable, NotFoundException, UnprocessableEntityException } from "@nestjs/common"; import { formatHHmm, toDateOnlyUTC, weekStartMondayUTC } from "../helpers/shifts-date-time-helpers"; import { normalizeShiftPayload, overlaps, resolveBankCodeByType } from "../utils/shifts.utils"; import { DayShiftResponse, UpsertAction } from "../types and interfaces/shifts-upsert.types"; 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"; @Injectable() export class ShiftsCommandService extends BaseApprovalService { constructor(prisma: PrismaService) { super(prisma); } //_____________________________________________________________________________________________ // APPROVAL AND DELEGATE METHODS //_____________________________________________________________________________________________ protected get delegate() { return this.prisma.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), ); } //_____________________________________________________________________________________________ // MASTER CRUD METHOD //_____________________________________________________________________________________________ async upsertShiftsByDate(email:string, date_string: string, dto: UpsertShiftDto): Promise<{ action: UpsertAction; day: DayShiftResponse[] }> { const { old_shift, new_shift } = dto; 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 ? normalizeShiftPayload(dto.old_shift) : undefined; const new_norm = dto.new_shift ? normalizeShiftPayload(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 resolveBankCodeByType(old_norm.type) : undefined; const new_bank_code_id = new_norm ? await resolveBankCodeByType(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 findExactOldShift = 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 assertNoOverlap = (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 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: 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, }); } }; //_____________________________________________________________________________________________ // DELETE //_____________________________________________________________________________________________ if ( old_shift && !new_shift ) { const existing = await findExactOldShift(); 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 (!old_shift && new_shift) { assertNoOverlap(); 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 if (old_shift && new_shift){ 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); 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'; } else { throw new BadRequestException('At least one of old_shift or new_shift must be provided'); } //Reload the day (truth source) const fresh_day = await transaction.shifts.findMany({ where: { date: date_only, timesheet_id: timesheet.id, }, include: { bank_code: true }, orderBy: { start_time: 'asc' }, }); 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, })), }; }); return result; } }