refactor(shifts): refactored shiftCommandService and moved helpers to shifthelpersService

This commit is contained in:
Matthieu Haineault 2025-10-10 16:44:44 -04:00
parent 408e52b4f5
commit 4ff512a207
8 changed files with 324 additions and 268 deletions

View File

@ -3,6 +3,7 @@ import { BadRequestException, Injectable } from "@nestjs/common";
import { ShiftsCommandService } from "src/modules/shifts/services/shifts-command.service";
import { PrismaService } from "src/prisma/prisma.service";
import { LeaveTypes } from "@prisma/client";
import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
@Injectable()
export class LeaveRequestsUtils {
@ -44,7 +45,9 @@ export class LeaveRequestsUtils {
include: { bank_code: true },
});
await this.shiftsCommand.upsertShiftsByDate(email, {
const action: UpsertAction = existing ? 'update' : 'create';
await this.shiftsCommand.upsertShifts(email, action, {
old_shift: existing
? {
date: yyyy_mm_dd,
@ -86,7 +89,7 @@ export class LeaveRequestsUtils {
});
if (!existing) return;
await this.shiftsCommand.upsertShiftsByDate(email, {
await this.shiftsCommand.upsertShifts(email, 'delete', {
old_shift: {
date: yyyy_mm_dd,
start_time: hhmmFromLocal(existing.start_time),

View File

@ -10,6 +10,7 @@ import { ShiftsCommandService } from "../shifts/services/shifts-command.service"
import { SharedModule } from "../shared/shared.module";
import { PrismaService } from "src/prisma/prisma.service";
import { BusinessLogicsModule } from "../business-logics/business-logics.module";
import { ShiftsHelpersService } from "../shifts/helpers/shifts.helpers";
@Module({
imports: [PrismaModule, TimesheetsModule, SharedModule, BusinessLogicsModule],
@ -20,6 +21,7 @@ import { BusinessLogicsModule } from "../business-logics/business-logics.module"
ExpensesCommandService,
ShiftsCommandService,
PrismaService,
ShiftsHelpersService,
],
controllers: [PayPeriodsController],
exports: [

View File

@ -7,6 +7,7 @@ import { ShiftsQueryService } from "../services/shifts-query.service";
import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto";
import { UpsertShiftDto } from "../dtos/upsert-shift.dto";
import { OverviewRow } from "../types-and-interfaces/shifts-overview-row.interface";
import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
@ApiTags('Shifts')
@ApiBearerAuth('access-token')
@ -21,9 +22,9 @@ export class ShiftsController {
@Put('upsert/:email')
async upsert_by_date(
@Param('email') email_param: string,
@Body() payload: UpsertShiftDto,
@Body() payload: UpsertShiftDto, action: UpsertAction,
) {
return this.shiftsCommandService.upsertShiftsByDate(email_param, payload);
return this.shiftsCommandService.upsertShifts(email_param, action, payload);
}
@Patch('approval/:id')
@ -72,55 +73,4 @@ export class ShiftsController {
return Buffer.from('\uFEFF' + header + body, 'utf8');
}
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
// @Post()
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Create shift' })
// @ApiResponse({ status: 201, description: 'Shift created',type: CreateShiftDto })
// @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
// create(@Body() dto: CreateShiftDto): Promise<Shifts> {
// return this.shiftsService.create(dto);
// }
// @Get()
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Find all shifts' })
// @ApiResponse({ status: 201, description: 'List of shifts found',type: CreateShiftDto, isArray: true })
// @ApiResponse({ status: 400, description: 'List of shifts not found' })
// @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
// findAll(@Query() filters: SearchShiftsDto) {
// return this.shiftsService.findAll(filters);
// }
// @Get(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Find shift' })
// @ApiResponse({ status: 201, description: 'Shift found',type: CreateShiftDto })
// @ApiResponse({ status: 400, description: 'Shift not found' })
// findOne(@Param('id', ParseIntPipe) id: number): Promise<Shifts> {
// return this.shiftsService.findOne(id);
// }
// @Patch(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Update shift' })
// @ApiResponse({ status: 201, description: 'Shift updated',type: CreateShiftDto })
// @ApiResponse({ status: 400, description: 'Shift not found' })
// update(@Param('id', ParseIntPipe) id: number,@Body() dto: UpdateShiftsDto): Promise<Shifts> {
// return this.shiftsService.update(id, dto);
// }
// @Delete(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Delete shift' })
// @ApiResponse({ status: 201, description: 'Shift deleted',type: CreateShiftDto })
// @ApiResponse({ status: 400, description: 'Shift not found' })
// remove(@Param('id', ParseIntPipe) id: number): Promise<Shifts> {
// return this.shiftsService.remove(id);
// }
}

View File

@ -0,0 +1,136 @@
import { BadRequestException, UnprocessableEntityException, NotFoundException, ConflictException } from "@nestjs/common";
import { Prisma, Shifts } from "@prisma/client";
import { UpsertShiftDto } from "../dtos/upsert-shift.dto";
import { DayShiftResponse } from "../types-and-interfaces/shifts-upsert.types";
import { normalizeShiftPayload, overlaps } from "../utils/shifts.utils";
import { weekStartSunday, formatHHmm } from "./shifts-date-time-helpers";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
import { OvertimeService } from "src/modules/business-logics/services/overtime.service";
import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
export type Tx = Prisma.TransactionClient;
export type Normalized = Awaited<ReturnType<typeof normalizeShiftPayload>>;
export class ShiftsHelpersService {
constructor(
private readonly bankTypeResolver: BankCodesResolver,
private readonly overtimeService: OvertimeService,
) { }
async ensureTimesheet(tx: Tx, employee_id: number, date_only: Date) {
const start_of_week = weekStartSunday(date_only);
return tx.timesheets.upsert({
where: { employee_id_start_date: { employee_id, start_date: start_of_week } },
update: {},
create: { employee_id, start_date: start_of_week },
select: { id: true },
});
}
async normalizeRequired(
raw: UpsertShiftDto['new_shift'] | UpsertShiftDto['old_shift'] | undefined | null,
label: 'old_shift' | 'new_shift' = 'new_shift',
): Promise<Normalized> {
if (!raw) throw new BadRequestException(`${label} is required`);
const norm = await normalizeShiftPayload(raw);
if (norm.end_time.getTime() <= norm.start_time.getTime()) {
throw new UnprocessableEntityException(` ${label}.end_time must be > ${label}.start_time`);
}
return norm;
}
async resolveBankIdRequired(tx: Tx, type: string, label: 'old_shift' | 'new_shift'): Promise<number> {
const found = await this.bankTypeResolver.findByType(type, tx);
const id = found?.id;
if (typeof id !== 'number') {
throw new NotFoundException(`bank code not found for ${label}.type: ${type ?? ''}`);
}
return id;
}
async getDayShifts(tx: Tx, timesheet_id: number, dateIso: string) {
return tx.shifts.findMany({
where: { timesheet_id, date: dateIso },
include: { bank_code: true },
orderBy: { start_time: 'asc' },
});
}
async assertNoOverlap(
day_shifts: Array<Shifts & { bank_code: { type: string } | null }>,
new_norm: Normalized | undefined,
exclude_id?: number,
) {
if (!new_norm) return;
const conflicts = day_shifts.filter((s) => {
if (exclude_id && s.id === exclude_id) return false;
return overlaps(
new_norm.start_time.getTime(),
new_norm.end_time.getTime(),
s.start_time.getTime(),
s.end_time.getTime(),
);
});
if (conflicts.length) {
const payload = conflicts.map((s) => ({
start_time: formatHHmm(s.start_time),
end_time: formatHHmm(s.end_time),
type: s.bank_code?.type ?? 'UNKNOWN',
}));
throw new ConflictException({
error_code: 'SHIFT_OVERLAP',
message: 'New shift overlaps with existing shift(s)',
conflicts: payload,
});
}
}
async findExactOldShift(
tx: Tx,
params: {
timesheet_id: number;
date_only: Date;
norm: Normalized;
bank_code_id: number;
},
) {
const { timesheet_id, date_only, norm, bank_code_id } = params;
return tx.shifts.findFirst({
where: {
timesheet_id,
date: date_only,
start_time: norm.start_time,
end_time: norm.end_time,
is_remote: norm.is_remote,
is_approved: norm.is_approved,
comment: norm.comment ?? null,
bank_code_id,
},
select: { id: true },
});
}
async afterWriteOvertimeAndLog(tx: Tx, employee_id: number, date_only: Date, action: UpsertAction) {
// Switch regular → weekly overtime si > 40h
await this.overtimeService.transformRegularHoursToWeeklyOvertime(employee_id, date_only, tx);
const [daily, weekly] = await Promise.all([
this.overtimeService.getDailyOvertimeHoursForDay(employee_id, date_only),
this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only),
]);
}
async mapDay(
fresh: Array<Shifts & { bank_code: { type: string } | null }>,
): Promise<DayShiftResponse[]> {
return fresh.map((s) => ({
start_time: formatHHmm(s.start_time),
end_time: formatHHmm(s.end_time),
type: s.bank_code?.type ?? 'UNKNOWN',
is_remote: s.is_remote,
comment: s.comment ?? null,
}));
}
}

View File

@ -1,233 +1,196 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, UnprocessableEntityException } from "@nestjs/common";
import { normalizeShiftPayload, overlaps } from "../utils/shifts.utils";
import { DayShiftResponse, UpsertAction } from "../types-and-interfaces/shifts-upsert.types";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
import { Prisma, Shifts } from "@prisma/client";
import { UpsertShiftDto } from "../dtos/upsert-shift.dto";
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { normalizeShiftPayload } from "../utils/shifts.utils";
import { DayShiftResponse } from "../types-and-interfaces/shifts-upsert.types";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
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";
import { OvertimeService } from "src/modules/business-logics/services/overtime.service";
import { formatHHmm, toDateOnly, weekStartSunday } from "../helpers/shifts-date-time-helpers";
import { PrismaService } from "src/prisma/prisma.service";
import { toDateOnly } from "../helpers/shifts-date-time-helpers";
import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
import { ShiftsHelpersService } from "../helpers/shifts.helpers";
@Injectable()
export class ShiftsCommandService extends BaseApprovalService<Shifts> {
private readonly logger = new Logger(ShiftsCommandService.name);
private readonly logger = new Logger(ShiftsCommandService.name);
constructor(
prisma: PrismaService,
private readonly emailResolver: EmailToIdResolver,
private readonly bankTypeResolver: BankCodesResolver,
private readonly overtimeService: OvertimeService,
) { super(prisma); }
constructor(
prisma: PrismaService,
private readonly emailResolver: EmailToIdResolver,
private readonly helpersService: ShiftsHelpersService,
) { super(prisma); }
//_____________________________________________________________________________________________
// APPROVAL AND DELEGATE METHODS
//_____________________________________________________________________________________________
protected get delegate() {
return this.prisma.shifts;
}
//_____________________________________________________________________________________________
// APPROVAL AND DELEGATE METHODS
//_____________________________________________________________________________________________
protected get delegate() {
return this.prisma.shifts;
}
protected delegateFor(transaction: Prisma.TransactionClient) {
return transaction.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),
);
}
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, dto: UpsertShiftDto):
Promise<{ action: UpsertAction; day: DayShiftResponse[] }> {
const { old_shift, new_shift } = dto;
//_____________________________________________________________________________________________
// MASTER CRUD METHOD
//_____________________________________________________________________________________________
async upsertShifts(
email: string,
action: UpsertAction,
dto: UpsertShiftDto,
): Promise<{
action: UpsertAction;
day: DayShiftResponse[];
}> {
if (!dto.old_shift && !dto.new_shift) throw new BadRequestException('At least one of old or new shift must be provided');
if(!dto.old_shift && !dto.new_shift) {
throw new BadRequestException('At least one of old or new shift must be provided');
}
const date = dto.new_shift?.date ?? dto.old_shift?.date;
if (!date) throw new BadRequestException("A date (YYYY-MM-DD) must be provided in old_shift or new_shift");
if (dto.old_shift?.date && dto.new_shift?.date && dto.old_shift.date !== dto.new_shift.date) {
throw new BadRequestException('old_shift.date and new_shift.date must be identical');
}
const date = new_shift?.date ?? old_shift?.date;
if (!date) throw new BadRequestException("A date (YYYY-MM-DD) must be provided in old_shift or new_shift");
if (old_shift?.date
&& new_shift?.date
&& old_shift.date
!== new_shift.date) throw new BadRequestException("old_shift.date and new_shift.date must be identical");
const employee_id = await this.emailResolver.findIdByEmail(email);//resolve employee id using email
const date_only = toDateOnly(date);
const employee_id = await this.emailResolver.findIdByEmail(email);
if(action === 'create') {
if(!dto.new_shift || dto.old_shift) {
throw new BadRequestException(`Only new_shift must be provided for create`);
}
return this.createShift(employee_id, date, dto);
}
if(action === 'update'){
if(!dto.old_shift || !dto.new_shift) {
throw new BadRequestException(`Both new_shift and old_shift must be provided for update`);
}
return this.updateShift(employee_id, date, dto);
}
if(action === 'delete'){
if(!dto.old_shift || dto.new_shift) {
throw new BadRequestException('Only old_shift must be provided for delete');
}
return this.deleteShift(employee_id, date, dto);
}
throw new BadRequestException(`Unknown action: ${action}`);
}
return this.prisma.$transaction(async (tx) => {
const start_of_week = weekStartSunday(date_only);
//_________________________________________________________________
// CREATE
//_________________________________________________________________
private async createShift(
employee_id: number,
date_iso: string,
dto: UpsertShiftDto,
): Promise<{action: UpsertAction; day: DayShiftResponse[]}> {
return this.prisma.$transaction(async (tx) => {
const date_only = toDateOnly(date_iso);
const { id: timesheet_id } = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
const timesheet = await tx.timesheets.upsert({
where: { employee_id_start_date: { employee_id, start_date: start_of_week } },
update: {},
create: { employee_id, start_date: start_of_week },
select: { id: true },
});
const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift);
const new_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, new_norm_shift.type, 'new_shift');
//validation/sanitation
//resolve bank_code_id using type
const old_norm_shift = old_shift ? await normalizeShiftPayload(old_shift) : undefined;
if (old_norm_shift && old_norm_shift.end_time.getTime() <= old_norm_shift.start_time.getTime()) {
throw new UnprocessableEntityException(' old_shift.end_time must be > old_shift.start_time');
}
const old_bank_code_id: number | undefined = old_norm_shift ? (await this.bankTypeResolver.findByType(old_norm_shift.type, tx))?.id : undefined;
const day_shifts = await this.helpersService.getDayShifts(tx, timesheet_id, date_iso);
const new_norm_shift = new_shift ? await normalizeShiftPayload(new_shift) : undefined;
if (new_norm_shift && new_norm_shift.end_time.getTime() <= new_norm_shift.start_time.getTime()) {
throw new UnprocessableEntityException(' new_shift.end_time must be > new_shift.start_time');
}
const new_bank_code_id: number | undefined = new_norm_shift ? (await this.bankTypeResolver.findByType(new_norm_shift.type, tx))?.id : undefined;
await this.helpersService.assertNoOverlap(day_shifts, new_norm_shift);
await tx.shifts.create({
data: {
timesheet_id,
date: date_only,
start_time: new_norm_shift.start_time,
end_time: new_norm_shift.end_time,
is_remote: new_norm_shift.is_remote,
is_approved: new_norm_shift.is_approved,
comment: new_norm_shift.comment ?? '',
bank_code_id: new_bank_code_id,
},
});
await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only,'create');
const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet_id, date_iso);
return { action: 'create', day: await this.helpersService.mapDay(fresh_shift)};
});
}
//fetch all shifts in a single day and verify possible overlaps
const day_shifts = await tx.shifts.findMany({
where: { timesheet_id: timesheet.id, date: date_only },
include: { bank_code: true },
orderBy: { start_time: 'asc'},
});
//_________________________________________________________________
// UPDATE
//_________________________________________________________________
private async updateShift(
employee_id: number,
date_iso: string,
dto: UpsertShiftDto,
): Promise<{ action: UpsertAction; day: DayShiftResponse[];}>{
return this.prisma.$transaction(async (tx) => {
const date_only = toDateOnly(date_iso);
const { id: timesheet_id } = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
const findExactOldShift = async ()=> {
if(!old_norm_shift || old_bank_code_id === undefined) return undefined;
const old_comment = old_norm_shift.comment ?? null;
const old_norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift');
const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift, 'new_shift');
return await tx.shifts.findFirst({
where: {
timesheet_id: timesheet.id,
date: date_only,
start_time: old_norm_shift.start_time,
end_time: old_norm_shift.end_time,
is_remote: old_norm_shift.is_remote,
is_approved: old_norm_shift.is_approved,
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_shift) return;
const overlap_with = day_shifts.filter((shift)=> {
if(exclude_shift_id && shift.id === exclude_shift_id) return false;
return overlaps(
new_norm_shift.start_time.getTime(),
new_norm_shift.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});
}
};
let action: UpsertAction;
//_____________________________________________________________________________________________
// DELETE
//_____________________________________________________________________________________________
if ( old_shift && !new_shift ) {
if (old_bank_code_id === undefined) throw new NotFoundException(`bank code not found for old_shift.type: ${old_norm_shift?.type ?? ''}`);
const existing = await findExactOldShift();
if(!existing) {
throw new NotFoundException({
error_code: 'SHIFT_STALE',
message: 'The shift was modified or deleted by someone else',
});
}
await tx.shifts.delete({ where: { id: existing.id } } );
action = 'deleted';
}
//_____________________________________________________________________________________________
// CREATE
//_____________________________________________________________________________________________
else if (!old_shift && new_shift) {
if (new_bank_code_id === undefined) throw new NotFoundException(`bank code not found for new_shift.type: ${new_norm_shift?.type ?? ''}`);
assertNoOverlap();
await tx.shifts.create({
data: {
timesheet_id: timesheet.id,
date: date_only,
start_time: new_norm_shift!.start_time,
end_time: new_norm_shift!.end_time,
is_remote: new_norm_shift!.is_remote,
comment: new_norm_shift!.comment ?? null,
bank_code_id: new_bank_code_id!,
},
});
action = 'created';
}
//_____________________________________________________________________________________________
// UPDATE
//_____________________________________________________________________________________________
else if (old_shift && new_shift){
if (old_bank_code_id === undefined) throw new NotFoundException(`bank code not found for old_shift.type: ${old_norm_shift?.type ?? ''}`);
if (new_bank_code_id === undefined) throw new NotFoundException(`bank code not found for new_shift.type: ${new_norm_shift?.type ?? ''}`);
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);
const old_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, old_norm_shift.type, 'old_shift');
const new_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, new_norm_shift.type, 'new_shift');
await tx.shifts.update({
where: {
id: existing.id
},
data: {
start_time: new_norm_shift!.start_time,
end_time: new_norm_shift!.end_time,
is_remote: new_norm_shift!.is_remote,
comment: new_norm_shift!.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');
const day_shifts = await this.helpersService.getDayShifts(tx, timesheet_id, date_iso);
const existing = await this.helpersService.findExactOldShift(tx, {
timesheet_id,
date_only,
norm: old_norm_shift,
bank_code_id: old_bank_code_id,
});
if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else');
//switches regular hours to overtime hours when exceeds 40hrs per week.
await this.overtimeService.transformRegularHoursToWeeklyOvertime(employee_id, date_only, tx);
await this.helpersService.assertNoOverlap(day_shifts, new_norm_shift, existing.id);
//Reload the day (truth source)
const fresh_day = await tx.shifts.findMany({
where: {
date: date_only,
timesheet_id: timesheet.id,
},
include: { bank_code: true },
orderBy: { start_time: 'asc' },
});
await tx.shifts.update({
where: { id: existing.id },
data: {
start_time: new_norm_shift.start_time,
end_time: new_norm_shift.end_time,
is_remote: new_norm_shift.is_remote,
comment: new_norm_shift.comment ?? '',
bank_code_id: new_bank_code_id,
},
});
await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only, 'update');
const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet_id, date_iso);
return { action: 'update', day: await this.helpersService.mapDay(fresh_shift)};
});
try {
const [ daily_overtime, weekly_overtime ] = await Promise.all([
this.overtimeService.getDailyOvertimeHoursForDay(employee_id, date_only),
this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only),
]);
this.logger.debug(`[OVERTIME] employee_id= ${employee_id}, date=${date_only.toISOString().slice(0,10)}
| daily= ${daily_overtime.toFixed(2)}h, weekly: ${weekly_overtime.toFixed(2)}h, (action:${action})`);
} catch (error) {
this.logger.warn(`Failed to compute overtime after ${action} : ${(error as Error).message}`);
}
}
//_________________________________________________________________
// DELETE
//_________________________________________________________________
private async deleteShift(
employee_id: number,
date_iso: string,
dto: UpsertShiftDto,
): Promise<{ action: UpsertAction; day: DayShiftResponse[]; }>{
return this.prisma.$transaction(async (tx) => {
const date_only = toDateOnly(date_iso);
const { id: timesheet_id } = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
const old_norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift');
const old_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, old_norm_shift.type, 'old_shift');
const existing = await this.helpersService.findExactOldShift(tx, {
timesheet_id,
date_only,
norm: old_norm_shift,
bank_code_id: old_bank_code_id,
});
if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else');
await tx.shifts.delete({ where: { id: existing.id } });
await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only, 'delete');
const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet_id, date_iso);
return { action: 'delete', day: await this.helpersService.mapDay(fresh_shift)};
});
}
}
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,
})),
};
});
}
}

