From c9dbd43f9fbee9a8601f84ea4d5242dc57f44443 Mon Sep 17 00:00:00 2001 From: lynaDev2 Date: Mon, 1 Jun 2026 14:39:02 +0000 Subject: [PATCH 1/3] backend(assets): add assets module, controller, service and DTOs; wire into opsce and app modules --- backend/src/app.module.ts | 2 + backend/src/opsce/assets/assets.controller.ts | 89 ++++++++ backend/src/opsce/assets/assets.module.ts | 6 +- backend/src/opsce/assets/assets.service.ts | 188 +++++++++++++++++ .../src/opsce/assets/dto/create-asset.dto.ts | 198 ++++++++++++++++++ .../opsce/assets/dto/transfer-asset.dto.ts | 41 ++++ .../src/opsce/assets/dto/update-asset.dto.ts | 4 + backend/src/opsce/opsce.module.ts | 12 +- 8 files changed, 537 insertions(+), 3 deletions(-) create mode 100644 backend/src/opsce/assets/assets.controller.ts create mode 100644 backend/src/opsce/assets/assets.service.ts create mode 100644 backend/src/opsce/assets/dto/create-asset.dto.ts create mode 100644 backend/src/opsce/assets/dto/transfer-asset.dto.ts create mode 100644 backend/src/opsce/assets/dto/update-asset.dto.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2dcad4bf..1dd1cfbe 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -4,6 +4,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { OpsceModule } from './opsce/opsce.module'; @Module({ imports: [ @@ -22,6 +23,7 @@ import { AppService } from './app.service'; }), inject: [ConfigService], }), + OpsceModule, ], controllers: [AppController], providers: [AppService], diff --git a/backend/src/opsce/assets/assets.controller.ts b/backend/src/opsce/assets/assets.controller.ts new file mode 100644 index 00000000..2a43bc60 --- /dev/null +++ b/backend/src/opsce/assets/assets.controller.ts @@ -0,0 +1,89 @@ +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + Param, + Patch, + Post, + Query, + Req, + UnauthorizedException, + UsePipes, + ValidationPipe, + DefaultValuePipe, + ParseIntPipe, +} from '@nestjs/common'; +import { Request } from 'express'; +import { AssetsService } from './assets.service'; +import { CreateAssetDto } from './dto/create-asset.dto'; +import { UpdateAssetDto } from './dto/update-asset.dto'; +import { TransferAssetDto } from './dto/transfer-asset.dto'; +import { UserRole } from '../users/entities/user.entity'; + +@Controller('assets') +export class AssetsController { + constructor(private readonly assetsService: AssetsService) {} + + private getUser(req: Request) { + const user = req.user as { id: string; role?: string } | undefined; + if (!user || !user.id) { + throw new UnauthorizedException('Authentication required'); + } + return user; + } + + private assertRole(user: { role?: string }, allowedRoles: string[]) { + if (!allowedRoles.includes(user.role)) { + throw new ForbiddenException('Insufficient permissions'); + } + } + + @Post() + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + async create(@Req() req: Request, @Body() dto: CreateAssetDto) { + const user = this.getUser(req); + this.assertRole(user, [UserRole.ADMIN, UserRole.MANAGER]); + return this.assetsService.create(dto, user.id); + } + + @Get() + async findAll( + @Req() req: Request, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page = 1, + @Query('limit', new DefaultValuePipe(25), ParseIntPipe) limit = 25, + ) { + this.getUser(req); + return this.assetsService.findAll(page, limit); + } + + @Get(':id') + async findOne(@Req() req: Request, @Param('id') id: string) { + this.getUser(req); + return this.assetsService.findOne(id); + } + + @Patch(':id') + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + async update(@Req() req: Request, @Param('id') id: string, @Body() dto: UpdateAssetDto) { + const user = this.getUser(req); + this.assertRole(user, [UserRole.ADMIN, UserRole.MANAGER]); + return this.assetsService.update(id, dto, user.id); + } + + @Delete(':id') + async remove(@Req() req: Request, @Param('id') id: string) { + const user = this.getUser(req); + this.assertRole(user, [UserRole.ADMIN]); + return this.assetsService.softRemove(id, user.id); + } + + @Post(':id/transfer') + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + async transfer(@Req() req: Request, @Param('id') id: string, @Body() dto: TransferAssetDto) { + const user = this.getUser(req); + this.assertRole(user, [UserRole.ADMIN, UserRole.MANAGER]); + return this.assetsService.transfer(id, dto, user.id); + } +} diff --git a/backend/src/opsce/assets/assets.module.ts b/backend/src/opsce/assets/assets.module.ts index f1055a63..0f325327 100644 --- a/backend/src/opsce/assets/assets.module.ts +++ b/backend/src/opsce/assets/assets.module.ts @@ -1,9 +1,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Asset } from './entities/asset.entity'; +import { AssetsController } from './assets.controller'; +import { AssetsService } from './assets.service'; @Module({ imports: [TypeOrmModule.forFeature([Asset])], - exports: [TypeOrmModule], + controllers: [AssetsController], + providers: [AssetsService], + exports: [TypeOrmModule, AssetsService], }) export class AssetsModule {} diff --git a/backend/src/opsce/assets/assets.service.ts b/backend/src/opsce/assets/assets.service.ts new file mode 100644 index 00000000..74367a8f --- /dev/null +++ b/backend/src/opsce/assets/assets.service.ts @@ -0,0 +1,188 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuditService } from '../../audit/audit.service'; +import { Asset } from './entities/asset.entity'; +import { CreateAssetDto } from './dto/create-asset.dto'; +import { UpdateAssetDto } from './dto/update-asset.dto'; +import { TransferAssetDto } from './dto/transfer-asset.dto'; + +@Injectable() +export class AssetsService { + private readonly relations = ['assignedToUser', 'department', 'location']; + + constructor( + @InjectRepository(Asset) + private readonly assetRepo: Repository, + private readonly auditService: AuditService, + ) {} + + async create(dto: CreateAssetDto, userId?: string): Promise { + const payload = this.mapDtoToEntity(dto) as Partial; + if (dto.assignedTo) { + payload.assignedAt = new Date(); + } + if (userId) { + payload.createdBy = userId; + } + + const asset = this.assetRepo.create(payload); + const saved = await this.assetRepo.save(asset); + + await this.auditService.log({ + userId, + action: 'ASSET_CREATED', + resourceType: 'asset', + resourceId: saved.id, + newValue: saved, + }); + + return this.findOne(saved.id); + } + + async findAll(page = 1, limit = 25): Promise<{ data: Asset[]; total: number; page: number; limit: number }> { + const [data, total] = await this.assetRepo.findAndCount({ + relations: this.relations, + order: { createdAt: 'DESC' }, + take: limit, + skip: (page - 1) * limit, + }); + return { data, total, page, limit }; + } + + async findOne(id: string): Promise { + const asset = await this.assetRepo.findOne({ + where: { id }, + relations: this.relations, + }); + + if (!asset) { + throw new NotFoundException('Asset not found'); + } + + return asset; + } + + async update(id: string, dto: UpdateAssetDto, userId?: string): Promise { + const asset = await this.assetRepo.findOne({ where: { id } }); + + if (!asset) { + throw new NotFoundException('Asset not found'); + } + + const oldValue = { + assignedTo: asset.assignedToUserId, + departmentId: asset.departmentId, + locationId: asset.locationId, + name: asset.name, + category: asset.category, + status: asset.status, + condition: asset.condition, + }; + + const payload = this.mapDtoToEntity(dto) as Partial; + Object.entries(payload).forEach(([key, value]) => { + if (value !== undefined) { + (asset as any)[key] = value; + } + }); + + if (dto.assignedTo && dto.assignedTo !== asset.assignedToUserId) { + asset.assignedAt = new Date(); + } + if (userId) { + asset.updatedBy = userId; + } + + const updated = await this.assetRepo.save(asset); + + await this.auditService.log({ + userId, + action: 'ASSET_UPDATED', + resourceType: 'asset', + resourceId: updated.id, + oldValue, + newValue: updated, + }); + + return this.findOne(updated.id); + } + + async transfer(id: string, dto: TransferAssetDto, userId?: string): Promise { + const asset = await this.assetRepo.findOne({ where: { id } }); + + if (!asset) { + throw new NotFoundException('Asset not found'); + } + + const oldValue = { + assignedTo: asset.assignedToUserId, + departmentId: asset.departmentId, + locationId: asset.locationId, + }; + + if (dto.assignedTo !== undefined) { + asset.assignedToUserId = dto.assignedTo; + asset.assignedAt = new Date(); + } + if (dto.departmentId !== undefined) { + asset.departmentId = dto.departmentId; + } + if (dto.locationId !== undefined) { + asset.locationId = dto.locationId; + } + if (userId) { + asset.updatedBy = userId; + } + + const updated = await this.assetRepo.save(asset); + + await this.auditService.log({ + userId, + action: 'ASSET_TRANSFER', + resourceType: 'asset', + resourceId: updated.id, + oldValue, + newValue: { + assignedTo: updated.assignedToUserId, + departmentId: updated.departmentId, + locationId: updated.locationId, + }, + }); + + return this.findOne(updated.id); + } + + async softRemove(id: string, userId?: string): Promise { + const asset = await this.assetRepo.findOne({ where: { id } }); + + if (!asset) { + throw new NotFoundException('Asset not found'); + } + + const oldValue = { ...asset }; + asset.deletedBy = userId; + + const deletedAsset = await this.assetRepo.softRemove(asset); + + await this.auditService.log({ + userId, + action: 'ASSET_DELETED', + resourceType: 'asset', + resourceId: deletedAsset.id, + oldValue, + newValue: { deletedAt: deletedAsset.deletedAt, deletedBy: deletedAsset.deletedBy }, + }); + + return deletedAsset; + } + + private mapDtoToEntity(dto: CreateAssetDto | UpdateAssetDto | TransferAssetDto): Record { + const payload = { ...dto } as Record; + if ('assignedTo' in payload) { + payload.assignedToUserId = payload.assignedTo; + delete payload.assignedTo; + } + return payload; + } +} diff --git a/backend/src/opsce/assets/dto/create-asset.dto.ts b/backend/src/opsce/assets/dto/create-asset.dto.ts new file mode 100644 index 00000000..d471b580 --- /dev/null +++ b/backend/src/opsce/assets/dto/create-asset.dto.ts @@ -0,0 +1,198 @@ +import { Type } from 'class-transformer'; +import { + ArrayNotEmpty, + ArrayUnique, + IsArray, + IsDateString, + IsEnum, + IsInt, + IsNotEmpty, + IsNumber, + IsObject, + IsOptional, + IsString, + IsUUID, + IsUrl, + Length, + Max, + MaxLength, + Min, +} from 'class-validator'; +import { + AssetCondition, + AssetStatus, + DepreciationMethod, + InsuranceInfo, + MaintenanceSchedule, + WarrantyInfo, +} from '../entities/asset.entity'; + +export class CreateAssetDto { + @IsString() + @IsNotEmpty() + @MaxLength(200) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + assetTag?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + serialNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + manufacturer?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + model?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1900) + @Max(2100) + modelYear?: number; + + @IsString() + @IsNotEmpty() + @MaxLength(100) + category: string; + + @IsOptional() + @IsString() + @MaxLength(100) + subCategory?: string; + + @IsOptional() + @IsEnum(AssetStatus) + status?: AssetStatus; + + @IsOptional() + @IsEnum(AssetCondition) + condition?: AssetCondition; + + @IsOptional() + @IsArray() + @ArrayUnique() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + @Length(3, 3) + currency?: string; + + @IsOptional() + @IsDateString() + purchaseDate?: string; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + purchaseValue?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + currentValue?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + residualValue?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + usefulLifeYears?: number; + + @IsOptional() + @IsEnum(DepreciationMethod) + depreciationMethod?: DepreciationMethod; + + @IsOptional() + @IsObject() + depreciationConfig?: Record; + + @IsOptional() + @IsString() + @MaxLength(200) + vendor?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + purchaseOrderNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + invoiceNumber?: string; + + @IsOptional() + @IsObject() + warrantyInfo?: WarrantyInfo; + + @IsOptional() + @IsObject() + insuranceInfo?: InsuranceInfo; + + @IsOptional() + @IsDateString() + nextMaintenanceDue?: string; + + @IsOptional() + @IsDateString() + lastMaintenanceDate?: string; + + @IsOptional() + @IsObject() + maintenanceSchedule?: MaintenanceSchedule; + + @IsOptional() + @IsUUID() + assignedTo?: string; + + @IsOptional() + @IsUUID() + departmentId?: string; + + @IsOptional() + @IsUUID() + locationId?: string; + + @IsOptional() + @IsUrl() + photoUrl?: string; + + @IsOptional() + @IsArray() + @ArrayUnique() + @IsUrl({}, { each: true }) + additionalPhotoUrls?: string[]; + + @IsOptional() + @IsArray() + @ArrayUnique() + @IsUrl({}, { each: true }) + documentUrls?: string[]; + + @IsOptional() + @IsObject() + metadata?: Record; +} diff --git a/backend/src/opsce/assets/dto/transfer-asset.dto.ts b/backend/src/opsce/assets/dto/transfer-asset.dto.ts new file mode 100644 index 00000000..c4e047e8 --- /dev/null +++ b/backend/src/opsce/assets/dto/transfer-asset.dto.ts @@ -0,0 +1,41 @@ +import { + IsOptional, + IsUUID, + Validate, + ValidatorConstraint, + ValidatorConstraintInterface, + ValidationArguments, +} from 'class-validator'; + +@ValidatorConstraint({ name: 'atLeastOneTransferTarget', async: false }) +class AtLeastOneTransferTarget implements ValidatorConstraintInterface { + validate(_: any, args: ValidationArguments): boolean { + const object = args.object as TransferAssetDto; + return !!( + object.assignedTo || + object.departmentId || + object.locationId + ); + } + + defaultMessage(): string { + return 'At least one of assignedTo, departmentId, or locationId must be provided'; + } +} + +export class TransferAssetDto { + @IsOptional() + @IsUUID() + assignedTo?: string; + + @IsOptional() + @IsUUID() + departmentId?: string; + + @IsOptional() + @IsUUID() + locationId?: string; + + @Validate(AtLeastOneTransferTarget) + _atLeastOneChecker?: boolean; +} diff --git a/backend/src/opsce/assets/dto/update-asset.dto.ts b/backend/src/opsce/assets/dto/update-asset.dto.ts new file mode 100644 index 00000000..891e1443 --- /dev/null +++ b/backend/src/opsce/assets/dto/update-asset.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateAssetDto } from './create-asset.dto'; + +export class UpdateAssetDto extends PartialType(CreateAssetDto) {} diff --git a/backend/src/opsce/opsce.module.ts b/backend/src/opsce/opsce.module.ts index 8848ae77..0eac50e5 100644 --- a/backend/src/opsce/opsce.module.ts +++ b/backend/src/opsce/opsce.module.ts @@ -1,12 +1,20 @@ import { Module } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; import { AssetsModule } from './assets/assets.module'; import { DepartmentsModule } from './departments/departments.module'; import { AuditModule } from './audit/audit.module'; import { UsersModule } from './users/users.module'; import { LocationsModule } from './locations/locations.module'; +import { HttpExceptionFilter } from './common/filters/http-exception.filter'; @Module({ - imports: [UsersModule, LocationsModule, AuditModule, DepartmentsModule], - exports: [UsersModule, LocationsModule, AuditModule, DepartmentsModule], + imports: [UsersModule, LocationsModule, AuditModule, DepartmentsModule, AssetsModule], + providers: [ + { + provide: APP_FILTER, + useClass: HttpExceptionFilter, + }, + ], + exports: [UsersModule, LocationsModule, AuditModule, DepartmentsModule, AssetsModule], }) export class OpsceModule {} From 1b05c17ff5e4a9eb99bda8e1a673c04753a8336f Mon Sep 17 00:00:00 2001 From: lynaDev2 Date: Mon, 1 Jun 2026 15:12:57 +0000 Subject: [PATCH 2/3] fix(backend): correct audit log types and import paths for assets; ensure build --- backend/src/opsce/assets/assets.service.ts | 2 +- backend/src/opsce/audit/audit.service.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/src/opsce/assets/assets.service.ts b/backend/src/opsce/assets/assets.service.ts index 74367a8f..6cf87c3e 100644 --- a/backend/src/opsce/assets/assets.service.ts +++ b/backend/src/opsce/assets/assets.service.ts @@ -1,7 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { AuditService } from '../../audit/audit.service'; +import { AuditService } from '../audit/audit.service'; import { Asset } from './entities/asset.entity'; import { CreateAssetDto } from './dto/create-asset.dto'; import { UpdateAssetDto } from './dto/update-asset.dto'; diff --git a/backend/src/opsce/audit/audit.service.ts b/backend/src/opsce/audit/audit.service.ts index afd7f12f..03d75949 100644 --- a/backend/src/opsce/audit/audit.service.ts +++ b/backend/src/opsce/audit/audit.service.ts @@ -8,8 +8,8 @@ export interface LogDto { action: string; resourceType: string; resourceId: string; - oldValue?: Record; - newValue?: Record; + oldValue?: unknown; + newValue?: unknown; ipAddress?: string; userAgent?: string; } @@ -22,8 +22,8 @@ export class AuditService { ) {} async log(dto: LogDto): Promise { - const entry = this.repo.create(dto); - return this.repo.save(entry); + const entry = this.repo.create(dto as any); + return this.repo.save(entry as any); } async findByResource(resourceType: string, resourceId: string): Promise { From e789b5485a36e5d443b288ab15c75ccf818da88c Mon Sep 17 00:00:00 2001 From: lynaDev2 Date: Mon, 1 Jun 2026 16:29:33 +0000 Subject: [PATCH 3/3] feat(opsce): add assets CRUD, asset transfer workflow and global exception filter --- backend/src/opsce/assets/assets.service.ts | 2 +- .../opsce/auth/decorators/roles.decorator.ts | 5 ++ .../src/opsce/auth/guards/roles.guard.spec.ts | 47 ++++++++++++++++ backend/src/opsce/auth/guards/roles.guard.ts | 29 ++++++++++ .../filters/http-exception.filter.spec.ts | 36 ++++++++++++ .../common/filters/http-exception.filter.ts | 56 +++++++++++++++++++ 6 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 backend/src/opsce/auth/decorators/roles.decorator.ts create mode 100644 backend/src/opsce/auth/guards/roles.guard.spec.ts create mode 100644 backend/src/opsce/auth/guards/roles.guard.ts create mode 100644 backend/src/opsce/common/filters/http-exception.filter.spec.ts create mode 100644 backend/src/opsce/common/filters/http-exception.filter.ts diff --git a/backend/src/opsce/assets/assets.service.ts b/backend/src/opsce/assets/assets.service.ts index 6cf87c3e..74367a8f 100644 --- a/backend/src/opsce/assets/assets.service.ts +++ b/backend/src/opsce/assets/assets.service.ts @@ -1,7 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { AuditService } from '../audit/audit.service'; +import { AuditService } from '../../audit/audit.service'; import { Asset } from './entities/asset.entity'; import { CreateAssetDto } from './dto/create-asset.dto'; import { UpdateAssetDto } from './dto/update-asset.dto'; diff --git a/backend/src/opsce/auth/decorators/roles.decorator.ts b/backend/src/opsce/auth/decorators/roles.decorator.ts new file mode 100644 index 00000000..3288d7e2 --- /dev/null +++ b/backend/src/opsce/auth/decorators/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import { UserRole } from '../../users/entities/user.entity'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); diff --git a/backend/src/opsce/auth/guards/roles.guard.spec.ts b/backend/src/opsce/auth/guards/roles.guard.spec.ts new file mode 100644 index 00000000..7de83178 --- /dev/null +++ b/backend/src/opsce/auth/guards/roles.guard.spec.ts @@ -0,0 +1,47 @@ +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { RolesGuard } from './roles.guard'; +import { UserRole } from '../../users/entities/user.entity'; + +describe('RolesGuard', () => { + let reflector: Reflector; + let guard: RolesGuard; + + beforeEach(() => { + reflector = new Reflector(); + guard = new RolesGuard(reflector); + }); + + function createContext(user: Record | undefined): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => ({ user }), + }), + getHandler: () => () => undefined, + } as unknown as ExecutionContext; + } + + it('allows access when no roles are required', () => { + jest.spyOn(reflector, 'get').mockReturnValue(undefined); + const context = createContext({ role: UserRole.VIEWER }); + expect(guard.canActivate(context)).toBe(true); + }); + + it('allows ADMIN for ADMIN-protected routes', () => { + jest.spyOn(reflector, 'get').mockReturnValue([UserRole.ADMIN]); + const context = createContext({ role: UserRole.ADMIN }); + expect(guard.canActivate(context)).toBe(true); + }); + + it('allows MANAGER for ADMIN or MANAGER routes', () => { + jest.spyOn(reflector, 'get').mockReturnValue([UserRole.ADMIN, UserRole.MANAGER]); + const context = createContext({ role: UserRole.MANAGER }); + expect(guard.canActivate(context)).toBe(true); + }); + + it('forbids VIEWER for ADMIN-only routes', () => { + jest.spyOn(reflector, 'get').mockReturnValue([UserRole.ADMIN]); + const context = createContext({ role: UserRole.VIEWER }); + expect(() => guard.canActivate(context)).toThrow(ForbiddenException); + }); +}); diff --git a/backend/src/opsce/auth/guards/roles.guard.ts b/backend/src/opsce/auth/guards/roles.guard.ts new file mode 100644 index 00000000..c662b2ef --- /dev/null +++ b/backend/src/opsce/auth/guards/roles.guard.ts @@ -0,0 +1,29 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from '../decorators/roles.decorator'; +import { UserRole } from '../../users/entities/user.entity'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.get(ROLES_KEY, context.getHandler()); + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const user = request.user as { role?: string } | undefined; + + if (!user || !user.role) { + throw new ForbiddenException('Insufficient permissions'); + } + + if (!requiredRoles.includes(user.role as UserRole)) { + throw new ForbiddenException('Insufficient permissions'); + } + + return true; + } +} diff --git a/backend/src/opsce/common/filters/http-exception.filter.spec.ts b/backend/src/opsce/common/filters/http-exception.filter.spec.ts new file mode 100644 index 00000000..5f839cae --- /dev/null +++ b/backend/src/opsce/common/filters/http-exception.filter.spec.ts @@ -0,0 +1,36 @@ +import { ArgumentsHost, BadRequestException } from '@nestjs/common'; +import { HttpExceptionFilter } from './http-exception.filter'; + +describe('HttpExceptionFilter', () => { + it('returns consistent JSON for HttpException errors', () => { + const response = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + const request = { + url: '/api/assets', + }; + + const host = { + switchToHttp: () => ({ + getResponse: () => response, + getRequest: () => request, + }), + } as unknown as ArgumentsHost; + + const filter = new HttpExceptionFilter(); + filter.catch(new BadRequestException('Invalid asset payload'), host); + + expect(response.status).toHaveBeenCalledWith(400); + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 400, + message: 'Invalid asset payload', + error: 'Bad Request', + timestamp: expect.any(String), + path: '/api/assets', + }), + ); + }); +}); diff --git a/backend/src/opsce/common/filters/http-exception.filter.ts b/backend/src/opsce/common/filters/http-exception.filter.ts new file mode 100644 index 00000000..ac088916 --- /dev/null +++ b/backend/src/opsce/common/filters/http-exception.filter.ts @@ -0,0 +1,56 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, +} from '@nestjs/common'; + +@Catch() +export class HttpExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let error = 'Internal Server Error'; + let message: string | string[] = 'An unexpected error occurred.'; + + if (exception instanceof HttpException) { + status = exception.getStatus(); + const responseBody = exception.getResponse(); + if (typeof responseBody === 'string') { + message = responseBody; + } else if ( + responseBody && + typeof responseBody === 'object' && + 'message' in responseBody + ) { + const messageValue = (responseBody as any).message; + if (Array.isArray(messageValue)) { + message = messageValue.join(', '); + } else { + message = messageValue; + } + error = (responseBody as any).error ?? exception.name; + } else { + message = exception.message; + } + error = error || exception.name; + } else if (exception instanceof Error) { + message = process.env.NODE_ENV === 'production' + ? 'Internal Server Error' + : exception.message; + error = exception.name ?? error; + } + + response.status(status).json({ + statusCode: status, + message, + error, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +}