diff --git a/src/modules/customers/controllers/customers.controller.ts b/src/modules/customers/controllers/customers.controller.ts index aaf4e42..713ebde 100644 --- a/src/modules/customers/controllers/customers.controller.ts +++ b/src/modules/customers/controllers/customers.controller.ts @@ -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' }) diff --git a/src/modules/customers/services/customers.service.ts b/src/modules/customers/services/customers.service.ts index 6163552..b0b68c8 100644 --- a/src/modules/customers/services/customers.service.ts +++ b/src/modules/customers/services/customers.service.ts @@ -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 { - 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 { +// 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 { - return this.prisma.customers.findMany({ - include: { user: true }, - }) - } +// findAll(): Promise { +// return this.prisma.customers.findMany({ +// include: { user: true }, +// }) +// } - async findOne(id:number): Promise { - 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 { +// 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 { - const customer = await this.findOne(id); +// async update(id: number,dto: UpdateCustomerDto): Promise { +// 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 { - 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 { +// await this.findOne(id); +// return this.prisma.customers.delete({ where: { id }}); +// } } diff --git a/src/modules/employees/controllers/employees.controller.ts b/src/modules/employees/controllers/employees.controller.ts index b20c78e..2026a28 100644 --- a/src/modules/employees/controllers/employees.controller.ts +++ b/src/modules/employees/controllers/employees.controller.ts @@ -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 { - 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 { + // return this.employeesService.create(dto); + // } // @Get() // //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING) // @ApiOperation({summary: 'Find all employees' }) diff --git a/src/modules/employees/employees.module.ts b/src/modules/employees/employees.module.ts index 22e5cc6..b362663 100644 --- a/src/modules/employees/employees.module.ts +++ b/src/modules/employees/employees.module.ts @@ -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 {} diff --git a/src/modules/employees/services/employees-archival.service.ts b/src/modules/employees/services/employees-archival.service.ts new file mode 100644 index 0000000..b13fa74 --- /dev/null +++ b/src/modules/employees/services/employees-archival.service.ts @@ -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 { + // 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é n’est 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 { + 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 { + // 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 { + return this.prisma.employeesArchive.findMany(); + } + + //fetches an archived employee + async findOneArchived(id: number): Promise { + return this.prisma.employeesArchive.findUniqueOrThrow({ where: { id } }); + } + +} + diff --git a/src/modules/employees/services/employees.service.ts b/src/modules/employees/services/employees.service.ts index fd7dab7..2833bff 100644 --- a/src/modules/employees/services/employees.service.ts +++ b/src/modules/employees/services/employees.service.ts @@ -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 { - 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 { - return this.prisma.employees.findMany({ - include: { user: true }, - }); - } + constructor(private readonly prisma: PrismaService) { } findListEmployees(): Promise { 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 { - 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 { + async findOneProfile(email: string): Promise { 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 { - 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 { + // 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 { + // return this.prisma.employees.findMany({ + // include: { user: true }, + // }); + // } + + // async findOne(email: string): Promise { + // 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 { + // 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 { + // async remove(email: string): Promise { - 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 { - // 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é n’est 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 { - 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 { - // 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 { - return this.prisma.employeesArchive.findMany(); - } - - //fetches an archived employee - async findOneArchived(id: number): Promise { - return this.prisma.employeesArchive.findUniqueOrThrow({ where: { id } }); - } - -} +} \ No newline at end of file diff --git a/src/modules/employees/utils/employee.utils.ts b/src/modules/employees/utils/employee.utils.ts new file mode 100644 index 0000000..3534f3d --- /dev/null +++ b/src/modules/employees/utils/employee.utils.ts @@ -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; +} \ No newline at end of file diff --git a/src/modules/expenses/controllers/expenses.controller.ts b/src/modules/expenses/controllers/expenses.controller.ts index c352394..03173e9 100644 --- a/src/modules/expenses/controllers/expenses.controller.ts +++ b/src/modules/expenses/controllers/expenses.controller.ts @@ -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, ) {} diff --git a/src/modules/expenses/expenses.module.ts b/src/modules/expenses/expenses.module.ts index 3948e70..39f1357 100644 --- a/src/modules/expenses/expenses.module.ts +++ b/src/modules/expenses/expenses.module.ts @@ -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, diff --git a/src/modules/expenses/services/expenses-archival.service.ts b/src/modules/expenses/services/expenses-archival.service.ts new file mode 100644 index 0000000..fc17c63 --- /dev/null +++ b/src/modules/expenses/services/expenses-archival.service.ts @@ -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 { + //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 { + return this.prisma.expensesArchive.findMany(); + } + + //fetches an archived timesheet + async findOneArchived(id: number): Promise { + return this.prisma.expensesArchive.findUniqueOrThrow({ where: { id } }); + } +} \ No newline at end of file diff --git a/src/modules/expenses/services/expenses-command.service.ts b/src/modules/expenses/services/expenses-command.service.ts index ae8af6b..9ec2604 100644 --- a/src/modules/expenses/services/expenses-command.service.ts +++ b/src/modules/expenses/services/expenses-command.service.ts @@ -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 { 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 { ); } - //-------------------- 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 { }); 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 { 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 { }; 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 { 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 { }); 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 { }); } - - //-------------------- 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 => this.employeesRepo.findIdByEmail(email); @@ -280,34 +263,8 @@ export class ExpensesCommandService extends BaseApprovalService { 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); - } \ No newline at end of file diff --git a/src/modules/expenses/services/expenses-query.service.ts b/src/modules/expenses/services/expenses-query.service.ts index b719a79..e82e0a4 100644 --- a/src/modules/expenses/services/expenses-query.service.ts +++ b/src/modules/expenses/services/expenses-query.service.ts @@ -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 { - const { timesheet_id, bank_code_id, date, amount:rawAmount, - comment, is_approved,supervisor_comment} = dto; + // async create(dto: CreateExpenseDto): Promise { + // 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 { + // 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 { + // 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 { - const where = buildPrismaWhere(filters); - const expenses = await this.prisma.expenses.findMany({ where }) - return expenses; - } + // async update(id: number, dto: UpdateExpenseDto): Promise { + // 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 { - 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 { + // await this.findOne(id); + // return this.prisma.expenses.delete({ where: { id } }); + // } - async update(id: number, dto: UpdateExpenseDto): Promise { - 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 { - await this.findOne(id); - return this.prisma.expenses.delete({ where: { id } }); - } - - - //archivation functions ****************************************************** - - async archiveOld(): Promise { - //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 { - return this.prisma.expensesArchive.findMany(); - } - - //fetches an archived timesheet - async findOneArchived(id: number): Promise { - return this.prisma.expensesArchive.findUniqueOrThrow({ where: { id } }); - } } \ No newline at end of file diff --git a/src/modules/expenses/utils/expenses.utils.ts b/src/modules/expenses/utils/expenses.utils.ts index 87e2120..6959bde 100644 --- a/src/modules/expenses/utils/expenses.utils.ts +++ b/src/modules/expenses/utils/expenses.utils.ts @@ -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) }: {}), }; -} \ No newline at end of file +} + + 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!); + }; diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index c936026..f0bd218 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -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') diff --git a/src/modules/shifts/helpers/shifts-date-time-helpers.ts b/src/modules/shifts/helpers/shifts-date-time-helpers.ts index 94ecf5e..3e9e7f6 100644 --- a/src/modules/shifts/helpers/shifts-date-time-helpers.ts +++ b/src/modules/shifts/helpers/shifts-date-time-helpers.ts @@ -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}`; +} + diff --git a/src/modules/shifts/services/shifts-archival.service.ts b/src/modules/shifts/services/shifts-archival.service.ts new file mode 100644 index 0000000..667ba3a --- /dev/null +++ b/src/modules/shifts/services/shifts-archival.service.ts @@ -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 { + //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 { + return this.prisma.shiftsArchive.findMany(); + } + + //fetches an archived timesheet + async findOneArchived(id: number): Promise { + return this.prisma.shiftsArchive.findUniqueOrThrow({ where: { id } }); + } + +} \ No newline at end of file diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index cccfbcc..023196d 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -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 { 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((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 { - 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((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; + } } \ No newline at end of file diff --git a/src/modules/shifts/services/shifts-query.service.ts b/src/modules/shifts/services/shifts-query.service.ts index cd1c286..0d6bc6f 100644 --- a/src/modules/shifts/services/shifts-query.service.ts +++ b/src/modules/shifts/services/shifts-query.service.ts @@ -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 { - 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 { - const where = buildPrismaWhere(filters); - const shifts = await this.prisma.shifts.findMany({ where }) - return shifts; - } - - async findOne(id: number): Promise { - 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 { - 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 { - await this.findOne(id); - return this.prisma.shifts.delete({ where: { id } }); - } - async getSummary(period_id: number): Promise { //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 { - //fetches archived timesheet's Ids - const archived_timesheets = await this.prisma.timesheetsArchive.findMany({ - select: { timesheet_id: true }, - }); + // async update(id: number, dto: UpdateShiftsDto): Promise { + // 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 { + // 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 { +// 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 { - 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 { +// const where = buildPrismaWhere(filters); +// const shifts = await this.prisma.shifts.findMany({ where }) +// return shifts; +// } - //fetches an archived timesheet - async findOneArchived(id: number): Promise { - return this.prisma.shiftsArchive.findUniqueOrThrow({ where: { id } }); - } +// async findOne(id: number): Promise { +// 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; +// } } \ No newline at end of file diff --git a/src/modules/shifts/shifts.module.ts b/src/modules/shifts/shifts.module.ts index 7c0e3ef..103442a 100644 --- a/src/modules/shifts/shifts.module.ts +++ b/src/modules/shifts/shifts.module.ts @@ -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 {} diff --git a/src/modules/shifts/types and interfaces/shifts-overview-row.interface.ts b/src/modules/shifts/types and interfaces/shifts-overview-row.interface.ts new file mode 100644 index 0000000..145885b --- /dev/null +++ b/src/modules/shifts/types and interfaces/shifts-overview-row.interface.ts @@ -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; +} \ No newline at end of file diff --git a/src/modules/shifts/types and interfaces/shifts-upsert.types.ts b/src/modules/shifts/types and interfaces/shifts-upsert.types.ts new file mode 100644 index 0000000..85e6212 --- /dev/null +++ b/src/modules/shifts/types and interfaces/shifts-upsert.types.ts @@ -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'; \ No newline at end of file diff --git a/src/modules/shifts/utils/shifts.utils.ts b/src/modules/shifts/utils/shifts.utils.ts new file mode 100644 index 0000000..cec997f --- /dev/null +++ b/src/modules/shifts/utils/shifts.utils.ts @@ -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 { + 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 }; + } \ No newline at end of file diff --git a/src/modules/timesheets/services/timesheet-archive.service.ts b/src/modules/timesheets/services/timesheet-archive.service.ts new file mode 100644 index 0000000..4988c75 --- /dev/null +++ b/src/modules/timesheets/services/timesheet-archive.service.ts @@ -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 { + //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 { + return this.prisma.timesheetsArchive.findMany(); + } + + //fetches an archived timesheet + async findOneArchived(id: number): Promise { + return this.prisma.timesheetsArchive.findUniqueOrThrow({ where: { id } }); + } +} \ No newline at end of file diff --git a/src/modules/timesheets/services/timesheets-command.service.ts b/src/modules/timesheets/services/timesheets-command.service.ts index 142fced..3f88d40 100644 --- a/src/modules/timesheets/services/timesheets-command.service.ts +++ b/src/modules/timesheets/services/timesheets-command.service.ts @@ -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{ @@ -14,7 +14,9 @@ export class TimesheetsCommandService extends BaseApprovalService{ 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{ } - //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{ //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{ 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, diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 8af043e..71d83c0 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -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 { @@ -80,7 +79,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; @@ -98,7 +97,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, @@ -171,28 +170,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 { @@ -204,85 +203,40 @@ export class TimesheetsQueryService { expenses, } as TimesheetDto; } + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ - async findOne(id: number): Promise { - 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 { + // 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 { - await this.findOne(id); - return this.prisma.timesheets.delete({ where: { id } }); - } - - -//archivation functions ****************************************************** - - async archiveOld(): Promise { - //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 { - return this.prisma.timesheetsArchive.findMany(); - } - - //fetches an archived timesheet - async findOneArchived(id: number): Promise { - return this.prisma.timesheetsArchive.findUniqueOrThrow({ where: { id } }); - } + // async remove(id: number): Promise { + // await this.findOne(id); + // return this.prisma.timesheets.delete({ where: { id } }); + // } } diff --git a/src/modules/timesheets/timesheets.module.ts b/src/modules/timesheets/timesheets.module.ts index b957fe6..450c7b3 100644 --- a/src/modules/timesheets/timesheets.module.ts +++ b/src/modules/timesheets/timesheets.module.ts @@ -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 {} diff --git a/src/modules/timesheets/utils/timesheet.helpers.ts b/src/modules/timesheets/utils/timesheet.helpers.ts index 7718f4c..4e6cda1 100644 --- a/src/modules/timesheets/utils/timesheet.helpers.ts +++ b/src/modules/timesheets/utils/timesheet.helpers.ts @@ -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;