From ddb6fa2ada8c335b4e7b5ed65c202bd2f86106ca Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Wed, 19 Nov 2025 10:41:49 -0500 Subject: [PATCH] feat(pay_periods): added Result Pattern to pay-period module --- .../controllers/pay-periods.controller.ts | 32 ++-- .../services/pay-periods-command.service.ts | 47 +++--- .../services/pay-periods-query.service.ts | 150 ++++++++++-------- 3 files changed, 126 insertions(+), 103 deletions(-) diff --git a/src/time-and-attendance/pay-period/controllers/pay-periods.controller.ts b/src/time-and-attendance/pay-period/controllers/pay-periods.controller.ts index 1d43791..dc02a87 100644 --- a/src/time-and-attendance/pay-period/controllers/pay-periods.controller.ts +++ b/src/time-and-attendance/pay-period/controllers/pay-periods.controller.ts @@ -2,13 +2,14 @@ import { Body, Controller, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Query import { PayPeriodOverviewDto } from "../dtos/overview-pay-period.dto"; import { PayPeriodsQueryService } from "../services/pay-periods-query.service"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; -import { Roles as RoleEnum } from '.prisma/client'; import { PayPeriodsCommandService } from "../services/pay-periods-command.service"; import { PayPeriodBundleDto } from "../dtos/bundle-pay-period.dto"; import { BulkCrewApprovalDto } from "../dtos/bulk-crew-approval.dto"; - +import { GLOBAL_CONTROLLER_ROLES, MANAGER_ROLES } from "src/common/shared/role-groupes"; +import { Result } from "src/common/errors/result-error.factory"; @Controller('pay-periods') +@RolesAllowed(...GLOBAL_CONTROLLER_ROLES) export class PayPeriodsController { constructor( @@ -17,12 +18,14 @@ export class PayPeriodsController { ) { } @Get('current-and-all') - async getCurrentAndAll(@Query('date') date?: string): Promise { - const [current, periods] = await Promise.all([ - this.queryService.findCurrent(date), - this.queryService.findAll(), - ]); - return { current, periods }; + async getCurrentAndAll(@Query('date') date?: string): Promise> { + const current = await this.queryService.findCurrent(date); + if (!current.success) return { success: false, error: 'INVALID_PAY_PERIOD' }; + + const periods = await this.queryService.findAll(); + if (!periods.success) return { success: false, error: 'INVALID_PAY_PERIOD' }; + + return { success: true, data: { current: current.data, periods: periods.data } }; } @Get("date/:date") @@ -39,31 +42,30 @@ export class PayPeriodsController { } @Patch("crew/pay-period-approval") - @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) + @RolesAllowed(...MANAGER_ROLES) async bulkApproval(@Req() req, @Body() dto: BulkCrewApprovalDto) { const email = req.user?.email; - if(!email) throw new UnauthorizedException(`Session infos not found`); + if (!email) throw new UnauthorizedException(`Session infos not found`); return this.commandService.bulkApproveCrew(email, dto); } @Get('crew/:year/:periodNumber') - @RolesAllowed(RoleEnum.SUPERVISOR) + @RolesAllowed(...MANAGER_ROLES) async getCrewOverview(@Req() req, @Param('year', ParseIntPipe) year: number, @Param('periodNumber', ParseIntPipe) period_no: number, @Query('includeSubtree', new ParseBoolPipe({ optional: true })) include_subtree = false, - ): Promise { + ): Promise> { const email = req.user?.email; - if(!email) throw new UnauthorizedException(`Session infos not found`); + if (!email) throw new UnauthorizedException(`Session infos not found`); return this.queryService.getCrewOverview(year, period_no, email, include_subtree); } @Get('overview/:year/:periodNumber') - @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) async getOverviewByYear( @Param('year', ParseIntPipe) year: number, @Param('periodNumber', ParseIntPipe) period_no: number, - ): Promise { + ): Promise> { return this.queryService.getOverviewByYearPeriod(year, period_no); } } diff --git a/src/time-and-attendance/pay-period/services/pay-periods-command.service.ts b/src/time-and-attendance/pay-period/services/pay-periods-command.service.ts index d014259..fb0d2d8 100644 --- a/src/time-and-attendance/pay-period/services/pay-periods-command.service.ts +++ b/src/time-and-attendance/pay-period/services/pay-periods-command.service.ts @@ -1,46 +1,47 @@ -import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"; +import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; import { BulkCrewApprovalDto } from "../dtos/bulk-crew-approval.dto"; import { PayPeriodsQueryService } from "./pay-periods-query.service"; import { TimesheetApprovalService } from "src/time-and-attendance/timesheets/services/timesheet-approval.service"; +import { Result } from "src/common/errors/result-error.factory"; - //change promise to return result pattern +//change promise to return result pattern @Injectable() export class PayPeriodsCommandService { constructor( private readonly prisma: PrismaService, private readonly timesheetsApproval: TimesheetApprovalService, private readonly query: PayPeriodsQueryService, - ) {} + ) { } //function to approve pay-periods according to selected crew members - async bulkApproveCrew(email: string, dto:BulkCrewApprovalDto): Promise<{updated: number}> { + async bulkApproveCrew(email: string, dto: BulkCrewApprovalDto): Promise> { const { include_subtree, items } = dto; - if(!items?.length) throw new BadRequestException('no items to process'); + if (!items?.length) return { success: false, error: 'EMPLOYEE_NOT_FOUND' }; //fetch and validate supervisor status const supervisor = await this.query.getSupervisor(email); - if(!supervisor) throw new NotFoundException('No employee record linked to current user'); - if(!supervisor.is_supervisor) throw new ForbiddenException('Employee is not a supervisor'); + if (!supervisor) return { success: false, error: 'EMPLOYEE_NOT_FOUND' }; + if (!supervisor.is_supervisor) return { success: false, error: 'INVALID_EMPLOYEE' }; //fetches emails of crew members linked to supervisor const crew_emails = await this.query.resolveCrewEmails(supervisor.id, include_subtree); + if (!crew_emails.success) return { success: false, error: 'INVALID_EMAIL' }; - - for(const item of items) { - if(!crew_emails.has(item.employee_email)) { - throw new ForbiddenException(`Employee ${item.employee_email} not in supervisor crew`); + for (const item of items) { + if (!crew_emails.data.has(item.employee_email)) { + return { success: false, error: 'INVALID_EMPLOYEE' } } } - const period_cache = new Map(); - const getPeriod = async (year:number, period_no: number) => { + const period_cache = new Map(); + const getPeriod = async (year: number, period_no: number) => { const key = `${year}-${period_no}`; - if(period_cache.has(key)) return period_cache.get(key)!; + if (period_cache.has(key)) return period_cache.get(key)!; - const period = await this.query.getPeriodWindow(year,period_no); - if(!period) throw new NotFoundException(`Pay period ${year}-${period_no} not found`); + const period = await this.query.getPeriodWindow(year, period_no); + if (!period) throw new NotFoundException(`Pay period ${year}-${period_no} not found`); period_cache.set(key, period); return period; }; @@ -48,27 +49,27 @@ export class PayPeriodsCommandService { let updated = 0; await this.prisma.$transaction(async (transaction) => { - for(const item of items) { + for (const item of items) { const { period_start, period_end } = await getPeriod(item.pay_year, item.period_no); const timesheets = await transaction.timesheets.findMany({ - where: { + where: { employee: { user: { email: item.employee_email } }, OR: [ - {shift : { some: { date: { gte: period_start, lte: period_end } } } }, - {expense: { some: { date: { gte: period_start, lte: period_end } } } }, + { shift: { some: { date: { gte: period_start, lte: period_end } } } }, + { expense: { some: { date: { gte: period_start, lte: period_end } } } }, ], }, select: { id: true }, }); - for(const { id } of timesheets) { + for (const { id } of timesheets) { await this.timesheetsApproval.cascadeApprovalWithtx(transaction, id, item.approve); updated++; } - + } }); - return {updated}; + return { success: true, data: { updated } }; } } \ No newline at end of file diff --git a/src/time-and-attendance/pay-period/services/pay-periods-query.service.ts b/src/time-and-attendance/pay-period/services/pay-periods-query.service.ts index 1e5e0c2..59d4f3f 100644 --- a/src/time-and-attendance/pay-period/services/pay-periods-query.service.ts +++ b/src/time-and-attendance/pay-period/services/pay-periods-query.service.ts @@ -1,24 +1,23 @@ -import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; import { computeHours, computePeriod, listPayYear, payYearOfDate } from "src/common/utils/date-utils"; import { PayPeriodOverviewDto } from "../dtos/overview-pay-period.dto"; import { EmployeePeriodOverviewDto } from "../dtos/overview-employee-period.dto"; import { PayPeriodDto } from "../dtos/pay-period.dto"; import { mapPayPeriodToDto } from "../mappers/pay-periods.mapper"; +import { Result } from "src/common/errors/result-error.factory"; @Injectable() export class PayPeriodsQueryService { constructor(private readonly prisma: PrismaService) { } - //change promise to return result pattern - - async getOverview(pay_period_no: number): Promise { + async getOverview(pay_period_no: number): Promise> { const period = await this.prisma.payPeriods.findFirst({ where: { pay_period_no }, orderBy: { pay_year: "desc" }, }); - if (!period) throw new NotFoundException(`PAY_PERIOD_NOT_FOUND`); + if (!period) return { success: false, error: `PAY_PERIOD_NOT_FOUND` }; - return this.buildOverview({ + const overview = await this.buildOverview({ period_start: period.period_start, period_end: period.period_end, payday: period.payday, @@ -26,23 +25,28 @@ export class PayPeriodsQueryService { pay_year: period.pay_year, label: period.label, }); + if (!overview.success) return { success: false, error: 'INVALID_PAY_PERIOD' } + + return { success: true, data: overview.data } } - async getOverviewByYearPeriod(pay_year: number, period_no: number): Promise { + async getOverviewByYearPeriod(pay_year: number, period_no: number): Promise> { const period = computePeriod(pay_year, period_no); - return this.buildOverview({ + const overview = await this.buildOverview({ period_start: period.period_start, period_end: period.period_end, period_no: period.period_no, pay_year: period.pay_year, payday: period.payday, label: period.label, - } as any); + }); + if (!overview.success) return { success: false, error: 'INVALID_PAY_PERIOD' } + return { success: true, data: overview.data } } //find crew member associated with supervisor private async resolveCrew(supervisor_id: number, include_subtree: boolean): - Promise> { + Promise, string>> { const result: Array<{ id: number; first_name: string; last_name: string; email: string; }> = []; let frontier = await this.prisma.employees.findMany({ @@ -53,7 +57,7 @@ export class PayPeriodsQueryService { id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email }))); - if (!include_subtree) return result; + if (!include_subtree) return { success: true, data: result }; while (frontier.length) { const parent_ids = frontier.map(emp => emp.id); @@ -67,20 +71,21 @@ export class PayPeriodsQueryService { }))); frontier = next; } - return result; + return { success: true, data: result }; } //fetchs crew emails - async resolveCrewEmails(supervisor_id: number, include_subtree: boolean): Promise> { + async resolveCrewEmails(supervisor_id: number, include_subtree: boolean): Promise, string>> { const crew = await this.resolveCrew(supervisor_id, include_subtree); - return new Set(crew.map(crew_member => crew_member.email).filter(Boolean)); + if (!crew.success) return { success: false, error: crew.error } + return { success: true, data: new Set(crew.data.map(crew_member => crew_member.email).filter(Boolean)) } } async getCrewOverview(pay_year: number, period_no: number, email: string, include_subtree: boolean): - Promise { + Promise> { // 1) Search for the period const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no: period_no } }); - if (!period) throw new NotFoundException(`PAY_PERIOD_NOT_FOUND`); + if (!period) return { success: false, error: 'PAY_PERIOD_NOT_FOUND' } // 2) fetch supervisor const supervisor = await this.prisma.employees.findFirst({ @@ -91,15 +96,16 @@ export class PayPeriodsQueryService { }, }); - if (!supervisor) throw new NotFoundException('No employee record linked to current user'); - if (!supervisor.is_supervisor) throw new ForbiddenException('Employee is not a supervisor'); + if (!supervisor) return { success: false, error: 'EMPLOYEE_NOT_FOUND' } + if (!supervisor.is_supervisor) return { success: false, error: 'INVALID_EMPLOYEE' } // 3)fetchs crew members const crew = await this.resolveCrew(supervisor.id, include_subtree); // [{ id, first_name, last_name }] - const crew_ids = crew.map(c => c.id); + if (!crew.success) return { success: false, error: crew.error } + const crew_ids = crew.data.map(c => c.id); // seed names map for employee without data const seed_names = new Map( - crew.map(crew => [ + crew.data.map(crew => [ crew.id, { name: `${crew.first_name} ${crew.last_name}`.trim(), @@ -108,17 +114,18 @@ export class PayPeriodsQueryService { ] ) ); - - // 4) overview build - return this.buildOverview({ + const overview = await this.buildOverview({ period_no: period.pay_period_no, period_start: period.period_start, period_end: period.period_end, payday: period.payday, pay_year: period.pay_year, label: period.label, - //add is_approved - }, { filtered_employee_ids: crew_ids, seed_names }); + }, { filtered_employee_ids: crew_ids, seed_names }) + if (!overview.success) return { success: false, error: 'INVALID_PAY_PERIOD' } + + // 4) overview build + return { success: true, data: overview.data } } private async buildOverview( @@ -127,7 +134,7 @@ export class PayPeriodsQueryService { period_no: number; pay_year: number; label: string; }, //add is_approved options?: { filtered_employee_ids?: number[]; seed_names?: Map } - ): Promise { + ): Promise> { const toDateString = (d: Date) => d.toISOString().slice(0, 10); const toMoney = (v: any) => (typeof v === "object" && "toNumber" in v ? v.toNumber() : (v as number)); @@ -312,13 +319,16 @@ export class PayPeriodsQueryService { ); return { - pay_period_no: period.period_no, - pay_year: period.pay_year, - payday: toDateString(payd), - period_start: toDateString(start), - period_end: toDateString(end), - label: period.label, - employees_overview, + success: true, + data: { + pay_period_no: period.period_no, + pay_year: period.pay_year, + payday: toDateString(payd), + period_start: toDateString(start), + period_end: toDateString(end), + label: period.label, + employees_overview, + } }; } @@ -329,72 +339,82 @@ export class PayPeriodsQueryService { }); } - async findAll(): Promise { + async findAll(): Promise> { const currentPayYear = payYearOfDate(new Date()); - return listPayYear(currentPayYear).map(period => ({ - pay_period_no: period.period_no, - pay_year: period.pay_year, - payday: period.payday, - period_start: period.period_start, - period_end: period.period_end, - label: period.label, - //add is_approved - })); + return { + success: true, + data: listPayYear(currentPayYear).map(period => ({ + pay_period_no: period.period_no, + pay_year: period.pay_year, + payday: period.payday, + period_start: period.period_start, + period_end: period.period_end, + label: period.label, + })) + }; } - async findOne(period_no: number): Promise { + async findOne(period_no: number): Promise> { const row = await this.prisma.payPeriods.findFirst({ where: { pay_period_no: period_no }, orderBy: { pay_year: "desc" }, }); - if (!row) throw new NotFoundException(`PAY_PERIOD_NOT_FOUND`); - return mapPayPeriodToDto(row); + if (!row) return { success: false, error: `PAY_PERIOD_NOT_FOUND` } + return { success: true, data: mapPayPeriodToDto(row) }; } - async findCurrent(date?: string): Promise { + async findCurrent(date?: string): Promise> { const iso_day = date ?? new Date().toISOString().slice(0, 10); - return this.findByDate(iso_day); + const pay_period = await this.findByDate(iso_day); + if (!pay_period.success) return { success: false, error: 'INVALID_PAY_PERIOD' } + return { success: true, data: pay_period.data } } - async findOneByYearPeriod(pay_year: number, period_no: number): Promise { + async findOneByYearPeriod(pay_year: number, period_no: number): Promise> { const row = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no: period_no }, }); - if (row) return mapPayPeriodToDto(row); + if (row) return { success: true, data: mapPayPeriodToDto(row) }; // fallback for outside of view periods const period = computePeriod(pay_year, period_no); return { - pay_period_no: period.period_no, - pay_year: period.pay_year, - period_start: period.period_start, - payday: period.payday, - period_end: period.period_end, - label: period.label + success: true, + data: { + pay_period_no: period.period_no, + pay_year: period.pay_year, + period_start: period.period_start, + payday: period.payday, + period_end: period.period_end, + label: period.label + } } } //function to cherry pick a Date to find a period - async findByDate(date: string): Promise { + async findByDate(date: string): Promise> { const dt = new Date(date); const row = await this.prisma.payPeriods.findFirst({ where: { period_start: { lte: dt }, period_end: { gte: dt } }, }); - if (row) return mapPayPeriodToDto(row); + if (row) return { success: true, data: mapPayPeriodToDto(row) }; //fallback for outwside view periods const pay_year = payYearOfDate(date); const periods = listPayYear(pay_year); const hit = periods.find(period => date >= period.period_start && date <= period.period_end); - if (!hit) throw new NotFoundException(`PAY_PERIOD_NOT_FOUND`); + if (!hit) return { success: false, error: `PAY_PERIOD_NOT_FOUND` } return { - pay_period_no: hit.period_no, - pay_year: hit.pay_year, - period_start: hit.period_start, - period_end: hit.period_end, - payday: hit.payday, - label: hit.label + success: true, + data: { + pay_period_no: hit.period_no, + pay_year: hit.pay_year, + period_start: hit.period_start, + period_end: hit.period_end, + payday: hit.payday, + label: hit.label + } } }