targo-backend/src/time-and-attendance/time-tracker/shifts/services/shifts-upsert.service.ts

457 lines
22 KiB
TypeScript

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<CreateShiftResult[]> {
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<string, number[]>();
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<string, { start_time: Date; end_time: Date, date: Date }[]>();
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<UpdateShiftResult[]> {
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<string, { existing: { start: Date; end: Date; id: number; date: Date; }[], incoming: typeof planned_updates }>();
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<string, { timesheet_id: number; date: Date }>();
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<string, { id: number; start: Date; end: Date; date: Date }[]>();
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<Normalized> => {
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 };
}
}