import { toDateFromString, toHHmmFromString, toStringFromDate, toStringFromHHmm } from "../../../../utils/date-time-helpers"; import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"; import { OvertimeService, WeekOvertimeSummary } from "src/time-and-attendance/domains/services/overtime.service"; import { updateShiftDto } from "../dtos/shift-update.dto"; import { PrismaService } from "src/prisma/prisma.service"; import { GetShiftDto } from "../dtos/shift-get.dto"; import { ShiftDto } from "../dtos/shift-create.dto"; import { shift_select } from "src/time-and-attendance/utils/selects.utils"; type Normalized = { date: Date; start_time: Date; end_time: Date; }; export type ShiftWithOvertimeDto = { shift: GetShiftDto; overtime: WeekOvertimeSummary; }; export type CreateResult = { ok: true; data: ShiftWithOvertimeDto } | { ok: false; error: any }; export type UpdatePayload = { id: number; dto: updateShiftDto }; export type UpdateResult = { ok: true; id: number; data: ShiftWithOvertimeDto } | { ok: false; id: number; error: any }; export type DeleteResult = { ok: true; id: number; overtime: WeekOvertimeSummary } | { ok: false; id: number; error: any }; type NormedOk = { index: number; dto: ShiftDto; normed: Normalized }; type NormedErr = { index: number; error: any }; const overlaps = (a: { start: Date; end: Date }, b: { start: Date; end: Date }) => !(a.end <= b.start || a.start >= b.end); @Injectable() export class ShiftsUpsertService { constructor( private readonly prisma: PrismaService, private readonly overtime: OvertimeService, ) { } //_________________________________________________________________ // 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(timesheet_id: number, 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})`) }; } 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(); ok_items.forEach(({ index, normed }) => { const d = normed.date; const key = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); if (!regroup_by_date.has(key)) regroup_by_date.set(key, []); regroup_by_date.get(key)!.push(index); }); 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 }; }) .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({ 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 CreateResult) : ({ ok: false, error: new BadRequestException('Batch aborted due to overlaps in another date group') }) ); } } } return this.prisma.$transaction(async (tx) => { const results: CreateResult[] = Array.from({ length: dtos.length }, () => ({ ok: false, error: new Error('uninitialized') })); normed_shift.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: { timesheet_id, 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 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] = { ok: false, error: new ConflictException({ error_code: 'SHIFT_OVERLAP', message: 'New shift overlaps with existing shift(s)', conflicts: [{ start_time: toStringFromHHmm(hit.start_time), end_time: toStringFromHHmm(hit.end_time), type: 'UNKNOWN', }], }), }; continue; } const row = await tx.shifts.create({ data: { timesheet_id, bank_code_id: dto.bank_code_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, }); existing.push({ start_time: row.start_time, end_time: row.end_time }); const summary = await this.overtime.getWeekOvertimeSummary(timesheet_id, normed.date, tx); const shift: GetShiftDto = { timesheet_id: row.timesheet_id, bank_code_id: row.bank_code_id, 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(updates: UpdatePayload[]): Promise { if (!Array.isArray(updates) || updates.length === 0) return []; return this.prisma.$transaction(async (tx) => { const shift_ids = updates.map(update_shift => update_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.id); if (!existing) { return updates.map(exist => exist.id === update.id ? ({ ok: false, id: update.id, error: new NotFoundException(`Shift with id: ${update.id} not found`) } as UpdateResult) : ({ ok: false, id: exist.id, error: new BadRequestException('Batch aborted due to missing shift') })); } if (existing.is_approved) { return updates.map(exist => exist.id === update.id ? ({ ok: false, id: update.id, error: new BadRequestException('Approved shift cannot be updated') } as UpdateResult) : ({ ok: false, id: exist.id, error: new BadRequestException('Batch aborted due to approved shift in update set') })); } } const planned_updates = updates.map(update => { const exist_shift = regroup_id.get(update.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), }; return { update, exist_shift, normed }; }); const groups = new Map(); function key(timesheet: number, d: Date) { const day_date = new Date(d.getFullYear(), d.getMonth(), d.getDate()); 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.getFullYear(), group.date.getMonth(), group.date.getDate()); const existing = await tx.shifts.findMany({ where: { timesheet_id: group.timesheet_id, date: day_date }, select: { id: true, start_time: true, end_time: true }, }); groups.set(key(group.timesheet_id, day_date), { existing: existing.map(row => ({ id: row.id, start: row.start_time, end: row.end_time })), 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 }, { start: planned.normed.start_time, end: planned.normed.end_time }) ); if (conflict) { return updates.map(exist => exist.id === planned.exist_shift.id ? ({ ok: false, id: exist.id, error: new ConflictException({ error_code: 'SHIFT_OVERLAP', message: 'New shift overlaps with existing shift(s)', conflicts: [{ start_time: toStringFromHHmm(conflict.start), end_time: toStringFromHHmm(conflict.end), type: 'UNKNOWN' }], }) } as UpdateResult) : ({ ok: false, id: exist.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 }); } 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 }, { start: arr[i].start, end: arr[i].end })) { const error = new ConflictException({ error_code: 'SHIFT_OVERLAP_BATCH', message: 'Overlaps between updates within the same day.' }); return updates.map(exist => ({ ok: false, id: exist.id, error: error })); } } } const results: UpdateResult[] = []; 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.bank_code_id !== undefined) data.bank_code_id = dto.bank_code_id; 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, bank_code_id: row.bank_code_id, 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 NotFoundException(`Shift with id #${shift_id} not found`); 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 = (dto: ShiftDto): Normalized => { 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 }; } }