feat(decorator): create a new Access decorator and added external_payroll_id to findEmployee

This commit is contained in:
Matthieu Haineault 2025-12-01 08:34:08 -05:00
parent f499abbb78
commit ddb469cd8e
16 changed files with 177 additions and 351 deletions

View File

@ -500,52 +500,6 @@
]
}
},
"/employees": {
"patch": {
"operationId": "EmployeesController_updateOrArchiveOrRestore",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateEmployeeDto"
}
}
}
},
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Employees"
]
},
"post": {
"operationId": "EmployeesController_create",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateEmployeeDto"
}
}
}
},
"responses": {
"201": {
"description": ""
}
},
"tags": [
"Employees"
]
}
},
"/preferences/update": {
"patch": {
"operationId": "PreferencesController_updatePreferences",
@ -639,29 +593,6 @@
"ModuleAccess"
]
}
},
"/module_access/revoke": {
"patch": {
"operationId": "ModuleAccessController_revokeModuleAccess",
"parameters": [
{
"name": "employee_email",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"ModuleAccess"
]
}
}
},
"info": {
@ -729,154 +660,6 @@
"type": "object",
"properties": {}
},
"UpdateEmployeeDto": {
"type": "object",
"properties": {
"id": {
"type": "number",
"example": 1,
"description": "Unique ID of an employee(primary-key, auto-incremented)"
},
"user_id": {
"type": "string",
"example": "0e6e2e1f-b157-4c7c-ae3f-999b3e4f914d",
"description": "UUID of the user linked to that employee"
},
"first_name": {
"type": "string",
"example": "Frodo",
"description": "Employee`s first name"
},
"last_name": {
"type": "string",
"example": "Baggins",
"description": "Employee`s last name"
},
"email": {
"type": "string",
"example": "i_cant_do_this_sam@targointernet.com",
"description": "Employee`s email"
},
"phone_number": {
"type": "string",
"example": "82538437464",
"description": "Employee`s phone number"
},
"residence": {
"type": "string",
"example": "1 Bagshot Row, Hobbiton, The Shire, Middle-earth",
"description": "Employee`s residence"
},
"external_payroll_id": {
"type": "number",
"example": 7464,
"description": "external ID for the pay system"
},
"company_code": {
"type": "number",
"example": 335567447,
"description": "Employee`s company code"
},
"job_title": {
"type": "string",
"example": "technicient",
"description": "employee`s job title"
},
"first_work_day": {
"format": "date-time",
"type": "string",
"example": "23/09/3018",
"description": "New hire date or undefined"
},
"last_work_day": {
"format": "date-time",
"type": "string",
"example": "25/03/3019",
"description": "Termination date (null to restore)"
},
"supervisor_id": {
"type": "number",
"description": "Supervisor ID"
}
}
},
"CreateEmployeeDto": {
"type": "object",
"properties": {
"id": {
"type": "number",
"example": 1,
"description": "Unique ID of an employee(primary-key, auto-incremented)"
},
"user_id": {
"type": "string",
"example": "0e6e2e1f-b157-4c7c-ae3f-999b3e4f914d",
"description": "UUID of the user linked to that employee"
},
"first_name": {
"type": "string",
"example": "Frodo",
"description": "Employee`s first name"
},
"last_name": {
"type": "string",
"example": "Baggins",
"description": "Employee`s last name"
},
"email": {
"type": "string",
"example": "i_cant_do_this_sam@targointernet.com",
"description": "Employee`s email"
},
"phone_number": {
"type": "string",
"example": "82538437464",
"description": "Employee`s phone number"
},
"residence": {
"type": "string",
"example": "1 Bagshot Row, Hobbiton, The Shire, Middle-earth",
"description": "Employee`s residence"
},
"external_payroll_id": {
"type": "number",
"example": 7464,
"description": "external ID for the pay system"
},
"company_code": {
"type": "number",
"example": 335567447,
"description": "Employee`s company code"
},
"job_title": {
"type": "string",
"example": "technicient",
"description": "employee`s job title"
},
"first_work_day": {
"type": "string",
"example": "23/09/3018",
"description": "Employee`s first working day"
},
"last_work_day": {
"type": "string",
"example": "25/03/3019",
"description": "Employee`s last working day"
}
},
"required": [
"id",
"user_id",
"first_name",
"last_name",
"email",
"phone_number",
"external_payroll_id",
"company_code",
"job_title",
"first_work_day"
]
},
"PreferencesDto": {
"type": "object",
"properties": {}

View File

@ -1,11 +0,0 @@
import { SetMetadata } from "@nestjs/common";
export const OWNER_KEY = 'ownership';
export interface OwnershipMeta {
serviceToken: string;
idParam?: string;
ownerField?: string;
}
export const CheckOwnership = (meta: OwnershipMeta) =>
SetMetadata(OWNER_KEY, meta);

View File

@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
export const Access = createParamDecorator(
(data: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
},
);

View File

@ -1,51 +0,0 @@
import {
CanActivate,
Injectable,
ExecutionContext,
ForbiddenException,
} from "@nestjs/common";
import { Reflector, ModuleRef } from "@nestjs/core";
import { OWNER_KEY, OwnershipMeta } from "../decorators/ownership.decorator";
import { Request } from 'express';
interface RequestWithUser extends Request {
user: { id: string, role: string };
}
@Injectable()
export class OwnershipGuard implements CanActivate {
constructor(
private reflector: Reflector,
private moduleRef: ModuleRef,
) { }
async canActivate(context: ExecutionContext): Promise<boolean> {
const meta = this.reflector.get<OwnershipMeta>(
OWNER_KEY, context.getHandler(),
);
if (!meta)
return true;
const request = context.switchToHttp().getRequest<RequestWithUser>();
const user = request.user;
const resourceId = request.params[meta.idParam || 'id'];
const service = this.moduleRef.get<any>(
meta.serviceToken,
{ strict: false },
);
const resource = await service.findOne(resourceId);
const ownerField = meta.ownerField || 'ownerId';
if (user.role === 'ADMIN') {
return true;
}
if (!resource || resource[ownerField] !== user.id) {
throw new ForbiddenException(
`You do not own the rights to this resource.`
);
}
return true;
}
}

View File

@ -13,19 +13,19 @@ export class EmployeeTimesheetResolver {
constructor(
private readonly prisma: PrismaService,
private readonly emailResolver: EmailToIdResolver,
) {}
) { }
readonly findTimesheetIdByEmail = async (email: string, date: Date, client?: Tx): Promise<Result<{id: number}, string>> => {
readonly findTimesheetIdByEmail = async (email: string, date: Date, client?: Tx): Promise<Result<{ id: number }, string>> => {
const db = client ?? this.prisma;
const employee_id = await this.emailResolver.findIdByEmail(email);
if(!employee_id.success) return { success: false, error: employee_id.error}
if (!employee_id.success) return { success: false, error: employee_id.error }
const start_date = weekStartSunday(date);
console.log('start date: ', start_date);
const timesheet = await db.timesheets.findFirst({
where: { employee_id : employee_id.data, start_date: start_date },
where: { employee_id: employee_id.data, start_date: start_date },
select: { id: true },
});
if(!timesheet) throw new NotFoundException(`TIMESHEET_NOT_FOUND`);
return { success: true, data: {id: timesheet.id} };
if (!timesheet) return { success: false, error: 'TIMESHEET_NOT_FOUND' };
return { success: true, data: { id: timesheet.id } };
}
}

View File

@ -1,56 +1,82 @@
import { Controller, Get, Patch, Param, Body, NotFoundException, Req, Post, Query } from "@nestjs/common";
import { Employees } from "@prisma/client";
import { Controller, Get, Req, Query } from "@nestjs/common";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { Result } from "src/common/errors/result-error.factory";
import { GLOBAL_CONTROLLER_ROLES, MANAGER_ROLES } from "src/common/shared/role-groupes";
import { CreateEmployeeDto } from "src/identity-and-account/employees/dtos/create-employee.dto";
import { Access } from "src/common/guards/module-access.guard";
import { GLOBAL_CONTROLLER_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";
import { AccessGetService } from "src/identity-and-account/user-module-access/services/module-access-get.service";
@RolesAllowed(...GLOBAL_CONTROLLER_ROLES)
@Controller('employees')
export class EmployeesController {
constructor(
private readonly employeesService: EmployeesService,
private readonly archiveService: EmployeesArchivalService,
private readonly accessGetService: AccessGetService,
) { }
@Get('profile')
findOneProfile(
@Req() req,
async findOneProfile(
@Access('email') email:string,
@Query('employee_email') employee_email?: string,
): Promise<Result<EmployeeProfileItemDto,string>> {
const email = req.user?.email;
): Promise<Result<EmployeeProfileItemDto, string>> {
//fetch the current user granted access
const granted_access = await this.accessGetService.findModuleAccess(email);
if (!granted_access.success) return { success: false, error: 'INVALID_USER' };
//check if credentials are enough to use this resource
if (!granted_access.data.personal_profile) return { success: false, error: 'UNAUTHORIZED_ACCESS' };
return this.employeesService.findOneProfile(employee_email ?? email);
}
//TODO: create a custom decorator to replace the findModuleAcces call function
@Get('employee-list')
@RolesAllowed(...MANAGER_ROLES)
findListEmployees(): Promise<Result<EmployeeListItemDto[], string>> {
return this.employeesService.findListEmployees();
}
@Patch()
@RolesAllowed(...MANAGER_ROLES)
async updateOrArchiveOrRestore(@Req() req, @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
async findListEmployees(@Req() req): Promise<Result<EmployeeListItemDto[], string>> {
const email = req.user?.email;
const result = await this.archiveService.patchEmployee(email, dto);
if (!result) {
throw new NotFoundException(`Employee with email: ${email} is not found in active or archive.`)
}
return result;
//fetch the current user granted access
const granted_access = await this.accessGetService.findModuleAccess(email);
if (!granted_access.success) return { success: false, error: 'INVALID_USER' };
//check if credentials are enough to use this resource
if (!granted_access.data.employee_management) return { success: false, error: 'UNAUTHORIZED_ACCESS' };
const employee_list = await this.employeesService.findListEmployees();
if (!employee_list.success) return { success: false, error: employee_list.error };
return { success: true, data: employee_list.data };
}
@Post()
@RolesAllowed(...MANAGER_ROLES)
create(@Body() dto: CreateEmployeeDto): Promise<Employees> {
return this.employeesService.create(dto);
}
// @Patch()
// async updateOrArchiveOrRestore(@Req() req, @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 email = req.user?.email;
// const result = await this.archiveService.patchEmployee(email, dto);
// if (!result) {
// throw new NotFoundException(`Employee with email: ${email} is not found in active or archive.`)
// }
// return result;
// }
// @Post()
// async create(@Body() dto: CreateEmployeeDto, @Req() req): Promise<Result<Employees, string>> {
// try {
// const email = req.user?.email;
// //fetch the current user granted access
// const granted_access = await this.accessgetService.findModuleAccess(email);
// if (!granted_access.success) return { success: false, error: 'INVALID_USER' };
// //check if credentials are enough to use this resource
// if (!granted_access.data.employee_management) return { success: false, error: 'UNAUTHORIZED_ACCESS' };
// const created_employee = await this.employeesService.create(dto);
// return { success: true, data: created_employee };
// } catch (error) {
// return { success: false, error: 'UNAUTHORIZED_USER' };
// }
// }
}

View File

@ -5,4 +5,5 @@ export class EmployeeListItemDto {
supervisor_full_name: string | null;
company_name: number | null;
job_title: string | null;
external_payroll_id: number;
}

View File

@ -10,4 +10,5 @@ export class EmployeeProfileItemDto {
first_work_day: string;
last_work_day?: string | null;
residence: string | null;
external_payroll_id: number;
}

View File

@ -2,11 +2,13 @@ import { Module } from '@nestjs/common';
import { EmployeesController } from './controllers/employees.controller';
import { EmployeesService } from './services/employees.service';
import { EmployeesArchivalService } from 'src/identity-and-account/employees/services/employees-archival.service';
import { AccessGetService } from 'src/identity-and-account/user-module-access/services/module-access-get.service';
import { EmailToIdResolver } from 'src/common/mappers/email-id.mapper';
@Module({
imports: [],
controllers: [EmployeesController],
providers: [EmployeesService, EmployeesArchivalService],
providers: [EmployeesService, EmployeesArchivalService, AccessGetService, EmailToIdResolver],
exports: [EmployeesService ],
})
export class EmployeesModule {}

View File

@ -32,6 +32,7 @@ export class EmployeesService {
},
job_title: true,
company_code: true,
external_payroll_id: true,
}
}).then(rows => rows.map(r => ({
first_name: r.user.first_name,
@ -39,6 +40,7 @@ export class EmployeesService {
email: r.user.email,
company_name: r.company_code,
job_title: r.job_title,
external_payroll_id: r.external_payroll_id,
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,
})),
@ -72,6 +74,7 @@ export class EmployeesService {
company_code: true,
first_work_day: true,
last_work_day: true,
external_payroll_id: true,
}
});
if (!employee)return {success: false, error: `Employee with email ${email} not found`};
@ -86,6 +89,7 @@ export class EmployeesService {
phone_number: employee.user.phone_number,
company_name: employee.company_code,
job_title: employee.job_title,
external_payroll_id: employee.external_payroll_id,
employee_full_name: `${employee.user.first_name} ${employee.user.last_name}`,
first_work_day: employee.first_work_day.toISOString().slice(0, 10),
last_work_day: employee.last_work_day ? employee.last_work_day.toISOString().slice(0, 10) : null,

View File

@ -1,4 +1,5 @@
import { Body, Controller, Get, Patch, Query, Req } from "@nestjs/common";
import { Result } from "src/common/errors/result-error.factory";
import { ModuleAccess } from "src/identity-and-account/user-module-access/dtos/module-acces.dto";
import { AccessGetService } from "src/identity-and-account/user-module-access/services/module-access-get.service";
import { AccessUpdateService } from "src/identity-and-account/user-module-access/services/module-access-update.service";
@ -14,9 +15,21 @@ export class ModuleAccessController {
async findAccess(
@Req() req,
@Query('employee_email') employee_email?: string
) {
const email = req.user?.email;
): Promise<Result<boolean, string>> {
try {
const email: string = req.user?.email;
//fetch the current user granted access
const granted_access = await this.getService.findModuleAccess(email);
if (!granted_access.success) return { success: false, error: 'INVALID_USER' };
//check if credentials are enough to use this resource
if (!granted_access.data.employee_management) return { success: false, error: 'UNAUTHORIZED_ACCESS' };
await this.getService.findModuleAccess(email, employee_email);
return { success: true, data: true };
} catch (error) {
return { success: false, error: 'UNAUTORIZED_USER' };
}
};
@Patch('update')
@ -24,17 +37,42 @@ export class ModuleAccessController {
@Req() req,
@Body() dto: ModuleAccess,
@Query('employee_email') employee_email?: string
) {
const email = req.user?.email;
): Promise<Result<boolean, string>> {
try {
const email: string = req.user?.email;
//fetch the current user granted access
const granted_access = await this.getService.findModuleAccess(email);
if (!granted_access.success) return { success: false, error: 'INVALID_USER' };
//check if credentials are enough to use this resource
if (!granted_access.data.employee_management) return { success: false, error: 'UNAUTHORIZED_ACCESS' };
await this.updateService.updateModuleAccess(email, dto, employee_email);
return { success: true, data: true };
} catch (error) {
return { success: false, error: 'UNAUTORIZED_USER' };
}
};
@Patch('revoke')
async revokeModuleAccess(
@Req() req,
@Query('employee_email') employee_email?: string
) {
const email = req.user?.email;
await this.updateService.revokeModuleAccess(email, employee_email);
};
// @Patch('revoke')
// async revokeModuleAccess(
// @Req() req,
// @Query('employee_email') employee_email?: string
// ) {
// try {
// const email: string = req.user?.email;
// //fetch the current user granted access
// const granted_access = await this.getService.findModuleAccess(email);
// if (!granted_access.success) return { success: false, error: 'INVALID_USER' };
// //check if credentials are enough to use this resource
// if (!granted_access.data.employee_management) return { success: false, error: 'UNAUTHORIZED_ACCESS' };
// await this.updateService.revokeModuleAccess(email, employee_email);
// return { success: true, data: true };
// } catch (error) {
// return { success: false, error: 'UNAUTORIZED_USER' };
// }
// };
}

View File

@ -7,6 +7,6 @@ import { EmailToIdResolver } from "src/common/mappers/email-id.mapper";
@Module({
controllers: [ModuleAccessController],
providers: [AccessUpdateService, AccessGetService, EmailToIdResolver],
exports: [],
exports: [AccessGetService],
})
export class ModuleAccessModule { }

View File

@ -4,6 +4,7 @@ import { EmailToIdResolver } from "src/common/mappers/email-id.mapper";
import { ModuleAccess } from "src/identity-and-account/user-module-access/dtos/module-acces.dto";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class AccessGetService {
constructor(
@ -38,5 +39,5 @@ export class AccessGetService {
dashboard: access.dashboard,
};
return { success: true, data: granted_access }
}
};
}

View File

@ -6,36 +6,61 @@ import { PrismaService } from 'src/prisma/prisma.service';
export abstract class AbstractUserService {
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 } });
const user = await this.prisma.users.findUnique({
where: { email },
include: {
user_module_access: {
select: {
dashboard: true,
employee_list: true,
employee_management: true,
personal_profile: true,
timesheets: true,
timesheets_approval: true,
},
},
},
});
if (!user) {
throw new NotFoundException(`No user with email #${email} exists`);
}
const user_module_access = user.user_module_access ?? {
dashboard: false,
employee_list: false,
employee_management: false,
personal_profile: false,
timesheets: false,
timesheets_approval: false,
};
const clean_user = {
first_name: user.first_name,
last_name: user.last_name,
email: user.email,
role: user.role,
user_module_access,
}
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

@ -13,7 +13,6 @@ import { ModuleRef, NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
// import { JwtAuthGuard } from './modules/authentication/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard';
import { OwnershipGuard } from './common/guards/ownership.guard';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { writeFileSync } from 'fs';
import * as session from 'express-session';
@ -32,7 +31,6 @@ async function bootstrap() {
app.useGlobalGuards(
// new JwtAuthGuard(reflector), //Authentification JWT
new RolesGuard(reflector), //deny-by-default and Role-based Access Control
new OwnershipGuard(reflector, app.get(ModuleRef)), //Global use of OwnershipGuard, not implemented yet
);
// Authentication and session