targo-backend/src/modules/shifts/services/shifts-command.service.ts

247 lines
9.3 KiB
TypeScript

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<Shifts> {
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<Shifts> {
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<DayShiftResponse>((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;
}
}