fix(modules): deprecate old methods and extract utils and helpers. created archival services.

This commit is contained in:
Matthieu Haineault 2025-10-07 08:18:46 -04:00
parent 79c5bec0ee
commit 57b74b1726
27 changed files with 1253 additions and 1201 deletions

View File

@ -14,10 +14,10 @@ import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagg
export class CustomersController {
constructor(private readonly customersService: CustomersService) {}
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
// @Post()
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Create customer' })

View File

@ -1,92 +1,93 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { CreateCustomerDto } from '../dtos/create-customer.dto';
import { Customers, Users } from '@prisma/client';
import { UpdateCustomerDto } from '../dtos/update-customer.dto';
import { Injectable } from '@nestjs/common';
@Injectable()
export class CustomersService {
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateCustomerDto): Promise<Customers> {
const {
first_name,
last_name,
email,
phone_number,
residence,
invoice_id,
} = dto;
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
return this.prisma.$transaction(async (transaction) => {
const user: Users = await transaction.users.create({
data: {
first_name,
last_name,
email,
phone_number,
residence,
},
});
return transaction.customers.create({
data: {
user_id: user.id,
invoice_id,
},
});
});
}
// constructor(private readonly prisma: PrismaService) {}
// async create(dto: CreateCustomerDto): Promise<Customers> {
// const {
// first_name,
// last_name,
// email,
// phone_number,
// residence,
// invoice_id,
// } = dto;
// return this.prisma.$transaction(async (transaction) => {
// const user: Users = await transaction.users.create({
// data: {
// first_name,
// last_name,
// email,
// phone_number,
// residence,
// },
// });
// return transaction.customers.create({
// data: {
// user_id: user.id,
// invoice_id,
// },
// });
// });
// }
findAll(): Promise<Customers[]> {
return this.prisma.customers.findMany({
include: { user: true },
})
}
// findAll(): Promise<Customers[]> {
// return this.prisma.customers.findMany({
// include: { user: true },
// })
// }
async findOne(id:number): Promise<Customers> {
const customer = await this.prisma.customers.findUnique({
where: { id },
include: { user: true },
});
if(!customer) throw new NotFoundException(`Customer #${id} not found`);
return customer;
}
// async findOne(id:number): Promise<Customers> {
// const customer = await this.prisma.customers.findUnique({
// where: { id },
// include: { user: true },
// });
// if(!customer) throw new NotFoundException(`Customer #${id} not found`);
// return customer;
// }
async update(id: number,dto: UpdateCustomerDto): Promise<Customers> {
const customer = await this.findOne(id);
// async update(id: number,dto: UpdateCustomerDto): Promise<Customers> {
// const customer = await this.findOne(id);
const {
first_name,
last_name,
email,
phone_number,
residence,
invoice_id,
} = dto;
// const {
// first_name,
// last_name,
// email,
// phone_number,
// residence,
// invoice_id,
// } = dto;
return this.prisma.$transaction(async (transaction) => {
await transaction.users.update({
where: { id: customer.user_id },
data: {
...(first_name !== undefined && { first_name }),
...(last_name !== undefined && { last_name }),
...(email !== undefined && { email }),
...(phone_number !== undefined && { phone_number }),
...(residence !== undefined && { residence }),
},
});
// return this.prisma.$transaction(async (transaction) => {
// await transaction.users.update({
// where: { id: customer.user_id },
// data: {
// ...(first_name !== undefined && { first_name }),
// ...(last_name !== undefined && { last_name }),
// ...(email !== undefined && { email }),
// ...(phone_number !== undefined && { phone_number }),
// ...(residence !== undefined && { residence }),
// },
// });
return transaction.customers.update({
where: { id },
data: {
...(invoice_id !== undefined && { invoice_id }),
},
});
});
}
async remove(id: number): Promise<Customers> {
await this.findOne(id);
return this.prisma.customers.delete({ where: { id }});
}
// return transaction.customers.update({
// where: { id },
// data: {
// ...(invoice_id !== undefined && { invoice_id }),
// },
// });
// });
// }
// async remove(id: number): Promise<Customers> {
// await this.findOne(id);
// return this.prisma.customers.delete({ where: { id }});
// }
}

View File