View File

@ -6,18 +6,20 @@ import { NotificationsModule } from '../notifications/notifications.module';
import { ShiftsQueryService } from './services/shifts-query.service';
import { ShiftsArchivalService } from './services/shifts-archival.service';
import { SharedModule } from '../shared/shared.module';
import { ShiftsHelpersService } from './helpers/shifts.helpers';
@Module({
imports: [
BusinessLogicsModule,
NotificationsModule,
SharedModule
SharedModule,
],
controllers: [ShiftsController],
providers: [
ShiftsQueryService,
ShiftsCommandService,
ShiftsArchivalService,
ShiftsArchivalService,
ShiftsHelpersService,
],
exports: [
ShiftsQueryService,

View File

@ -5,5 +5,3 @@ export type DayShiftResponse = {
is_remote: boolean;
comment: string | null;
}
export type UpsertAction = 'created' | 'updated' | 'deleted';

View File

@ -8,6 +8,7 @@ import { BusinessLogicsModule } from 'src/modules/business-logics/business-l
import { SharedModule } from '../shared/shared.module';
import { Module } from '@nestjs/common';
import { TimesheetSelectorsService } from './utils-helpers-others/timesheet.selectors';
import { ShiftsHelpersService } from '../shifts/helpers/shifts.helpers';
@Module({
imports: [
@ -21,7 +22,8 @@ import { TimesheetSelectorsService } from './utils-helpers-others/timesheet.sele
ShiftsCommandService,
ExpensesCommandService,
TimesheetArchiveService,
TimesheetSelectorsService,
TimesheetSelectorsService,
ShiftsHelpersService,
],
exports: [
TimesheetsQueryService,