feat(pay-period): begin integration of SSE route for live event-based feedback on timesheet update

This commit is contained in:
Nicolas Drolet 2026-01-20 16:28:17 -05:00
parent bf630acb7c
commit 646cb58e98
10 changed files with 121 additions and 16 deletions

View File

@ -6,6 +6,7 @@ import { BankCodesResolver } from "src/common/mappers/bank-type-id.mapper";
import { EmployeeTimesheetResolver } from "src/common/mappers/timesheet.mapper";
import { ExpenseDeleteService } from "src/time-and-attendance/expenses/services/expense-delete.service";
import { ExpenseCreateService } from "src/time-and-attendance/expenses/services/expense-create.service";
import { PayPeriodEventService } from "src/time-and-attendance/pay-period/services/pay-period-event.service";
@Module({
controllers: [ExpenseController],
@ -16,6 +17,7 @@ import { ExpenseCreateService } from "src/time-and-attendance/expenses/services/
EmailToIdResolver,
BankCodesResolver,
EmployeeTimesheetResolver,
PayPeriodEventService,
],
})

View File

@ -0,0 +1,5 @@
export class PayPeriodEvent {
employee_email: string;
event_type: 'expense' | 'shift';
action: 'create' | 'update' | 'delete';
}

View File

@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, ParseIntPipe, Patch } from "@nestjs/common";
import { Body, Controller, Get, Param, MessageEvent, ParseIntPipe, Patch, Sse } from "@nestjs/common";
import { PayPeriodOverviewDto } from "./dtos/overview-pay-period.dto";
import { PayPeriodsQueryService } from "./services/pay-periods-query.service";
import { PayPeriodsCommandService } from "./services/pay-periods-command.service";
@ -6,6 +6,8 @@ import { Result } from "src/common/errors/result-error.factory";
import { ModuleAccessAllowed } from "src/common/decorators/modules-guard.decorators";
import { Modules as ModulesEnum } from ".prisma/client";
import { GetOverviewService } from "src/time-and-attendance/pay-period/services/pay-periods-build-overview.service";
import { map, Observable } from "rxjs";
import { PayPeriodEventService } from "src/time-and-attendance/pay-period/services/pay-period-event.service";
@Controller('pay-periods')
export class PayPeriodsController {
@ -14,8 +16,14 @@ export class PayPeriodsController {
private readonly queryService: PayPeriodsQueryService,
private readonly getOverviewService: GetOverviewService,
private readonly commandService: PayPeriodsCommandService,
private readonly payPeriodEventService: PayPeriodEventService,
) { }
@Sse("subscribe")
sse(): Observable<MessageEvent> {
return this.payPeriodEventService.stream().pipe(map(event => ({ data: event, })));
}
@Get("date/:date")
@ModuleAccessAllowed(ModulesEnum.timesheets)
async findByDate(@Param("date") date: string) {
@ -38,9 +46,9 @@ export class PayPeriodsController {
@Body('timesheet_ids') timesheet_ids: number[],
@Body('is_approved') is_approved: boolean,
): Promise<Result<{ shifts: number, expenses: number }, string>> {
if (!email) return {success: false, error: 'EMAIL_REQUIRED'};
if (!timesheet_ids || timesheet_ids.length < 1) return {success: false, error: 'TIMESHEET_ID_REQUIRED'};
if (is_approved === null) return {success: false, error: 'APPROVAL_STATUS_REQUIRED'}
if (!email) return { success: false, error: 'EMAIL_REQUIRED' };
if (!timesheet_ids || timesheet_ids.length < 1) return { success: false, error: 'TIMESHEET_ID_REQUIRED' };
if (is_approved === null) return { success: false, error: 'APPROVAL_STATUS_REQUIRED' }
return this.commandService.bulkApproveEmployee(email, timesheet_ids, is_approved);
}

View File

@ -5,6 +5,7 @@ import { PayPeriodsCommandService } from "src/time-and-attendance/pay-period/ser
import { TimesheetsModule } from "src/time-and-attendance/timesheets/timesheets.module";
import { EmailToIdResolver } from "src/common/mappers/email-id.mapper";
import { GetOverviewService } from "src/time-and-attendance/pay-period/services/pay-periods-build-overview.service";
import { PayPeriodEventService } from "src/time-and-attendance/pay-period/services/pay-period-event.service";
@Module({
imports:[TimesheetsModule],
@ -13,6 +14,7 @@ import { GetOverviewService } from "src/time-and-attendance/pay-period/services/
PayPeriodsQueryService,
PayPeriodsCommandService,
GetOverviewService,
PayPeriodEventService,
EmailToIdResolver,
],
})

View File

@ -0,0 +1,16 @@
import { Injectable } from "@nestjs/common";
import { Observable, Subject } from "rxjs";
import { PayPeriodEvent } from "src/time-and-attendance/pay-period/dtos/pay-period-event.dto";
@Injectable()
export class PayPeriodEventService {
private readonly pay_period_events$ = new Subject<PayPeriodEvent>();
emit(event: PayPeriodEvent) {
this.pay_period_events$.next(event);
}
stream(): Observable<PayPeriodEvent> {
return this.pay_period_events$.asObservable();
}
}

View File

@ -11,6 +11,8 @@ import { VacationService } from "src/time-and-attendance/domains/services/vacati
import { BankedHoursService } from "src/time-and-attendance/domains/services/banking-hours.service";
import { SickLeaveService } from "src/time-and-attendance/domains/services/sick-leave.service";
import { paid_time_off_types } from "src/time-and-attendance/paid-time-off/paid-time-off.dto";
import { PayPeriodEventService } from "src/time-and-attendance/pay-period/services/pay-period-event.service";
import { Modules } from "@prisma/client";
@Injectable()
export class ShiftsCreateService {
@ -21,15 +23,16 @@ export class ShiftsCreateService {
private readonly vacationService: VacationService,
private readonly bankingService: BankedHoursService,
private readonly sickService: SickLeaveService,
private readonly payPeriodEventService: PayPeriodEventService,
) { }
//_________________________________________________________________
// CREATE WRAPPER FUNCTION FOR ONE OR MANY INPUT
//_________________________________________________________________
async createOneOrManyShifts(email: string, shifts: ShiftDto[]): Promise<Result<boolean, string>> {
async createOneOrManyShifts(email: string, shifts: ShiftDto[], is_from_timesheet: boolean = true): Promise<Result<boolean, string>> {
try {
//verify if array is empty or not
if (!Array.isArray(shifts) || shifts.length === 0) return { success: false, error: 'No data received' };
if (!Array.isArray(shifts) || shifts.length === 0) return { success: false, error: 'NO_DATA_RECEIVED' };
//verify if email is valid or not
const employee_id = await this.emailResolver.findIdByEmail(email);
@ -57,6 +60,15 @@ export class ShiftsCreateService {
//verify if shifts were created and returns an array of errors if needed
if (created_shifts.length === 0) return { success: false, error: errors.join(' | ') || 'No shift created' };
// push to event service to notify timesheet-approval subscribers of change
if (is_from_timesheet) {
this.payPeriodEventService.emit({
employee_email: email,
event_type: 'shift',
action: 'create'
})
}
// returns array of created shifts
return { success: true, data: true }
} catch (error) {
@ -150,6 +162,7 @@ export class ShiftsCreateService {
comment: dto.comment ?? '',
},
});
//builds an object to return for display in the frontend
const shift: ShiftDto = {
id: created_shift.id,

View File

@ -1,14 +1,18 @@
import { Injectable } from "@nestjs/common";
import { Result } from "src/common/errors/result-error.factory";
import { EmailToIdResolver } from "src/common/mappers/email-id.mapper";
import { computeHours } from "src/common/utils/date-utils";
import { PrismaService } from "src/prisma/prisma.service";
import { PaidTimeOFfBankHoursService } from "src/time-and-attendance/paid-time-off/paid-time-off.service";
import { PayPeriodEventService } from "src/time-and-attendance/pay-period/services/pay-period-event.service";
@Injectable()
export class ShiftsDeleteService {
constructor(
private readonly prisma: PrismaService,
private readonly paidTimeOffService: PaidTimeOFfBankHoursService,
private readonly emailResolver: EmailToIdResolver,
private readonly payPeriodEventService: PayPeriodEventService,
) { }
//_________________________________________________________________
// DELETE
@ -16,8 +20,29 @@ export class ShiftsDeleteService {
//finds shifts using shit_ids
//ajust paid-time-off banks
//blocs deletion if approved
async deleteShift(shift_id: number): Promise<Result<number, string>> {
async deleteShift(shift_id: number, email: string, is_from_timesheet: boolean = true): Promise<Result<number, string>> {
try {
//verify if email is valid or not
const employee_id = await this.emailResolver.findIdByEmail(email);
if (!employee_id.success) return { success: false, error: employee_id.error };
// check if shift actually belongs to employee
const shift = await this.prisma.shifts.findUnique({
where: { id: shift_id },
select: {
timesheet: {
select: {
employee_id: true,
}
}
}
});
if (!shift || shift.timesheet.employee_id !== employee_id.data)
return { success: false, error: 'SHIFT_NOT_FOUND'}
// return deletion result
return await this.prisma.$transaction(async (tx) => {
const shift = await tx.shifts.findUnique({
where: { id: shift_id },
@ -31,9 +56,9 @@ export class ShiftsDeleteService {
bank_code: { select: { type: true } },
},
});
if (!shift) return { success: false, error: `SHIFT_NOT_FOUND` };
if (shift.is_approved) return { success: false, error: 'APPROUVED_SHIFT' };
if (shift.is_approved) return { success: false, error: 'APPROVED_SHIFT' };
//call to ajust paid_time_off hour banks
await this.paidTimeOffService.updatePaidTimeoffBankHoursWhenShiftDelete(
@ -43,6 +68,16 @@ export class ShiftsDeleteService {
shift.timesheet.employee_id
);
await tx.shifts.delete({ where: { id: shift_id } });
// push to event service to notify timesheet-approval subscribers of change
if (is_from_timesheet) {
this.payPeriodEventService.emit({
employee_email: email,
event_type: 'shift',
action: 'delete'
})
}
return { success: true, data: shift.id };
});
} catch (error) {

View File

@ -12,6 +12,7 @@ import { ShiftDto } from "src/time-and-attendance/shifts/shift.dto";
import { EmailToIdResolver } from "src/common/mappers/email-id.mapper";
import { PaidTimeOFfBankHoursService } from "src/time-and-attendance/paid-time-off/paid-time-off.service";
import { paid_time_off_types } from "src/time-and-attendance/paid-time-off/paid-time-off.dto";
import { PayPeriodEventService } from "src/time-and-attendance/pay-period/services/pay-period-event.service";
@Injectable()
export class ShiftsUpdateService {
@ -21,9 +22,10 @@ export class ShiftsUpdateService {
private readonly timesheetResolver: EmployeeTimesheetResolver,
private readonly emailResolver: EmailToIdResolver,
private readonly paidTimeOffService: PaidTimeOFfBankHoursService,
private readonly payPeriodEventService: PayPeriodEventService,
) { }
async updateOneOrManyShifts(shifts: ShiftDto[], email: string): Promise<Result<boolean, string>> {
async updateOneOrManyShifts(shifts: ShiftDto[], email: string, is_from_timesheet: boolean = true): Promise<Result<boolean, string>> {
try {
//verify if array is empty or not
if (!Array.isArray(shifts) || shifts.length === 0) return { success: false, error: 'No data received' };
@ -54,6 +56,15 @@ export class ShiftsUpdateService {
//verify if shifts were updated and returns an array of errors if needed
if (updated_shifts.length === 0) return { success: false, error: errors.join(' | ') || 'No shift updated' };
// push to event service to notify timesheet-approval subscribers of change
if (is_from_timesheet) {
this.payPeriodEventService.emit({
employee_email: email,
event_type: 'shift',
action: 'create'
})
}
// returns array of updated shifts
return { success: true, data: true }
} catch (error) {

View File

@ -26,8 +26,8 @@ export class ShiftController {
@Post('create/:email')
@ModuleAccessAllowed(ModulesEnum.timesheets_approval)
createBatchByTimesheetsApproval(@Param('email') email:string, @Body() dtos: ShiftDto[]): Promise<Result<boolean, string>> {
return this.create_service.createOneOrManyShifts(email,dtos);
createBatchByTimesheetsApproval(@Param('email') email: string, @Body() dtos: ShiftDto[]): Promise<Result<boolean, string>> {
return this.create_service.createOneOrManyShifts(email, dtos, false);
}
@Patch('update')
@ -36,10 +36,21 @@ export class ShiftController {
return this.update_service.updateOneOrManyShifts(dtos, email);
}
@Delete(':shift_id')
@ModuleAccessAllowed(ModulesEnum.timesheets)
remove(@Param('shift_id') shift_id: number): Promise<Result<number, string>> {
return this.delete_service.deleteShift(shift_id);
@Patch('update/:email')
@ModuleAccessAllowed(ModulesEnum.timesheets_approval)
updateBatchByTimesheetApproval(@Param('email') email: string, @Body() dtos: ShiftDto[]): Promise<Result<boolean, string>> {
return this.update_service.updateOneOrManyShifts(dtos, email, false);
}
@Delete(':shift_id')
@ModuleAccessAllowed(ModulesEnum.timesheets)
remove(@Access('email') email: string, @Param('shift_id') shift_id: number): Promise<Result<number, string>> {
return this.delete_service.deleteShift(shift_id, email);
}
@Delete(':shift_id/:email')
@ModuleAccessAllowed(ModulesEnum.timesheets)
removeByTimesheetApproval(@Param('shift_id') shift_id: number, @Param('email') email: string): Promise<Result<number, string>> {
return this.delete_service.deleteShift(shift_id, email, false);
}
}

View File

@ -9,6 +9,7 @@ import { VacationService } from 'src/time-and-attendance/domains/services/vacati
import { BankedHoursService } from 'src/time-and-attendance/domains/services/banking-hours.service';
import { PaidTimeOffModule } from 'src/time-and-attendance/paid-time-off/paid-time-off.module';
import { PaidTimeOFfBankHoursService } from 'src/time-and-attendance/paid-time-off/paid-time-off.service';
import { PayPeriodEventService } from 'src/time-and-attendance/pay-period/services/pay-period-event.service';
@Module({
imports: [PaidTimeOffModule],
@ -20,6 +21,7 @@ import { PaidTimeOFfBankHoursService } from 'src/time-and-attendance/paid-time-o
VacationService,
BankedHoursService,
PaidTimeOFfBankHoursService,
PayPeriodEventService,
],
exports: [
ShiftsCreateService,