@ -1,29 +1,22 @@
import { Body,Controller,Delete,Get,NotFoundException,Param,ParseIntPipe,Patch,Post,UseGuards } from '@nestjs/common';
import { Employees, Roles as RoleEnum } from '@prisma/client';
import { Body,Controller,Get,NotFoundException,Param,Patch } from '@nestjs/common';
import { EmployeesService } from '../services/employees.service';
import { CreateEmployeeDto } from '../dtos/create-employee.dto';
import { UpdateEmployeeDto } from '../dtos/update-employee.dto';
import { RolesAllowed } from '../../../common/decorators/roles.decorators';
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { EmployeeListItemDto } from '../dtos/list-employee.dto';
import { EmployeeProfileItemDto } from '../dtos/profil-employee.dto';
import { EmployeesArchivalService } from '../services/employees-archival.service';
@ApiTags('Employees')
@ApiBearerAuth('access-token')
// @UseGuards()
@Controller('employees')
export class EmployeesController {
constructor(private readonly employeesService: EmployeesService) {}
constructor(
private readonly employeesService: EmployeesService,
private readonly archiveService: EmployeesArchivalService,
) {}
@Post()
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({summary: 'Create employee' })
@ApiResponse({ status: 201, description: 'Employee created', type: CreateEmployeeDto })
@ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
create(@Body() dto: CreateEmployeeDto): Promise<Employees> {
return this.employeesService.create(dto);
}
@Get('employee-list')
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING)
@ApiOperation({summary: 'Find all employees with scoped info' })
@ -45,7 +38,7 @@ export class EmployeesController {
// 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(email, dto);
const result = await this.archiveService.patchEmployee(email, dto);
if(!result) {
throw new NotFoundException(`Employee with email: ${ email } is not found in active or archive.`)
}
@ -56,6 +49,14 @@ export class EmployeesController {
// Deprecated or unused methods
//_____________________________________________________________________________________________
// @Post()
// //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({summary: 'Create employee' })
// @ApiResponse({ status: 201, description: 'Employee created', type: CreateEmployeeDto })
// @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
// 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' })

View File

@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { EmployeesController } from './controllers/employees.controller';
import { EmployeesService } from './services/employees.service';
import { EmployeesArchivalService } from './services/employees-archival.service';
@Module({
controllers: [EmployeesController],
providers: [EmployeesService],
exports: [EmployeesService],
providers: [EmployeesService, EmployeesArchivalService],
exports: [EmployeesService, EmployeesArchivalService],
})
export class EmployeesModule {}

View File

@ -0,0 +1,173 @@
import { Injectable } from "@nestjs/common";
import { Employees, EmployeesArchive, Users } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
import { UpdateEmployeeDto } from "../dtos/update-employee.dto";
import { toDateOrUndefined, toDateOrNull } from "../utils/employee.utils";
@Injectable()
export class EmployeesArchivalService {
constructor(private readonly prisma: PrismaService) { }
async patchEmployee(email: string, dto: UpdateEmployeeDto): Promise<Employees | EmployeesArchive | null> {
// 1) Tenter sur employés actifs
const active = await this.prisma.employees.findFirst({
where: { user: { email } },
include: { user: true },
});
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 && 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: new_email,
phone_number,
residence,
external_payroll_id,
company_code,
job_title,
first_work_day,
last_work_day,
supervisor_id,
is_supervisor,
} = dto as any;
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 (transaction) => {
if (
first_name !== undefined ||
last_name !== undefined ||
new_email !== undefined ||
phone_number !== undefined ||
residence !== undefined
) {
await transaction.users.update({
where: { id: active.user_id },
data: {
...(first_name !== undefined ? { first_name } : {}),
...(last_name !== undefined ? { last_name } : {}),
...(email !== undefined ? { email: new_email } : {}),
...(phone_number !== undefined ? { phone_number } : {}),
...(residence !== undefined ? { residence } : {}),
},
});
}
const updated = await transaction.employees.update({
where: { id: active.id },
data: {
...(external_payroll_id !== undefined ? { external_payroll_id } : {}),
...(company_code !== undefined ? { company_code } : {}),
...(job_title !== undefined ? { job_title } : {}),
...(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 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: { user_id: user.id },
include: { user: true },
});
if (archived) {
// Condition de restauration : last_work_day === null ou first_work_day fourni
const restore = dto.last_work_day === null || dto.first_work_day != null;
if (restore) {
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(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 => {
//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: active.id,
user_id: active.user_id,
first_name: active.user.first_name,
last_name: active.user.last_name,
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,
external_payroll_id: active.external_payroll_id,
},
include: { user: true }
});
//delete from employees table
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: 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: {
user_id: archived.user_id,
company_code: archived.company_code,
job_title: archived.job_title,
first_work_day: archived.first_work_day,
last_work_day: null,
is_supervisor: archived.is_supervisor ?? false,
external_payroll_id: archived.external_payroll_id,
},
});
//deleting archived entry by id
await transaction.employeesArchive.delete({ where: { id: archived.id } });
//return restored employee
return restored;
});
}
//fetches all archived employees
async findAllArchived(): Promise<EmployeesArchive[]> {
return this.prisma.employeesArchive.findMany();
}
//fetches an archived employee
async findOneArchived(id: number): Promise<EmployeesArchive> {
return this.prisma.employeesArchive.findUniqueOrThrow({ where: { id } });
}
}

View File

@ -1,69 +1,11 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
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 { EmployeeProfileItemDto } from '../dtos/profil-employee.dto';
function toDateOrNull(v?: string | null): Date | null {
if (!v) return null;
const day = new Date(v);
return isNaN(day.getTime()) ? null : day;
}
function toDateOrUndefined(v?: string | null): Date | undefined {
const day = toDateOrNull(v ?? undefined);
return day === null ? undefined : day;
}
@Injectable()
export class EmployeesService {
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateEmployeeDto): Promise<Employees> {
const {
first_name,
last_name,
email,
phone_number,
residence,
external_payroll_id,
company_code,
job_title,
first_work_day,
last_work_day,
is_supervisor,
} = dto;
return this.prisma.$transaction(async (transaction) => {
const user: Users = await transaction.users.create({
data: {
first_name,
last_name,
email,
phone_number,
residence,
},
});
return transaction.employees.create({
data: {
user_id: user.id,
external_payroll_id,
company_code,
job_title,
first_work_day,
last_work_day,
is_supervisor,
},
});
});
}
findAll(): Promise<Employees[]> {
return this.prisma.employees.findMany({
include: { user: true },
});
}
constructor(private readonly prisma: PrismaService) { }
findListEmployees(): Promise<EmployeeListItemDto[]> {
return this.prisma.employees.findMany({
@ -71,331 +13,220 @@ export class EmployeesService {
user: {
select: {
first_name: true,
last_name: true,
email: true,
},
last_name: true,
email: true,
},
},
supervisor: {
select: {
user: {
select: {
first_name: true,
last_name: true,
last_name: true,
},
},
},
},
job_title: true,
job_title: true,
company_code: true,
}
}).then(rows => rows.map(r => ({
first_name: r.user.first_name,
last_name: r.user.last_name,
employee_full_name: `${r.user.first_name} ${r.user.last_name}`,
email: r.user.email,
first_name: r.user.first_name,
last_name: r.user.last_name,
email: r.user.email,
company_name: r.company_code,
job_title: r.job_title,
employee_full_name: `${r.user.first_name} ${r.user.last_name}`,
supervisor_full_name: r.supervisor ? `${r.supervisor.user.first_name} ${r.supervisor.user.last_name}` : null,
company_name: r.company_code,
job_title: r.job_title,
})),
})),
);
}
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 with email: ${email} not found`);
}
return emp;
}
async findOneProfile(email:string): Promise<EmployeeProfileItemDto> {
async findOneProfile(email: string): Promise<EmployeeProfileItemDto> {
const emp = await this.prisma.employees.findFirst({
where: { user: { email } },
select: {
user: {
select: {
first_name: true,
last_name: true,
email: true,
first_name: true,
last_name: true,
email: true,
phone_number: true,
residence: true,
},
residence: true,
},
},
supervisor: {
select: {
user: {
select: {
first_name: true,
last_name: true,
last_name: true,
},
},
},
},
job_title: true,
company_code: true,
job_title: true,
company_code: true,
first_work_day: true,
last_work_day: true,
last_work_day: true,
}
});
if (!emp) throw new NotFoundException(`Employee with email ${email} not found`);
if (!emp) throw new NotFoundException(`Employee with email ${email} not found`);
return {
first_name: emp.user.first_name,
last_name: emp.user.last_name,
employee_full_name: `${emp.user.first_name} ${emp.user.last_name}`,
email: emp.user.email,
residence: emp.user.residence,
phone_number: emp.user.phone_number,
supervisor_full_name: emp.supervisor ? `${emp.supervisor.user.first_name}, ${emp.supervisor.user.last_name}` : null,
company_name: emp.company_code,
job_title: emp.job_title,
first_work_day: emp.first_work_day.toISOString().slice(0,10),
last_work_day: emp.last_work_day ? emp.last_work_day.toISOString().slice(0,10) : null,
};
return {
first_name: emp.user.first_name,
last_name: emp.user.last_name,
email: emp.user.email,
residence: emp.user.residence,
phone_number: emp.user.phone_number,
company_name: emp.company_code,
job_title: emp.job_title,
employee_full_name: `${emp.user.first_name} ${emp.user.last_name}`,
first_work_day: emp.first_work_day.toISOString().slice(0, 10),
last_work_day: emp.last_work_day ? emp.last_work_day.toISOString().slice(0, 10) : null,
supervisor_full_name: emp.supervisor ? `${emp.supervisor.user.first_name}, ${emp.supervisor.user.last_name}` : null,
};
}
async update(
email: string,
dto: UpdateEmployeeDto,
): Promise<Employees> {
const emp = await this.findOne(email);
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
const {
first_name,
last_name,
phone_number,
residence,
external_payroll_id,
company_code,
job_title,
first_work_day,
last_work_day,
is_supervisor,
email: new_email,
} = dto;
// async create(dto: CreateEmployeeDto): Promise<Employees> {
// const {
// first_name,
// last_name,
// email,
// phone_number,
// residence,
// external_payroll_id,
// company_code,
// job_title,
// first_work_day,
// last_work_day,
// is_supervisor,
// } = dto;
return this.prisma.$transaction(async (transaction) => {
if(
first_name !== undefined ||
last_name !== undefined ||
new_email !== undefined ||
phone_number !== undefined ||
residence !== undefined
){
await transaction.users.update({
where: { id: emp.user_id },
data: {
...(first_name !== undefined && { first_name }),
...(last_name !== undefined && { last_name }),
...(email !== undefined && { email }),
...(phone_number !== undefined && { phone_number }),
...(residence !== undefined && { residence }),
},
});
}
// return this.prisma.$transaction(async (transaction) => {
// const user: Users = await transaction.users.create({
// data: {
// first_name,
// last_name,
// email,
// phone_number,
// residence,
// },
// });
// return transaction.employees.create({
// data: {
// user_id: user.id,
// external_payroll_id,
// company_code,
// job_title,
// first_work_day,
// last_work_day,
// is_supervisor,
// },
// });
// });
// }
const updated = await transaction.employees.update({
where: { id: emp.id },
data: {
...(external_payroll_id !== undefined && { external_payroll_id }),
...(company_code !== undefined && { company_code }),
...(first_work_day !== undefined && { first_work_day }),
...(last_work_day !== undefined && { last_work_day }),
...(job_title !== undefined && { job_title }),
...(is_supervisor !== undefined && { is_supervisor }),
},
});
return updated;
});
}
// findAll(): Promise<Employees[]> {
// return this.prisma.employees.findMany({
// include: { user: true },
// });
// }
// 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 with email: ${email} not found`);
// }
// return emp;
// }
// async update(
// email: string,
// dto: UpdateEmployeeDto,
// ): Promise<Employees> {
// const emp = await this.findOne(email);
// const {
// first_name,
// last_name,
// phone_number,
// residence,
// external_payroll_id,
// company_code,
// job_title,
// first_work_day,
// last_work_day,
// is_supervisor,
// email: new_email,
// } = dto;
// return this.prisma.$transaction(async (transaction) => {
// if(
// first_name !== undefined ||
// last_name !== undefined ||
// new_email !== undefined ||
// phone_number !== undefined ||
// residence !== undefined
// ){
// await transaction.users.update({
// where: { id: emp.user_id },
// data: {
// ...(first_name !== undefined && { first_name }),
// ...(last_name !== undefined && { last_name }),
// ...(email !== undefined && { email }),
// ...(phone_number !== undefined && { phone_number }),
// ...(residence !== undefined && { residence }),
// },
// });
// }
// const updated = await transaction.employees.update({
// where: { id: emp.id },
// data: {
// ...(external_payroll_id !== undefined && { external_payroll_id }),
// ...(company_code !== undefined && { company_code }),
// ...(first_work_day !== undefined && { first_work_day }),
// ...(last_work_day !== undefined && { last_work_day }),
// ...(job_title !== undefined && { job_title }),
// ...(is_supervisor !== undefined && { is_supervisor }),
// },
// });
// return updated;
// });
// }
async remove(email: string): Promise<Employees> {
// async remove(email: string): Promise<Employees> {
const emp = await this.findOne(email);
// const emp = await this.findOne(email);
return this.prisma.$transaction(async (transaction) => {
await transaction.employees.updateMany({
where: { supervisor_id: emp.id },
data: { supervisor_id: null },
});
const deleted_employee = await transaction.employees.delete({
where: {id: emp.id },
});
await transaction.users.delete({
where: { id: emp.user_id },
});
return deleted_employee;
});
}
// return this.prisma.$transaction(async (transaction) => {
// await transaction.employees.updateMany({
// where: { supervisor_id: emp.id },
// data: { supervisor_id: null },
// });
// const deleted_employee = await transaction.employees.delete({
// where: {id: emp.id },
// });
// await transaction.users.delete({
// where: { id: emp.user_id },
// });
// return deleted_employee;
// });
// }
//archivation functions ******************************************************
async patchEmployee(email: string, dto: UpdateEmployeeDto): Promise<Employees | EmployeesArchive | null> {
// 1) Tenter sur employés actifs
const active = await this.prisma.employees.findFirst({
where: { user: { email } },
include: { user: true },
});
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 && 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: new_email,
phone_number,
residence,
external_payroll_id,
company_code,
job_title,
first_work_day,
last_work_day,
supervisor_id,
is_supervisor,
} = dto as any;
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 (transaction) => {
if(
first_name !== undefined ||
last_name !== undefined ||
new_email !== undefined ||
phone_number !== undefined ||
residence !== undefined
) {
await transaction.users.update({
where: { id: active.user_id },
data: {
...(first_name !== undefined ? { first_name } : {}),
...(last_name !== undefined ? { last_name } : {}),
...(email !== undefined ? { email: new_email }: {}),
...(phone_number !== undefined ? { phone_number } : {}),
...(residence !== undefined ? { residence } : {}),
},
});
}
const updated = await transaction.employees.update({
where: { id: active.id },
data: {
...(external_payroll_id !== undefined ? { external_payroll_id } : {}),
...(company_code !== undefined ? { company_code } : {}),
...(job_title !== undefined ? { job_title } : {}),
...(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 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: { user_id: user.id },
include: { user: true },
});
if (archived) {
// Condition de restauration : last_work_day === null ou first_work_day fourni
const restore = dto.last_work_day === null || dto.first_work_day != null;
if (restore) {
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(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 => {
//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: 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: active.id } });
//return archived employee
return archived
});
}
//transfers the employee from archive to the employees table
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: {
user_id: archived.user_id,
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,
is_supervisor: archived.is_supervisor ?? false,
},
});
//deleting archived entry by id
await transaction.employeesArchive.delete({ where: { id: archived.id } });
//return restored employee
return restored;
});
}
//fetches all archived employees
async findAllArchived(): Promise<EmployeesArchive[]> {
return this.prisma.employeesArchive.findMany();
}
//fetches an archived employee
async findOneArchived(id: number): Promise<EmployeesArchive> {
return this.prisma.employeesArchive.findUniqueOrThrow({ where: { id } });
}
}
}

