feat(archival): setup services and modules for archivation options via Cron job. small fixes to schema.prisma

This commit is contained in:
Matthieu Haineault 2025-07-29 14:54:19 -04:00
parent a7c8b62012
commit 5274bf41c1
18 changed files with 1598 additions and 1276 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "shifts_archive" ALTER COLUMN "archive_at" SET DEFAULT CURRENT_TIMESTAMP;

View File

@ -43,7 +43,7 @@ model Employees {
supervisor Employees? @relation("EmployeeSupervisor", fields: [supervisor_id], references: [id]) supervisor Employees? @relation("EmployeeSupervisor", fields: [supervisor_id], references: [id])
supervisor_id Int? supervisor_id Int?
managed_employees Employees[] @relation("EmployeeSupervisor") //changer pour crew à la prochaine MaJ crew Employees[] @relation("EmployeeSupervisor")
archive EmployeesArchive[] @relation("EmployeeToArchive") archive EmployeesArchive[] @relation("EmployeeToArchive")
timesheet Timesheets[] @relation("TimesheetEmployee") timesheet Timesheets[] @relation("TimesheetEmployee")
@ -183,7 +183,7 @@ model ShiftsArchive {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
shift Shifts @relation("ShiftsToArchive", fields: [shift_id], references: [id]) shift Shifts @relation("ShiftsToArchive", fields: [shift_id], references: [id])
shift_id Int shift_id Int
archive_at DateTime archive_at DateTime @default(now())
timesheet_id Int timesheet_id Int
shift_code_id Int shift_code_id Int
description String? description String?

View File

@ -17,10 +17,12 @@ import { ExpensesModule } from './modules/expenses/expenses.module';
import { ExpenseCodesModule } from './modules/expense-codes/expense-codes.module'; import { ExpenseCodesModule } from './modules/expense-codes/expense-codes.module';
import { PayperiodsModule } from './modules/pay-periods/pay-periods.module'; import { PayperiodsModule } from './modules/pay-periods/pay-periods.module';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ArchivalModule } from './modules/archival/archival.module';
@Module({ @Module({
imports: [ imports: [
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
ArchivalModule,
PrismaModule, PrismaModule,
HealthModule, HealthModule,
UsersModule, UsersModule,

View File

@ -1,4 +1,9 @@
import 'reflect-metadata'; import 'reflect-metadata';
//import and if case for @nestjs/schedule Cron jobs
import * as nodeCrypto from 'crypto';
if(!(globalThis as any).crypto) {
(globalThis as any).crypto = nodeCrypto;
}
import { ModuleRef, NestFactory, Reflector } from '@nestjs/core'; import { ModuleRef, NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';

View File

@ -0,0 +1,20 @@
import { Module } from "@nestjs/common";
import { ScheduleModule } from "@nestjs/schedule";
import { TimesheetsModule } from "../timesheets/timesheets.module";
import { ExpensesModule } from "../expenses/expenses.module";
import { ShiftsModule } from "../shifts/shifts.module";
import { LeaveRequestsModule } from "../leave-requests/leave-requests.module";
import { ArchivalService } from "./services/archival.service";
@Module({
imports: [
ScheduleModule,
TimesheetsModule,
ExpensesModule,
ShiftsModule,
LeaveRequestsModule,
],
providers: [ArchivalService],
})
export class ArchivalModule {}

View File

@ -0,0 +1,40 @@
import { Injectable, Logger } from "@nestjs/common";
import { Cron, Timeout } from "@nestjs/schedule";
import { ExpensesService } from "src/modules/expenses/services/expenses.service";
import { LeaveRequestsService } from "src/modules/leave-requests/services/leave-request.service";
import { ShiftsService } from "src/modules/shifts/services/shifts.service";
import { TimesheetsService } from "src/modules/timesheets/services/timesheets.service";
@Injectable()
export class ArchivalService {
private readonly logger = new Logger(ArchivalService.name);
constructor(
private readonly timesheetsService: TimesheetsService,
private readonly expensesService: ExpensesService,
private readonly shiftsService: ShiftsService,
private readonly leaveRequestsService: LeaveRequestsService,
) {}
@Cron('0 0 3 * * 1', {timeZone:'America/Toronto'}) // chaque premier lundi du mois à 03h00
async handleMonthlyArchival() {
const today = new Date();
const dayOfMonth = today.getDate();
if (dayOfMonth > 7) {
this.logger.log('Archive {awaiting 1st monday of the month for archivation process}')
return;
}
this.logger.log('monthly archivation in process');
try {
await this.timesheetsService.archiveOld();
await this.expensesService.archiveOld();
await this.shiftsService.archiveOld();
await this.leaveRequestsService.archiveExpired();
this.logger.log('archivation process done');
} catch (err) {
this.logger.log('an error occured during archivation ', err);
}
}
}

View File

@ -1,4 +1,4 @@
import { Body,Controller,Delete,Get,Param,ParseIntPipe,Patch,Post,UseGuards } from '@nestjs/common'; import { Body,Controller,Delete,Get,NotFoundException,Param,ParseIntPipe,Patch,Post,UseGuards } from '@nestjs/common';
import { Employees, Roles as RoleEnum } from '@prisma/client'; import { Employees, Roles as RoleEnum } from '@prisma/client';
import { EmployeesService } from '../services/employees.service'; import { EmployeesService } from '../services/employees.service';
import { CreateEmployeeDto } from '../dtos/create-employee.dto'; import { CreateEmployeeDto } from '../dtos/create-employee.dto';
@ -62,4 +62,16 @@ export class EmployeesController {
remove(@Param('id', ParseIntPipe) id: number): Promise<Employees> { remove(@Param('id', ParseIntPipe) id: number): Promise<Employees> {
return this.employeesService.remove(id); return this.employeesService.remove(id);
} }
@Patch(':id')
async updateOrArchiveOrRestore(
@Param('id') id: string,
@Body() dto: UpdateEmployeeDto,
) {
const result = await this.employeesService.patchEmployee(+id, dto);
if(!result) {
throw new NotFoundException(`Employee #${ id } not found in active or archive.`)
}
return result;
}
} }

View File

@ -1,4 +1,8 @@
import { PartialType } from '@nestjs/swagger'; import { PartialType } from '@nestjs/swagger';
import { CreateEmployeeDto } from './create-employee.dto'; import { CreateEmployeeDto } from './create-employee.dto';
export class UpdateEmployeeDto extends PartialType(CreateEmployeeDto) {} export class UpdateEmployeeDto extends PartialType(CreateEmployeeDto) {
first_work_day?: Date;
last_work_day?: Date;
supervisor_id?: number;
}

View File

@ -107,4 +107,86 @@ async update(
await this.findOne(id); await this.findOne(id);
return this.prisma.employees.delete({ where: { id } }); return this.prisma.employees.delete({ where: { id } });
} }
//archivation function
async patchEmployee(id: number, dto: UpdateEmployeeDto): Promise<any> {
//fetching existing employee
const existing = await this.prisma.employees.findUnique({
where: { id },
include: { user: true, archive: true },
});
if (existing) {
//verify last_work_day is not null => trigger archivation
if(dto.last_work_day != undefined && existing.last_work_day == null) {
return this.archiveOnTermination(existing, dto);
}
//if null => regular update
return this.prisma.employees.update({
where: { id },
data: dto,
});
}
//if not found => fetch archives side for restoration
const archived = await this.prisma.employeesArchive.findFirst({
where: { employee_id: id },
include: { employee: true, user: true },
});
if (archived) {
//conditions for restoration
const restore = dto.last_work_day === null || dto.first_work_day != null;
if(restore) {
return this.restoreEmployee(archived, dto);
}
}
//if neither activated nor archivated => 404
return null;
}
//transfers the employee to archive and then delete from employees table
private async archiveOnTermination(existing: any, dto: UpdateEmployeeDto): Promise<any> {
return this.prisma.$transaction(async transaction => {
//archive insertion
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,
first_Work_Day: existing.first_Work_Day,
last_work_day: existing.last_work_day,
supervisor_id: existing.supervisor_id ?? null,
},
});
//delete from employees table
await transaction.employees.delete({ where: { id: existing.id } });
//return archived employee
return archived
});
}
//transfers the employee from archive to the employees table
private async restoreEmployee(archived: any, dto: UpdateEmployeeDto): Promise<any> {
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,
first_work_day: dto.first_work_day ?? archived.first_Work_Day,
last_work_day: null,
supervisor_id: dto.supervisor_id ?? archived.supervisor_id,
},
});
//deleting archived entry by id
await transaction.employeesArchive.delete({ where: { id: archived.id } });
//return restored employee
return restored;
});
}
} }

View File

@ -5,7 +5,8 @@ import { ExpensesService } from "./services/expenses.service";
@Module({ @Module({
controllers: [ExpensesController], controllers: [ExpensesController],
providers: [ExpensesService, PrismaService] providers: [ExpensesService, PrismaService],
exports: [ ExpensesService ],
}) })
export class ExpensesModule {} export class ExpensesModule {}

View File

@ -86,4 +86,50 @@ export class ExpensesService {
await this.findOne(id); await this.findOne(id);
return this.prisma.expenses.delete({ where: { id } }); return this.prisma.expenses.delete({ where: { id } });
} }
//archivation function
async archiveOld(): Promise<void> {
//fetches archived timesheet's Ids
const archivedTimesheets = await this.prisma.timesheetsArchive.findMany({
select: { timesheet_id: true },
});
const timesheetIds = archivedTimesheets.map(sheet => sheet.timesheet_id);
if(timesheetIds.length === 0) {
return;
}
// copy/delete transaction
await this.prisma.$transaction(async transaction => {
//fetches expenses to move to archive
const expensesToArchive = await transaction.expenses.findMany({
where: { timesheet_id: { in: timesheetIds } },
});
if(expensesToArchive.length === 0) {
return;
}
//copies sent to archive table
await transaction.expensesArchive.createMany({
data: expensesToArchive.map(exp => ({
expense_id: exp.id,
timesheet_id: exp.timesheet_id,
expense_code_id: exp.expense_code_id,
date: exp.date,
amount: exp.amount,
attachement: exp.attachement,
description: exp.description,
is_approved: exp.is_approved,
supervisor_comment: exp.supervisor_comment,
})),
});
//delete from expenses table
await transaction.expenses.deleteMany({
where: { id: { in: expensesToArchive.map(exp => exp.id) } },
})
})
}
} }

