From c274550a91dfd44bfae2a58e29fe2e59ed5ae9ca Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Mon, 3 Nov 2025 10:15:40 -0500 Subject: [PATCH] refactor(shifts): removed email from param of create shift and used req-user data instead --- .../shifts/controllers/shift.controller.ts | 8 +- .../shifts/services/shifts-upsert.service.ts | 151 +++++++++++++----- 2 files changed, 114 insertions(+), 45 deletions(-) diff --git a/src/time-and-attendance/modules/time-tracker/shifts/controllers/shift.controller.ts b/src/time-and-attendance/modules/time-tracker/shifts/controllers/shift.controller.ts index 6928cc8..e26891b 100644 --- a/src/time-and-attendance/modules/time-tracker/shifts/controllers/shift.controller.ts +++ b/src/time-and-attendance/modules/time-tracker/shifts/controllers/shift.controller.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Body, Controller, Delete, Param, Patch, Post } from "@nestjs/common"; +import { BadRequestException, Body, Controller, Delete, Param, Patch, Post, Req } from "@nestjs/common"; import { CreateShiftResult, UpdateShiftResult } from "src/time-and-attendance/utils/type.utils"; import { ShiftsUpsertService } from "src/time-and-attendance/modules/time-tracker/shifts/services/shifts-upsert.service"; import { UpdateShiftDto } from "src/time-and-attendance/modules/time-tracker/shifts/dtos/shift-update.dto"; @@ -10,11 +10,13 @@ export class ShiftController { constructor( private readonly upsert_service: ShiftsUpsertService ){} @Post('create') - createBatch( + createBatch( + @Req() req, @Body()dtos: ShiftDto[]): Promise { + const email = req.user?.email; const list = Array.isArray(dtos) ? dtos : []; if(list.length === 0) throw new BadRequestException('Body is missing or invalid (create shifts)'); - return this.upsert_service.createShifts(dtos) + return this.upsert_service.createShifts(email, dtos) } diff --git a/src/time-and-attendance/modules/time-tracker/shifts/services/shifts-upsert.service.ts b/src/time-and-attendance/modules/time-tracker/shifts/services/shifts-upsert.service.ts index e16797c..5a1e6c4 100644 --- a/src/time-and-attendance/modules/time-tracker/shifts/services/shifts-upsert.service.ts +++ b/src/time-and-attendance/modules/time-tracker/shifts/services/shifts-upsert.service.ts @@ -1,5 +1,5 @@ import { CreateShiftResult, NormedOk, NormedErr, UpdateShiftResult, UpdateShiftPayload, UpdateShiftChanges, Normalized } from "src/time-and-attendance/utils/type.utils"; -import { overlaps, toStringFromHHmm, toStringFromDate, toDateFromString, toHHmmFromString } from "src/time-and-attendance/utils/date-time.utils"; +import { overlaps, toStringFromHHmm, toStringFromDate, toDateFromString, toHHmmFromString, weekStartSunday } from "src/time-and-attendance/utils/date-time.utils"; import { Injectable, BadRequestException, ConflictException, NotFoundException } from "@nestjs/common"; import { OvertimeService } from "src/time-and-attendance/domains/services/overtime.service"; import { UpdateShiftDto } from "src/time-and-attendance/modules/time-tracker/shifts/dtos/shift-update.dto"; @@ -7,6 +7,7 @@ import { PrismaService } from "src/prisma/prisma.service"; import { shift_select } from "src/time-and-attendance/utils/selects.utils"; import { GetShiftDto } from "src/time-and-attendance/modules/time-tracker/shifts/dtos/shift-get.dto"; import { ShiftDto } from "src/time-and-attendance/modules/time-tracker/shifts/dtos/shift-create.dto"; +import { EmailToIdResolver } from "src/time-and-attendance/modules/shared/utils/resolve-email-id.utils"; @@ -15,6 +16,7 @@ export class ShiftsUpsertService { constructor( private readonly prisma: PrismaService, private readonly overtime: OvertimeService, + private readonly emailResolver: EmailToIdResolver, ) { } //_________________________________________________________________ @@ -25,76 +27,140 @@ export class ShiftsUpsertService { //checks for overlaping shifts //create new shifts //calculate overtime - async createShifts(dtos: ShiftDto[]): Promise { + async createShifts(email: string, dtos: ShiftDto[]): Promise { if (!Array.isArray(dtos) || dtos.length === 0) return []; - const normed_shift: Array = dtos.map((dto, index) => { - try { - const normed = this.normalizeShiftDto(dto); - if (normed.end_time <= normed.start_time) { - return { index, error: new BadRequestException(`end_time must be greater than start_time (index ${index})`) }; + const employee_id = await this.emailResolver.findIdByEmail(email); + + const normed_shifts = await Promise.all( + dtos.map(async (dto, index) => { + try { + const normed = this.normalizeShiftDto(dto); + if (normed.end_time <= normed.start_time) { + return { + index, + error: new BadRequestException( + `end_time must be greater than start_time (index ${index})` + ), + }; + } + + const start_date = weekStartSunday(normed.date); + + const timesheet = await this.prisma.timesheets.findFirst({ + where: { start_date, employee_id }, + select: { id: true }, + }); + if (!timesheet) { + return { + index, + error: new NotFoundException(`Timesheet not found`), + }; + + } + + return { + index, + dto, + normed, + timesheet_id: timesheet.id, + }; + } catch (error) { + return { index, error }; } - return { index, dto, normed }; - } catch (error) { - return { index, error }; - } - }); - const ok_items = normed_shift.filter((x): x is NormedOk => "normed" in x); + })); - const regroup_by_date = new Map(); + const ok_items = normed_shifts.filter( + (item): item is NormedOk & { timesheet_id: number } => "normed" in item); - ok_items.forEach(({ index, normed }) => { - const d = normed.date; - const key = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + const regroup_by_date = new Map(); + ok_items.forEach(({ index, normed, timesheet_id }) => { + const day = new Date(normed.date.getFullYear(), normed.date.getMonth(), normed.date.getDate()).getTime(); + const key = `${timesheet_id}|${day}`; if (!regroup_by_date.has(key)) regroup_by_date.set(key, []); regroup_by_date.get(key)!.push(index); }); + const timesheet_keys = Array.from(regroup_by_date.keys()).map((raw) => { + const [timesheet, day] = raw.split('|'); + return { + timesheet_id: Number(timesheet), + day: Number(day), + key: raw, + }; + }); + for (const indices of regroup_by_date.values()) { const ordered = indices .map(index => { - const item = normed_shift[index] as NormedOk; - return { index: index, start: item.normed.start_time, end: item.normed.end_time }; + const item = normed_shifts[index] as NormedOk & { timesheet_id: number }; + return { + index: index, + start: item.normed.start_time, + end: item.normed.end_time + }; }) .sort((a, b) => a.start.getTime() - b.start.getTime()); for (let j = 1; j < ordered.length; j++) { - if (overlaps({ start: ordered[j - 1].start, end: ordered[j - 1].end }, { start: ordered[j].start, end: ordered[j].end })) { - const err = new ConflictException({ + if ( + overlaps( + { start: ordered[j - 1].start, end: ordered[j - 1].end }, + { start: ordered[j].start, end: ordered[j].end } + ) + ) { + const error = new ConflictException({ error_code: 'SHIFT_OVERLAP_BATCH', message: 'New shift overlaps with another shift in the same batch (same day).', }); return dtos.map((_dto, key) => indices.includes(key) - ? ({ ok: false, error: err } as CreateShiftResult) - : ({ ok: false, error: new BadRequestException('Batch aborted due to overlaps in another date group') }) + ? ({ + ok: false, + error + } as CreateShiftResult) + : ({ + ok: false, + error: new BadRequestException( + 'Batch aborted due to overlaps in another date group' + ), + }), ); } } } return this.prisma.$transaction(async (tx) => { - const results: CreateShiftResult[] = Array.from({ length: dtos.length }, () => ({ ok: false, error: new Error('uninitialized') })); + const results: CreateShiftResult[] = Array.from( + { length: dtos.length }, + () => ({ ok: false, error: new Error('uninitialized') })); + const existing_map = new Map(); - normed_shift.forEach((x, i) => { + for (const { timesheet_id, day, key } of timesheet_keys) { + const day_date = new Date(day); + const rows = await tx.shifts.findMany({ + where: { timesheet_id, date: day_date }, + select: { start_time: true, end_time: true }, + }); + existing_map.set( + key, + rows.map((row) => ({ start_time: row.start_time, end_time: row.end_time })), + ); + } + + normed_shifts.forEach((x, i) => { if ("error" in x) results[i] = { ok: false, error: x.error }; }); - const unique_dates = Array.from(regroup_by_date.keys()).map(ms => new Date(ms)); - const existing_date = new Map(); - for (const d of unique_dates) { - const rows = await tx.shifts.findMany({ - where: { date: d }, - select: { start_time: true, end_time: true }, - }); - existing_date.set(d.getTime(), rows.map(r => ({ start_time: r.start_time, end_time: r.end_time }))); - } - for (const item of ok_items) { - const { index, dto, normed } = item; - const dayKey = new Date(normed.date.getFullYear(), normed.date.getMonth(), normed.date.getDate()).getTime(); - const existing = existing_date.get(dayKey) ?? []; - + const { index, dto, normed, timesheet_id } = item; + const day_key = new Date(normed.date.getFullYear(), normed.date.getMonth(), normed.date.getDate()).getTime(); + const map_key = `${timesheet_id}|${day_key}`; + let existing = existing_map.get(map_key); + if(!existing) { + existing = []; + existing_map.set(map_key, existing); + } const hit = existing.find(e => overlaps({ start: e.start_time, end: e.end_time }, { start: normed.start_time, end: normed.end_time })); if (hit) { results[index] = { @@ -114,7 +180,7 @@ export class ShiftsUpsertService { const row = await tx.shifts.create({ data: { - timesheet_id: dto.timesheet_id, + timesheet_id: timesheet_id, bank_code_id: dto.bank_code_id, date: normed.date, start_time: normed.start_time, @@ -126,10 +192,11 @@ export class ShiftsUpsertService { }); existing.push({ start_time: row.start_time, end_time: row.end_time }); + existing_map.set(map_key, existing); - const summary = await this.overtime.getWeekOvertimeSummary(dto.timesheet_id, normed.date, tx); + const summary = await this.overtime.getWeekOvertimeSummary(timesheet_id, normed.date, tx); const shift: GetShiftDto = { - timesheet_id: row.timesheet_id, + timesheet_id: timesheet_id, bank_code_id: row.bank_code_id, date: toStringFromDate(row.date), start_time: toStringFromHHmm(row.start_time),