feat(role-guards): added role-guards group and added role check to controllers

This commit is contained in:
Matthieu Haineault 2025-11-05 14:27:54 -05:00
parent 02ebb23d7a
commit 1a0532846f
9 changed files with 146 additions and 132 deletions

View File

@ -17,9 +17,9 @@ export class OwnershipGuard implements CanActivate {
constructor(
private reflector: Reflector,
private moduleRef: ModuleRef,
) {}
) { }
async canActivate(context: ExecutionContext): Promise<boolean>{
async canActivate(context: ExecutionContext): Promise<boolean> {
const meta = this.reflector.get<OwnershipMeta>(
OWNER_KEY, context.getHandler(),
);

View File

@ -17,7 +17,7 @@ interface RequestWithUser extends Request {
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
constructor(private reflector: Reflector) { }
/**
* @swagger
@ -37,9 +37,9 @@ export class RolesGuard implements CanActivate {
* or returns `false` if the user is not authenticated.
*/
canActivate(ctx: ExecutionContext): boolean {
const requiredRoles = this.reflector.get<Roles[]>(
const requiredRoles = this.reflector.getAllAndOverride<Roles[]>(
ROLES_KEY,
ctx.getHandler(),
[ctx.getHandler(), ctx.getClass()],
);
//for "deny-by-default" when role is wrong or unavailable
if (!requiredRoles || requiredRoles.length === 0) {

View File

@ -0,0 +1,15 @@
import { Roles as RoleEnum } from ".prisma/client";
export const GLOBAL_CONTROLLER_ROLES: readonly RoleEnum[] = [
RoleEnum.EMPLOYEE,
RoleEnum.ACCOUNTING,
RoleEnum.HR,
RoleEnum.SUPERVISOR,
RoleEnum.ADMIN,
];
export const MANAGER_ROLES: readonly RoleEnum[] = [
RoleEnum.HR,
RoleEnum.SUPERVISOR,
RoleEnum.ADMIN,
]

View File

@ -1,37 +1,35 @@
import { Controller, Get, Patch, Param, Body, NotFoundException } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiParam } from "@nestjs/swagger";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { GLOBAL_CONTROLLER_ROLES, MANAGER_ROLES } from "src/common/shared/role-groupes";
import { EmployeeListItemDto } from "src/identity-and-account/employees/dtos/list-employee.dto";
import { EmployeeProfileItemDto } from "src/identity-and-account/employees/dtos/profil-employee.dto";
import { UpdateEmployeeDto } from "src/identity-and-account/employees/dtos/update-employee.dto";
import { EmployeesArchivalService } from "src/identity-and-account/employees/services/employees-archival.service";
import { EmployeesService } from "src/identity-and-account/employees/services/employees.service";
@ApiBearerAuth('access-token')
// @UseGuards()
@RolesAllowed(...GLOBAL_CONTROLLER_ROLES)
@Controller('employees')
export class EmployeesController {
constructor(
private readonly employeesService: EmployeesService,
private readonly archiveService: EmployeesArchivalService,
) {}
private readonly archiveService: EmployeesArchivalService,
) { }
@Get('employee-list')
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING)
@RolesAllowed(...MANAGER_ROLES)
findListEmployees(): Promise<EmployeeListItemDto[]> {
return this.employeesService.findListEmployees();
}
@Patch(':email')
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiBearerAuth('access-token')
@RolesAllowed(...MANAGER_ROLES)
async updateOrArchiveOrRestore(@Param('email') email: string, @Body() dto: UpdateEmployeeDto,) {
// 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.archiveService.patchEmployee(email, dto);
if(!result) {
throw new NotFoundException(`Employee with email: ${ email } is not found in active or archive.`)
if (!result) {
throw new NotFoundException(`Employee with email: ${email} is not found in active or archive.`)
}
return result;
}
@ -68,10 +66,6 @@ export class EmployeesController {
// }
@Get('profile/:email')
@ApiOperation({summary: 'Find employee profile' })
@ApiParam({ name: 'email', type: String, description: 'Identifier of the employee' })
@ApiResponse({ status: 200, description: 'Employee profile found', type: EmployeeProfileItemDto })
@ApiResponse({ status: 400, description: 'Employee profile not found' })
findOneProfile(@Param('email') email: string): Promise<EmployeeProfileItemDto> {
return this.employeesService.findOneProfile(email);
}

View File

@ -4,38 +4,38 @@ import { PrismaService } from 'src/prisma/prisma.service';
@Injectable()
export abstract class AbstractUserService {
constructor(protected readonly prisma: PrismaService) {}
constructor(protected readonly prisma: PrismaService) { }
findAll(): Promise<Users[]> {
return this.prisma.users.findMany();
}
findAll(): Promise<Users[]> {
return this.prisma.users.findMany();
}
async findOne( id: string ): Promise<Users> {
const user = await this.prisma.users.findUnique({ where: { id } });
if (!user) {
throw new NotFoundException(`User #${id} not found`);
}
return user;
}
async findOne(id: string): Promise<Users> {
const user = await this.prisma.users.findUnique({ where: { id } });
if (!user) {
throw new NotFoundException(`User #${id} not found`);
}
return user;
}
async findOneByEmail( email: string ): Promise<Partial<Users>> {
const user = await this.prisma.users.findUnique({ where: { email } });
if (!user) {
throw new NotFoundException(`No user with email #${email} exists`);
}
async findOneByEmail(email: string): Promise<Partial<Users>> {
const user = await this.prisma.users.findUnique({ where: { email } });
if (!user) {
throw new NotFoundException(`No user with email #${email} exists`);
}
const clean_user = {
first_name: user.first_name,
last_name: user.last_name,
email: user.email,
role: user.role,
}
const clean_user = {
first_name: user.first_name,
last_name: user.last_name,
email: user.email,
role: user.role,
}
return clean_user;
}
return clean_user;
}
async remove(id: string): Promise<Users> {
await this.findOne(id);
return this.prisma.users.delete({ where: { id } });
}
async remove(id: string): Promise<Users> {
await this.findOne(id);
return this.prisma.users.delete({ where: { id } });
}
}

View File

@ -6,53 +6,54 @@ import { SchedulePresetsApplyService } from "src/time-and-attendance/time-tracke
import { SchedulePresetsGetService } from "src/time-and-attendance/time-tracker/schedule-presets/services/schedule-presets-get.service";
import { SchedulePresetsUpsertService } from "src/time-and-attendance/time-tracker/schedule-presets/services/schedule-presets-upsert.service";
import { Roles as RoleEnum } from '.prisma/client';
import { GLOBAL_CONTROLLER_ROLES, MANAGER_ROLES } from "src/common/shared/role-groupes";
@Controller('schedule-presets')
@RolesAllowed(...GLOBAL_CONTROLLER_ROLES)
export class SchedulePresetsController {
constructor(
private readonly upsertService: SchedulePresetsUpsertService,
private readonly getService: SchedulePresetsGetService,
private readonly upsertService: SchedulePresetsUpsertService,
private readonly getService: SchedulePresetsGetService,
private readonly applyPresetsService: SchedulePresetsApplyService,
){}
) { }
//used to create a schedule preset
@Post('create')
@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ADMIN)
async createPreset( @Req() req, @Body() dto: SchedulePresetsDto ) {
@RolesAllowed(...MANAGER_ROLES)
async createPreset(@Req() req, @Body() dto: SchedulePresetsDto) {
const email = req.user?.email;
return await this.upsertService.createPreset(email, dto);
}
//used to update an already existing schedule preset
@Patch('update/:preset_id')
@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ADMIN)
async updatePreset( @Param('preset_id', ParseIntPipe) preset_id: number,@Body() dto: SchedulePresetsUpdateDto ) {
@RolesAllowed(...MANAGER_ROLES)
async updatePreset(@Param('preset_id', ParseIntPipe) preset_id: number, @Body() dto: SchedulePresetsUpdateDto) {
return await this.upsertService.updatePreset(preset_id, dto);
}
//used to delete a schedule preset
@Delete('delete/:preset_id')
@RolesAllowed(RoleEnum.ADMIN)
async deletePreset( @Param('preset_id') preset_id: number ) {
async deletePreset(@Param('preset_id') preset_id: number) {
return await this.upsertService.deletePreset(preset_id);
}
//used to show the list of available schedule presets
@Get('find-list')
@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ADMIN)
async findListById( @Req() req) {
@RolesAllowed(...MANAGER_ROLES)
async findListById(@Req() req) {
const email = req.user?.email;
return this.getService.getSchedulePresets(email);
}
//used to apply a preset to a timesheet
@Post('apply-presets')
@RolesAllowed(RoleEnum.EMPLOYEE, RoleEnum.ACCOUNTING, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ADMIN)
async applyPresets( @Req() req, @Query('preset') preset_name: string, @Query('start') start_date: string ) {
async applyPresets(@Req() req, @Query('preset') preset_name: string, @Query('start') start_date: string) {
const email = req.user?.email;
if(!preset_name?.trim()) throw new BadRequestException('Query "preset" is required');
if(!start_date?.trim()) throw new BadRequestException('Query "start" is required YYYY-MM-DD');
if (!preset_name?.trim()) throw new BadRequestException('Query "preset" is required');
if (!start_date?.trim()) throw new BadRequestException('Query "start" is required YYYY-MM-DD');
return this.applyPresetsService.applyToTimesheet(email, preset_name, start_date);
}
}

View File

@ -3,17 +3,18 @@ import { ShiftDto } from "src/time-and-attendance/time-tracker/shifts/dtos/shift
import { UpdateShiftDto } from "src/time-and-attendance/time-tracker/shifts/dtos/shift-update.dto";
import { ShiftsUpsertService } from "src/time-and-attendance/time-tracker/shifts/services/shifts-upsert.service";
import { CreateShiftResult, UpdateShiftResult } from "src/time-and-attendance/utils/type.utils";
import { Roles as RoleEnum } from '.prisma/client';
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { GLOBAL_CONTROLLER_ROLES } from "src/common/shared/role-groupes";
@Controller('shift')
@RolesAllowed(...GLOBAL_CONTROLLER_ROLES)
export class ShiftController {
constructor(
private readonly upsert_service: ShiftsUpsertService,
){}
@Post('create')
@RolesAllowed(RoleEnum.EMPLOYEE, RoleEnum.ACCOUNTING, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ADMIN)
createBatch( @Req() req, @Body()dtos: ShiftDto[]): Promise<CreateShiftResult[]> {
const email = req.user?.email;
const list = Array.isArray(dtos) ? dtos : [];
@ -21,10 +22,7 @@ export class ShiftController {
return this.upsert_service.createShifts(email, dtos)
}
//change Body to receive dtos
@Patch('update')
@RolesAllowed(RoleEnum.EMPLOYEE, RoleEnum.ACCOUNTING, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ADMIN)
updateBatch( @Body() dtos: UpdateShiftDto[]): Promise<UpdateShiftResult[]>{
const list = Array.isArray(dtos) ? dtos: [];
if(list.length === 0) throw new BadRequestException('Body is missing or invalid (update shifts)');
@ -32,7 +30,6 @@ export class ShiftController {
}
@Delete(':shift_id')
@RolesAllowed(RoleEnum.EMPLOYEE, RoleEnum.ACCOUNTING, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ADMIN)
remove(@Param('shift_id') shift_id: number ) {
return this.upsert_service.deleteShift(shift_id);
}

View File

@ -49,8 +49,8 @@ export class ShiftsUpsertService {
};
return { index, error };
}
if(!normed.end_time) throw new BadRequestException('A shift needs an end_time');
if(!normed.start_time) throw new BadRequestException('A shift needs a start_time');
if (!normed.end_time) throw new BadRequestException('A shift needs an end_time');
if (!normed.start_time) throw new BadRequestException('A shift needs a start_time');
const timesheet = await this.prisma.timesheets.findUnique({
where: { id: dto.timesheet_id, employee_id },
@ -116,7 +116,7 @@ export class ShiftsUpsertService {
if (
overlaps(
{ start: ordered[j - 1].start, end: ordered[j - 1].end, date: ordered[j - 1].date },
{ start: ordered[j].start, end: ordered[j].end, date: ordered[j].date },
{ start: ordered[j].start, end: ordered[j].end, date: ordered[j].date },
)
) {
const error = new ConflictException({
@ -148,7 +148,7 @@ export class ShiftsUpsertService {
where: { timesheet_id, date: day_date },
select: { start_time: true, end_time: true, id: true, date: true },
});
existing_map.set( key, rows.map((row) => ({ start_time: row.start_time, end_time: row.end_time, date: row.date })));
existing_map.set(key, rows.map((row) => ({ start_time: row.start_time, end_time: row.end_time, date: row.date })));
}
normed_shifts.forEach((x, i) => {
@ -165,7 +165,7 @@ export class ShiftsUpsertService {
existing_map.set(map_key, existing);
}
const hit = existing.find(exist => overlaps({ start: exist.start_time, end: exist.end_time, date: exist.date },
{ start: normed.start_time, end: normed.end_time, date:normed.date}));
{ start: normed.start_time, end: normed.end_time, date: normed.date }));
if (hit) {
results[index] = {
ok: false,
@ -236,11 +236,11 @@ export class ShiftsUpsertService {
// recalculate overtime after update
// return an updated version to display
async updateShifts(dtos: UpdateShiftDto[]): Promise<UpdateShiftResult[]> {
if (!Array.isArray(dtos) || dtos.length === 0) return [];
if (!Array.isArray(dtos) || dtos.length === 0) throw new BadRequestException({ error_code: 'SHIFT_MISSING' });
const updates: UpdateShiftPayload[] = await Promise.all(dtos.map((item) => {
const { id, ...rest } = item;
if (!Number.isInteger(id)) throw new ConflictException({ error_code: 'INVALID_SHIFT'});
if (!id) throw new BadRequestException({ error_code: 'SHIFT_INVALID' });
const changes: UpdateShiftChanges = {};
if (rest.date !== undefined) changes.date = rest.date;
@ -265,13 +265,15 @@ export class ShiftsUpsertService {
const existing = regroup_id.get(update.id);
if (!existing) {
return updates.map(exist => exist.id === update.id
? ({ ok: false, id: update.id, error: new NotFoundException(`Shift with id: ${update.id} not found`) } as UpdateShiftResult)
: ({ ok: false, id: exist.id, error: new BadRequestException('Batch aborted due to missing shift') }));
? ({ ok: false, id: update.id, error: new NotFoundException({ error_code: 'SHIFT_MISSING' }) } as UpdateShiftResult)
: ({ ok: false, id: exist.id, error: new BadRequestException({ error_code: 'SHIFT_INVALID' }) })
);
}
if (existing.is_approved) {
return updates.map(exist => exist.id === update.id
? ({ ok: false, id: update.id, error: new BadRequestException('Approved shift cannot be updated') } as UpdateShiftResult)
: ({ ok: false, id: exist.id, error: new BadRequestException('Batch aborted due to approved shift in update set') }));
? ({ ok: false, id: update.id, error: new BadRequestException({ error_code: 'SHIFT_INVALID' }) } as UpdateShiftResult)
: ({ ok: false, id: exist.id, error: new BadRequestException({ error_code: 'SHIFT_INVALID' }) })
);
}
}
@ -307,12 +309,14 @@ export class ShiftsUpsertService {
where: { timesheet_id: group.timesheet_id, date: day_date },
select: { id: true, start_time: true, end_time: true, date: true },
});
groups.set(key(group.timesheet_id, day_date), { existing: existing.map(row => ({
id: row.id,
start: row.start_time,
end: row.end_time,
date: row.date,
})), incoming: planned_updates });
groups.set(key(group.timesheet_id, day_date), {
existing: existing.map(row => ({
id: row.id,
start: row.start_time,
end: row.end_time,
date: row.date,
})), incoming: planned_updates
});
}
for (const planned of planned_updates) {
@ -327,7 +331,7 @@ export class ShiftsUpsertService {
return updates.map(exist =>
exist.id === planned.exist_shift.id
? ({
ok: false, id: exist.id, error:{
ok: false, id: exist.id, error: {
error_code: 'SHIFT_OVERLAP',
conflicts: {
start_time: toStringFromHHmm(conflict.start),
@ -336,7 +340,7 @@ export class ShiftsUpsertService {
},
}
} as UpdateShiftResult)
: ({ ok: false, id: exist.id, error: new BadRequestException('Batch aborted due to overlap in another update') })
: ({ ok: false, id: exist.id, error: new BadRequestException({ error_code: 'SHIFT_OVERLAP' }) })
);
}
}
@ -358,7 +362,7 @@ export class ShiftsUpsertService {
for (let i = 1; i < arr.length; i++) {
if (overlaps(
{ start: arr[i - 1].start, end: arr[i - 1].end, date: arr[i - 1].date },
{ start: arr[i].start, end: arr[i].end, date: arr[i].date })
{ start: arr[i].start, end: arr[i].end, date: arr[i].date })
) {
const error = {
error_code: 'SHIFT_OVERLAP',
@ -426,7 +430,7 @@ export class ShiftsUpsertService {
where: { id: shift_id },
select: { id: true, date: true, timesheet_id: true },
});
if (!shift) throw new ConflictException({ error_code: 'INVALID_SHIFT'});
if (!shift) throw new ConflictException({ error_code: 'SHIFT_INVALID' });
await tx.shifts.delete({ where: { id: shift_id } });

View File

@ -1,31 +1,34 @@
import { Body, Controller, Get, ParseBoolPipe, ParseIntPipe, Patch, Query, Req, UnauthorizedException} from "@nestjs/common";
import { Body, Controller, Get, ParseBoolPipe, ParseIntPipe, Patch, Query, Req, UnauthorizedException } from "@nestjs/common";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { GetTimesheetsOverviewService } from "src/time-and-attendance/time-tracker/timesheets/services/timesheet-get-overview.service";
import { Roles as RoleEnum } from '.prisma/client';
import { TimesheetApprovalService } from "src/time-and-attendance/time-tracker/timesheets/services/timesheet-approval.service";
import { GLOBAL_CONTROLLER_ROLES, MANAGER_ROLES } from "src/common/shared/role-groupes";
@Controller('timesheets')
@RolesAllowed(...GLOBAL_CONTROLLER_ROLES)
export class TimesheetController {
constructor(
private readonly timesheetOverview: GetTimesheetsOverviewService,
private readonly approvalService: TimesheetApprovalService,
){}
) { }
@Get()
@RolesAllowed(RoleEnum.SUPERVISOR, RoleEnum.HR, RoleEnum.ACCOUNTING, RoleEnum.ADMIN)
async getTimesheetByIds(
@Req() req, @Query('year', ParseIntPipe) year:number, @Query('period_number', ParseIntPipe) period_number: number) {
getTimesheetByPayPeriod(
@Req() req,
@Query('year', ParseIntPipe) year: number,
@Query('period_number', ParseIntPipe) period_number: number
) {
const email = req.user?.email;
if(!email) throw new UnauthorizedException('Unauthorized User');
if (!email) throw new UnauthorizedException('Unauthorized User');
return this.timesheetOverview.getTimesheetsForEmployeeByPeriod(email, year, period_number);
}
@Patch('timesheet-approval')
@RolesAllowed(RoleEnum.SUPERVISOR, RoleEnum.HR, RoleEnum.ACCOUNTING, RoleEnum.ADMIN)
async approveTimesheet(
@RolesAllowed(...MANAGER_ROLES)
approveTimesheet(
@Body('timesheet_id', ParseIntPipe) timesheet_id: number,
@Body('is_approved' , ParseBoolPipe) is_approved: boolean,
@Body('is_approved', ParseBoolPipe) is_approved: boolean,
) {
return this.approvalService.approveTimesheetById(timesheet_id, is_approved);
}