fix(shifts): rework update and create to match ShiftEntity

This commit is contained in:
Matthieu Haineault 2025-11-06 11:04:55 -05:00
parent 032e1de631
commit c0189dc61d
6 changed files with 149 additions and 142 deletions

View File

@ -15,6 +15,11 @@ export class EmployeesController {
private readonly archiveService: EmployeesArchivalService,
) { }
@Get('profile/:email')
findOneProfile(@Param('email') email: string): Promise<EmployeeProfileItemDto> {
return this.employeesService.findOneProfile(email);
}
@Get('employee-list')
@RolesAllowed(...MANAGER_ROLES)
findListEmployees(): Promise<EmployeeListItemDto[]> {
@ -34,6 +39,8 @@ export class EmployeesController {
return result;
}
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
@ -46,29 +53,6 @@ export class EmployeesController {
// create(@Body() dto: CreateEmployeeDto): Promise<Employees> {
// return this.employeesService.create(dto);
// }
// @Get()
// //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING)
// @ApiOperation({summary: 'Find all employees' })
// @ApiResponse({ status: 200, description: 'List of employees found', type: CreateEmployeeDto, isArray: true })
// @ApiResponse({ status: 400, description: 'List of employees not found' })
// findAll(): Promise<Employees[]> {
// return this.employeesService.findAll();
// }
// @Get(':email')
// //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR,RoleEnum.ACCOUNTING )
// @ApiOperation({summary: 'Find employee' })
// @ApiResponse({ status: 200, description: 'Employee found', type: CreateEmployeeDto })
// @ApiResponse({ status: 400, description: 'Employee not found' })
// findOne(@Param('email', ParseIntPipe) email: string): Promise<Employees> {
// return this.employeesService.findOne(email);
// }
@Get('profile/:email')
findOneProfile(@Param('email') email: string): Promise<EmployeeProfileItemDto> {
return this.employeesService.findOneProfile(email);
}
// @Delete(':email')
// //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR )

View File

@ -23,7 +23,7 @@ export class ShiftController {
}
@Patch('update')
updateBatch( @Body() dtos: UpdateShiftDto[]): Promise<UpdateShiftResult[]>{
updateBatch( @Body() dtos: ShiftDto[]): Promise<UpdateShiftResult[]>{
const list = Array.isArray(dtos) ? dtos: [];
if(list.length === 0) throw new BadRequestException('Body is missing or invalid (update shifts)');
return this.upsert_service.updateShifts(dtos);

View File

@ -1,4 +1,5 @@
export class GetShiftDto {
shift_id: number;
timesheet_id: number;
type: string;
date: string;

View File

@ -0,0 +1,11 @@
export class ShiftEntity {
id: number;
timesheet_id: number;
bank_code_id: number;
date: string;
start_time: string;
end_time: string;
is_remote: boolean;
is_approved: boolean;
comment?: string;
}

View File

@ -1,16 +1,15 @@
import { CreateShiftResult, NormedOk, UpdateShiftResult, UpdateShiftPayload, UpdateShiftChanges, Normalized } from "src/time-and-attendance/utils/type.utils";
import { CreateShiftResult, NormedOk, UpdateShiftResult, 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 { ShiftEntity } from "src/time-and-attendance/time-tracker/shifts/dtos/shift-payload.dto";
import { ShiftDto } from "src/time-and-attendance/time-tracker/shifts/dtos/shift-create.dto";
import { response } from "express";
@Injectable()
export class ShiftsUpsertService {
@ -33,54 +32,59 @@ export class ShiftsUpsertService {
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,
const results: CreateShiftResult[] = [];
const normed_shifts: (NormedOk | undefined)[] = 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),
},
};
} catch (error) {
return { index, error };
results.push({ ok: false, error });
}
}));
const ok_items = normed_shifts.filter(
(item): item is NormedOk & { timesheet_id: number } => "normed" in item);
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),
},
};
results.push({ ok: false, error });
return;
}
const bank_code = await this.typeResolver.findBankCodeIDByType(dto.type);
const entity: ShiftEntity = {
bank_code_id: bank_code.id,
...dto,
};
return {
index,
dto: entity,
normed,
timesheet_id: timesheet.id,
};
} catch (error) {
results.push({ ok: false, error });
return;
}
}));
const ok_items = normed_shifts.filter((item) => item !== undefined);
const regroup_by_date = new Map<string, number[]>();
ok_items.forEach(({ index, normed, timesheet_id }) => {
@ -151,7 +155,7 @@ export class ShiftsUpsertService {
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) => {
ok_items.forEach((x, i) => {
if ("error" in x) results[i] = { ok: false, error: x.error };
});
@ -184,7 +188,7 @@ export class ShiftsUpsertService {
const row = await tx.shifts.create({
data: {
timesheet_id: timesheet_id,
bank_code_id: normed.id,
bank_code_id: normed.bank_code_id,
date: normed.date,
start_time: normed.start_time,
end_time: normed.end_time,
@ -207,6 +211,7 @@ export class ShiftsUpsertService {
const summary = await this.overtime.getWeekOvertimeSummary(timesheet_id, normed.date, tx);
const shift: GetShiftDto = {
shift_id: row.id,
timesheet_id: timesheet_id,
type: bank_type,
date: toStringFromDate(row.date),
@ -235,26 +240,24 @@ export class ShiftsUpsertService {
// update shifts in DB
// recalculate overtime after update
// return an updated version to display
async updateShifts(dtos: UpdateShiftDto[]): Promise<UpdateShiftResult[]> {
async updateShifts(dtos: ShiftDto[]): 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 };
const updates: ShiftEntity[] = await Promise.all(dtos.map(async (item) => {
try {
const bank_code = await this.typeResolver.findBankCodeIDByType(item.type);
return {
bank_code_id: bank_code.id,
...item,
}
} catch (error) {
throw new BadRequestException('INVALID_SHIFT');
}
}));
return this.prisma.$transaction(async (tx) => {
const shift_ids = updates.map(update_shift => update_shift.shift_id);
const shift_ids = updates.map(update_shift => update_shift.id);
const rows = await tx.shifts.findMany({
where: { id: { in: shift_ids } },
select: shift_select,
@ -262,31 +265,28 @@ export class ShiftsUpsertService {
const regroup_id = new Map(rows.map(r => [r.id, r]));
for (const update of updates) {
const existing = regroup_id.get(update.shift_id);
const existing = regroup_id.get(update.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' }) })
return updates.map(exist => exist.id === update.id
? ({ ok: false, id: update.id, error: new NotFoundException({ error_code: 'SHIFT_MISSING' }) } as UpdateShiftResult)
: ({ ok: false, id: exist.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' }) })
return updates.map(exist => exist.id === update.id
? ({ ok: false, id: update.id, error: new BadRequestException({ error_code: 'SHIFT_INVALID' }) } as UpdateShiftResult)
: ({ ok: false, id: exist.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 exist_shift = regroup_id.get(update.id)!;
const normed: Normalized = {
date: toDateFromString(date_string),
start_time: toHHmmFromString(start_string),
end_time: toHHmmFromString(end_string),
id: exist_shift.id,
date: toDateFromString(update.date),
start_time: toHHmmFromString(update.start_time),
end_time: toHHmmFromString(update.end_time),
bank_code_id: exist_shift.bank_code_id,
};
return { update, exist_shift, normed };
});
@ -329,9 +329,9 @@ export class ShiftsUpsertService {
);
if (conflict) {
return updates.map(exist =>
exist.shift_id === planned.exist_shift.id
exist.id === planned.exist_shift.id
? ({
ok: false, id: exist.shift_id, error:{
ok: false, id: exist.id, error: {
error_code: 'SHIFT_OVERLAP',
conflicts: {
start_time: toStringFromHHmm(conflict.start),
@ -340,7 +340,7 @@ export class ShiftsUpsertService {
},
}
} as UpdateShiftResult)
: ({ ok: false, id: exist.shift_id, error: new BadRequestException('Batch aborted due to overlap in another update') })
: ({ ok: false, id: exist.id, error: new BadRequestException('Batch aborted due to overlap in another update') })
);
}
}
@ -373,49 +373,59 @@ export class ShiftsUpsertService {
},
};
return updates.map(exist => ({ ok: false, id: exist.shift_id, error: error }));
return updates.map(exist => ({ ok: false, id: exist.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;
try {
const date = toStringFromDate(planned.normed.date);
const start_time = toStringFromHHmm(planned.normed.start_time);
const end_time = toStringFromHHmm(planned.normed.end_time);
const row = await tx.shifts.update({
where: { id: planned.exist_shift.id },
data,
select: shift_select,
});
const data: Partial<ShiftEntity> = {
bank_code_id: planned.normed.bank_code_id,
date: date,
start_time: start_time,
end_time: end_time,
is_remote: planned.update.is_remote,
is_approved: planned.exist_shift.is_approved,
comment: planned.update.comment,
};
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 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 type = await this.typeResolver.findTypeByBankCodeId(row.bank_code_id);
const dto: GetShiftDto = {
shift_id: row.id,
timesheet_id: row.timesheet_id,
type: type.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: dto, overtime: summary_new } });
} catch (error) {
throw new BadRequestException('INVALID_SHIFT');
}
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;
});
}
//_________________________________________________________________
@ -451,6 +461,6 @@ export class ShiftsUpsertService {
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 };
return { date, start_time, end_time, bank_code_id: bank_code_id };
}
}

View File

@ -4,6 +4,7 @@ import { updateExpenseDto } from "src/time-and-attendance/expenses/dtos/expense-
import { SchedulePresetsDto } from "src/time-and-attendance/time-tracker/schedule-presets/dtos/create-schedule-presets.dto";
import { ShiftDto } from "src/time-and-attendance/time-tracker/shifts/dtos/shift-create.dto";
import { GetShiftDto } from "src/time-and-attendance/time-tracker/shifts/dtos/shift-get.dto";
import { ShiftEntity } from "src/time-and-attendance/time-tracker/shifts/dtos/shift-payload.dto";
import { UpdateShiftDto } from "src/time-and-attendance/time-tracker/shifts/dtos/shift-update.dto";
import { leaveRequestsSelect } from "src/time-and-attendance/utils/selects.utils";
@ -25,7 +26,7 @@ export type TotalExpenses = {
mileage: number;
};
export type Normalized = { date: Date; start_time: Date; end_time: Date; id: number};
export type Normalized = { date: Date; start_time: Date; end_time: Date; bank_code_id: number};
export type ShiftWithOvertimeDto = {
shift: GetShiftDto;
@ -51,7 +52,7 @@ export type DeleteExpenseResult = { ok: true; id: number; } | { ok: false; id: n
export type NormedOk = { index: number; dto: ShiftDto; normed: Normalized };
export type NormedOk = { index: number; dto: ShiftEntity; normed: Normalized, timesheet_id: number };
export type NormedErr = { index: number; error: any };