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/assets/entities/asset.entity.ts b/backend/src/opsce/assets/entities/asset.entity.ts index 3670b7ee..46ef9ed1 100644 --- a/backend/src/opsce/assets/entities/asset.entity.ts +++ b/backend/src/opsce/assets/entities/asset.entity.ts @@ -6,17 +6,24 @@ import { UpdateDateColumn, DeleteDateColumn, ManyToOne, + OneToMany, + JoinColumn, Index, Check, BeforeInsert, BeforeUpdate, } from 'typeorm'; + +import { Department } from '../../departments/entities/department.entity'; +import { Location } from '../../locations/entities/location.entity'; + import { User } from '../../users/entities/user.entity'; 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}$/; @@ -27,6 +34,65 @@ 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; +} + + RESERVED = 'reserved', LOST = 'lost', STOLEN = 'stolen', @@ -84,6 +150,7 @@ export interface DepreciationConfig { annualRate?: number; } + export interface MaintenanceSchedule { frequency: MaintenanceFrequency; /** ISO 8601 date of the next scheduled maintenance */ @@ -116,11 +183,19 @@ export interface AssetCheckout { // ─── 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']) + @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`) @@ -146,7 +221,14 @@ export class Asset { * 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', + }) + @Index('IDX_ASSET_TAG', { unique: true, where: '"deletedAt" IS NULL AND "assetTag" IS NOT NULL' }) + @Column({ length: 30, nullable: true }) assetTag?: string; @@ -154,7 +236,14 @@ export class Asset { * 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', + }) + @Index('IDX_ASSET_SERIAL', { unique: true, where: '"deletedAt" IS NULL AND "serialNumber" IS NOT NULL' }) + @Column({ length: 100, nullable: true }) serialNumber?: string; @@ -178,6 +267,8 @@ export class Asset { * Optional sub-category (e.g. category="IT" → subCategory="Laptop"). */ @Index() + + @Column({ length: 100, nullable: true }) subCategory?: string; @@ -187,6 +278,7 @@ export class Asset { default: AssetStatus.ACTIVE, }) @Index() + status: AssetStatus; @Column({ @@ -196,6 +288,28 @@ export class Asset { }) 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; + /** * Searchable tags (e.g. ["portable", "shared", "critical"]). * Deduplicated and lowercased by lifecycle hook. @@ -209,16 +323,39 @@ export class Asset { @Column({ length: 3, nullable: true, default: 'USD' }) currency?: string; + @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 }) + @Column({ type: 'decimal', precision: 15, scale: 2, nullable: true }) + purchaseValue?: number; /** Book value as of the last valuation. */ @Column({ type: 'decimal', precision: 15, 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 }) + /** Salvage / residual value at end of useful life. */ @Column({ type: 'decimal', precision: 15, scale: 2, nullable: true }) residualValue?: number; @@ -289,6 +426,7 @@ export class Asset { // ─── Assignment ────────────────────────────────────────────────────────────── @Column({ type: 'uuid', nullable: true }) + assignedToUserId?: string; @ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL', eager: false }) @@ -316,14 +454,30 @@ export class Asset { @Column({ type: 'uuid', nullable: true }) departmentId?: string; + + @ManyToOne(() => Department, { + nullable: true, + onDelete: 'SET NULL', + eager: false, + }) + @ManyToOne(() => Department, { nullable: true, onDelete: 'SET NULL', eager: false }) + @JoinColumn({ name: 'departmentId' }) department?: Department; @Column({ type: 'uuid', nullable: true }) locationId?: string; + + @ManyToOne(() => Location, { + nullable: true, + onDelete: 'SET NULL', + eager: false, + }) + @ManyToOne(() => Location, { nullable: true, onDelete: 'SET NULL', eager: false }) + @JoinColumn({ name: 'locationId' }) location?: Location; @@ -398,6 +552,20 @@ export class Asset { 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 null; const msPerYear = 365.25 * 24 * 60 * 60 * 1000; @@ -405,6 +573,7 @@ export class Asset { 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))); } @@ -414,10 +583,20 @@ export class Asset { */ 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), + ), + if (this.accruedDepreciation == null || this.purchaseValue == null) return null; return Math.max( this.residualValue ?? 0, parseFloat((Number(this.purchaseValue) - this.accruedDepreciation).toFixed(2)), + ); } @@ -441,10 +620,16 @@ export class Asset { @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.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)) { @@ -498,4 +683,8 @@ export class Asset { throw new Error('currentValue cannot exceed purchaseValue'); } } -} \ No newline at end of file + +} + +} + 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/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/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..cb00165a --- /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<(Department & { childCount: number })[]> { + 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 } as Department & { childCount: number }; + }); + } + + 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/departments/entities/department.entity.ts b/backend/src/opsce/departments/entities/department.entity.ts index ec75977a..d6f3151e 100644 --- a/backend/src/opsce/departments/entities/department.entity.ts +++ b/backend/src/opsce/departments/entities/department.entity.ts @@ -9,9 +9,13 @@ import { OneToMany, ManyToMany, JoinColumn, + + Index, + JoinTable, Index, Check, + BeforeInsert, BeforeUpdate, } from 'typeorm'; @@ -27,6 +31,8 @@ export const DEPARTMENT_CODE_PATTERN = /^[A-Z0-9_-]{2,20}$/; // ─── Entity ─────────────────────────────────────────────────────────────────── +export const DEPARTMENT_CODE_PATTERN = /^[A-Z0-9-]{2,20}$/; + @Entity('departments') @Index('IDX_DEPT_PARENT_ACTIVE', ['parentId', 'isActive']) @Index('IDX_DEPT_DELETED_AT', ['deletedAt']) @@ -51,6 +57,7 @@ export class Department { @Column({ type: 'text', nullable: true }) description?: string; + /** * Short uppercase code used in HR systems (e.g. "ENG", "FIN-OPS"). * Must match DEPARTMENT_CODE_PATTERN when provided. @@ -59,6 +66,7 @@ export class Department { @Column({ length: 20, nullable: true }) code?: string; + @Column({ default: true }) isActive: boolean; @@ -68,6 +76,15 @@ export class Department { 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 }) + * Materialized path for efficient ancestor/descendant queries. * Format: //// * Maintained automatically by lifecycle hooks. @@ -86,12 +103,24 @@ export class Department { nullable: true, onDelete: 'SET NULL', }) + @JoinColumn({ name: 'parentId' }) parent?: Department; @OneToMany(() => Department, (d) => d.parent, { cascade: ['soft-remove'] }) children: Department[]; + + /** + * 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; + // ─── Staffing ───────────────────────────────────────────────────────────── /** @@ -214,10 +243,14 @@ export class Department { */ get ancestorIds(): string[] { if (!this.path) return []; + + return this.path.split('/').filter(Boolean).slice(0, -1); // last segment is this department's own id + return this.path .split('/') .filter(Boolean) .slice(0, -1); // last segment is this department's own id + } // ─── Lifecycle hooks ────────────────────────────────────────────────────── @@ -246,4 +279,8 @@ export class Department { if (this.description) this.description = this.description.trim(); if (this.code) this.code = this.code.toUpperCase().trim(); } -} \ No newline at end of file + +} + +} + 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/entities/location.entity.ts b/backend/src/opsce/locations/entities/location.entity.ts index 81c518b5..841c3e1a 100644 --- a/backend/src/opsce/locations/entities/location.entity.ts +++ b/backend/src/opsce/locations/entities/location.entity.ts @@ -17,6 +17,26 @@ import { } 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', + // ─── Constants ──────────────────────────────────────────────────────────────── export const MAX_LOCATION_DEPTH = 6; @@ -45,10 +65,17 @@ export enum LocationStatus { } export enum AccessLevel { + + PUBLIC = 'public', + RESTRICTED = 'restricted', + PRIVATE = 'private', + SECURE = 'secure', + PUBLIC = 'public', RESTRICTED = 'restricted', PRIVATE = 'private', SECURE = 'secure', + } // ─── Embedded value objects ─────────────────────────────────────────────────── @@ -80,6 +107,7 @@ export interface OperatingHours { days: number[]; /** IANA timezone, e.g. "Africa/Lagos" */ timezone: string; + } export interface LocationDimensions { @@ -93,12 +121,32 @@ export interface LocationDimensions { areaM2?: number; } + +} + +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']) + @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`) @@ -107,6 +155,7 @@ export interface LocationDimensions { ) export class Location { + // ─── Identity ─────────────────────────────────────────────────────────────── @PrimaryGeneratedColumn('uuid') @@ -120,11 +169,22 @@ export class Location { @Column({ length: 200 }) 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', + }) + @Index('IDX_LOC_CODE_ACTIVE', { where: '"deletedAt" IS NULL AND "code" IS NOT NULL' }) + @Column({ length: 30, nullable: true }) code?: string; @@ -138,6 +198,11 @@ export class Location { }) status: LocationStatus; + + @Column({ nullable: true }) + address?: string; + + @Column({ type: 'enum', enum: AccessLevel, @@ -145,12 +210,16 @@ export class Location { }) accessLevel: AccessLevel; + + @Column({ nullable: true }) + @Column({ type: 'text', nullable: true }) description?: string; // ─── Hierarchy / materialized path ───────────────────────────────────────── @Column({ type: 'uuid', nullable: true }) + parentId?: string; /** @@ -177,10 +246,12 @@ export class Location { // ─── Physical attributes ──────────────────────────────────────────────────── + /** Physical mailing / street address (buildings / campus level). */ @Column({ type: 'text', nullable: true }) address?: string; + /** Floor number within a building (relevant for FLOOR / ROOM / ZONE / DESK). */ @Column({ type: 'int', nullable: true }) floorNumber?: number; @@ -243,8 +314,13 @@ export class Location { @ManyToMany(() => User, { cascade: false, eager: false }) @JoinTable({ name: 'location_managers', + + joinColumn: { name: 'locationId', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'userId', referencedColumnName: 'id' }, + joinColumn: { name: 'locationId', referencedColumnName: 'id' }, inverseJoinColumn: { name: 'userId', referencedColumnName: 'id' }, + }) managers: User[]; @@ -267,8 +343,17 @@ export class Location { @Column({ type: 'jsonb', nullable: true }) metadata?: Record; + + /** + * Materialized path for efficient tree queries. + * Format: "/{rootId}/{childId}/{...}/{thisId}/" + */ + @Column({ length: 500, nullable: true }) + path?: string; + // ─── Legacy compatibility ─────────────────────────────────────────────────── + /** * Kept for backwards compatibility — prefer `status` for new code. * Synced with status in the BeforeInsert / BeforeUpdate hook. @@ -316,7 +401,14 @@ export class Location { */ get occupancyPct(): number | null { if (this.capacity == null || this.capacity === 0) return null; +s + return Math.min( + 100, + Math.round(((this.currentOccupancy ?? 0) / this.capacity) * 100), + ); + return Math.min(100, Math.round(((this.currentOccupancy ?? 0) / this.capacity) * 100)); + } /** @@ -341,10 +433,16 @@ export class Location { @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(); + 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(); @@ -359,8 +457,17 @@ export class Location { 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())), + ]; + 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() @@ -372,8 +479,18 @@ export class Location { 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`, + ); + } + } + } +} + throw new Error(`Invalid longitude ${lng}: must be between -180 and 180`); } } } -} \ No newline at end of file +} + 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..c520b641 --- /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<(Location & { childCount: number })[]> { + 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 } as Location & { childCount: number }; + }); + } + + 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 } as Location & { assetCount: number }; + } + + 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 2e1b31bf..23891321 100644 --- a/backend/src/opsce/opsce.module.ts +++ b/backend/src/opsce/opsce.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; -import { ConfigModule } 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 { 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'; /** * OpsceModule @@ -15,23 +16,22 @@ import { LocationsModule } from '../locations/locations.module'; */ @Module({ imports: [ - // ConfigModule is global — listed here for explicit documentation only. - // Remove this import if it causes duplicate-module warnings in your version - // of @nestjs/config. - ConfigModule, - + AuthModule, UsersModule, LocationsModule, AuditModule, DepartmentsModule, AssetsModule, + UploadsModule, ], exports: [ + AuthModule, UsersModule, LocationsModule, AuditModule, DepartmentsModule, AssetsModule, + UploadsModule, ], }) export class OpsceModule {} \ No newline at end of file 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; +} diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 5285db82..6bd48fb1 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -161,6 +161,7 @@ dependencies = [ name = "assetsup" version = "0.1.0" dependencies = [ + "opsce", "soroban-sdk", ]