refactor(module): refactor employees, archives and pay-period to use email instead of id and switch pay-period's requests to transaction

This commit is contained in:
Matthieu Haineault 2025-08-19 08:34:35 -04:00
parent fe87c36884
commit 7a9adeec69
10 changed files with 247 additions and 163 deletions

View File

@ -253,16 +253,16 @@
]
}
},
"/employees/{id}": {
"/employees/{email}": {
"get": {
"operationId": "EmployeesController_findOne",
"parameters": [
{
"name": "id",
"name": "email",
"required": true,
"in": "path",
"schema": {
"type": "number"
"type": "string"
}
}
],
@ -295,10 +295,10 @@
"operationId": "EmployeesController_remove",
"parameters": [
{
"name": "id",
"name": "email",
"required": true,
"in": "path",
"description": "Identifier of the employee to delete",
"description": "Email of the employee to delete",
"schema": {
"type": "number"
}
@ -326,10 +326,10 @@
"operationId": "EmployeesController_updateOrArchiveOrRestore",
"parameters": [
{
"name": "id",
"name": "email",
"required": true,
"in": "path",
"description": "Identifier of the employee",
"description": "Email of the employee",
"schema": {
"type": "number"
}

View File

@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `first_Work_Day` on the `employees_archive` table. All the data in the column will be lost.
- Added the required column `first_work_day` to the `employees_archive` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "public"."employees_archive" DROP COLUMN "first_Work_Day",
ADD COLUMN "first_work_day" DATE NOT NULL;

View File

@ -24,7 +24,7 @@ async function main() {
last_name: e.user.last_name,
external_payroll_id: e.external_payroll_id,
company_code: e.company_code,
first_Work_Day: e.first_work_day,
first_work_day: e.first_work_day,
last_work_day: daysAgo(30),
supervisor_id: e.supervisor_id ?? null,
job_title: e.job_title,

View File

@ -69,7 +69,7 @@ model EmployeesArchive {
is_supervisor Boolean
external_payroll_id Int
company_code Int
first_Work_Day DateTime @db.Date
first_work_day DateTime @db.Date
last_work_day DateTime @db.Date
supervisor_id Int?
supervisor Employees? @relation("EmployeeSupervisorToArchive", fields: [supervisor_id], references: [id])

View File

@ -1,26 +1,52 @@
import { NotFoundException } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { PrismaService } from "src/prisma/prisma.service";
type UpdatableDelegate<T> = {
update(args: {
where: { id: number },
data: { is_approved: boolean },
}): Promise<T>;
};
//abstract class for approving or rejecting a shift, expense, timesheet or pay-period
export abstract class BaseApprovalService<T> {
protected constructor(protected readonly prisma: PrismaService) {}
//returns the corresponding Prisma delegate
protected abstract get delegate(): {
update(args: {where: {id: number };
data: { is_approved: boolean }
}): Promise<T>;
};
protected abstract get delegate(): UpdatableDelegate<T>;
protected abstract delegateFor(transaction: Prisma.TransactionClient): UpdatableDelegate<T>;
//standard update Aproval
async updateApproval(id: number, isApproved: boolean): Promise<T> {
const entity = await this.delegate.update({
try{
return await this.delegate.update({
where: { id },
data: { is_approved: isApproved },
});
}catch (error: any) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") {
throw new NotFoundException(`Entity #${id} not found`);
}
throw error;
}
}
if(!entity) throw new NotFoundException(`Entity #${id} not found`);
return entity;
//approval with transaction to avoid many requests
async updateApprovalWithTx(transaction: Prisma.TransactionClient, id: number, isApproved: boolean): Promise<T> {
try {
return await this.delegateFor(transaction).update({
where: { id },
data: { is_approved: isApproved },
});
} catch (error: any ){
if(error instanceof PrismaClientKnownRequestError && error.code === 'P2025') {
throw new NotFoundException(`Entity #${id} not found`);
}
throw error;
}
}
}

View File

@ -42,13 +42,13 @@ export class EmployeesController {
return this.employeesService.findListEmployees();
}
@Get(':id')
@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('id', ParseIntPipe) id: number): Promise<Employees> {
return this.employeesService.findOne(id);
findOne(@Param('email', ParseIntPipe) email: string): Promise<Employees> {
return this.employeesService.findOne(email);
}
@Get('profile/:email')
@ -60,31 +60,31 @@ export class EmployeesController {
return this.employeesService.findOneProfile(email);
}
@Delete(':id')
@Delete(':email')
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR )
@ApiOperation({summary: 'Delete employee' })
@ApiParam({ name: 'id', type: Number, description: 'Identifier of the employee to delete' })
@ApiParam({ name: 'email', type: Number, description: 'Email of the employee to delete' })
@ApiResponse({ status: 204, description: 'Employee deleted' })
@ApiResponse({ status: 404, description: 'Employee not found' })
remove(@Param('id', ParseIntPipe) id: number): Promise<Employees> {
return this.employeesService.remove(id);
remove(@Param('email', ParseIntPipe) email: string): Promise<Employees> {
return this.employeesService.remove(email);
}
@Patch(':id')
@Patch(':email')
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiBearerAuth('access-token')
@ApiOperation({ summary: 'Update, archive or restore an employee' })
@ApiParam({ name: 'id', type: Number, description: 'Identifier of the employee' })
@ApiParam({ name: 'email', type: Number, description: 'Email of the employee' })
@ApiResponse({ status: 200, description: 'Employee updated or restored', type: CreateEmployeeDto })
@ApiResponse({ status: 202, description: 'Employee archived successfully', type: CreateEmployeeDto })
@ApiResponse({ status: 404, description: 'Employee not found in active or archive' })
async updateOrArchiveOrRestore(@Param('id') id: string, @Body() dto: UpdateEmployeeDto,) {
async updateOrArchiveOrRestore(@Param('email') email: string, @Body() dto: UpdateEmployeeDto,) {
// if last_work_day is set => archive the employee
// else if employee is archived and first_work_day or last_work_day = null => restore
//otherwise => standard update
const result = await this.employeesService.patchEmployee(+id, dto);
const result = await this.employeesService.patchEmployee(email, dto);
if(!result) {
throw new NotFoundException(`Employee #${ id } not found in active or archive.`)
throw new NotFoundException(`Employee with email: ${ email } is not found in active or archive.`)
}
return result;
}

View File

@ -4,7 +4,6 @@ import { CreateEmployeeDto } from '../dtos/create-employee.dto';
import { UpdateEmployeeDto } from '../dtos/update-employee.dto';
import { Employees, EmployeesArchive, Users } from '@prisma/client';
import { EmployeeListItemDto } from '../dtos/list-employee.dto';
import { Roles as RoleEnum } from '@prisma/client';
import { EmployeeProfileItemDto } from '../dtos/profil-employee.dto';
function toDateOrNull(v?: string | null): Date | null {
@ -100,13 +99,15 @@ export class EmployeesService {
);
}
async findOne(id: number): Promise<Employees> {
const emp = await this.prisma.employees.findUnique({
where: { id },
async findOne(email: string): Promise<Employees> {
const emp = await this.prisma.employees.findFirst({
where: { user: { email } },
include: { user: true },
});
//add search for archived employees
if (!emp) {
throw new NotFoundException(`Employee #${id} not found`);
throw new NotFoundException(`Employee with email: ${email} not found`);
}
return emp;
}
@ -156,16 +157,15 @@ export class EmployeesService {
};
}
async update(
id: number,
async update(
email: string,
dto: UpdateEmployeeDto,
): Promise<Employees> {
const emp = await this.findOne(id);
const emp = await this.findOne(email);
const {
first_name,
last_name,
email,
phone_number,
residence,
external_payroll_id,
@ -173,10 +173,18 @@ async update(
job_title,
first_work_day,
last_work_day,
is_supervisor
is_supervisor,
email: newEmail,
} = dto;
return this.prisma.$transaction(async (tx) => {
if(
first_name !== undefined ||
last_name !== undefined ||
newEmail !== undefined ||
phone_number !== undefined ||
residence !== undefined
){
await tx.users.update({
where: { id: emp.user_id },
data: {
@ -187,9 +195,10 @@ async update(
...(residence !== undefined && { residence }),
},
});
}
return tx.employees.update({
where: { id },
const updated = await tx.employees.update({
where: { id: emp.id },
data: {
...(external_payroll_id !== undefined && { external_payroll_id }),
...(company_code !== undefined && { company_code }),
@ -199,36 +208,51 @@ async update(
...(is_supervisor !== undefined && { is_supervisor }),
},
});
return updated;
});
}
async remove(id: number): Promise<Employees> {
await this.findOne(id);
return this.prisma.employees.delete({ where: { id } });
async remove(email: string): Promise<Employees> {
const emp = await this.findOne(email);
return this.prisma.$transaction(async (tx) => {
await tx.employees.updateMany({
where: { supervisor_id: emp.id },
data: { supervisor_id: null },
});
const deletedEmployee = await tx.employees.delete({
where: {id: emp.id },
});
await tx.users.delete({
where: { id: emp.user_id },
});
return deletedEmployee;
});
}
//archivation functions ******************************************************
async patchEmployee(id: number, dto: UpdateEmployeeDto): Promise<Employees | EmployeesArchive | null> {
async patchEmployee(email: string, dto: UpdateEmployeeDto): Promise<Employees | EmployeesArchive | null> {
// 1) Tenter sur employés actifs
const existing = await this.prisma.employees.findUnique({
where: { id },
const active = await this.prisma.employees.findFirst({
where: { user: { email } },
include: { user: true },
});
if (existing) {
if (active) {
// Archivage : si on reçoit un last_work_day défini et que l'employé nest pas déjà terminé
if (dto.last_work_day !== undefined && existing.last_work_day == null && dto.last_work_day !== null) {
return this.archiveOnTermination(existing, dto);
if (dto.last_work_day !== undefined && active.last_work_day == null && dto.last_work_day !== null) {
return this.archiveOnTermination(active, dto);
}
// Sinon, update standard (split Users/Employees)
const {
first_name,
last_name,
email,
email: newEmail,
phone_number,
residence,
external_payroll_id,
@ -238,54 +262,59 @@ async patchEmployee(id: number, dto: UpdateEmployeeDto): Promise<Employees | Emp
last_work_day,
supervisor_id,
is_supervisor,
} = dto;
} = dto as any;
const fw = toDateOrUndefined(first_work_day);
const lw = (dto.hasOwnProperty('last_work_day'))
const first_work_d = toDateOrUndefined(first_work_day);
const last_work_d = Object.prototype.hasOwnProperty('last_work_day')
? toDateOrNull(last_work_day ?? null)
: undefined;
await this.prisma.$transaction(async (tx) => {
const willUpdateUser =
if(
first_name !== undefined ||
last_name !== undefined ||
email !== undefined ||
newEmail !== undefined ||
phone_number !== undefined ||
residence !== undefined;
if (willUpdateUser) {
residence !== undefined
) {
await tx.users.update({
where: { id: existing.user_id },
where: { id: active.user_id },
data: {
...(first_name !== undefined ? { first_name } : {}),
...(last_name !== undefined ? { last_name } : {}),
...(email !== undefined ? { email } : {}),
...(email !== undefined ? { email: newEmail }: {}),
...(phone_number !== undefined ? { phone_number } : {}),
...(residence !== undefined ? { residence } : {}),
...(is_supervisor !== undefined ? { is_supervisor }: {}),
},
});
}
await tx.employees.update({
where: { id },
const updated = await tx.employees.update({
where: { id: active.id },
data: {
...(external_payroll_id !== undefined ? { external_payroll_id } : {}),
...(company_code !== undefined ? { company_code } : {}),
...(job_title !== undefined ? { job_title } : {}),
...(fw !== undefined ? { first_work_day: fw } : {}),
...(lw !== undefined ? { last_work_day: lw } : {}),
...(first_work_d !== undefined ? { first_work_day: first_work_d } : {}),
...(last_work_d !== undefined ? { last_work_day: last_work_d } : {}),
...(is_supervisor !== undefined ? { is_supervisor } : {}),
...(supervisor_id !== undefined ? { supervisor_id } : {}),
},
});
include: { user: true },
});
return this.prisma.employees.findUnique({ where: { id } });
return updated;
});
return this.prisma.employees.findFirst({ where: { user: {email} } });
}
const user = await this.prisma.users.findUnique({where: {email}});
if(!user) return null;
// 2) Pas trouvé en actifs → regarder en archive (pour restauration)
const archived = await this.prisma.employeesArchive.findFirst({
where: { employee_id: id },
where: { user_id: user.id },
include: { user: true },
});
@ -296,51 +325,57 @@ async patchEmployee(id: number, dto: UpdateEmployeeDto): Promise<Employees | Emp
return this.restoreEmployee(archived, dto);
}
}
// 3) Ni actif, ni archivé → 404 dans le controller
return null;
}
//transfers the employee to archive and then delete from employees table
private async archiveOnTermination(existing: any, dto: UpdateEmployeeDto): Promise<any> {
private async archiveOnTermination(active: Employees & {user: Users }, dto: UpdateEmployeeDto): Promise<EmployeesArchive> {
const last_work_d = toDateOrNull(dto.last_work_day!);
if(!last_work_d) throw new Error('invalide last_work_day for archive');
return this.prisma.$transaction(async transaction => {
//archive insertion
//detach crew from supervisor if employee is a supervisor
await transaction.employees.updateMany({
where: { supervisor_id: active.id },
data: { supervisor_id: null },
})
const archived = await transaction.employeesArchive.create({
data: {
employee_id: existing.id,
user_id: existing.user_id,
first_name: existing.first_name,
last_name: existing.last_name,
external_payroll_id: existing.external_payroll_id,
company_code: existing.company_code,
job_title: existing.job_title,
first_Work_Day: existing.first_Work_Day,
last_work_day: existing.last_work_day,
supervisor_id: existing.supervisor_id ?? null,
is_supervisor: existing.is_supervisor,
employee_id: active.id,
user_id: active.user_id,
first_name: active.user.first_name,
last_name: active.user.last_name,
external_payroll_id: active.external_payroll_id,
company_code: active.company_code,
job_title: active.job_title,
first_work_day: active.first_work_day,
last_work_day: last_work_d,
supervisor_id: active.supervisor_id ?? null,
is_supervisor: active.is_supervisor,
},
include: { user: true}
});
//delete from employees table
await transaction.employees.delete({ where: { id: existing.id } });
await transaction.employees.delete({ where: { id: active.id } });
//return archived employee
return archived
});
}
//transfers the employee from archive to the employees table
private async restoreEmployee(archived: any, dto: UpdateEmployeeDto): Promise<any> {
private async restoreEmployee(archived: EmployeesArchive & { user:Users }, dto: UpdateEmployeeDto): Promise<Employees> {
const first_work_d = toDateOrUndefined(dto.first_work_day);
return this.prisma.$transaction(async transaction => {
//restores the archived employee into the employees table
const restored = await transaction.employees.create({
data: {
id: archived.employee_id,
user_id: archived.user_id,
external_payroll_id: dto.external_payroll_id ?? archived.external_payroll_id,
company_code: dto.company_code ?? archived.company_code,
job_title: dto.job_title ?? archived.job_title,
first_work_day: dto.first_work_day ?? archived.first_Work_Day,
external_payroll_id: archived.external_payroll_id,
company_code: archived.company_code,
job_title: archived.job_title,
first_work_day: archived.first_work_day,
last_work_day: null,
supervisor_id: dto.supervisor_id ?? archived.supervisor_id,
is_supervisor: archived.is_supervisor ?? false,
},
});
//deleting archived entry by id

View File

@ -11,11 +11,11 @@ export class PayPeriodsCommandService {
async approvalPayPeriod(year: number , periodNumber: number): Promise<void> {
const period = await this.prisma.payPeriods.findFirst({
where: { period_number: periodNumber },
where: { year, period_number: periodNumber},
});
if (!period) throw new NotFoundException(`PayPeriod #${periodNumber} not found`);
if (!period) throw new NotFoundException(`PayPeriod #${year}-${periodNumber} not found`);
//fetches timesheet of selected period if the timesheet as atleast 1 shift or 1 expense
//fetches timesheet of selected period if the timesheet has atleast 1 shift or 1 expense
const timesheetList = await this.prisma.timesheets.findMany({
where: {
OR: [
@ -31,11 +31,14 @@ export class PayPeriodsCommandService {
},
],
},
select: { id: true },
});
//approval of both timesheet (cascading to the approval of related shifts and expenses)
for(const timesheet of timesheetList) {
await this.timesheetsApproval.updateApproval(timesheet.id, true);
await this.prisma.$transaction(async (transaction)=> {
for(const {id} of timesheetList) {
await this.timesheetsApproval.updateApprovalWithTx(transaction,id, true);
}
})
}
}

View File

@ -23,19 +23,19 @@ export class PayPeriodsQueryService {
}
async getOverviewByYearPeriod(year: number, periodNumber: number): Promise<PayPeriodOverviewDto> {
const p = computePeriod(year, periodNumber);
const period = computePeriod(year, periodNumber);
return this.buildOverview({
start_date: p.start_date,
end_date : p.end_date,
period_number: p.period_number,
year: p.year,
label:p.label,
start_date: period.start_date,
end_date : period.end_date,
period_number: period.period_number,
year: period.year,
label:period.label,
} as any);
}
private async buildOverview(
period: { start_date: string | Date; end_date: string | Date; period_number: number; year: number; label: string; },
opts?: { restrictEmployeeIds?: number[]; seedNames?: Map<number, string> },
options?: { filteredEmployeeIds?: number[]; seedNames?: Map<number, string> },
): Promise<PayPeriodOverviewDto> {
const toDateString = (d: Date) => d.toISOString().slice(0, 10);
const toMoney = (v: any) => (typeof v === "object" && "toNumber" in v ? v.toNumber() : (v as number));
@ -48,7 +48,8 @@ export class PayPeriodsQueryService {
? period.end_date
: new Date(`${period.end_date}T00:00:00.000Z`);
const whereEmployee = opts?.restrictEmployeeIds?.length ? { employee_id: { in: opts.restrictEmployeeIds } }: {};
//restrictEmployeeIds = filter for shifts and expenses by employees
const whereEmployee = options?.filteredEmployeeIds?.length ? { employee_id: { in: options.filteredEmployeeIds } }: {};
// SHIFTS (filtered by crew)
const shifts = await this.prisma.shifts.findMany({
@ -99,8 +100,8 @@ export class PayPeriodsQueryService {
const byEmployee = new Map<number, EmployeePeriodOverviewDto>();
// seed for employee without data
if (opts?.seedNames) {
for (const [id, name] of opts.seedNames.entries()) {
if (options?.seedNames) {
for (const [id, name] of options.seedNames.entries()) {
byEmployee.set(id, {
employee_id: id,
employee_name: name,
@ -132,37 +133,37 @@ export class PayPeriodsQueryService {
return byEmployee.get(id)!;
};
for (const s of shifts) {
const e = s.timesheet.employee;
const name = `${e.user.first_name} ${e.user.last_name}`.trim();
const rec = ensure(e.id, name);
for (const shift of shifts) {
const employee = shift.timesheet.employee;
const name = `${employee.user.first_name} ${employee.user.last_name}`.trim();
const rec = ensure(employee.id, name);
const hours = computeHours(s.start_time, s.end_time);
const cat = (s.bank_code?.categorie || "REGULAR").toUpperCase();
switch (cat) {
const hours = computeHours(shift.start_time, shift.end_time);
const categorie = (shift.bank_code?.categorie || "REGULAR").toUpperCase();
switch (categorie) {
case "EVENING": rec.evening_hours += hours; break;
case "EMERGENCY":
case "URGENT": rec.emergency_hours += hours; break;
case "OVERTIME": rec.overtime_hours += hours; break;
default: rec.regular_hours += hours; break;
}
rec.is_approved = rec.is_approved && s.timesheet.is_approved;
rec.is_approved = rec.is_approved && shift.timesheet.is_approved;
}
for (const ex of expenses) {
const e = ex.timesheet.employee;
const name = `${e.user.first_name} ${e.user.last_name}`.trim();
const rec = ensure(e.id, name);
for (const expense of expenses) {
const exp = expense.timesheet.employee;
const name = `${exp.user.first_name} ${exp.user.last_name}`.trim();
const record = ensure(exp.id, name);
const amount = toMoney(ex.amount);
rec.expenses += amount;
const amount = toMoney(expense.amount);
record.expenses += amount;
const cat = (ex.bank_code?.categorie || "").toUpperCase();
const rate = ex.bank_code?.modifier ?? 0;
if (cat === "MILEAGE" && rate > 0) {
rec.mileage += amount / rate;
const categorie = (expense.bank_code?.categorie || "").toUpperCase();
const rate = expense.bank_code?.modifier ?? 0;
if (categorie === "MILEAGE" && rate > 0) {
record.mileage += amount / rate;
}
rec.is_approved = rec.is_approved && ex.timesheet.is_approved;
record.is_approved = record.is_approved && expense.timesheet.is_approved;
}
const employees_overview = Array.from(byEmployee.values()).sort((a, b) =>
@ -199,7 +200,7 @@ export class PayPeriodsQueryService {
const seedNames = new Map<number, string>(crew.map(c => [c.id, `${c.first_name} ${c.last_name}`.trim()]));
// 4) overview build
return this.buildOverview(period, { restrictEmployeeIds: crewIds, seedNames });
return this.buildOverview(period, { filteredEmployeeIds: crewIds, seedNames });
}
private async resolveCrew(supervisorId: number, includeSubtree: boolean): Promise<Array<{ id: number; first_name: string; last_name: string }>> {

View File

@ -1,6 +1,6 @@
import { Injectable } from "@nestjs/common";
import { Timesheets } from "@prisma/client";
import { Prisma, Timesheets } from "@prisma/client";
import { BaseApprovalService } from "src/common/shared/base-approval.service";
import { PrismaService } from "src/prisma/prisma.service";
@ -11,16 +11,25 @@ export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
protected get delegate() {
return this.prisma.timesheets;
}
protected delegateFor(transaction: Prisma.TransactionClient) {
return transaction.timesheets;
}
async updateApproval(timesheetId: number, isApproved: boolean): Promise<Timesheets> {
const timesheet = await super.updateApproval(timesheetId, isApproved);
async updateApproval(id: number, isApproved: boolean): Promise<Timesheets> {
return this.prisma.$transaction((transaction) =>
this.updateApprovalWithTx(transaction, id, isApproved),
);
}
await this.prisma.shifts.updateMany({
async cascadeApprovalWithtx(transaction: Prisma.TransactionClient, timesheetId: number, isApproved: boolean): Promise<Timesheets> {
const timesheet = await this.updateApprovalWithTx(transaction, timesheetId, isApproved);
await transaction.shifts.updateMany({
where: { timesheet_id: timesheetId },
data: { is_approved: isApproved },
});
await this.prisma.expenses.updateMany({
await transaction.expenses.updateManyAndReturn({
where: { timesheet_id: timesheetId },
data: { is_approved: isApproved },
});