import { CreateShiftResult, NormedOk, 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 { Injectable, BadRequestException, ConflictException, NotFoundException } from "@nestjs/common"; import { shift_select, timesheet_select } from "src/time-and-attendance/utils/selects.utils"; import { BankCodesResolver } from "src/time-and-attendance/utils/resolve-bank-type-id.utils"; import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils"; import { OvertimeService } from "src/time-and-attendance/domains/services/overtime.service"; import { UpdateShiftDto } from "src/time-and-attendance/time-tracker/shifts/dtos/shift-update.dto"; import { PrismaService } from "src/prisma/prisma.service"; import { GetShiftDto } from "src/time-and-attendance/time-tracker/shifts/dtos/shift-get.dto"; import { ShiftDto } from "src/time-and-attendance/time-tracker/shifts/dtos/shift-create.dto"; @Injectable() export class ShiftsUpsertService { constructor( private readonly prisma: PrismaService, private readonly overtime: OvertimeService, private readonly emailResolver: EmailToIdResolver, private readonly typeResolver: BankCodesResolver, ) { } //_________________________________________________________________ // CREATE //_________________________________________________________________ //normalized frontend data to match DB //loads all shifts from a selected day to check for overlaping shifts //checks for overlaping shifts //create new shifts //calculate overtime async createShifts(email: string, dtos: ShiftDto[]): Promise { if (!Array.isArray(dtos) || dtos.length === 0) return []; const employee_id = await this.emailResolver.findIdByEmail(email); const normed_shifts = await Promise.all( dtos.map(async (dto, index) => { try { const normed = await this.normalizeShiftDto(dto); if (normed.end_time <= normed.start_time) { const error = { error_code: 'SHIFT_OVERLAP', conflicts: { start_time: toStringFromHHmm(normed.start_time), end_time: toStringFromHHmm(normed.end_time), date: toStringFromDate(normed.date), }, }; return { index, error }; } if (!normed.end_time) throw new BadRequestException('A shift needs an end_time'); if (!normed.start_time) throw new BadRequestException('A shift needs a start_time'); const timesheet = await this.prisma.timesheets.findUnique({ where: { id: dto.timesheet_id, employee_id }, select: timesheet_select, }); if (!timesheet) { const error = { error_code: 'INVALID_TIMESHEET', conflicts: { start_time: toStringFromHHmm(normed.start_time), end_time: toStringFromHHmm(normed.end_time), date: toStringFromDate(normed.date), }, }; return { index, error }; } return { index, dto, normed, timesheet_id: timesheet.id, }; } catch (error) { return { index, error }; } })); const ok_items = normed_shifts.filter( (item): item is NormedOk & { timesheet_id: number } => "normed" in item); const regroup_by_date = new Map(); ok_items.forEach(({ index, normed, timesheet_id }) => { const day = new Date(normed.date.getUTCFullYear(), normed.date.getUTCMonth(), normed.date.getUTCDate()).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_shifts[index] as NormedOk & { timesheet_id: number }; return { index: index, start: item.normed.start_time, end: item.normed.end_time, date: item.normed.date, }; }) .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, date: ordered[j - 1].date }, { start: ordered[j].start, end: ordered[j].end, date: ordered[j].date }, ) ) { const error = new ConflictException({ error_code: 'SHIFT_OVERLAP', conflicts: { start_time: toStringFromHHmm(ordered[j].start), end_time: toStringFromHHmm(ordered[j].end), date: toStringFromDate(ordered[j].date), }, }); return dtos.map((_dto, key) => indices.includes(key) ? ({ ok: false, error } as CreateShiftResult) : ({ ok: false, error }), ); } } } return this.prisma.$transaction(async (tx) => { const results: CreateShiftResult[] = Array.from( { length: dtos.length }, () => ({ ok: false, error: new Error('uninitialized') })); const existing_map = new Map(); 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, id: true, date: true }, }); existing_map.set(key, rows.map((row) => ({ start_time: row.start_time, end_time: row.end_time, date: row.date }))); } normed_shifts.forEach((x, i) => { if ("error" in x) results[i] = { ok: false, error: x.error }; }); for (const item of ok_items) { const { index, dto, normed, timesheet_id } = item; const day_key = new Date(normed.date.getUTCFullYear(), normed.date.getUTCMonth(), normed.date.getUTCDate()).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(exist => overlaps({ start: exist.start_time, end: exist.end_time, date: exist.date }, { start: normed.start_time, end: normed.end_time, date: normed.date })); if (hit) { results[index] = { ok: false, error: { error_code: 'SHIFT_OVERLAP', conflicts: { start_time: toStringFromHHmm(hit.start_time), end_time: toStringFromHHmm(hit.end_time), date: toStringFromDate(hit.date), }, }, }; continue; } const row = await tx.shifts.create({ data: { timesheet_id: timesheet_id, bank_code_id: normed.id, date: normed.date, start_time: normed.start_time, end_time: normed.end_time, is_remote: dto.is_remote, comment: dto.comment ?? undefined, }, select: shift_select, }); const normalizeHHmm = (value: Date) => toHHmmFromString(toStringFromHHmm(value)); const normalized_row = { start_time: normalizeHHmm(row.start_time), end_time: normalizeHHmm(row.end_time), date: toDateFromString(row.date), }; existing.push(normalized_row); existing_map.set(map_key, existing); const { type: bank_type } = await this.typeResolver.findTypeByBankCodeId(row.bank_code_id); const summary = await this.overtime.getWeekOvertimeSummary(timesheet_id, normed.date, tx); const shift: GetShiftDto = { timesheet_id: timesheet_id, type: bank_type, date: toStringFromDate(row.date), start_time: toStringFromHHmm(row.start_time), end_time: toStringFromHHmm(row.end_time), is_remote: row.is_remote, is_approved: false, comment: row.comment ?? undefined, }; results[index] = { ok: true, data: { shift, overtime: summary } }; } return results; }); } //_________________________________________________________________ // UPDATE //_________________________________________________________________ // finds existing shifts in DB // verify if shifts are already approved // normalized Date and Time format to string // check for valid start and end times // check for overlaping possibility // buil a set of data to manipulate modified data only // update shifts in DB // recalculate overtime after update // return an updated version to display async updateShifts(dtos: UpdateShiftDto[]): Promise { if (!Array.isArray(dtos) || dtos.length === 0) throw new BadRequestException({ error_code: 'SHIFT_MISSING' }); const updates: UpdateShiftPayload[] = await Promise.all(dtos.map((item) => { const { shift_id, ...rest } = item; if (!shift_id) throw new BadRequestException({ error_code: 'SHIFT_INVALID' }); const changes: UpdateShiftChanges = {}; if (rest.date !== undefined) changes.date = rest.date; if (rest.start_time !== undefined) changes.start_time = rest.start_time; if (rest.end_time !== undefined) changes.end_time = rest.end_time; if (rest.type !== undefined) changes.type = rest.type; if (rest.is_remote !== undefined) changes.is_remote = rest.is_remote; if (rest.comment !== undefined) changes.comment = rest.comment; return { shift_id, dto: changes }; })); return this.prisma.$transaction(async (tx) => { const shift_ids = updates.map(update_shift => update_shift.shift_id); const rows = await tx.shifts.findMany({ where: { id: { in: shift_ids } }, select: shift_select, }); const regroup_id = new Map(rows.map(r => [r.id, r])); for (const update of updates) { const existing = regroup_id.get(update.shift_id); if (!existing) { return updates.map(exist => exist.shift_id === update.shift_id ? ({ ok: false, id: update.shift_id, error: new NotFoundException({ error_code: 'SHIFT_MISSING' }) } as UpdateShiftResult) : ({ ok: false, id: exist.shift_id, error: new BadRequestException({ error_code: 'SHIFT_INVALID' }) }) ); } if (existing.is_approved) { return updates.map(exist => exist.shift_id === update.shift_id ? ({ ok: false, id: update.shift_id, error: new BadRequestException({ error_code: 'SHIFT_INVALID' }) } as UpdateShiftResult) : ({ ok: false, id: exist.shift_id, error: new BadRequestException({ error_code: 'SHIFT_INVALID' }) }) ); } } const planned_updates = updates.map(update => { const exist_shift = regroup_id.get(update.shift_id)!; const date_string = update.dto.date ?? toStringFromDate(exist_shift.date); const start_string = update.dto.start_time ?? toStringFromHHmm(exist_shift.start_time); const end_string = update.dto.end_time ?? toStringFromHHmm(exist_shift.end_time); const normed: Normalized = { date: toDateFromString(date_string), start_time: toHHmmFromString(start_string), end_time: toHHmmFromString(end_string), id: exist_shift.id, }; return { update, exist_shift, normed }; }); const groups = new Map(); function key(timesheet: number, d: Date) { const day_date = new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); return `${timesheet}|${day_date.getTime()}`; } const unique_pairs = new Map(); for (const { exist_shift, normed } of planned_updates) { unique_pairs.set(key(exist_shift.timesheet_id, exist_shift.date), { timesheet_id: exist_shift.timesheet_id, date: exist_shift.date }); unique_pairs.set(key(exist_shift.timesheet_id, normed.date), { timesheet_id: exist_shift.timesheet_id, date: normed.date }); } for (const group of unique_pairs.values()) { const day_date = new Date(group.date.getUTCFullYear(), group.date.getUTCMonth(), group.date.getUTCDate()); const existing = await tx.shifts.findMany({ where: { timesheet_id: group.timesheet_id, date: day_date }, select: { id: true, start_time: true, end_time: true, date: true }, }); groups.set(key(group.timesheet_id, day_date), { existing: existing.map(row => ({ id: row.id, start: row.start_time, end: row.end_time, date: row.date, })), incoming: planned_updates }); } for (const planned of planned_updates) { const keys = key(planned.exist_shift.timesheet_id, planned.normed.date); const group = groups.get(keys)!; const conflict = group.existing.find(row => row.id !== planned.exist_shift.id && overlaps({ start: row.start, end: row.end, date: row.date }, { start: planned.normed.start_time, end: planned.normed.end_time, date: planned.normed.date }) ); if (conflict) { return updates.map(exist => exist.shift_id === planned.exist_shift.id ? ({ ok: false, id: exist.shift_id, error:{ error_code: 'SHIFT_OVERLAP', conflicts: { start_time: toStringFromHHmm(conflict.start), end_time: toStringFromHHmm(conflict.end), date: toStringFromDate(conflict.date), }, } } as UpdateShiftResult) : ({ ok: false, id: exist.shift_id, error: new BadRequestException('Batch aborted due to overlap in another update') }) ); } } const regoup_by_day = new Map(); for (const planned of planned_updates) { const keys = key(planned.exist_shift.timesheet_id, planned.normed.date); if (!regoup_by_day.has(keys)) regoup_by_day.set(keys, []); regoup_by_day.get(keys)!.push({ id: planned.exist_shift.id, start: planned.normed.start_time, end: planned.normed.end_time, date: planned.normed.date }); } for (const arr of regoup_by_day.values()) { arr.sort((a, b) => a.start.getTime() - b.start.getTime()); for (let i = 1; i < arr.length; i++) { if (overlaps( { start: arr[i - 1].start, end: arr[i - 1].end, date: arr[i - 1].date }, { start: arr[i].start, end: arr[i].end, date: arr[i].date }) ) { const error = { error_code: 'SHIFT_OVERLAP', conflicts: { start_time: toStringFromHHmm(arr[i].start), end_time: toStringFromHHmm(arr[i].end), date: toStringFromDate(arr[i].date), }, }; return updates.map(exist => ({ ok: false, id: exist.shift_id, error: error })); } } } const results: UpdateShiftResult[] = []; for (const planned of planned_updates) { const data: any = {}; const { dto } = planned.update; if (dto.date !== undefined) data.date = planned.normed.date; if (dto.start_time !== undefined) data.start_time = planned.normed.start_time; if (dto.end_time !== undefined) data.end_time = planned.normed.end_time; if (dto.type !== undefined) data.type = dto.type; if (dto.is_remote !== undefined) data.is_remote = dto.is_remote; if (dto.comment !== undefined) data.comment = dto.comment ?? null; const row = await tx.shifts.update({ where: { id: planned.exist_shift.id }, data, select: shift_select, }); const summary_new = await this.overtime.getWeekOvertimeSummary(row.timesheet_id, planned.exist_shift.date, tx); if (row.date.getTime() !== planned.exist_shift.date.getTime()) { await this.overtime.getWeekOvertimeSummary(row.timesheet_id, row.date, tx); } const shift: GetShiftDto = { timesheet_id: row.timesheet_id, type: data.type, date: toStringFromDate(row.date), start_time: toStringFromHHmm(row.start_time), end_time: toStringFromHHmm(row.end_time), is_approved: row.is_approved, is_remote: row.is_remote, comment: row.comment ?? undefined, }; results.push({ ok: true, id: planned.exist_shift.id, data: { shift, overtime: summary_new } }); } return results; }); } //_________________________________________________________________ // DELETE //_________________________________________________________________ //finds shifts using shit_ids //recalc overtime shifts after delete //blocs deletion if approved async deleteShift(shift_id: number) { return await this.prisma.$transaction(async (tx) => { const shift = await tx.shifts.findUnique({ where: { id: shift_id }, select: { id: true, date: true, timesheet_id: true }, }); if (!shift) throw new ConflictException({ error_code: 'SHIFT_INVALID' }); await tx.shifts.delete({ where: { id: shift_id } }); const summary = await this.overtime.getWeekOvertimeSummary(shift.timesheet_id, shift.date, tx); return { success: true, overtime: summary }; }); } //_________________________________________________________________ // LOCAL HELPERS //_________________________________________________________________ //converts all string hours and date to Date and HHmm formats private normalizeShiftDto = async (dto: ShiftDto): Promise => { const { id: bank_code_id } = await this.typeResolver.findBankCodeIDByType(dto.type); const date = toDateFromString(dto.date); const start_time = toHHmmFromString(dto.start_time); const end_time = toHHmmFromString(dto.end_time); return { date, start_time, end_time, id: bank_code_id }; } }