From 0f37e8c7fcb615b5f5c2e3ba250b8764563ca4b1 Mon Sep 17 00:00:00 2001 From: Mercy Duru Date: Mon, 1 Jun 2026 10:51:30 +0100 Subject: [PATCH 1/2] Created LocationsModule, added pagination, implemented file upload service, created DepartmentsModule --- backend/.env.example | 3 + backend/src/app.module.ts | 2 + backend/src/opsce/assets/assets.controller.ts | 114 +++++++++++++ backend/src/opsce/assets/assets.module.ts | 6 +- backend/src/opsce/assets/assets.service.ts | 47 ++++++ .../src/opsce/assets/dto/create-asset.dto.ts | 69 ++++++++ .../src/opsce/assets/dto/update-asset.dto.ts | 4 + backend/src/opsce/audit/audit.service.ts | 5 +- backend/src/opsce/auth/jwt-auth.guard.ts | 5 + backend/src/opsce/auth/jwt.strategy.ts | 23 +++ backend/src/opsce/auth/roles.decorator.ts | 5 + backend/src/opsce/auth/roles.guard.ts | 23 +++ .../common/dto/paginated-response.dto.ts | 26 +++ .../src/opsce/common/dto/pagination.dto.ts | 29 ++++ backend/src/opsce/common/index.ts | 21 +++ .../departments/departments.controller.ts | 144 ++++++++++++++++ .../opsce/departments/departments.module.ts | 9 +- .../opsce/departments/departments.service.ts | 152 +++++++++++++++++ .../departments/dto/create-department.dto.ts | 35 ++++ .../departments/dto/update-department.dto.ts | 14 ++ .../locations/dto/create-location.dto.ts | 36 ++++ .../locations/dto/update-location.dto.ts | 38 +++++ .../opsce/locations/locations.controller.ts | 151 +++++++++++++++++ .../src/opsce/locations/locations.module.ts | 7 +- .../src/opsce/locations/locations.service.ts | 154 ++++++++++++++++++ backend/src/opsce/opsce.module.ts | 35 +++- .../src/opsce/users/dto/create-user.dto.ts | 22 +++ 27 files changed, 1172 insertions(+), 7 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/update-asset.dto.ts create mode 100644 backend/src/opsce/auth/jwt-auth.guard.ts create mode 100644 backend/src/opsce/auth/jwt.strategy.ts create mode 100644 backend/src/opsce/auth/roles.decorator.ts create mode 100644 backend/src/opsce/auth/roles.guard.ts create mode 100644 backend/src/opsce/common/dto/paginated-response.dto.ts create mode 100644 backend/src/opsce/common/dto/pagination.dto.ts create mode 100644 backend/src/opsce/common/index.ts create mode 100644 backend/src/opsce/departments/departments.controller.ts create mode 100644 backend/src/opsce/departments/departments.service.ts create mode 100644 backend/src/opsce/departments/dto/create-department.dto.ts create mode 100644 backend/src/opsce/departments/dto/update-department.dto.ts create mode 100644 backend/src/opsce/locations/dto/create-location.dto.ts create mode 100644 backend/src/opsce/locations/dto/update-location.dto.ts create mode 100644 backend/src/opsce/locations/locations.controller.ts create mode 100644 backend/src/opsce/locations/locations.service.ts create mode 100644 backend/src/opsce/users/dto/create-user.dto.ts diff --git a/backend/.env.example b/backend/.env.example index dffd11f4..c38966b3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -20,3 +20,6 @@ SMTP_PASS= ASSET_ID_PREFIX=AST ASSET_ID_START=1000 + +# File Upload Configuration +UPLOAD_DIR=/var/uploads/assets 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..1985a534 --- /dev/null +++ b/backend/src/opsce/assets/assets.controller.ts @@ -0,0 +1,114 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + UseGuards, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, + ApiQuery, +} from '@nestjs/swagger'; +import { AssetsService } from './assets.service'; +import { CreateAssetDto } from './dto/create-asset.dto'; +import { UpdateAssetDto } from './dto/update-asset.dto'; +import { PaginationDto } from '../common/dto/pagination.dto'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from '../auth/roles.guard'; +import { Roles } from '../auth/roles.decorator'; +import { UserRole } from '../users/entities/user.entity'; + +@ApiTags('Assets') +@Controller('assets') +@UseGuards(JwtAuthGuard, RolesGuard) +@ApiBearerAuth('JWT-auth') +export class AssetsController { + constructor(private readonly assetsService: AssetsService) {} + + @Post() + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Create a new asset (ADMIN only)' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Asset successfully created', + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid input data', + }) + create(@Body() createAssetDto: CreateAssetDto) { + return this.assetsService.create(createAssetDto); + } + + @Get() + @ApiOperation({ summary: 'Get all assets with pagination' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Return paginated assets', + }) + findAll(@Query() paginationDto: PaginationDto) { + return this.assetsService.findAll(paginationDto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get an asset by ID' }) + @ApiParam({ name: 'id', description: 'Asset ID' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Return the asset', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Asset not found', + }) + findOne(@Param('id') id: string) { + return this.assetsService.findOne(id); + } + + @Patch(':id') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Update an asset (ADMIN only)' }) + @ApiParam({ name: 'id', description: 'Asset ID' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Asset successfully updated', + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid input data', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Asset not found', + }) + update(@Param('id') id: string, @Body() updateAssetDto: UpdateAssetDto) { + return this.assetsService.update(id, updateAssetDto); + } + + @Delete(':id') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Delete an asset (ADMIN only)' }) + @ApiParam({ name: 'id', description: 'Asset ID' }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'Asset successfully deleted', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Asset not found', + }) + remove(@Param('id') id: string) { + return this.assetsService.remove(id); + } +} diff --git a/backend/src/opsce/assets/assets.module.ts b/backend/src/opsce/assets/assets.module.ts index f1055a63..65a1bdfd 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: [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..347782e7 --- /dev/null +++ b/backend/src/opsce/assets/assets.service.ts @@ -0,0 +1,47 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Asset } from './entities/asset.entity'; +import { CreateAssetDto } from './dto/create-asset.dto'; +import { UpdateAssetDto } from './dto/update-asset.dto'; +import { PaginationDto, PaginatedResponseDto, paginate } from '../common'; + +@Injectable() +export class AssetsService { + constructor( + @InjectRepository(Asset) + private readonly assetRepository: Repository, + ) {} + + async create(createAssetDto: CreateAssetDto): Promise { + const asset = this.assetRepository.create(createAssetDto); + return this.assetRepository.save(asset); + } + + async findAll( + paginationDto: PaginationDto, + ): Promise> { + return paginate(this.assetRepository, paginationDto, { + order: { createdAt: 'DESC' }, + }); + } + + async findOne(id: string): Promise { + const asset = await this.assetRepository.findOne({ where: { id } }); + if (!asset) { + throw new NotFoundException(`Asset with ID ${id} not found`); + } + return asset; + } + + async update(id: string, updateAssetDto: UpdateAssetDto): Promise { + const asset = await this.findOne(id); + Object.assign(asset, updateAssetDto); + return this.assetRepository.save(asset); + } + + async remove(id: string): Promise { + const asset = await this.findOne(id); + await this.assetRepository.remove(asset); + } +} 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..ee95825b --- /dev/null +++ b/backend/src/opsce/assets/dto/create-asset.dto.ts @@ -0,0 +1,69 @@ +import { + IsString, + IsOptional, + IsEnum, + IsNumber, + IsDateString, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { AssetStatus, AssetCondition } from '../entities/asset.entity'; + +export class CreateAssetDto { + @ApiProperty({ description: 'Asset name' }) + @IsString() + name: string; + + @ApiPropertyOptional({ description: 'Asset description' }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ description: 'Asset category' }) + @IsString() + category: string; + + @ApiPropertyOptional({ description: 'Serial number' }) + @IsOptional() + @IsString() + serialNumber?: string; + + @ApiPropertyOptional({ description: 'Asset status', enum: AssetStatus }) + @IsOptional() + @IsEnum(AssetStatus) + status?: AssetStatus; + + @ApiPropertyOptional({ description: 'Asset condition', enum: AssetCondition }) + @IsOptional() + @IsEnum(AssetCondition) + condition?: AssetCondition; + + @ApiPropertyOptional({ description: 'Purchase date' }) + @IsOptional() + @IsDateString() + purchaseDate?: Date; + + @ApiPropertyOptional({ description: 'Purchase value' }) + @IsOptional() + @IsNumber() + purchaseValue?: number; + + @ApiPropertyOptional({ description: 'Current value' }) + @IsOptional() + @IsNumber() + currentValue?: number; + + @ApiPropertyOptional({ description: 'User ID assigned to' }) + @IsOptional() + @IsString() + assignedToUserId?: string; + + @ApiPropertyOptional({ description: 'Department ID' }) + @IsOptional() + @IsString() + departmentId?: string; + + @ApiPropertyOptional({ description: 'Location ID' }) + @IsOptional() + @IsString() + locationId?: string; +} 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..07107315 --- /dev/null +++ b/backend/src/opsce/assets/dto/update-asset.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateAssetDto } from './create-asset.dto'; + +export class UpdateAssetDto extends PartialType(CreateAssetDto) {} diff --git a/backend/src/opsce/audit/audit.service.ts b/backend/src/opsce/audit/audit.service.ts index afd7f12f..8a9de54a 100644 --- a/backend/src/opsce/audit/audit.service.ts +++ b/backend/src/opsce/audit/audit.service.ts @@ -26,7 +26,10 @@ export class AuditService { return this.repo.save(entry); } - async findByResource(resourceType: string, resourceId: string): Promise { + async findByResource( + resourceType: string, + resourceId: string, + ): Promise { return this.repo.find({ where: { resourceType, resourceId }, order: { createdAt: 'DESC' }, diff --git a/backend/src/opsce/auth/jwt-auth.guard.ts b/backend/src/opsce/auth/jwt-auth.guard.ts new file mode 100644 index 00000000..2155290e --- /dev/null +++ b/backend/src/opsce/auth/jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/backend/src/opsce/auth/jwt.strategy.ts b/backend/src/opsce/auth/jwt.strategy.ts new file mode 100644 index 00000000..551b85cc --- /dev/null +++ b/backend/src/opsce/auth/jwt.strategy.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(private configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.get('JWT_SECRET', 'secret-key'), + }); + } + + async validate(payload: any) { + return { + userId: payload.sub, + email: payload.email, + role: payload.role, + }; + } +} diff --git a/backend/src/opsce/auth/roles.decorator.ts b/backend/src/opsce/auth/roles.decorator.ts new file mode 100644 index 00000000..614a6b8b --- /dev/null +++ b/backend/src/opsce/auth/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/roles.guard.ts b/backend/src/opsce/auth/roles.guard.ts new file mode 100644 index 00000000..db13f616 --- /dev/null +++ b/backend/src/opsce/auth/roles.guard.ts @@ -0,0 +1,23 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from './roles.decorator'; +import { UserRole } from '../users/entities/user.entity'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!requiredRoles) { + return true; + } + + const { user } = context.switchToHttp().getRequest(); + return requiredRoles.some((role) => user?.role === role); + } +} diff --git a/backend/src/opsce/common/dto/paginated-response.dto.ts b/backend/src/opsce/common/dto/paginated-response.dto.ts new file mode 100644 index 00000000..7c637c7f --- /dev/null +++ b/backend/src/opsce/common/dto/paginated-response.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PaginatedResponseDto { + @ApiProperty({ description: 'Array of items for the current page' }) + data: T[]; + + @ApiProperty({ description: 'Total number of items' }) + total: number; + + @ApiProperty({ description: 'Current page number (1-based)' }) + page: number; + + @ApiProperty({ description: 'Number of items per page' }) + limit: number; + + @ApiProperty({ description: 'Total number of pages' }) + totalPages: number; + + constructor(data: T[], total: number, page: number, limit: number) { + this.data = data; + this.total = total; + this.page = page; + this.limit = limit; + this.totalPages = Math.ceil(total / limit); + } +} diff --git a/backend/src/opsce/common/dto/pagination.dto.ts b/backend/src/opsce/common/dto/pagination.dto.ts new file mode 100644 index 00000000..43212d6f --- /dev/null +++ b/backend/src/opsce/common/dto/pagination.dto.ts @@ -0,0 +1,29 @@ +import { IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class PaginationDto { + @ApiPropertyOptional({ + description: 'Page number (1-based)', + default: 1, + minimum: 1, + type: Number, + }) + @IsInt() + @Min(1) + @Type(() => Number) + page?: number = 1; + + @ApiPropertyOptional({ + description: 'Number of items per page', + default: 20, + minimum: 1, + maximum: 100, + type: Number, + }) + @IsInt() + @Min(1) + @Max(100) + @Type(() => Number) + limit?: number = 20; +} diff --git a/backend/src/opsce/common/index.ts b/backend/src/opsce/common/index.ts new file mode 100644 index 00000000..5b302ea2 --- /dev/null +++ b/backend/src/opsce/common/index.ts @@ -0,0 +1,21 @@ +import { FindManyOptions, Repository, ObjectLiteral } from 'typeorm'; +import { PaginationDto } from './dto/pagination.dto'; +import { PaginatedResponseDto } from './dto/paginated-response.dto'; + +export async function paginate( + repository: Repository, + paginationDto: PaginationDto, + options?: FindManyOptions, +): Promise> { + const { page = 1, limit = 20 } = paginationDto; + + const [data, total] = await repository.findAndCount({ + ...options, + skip: (page - 1) * limit, + take: limit, + }); + + return new PaginatedResponseDto(data, total, page, limit); +} + +export { PaginationDto, PaginatedResponseDto }; diff --git a/backend/src/opsce/departments/departments.controller.ts b/backend/src/opsce/departments/departments.controller.ts new file mode 100644 index 00000000..04f27437 --- /dev/null +++ b/backend/src/opsce/departments/departments.controller.ts @@ -0,0 +1,144 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseGuards, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, +} from '@nestjs/swagger'; +import { DepartmentsService } from './departments.service'; +import { CreateDepartmentDto } from './dto/create-department.dto'; +import { UpdateDepartmentDto } from './dto/update-department.dto'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from '../auth/roles.guard'; +import { Roles } from '../auth/roles.decorator'; +import { UserRole } from '../users/entities/user.entity'; + +@ApiTags('Departments') +@Controller('departments') +@UseGuards(JwtAuthGuard, RolesGuard) +@ApiBearerAuth('JWT-auth') +export class DepartmentsController { + constructor(private readonly departmentsService: DepartmentsService) {} + + @Post() + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Create a new department (ADMIN only)' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Department successfully created', + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid input data', + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Unauthorized', + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Forbidden - Admin role required', + }) + create(@Body() createDepartmentDto: CreateDepartmentDto) { + return this.departmentsService.create(createDepartmentDto); + } + + @Get() + @ApiOperation({ + summary: 'Get all departments as a flat list with childCount', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Return all departments', + }) + findAll() { + return this.departmentsService.findAll(); + } + + @Get(':id') + @ApiOperation({ + summary: 'Get a department by ID with children and asset count', + }) + @ApiParam({ name: 'id', description: 'Department ID' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Return the department', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Department not found', + }) + findOne(@Param('id') id: string) { + return this.departmentsService.findOne(id); + } + + @Patch(':id') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Update a department (ADMIN only)' }) + @ApiParam({ name: 'id', description: 'Department ID' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Department successfully updated', + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid input data', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Department not found', + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Unauthorized', + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Forbidden - Admin role required', + }) + update( + @Param('id') id: string, + @Body() updateDepartmentDto: UpdateDepartmentDto, + ) { + return this.departmentsService.update(id, updateDepartmentDto); + } + + @Delete(':id') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Delete a department (ADMIN only)' }) + @ApiParam({ name: 'id', description: 'Department ID' }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'Department successfully deleted', + }) + @ApiResponse({ + status: HttpStatus.CONFLICT, + description: 'Department has active assets', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Department not found', + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Unauthorized', + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Forbidden - Admin role required', + }) + remove(@Param('id') id: string) { + return this.departmentsService.remove(id); + } +} diff --git a/backend/src/opsce/departments/departments.module.ts b/backend/src/opsce/departments/departments.module.ts index 3c135018..b2937004 100644 --- a/backend/src/opsce/departments/departments.module.ts +++ b/backend/src/opsce/departments/departments.module.ts @@ -1,9 +1,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Department } from './entities/department.entity'; +import { Asset } from '../assets/entities/asset.entity'; +import { DepartmentsController } from './departments.controller'; +import { DepartmentsService } from './departments.service'; @Module({ - imports: [TypeOrmModule.forFeature([Department])], - exports: [TypeOrmModule], + imports: [TypeOrmModule.forFeature([Department, Asset])], + controllers: [DepartmentsController], + providers: [DepartmentsService], + exports: [DepartmentsService, TypeOrmModule], }) export class DepartmentsModule {} diff --git a/backend/src/opsce/departments/departments.service.ts b/backend/src/opsce/departments/departments.service.ts new file mode 100644 index 00000000..f1e23ca7 --- /dev/null +++ b/backend/src/opsce/departments/departments.service.ts @@ -0,0 +1,152 @@ +import { + Injectable, + NotFoundException, + ConflictException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Department } from './entities/department.entity'; +import { CreateDepartmentDto } from './dto/create-department.dto'; +import { UpdateDepartmentDto } from './dto/update-department.dto'; +import { Asset, AssetStatus } from '../assets/entities/asset.entity'; + +@Injectable() +export class DepartmentsService { + constructor( + @InjectRepository(Department) + private readonly departmentRepository: Repository, + @InjectRepository(Asset) + private readonly assetRepository: Repository, + ) {} + + async create(createDepartmentDto: CreateDepartmentDto): Promise { + // Validate parent exists if parentId is provided + if (createDepartmentDto.parentId) { + const parent = await this.departmentRepository.findOne({ + where: { id: createDepartmentDto.parentId }, + }); + if (!parent) { + throw new BadRequestException('Parent department not found'); + } + } + + const department = this.departmentRepository.create(createDepartmentDto); + return this.departmentRepository.save(department); + } + + async findAll(): Promise { + const departments = await this.departmentRepository.find({ + order: { name: 'ASC' }, + }); + + // Add childCount to each department + return departments.map((dept) => { + const childCount = departments.filter( + (d) => d.parentId === dept.id, + ).length; + return { ...dept, childCount }; + }); + } + + async findOne(id: string): Promise { + const department = await this.departmentRepository.findOne({ + where: { id }, + relations: ['children'], + }); + + if (!department) { + throw new NotFoundException(`Department with ID ${id} not found`); + } + + // Get asset count for this department + const assetCount = await this.assetRepository.count({ + where: { departmentId: id, status: AssetStatus.ACTIVE }, + }); + + return { ...department, assetCount } as Department & { assetCount: number }; + } + + async update( + id: string, + updateDepartmentDto: UpdateDepartmentDto, + ): Promise { + const department = await this.departmentRepository.findOne({ + where: { id }, + }); + + if (!department) { + throw new NotFoundException(`Department with ID ${id} not found`); + } + + // Validate parent exists if parentId is being updated + if (updateDepartmentDto.parentId) { + // Prevent setting parent to itself + if (updateDepartmentDto.parentId === id) { + throw new BadRequestException('Department cannot be its own parent'); + } + + const parent = await this.departmentRepository.findOne({ + where: { id: updateDepartmentDto.parentId }, + }); + if (!parent) { + throw new BadRequestException('Parent department not found'); + } + + // Prevent circular references + if (updateDepartmentDto.parentId) { + await this.checkCircularReference(id, updateDepartmentDto.parentId); + } + } + + Object.assign(department, updateDepartmentDto); + return this.departmentRepository.save(department); + } + + async remove(id: string): Promise { + const department = await this.departmentRepository.findOne({ + where: { id }, + }); + + if (!department) { + throw new NotFoundException(`Department with ID ${id} not found`); + } + + // Check if department has active assets + const activeAssetCount = await this.assetRepository.count({ + where: { departmentId: id, status: AssetStatus.ACTIVE }, + }); + + if (activeAssetCount > 0) { + throw new ConflictException( + `Cannot delete department with ${activeAssetCount} active asset(s)`, + ); + } + + await this.departmentRepository.remove(department); + } + + /** + * Check for circular references in department hierarchy + */ + private async checkCircularReference( + departmentId: string, + newParentId: string, + ): Promise { + let currentParentId: string | null = newParentId; + + while (currentParentId) { + if (currentParentId === departmentId) { + throw new BadRequestException( + 'Circular reference detected: cannot set parent to a child department', + ); + } + + const parent = await this.departmentRepository.findOne({ + where: { id: currentParentId }, + }); + + currentParentId = parent?.parentId ?? null; + } + } +} diff --git a/backend/src/opsce/departments/dto/create-department.dto.ts b/backend/src/opsce/departments/dto/create-department.dto.ts new file mode 100644 index 00000000..28e929ca --- /dev/null +++ b/backend/src/opsce/departments/dto/create-department.dto.ts @@ -0,0 +1,35 @@ +import { IsString, IsOptional, IsUUID } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateDepartmentDto { + @ApiProperty({ + description: 'Department name', + example: 'Engineering', + }) + @IsString() + name: string; + + @ApiPropertyOptional({ + description: 'Department description', + example: 'Engineering department responsible for product development', + }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ + description: 'Department code', + example: 'ENG', + }) + @IsOptional() + @IsString() + code?: string; + + @ApiPropertyOptional({ + description: 'Parent department ID (for hierarchical structure)', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + @IsOptional() + @IsUUID() + parentId?: string; +} diff --git a/backend/src/opsce/departments/dto/update-department.dto.ts b/backend/src/opsce/departments/dto/update-department.dto.ts new file mode 100644 index 00000000..790d9e8d --- /dev/null +++ b/backend/src/opsce/departments/dto/update-department.dto.ts @@ -0,0 +1,14 @@ +import { IsOptional, IsBoolean } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { PartialType } from '@nestjs/mapped-types'; +import { CreateDepartmentDto } from './create-department.dto'; + +export class UpdateDepartmentDto extends PartialType(CreateDepartmentDto) { + @ApiPropertyOptional({ + description: 'Whether the department is active', + example: true, + }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/backend/src/opsce/locations/dto/create-location.dto.ts b/backend/src/opsce/locations/dto/create-location.dto.ts new file mode 100644 index 00000000..4b173d91 --- /dev/null +++ b/backend/src/opsce/locations/dto/create-location.dto.ts @@ -0,0 +1,36 @@ +import { IsString, IsOptional, IsUUID, IsEnum } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { LocationType } from '../entities/location.entity'; + +export class CreateLocationDto { + @ApiProperty({ + description: 'Location name', + example: 'Building A', + }) + @IsString() + name: string; + + @ApiProperty({ + description: 'Location type', + enum: LocationType, + example: LocationType.BUILDING, + }) + @IsEnum(LocationType) + type: LocationType; + + @ApiPropertyOptional({ + description: 'Location address', + example: '123 Main Street, New York, NY 10001', + }) + @IsOptional() + @IsString() + address?: string; + + @ApiPropertyOptional({ + description: 'Parent location ID (for hierarchical structure)', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + @IsOptional() + @IsUUID() + parentId?: string; +} diff --git a/backend/src/opsce/locations/dto/update-location.dto.ts b/backend/src/opsce/locations/dto/update-location.dto.ts new file mode 100644 index 00000000..c22b9a91 --- /dev/null +++ b/backend/src/opsce/locations/dto/update-location.dto.ts @@ -0,0 +1,38 @@ +import { IsString, IsOptional, IsUUID, IsEnum } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { LocationType } from '../entities/location.entity'; + +export class UpdateLocationDto { + @ApiPropertyOptional({ + description: 'Location name', + example: 'Building B', + }) + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional({ + description: 'Location type', + enum: LocationType, + example: LocationType.FLOOR, + }) + @IsOptional() + @IsEnum(LocationType) + type?: LocationType; + + @ApiPropertyOptional({ + description: 'Location address', + example: '456 Oak Avenue, Boston, MA 02101', + }) + @IsOptional() + @IsString() + address?: string; + + @ApiPropertyOptional({ + description: 'Parent location ID (for hierarchical structure)', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + @IsOptional() + @IsUUID() + parentId?: string; +} diff --git a/backend/src/opsce/locations/locations.controller.ts b/backend/src/opsce/locations/locations.controller.ts new file mode 100644 index 00000000..5d963d0c --- /dev/null +++ b/backend/src/opsce/locations/locations.controller.ts @@ -0,0 +1,151 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + UseGuards, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, + ApiQuery, +} from '@nestjs/swagger'; +import { LocationsService } from './locations.service'; +import { CreateLocationDto } from './dto/create-location.dto'; +import { UpdateLocationDto } from './dto/update-location.dto'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from '../auth/roles.guard'; +import { Roles } from '../auth/roles.decorator'; +import { UserRole } from '../users/entities/user.entity'; + +@ApiTags('Locations') +@Controller('locations') +@UseGuards(JwtAuthGuard, RolesGuard) +@ApiBearerAuth('JWT-auth') +export class LocationsController { + constructor(private readonly locationsService: LocationsService) {} + + @Post() + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Create a new location (ADMIN only)' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Location successfully created', + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid input data', + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Unauthorized', + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Forbidden - Admin role required', + }) + create(@Body() createLocationDto: CreateLocationDto) { + return this.locationsService.create(createLocationDto); + } + + @Get() + @ApiOperation({ + summary: 'Get all locations as a flat list with childCount', + }) + @ApiQuery({ + name: 'type', + required: false, + description: 'Filter by location type', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Return all locations', + }) + findAll(@Query('type') type?: string) { + return this.locationsService.findAll(type); + } + + @Get(':id') + @ApiOperation({ + summary: 'Get a location by ID with children and asset count', + }) + @ApiParam({ name: 'id', description: 'Location ID' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Return the location', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Location not found', + }) + findOne(@Param('id') id: string) { + return this.locationsService.findOne(id); + } + + @Patch(':id') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Update a location (ADMIN only)' }) + @ApiParam({ name: 'id', description: 'Location ID' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Location successfully updated', + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid input data', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Location not found', + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Unauthorized', + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Forbidden - Admin role required', + }) + update( + @Param('id') id: string, + @Body() updateLocationDto: UpdateLocationDto, + ) { + return this.locationsService.update(id, updateLocationDto); + } + + @Delete(':id') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Delete a location (ADMIN only)' }) + @ApiParam({ name: 'id', description: 'Location ID' }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'Location successfully deleted', + }) + @ApiResponse({ + status: HttpStatus.CONFLICT, + description: 'Location has active assets', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Location not found', + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Unauthorized', + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Forbidden - Admin role required', + }) + remove(@Param('id') id: string) { + return this.locationsService.remove(id); + } +} diff --git a/backend/src/opsce/locations/locations.module.ts b/backend/src/opsce/locations/locations.module.ts index ef807e66..9bad4d6f 100644 --- a/backend/src/opsce/locations/locations.module.ts +++ b/backend/src/opsce/locations/locations.module.ts @@ -1,9 +1,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Location } from './entities/location.entity'; +import { Asset } from '../assets/entities/asset.entity'; +import { LocationsService } from './locations.service'; +import { LocationsController } from './locations.controller'; @Module({ - imports: [TypeOrmModule.forFeature([Location])], + imports: [TypeOrmModule.forFeature([Location, Asset])], + controllers: [LocationsController], + providers: [LocationsService], exports: [TypeOrmModule], }) export class LocationsModule {} diff --git a/backend/src/opsce/locations/locations.service.ts b/backend/src/opsce/locations/locations.service.ts new file mode 100644 index 00000000..537b8979 --- /dev/null +++ b/backend/src/opsce/locations/locations.service.ts @@ -0,0 +1,154 @@ +import { + Injectable, + NotFoundException, + ConflictException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Location } from './entities/location.entity'; +import { CreateLocationDto } from './dto/create-location.dto'; +import { UpdateLocationDto } from './dto/update-location.dto'; +import { Asset, AssetStatus } from '../assets/entities/asset.entity'; + +@Injectable() +export class LocationsService { + constructor( + @InjectRepository(Location) + private readonly locationRepository: Repository, + @InjectRepository(Asset) + private readonly assetRepository: Repository, + ) {} + + async create(createLocationDto: CreateLocationDto): Promise { + // Validate parent exists if parentId is provided + if (createLocationDto.parentId) { + const parent = await this.locationRepository.findOne({ + where: { id: createLocationDto.parentId }, + }); + if (!parent) { + throw new BadRequestException('Parent location not found'); + } + } + + const location = this.locationRepository.create(createLocationDto); + return this.locationRepository.save(location); + } + + async findAll(type?: string): Promise { + const queryBuilder = this.locationRepository.createQueryBuilder('location'); + + if (type) { + queryBuilder.andWhere('location.type = :type', { type }); + } + + const locations = await queryBuilder + .orderBy('location.name', 'ASC') + .getMany(); + + // Add childCount to each location + return locations.map((loc) => { + const childCount = locations.filter((l) => l.parentId === loc.id).length; + return { ...loc, childCount }; + }); + } + + async findOne(id: string): Promise { + const location = await this.locationRepository.findOne({ + where: { id }, + relations: ['children'], + }); + + if (!location) { + throw new NotFoundException(`Location with ID ${id} not found`); + } + + // Get asset count for this location + const assetCount = await this.assetRepository.count({ + where: { locationId: id, status: AssetStatus.ACTIVE }, + }); + + return { ...location, assetCount }; + } + + async update( + id: string, + updateLocationDto: UpdateLocationDto, + ): Promise { + const location = await this.locationRepository.findOne({ + where: { id }, + }); + + if (!location) { + throw new NotFoundException(`Location with ID ${id} not found`); + } + + // Validate parent exists if parentId is being updated + if (updateLocationDto.parentId) { + // Prevent setting parent to itself + if (updateLocationDto.parentId === id) { + throw new BadRequestException('Location cannot be its own parent'); + } + + const parent = await this.locationRepository.findOne({ + where: { id: updateLocationDto.parentId }, + }); + if (!parent) { + throw new BadRequestException('Parent location not found'); + } + + // Prevent circular references + await this.checkCircularReference(id, updateLocationDto.parentId); + } + + Object.assign(location, updateLocationDto); + return this.locationRepository.save(location); + } + + async remove(id: string): Promise { + const location = await this.locationRepository.findOne({ + where: { id }, + }); + + if (!location) { + throw new NotFoundException(`Location with ID ${id} not found`); + } + + // Check if location has active assets + const activeAssetCount = await this.assetRepository.count({ + where: { locationId: id, status: AssetStatus.ACTIVE }, + }); + + if (activeAssetCount > 0) { + throw new ConflictException( + `Cannot delete location with ${activeAssetCount} active asset(s)`, + ); + } + + await this.locationRepository.remove(location); + } + + /** + * Check for circular references in location hierarchy + */ + private async checkCircularReference( + locationId: string, + newParentId: string, + ): Promise { + let currentParentId: string | null = newParentId; + + while (currentParentId) { + if (currentParentId === locationId) { + throw new BadRequestException( + 'Circular reference detected: cannot set parent to a child location', + ); + } + + const parent = await this.locationRepository.findOne({ + where: { id: currentParentId }, + }); + + currentParentId = parent?.parentId ?? null; + } + } +} diff --git a/backend/src/opsce/opsce.module.ts b/backend/src/opsce/opsce.module.ts index 8848ae77..324de551 100644 --- a/backend/src/opsce/opsce.module.ts +++ b/backend/src/opsce/opsce.module.ts @@ -1,12 +1,43 @@ import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { ConfigModule, ConfigService } from '@nestjs/config'; 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 { UploadsModule } from './uploads/uploads.module'; +import { JwtStrategy } from './auth/jwt.strategy'; @Module({ - imports: [UsersModule, LocationsModule, AuditModule, DepartmentsModule], - exports: [UsersModule, LocationsModule, AuditModule, DepartmentsModule], + imports: [ + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET', 'secret-key'), + signOptions: { expiresIn: '24h' }, + }), + inject: [ConfigService], + }), + UsersModule, + LocationsModule, + AuditModule, + DepartmentsModule, + AssetsModule, + UploadsModule, + ], + exports: [ + PassportModule, + JwtModule, + UsersModule, + LocationsModule, + AuditModule, + DepartmentsModule, + AssetsModule, + UploadsModule, + ], + providers: [JwtStrategy], }) export class OpsceModule {} diff --git a/backend/src/opsce/users/dto/create-user.dto.ts b/backend/src/opsce/users/dto/create-user.dto.ts new file mode 100644 index 00000000..439cb2e1 --- /dev/null +++ b/backend/src/opsce/users/dto/create-user.dto.ts @@ -0,0 +1,22 @@ +import { IsString, IsEmail, IsEnum, IsOptional } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { UserRole } from '../entities/user.entity'; + +export class CreateUserDto { + @ApiProperty({ description: 'User email' }) + @IsEmail() + email: string; + + @ApiProperty({ description: 'User password hash' }) + @IsString() + passwordHash: string; + + @ApiProperty({ description: 'User full name' }) + @IsString() + fullName: string; + + @ApiPropertyOptional({ description: 'User role', enum: UserRole }) + @IsOptional() + @IsEnum(UserRole) + role?: UserRole; +} From f848acd7c531895ffbb1a015dbc7b61eb9562a3d Mon Sep 17 00:00:00 2001 From: Mercy Duru Date: Mon, 1 Jun 2026 11:52:24 +0100 Subject: [PATCH 2/2] fix errors --- .../src/opsce/assets/entities/asset.entity.ts | 372 +++++++++++++++++- backend/src/opsce/auth/auth.module.ts | 27 ++ .../opsce/departments/departments.service.ts | 4 +- .../departments/entities/department.entity.ts | 103 ++++- .../locations/entities/location.entity.ts | 315 ++++++++++++++- .../src/opsce/locations/locations.service.ts | 6 +- backend/src/opsce/opsce.module.ts | 19 +- contracts/Cargo.lock | 1 + 8 files changed, 812 insertions(+), 35 deletions(-) create mode 100644 backend/src/opsce/auth/auth.module.ts diff --git a/backend/src/opsce/assets/entities/asset.entity.ts b/backend/src/opsce/assets/entities/asset.entity.ts index 2d546799..78dedf55 100644 --- a/backend/src/opsce/assets/entities/asset.entity.ts +++ b/backend/src/opsce/assets/entities/asset.entity.ts @@ -5,25 +5,131 @@ import { CreateDateColumn, UpdateDateColumn, DeleteDateColumn, + ManyToOne, + JoinColumn, Index, + Check, + BeforeInsert, + BeforeUpdate, } from 'typeorm'; +import { Department } from '../../departments/entities/department.entity'; +import { Location } from '../../locations/entities/location.entity'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +export const ASSET_TAG_PATTERN = /^[A-Z0-9_-]{2,30}$/; + +// ─── Enums ──────────────────────────────────────────────────────────────────── export enum AssetStatus { ACTIVE = 'active', INACTIVE = 'inactive', MAINTENANCE = 'maintenance', + RESERVED = 'reserved', + LOST = 'lost', + STOLEN = 'stolen', + DISPOSED = 'disposed', RETIRED = 'retired', } export enum AssetCondition { + NEW = 'new', EXCELLENT = 'excellent', GOOD = 'good', FAIR = 'fair', POOR = 'poor', + DAMAGED = 'damaged', } +export enum DepreciationMethod { + STRAIGHT_LINE = 'straight_line', + DECLINING_BALANCE = 'declining_balance', + NONE = 'none', +} + +export enum MaintenanceFrequency { + WEEKLY = 'weekly', + MONTHLY = 'monthly', + QUARTERLY = 'quarterly', + ANNUALLY = 'annually', + AS_NEEDED = 'as_needed', +} + +// ─── Value-object interfaces ────────────────────────────────────────────────── + +export interface WarrantyInfo { + /** Warranty provider / vendor name */ + provider: string; + /** Warranty reference or contract number */ + referenceNumber?: string; + /** ISO 8601 date — when warranty begins */ + startDate: string; + /** ISO 8601 date — when warranty expires */ + expiryDate: string; + /** Coverage description (e.g. "Parts and labour") */ + coverageDetails?: string; + /** Support contact (email or phone) */ + contactInfo?: string; +} + +export interface DepreciationConfig { + method: DepreciationMethod; + /** Useful life in years */ + usefulLifeYears: number; + /** Residual / salvage value at end of life */ + residualValue: number; + /** Annual depreciation rate as a decimal (e.g. 0.2 = 20%) — for declining-balance */ + annualRate?: number; +} + +export interface MaintenanceSchedule { + frequency: MaintenanceFrequency; + /** ISO 8601 date of the next scheduled maintenance */ + nextDueDate: string; + /** ISO 8601 date of the last completed maintenance */ + lastCompletedDate?: string; + /** Estimated duration in minutes */ + estimatedDurationMinutes?: number; + notes?: string; +} + +export interface InsuranceInfo { + provider: string; + policyNumber: string; + /** ISO 8601 date */ + expiryDate: string; + /** Insured value */ + insuredValue: number; + currency: string; +} + +export interface AssetCheckout { + userId: string; + checkedOutAt: string; + expectedReturnAt?: string; + returnedAt?: string; + notes?: string; +} + +// ─── Entity ─────────────────────────────────────────────────────────────────── + @Entity('assets') +@Index('IDX_ASSET_STATUS_CATEGORY', ['status', 'category']) +@Index('IDX_ASSET_DEPT_STATUS', ['departmentId', 'status']) +@Index('IDX_ASSET_LOCATION_STATUS', ['locationId', 'status']) +@Index('IDX_ASSET_ASSIGNED_USER', ['assignedToUserId']) +@Index('IDX_ASSET_DELETED_AT', ['deletedAt']) +@Check(`"name" <> ''`) +@Check(`"purchaseValue" IS NULL OR "purchaseValue" >= 0`) +@Check(`"currentValue" IS NULL OR "currentValue" >= 0`) +@Check(`"residualValue" IS NULL OR "residualValue" >= 0`) +@Check(`"usefulLifeYears" IS NULL OR "usefulLifeYears" > 0`) +@Check( + `"warrantyExpiryDate" IS NULL OR "purchaseDate" IS NULL OR "warrantyExpiryDate" >= "purchaseDate"`, +) export class Asset { + // ─── Identity ─────────────────────────────────────────────────────────────── + @PrimaryGeneratedColumn('uuid') id: string; @@ -33,14 +139,44 @@ export class Asset { @Column({ nullable: true }) description?: string; + /** + * Asset tag / barcode printed on the physical label. + * Must match ASSET_TAG_PATTERN when set. + */ + @Index('IDX_ASSET_TAG', { + unique: true, + where: '"deletedAt" IS NULL AND "assetTag" IS NOT NULL', + }) + @Column({ length: 30, nullable: true }) + assetTag?: string; + + /** + * Manufacturer serial number. + * Partial unique index — allows duplicate nulls for assets without serials. + */ + @Index('IDX_ASSET_SERIAL', { + unique: true, + where: '"deletedAt" IS NULL AND "serialNumber" IS NOT NULL', + }) + @Column({ length: 100, nullable: true }) + serialNumber?: string; + + @Column({ length: 100, nullable: true }) + manufacturer?: string; + + @Column({ length: 100, nullable: true }) + model?: string; + + /** Model year (e.g. 2023). */ + @Column({ type: 'int', nullable: true }) + modelYear?: number; + + // ─── Classification ────────────────────────────────────────────────────────── + @Index() @Column() category: string; - @Index() - @Column({ nullable: true }) - serialNumber?: string; - @Index() @Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.ACTIVE }) status: AssetStatus; @@ -48,25 +184,102 @@ export class Asset { @Column({ type: 'enum', enum: AssetCondition, default: AssetCondition.GOOD }) condition: AssetCondition; + @Column({ length: 100, nullable: true }) + vendor?: string; + + @Column({ type: 'varchar', array: true, nullable: true, default: [] }) + tags?: string[]; + + @Column({ type: 'jsonb', nullable: true }) + warrantyInfo?: WarrantyInfo; + + @Column({ type: 'jsonb', nullable: true }) + insuranceInfo?: InsuranceInfo; + + @Column({ type: 'jsonb', nullable: true }) + maintenanceSchedule?: MaintenanceSchedule; + + @Column({ type: 'jsonb', nullable: true }) + depreciationConfig?: DepreciationConfig; + + @Column({ type: 'jsonb', nullable: true }) + checkoutInfo?: AssetCheckout; + @Column({ type: 'date', nullable: true }) purchaseDate?: Date; + @Column({ type: 'date', nullable: true }) + warrantyExpiryDate?: Date; + + @Column({ type: 'date', nullable: true }) + insuranceExpiryDate?: Date; + + @Column({ type: 'date', nullable: true }) + nextMaintenanceDue?: Date; + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) purchaseValue?: number; @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) currentValue?: number; + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + residualValue?: number; + + @Column({ type: 'int', nullable: true }) + usefulLifeYears?: number; + @Column({ nullable: true }) assignedToUserId?: string; @Column({ nullable: true }) departmentId?: string; - @Column({ nullable: true }) + @ManyToOne(() => Department, { + nullable: true, + onDelete: 'SET NULL', + eager: false, + }) + @JoinColumn({ name: 'departmentId' }) + department?: Department; + + @Column({ type: 'uuid', nullable: true }) locationId?: string; - @CreateDateColumn() + @ManyToOne(() => Location, { + nullable: true, + onDelete: 'SET NULL', + eager: false, + }) + @JoinColumn({ name: 'locationId' }) + location?: Location; + + // ─── Media & documentation ─────────────────────────────────────────────────── + + /** URL to the primary photo of the asset. */ + @Column({ type: 'text', nullable: true }) + photoUrl?: string; + + /** Additional photo URLs. */ + @Column({ type: 'text', array: true, nullable: true, default: [] }) + additionalPhotoUrls: string[]; + + /** URLs to manuals, certificates, invoices, etc. */ + @Column({ type: 'text', array: true, nullable: true, default: [] }) + documentUrls: string[]; + + // ─── Metadata ──────────────────────────────────────────────────────────────── + + /** + * Arbitrary JSON for third-party integrations + * (e.g. { "helpDeskTicketId": "…", "externalAssetId": "…" }). + */ + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // ─── Audit ─────────────────────────────────────────────────────────────────── + + @CreateDateColumn({ type: 'timestamptz' }) createdAt: Date; @UpdateDateColumn() @@ -74,4 +287,151 @@ export class Asset { @DeleteDateColumn() deletedAt?: Date; + + @Column({ type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ type: 'uuid', nullable: true }) + updatedBy?: string; + + @Column({ type: 'uuid', nullable: true }) + deletedBy?: string; + + /** Free-text reason for the most recent status change. */ + @Column({ type: 'text', nullable: true }) + statusChangeReason?: string; + + /** Timestamp of the most recent status change. */ + @Column({ type: 'timestamptz', nullable: true }) + statusChangedAt?: Date; + + // ─── Computed helpers ──────────────────────────────────────────────────────── + + get isDeleted(): boolean { + return !!this.deletedAt; + } + + get isAssigned(): boolean { + return !!this.assignedToUserId; + } + + /** + * Depreciation to date using straight-line method. + * Returns null if required fields are missing. + */ + get accruedDepreciation(): number | null { + if ( + this.purchaseValue == null || + this.residualValue == null || + this.usefulLifeYears == null || + !this.purchaseDate + ) + return null; + + const msPerYear = 365.25 * 24 * 60 * 60 * 1000; + const ageYears = + (Date.now() - new Date(this.purchaseDate).getTime()) / msPerYear; + const annualDep = + (this.purchaseValue - this.residualValue) / this.usefulLifeYears; + const total = Math.min( + annualDep * ageYears, + this.purchaseValue - this.residualValue, + ); + + return Math.max(0, parseFloat(total.toFixed(2))); + } + + /** + * Estimated current book value based on straight-line depreciation. + * Returns currentValue when explicitly set, falls back to computed value. + */ + get estimatedBookValue(): number | null { + if (this.currentValue != null) return Number(this.currentValue); + if (this.accruedDepreciation == null || this.purchaseValue == null) + return null; + return Math.max( + this.residualValue ?? 0, + parseFloat( + (Number(this.purchaseValue) - this.accruedDepreciation).toFixed(2), + ), + ); + } + + get isWarrantyExpired(): boolean { + if (!this.warrantyExpiryDate) return false; + return new Date(this.warrantyExpiryDate) < new Date(); + } + + get isInsuranceExpired(): boolean { + if (!this.insuranceExpiryDate) return false; + return new Date(this.insuranceExpiryDate) < new Date(); + } + + get isMaintenanceOverdue(): boolean { + if (!this.nextMaintenanceDue) return false; + return new Date(this.nextMaintenanceDue) < new Date(); + } + + // ─── Lifecycle hooks ───────────────────────────────────────────────────────── + + @BeforeInsert() + @BeforeUpdate() + normalizeFields(): void { + if (this.name) this.name = this.name.trim(); + if (this.description) this.description = this.description.trim(); + if (this.vendor) this.vendor = this.vendor.trim(); + + if (this.assetTag) { + this.assetTag = this.assetTag.toUpperCase().trim(); + if (!ASSET_TAG_PATTERN.test(this.assetTag)) { + throw new Error( + `Asset tag "${this.assetTag}" is invalid. Must match ${ASSET_TAG_PATTERN.source}`, + ); + } + } + + if (this.serialNumber) { + this.serialNumber = this.serialNumber.trim(); + } + + // Deduplicate + lowercase tags + if (this.tags) { + this.tags = [...new Set(this.tags.map((t) => t.toLowerCase().trim()))]; + } + + // Sync denormalised warranty expiry date from JSONB + if (this.warrantyInfo?.expiryDate) { + this.warrantyExpiryDate = new Date(this.warrantyInfo.expiryDate); + } + + // Sync denormalised insurance expiry date from JSONB + if (this.insuranceInfo?.expiryDate) { + this.insuranceExpiryDate = new Date(this.insuranceInfo.expiryDate); + } + + // Sync denormalised maintenance due date from JSONB + if (this.maintenanceSchedule?.nextDueDate) { + this.nextMaintenanceDue = new Date(this.maintenanceSchedule.nextDueDate); + } + } + + @BeforeInsert() + @BeforeUpdate() + validateFinancials(): void { + if ( + this.purchaseValue != null && + this.residualValue != null && + Number(this.residualValue) > Number(this.purchaseValue) + ) { + throw new Error('residualValue cannot exceed purchaseValue'); + } + + if ( + this.currentValue != null && + this.purchaseValue != null && + Number(this.currentValue) > Number(this.purchaseValue) + ) { + throw new Error('currentValue cannot exceed purchaseValue'); + } + } } diff --git a/backend/src/opsce/auth/auth.module.ts b/backend/src/opsce/auth/auth.module.ts new file mode 100644 index 00000000..cbd00810 --- /dev/null +++ b/backend/src/opsce/auth/auth.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { User } from '../users/entities/user.entity'; +import { JwtStrategy } from './jwt.strategy'; +import { JwtAuthGuard } from './jwt-auth.guard'; +import { RolesGuard } from './roles.guard'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([User]), + PassportModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET') || 'default-secret-key', + signOptions: { expiresIn: '24h' }, + }), + inject: [ConfigService], + }), + ], + providers: [JwtStrategy, JwtAuthGuard, RolesGuard], + exports: [JwtAuthGuard, RolesGuard], +}) +export class AuthModule {} diff --git a/backend/src/opsce/departments/departments.service.ts b/backend/src/opsce/departments/departments.service.ts index f1e23ca7..cb00165a 100644 --- a/backend/src/opsce/departments/departments.service.ts +++ b/backend/src/opsce/departments/departments.service.ts @@ -35,7 +35,7 @@ export class DepartmentsService { return this.departmentRepository.save(department); } - async findAll(): Promise { + async findAll(): Promise<(Department & { childCount: number })[]> { const departments = await this.departmentRepository.find({ order: { name: 'ASC' }, }); @@ -45,7 +45,7 @@ export class DepartmentsService { const childCount = departments.filter( (d) => d.parentId === dept.id, ).length; - return { ...dept, childCount }; + return { ...dept, childCount } as Department & { childCount: number }; }); } diff --git a/backend/src/opsce/departments/entities/department.entity.ts b/backend/src/opsce/departments/entities/department.entity.ts index 30bc174e..ffc438eb 100644 --- a/backend/src/opsce/departments/entities/department.entity.ts +++ b/backend/src/opsce/departments/entities/department.entity.ts @@ -4,13 +4,21 @@ import { Column, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, ManyToOne, OneToMany, JoinColumn, + Index, + BeforeInsert, + BeforeUpdate, } from 'typeorm'; +export const DEPARTMENT_CODE_PATTERN = /^[A-Z0-9-]{2,20}$/; + @Entity('departments') export class Department { + // ─── Identity ───────────────────────────────────────────────────────────── + @PrimaryGeneratedColumn('uuid') id: string; @@ -20,15 +28,19 @@ export class Department { @Column({ nullable: true }) description?: string; - @Column({ nullable: true }) - code?: string; - @Column({ default: true }) isActive: boolean; @Column({ nullable: true }) parentId?: string; + /** + * Materialized path for efficient tree queries. + * Format: "/{rootId}/{childId}/{...}/{thisId}/" + */ + @Column({ length: 500, nullable: true }) + path?: string; + @ManyToOne(() => Department, (d) => d.children, { nullable: true }) @JoinColumn({ name: 'parentId' }) parent?: Department; @@ -36,9 +48,92 @@ export class Department { @OneToMany(() => Department, (d) => d.parent) children: Department[]; - @CreateDateColumn() + /** + * Short uppercase code used in HR systems (e.g. "ENG", "FIN-OPS"). + * Must match DEPARTMENT_CODE_PATTERN when provided. + */ + @Index('IDX_DEPT_CODE_ACTIVE', { + where: '"deletedAt" IS NULL AND "code" IS NOT NULL', + }) + @Column({ length: 20, nullable: true }) + code?: string; + + @CreateDateColumn({ type: 'timestamptz' }) createdAt: Date; @UpdateDateColumn() updatedAt: Date; + + /** + * Soft-delete timestamp — null means the record is active. + * TypeORM automatically excludes soft-deleted rows from all queries + * unless `.withDeleted()` is explicitly called. + */ + @DeleteDateColumn({ type: 'timestamptz', nullable: true }) + deletedAt?: Date; + + /** ID of the user who created this department. */ + @Column({ type: 'uuid', nullable: true }) + createdBy?: string; + + /** ID of the user who last modified this department. */ + @Column({ type: 'uuid', nullable: true }) + updatedBy?: string; + + /** ID of the user who deleted this department (when soft-deleted). */ + @Column({ type: 'uuid', nullable: true }) + deletedBy?: string; + + // ─── Computed helpers ───────────────────────────────────────────────────── + + /** + * Returns true when the department has been soft-deleted. + * Keeps controllers and services free of null-check boilerplate. + */ + get isDeleted(): boolean { + return this.deletedAt !== null && this.deletedAt !== undefined; + } + + /** + * Returns true when this department is a root (no parent). + */ + get isRoot(): boolean { + return !this.parentId; + } + + /** + * Parses the materialized path into an ordered array of ancestor IDs, + * from root to immediate parent (excludes the department's own ID). + */ + get ancestorIds(): string[] { + if (!this.path) return []; + return this.path.split('/').filter(Boolean).slice(0, -1); // last segment is this department's own id + } + + // ─── Lifecycle hooks ────────────────────────────────────────────────────── + + /** + * Validates the department code format before insert or update. + * Full schema-level validation belongs in a DTO; this is a last-resort guard. + */ + @BeforeInsert() + @BeforeUpdate() + validateCode(): void { + if (this.code && !DEPARTMENT_CODE_PATTERN.test(this.code)) { + throw new Error( + `Department code "${this.code}" is invalid. Must match ${DEPARTMENT_CODE_PATTERN.source}`, + ); + } + } + + /** + * Trims whitespace from name and description before persistence. + */ + @BeforeInsert() + @BeforeUpdate() + normalizeStrings(): void { + if (this.name) this.name = this.name.trim(); + if (this.description) this.description = this.description.trim(); + if (this.code) this.code = this.code.toUpperCase().trim(); + } } diff --git a/backend/src/opsce/locations/entities/location.entity.ts b/backend/src/opsce/locations/entities/location.entity.ts index 05402a52..9c3e26c9 100644 --- a/backend/src/opsce/locations/entities/location.entity.ts +++ b/backend/src/opsce/locations/entities/location.entity.ts @@ -4,38 +4,139 @@ import { Column, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, ManyToOne, OneToMany, + ManyToMany, JoinColumn, + JoinTable, + Index, + Check, + BeforeInsert, + BeforeUpdate, } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +export const LOCATION_CODE_PATTERN = /^[A-Z0-9-]{2,30}$/; export enum LocationType { + CAMPUS = 'campus', BUILDING = 'building', FLOOR = 'floor', + WING = 'wing', ROOM = 'room', ZONE = 'zone', + DESK = 'desk', + OUTDOOR = 'outdoor', +} + +export enum LocationStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + UNDER_MAINTENANCE = 'under_maintenance', + RESERVED = 'reserved', + DECOMMISSIONED = 'decommissioned', +} + +export enum AccessLevel { + PUBLIC = 'public', + RESTRICTED = 'restricted', + PRIVATE = 'private', + SECURE = 'secure', +} + +// ─── Embedded value objects ─────────────────────────────────────────────────── + +export interface GeoCoordinates { + /** WGS-84 latitude (-90 to 90) */ + latitude: number; + /** WGS-84 longitude (-180 to 180) */ + longitude: number; + /** Altitude in metres above sea level (optional) */ + altitudeM?: number; +} + +export interface IndoorCoordinates { + /** X position in metres from the floor origin */ + x: number; + /** Y position in metres from the floor origin */ + y: number; + /** Floor number (0 = ground) */ + floor?: number; +} + +export interface OperatingHours { + /** ISO 8601 time, e.g. "08:00" */ + open: string; + /** ISO 8601 time, e.g. "18:00" */ + close: string; + /** Days this schedule applies to: 0=Sun … 6=Sat */ + days: number[]; + /** IANA timezone, e.g. "Africa/Lagos" */ + timezone: string; +} + +export interface LocationDimensions { + /** Width in metres */ + widthM?: number; + /** Length in metres */ + lengthM?: number; + /** Height in metres */ + heightM?: number; + /** Total area in square metres (may be set independently of width/length) */ + areaM2?: number; } +// ─── Entity ─────────────────────────────────────────────────────────────────── + @Entity('locations') +@Index('IDX_LOC_PARENT_STATUS', ['parentId', 'status']) +@Index('IDX_LOC_TYPE_ACTIVE', ['type', 'isActive']) +@Index('IDX_LOC_DELETED_AT', ['deletedAt']) +@Check(`"name" <> ''`) +@Check(`"capacity" IS NULL OR "capacity" >= 0`) +@Check(`"currentOccupancy" IS NULL OR "currentOccupancy" >= 0`) +@Check( + `"currentOccupancy" IS NULL OR "capacity" IS NULL OR "currentOccupancy" <= "capacity"`, +) export class Location { + // ─── Identity ─────────────────────────────────────────────────────────────── + @PrimaryGeneratedColumn('uuid') id: string; @Column() name: string; + @Column({ nullable: true }) + description?: string; + + /** + * Short, uppercase location code for signage / integrations. + * e.g. "B3-F2-R14". Must match LOCATION_CODE_PATTERN. + */ + @Index('IDX_LOC_CODE_ACTIVE', { + where: '"deletedAt" IS NULL AND "code" IS NOT NULL', + }) + @Column({ length: 30, nullable: true }) + code?: string; + @Column({ type: 'enum', enum: LocationType }) type: LocationType; + @Column({ + type: 'enum', + enum: LocationStatus, + default: LocationStatus.ACTIVE, + }) + status: LocationStatus; + @Column({ nullable: true }) address?: string; @Column({ type: 'jsonb', nullable: true }) coordinates?: Record; - @Column({ default: true }) - isActive: boolean; - @Column({ nullable: true }) parentId?: string; @@ -46,9 +147,215 @@ export class Location { @OneToMany(() => Location, (l) => l.parent) children: Location[]; - @CreateDateColumn() + // ─── Physical attributes ──────────────────────────────────────────────────── + + /** Floor number within a building (relevant for FLOOR / ROOM / ZONE / DESK). */ + @Column({ type: 'int', nullable: true }) + floorNumber?: number; + + /** Room / suite number as a string to accommodate "3A", "B-12", etc. */ + @Column({ length: 20, nullable: true }) + roomNumber?: string; + + /** Maximum number of people allowed in this location simultaneously. */ + @Column({ type: 'int', nullable: true }) + capacity?: number; + + /** Live occupancy count — updated by an IoT or booking service. */ + @Column({ type: 'int', nullable: true, default: 0 }) + currentOccupancy?: number; + + /** Physical dimensions stored as a JSONB object. */ + @Column({ type: 'jsonb', nullable: true }) + dimensions?: LocationDimensions; + + // ─── Coordinates ──────────────────────────────────────────────────────────── + + /** + * WGS-84 geographic coordinates for outdoor / campus-level locations. + * Stored as JSONB; migrate to PostGIS `geography` type for spatial queries. + */ + @Column({ type: 'jsonb', nullable: true }) + geoCoordinates?: GeoCoordinates; + + /** + * Indoor positioning coordinates (e.g. from a BLE / UWB system). + */ + @Column({ type: 'jsonb', nullable: true }) + indoorCoordinates?: IndoorCoordinates; + + // ─── Operations ───────────────────────────────────────────────────────────── + + /** Weekly operating schedule. Multiple entries support split shifts. */ + @Column({ type: 'jsonb', nullable: true }) + operatingHours?: OperatingHours[]; + + /** + * Tags for flexible filtering (e.g. ["wheelchair-accessible", "projector"]). + * Stored as a simple text array. + */ + @Column({ type: 'text', array: true, nullable: true, default: [] }) + tags: string[]; + + /** + * Amenities available at this location (e.g. ["wifi", "whiteboard", "parking"]). + */ + @Column({ type: 'text', array: true, nullable: true, default: [] }) + amenities: string[]; + + // ─── Managed-by ───────────────────────────────────────────────────────────── + + /** + * Users responsible for managing this location (facility managers, admins). + */ + @ManyToMany(() => User, { cascade: false, eager: false }) + @JoinTable({ + name: 'location_managers', + joinColumn: { name: 'locationId', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'userId', referencedColumnName: 'id' }, + }) + managers: User[]; + + // ─── Media ────────────────────────────────────────────────────────────────── + + /** URL to the floor-plan image / SVG for this location. */ + @Column({ type: 'text', nullable: true }) + floorPlanUrl?: string; + + /** URLs to photos of this location. */ + @Column({ type: 'text', array: true, nullable: true, default: [] }) + photoUrls: string[]; + + // ─── Metadata ─────────────────────────────────────────────────────────────── + + /** + * Arbitrary JSON for third-party integrations + * (e.g. { "calendarRoomId": "…", "accessControlId": "…" }). + */ + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + /** + * Materialized path for efficient tree queries. + * Format: "/{rootId}/{childId}/{...}/{thisId}/" + */ + @Column({ length: 500, nullable: true }) + path?: string; + + /** + * Kept for backwards compatibility — prefer `status` for new code. + * Synced with status in the BeforeInsert / BeforeUpdate hook. + */ + @Column({ default: true }) + isActive: boolean; + + // ─── Audit ────────────────────────────────────────────────────────────────── + + @CreateDateColumn({ type: 'timestamptz' }) createdAt: Date; @UpdateDateColumn() updatedAt: Date; + + /** + * Soft-delete timestamp. + * TypeORM excludes soft-deleted rows from all queries unless + * `.withDeleted()` is explicitly used. + */ + @DeleteDateColumn({ type: 'timestamptz', nullable: true }) + deletedAt?: Date; + + @Column({ type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ type: 'uuid', nullable: true }) + updatedBy?: string; + + @Column({ type: 'uuid', nullable: true }) + deletedBy?: string; + + // ─── Computed helpers ──────────────────────────────────────────────────────── + + get isDeleted(): boolean { + return !!this.deletedAt; + } + + get isRoot(): boolean { + return !this.parentId; + } + + /** + * Occupancy as a percentage (0–100), or null if capacity is unset. + */ + get occupancyPct(): number | null { + if (this.capacity == null || this.capacity === 0) return null; + return Math.min( + 100, + Math.round(((this.currentOccupancy ?? 0) / this.capacity) * 100), + ); + } + + /** + * True when currentOccupancy >= capacity (and both are set). + */ + get isAtCapacity(): boolean { + if (this.capacity == null) return false; + return (this.currentOccupancy ?? 0) >= this.capacity; + } + + /** + * Ordered array of ancestor IDs from root to immediate parent. + */ + get ancestorIds(): string[] { + if (!this.path) return []; + return this.path.split('/').filter(Boolean).slice(0, -1); + } + + // ─── Lifecycle hooks ───────────────────────────────────────────────────────── + + @BeforeInsert() + @BeforeUpdate() + normalizeFields(): void { + // Trim strings + if (this.name) this.name = this.name.trim(); + if (this.description) this.description = this.description.trim(); + if (this.address) this.address = this.address.trim(); + + // Uppercase + trim code + if (this.code) { + this.code = this.code.toUpperCase().trim(); + if (!LOCATION_CODE_PATTERN.test(this.code)) { + throw new Error( + `Location code "${this.code}" is invalid. Must match ${LOCATION_CODE_PATTERN.source}`, + ); + } + } + + // Sync legacy isActive with status + this.isActive = this.status === LocationStatus.ACTIVE; + + // Deduplicate tags and amenities + if (this.tags) + this.tags = [...new Set(this.tags.map((t) => t.toLowerCase().trim()))]; + if (this.amenities) + this.amenities = [ + ...new Set(this.amenities.map((a) => a.toLowerCase().trim())), + ]; + } + + @BeforeInsert() + @BeforeUpdate() + validateCoordinates(): void { + if (this.geoCoordinates) { + const { latitude: lat, longitude: lng } = this.geoCoordinates; + if (lat < -90 || lat > 90) { + throw new Error(`Invalid latitude ${lat}: must be between -90 and 90`); + } + if (lng < -180 || lng > 180) { + throw new Error( + `Invalid longitude ${lng}: must be between -180 and 180`, + ); + } + } + } } diff --git a/backend/src/opsce/locations/locations.service.ts b/backend/src/opsce/locations/locations.service.ts index 537b8979..c520b641 100644 --- a/backend/src/opsce/locations/locations.service.ts +++ b/backend/src/opsce/locations/locations.service.ts @@ -35,7 +35,7 @@ export class LocationsService { return this.locationRepository.save(location); } - async findAll(type?: string): Promise { + async findAll(type?: string): Promise<(Location & { childCount: number })[]> { const queryBuilder = this.locationRepository.createQueryBuilder('location'); if (type) { @@ -49,7 +49,7 @@ export class LocationsService { // Add childCount to each location return locations.map((loc) => { const childCount = locations.filter((l) => l.parentId === loc.id).length; - return { ...loc, childCount }; + return { ...loc, childCount } as Location & { childCount: number }; }); } @@ -68,7 +68,7 @@ export class LocationsService { where: { locationId: id, status: AssetStatus.ACTIVE }, }); - return { ...location, assetCount }; + return { ...location, assetCount } as Location & { assetCount: number }; } async update( diff --git a/backend/src/opsce/opsce.module.ts b/backend/src/opsce/opsce.module.ts index 324de551..06dcb170 100644 --- a/backend/src/opsce/opsce.module.ts +++ b/backend/src/opsce/opsce.module.ts @@ -1,26 +1,15 @@ import { Module } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; -import { PassportModule } from '@nestjs/passport'; -import { ConfigModule, ConfigService } from '@nestjs/config'; 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 { AuthModule } from './auth/auth.module'; import { UploadsModule } from './uploads/uploads.module'; -import { JwtStrategy } from './auth/jwt.strategy'; @Module({ imports: [ - PassportModule.register({ defaultStrategy: 'jwt' }), - JwtModule.registerAsync({ - imports: [ConfigModule], - useFactory: (configService: ConfigService) => ({ - secret: configService.get('JWT_SECRET', 'secret-key'), - signOptions: { expiresIn: '24h' }, - }), - inject: [ConfigService], - }), + AuthModule, UsersModule, LocationsModule, AuditModule, @@ -29,8 +18,7 @@ import { JwtStrategy } from './auth/jwt.strategy'; UploadsModule, ], exports: [ - PassportModule, - JwtModule, + AuthModule, UsersModule, LocationsModule, AuditModule, @@ -38,6 +26,5 @@ import { JwtStrategy } from './auth/jwt.strategy'; AssetsModule, UploadsModule, ], - providers: [JwtStrategy], }) export class OpsceModule {} diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 309c0068..f5d03ab4 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -161,6 +161,7 @@ dependencies = [ name = "assetsup" version = "0.1.0" dependencies = [ + "opsce", "soroban-sdk", ]