View File

@ -6,6 +6,7 @@ import { Module } from "@nestjs/common";
@Module({ @Module({
controllers: [LeaveRequestController], controllers: [LeaveRequestController],
providers: [ LeaveRequestsService, PrismaService], providers: [ LeaveRequestsService, PrismaService],
exports: [ LeaveRequestsService],
}) })
export class LeaveRequestsModule {} export class LeaveRequestsModule {}

View File

@ -106,4 +106,36 @@ export class LeaveRequestsService {
where: { id }, where: { id },
}); });
} }
//archivation function
async archiveExpired(): Promise<void> {
const now = new Date();
await this.prisma.$transaction(async transaction => {
//fetches expired leave requests
const expired = await transaction.leaveRequests.findMany({
where: { end_date_time: { lt: now } },
});
if(expired.length === 0) {
return;
}
//copy unto archive table
await transaction.leaveRequestsArchive.createMany({
data: expired.map(request => ({
leave_request_id: request.id,
employee_id: request.employee_id,
leave_type: request.leave_type,
start_date_time: request.start_date_time,
end_date_time: request.end_date_time,
comment: request.comment,
approval_status: request.approval_status,
})),
});
//delete from leave_requests table
await transaction.leaveRequests.deleteMany({
where: { id: { in: expired.map(request => request.id ) } },
});
});
}
} }

View File

@ -86,4 +86,48 @@ export class ShiftsService {
await this.findOne(id); await this.findOne(id);
return this.prisma.shifts.delete({ where: { id } }); return this.prisma.shifts.delete({ where: { id } });
} }
//archivation function
async archiveOld(): Promise<void> {
//fetches archived timesheet's Ids
const archivedTimesheets = await this.prisma.timesheetsArchive.findMany({
select: { timesheet_id: true },
});
const timesheetIds = archivedTimesheets.map(sheet => sheet.timesheet_id);
if(timesheetIds.length === 0) {
return;
}
// copy/delete transaction
await this.prisma.$transaction(async transaction => {
//fetches shifts to move to archive
const shiftsToArchive = await transaction.shifts.findMany({
where: { timesheet_id: { in: timesheetIds } },
});
if(shiftsToArchive.length === 0) {
return;
}
//copies sent to archive table
await transaction.shiftsArchive.createMany({
data: shiftsToArchive.map(shift => ({
shift_id: shift.id,
timesheet_id: shift.timesheet_id,
shift_code_id: shift.shift_code_id,
description: shift.description ?? undefined,
date: shift.date,
start_time: shift.start_time,
end_time: shift.end_time,
})),
});
//delete from shifts table
await transaction.shifts.deleteMany({
where: { id: { in: shiftsToArchive.map(shift => shift.id) } },
})
})
}
} }

View File

@ -5,6 +5,7 @@ import { PrismaService } from 'src/prisma/prisma.service';
@Module({ @Module({
controllers: [ShiftsController], controllers: [ShiftsController],
providers: [ShiftsService, PrismaService] providers: [ShiftsService, PrismaService],
exports: [ShiftsService],
}) })
export class ShiftsModule {} export class ShiftsModule {}

View File

@ -75,4 +75,44 @@ export class TimesheetsService {
} }
//Archivation function
async archiveOld(): Promise<void> {
//calcul du cutoff pour archivation
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - 6)
await this.prisma.$transaction(async transaction => {
//fetches all timesheets to cutoff
const oldSheets = await transaction.timesheets.findMany({
where: { shift: { every: { date: { lt: cutoff } },
},
},
select: {
id: true,
employee_id: true,
is_approved: true,
},
});
if( oldSheets.length === 0) {
return;
}
//preping data for archivation
const archiveDate = oldSheets.map(sheet => ({
timesheet_id: sheet.id,
employee_id: sheet.employee_id,
is_approved: sheet.is_approved,
}));
//copying data from timesheets table to archive table
await transaction.timesheetsArchive.createMany({
data: archiveDate,
});
//removing data from timesheets table
await transaction.timesheets.deleteMany({
where: { id: { in: oldSheets.map(s => s.id) } },
});
});
}
} }

View File

@ -4,6 +4,7 @@ import { TimesheetsService } from './services/timesheets.service';
@Module({ @Module({
controllers: [TimesheetsController], controllers: [TimesheetsController],
providers: [TimesheetsService] providers: [TimesheetsService],
exports: [TimesheetsService],
}) })
export class TimesheetsModule {} export class TimesheetsModule {}