View File

@ -0,0 +1,9 @@
export function toDateOrNull(v?: string | null): Date | null {
if (!v) return null;
const day = new Date(v);
return isNaN(day.getTime()) ? null : day;
}
export function toDateOrUndefined(v?: string | null): Date | undefined {
const day = toDateOrNull(v ?? undefined);
return day === null ? undefined : day;
}

View File

@ -1,13 +1,8 @@
import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Put, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
import { ExpensesQueryService } from "../services/expenses-query.service";
import { CreateExpenseDto } from "../dtos/create-expense.dto";
import { Expenses } from "@prisma/client";
import { Body, Controller, Param, Put, } from "@nestjs/common";
import { Roles as RoleEnum } from '.prisma/client';
import { UpdateExpenseDto } from "../dtos/update-expense.dto";
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { ExpensesCommandService } from "../services/expenses-command.service";
import { SearchExpensesDto } from "../dtos/search-expense.dto";
import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
import { UpsertExpenseResult } from "../types and interfaces/expenses.types.interfaces";
@ -17,7 +12,7 @@ import { UpsertExpenseResult } from "../types and interfaces/expenses.types.inte
@Controller('Expenses')
export class ExpensesController {
constructor(
private readonly query: ExpensesQueryService,
// private readonly query: ExpensesQueryService,
private readonly command: ExpensesCommandService,
) {}

View File

@ -6,12 +6,14 @@ import { ExpensesCommandService } from "./services/expenses-command.service";
import { BankCodesRepo } from "./repos/bank-codes.repo";
import { TimesheetsRepo } from "./repos/timesheets.repo";
import { EmployeesRepo } from "./repos/employee.repo";
import { ExpensesArchivalService } from "./services/expenses-archival.service";
@Module({
imports: [BusinessLogicsModule],
controllers: [ExpensesController],
providers: [
ExpensesQueryService,
ExpensesQueryService,
ExpensesArchivalService,
ExpensesCommandService,
BankCodesRepo,
TimesheetsRepo,
@ -19,6 +21,7 @@ import { EmployeesRepo } from "./repos/employee.repo";
],
exports: [
ExpensesQueryService,
ExpensesArchivalService,
BankCodesRepo,
TimesheetsRepo,
EmployeesRepo,

View File

@ -0,0 +1,62 @@
import { Injectable } from "@nestjs/common";
import { ExpensesArchive } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class ExpensesArchivalService {
constructor(private readonly prisma: PrismaService){}
async archiveOld(): Promise<void> {
//fetches archived timesheet's Ids
const archived_timesheets = await this.prisma.timesheetsArchive.findMany({
select: { timesheet_id: true },
});
const timesheet_ids = archived_timesheets.map(sheet => sheet.timesheet_id);
if(timesheet_ids.length === 0) {
return;
}
// copy/delete transaction
await this.prisma.$transaction(async transaction => {
//fetches expenses to move to archive
const expenses_to_archive = await transaction.expenses.findMany({
where: { timesheet_id: { in: timesheet_ids } },
});
if(expenses_to_archive.length === 0) {
return;
}
//copies sent to archive table
await transaction.expensesArchive.createMany({
data: expenses_to_archive.map(exp => ({
expense_id: exp.id,
timesheet_id: exp.timesheet_id,
bank_code_id: exp.bank_code_id,
date: exp.date,
amount: exp.amount,
attachment: exp.attachment,
comment: exp.comment,
is_approved: exp.is_approved,
supervisor_comment: exp.supervisor_comment,
})),
});
//delete from expenses table
await transaction.expenses.deleteMany({
where: { id: { in: expenses_to_archive.map(exp => exp.id) } },
})
})
}
//fetches all archived timesheets
async findAllArchived(): Promise<ExpensesArchive[]> {
return this.prisma.expensesArchive.findMany();
}
//fetches an archived timesheet
async findOneArchived(id: number): Promise<ExpensesArchive> {
return this.prisma.expensesArchive.findUniqueOrThrow({ where: { id } });
}
}

View File

@ -14,9 +14,11 @@ import {
import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces";
import {
assertAndTrimComment,
computeAmountDecimal,
computeMileageAmount,
mapDbExpenseToDayResponse,
normalizeType as normalizeTypeUtil
normalizeType,
parseAttachmentId
} from "../utils/expenses.utils";
@Injectable()
@ -25,9 +27,13 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
prisma: PrismaService,
private readonly bankCodesRepo: BankCodesRepo,
private readonly timesheetsRepo: TimesheetsRepo,
private readonly employeesRepo: EmployeesRepo,
private readonly employeesRepo: EmployeesRepo,
) { super(prisma); }
//_____________________________________________________________________________________________
// APPROVAL TX-DELEGATE METHODS
//_____________________________________________________________________________________________
protected get delegate() {
return this.prisma.expenses;
}
@ -42,7 +48,9 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
);
}
//-------------------- Master CRUD function --------------------
//_____________________________________________________________________________________________
// MASTER CRUD FUNCTION
//_____________________________________________________________________________________________
readonly upsertExpensesByDate = async (email: string, date: string, dto: UpsertExpenseDto,
): Promise<{ action:UpsertAction; day: ExpenseResponse[] }> => {
@ -82,7 +90,7 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
});
return rows.map((r) =>
this.mapDbToDayResponse({
mapDbExpenseToDayResponse({
date: r.date,
amount: r.amount ?? 0,
mileage: r.mileage ?? 0,
@ -106,12 +114,12 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
comment: string;
attachment: number | null;
}> => {
const type = this.normalizeType(payload.type);
const comment = this.assertAndTrimComment(payload.comment);
const attachment = this.parseAttachmentId(payload.attachment);
const type = normalizeType(payload.type);
const comment = assertAndTrimComment(payload.comment);
const attachment = parseAttachmentId(payload.attachment);
const { id: bank_code_id, modifier } = await this.lookupBankCodeOrThrow(tx, type);
let amount = this.computeAmountDecimal(type, payload, modifier);
const { id: bank_code_id, modifier } = await this.resolveBankCodeIdByType(tx, type);
let amount = computeAmountDecimal(type, payload, modifier);
let mileage: number | null = null;
if (type === 'MILEAGE') {
@ -172,7 +180,9 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
};
let action : UpsertAction;
//-------------------- DELETE --------------------
//_____________________________________________________________________________________________
// DELETE
//_____________________________________________________________________________________________
if(old_expense && !new_expense) {
const oldNorm = await normalizePayload(old_expense);
const existing = await findExactOld(oldNorm);
@ -185,7 +195,9 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
await tx.expenses.delete({where: { id: existing.id } });
action = 'delete';
}
//-------------------- CREATE --------------------
//_____________________________________________________________________________________________
// CREATE
//_____________________________________________________________________________________________
else if (!old_expense && new_expense) {
const new_exp = await normalizePayload(new_expense);
await tx.expenses.create({
@ -202,7 +214,9 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
});
action = 'create';
}
//-------------------- UPDATE --------------------
//_____________________________________________________________________________________________
// UPDATE
//_____________________________________________________________________________________________
else if(old_expense && new_expense) {
const oldNorm = await normalizePayload(old_expense);
const existing = await findExactOld(oldNorm);
@ -236,40 +250,9 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
});
}
//-------------------- helpers --------------------
private readonly normalizeType = (type: string): string =>
normalizeTypeUtil(type);
private readonly assertAndTrimComment = (comment: string): string =>
assertAndTrimComment(comment);
private readonly parseAttachmentId = (value: unknown): number | null => {
if (value == null) {
return null;
}
if (typeof value === 'number') {
if (!Number.isInteger(value) || value <= 0) {
throw new BadRequestException('Invalid attachment id');
}
return value;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed.length) return null;
if (!/^\d+$/.test(trimmed)) throw new BadRequestException('Invalid attachment id');
const parsed = Number(trimmed);
if (parsed <= 0) throw new BadRequestException('Invalid attachment id');
return parsed;
}
throw new BadRequestException('Invalid attachment id');
};
//_____________________________________________________________________________________________
// HELPERS
//_____________________________________________________________________________________________
private readonly resolveEmployeeIdByEmail = async (email: string): Promise<number> =>
this.employeesRepo.findIdByEmail(email);
@ -280,34 +263,8 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
return id;
};
private readonly lookupBankCodeOrThrow = async ( transaction: Prisma.TransactionClient, type: string
private readonly resolveBankCodeIdByType = async ( transaction: Prisma.TransactionClient, type: string
): Promise<{id: number; modifier: number}> =>
this.bankCodesRepo.findByType(type, transaction);
private readonly computeAmountDecimal = (
type: string,
payload: {
amount?: number;
mileage?: number;
},
modifier: number,
): Prisma.Decimal => {
if(type === 'MILEAGE') {
const km = payload.mileage ?? 0;
const amountNumber = computeMileageAmount(km, modifier);
return new Prisma.Decimal(amountNumber);
}
return new Prisma.Decimal(payload.amount!);
};
private readonly mapDbToDayResponse = (row: {
date: Date;
amount: Prisma.Decimal | number | string;
mileage: Prisma.Decimal | number | string;
comment: string;
is_approved: boolean;
bank_code: { type: string } | null;
}): ExpenseResponse => mapDbExpenseToDayResponse(row);
}

View File

@ -1,148 +1,93 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { CreateExpenseDto } from "../dtos/create-expense.dto";
import { Expenses, ExpensesArchive } from "@prisma/client";
import { UpdateExpenseDto } from "../dtos/update-expense.dto";
import { MileageService } from "src/modules/business-logics/services/mileage.service";
import { SearchExpensesDto } from "../dtos/search-expense.dto";
import { buildPrismaWhere } from "src/common/shared/build-prisma-where";
import { Injectable } from "@nestjs/common";
@Injectable()
export class ExpensesQueryService {
constructor(
private readonly prisma: PrismaService,
private readonly mileageService: MileageService,
) {}
// constructor(
// private readonly prisma: PrismaService,
// private readonly mileageService: MileageService,
// ) {}
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
async create(dto: CreateExpenseDto): Promise<Expenses> {
const { timesheet_id, bank_code_id, date, amount:rawAmount,
comment, is_approved,supervisor_comment} = dto;
// async create(dto: CreateExpenseDto): Promise<Expenses> {
// const { timesheet_id, bank_code_id, date, amount:rawAmount,
// comment, is_approved,supervisor_comment} = dto;
// //fetches type and modifier
// const bank_code = await this.prisma.bankCodes.findUnique({
// where: { id: bank_code_id },
// select: { type: true, modifier: true },
// });
// if(!bank_code) throw new NotFoundException(`bank_code #${bank_code_id} not found`);
// //if mileage -> service, otherwise the ratio is amount:1
// let final_amount: number;
// if(bank_code.type === 'mileage') {
// final_amount = await this.mileageService.calculateReimbursement(rawAmount, bank_code_id);
// }else {
// final_amount = parseFloat( (rawAmount * bank_code.modifier).toFixed(2));
// }
//fetches type and modifier
const bank_code = await this.prisma.bankCodes.findUnique({
where: { id: bank_code_id },
select: { type: true, modifier: true },
});
if(!bank_code) {
throw new NotFoundException(`bank_code #${bank_code_id} not found`)
}
// return this.prisma.expenses.create({
// data: {
// timesheet_id,
// bank_code_id,
// date,
// amount: final_amount,
// comment,
// is_approved,
// supervisor_comment
// },
// include: { timesheet: { include: { employee: { include: { user: true }}}},
// bank_code: true,
// },
// })
// }
//if mileage -> service, otherwise the ratio is amount:1
let final_amount: number;
if(bank_code.type === 'mileage') {
final_amount = await this.mileageService.calculateReimbursement(rawAmount, bank_code_id);
}else {
final_amount = parseFloat( (rawAmount * bank_code.modifier).toFixed(2));
}
// async findAll(filters: SearchExpensesDto): Promise<Expenses[]> {
// const where = buildPrismaWhere(filters);
// const expenses = await this.prisma.expenses.findMany({ where })
// return expenses;
// }
return this.prisma.expenses.create({
data: { timesheet_id, bank_code_id, date, amount: final_amount, comment, is_approved, supervisor_comment},
include: { timesheet: { include: { employee: { include: { user: true }}}},
bank_code: true,
},
})
}
// async findOne(id: number): Promise<Expenses> {
// const expense = await this.prisma.expenses.findUnique({
// where: { id },
// include: { timesheet: { include: { employee: { include: { user:true } } } },
// bank_code: true,
// },
// });
// if (!expense) {
// throw new NotFoundException(`Expense #${id} not found`);
// }
// return expense;
// }
async findAll(filters: SearchExpensesDto): Promise<Expenses[]> {
const where = buildPrismaWhere(filters);
const expenses = await this.prisma.expenses.findMany({ where })
return expenses;
}
// async update(id: number, dto: UpdateExpenseDto): Promise<Expenses> {
// await this.findOne(id);
// const { timesheet_id, bank_code_id, date, amount,
// comment, is_approved, supervisor_comment} = dto;
// return this.prisma.expenses.update({
// where: { id },
// data: {
// ...(timesheet_id !== undefined && { timesheet_id}),
// ...(bank_code_id !== undefined && { bank_code_id }),
// ...(date !== undefined && { date }),
// ...(amount !== undefined && { amount }),
// ...(comment !== undefined && { comment }),
// ...(is_approved !== undefined && { is_approved }),
// ...(supervisor_comment !== undefined && { supervisor_comment }),
// },
// include: { timesheet: { include: { employee: { include: { user: true } } } },
// bank_code: true,
// },
// });
// }
async findOne(id: number): Promise<Expenses> {
const expense = await this.prisma.expenses.findUnique({
where: { id },
include: { timesheet: { include: { employee: { include: { user:true } } } },
bank_code: true,
},
});
if (!expense) {
throw new NotFoundException(`Expense #${id} not found`);
}
return expense;
}
// async remove(id: number): Promise<Expenses> {
// await this.findOne(id);
// return this.prisma.expenses.delete({ where: { id } });
// }
async update(id: number, dto: UpdateExpenseDto): Promise<Expenses> {
await this.findOne(id);
const { timesheet_id, bank_code_id, date, amount,
comment, is_approved, supervisor_comment} = dto;
return this.prisma.expenses.update({
where: { id },
data: {
...(timesheet_id !== undefined && { timesheet_id}),
...(bank_code_id !== undefined && { bank_code_id }),
...(date !== undefined && { date }),
...(amount !== undefined && { amount }),
...(comment !== undefined && { comment }),
...(is_approved !== undefined && { is_approved }),
...(supervisor_comment !== undefined && { supervisor_comment }),
},
include: { timesheet: { include: { employee: { include: { user: true } } } },
bank_code: true,
},
});
}
async remove(id: number): Promise<Expenses> {
await this.findOne(id);
return this.prisma.expenses.delete({ where: { id } });
}
//archivation functions ******************************************************
async archiveOld(): Promise<void> {
//fetches archived timesheet's Ids
const archived_timesheets = await this.prisma.timesheetsArchive.findMany({
select: { timesheet_id: true },
});
const timesheet_ids = archived_timesheets.map(sheet => sheet.timesheet_id);
if(timesheet_ids.length === 0) {
return;
}
// copy/delete transaction
await this.prisma.$transaction(async transaction => {
//fetches expenses to move to archive
const expenses_to_archive = await transaction.expenses.findMany({
where: { timesheet_id: { in: timesheet_ids } },
});
if(expenses_to_archive.length === 0) {
return;
}
//copies sent to archive table
await transaction.expensesArchive.createMany({
data: expenses_to_archive.map(exp => ({
expense_id: exp.id,
timesheet_id: exp.timesheet_id,
bank_code_id: exp.bank_code_id,
date: exp.date,
amount: exp.amount,
attachment: exp.attachment,
comment: exp.comment,
is_approved: exp.is_approved,
supervisor_comment: exp.supervisor_comment,
})),
});
//delete from expenses table
await transaction.expenses.deleteMany({
where: { id: { in: expenses_to_archive.map(exp => exp.id) } },
})
})
}
//fetches all archived timesheets
async findAllArchived(): Promise<ExpensesArchive[]> {
return this.prisma.expensesArchive.findMany();
}
//fetches an archived timesheet
async findOneArchived(id: number): Promise<ExpensesArchive> {
return this.prisma.expensesArchive.findUniqueOrThrow({ where: { id } });
}
}

View File

@ -46,6 +46,32 @@ export function toNumberSafe(value: DecimalLike): number {
);
}
export const parseAttachmentId = (value: unknown): number | null => {
if (value == null) {
return null;
}
if (typeof value === 'number') {
if (!Number.isInteger(value) || value <= 0) {
throw new BadRequestException('Invalid attachment id');
}
return value;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed.length) return null;
if (!/^\d+$/.test(trimmed)) throw new BadRequestException('Invalid attachment id');
const parsed = Number(trimmed);
if (parsed <= 0) throw new BadRequestException('Invalid attachment id');
return parsed;
}
throw new BadRequestException('Invalid attachment id');
};
//map of a row for DayExpenseResponse
export function mapDbExpenseToDayResponse(row: {
@ -66,4 +92,20 @@ export function mapDbExpenseToDayResponse(row: {
is_approved: row.is_approved,
...(row.mileage !== null ? { mileage: toNum(row.mileage) }: {}),
};
}
}
export const computeAmountDecimal = (
type: string,
payload: {
amount?: number;
mileage?: number;
},
modifier: number,
): Prisma.Decimal => {
if(type === 'MILEAGE') {
const km = payload.mileage ?? 0;
const amountNumber = computeMileageAmount(km, modifier);
return new Prisma.Decimal(amountNumber);
}
return new Prisma.Decimal(payload.amount!);
};

View File

@ -1,15 +1,12 @@
import { Body, Controller, Delete, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Put, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
import { Shifts } from "@prisma/client";
import { CreateShiftDto } from "../dtos/create-shift.dto";
import { UpdateShiftsDto } from "../dtos/update-shift.dto";
import { Body, Controller, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Put, Query, } from "@nestjs/common";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { Roles as RoleEnum } from '.prisma/client';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { ShiftsCommandService } from "../services/shifts-command.service";
import { SearchShiftsDto } from "../dtos/search-shift.dto";
import { OverviewRow, ShiftsQueryService } from "../services/shifts-query.service";
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";
@ApiTags('Shifts')
@ApiBearerAuth('access-token')

View File

@ -16,3 +16,10 @@ export function toDateOnlyUTC(input: string | Date): Date {
const date = new Date(input);
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}
export function formatHHmm(time: Date): string {
const hh = String(time.getUTCHours()).padStart(2,'0');
const mm = String(time.getUTCMinutes()).padStart(2,'0');
return `${hh}:${mm}`;
}

View File

@ -0,0 +1,59 @@
import { ShiftsArchive } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
export class ShiftsArchivalService {
constructor(private readonly prisma: PrismaService){}
async archiveOld(): Promise<void> {
//fetches archived timesheet's Ids
const archived_timesheets = await this.prisma.timesheetsArchive.findMany({
select: { timesheet_id: true },
});
const timesheet_ids = archived_timesheets.map(sheet => sheet.timesheet_id);
if(timesheet_ids.length === 0) {
return;
}
// copy/delete transaction
await this.prisma.$transaction(async transaction => {
//fetches shifts to move to archive
const shifts_to_archive = await transaction.shifts.findMany({
where: { timesheet_id: { in: timesheet_ids } },
});
if(shifts_to_archive.length === 0) {
return;
}
//copies sent to archive table
await transaction.shiftsArchive.createMany({
data: shifts_to_archive.map(shift => ({
shift_id: shift.id,
timesheet_id: shift.timesheet_id,
bank_code_id: shift.bank_code_id,
comment: shift.comment ?? 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: shifts_to_archive.map(shift => shift.id) } },
})
})
}
//fetches all archived timesheets
async findAllArchived(): Promise<ShiftsArchive[]> {
return this.prisma.shiftsArchive.findMany();
}
//fetches an archived timesheet
async findOneArchived(id: number): Promise<ShiftsArchive> {
return this.prisma.shiftsArchive.findUniqueOrThrow({ where: { id } });
}
}

View File

@ -1,276 +1,19 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException, UnprocessableEntityException } from "@nestjs/common";
import { Prisma, Shifts } from "@prisma/client";
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";
import { ShiftPayloadDto, UpsertShiftDto } from "../dtos/upsert-shift.dto";
import { timeFromHHMMUTC, toDateOnlyUTC, weekStartMondayUTC } from "../helpers/shifts-date-time-helpers";
type DayShiftResponse = {
start_time: string;
end_time: string;
type: string;
is_remote: boolean;
comment: string | null;
}
type UpsertAction = 'created' | 'updated' | 'deleted';
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class ShiftsCommandService extends BaseApprovalService<Shifts> {
constructor(prisma: PrismaService) { super(prisma); }
//create/update/delete master 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
? this.normalize_shift_payload(dto.old_shift)
: undefined;
const new_norm = dto.new_shift
? this.normalize_shift_payload(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 this.lookup_bank_code_id_or_throw(old_norm.type)
: undefined;
const new_bank_code_id = new_norm
? await this.lookup_bank_code_id_or_throw(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 find_exact_old_shift = 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 assert_no_overlap = (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 this.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: this.format_hhmm(shift.start_time),
end_time: this.format_hhmm(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 find_exact_old_shift();
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) {
assert_no_overlap();
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 find_exact_old_shift();
if(!existing) {
throw new NotFoundException({
error_code: 'SHIFT_STALE',
message: 'The shift was modified or deleted by someone else',
});
}
assert_no_overlap(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: {
timesheet_id: timesheet.id,
date: date_only,
},
include: {
bank_code: true
},
orderBy: {
start_time: 'asc'
},
});
return {
action,
day: fresh_day.map<DayShiftResponse>((shift)=> ({
start_time: this.format_hhmm(shift.start_time),
end_time: this.format_hhmm(shift.end_time),
type: shift.bank_code?.type ?? 'UNKNOWN',
is_remote: shift.is_remote,
comment: shift.comment ?? null,
})),
};
});
return result;
}
private normalize_shift_payload(payload: ShiftPayloadDto) {
//normalize shift's infos
const start_time = timeFromHHMMUTC(payload.start_time);
const end_time = timeFromHHMMUTC(payload.end_time );
const type = (payload.type || '').trim().toUpperCase();
const is_remote = payload.is_remote === true;
//normalize comment
const raw_comment = payload.comment ?? null;
const trimmed = typeof raw_comment === 'string' ? raw_comment.trim() : null;
const comment = trimmed && trimmed.length > 0 ? trimmed: null;
return { start_time, end_time, type, is_remote, comment };
}
private async lookup_bank_code_id_or_throw(type: string): Promise<number> {
const bank = await this.prisma.bankCodes.findFirst({
where: { type },
select: { id: true },
});
if (!bank) {
throw new NotFoundException({ error_code: 'SHIFT_TYPE_UNKNOWN', message: `unknown shift type: ${type}` });
}
return bank.id;
}
private overlaps(
a_start_ms: number,
a_end_ms: number,
b_start_ms: number,
b_end_ms: number,
): boolean {
return a_start_ms < b_end_ms && b_start_ms < a_end_ms;
}
private format_hhmm(time: Date): string {
const hh = String(time.getUTCHours()).padStart(2,'0');
const mm = String(time.getUTCMinutes()).padStart(2,'0');
return `${hh}:${mm}`;
}
//approval methods
//_____________________________________________________________________________________________
// APPROVAL AND DELEGATE METHODS
//_____________________________________________________________________________________________
protected get delegate() {
return this.prisma.shifts;
}
@ -284,4 +27,221 @@ async upsertShiftsByDate(email:string, date_string: string, dto: UpsertShiftDto)
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;
}
}

View File

@ -1,25 +1,10 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { CreateShiftDto } from "../dtos/create-shift.dto";
import { Shifts, ShiftsArchive } from "@prisma/client";
import { UpdateShiftsDto } from "../dtos/update-shift.dto";
import { buildPrismaWhere } from "src/common/shared/build-prisma-where";
import { SearchShiftsDto } from "../dtos/search-shift.dto";
import { NotificationsService } from "src/modules/notifications/services/notifications.service";
import { computeHours, hoursBetweenSameDay } from "src/common/utils/date-utils";
import { computeHours } from "src/common/utils/date-utils";
import { OverviewRow } from "../types and interfaces/shifts-overview-row.interface";
const DAILY_LIMIT_HOURS = Number(process.env.DAILY_LIMIT_HOURS ?? 12);
export interface OverviewRow {
full_name: string;
supervisor: string;
total_regular_hrs: number;
total_evening_hrs: number;
total_overtime_hrs: number;
total_expenses: number;
total_mileage: number;
is_approved: boolean;
}
// const DAILY_LIMIT_HOURS = Number(process.env.DAILY_LIMIT_HOURS ?? 12);
@Injectable()
export class ShiftsQueryService {
@ -28,93 +13,6 @@ export class ShiftsQueryService {
private readonly notifs: NotificationsService,
) {}
async create(dto: CreateShiftDto): Promise<Shifts> {
const { timesheet_id, bank_code_id, date, start_time, end_time, comment } = dto;
//shift creation
const shift = await this.prisma.shifts.create({
data: { timesheet_id, bank_code_id, date, start_time, end_time, comment },
include: { timesheet: { include: { employee: { include: { user: true } } } },
bank_code: true,
},
});
//fetches all shifts of the same day to check for daily overtime
const same_day_shifts = await this.prisma.shifts.findMany({
where: { timesheet_id, date },
select: { id: true, date: true, start_time: true, end_time: true },
});
//sums hours of the day
const total_hours = same_day_shifts.reduce((sum, s) => {
return sum + hoursBetweenSameDay(s.date, s.start_time, s.end_time);
}, 0 );
//Notify if total hours > 8 for a single day
if(total_hours > DAILY_LIMIT_HOURS ) {
const user_id = String(shift.timesheet.employee.user.id);
const date_label = new Date(date).toLocaleDateString('fr-CA');
this.notifs.notify(user_id, {
type: 'shift.overtime.daily',
severity: 'warn',
message: `Tu viens de dépasser ${DAILY_LIMIT_HOURS.toFixed(2)}h pour la journée du ${date_label}
(total: ${total_hours.toFixed(2)}h).`,
ts: new Date().toISOString(),
meta: {
timesheet_id,
date: new Date(date).toISOString(),
total_hours,
threshold: DAILY_LIMIT_HOURS,
last_shift_id: shift.id
},
});
}
return shift;
}
async findAll(filters: SearchShiftsDto): Promise <Shifts[]> {
const where = buildPrismaWhere(filters);
const shifts = await this.prisma.shifts.findMany({ where })
return shifts;
}
async findOne(id: number): Promise<Shifts> {
const shift = await this.prisma.shifts.findUnique({
where: { id },
include: { timesheet: { include: { employee: { include: { user: true } } } },
bank_code: true,
},
});
if(!shift) {
throw new NotFoundException(`Shift #${id} not found`);
}
return shift;
}
async update(id: number, dto: UpdateShiftsDto): Promise<Shifts> {
await this.findOne(id);
const { timesheet_id, bank_code_id, date,start_time,end_time, comment} = dto;
return this.prisma.shifts.update({
where: { id },
data: {
...(timesheet_id !== undefined && { timesheet_id }),
...(bank_code_id !== undefined && { bank_code_id }),
...(date !== undefined && { date }),
...(start_time !== undefined && { start_time }),
...(end_time !== undefined && { end_time }),
...(comment !== undefined && { comment }),
},
include: { timesheet: { include: { employee: { include: { user: true } } } },
bank_code: true,
},
});
}
async remove(id: number): Promise<Shifts> {
await this.findOne(id);
return this.prisma.shifts.delete({ where: { id } });
}
async getSummary(period_id: number): Promise<OverviewRow[]> {
//fetch pay-period to display
const period = await this.prisma.payPeriods.findFirst({
@ -214,58 +112,94 @@ export class ShiftsQueryService {
return Array.from(mapRow.values()).sort((a,b) => a.full_name.localeCompare(b.full_name));
}
//archivation functions ******************************************************
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
async archiveOld(): Promise<void> {
//fetches archived timesheet's Ids
const archived_timesheets = await this.prisma.timesheetsArchive.findMany({
select: { timesheet_id: true },
});
// async update(id: number, dto: UpdateShiftsDto): Promise<Shifts> {
// await this.findOne(id);
// const { timesheet_id, bank_code_id, date,start_time,end_time, comment} = dto;
// return this.prisma.shifts.update({
// where: { id },
// data: {
// ...(timesheet_id !== undefined && { timesheet_id }),
// ...(bank_code_id !== undefined && { bank_code_id }),
// ...(date !== undefined && { date }),
// ...(start_time !== undefined && { start_time }),
// ...(end_time !== undefined && { end_time }),
// ...(comment !== undefined && { comment }),
// },
// include: { timesheet: { include: { employee: { include: { user: true } } } },
// bank_code: true,
// },
// });
// }
const timesheet_ids = archived_timesheets.map(sheet => sheet.timesheet_id);
if(timesheet_ids.length === 0) {
return;
}
// async remove(id: number): Promise<Shifts> {
// await this.findOne(id);
// return this.prisma.shifts.delete({ where: { id } });
// }
// copy/delete transaction
await this.prisma.$transaction(async transaction => {
//fetches shifts to move to archive
const shifts_to_archive = await transaction.shifts.findMany({
where: { timesheet_id: { in: timesheet_ids } },
});
if(shifts_to_archive.length === 0) {
return;
}
// async create(dto: CreateShiftDto): Promise<Shifts> {
// const { timesheet_id, bank_code_id, date, start_time, end_time, comment } = dto;
//copies sent to archive table
await transaction.shiftsArchive.createMany({
data: shifts_to_archive.map(shift => ({
shift_id: shift.id,
timesheet_id: shift.timesheet_id,
bank_code_id: shift.bank_code_id,
comment: shift.comment ?? undefined,
date: shift.date,
start_time: shift.start_time,
end_time: shift.end_time,
})),
});
// //shift creation
// const shift = await this.prisma.shifts.create({
// data: { timesheet_id, bank_code_id, date, start_time, end_time, comment },
// include: { timesheet: { include: { employee: { include: { user: true } } } },
// bank_code: true,
// },
// });
//delete from shifts table
await transaction.shifts.deleteMany({
where: { id: { in: shifts_to_archive.map(shift => shift.id) } },
})
// //fetches all shifts of the same day to check for daily overtime
// const same_day_shifts = await this.prisma.shifts.findMany({
// where: { timesheet_id, date },
// select: { id: true, date: true, start_time: true, end_time: true },
// });
})
}
// //sums hours of the day
// const total_hours = same_day_shifts.reduce((sum, s) => {
// return sum + hoursBetweenSameDay(s.date, s.start_time, s.end_time);
// }, 0 );
//fetches all archived timesheets
async findAllArchived(): Promise<ShiftsArchive[]> {
return this.prisma.shiftsArchive.findMany();
}
// //Notify if total hours > 8 for a single day
// if(total_hours > DAILY_LIMIT_HOURS ) {
// const user_id = String(shift.timesheet.employee.user.id);
// const date_label = new Date(date).toLocaleDateString('fr-CA');
// this.notifs.notify(user_id, {
// type: 'shift.overtime.daily',
// severity: 'warn',
// message: `Tu viens de dépasser ${DAILY_LIMIT_HOURS.toFixed(2)}h pour la journée du ${date_label}
// (total: ${total_hours.toFixed(2)}h).`,
// ts: new Date().toISOString(),
// meta: {
// timesheet_id,
// date: new Date(date).toISOString(),
// total_hours,
// threshold: DAILY_LIMIT_HOURS,
// last_shift_id: shift.id
// },
// });
// }
// return shift;
// }
// async findAll(filters: SearchShiftsDto): Promise <Shifts[]> {
// const where = buildPrismaWhere(filters);
// const shifts = await this.prisma.shifts.findMany({ where })
// return shifts;
// }
//fetches an archived timesheet
async findOneArchived(id: number): Promise<ShiftsArchive> {
return this.prisma.shiftsArchive.findUniqueOrThrow({ where: { id } });
}
// async findOne(id: number): Promise<Shifts> {
// const shift = await this.prisma.shifts.findUnique({
// where: { id },
// include: { timesheet: { include: { employee: { include: { user: true } } } },
// bank_code: true,
// },
// });
// if(!shift) {
// throw new NotFoundException(`Shift #${id} not found`);
// }
// return shift;
// }
}

View File

@ -4,11 +4,12 @@ import { BusinessLogicsModule } from 'src/modules/business-logics/business-logic
import { ShiftsCommandService } from './services/shifts-command.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { ShiftsQueryService } from './services/shifts-query.service';
import { ShiftsArchivalService } from './services/shifts-archival.service';
@Module({
imports: [BusinessLogicsModule, NotificationsModule],
controllers: [ShiftsController],
providers: [ShiftsQueryService, ShiftsCommandService],
exports: [ShiftsQueryService, ShiftsCommandService],
providers: [ShiftsQueryService, ShiftsCommandService, ShiftsArchivalService],
exports: [ShiftsQueryService, ShiftsCommandService, ShiftsArchivalService],
})
export class ShiftsModule {}

View File

@ -0,0 +1,10 @@
export interface OverviewRow {
full_name: string;
supervisor: string;
total_regular_hrs: number;
total_evening_hrs: number;
total_overtime_hrs: number;
total_expenses: number;
total_mileage: number;
is_approved: boolean;
}

View File

@ -0,0 +1,9 @@
export type DayShiftResponse = {
start_time: string;
end_time: string;
type: string;
is_remote: boolean;
comment: string | null;
}
export type UpsertAction = 'created' | 'updated' | 'deleted';

View File

@ -0,0 +1,37 @@
import { NotFoundException } from "@nestjs/common";
import { ShiftPayloadDto } from "../dtos/upsert-shift.dto";
import { timeFromHHMMUTC } from "../helpers/shifts-date-time-helpers";
export function overlaps(
a_start_ms: number,
a_end_ms: number,
b_start_ms: number,
b_end_ms: number,
): boolean {
return a_start_ms < b_end_ms && b_start_ms < a_end_ms;
}
export function resolveBankCodeByType(type: string): Promise<number> {
const bank = this.prisma.bankCodes.findFirst({
where: { type },
select: { id: true },
});
if (!bank) {
throw new NotFoundException({ error_code: 'SHIFT_TYPE_UNKNOWN', message: `unknown shift type: ${type}` });
}
return bank.id;
}
export function normalizeShiftPayload(payload: ShiftPayloadDto) {
//normalize shift's infos
const start_time = timeFromHHMMUTC(payload.start_time);
const end_time = timeFromHHMMUTC(payload.end_time );
const type = (payload.type || '').trim().toUpperCase();
const is_remote = payload.is_remote === true;
//normalize comment
const raw_comment = payload.comment ?? null;
const trimmed = typeof raw_comment === 'string' ? raw_comment.trim() : null;
const comment = trimmed && trimmed.length > 0 ? trimmed: null;
return { start_time, end_time, type, is_remote, comment };
}

View File

@ -0,0 +1,52 @@
import { TimesheetsArchive } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
export class TimesheetArchiveService {
constructor(private readonly prisma: PrismaService){}
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: { some: { 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) } } });
});
}
//fetches all archived timesheets
async findAllArchived(): Promise<TimesheetsArchive[]> {
return this.prisma.timesheetsArchive.findMany();
}
//fetches an archived timesheet
async findOneArchived(id: number): Promise<TimesheetsArchive> {
return this.prisma.timesheetsArchive.findUniqueOrThrow({ where: { id } });
}
}

View File

@ -1,4 +1,3 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { Prisma, Timesheets } from "@prisma/client";
import { BaseApprovalService } from "src/common/shared/base-approval.service";
@ -7,6 +6,7 @@ import { TimesheetsQueryService } from "./timesheets-query.service";
import { CreateTimesheetDto } from "../dtos/create-timesheet.dto";
import { TimesheetDto } from "../dtos/overview-timesheet.dto";
import { getWeekEnd, getWeekStart } from "src/common/utils/date-utils";
import { parseISODate, parseHHmm } from "../utils/timesheet.helpers";
@Injectable()
export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
@ -14,7 +14,9 @@ export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
prisma: PrismaService,
private readonly query: TimesheetsQueryService,
) {super(prisma);}
//_____________________________________________________________________________________________
// APPROVAL AND DELEGATE METHODS
//_____________________________________________________________________________________________
protected get delegate() {
return this.prisma.timesheets;
}
@ -46,16 +48,7 @@ export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
}
//create shifts within timesheet's week - employee overview functions
private parseISODate(iso: string): Date {
const [ y, m, d ] = iso.split('-').map(Number);
return new Date(y, (m ?? 1) - 1, d ?? 1);
}
private parseHHmm(t: string): Date {
const [ hh, mm ] = t.split(':').map(Number);
return new Date(1970, 0, 1, hh || 0, mm || 0, 0, 0);
}
async createWeekShiftsAndReturnOverview(
email:string,
@ -101,7 +94,7 @@ export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
//validations and insertions
for(const shift of shifts) {
const date = this.parseISODate(shift.date);
const date = parseISODate(shift.date);
if (date < start_week || date > end_week) throw new BadRequestException(`date ${shift.date} not in current week`);
const bank_code = await this.prisma.bankCodes.findFirst({
@ -115,8 +108,8 @@ export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
timesheet_id: timesheet.id,
bank_code_id: bank_code.id,
date: date,
start_time: this.parseHHmm(shift.start_time),
end_time: this.parseHHmm(shift.end_time),
start_time: parseHHmm(shift.start_time),
end_time: parseHHmm(shift.end_time),
comment: shift.comment ?? null,
is_approved: false,
is_remote: false,

View File

@ -1,7 +1,6 @@
import { Timesheets, TimesheetsArchive } from '@prisma/client';
import { Injectable, NotFoundException } from '@nestjs/common';
import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers';
import { computeHours, formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils';
import { buildPeriod, endOfDayUTC, toHHmm, toUTCDateOnly } from '../utils/timesheet.helpers';
import { Injectable, NotFoundException } from '@nestjs/common';
import { formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils';
import { PrismaService } from 'src/prisma/prisma.service';
import { OvertimeService } from 'src/modules/business-logics/services/overtime.service';
import { TimesheetDto } from '../dtos/overview-timesheet.dto';
@ -13,7 +12,7 @@ import type { ShiftRow, ExpenseRow } from '../utils/timesheet.helpers';
export class TimesheetsQueryService {
constructor(
private readonly prisma: PrismaService,
private readonly overtime: OvertimeService,
// private readonly overtime: OvertimeService,
) {}
async findAll(year: number, period_no: number, email: string): Promise<TimesheetPeriodDto> {
@ -67,7 +66,7 @@ export class TimesheetsQueryService {
orderBy: { date: 'asc' },
});
const to_num = (value: any) =>
const toNum = (value: any) =>
value && typeof value.toNumber === 'function' ? value.toNumber() :
typeof value === 'number' ? value :
value ? Number(value) : 0;
@ -85,7 +84,7 @@ export class TimesheetsQueryService {
const expenses: ExpenseRow[] = raw_expenses.map(expense => ({
date: expense.date,
amount: to_num(expense.amount),
amount: toNum(expense.amount),
comment: expense.comment ?? '',
supervisor_comment: expense.supervisor_comment ?? '',
is_approved: expense.is_approved ?? true,
@ -158,28 +157,28 @@ export class TimesheetsQueryService {
}
//small helper to format hours:minutes
const to_HH_mm = (date: Date) => date.toISOString().slice(11, 16);
//maps all shifts of selected timesheet
const shifts = timesheet.shift.map((shift_row) => ({
bank_type: shift_row.bank_code?.type ?? '',
date: formatDateISO(shift_row.date),
start_time: to_HH_mm(shift_row.start_time),
end_time: to_HH_mm(shift_row.end_time),
comment: shift_row.comment ?? '',
bank_type: shift_row.bank_code?.type ?? '',
date: formatDateISO(shift_row.date),
start_time: toHHmm(shift_row.start_time),
end_time: toHHmm(shift_row.end_time),
comment: shift_row.comment ?? '',
is_approved: shift_row.is_approved ?? false,
is_remote: shift_row.is_remote ?? false,
is_remote: shift_row.is_remote ?? false,
}));
//maps all expenses of selected timsheet
const expenses = timesheet.expense.map((exp) => ({
bank_type: exp.bank_code?.type ?? '',
date: formatDateISO(exp.date),
amount: Number(exp.amount) || 0,
km: 0,
comment: exp.comment ?? '',
supervisor_comment: exp.supervisor_comment ?? '',
bank_type: exp.bank_code?.type ?? '',
date: formatDateISO(exp.date),
amount: Number(exp.amount) || 0,
comment: exp.comment ?? '',
is_approved: exp.is_approved ?? false,
km: 0,
supervisor_comment: exp.supervisor_comment ?? '',
}));
return {
@ -191,85 +190,40 @@ export class TimesheetsQueryService {
expenses,
} as TimesheetDto;
}
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
async findOne(id: number): Promise<any> {
const timesheet = await this.prisma.timesheets.findUnique({
where: { id },
include: {
shift: { include: { bank_code: true } },
expense: { include: { bank_code: true } },
employee: { include: { user: true } },
},
});
if(!timesheet) {
throw new NotFoundException(`Timesheet #${id} not found`);
}
// async findOne(id: number): Promise<any> {
// const timesheet = await this.prisma.timesheets.findUnique({
// where: { id },
// include: {
// shift: { include: { bank_code: true } },
// expense: { include: { bank_code: true } },
// employee: { include: { user: true } },
// },
// });
// if(!timesheet) {
// throw new NotFoundException(`Timesheet #${id} not found`);
// }
const detailedShifts = timesheet.shift.map( s => {
const hours = computeHours(s.start_time, s.end_time);
const regularHours = Math.min(8, hours);
const dailyOvertime = this.overtime.getDailyOvertimeHours(s.start_time, s.end_time);
const payRegular = regularHours * s.bank_code.modifier;
const payOvertime = this.overtime.calculateOvertimePay(dailyOvertime, s.bank_code.modifier);
return { ...s, hours, payRegular, payOvertime };
});
const weeklyOvertimeHours = detailedShifts.length
? await this.overtime.getWeeklyOvertimeHours(
timesheet.employee_id,
timesheet.shift[0].date): 0;
return { ...timesheet, shift: detailedShifts, weeklyOvertimeHours };
}
// const detailedShifts = timesheet.shift.map( s => {
// const hours = computeHours(s.start_time, s.end_time);
// const regularHours = Math.min(8, hours);
// const dailyOvertime = this.overtime.getDailyOvertimeHours(s.start_time, s.end_time);
// const payRegular = regularHours * s.bank_code.modifier;
// const payOvertime = this.overtime.calculateOvertimePay(dailyOvertime, s.bank_code.modifier);
// return { ...s, hours, payRegular, payOvertime };
// });
// const weeklyOvertimeHours = detailedShifts.length
// ? await this.overtime.getWeeklyOvertimeHours(
// timesheet.employee_id,
// timesheet.shift[0].date): 0;
// return { ...timesheet, shift: detailedShifts, weeklyOvertimeHours };
// }
async remove(id: number): Promise<Timesheets> {
await this.findOne(id);
return this.prisma.timesheets.delete({ where: { id } });
}
//archivation functions ******************************************************
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: { some: { 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) } } });
});
}
//fetches all archived timesheets
async findAllArchived(): Promise<TimesheetsArchive[]> {
return this.prisma.timesheetsArchive.findMany();
}
//fetches an archived timesheet
async findOneArchived(id: number): Promise<TimesheetsArchive> {
return this.prisma.timesheetsArchive.findUniqueOrThrow({ where: { id } });
}
// async remove(id: number): Promise<Timesheets> {
// await this.findOne(id);
// return this.prisma.timesheets.delete({ where: { id } });
// }
}

View File

@ -1,13 +1,14 @@
import { Module } from '@nestjs/common';
import { TimesheetsController } from './controllers/timesheets.controller';
import { TimesheetsQueryService } from './services/timesheets-query.service';
import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module';
import { TimesheetsController } from './controllers/timesheets.controller';
import { TimesheetsQueryService } from './services/timesheets-query.service';
import { TimesheetArchiveService } from './services/timesheet-archive.service';
import { TimesheetsCommandService } from './services/timesheets-command.service';
import { ShiftsCommandService } from '../shifts/services/shifts-command.service';
import { ExpensesCommandService } from '../expenses/services/expenses-command.service';
import { BankCodesRepo } from '../expenses/repos/bank-codes.repo';
import { ShiftsCommandService } from '../shifts/services/shifts-command.service';
import { ExpensesCommandService } from '../expenses/services/expenses-command.service';
import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module';
import { BankCodesRepo } from '../expenses/repos/bank-codes.repo';
import { TimesheetsRepo } from '../expenses/repos/timesheets.repo';
import { EmployeesRepo } from '../expenses/repos/employee.repo';
import { EmployeesRepo } from '../expenses/repos/employee.repo';
import { Module } from '@nestjs/common';
@Module({
imports: [BusinessLogicsModule],
@ -17,10 +18,15 @@ import { EmployeesRepo } from '../expenses/repos/employee.repo';
TimesheetsCommandService,
ShiftsCommandService,
ExpensesCommandService,
TimesheetArchiveService,
BankCodesRepo,
TimesheetsRepo,
EmployeesRepo,
],
exports: [TimesheetsQueryService],
exports: [
TimesheetsQueryService,
TimesheetArchiveService,
TimesheetsCommandService
],
})
export class TimesheetsModule {}

View File

@ -10,6 +10,19 @@ export function dayKeyFromDate(date: Date, useUTC = true): DayKey {
return DAY_KEYS[index];
}
export const toHHmm = (date: Date) => date.toISOString().slice(11, 16);
//create shifts within timesheet's week - employee overview functions
export function parseISODate(iso: string): Date {
const [ y, m, d ] = iso.split('-').map(Number);
return new Date(y, (m ?? 1) - 1, d ?? 1);
}
export function parseHHmm(t: string): Date {
const [ hh, mm ] = t.split(':').map(Number);
return new Date(1970, 0, 1, hh || 0, mm || 0, 0, 0);
}
//Date & Format
const MS_PER_DAY = 86_400_000;
const MS_PER_HOUR = 3_600_000;