From 5cf8bca10c2409aec7e81993c949a0dd85db2aac Mon Sep 17 00:00:00 2001 From: sandrawillow001-afk Date: Mon, 1 Jun 2026 19:04:55 +0100 Subject: [PATCH] feat: implement BE-35/36/37/38 Swagger, bulk ops, mailer, unit tests [BE-38] Add full Jest unit test suite for AssetsService - 23 tests covering create, findAll, findAll+search, findOne, update, remove, bulkOperation, and repository isolation - All TypeORM repository calls mocked via NestJS TestingModule - AuditService mocked; verifies log() is called on mutations [BE-37] Add POST /api/assets/bulk endpoint - BulkAssetOperationDto with enum BulkOperation - Supported ops: update-status, reassign, change-department, change-location, soft-delete - Returns { succeeded[], failed[{id,reason}] } - Max 100 IDs enforced; ADMIN or MANAGER role required - Each successful op logged individually in AuditLog [BE-36] Add Swagger/OpenAPI decorators to AssetsController - @ApiTags, @ApiOperation, @ApiBody, @ApiParam, @ApiQuery, @ApiResponse (success + error codes), @ApiBearerAuth on all JWT-protected endpoints; @ApiExtraModels for schema refs [BE-35] Implement MailerService with nodemailer - MailerService.sendPasswordReset(to, token) - MailerService.sendMaintenanceDue(to, assetName, dueDate) - SMTP credentials from env: SMTP_HOST/PORT/USER/PASS/FROM - HTML templates in mailer/templates/email.templates.ts - Failures caught and logged, never thrown (non-critical) - MailerModule registered and exported in OpsceModule Also fix: resolve merge-conflict duplicate declarations in asset.entity.ts, department.entity.ts, location.entity.ts --- backend/src/opsce/assets/assets.controller.ts | 208 +++++++- backend/src/opsce/assets/assets.module.ts | 3 +- .../src/opsce/assets/assets.service.spec.ts | 449 ++++++++++++++++++ backend/src/opsce/assets/assets.service.ts | 167 ++++++- .../assets/dto/bulk-asset-operation.dto.ts | 59 +++ .../src/opsce/assets/entities/asset.entity.ts | 252 +--------- .../departments/entities/department.entity.ts | 109 +---- .../locations/entities/location.entity.ts | 234 +-------- backend/src/opsce/mailer/mailer.module.ts | 8 + backend/src/opsce/mailer/mailer.service.ts | 94 ++++ .../opsce/mailer/templates/email.templates.ts | 116 +++++ backend/src/opsce/opsce.module.ts | 13 +- 12 files changed, 1100 insertions(+), 612 deletions(-) create mode 100644 backend/src/opsce/assets/assets.service.spec.ts create mode 100644 backend/src/opsce/assets/dto/bulk-asset-operation.dto.ts create mode 100644 backend/src/opsce/mailer/mailer.module.ts create mode 100644 backend/src/opsce/mailer/mailer.service.ts create mode 100644 backend/src/opsce/mailer/templates/email.templates.ts diff --git a/backend/src/opsce/assets/assets.controller.ts b/backend/src/opsce/assets/assets.controller.ts index 1985a534..5740571e 100644 --- a/backend/src/opsce/assets/assets.controller.ts +++ b/backend/src/opsce/assets/assets.controller.ts @@ -9,6 +9,9 @@ import { Query, UseGuards, HttpStatus, + HttpCode, + BadRequestException, + Request, } from '@nestjs/common'; import { ApiTags, @@ -17,98 +20,255 @@ import { ApiBearerAuth, ApiParam, ApiQuery, + ApiBody, + ApiExtraModels, + getSchemaPath, } from '@nestjs/swagger'; import { AssetsService } from './assets.service'; import { CreateAssetDto } from './dto/create-asset.dto'; import { UpdateAssetDto } from './dto/update-asset.dto'; +import { + BulkAssetOperationDto, + BulkOperationResult, +} from './dto/bulk-asset-operation.dto'; import { PaginationDto } from '../common/dto/pagination.dto'; +import { PaginatedResponseDto } from '../common/dto/paginated-response.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'; +import { Asset } from './entities/asset.entity'; @ApiTags('Assets') @Controller('assets') @UseGuards(JwtAuthGuard, RolesGuard) @ApiBearerAuth('JWT-auth') +@ApiExtraModels(PaginatedResponseDto, Asset) export class AssetsController { constructor(private readonly assetsService: AssetsService) {} + // ─── Create ───────────────────────────────────────────────────────────────── + @Post() @Roles(UserRole.ADMIN) + @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: 'Create a new asset (ADMIN only)' }) + @ApiBody({ type: CreateAssetDto }) @ApiResponse({ status: HttpStatus.CREATED, - description: 'Asset successfully created', + description: 'Asset successfully created.', + type: Asset, }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, - description: 'Invalid input data', + description: 'Invalid input data or duplicate serial number.', + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Missing or invalid JWT token.', + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Insufficient role — ADMIN required.', }) - create(@Body() createAssetDto: CreateAssetDto) { + create(@Body() createAssetDto: CreateAssetDto, @Request() req: any) { return this.assetsService.create(createAssetDto); } + // ─── Bulk operations ──────────────────────────────────────────────────────── + + @Post('bulk') + @Roles(UserRole.ADMIN, UserRole.MANAGER) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Apply a bulk operation to multiple assets (ADMIN or MANAGER)', + description: + 'Accepts up to 100 asset IDs and applies a single operation to all of them. ' + + 'Each asset is processed independently — partial success is possible. ' + + 'Supported operations: update-status, reassign, change-department, change-location, soft-delete.', + }) + @ApiBody({ type: BulkAssetOperationDto }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Bulk operation completed. Returns per-asset success/failure.', + schema: { + example: { + succeeded: ['uuid-1', 'uuid-2'], + failed: [{ id: 'uuid-3', reason: 'Asset with ID uuid-3 not found' }], + }, + }, + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'More than 100 IDs supplied, or invalid payload.', + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Missing or invalid JWT token.', + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Insufficient role — ADMIN or MANAGER required.', + }) + async bulkOperation( + @Body() dto: BulkAssetOperationDto, + @Request() req: any, + ): Promise { + if (dto.ids.length > 100) { + throw new BadRequestException('Maximum 100 IDs per bulk request.'); + } + const userId: string | undefined = req?.user?.id; + return this.assetsService.bulkOperation(dto, userId); + } + + // ─── Find all ─────────────────────────────────────────────────────────────── + @Get() - @ApiOperation({ summary: 'Get all assets with pagination' }) - @ApiQuery({ name: 'page', required: false, type: Number }) - @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiOperation({ + summary: 'List all assets with pagination and optional search', + }) + @ApiQuery({ + name: 'page', + required: false, + type: Number, + description: 'Page number (1-based, default 1)', + example: 1, + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Items per page (default 20, max 100)', + example: 20, + }) + @ApiQuery({ + name: 'search', + required: false, + type: String, + description: 'Full-text search across name, category, and serial number', + example: 'laptop', + }) @ApiResponse({ status: HttpStatus.OK, - description: 'Return paginated assets', + description: 'Paginated list of assets.', + schema: { + allOf: [ + { $ref: getSchemaPath(PaginatedResponseDto) }, + { + properties: { + data: { type: 'array', items: { $ref: getSchemaPath(Asset) } }, + }, + }, + ], + }, }) - findAll(@Query() paginationDto: PaginationDto) { - return this.assetsService.findAll(paginationDto); + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Missing or invalid JWT token.', + }) + findAll( + @Query() paginationDto: PaginationDto, + @Query('search') search?: string, + ) { + return this.assetsService.findAll(paginationDto, search); } + // ─── Find one ─────────────────────────────────────────────────────────────── + @Get(':id') - @ApiOperation({ summary: 'Get an asset by ID' }) - @ApiParam({ name: 'id', description: 'Asset ID' }) + @ApiOperation({ summary: 'Get a single asset by ID' }) + @ApiParam({ + name: 'id', + description: 'Asset UUID', + example: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + }) @ApiResponse({ status: HttpStatus.OK, - description: 'Return the asset', + description: 'The requested asset.', + type: Asset, }) @ApiResponse({ status: HttpStatus.NOT_FOUND, - description: 'Asset not found', + description: 'Asset not found.', + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Missing or invalid JWT token.', }) findOne(@Param('id') id: string) { return this.assetsService.findOne(id); } + // ─── Update ───────────────────────────────────────────────────────────────── + @Patch(':id') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Update an asset (ADMIN only)' }) - @ApiParam({ name: 'id', description: 'Asset ID' }) + @ApiParam({ + name: 'id', + description: 'Asset UUID', + example: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + }) + @ApiBody({ type: UpdateAssetDto }) @ApiResponse({ status: HttpStatus.OK, - description: 'Asset successfully updated', + description: 'Asset successfully updated.', + type: Asset, }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, - description: 'Invalid input data', + description: 'Invalid input data.', }) @ApiResponse({ status: HttpStatus.NOT_FOUND, - description: 'Asset not found', + description: 'Asset not found.', }) - update(@Param('id') id: string, @Body() updateAssetDto: UpdateAssetDto) { - return this.assetsService.update(id, updateAssetDto); + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Missing or invalid JWT token.', + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Insufficient role — ADMIN required.', + }) + update( + @Param('id') id: string, + @Body() updateAssetDto: UpdateAssetDto, + @Request() req: any, + ) { + const userId: string | undefined = req?.user?.id; + return this.assetsService.update(id, updateAssetDto, userId); } + // ─── Delete ───────────────────────────────────────────────────────────────── + @Delete(':id') @Roles(UserRole.ADMIN) + @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Delete an asset (ADMIN only)' }) - @ApiParam({ name: 'id', description: 'Asset ID' }) + @ApiParam({ + name: 'id', + description: 'Asset UUID', + example: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + }) @ApiResponse({ status: HttpStatus.NO_CONTENT, - description: 'Asset successfully deleted', + description: 'Asset successfully deleted.', }) @ApiResponse({ status: HttpStatus.NOT_FOUND, - description: 'Asset not found', + description: 'Asset not found.', + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Missing or invalid JWT token.', + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Insufficient role — ADMIN required.', }) - remove(@Param('id') id: string) { - return this.assetsService.remove(id); + remove(@Param('id') id: string, @Request() req: any) { + const userId: string | undefined = req?.user?.id; + return this.assetsService.remove(id, userId); } } diff --git a/backend/src/opsce/assets/assets.module.ts b/backend/src/opsce/assets/assets.module.ts index 65a1bdfd..e2cc30f7 100644 --- a/backend/src/opsce/assets/assets.module.ts +++ b/backend/src/opsce/assets/assets.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Asset } from './entities/asset.entity'; import { AssetsController } from './assets.controller'; import { AssetsService } from './assets.service'; +import { AuditModule } from '../audit/audit.module'; @Module({ - imports: [TypeOrmModule.forFeature([Asset])], + imports: [TypeOrmModule.forFeature([Asset]), AuditModule], controllers: [AssetsController], providers: [AssetsService], exports: [AssetsService], diff --git a/backend/src/opsce/assets/assets.service.spec.ts b/backend/src/opsce/assets/assets.service.spec.ts new file mode 100644 index 00000000..11162fb1 --- /dev/null +++ b/backend/src/opsce/assets/assets.service.spec.ts @@ -0,0 +1,449 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { Repository, SelectQueryBuilder } from 'typeorm'; +import { AssetsService } from './assets.service'; +import { Asset, AssetStatus, AssetCondition } from './entities/asset.entity'; +import { AuditService } from '../audit/audit.service'; +import { CreateAssetDto } from './dto/create-asset.dto'; +import { UpdateAssetDto } from './dto/update-asset.dto'; +import { + BulkAssetOperationDto, + BulkOperation, +} from './dto/bulk-asset-operation.dto'; +import { PaginationDto } from '../common/dto/pagination.dto'; +import { PaginatedResponseDto } from '../common/dto/paginated-response.dto'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeAsset(overrides: Partial = {}): Asset { + const asset = new Asset(); + asset.id = 'asset-uuid-1'; + asset.name = 'Test Laptop'; + asset.category = 'IT'; + asset.status = AssetStatus.ACTIVE; + asset.condition = AssetCondition.GOOD; + asset.isTokenized = false; + asset.createdAt = new Date('2025-01-01'); + asset.updatedAt = new Date('2025-01-01'); + return Object.assign(asset, overrides); +} + +// ─── Mock factory ───────────────────────────────────────────────────────────── + +type MockRepository = Partial, jest.Mock>>; + +function createMockRepository(): MockRepository { + return { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + findAndCount: jest.fn(), + remove: jest.fn(), + softRemove: jest.fn(), + createQueryBuilder: jest.fn(), + }; +} + +// ─── Test suite ─────────────────────────────────────────────────────────────── + +describe('AssetsService', () => { + let service: AssetsService; + let assetRepo: MockRepository; + let auditService: { log: jest.Mock }; + + beforeEach(async () => { + assetRepo = createMockRepository(); + auditService = { log: jest.fn().mockResolvedValue({}) }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AssetsService, + { + provide: getRepositoryToken(Asset), + useValue: assetRepo, + }, + { + provide: AuditService, + useValue: auditService, + }, + ], + }).compile(); + + service = module.get(AssetsService); + }); + + afterEach(() => jest.clearAllMocks()); + + // ─── create() ────────────────────────────────────────────────────────────── + + describe('create()', () => { + const dto: CreateAssetDto = { + name: 'Test Laptop', + category: 'IT', + serialNumber: 'SN-001', + }; + + it('should create and return the asset on the success path', async () => { + const asset = makeAsset({ serialNumber: 'SN-001' }); + assetRepo.create!.mockReturnValue(asset); + assetRepo.save!.mockResolvedValue(asset); + + const result = await service.create(dto); + + expect(assetRepo.create).toHaveBeenCalledWith(dto); + expect(assetRepo.save).toHaveBeenCalledWith(asset); + expect(result).toEqual(asset); + }); + + it('should call AuditService.log after a successful create', async () => { + const asset = makeAsset({ serialNumber: 'SN-001' }); + assetRepo.create!.mockReturnValue(asset); + assetRepo.save!.mockResolvedValue(asset); + + await service.create(dto); + + expect(auditService.log).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'CREATE', + resourceType: 'Asset', + resourceId: asset.id, + }), + ); + }); + + it('should propagate a duplicate serial number error from the repository', async () => { + const asset = makeAsset({ serialNumber: 'SN-001' }); + assetRepo.create!.mockReturnValue(asset); + assetRepo.save!.mockRejectedValue( + Object.assign(new Error('duplicate key value'), { code: '23505' }), + ); + + await expect(service.create(dto)).rejects.toThrow('duplicate key value'); + expect(auditService.log).not.toHaveBeenCalled(); + }); + }); + + // ─── findAll() ───────────────────────────────────────────────────────────── + + describe('findAll()', () => { + it('should return a paginated response with the correct total count', async () => { + const assets = [makeAsset(), makeAsset({ id: 'asset-uuid-2', name: 'Monitor' })]; + assetRepo.findAndCount!.mockResolvedValue([assets, 2]); + + const pagination: PaginationDto = { page: 1, limit: 20 }; + const result = await service.findAll(pagination); + + expect(assetRepo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ skip: 0, take: 20 }), + ); + expect(result).toBeInstanceOf(PaginatedResponseDto); + expect(result.total).toBe(2); + expect(result.data).toHaveLength(2); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + }); + + it('should calculate totalPages correctly', async () => { + const assets = Array.from({ length: 5 }, (_, i) => + makeAsset({ id: `uuid-${i}` }), + ); + assetRepo.findAndCount!.mockResolvedValue([assets, 25]); + + const result = await service.findAll({ page: 1, limit: 5 }); + + expect(result.totalPages).toBe(5); + }); + + it('should apply correct skip offset for page 2', async () => { + assetRepo.findAndCount!.mockResolvedValue([[], 0]); + + await service.findAll({ page: 2, limit: 10 }); + + expect(assetRepo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ skip: 10, take: 10 }), + ); + }); + }); + + // ─── findAll() with search filter ────────────────────────────────────────── + + describe('findAll() with search filter', () => { + function buildMockQb(data: Asset[], total: number) { + const qb = { + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([data, total]), + } as unknown as SelectQueryBuilder; + return qb; + } + + it('should return only matching assets when search is provided', async () => { + const matchingAsset = makeAsset({ name: 'Dell Laptop' }); + const mockQb = buildMockQb([matchingAsset], 1); + assetRepo.createQueryBuilder!.mockReturnValue(mockQb); + + const result = await service.findAll({ page: 1, limit: 20 }, 'laptop'); + + expect(assetRepo.createQueryBuilder).toHaveBeenCalledWith('asset'); + expect((mockQb as any).where).toHaveBeenCalledWith( + expect.stringContaining('ILIKE'), + expect.objectContaining({ search: '%laptop%' }), + ); + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + }); + + it('should return empty results when search matches nothing', async () => { + const mockQb = buildMockQb([], 0); + assetRepo.createQueryBuilder!.mockReturnValue(mockQb); + + const result = await service.findAll({ page: 1, limit: 20 }, 'nonexistent'); + + expect(result.data).toHaveLength(0); + expect(result.total).toBe(0); + }); + + it('should NOT use createQueryBuilder when no search term is given', async () => { + assetRepo.findAndCount!.mockResolvedValue([[], 0]); + + await service.findAll({ page: 1, limit: 20 }); + + expect(assetRepo.createQueryBuilder).not.toHaveBeenCalled(); + expect(assetRepo.findAndCount).toHaveBeenCalled(); + }); + }); + + // ─── findOne() ───────────────────────────────────────────────────────────── + + describe('findOne()', () => { + it('should return the asset for a valid ID', async () => { + const asset = makeAsset(); + assetRepo.findOne!.mockResolvedValue(asset); + + const result = await service.findOne('asset-uuid-1'); + + expect(assetRepo.findOne).toHaveBeenCalledWith({ + where: { id: 'asset-uuid-1' }, + }); + expect(result).toEqual(asset); + }); + + it('should throw NotFoundException for an unknown ID', async () => { + assetRepo.findOne!.mockResolvedValue(null); + + await expect(service.findOne('unknown-id')).rejects.toThrow( + NotFoundException, + ); + await expect(service.findOne('unknown-id')).rejects.toThrow( + 'Asset with ID unknown-id not found', + ); + }); + }); + + // ─── update() ────────────────────────────────────────────────────────────── + + describe('update()', () => { + it('should update the asset fields and return the saved entity', async () => { + const asset = makeAsset(); + const updateDto: UpdateAssetDto = { name: 'Updated Laptop', status: AssetStatus.INACTIVE }; + const updatedAsset = makeAsset({ name: 'Updated Laptop', status: AssetStatus.INACTIVE }); + + assetRepo.findOne!.mockResolvedValue(asset); + assetRepo.save!.mockResolvedValue(updatedAsset); + + const result = await service.update('asset-uuid-1', updateDto, 'user-1'); + + expect(assetRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Updated Laptop', status: AssetStatus.INACTIVE }), + ); + expect(result).toEqual(updatedAsset); + }); + + it('should call AuditService.log with old and new values', async () => { + const asset = makeAsset(); + const updateDto: UpdateAssetDto = { name: 'Updated Laptop' }; + assetRepo.findOne!.mockResolvedValue(asset); + assetRepo.save!.mockResolvedValue(makeAsset({ name: 'Updated Laptop' })); + + await service.update('asset-uuid-1', updateDto, 'user-1'); + + expect(auditService.log).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + action: 'UPDATE', + resourceType: 'Asset', + resourceId: 'asset-uuid-1', + newValue: updateDto, + }), + ); + }); + + it('should throw NotFoundException when the asset does not exist', async () => { + assetRepo.findOne!.mockResolvedValue(null); + + await expect( + service.update('bad-id', { name: 'X' }, 'user-1'), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ─── remove() ────────────────────────────────────────────────────────────── + + describe('remove()', () => { + it('should remove the asset and log the deletion', async () => { + const asset = makeAsset(); + assetRepo.findOne!.mockResolvedValue(asset); + assetRepo.remove!.mockResolvedValue(asset); + + await service.remove('asset-uuid-1', 'user-1'); + + expect(assetRepo.remove).toHaveBeenCalledWith(asset); + expect(auditService.log).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + action: 'DELETE', + resourceType: 'Asset', + resourceId: 'asset-uuid-1', + }), + ); + }); + + it('should throw NotFoundException when the asset does not exist', async () => { + assetRepo.findOne!.mockResolvedValue(null); + + await expect(service.remove('bad-id', 'user-1')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + // ─── bulkOperation() ─────────────────────────────────────────────────────── + + describe('bulkOperation()', () => { + it('should update status for all valid IDs and return succeeded list', async () => { + const asset1 = makeAsset({ id: 'id-1' }); + const asset2 = makeAsset({ id: 'id-2' }); + assetRepo.findOne! + .mockResolvedValueOnce(asset1) + .mockResolvedValueOnce(asset2); + assetRepo.save!.mockResolvedValue({}); + + const dto: BulkAssetOperationDto = { + ids: ['id-1', 'id-2'], + operation: BulkOperation.UPDATE_STATUS, + payload: { status: AssetStatus.INACTIVE }, + }; + + const result = await service.bulkOperation(dto, 'user-1'); + + expect(result.succeeded).toEqual(['id-1', 'id-2']); + expect(result.failed).toHaveLength(0); + expect(assetRepo.save).toHaveBeenCalledTimes(2); + }); + + it('should record failed IDs when an asset is not found', async () => { + assetRepo.findOne!.mockResolvedValue(null); + + const dto: BulkAssetOperationDto = { + ids: ['missing-id'], + operation: BulkOperation.UPDATE_STATUS, + payload: { status: AssetStatus.INACTIVE }, + }; + + const result = await service.bulkOperation(dto, 'user-1'); + + expect(result.succeeded).toHaveLength(0); + expect(result.failed).toHaveLength(1); + expect(result.failed[0].id).toBe('missing-id'); + expect(result.failed[0].reason).toContain('not found'); + }); + + it('should handle partial success — some succeed, some fail', async () => { + const goodAsset = makeAsset({ id: 'good-id' }); + assetRepo.findOne! + .mockResolvedValueOnce(goodAsset) + .mockResolvedValueOnce(null); + assetRepo.save!.mockResolvedValue({}); + + const dto: BulkAssetOperationDto = { + ids: ['good-id', 'bad-id'], + operation: BulkOperation.REASSIGN, + payload: { assignedToUserId: 'user-abc' }, + }; + + const result = await service.bulkOperation(dto, 'user-1'); + + expect(result.succeeded).toEqual(['good-id']); + expect(result.failed[0].id).toBe('bad-id'); + }); + + it('should call softRemove for soft-delete operation', async () => { + const asset = makeAsset({ id: 'id-1' }); + assetRepo.findOne!.mockResolvedValue(asset); + assetRepo.softRemove!.mockResolvedValue(asset); + + const dto: BulkAssetOperationDto = { + ids: ['id-1'], + operation: BulkOperation.SOFT_DELETE, + payload: {}, + }; + + const result = await service.bulkOperation(dto, 'user-1'); + + expect(assetRepo.softRemove).toHaveBeenCalledWith(asset); + expect(result.succeeded).toEqual(['id-1']); + }); + + it('should log an audit entry for each successfully processed asset', async () => { + const asset = makeAsset({ id: 'id-1' }); + assetRepo.findOne!.mockResolvedValue(asset); + assetRepo.save!.mockResolvedValue({}); + + const dto: BulkAssetOperationDto = { + ids: ['id-1'], + operation: BulkOperation.CHANGE_DEPARTMENT, + payload: { departmentId: 'dept-1' }, + }; + + await service.bulkOperation(dto, 'user-1'); + + expect(auditService.log).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + resourceType: 'Asset', + resourceId: 'id-1', + }), + ); + }); + + it('should fail with a descriptive reason when required payload field is missing', async () => { + const asset = makeAsset({ id: 'id-1' }); + assetRepo.findOne!.mockResolvedValue(asset); + + const dto: BulkAssetOperationDto = { + ids: ['id-1'], + operation: BulkOperation.UPDATE_STATUS, + payload: {}, // missing status + }; + + const result = await service.bulkOperation(dto, 'user-1'); + + expect(result.failed[0].reason).toContain('payload.status is required'); + }); + }); + + // ─── Repository isolation ────────────────────────────────────────────────── + + describe('repository isolation', () => { + it('should never make real database calls — all repository methods are mocked', () => { + // Verify that the injected repository is our mock, not a real TypeORM repo + expect(assetRepo.findOne).toBeDefined(); + expect(typeof assetRepo.findOne).toBe('function'); + // Jest mock functions have a .mock property + expect((assetRepo.findOne as jest.Mock).mock).toBeDefined(); + }); + }); +}); diff --git a/backend/src/opsce/assets/assets.service.ts b/backend/src/opsce/assets/assets.service.ts index 347782e7..ab8234b8 100644 --- a/backend/src/opsce/assets/assets.service.ts +++ b/backend/src/opsce/assets/assets.service.ts @@ -1,26 +1,59 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { Asset } from './entities/asset.entity'; +import { Asset, AssetStatus } from './entities/asset.entity'; import { CreateAssetDto } from './dto/create-asset.dto'; import { UpdateAssetDto } from './dto/update-asset.dto'; +import { + BulkAssetOperationDto, + BulkOperation, + BulkOperationResult, +} from './dto/bulk-asset-operation.dto'; import { PaginationDto, PaginatedResponseDto, paginate } from '../common'; +import { AuditService } from '../audit/audit.service'; @Injectable() export class AssetsService { constructor( @InjectRepository(Asset) private readonly assetRepository: Repository, + private readonly auditService: AuditService, ) {} async create(createAssetDto: CreateAssetDto): Promise { const asset = this.assetRepository.create(createAssetDto); - return this.assetRepository.save(asset); + const saved = await this.assetRepository.save(asset); + + await this.auditService.log({ + action: 'CREATE', + resourceType: 'Asset', + resourceId: saved.id, + newValue: createAssetDto as unknown as Record, + }); + + return saved; } async findAll( paginationDto: PaginationDto, + search?: string, ): Promise> { + if (search) { + const { page = 1, limit = 20 } = paginationDto; + const qb = this.assetRepository + .createQueryBuilder('asset') + .where( + 'asset.name ILIKE :search OR asset.category ILIKE :search OR asset.serialNumber ILIKE :search', + { search: `%${search}%` }, + ) + .orderBy('asset.createdAt', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + const [data, total] = await qb.getManyAndCount(); + return new PaginatedResponseDto(data, total, page, limit); + } + return paginate(this.assetRepository, paginationDto, { order: { createdAt: 'DESC' }, }); @@ -34,14 +67,138 @@ export class AssetsService { return asset; } - async update(id: string, updateAssetDto: UpdateAssetDto): Promise { + async update( + id: string, + updateAssetDto: UpdateAssetDto, + userId?: string, + ): Promise { const asset = await this.findOne(id); + const oldValue = { ...asset } as unknown as Record; + Object.assign(asset, updateAssetDto); - return this.assetRepository.save(asset); + const saved = await this.assetRepository.save(asset); + + await this.auditService.log({ + userId, + action: 'UPDATE', + resourceType: 'Asset', + resourceId: id, + oldValue, + newValue: updateAssetDto as unknown as Record, + }); + + return saved; } - async remove(id: string): Promise { + async remove(id: string, userId?: string): Promise { const asset = await this.findOne(id); await this.assetRepository.remove(asset); + + await this.auditService.log({ + userId, + action: 'DELETE', + resourceType: 'Asset', + resourceId: id, + }); + } + + /** + * Apply a single operation to multiple assets in one request. + * Each asset is processed independently — failures do not roll back + * successful operations. Returns per-asset success/failure results. + * + * Maximum 100 IDs per request (enforced at the DTO layer). + */ + async bulkOperation( + dto: BulkAssetOperationDto, + userId?: string, + ): Promise { + const result: BulkOperationResult = { succeeded: [], failed: [] }; + + for (const id of dto.ids) { + try { + await this.applyBulkOperation(id, dto.operation, dto.payload ?? {}, userId); + result.succeeded.push(id); + } catch (error) { + result.failed.push({ + id, + reason: (error as Error).message ?? 'Unknown error', + }); + } + } + + return result; + } + + // ─── Private helpers ──────────────────────────────────────────────────────── + + private async applyBulkOperation( + id: string, + operation: BulkOperation, + payload: Record, + userId?: string, + ): Promise { + const asset = await this.findOne(id); + const oldValue = { ...asset } as unknown as Record; + + switch (operation) { + case BulkOperation.UPDATE_STATUS: { + const status = payload['status'] as AssetStatus; + if (!status) throw new Error('payload.status is required for update-status'); + asset.status = status; + if (payload['reason']) { + asset.statusChangeReason = payload['reason'] as string; + asset.statusChangedAt = new Date(); + } + break; + } + + case BulkOperation.REASSIGN: { + const assignedToUserId = payload['assignedToUserId'] as string; + if (!assignedToUserId) throw new Error('payload.assignedToUserId is required for reassign'); + asset.assignedToUserId = assignedToUserId; + asset.assignedAt = new Date(); + break; + } + + case BulkOperation.CHANGE_DEPARTMENT: { + const departmentId = payload['departmentId'] as string; + if (!departmentId) throw new Error('payload.departmentId is required for change-department'); + asset.departmentId = departmentId; + break; + } + + case BulkOperation.CHANGE_LOCATION: { + const locationId = payload['locationId'] as string; + if (!locationId) throw new Error('payload.locationId is required for change-location'); + asset.locationId = locationId; + break; + } + + case BulkOperation.SOFT_DELETE: { + await this.assetRepository.softRemove(asset); + await this.auditService.log({ + userId, + action: 'BULK_SOFT_DELETE', + resourceType: 'Asset', + resourceId: id, + oldValue, + }); + return; + } + + default: + throw new Error(`Unsupported bulk operation: ${operation as string}`); + } + + await this.assetRepository.save(asset); + await this.auditService.log({ + userId, + action: `BULK_${operation.toUpperCase().replace(/-/g, '_')}`, + resourceType: 'Asset', + resourceId: id, + oldValue, + newValue: payload, + }); } } diff --git a/backend/src/opsce/assets/dto/bulk-asset-operation.dto.ts b/backend/src/opsce/assets/dto/bulk-asset-operation.dto.ts new file mode 100644 index 00000000..f2176082 --- /dev/null +++ b/backend/src/opsce/assets/dto/bulk-asset-operation.dto.ts @@ -0,0 +1,59 @@ +import { + IsArray, + IsEnum, + IsObject, + IsOptional, + IsString, + IsUUID, + ArrayMaxSize, + ArrayMinSize, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export enum BulkOperation { + UPDATE_STATUS = 'update-status', + REASSIGN = 'reassign', + CHANGE_DEPARTMENT = 'change-department', + CHANGE_LOCATION = 'change-location', + SOFT_DELETE = 'soft-delete', +} + +export class BulkAssetOperationDto { + @ApiProperty({ + description: 'Array of asset UUIDs to operate on (max 100)', + type: [String], + example: ['uuid-1', 'uuid-2'], + }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(100) + @IsUUID('4', { each: true }) + ids: string[]; + + @ApiProperty({ + description: 'Operation to apply to all specified assets', + enum: BulkOperation, + example: BulkOperation.UPDATE_STATUS, + }) + @IsEnum(BulkOperation) + operation: BulkOperation; + + @ApiPropertyOptional({ + description: + 'Operation-specific payload. ' + + 'update-status: { status: AssetStatus, reason?: string } | ' + + 'reassign: { assignedToUserId: string } | ' + + 'change-department: { departmentId: string } | ' + + 'change-location: { locationId: string } | ' + + 'soft-delete: {} (empty object)', + example: { status: 'inactive' }, + }) + @IsOptional() + @IsObject() + payload?: Record; +} + +export interface BulkOperationResult { + succeeded: string[]; + failed: Array<{ id: string; reason: string }>; +} diff --git a/backend/src/opsce/assets/entities/asset.entity.ts b/backend/src/opsce/assets/entities/asset.entity.ts index f2071009..38ff7acc 100644 --- a/backend/src/opsce/assets/entities/asset.entity.ts +++ b/backend/src/opsce/assets/entities/asset.entity.ts @@ -13,14 +13,10 @@ import { BeforeUpdate, } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; 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}$/; @@ -31,65 +27,6 @@ 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', @@ -123,38 +60,25 @@ export enum MaintenanceFrequency { // ─── 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; } @@ -162,9 +86,7 @@ export interface MaintenanceSchedule { export interface InsuranceInfo { provider: string; policyNumber: string; - /** ISO 8601 date */ expiryDate: string; - /** Insured value */ insuredValue: number; currency: string; } @@ -200,33 +122,11 @@ export class Asset { @Column({ type: 'text', 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', - }) - @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', - }) - @Index('IDX_ASSET_SERIAL', { unique: true, where: '"deletedAt" IS NULL AND "serialNumber" IS NOT NULL' }) - @Column({ length: 100, nullable: true }) serialNumber?: string; @@ -236,7 +136,6 @@ export class Asset { @Column({ length: 100, nullable: true }) model?: string; - /** Model year (e.g. 2023). */ @Column({ type: 'int', nullable: true }) modelYear?: number; @@ -246,104 +145,37 @@ export class Asset { @Column({ length: 100 }) category: string; - /** - * Optional sub-category (e.g. category="IT" → subCategory="Laptop"). - */ @Index() - - @Column({ length: 100, nullable: true }) subCategory?: string; - @Column({ - type: 'enum', - enum: AssetStatus, - default: AssetStatus.ACTIVE, - }) @Index() - + @Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.ACTIVE }) status: AssetStatus; - @Column({ - type: 'enum', - enum: AssetCondition, - default: AssetCondition.GOOD, - }) + @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; - - /** - * Searchable tags (e.g. ["portable", "shared", "critical"]). - * Deduplicated and lowercased by lifecycle hook. - */ @Column({ type: 'text', array: true, nullable: true, default: [] }) tags: string[]; // ─── Financials ────────────────────────────────────────────────────────────── - /** ISO 4217 currency code for all monetary values (e.g. "USD", "NGN"). */ @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; - /** Useful life in years — used for depreciation calculations. */ @Column({ type: 'int', nullable: true }) usefulLifeYears?: number; @@ -355,33 +187,24 @@ export class Asset { }) depreciationMethod?: DepreciationMethod; - /** Full depreciation configuration stored as JSONB. */ @Column({ type: 'jsonb', nullable: true }) depreciationConfig?: DepreciationConfig; - /** Name of the vendor / supplier the asset was purchased from. */ @Column({ length: 200, nullable: true }) vendor?: string; - /** Purchase order number for procurement traceability. */ @Column({ length: 100, nullable: true }) purchaseOrderNumber?: string; - /** Invoice number from the vendor. */ @Column({ length: 100, nullable: true }) invoiceNumber?: string; // ─── Warranty ──────────────────────────────────────────────────────────────── - /** - * Denormalised expiry date for fast "expiring soon" queries. - * Kept in sync with warrantyInfo.expiryDate by the lifecycle hook. - */ @Index('IDX_ASSET_WARRANTY_EXPIRY') @Column({ type: 'date', nullable: true }) warrantyExpiryDate?: Date; - /** Full warranty detail stored as JSONB. */ @Column({ type: 'jsonb', nullable: true }) warrantyInfo?: WarrantyInfo; @@ -409,18 +232,15 @@ export class Asset { // ─── Assignment ────────────────────────────────────────────────────────────── @Column({ type: 'uuid', nullable: true }) - assignedToUserId?: string; @ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL', eager: false }) @JoinColumn({ name: 'assignedToUserId' }) assignedToUser?: User; - /** ISO 8601 timestamp when the current assignment began. */ @Column({ type: 'timestamptz', nullable: true }) assignedAt?: Date; - /** Expected return date for temporarily assigned assets. */ @Column({ type: 'date', nullable: true }) expectedReturnDate?: Date; @@ -429,53 +249,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; // ─── 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; @@ -499,37 +296,29 @@ export class Asset { @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; // ─── Tokenization ────────────────────────────────────────────────────────────── - /** Stellar contract ID for tokenized assets */ @Column({ type: 'text', nullable: true }) stellarContractId?: string; - /** Total number of shares for tokenized assets */ @Column({ type: 'decimal', precision: 20, scale: 0, nullable: true }) totalShares?: number; - /** Whether this asset has been tokenized on Stellar */ @Column({ default: false }) isTokenized: boolean; - /** Transaction hash of the tokenization transaction */ @Column({ type: 'text', nullable: true }) tokenizationTxHash?: string; - /** Timestamp when the asset was tokenized */ @Column({ type: 'timestamptz', nullable: true }) tokenizedAt?: Date; - /** Token symbol on Stellar network */ @Column({ length: 50, nullable: true }) tokenSymbol?: string; @@ -543,38 +332,21 @@ export class Asset { 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 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))); } @@ -583,16 +355,10 @@ 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)) { @@ -606,22 +372,18 @@ export class Asset { 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); } @@ -646,8 +408,4 @@ export class Asset { throw new Error('currentValue cannot exceed purchaseValue'); } } - } - -} - diff --git a/backend/src/opsce/departments/entities/department.entity.ts b/backend/src/opsce/departments/entities/department.entity.ts index d6f3151e..01c699d2 100644 --- a/backend/src/opsce/departments/entities/department.entity.ts +++ b/backend/src/opsce/departments/entities/department.entity.ts @@ -9,13 +9,9 @@ import { OneToMany, ManyToMany, JoinColumn, - - Index, - JoinTable, Index, Check, - BeforeInsert, BeforeUpdate, } from 'typeorm'; @@ -23,16 +19,11 @@ import { User } from '../../users/entities/user.entity'; // ─── Constants ──────────────────────────────────────────────────────────────── -/** Maximum nesting depth enforced at the application layer. */ export const MAX_DEPARTMENT_DEPTH = 5; - -/** Regex that department codes must satisfy. */ 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']) @@ -45,11 +36,6 @@ export class Department { @PrimaryGeneratedColumn('uuid') id: string; - /** - * Human-readable department name — unique across non-deleted departments. - * Uniqueness is enforced via a partial index rather than a column constraint - * so that soft-deleted names can be reused. - */ @Index('IDX_DEPT_NAME_ACTIVE', { where: '"deletedAt" IS NULL' }) @Column({ length: 150 }) name: string; @@ -57,16 +43,10 @@ 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. - */ @Index('IDX_DEPT_CODE_ACTIVE', { where: '"deletedAt" IS NULL AND "code" IS NOT NULL' }) @Column({ length: 20, nullable: true }) code?: string; - @Column({ default: true }) isActive: boolean; @@ -76,26 +56,12 @@ 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. */ @Column({ type: 'text', nullable: true }) path?: string; - /** - * Nesting depth — 0 for root departments. - * Maintained automatically by lifecycle hooks. - */ @Column({ type: 'int', default: 0 }) depth: number; @@ -103,30 +69,14 @@ 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 ───────────────────────────────────────────────────────────── - /** - * The designated head of this department. - * Nullable — a department may exist without an assigned head. - */ @Column({ type: 'uuid', nullable: true }) headId?: string; @@ -134,7 +84,6 @@ export class Department { @JoinColumn({ name: 'headId' }) head?: User; - /** Members directly assigned to this department. */ @ManyToMany(() => User, { cascade: false, eager: false }) @JoinTable({ name: 'department_members', @@ -143,10 +92,6 @@ export class Department { }) members: User[]; - /** - * Cached headcount — updated by application logic or a DB trigger. - * Avoids COUNT(*) joins on hot read paths. - */ @Column({ type: 'int', default: 0 }) memberCount: number; @@ -155,40 +100,25 @@ export class Department { @Column({ type: 'decimal', precision: 15, scale: 2, nullable: true }) budgetAmount?: number; - /** ISO 4217 currency code, e.g. "USD", "NGN". */ @Column({ length: 3, nullable: true }) budgetCurrency?: string; - /** Fiscal year the budget applies to (e.g. 2025). */ @Column({ type: 'int', nullable: true }) budgetYear?: number; // ─── Display / UX ───────────────────────────────────────────────────────── - /** - * Hex colour used in org-chart UIs (e.g. "#3B82F6"). - * Validated in the DTO layer. - */ @Column({ length: 7, nullable: true }) color?: string; - /** URL or icon key for org-chart and navigation usage. */ @Column({ type: 'text', nullable: true }) iconUrl?: string; - /** - * Display order among siblings — lower = shown first. - * Defaults to 0; ties are broken by createdAt. - */ @Column({ type: 'int', default: 0 }) sortOrder: number; // ─── Metadata ───────────────────────────────────────────────────────────── - /** - * Arbitrary JSON key–value metadata for integrations - * (e.g. external HR system IDs, Slack channel IDs). - */ @Column({ type: 'jsonb', nullable: true }) metadata?: Record; @@ -200,65 +130,35 @@ export class Department { @UpdateDateColumn({ type: 'timestamptz' }) 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 - - 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); } // ─── 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 { @@ -269,9 +169,6 @@ export class Department { } } - /** - * Trims whitespace from name and description before persistence. - */ @BeforeInsert() @BeforeUpdate() normalizeStrings(): void { @@ -279,8 +176,4 @@ export class Department { 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 841c3e1a..3f3db06c 100644 --- a/backend/src/opsce/locations/entities/location.entity.ts +++ b/backend/src/opsce/locations/entities/location.entity.ts @@ -17,30 +17,9 @@ 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; - export const LOCATION_CODE_PATTERN = /^[A-Z0-9_-]{1,30}$/; // ─── Enums ──────────────────────────────────────────────────────────────────── @@ -57,187 +36,98 @@ export enum LocationType { } export enum LocationStatus { - ACTIVE = 'active', - INACTIVE = 'inactive', + ACTIVE = 'active', + INACTIVE = 'inactive', UNDER_MAINTENANCE = 'under_maintenance', - RESERVED = 'reserved', - DECOMMISSIONED = 'decommissioned', + RESERVED = 'reserved', + DECOMMISSIONED = 'decommissioned', } export enum AccessLevel { - - PUBLIC = 'public', + PUBLIC = 'public', RESTRICTED = 'restricted', - PRIVATE = 'private', - SECURE = 'secure', - - PUBLIC = 'public', - RESTRICTED = 'restricted', - PRIVATE = 'private', - SECURE = 'secure', - + 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; -} - - } 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`) -@Check( - `"currentOccupancy" IS NULL OR "capacity" IS NULL OR "currentOccupancy" <= "capacity"`, -) +@Check(`"currentOccupancy" IS NULL OR "capacity" IS NULL OR "currentOccupancy" <= "capacity"`) export class Location { - // ─── Identity ─────────────────────────────────────────────────────────────── @PrimaryGeneratedColumn('uuid') id: string; - /** - * Human-readable name — unique among non-deleted siblings of the same parent. - * Full uniqueness is enforced by a partial index. - */ @Index('IDX_LOC_NAME_ACTIVE', { where: '"deletedAt" IS NULL' }) @Column({ length: 200 }) name: string; - - @Column({ nullable: true }) + @Column({ type: 'text', 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; @Column({ type: 'enum', enum: LocationType }) type: LocationType; - @Column({ - type: 'enum', - enum: LocationStatus, - default: LocationStatus.ACTIVE, - }) + @Column({ type: 'enum', enum: LocationStatus, default: LocationStatus.ACTIVE }) status: LocationStatus; - - @Column({ nullable: true }) - address?: string; - - - @Column({ - type: 'enum', - enum: AccessLevel, - default: AccessLevel.PUBLIC, - }) + @Column({ type: 'enum', enum: AccessLevel, default: AccessLevel.PUBLIC }) accessLevel: AccessLevel; - - @Column({ nullable: true }) - - @Column({ type: 'text', nullable: true }) - description?: string; - // ─── Hierarchy / materialized path ───────────────────────────────────────── @Column({ type: 'uuid', nullable: true }) - parentId?: string; - /** - * Materialized path for O(1) ancestor queries and O(depth) subtree queries. - * Format: //…/// - */ @Index('IDX_LOC_PATH') @Column({ type: 'text', nullable: true }) path?: string; - /** Nesting depth — 0 for root locations (campus / standalone building). */ @Column({ type: 'int', default: 0 }) depth: number; - @ManyToOne(() => Location, (l) => l.children, { - nullable: true, - onDelete: 'SET NULL', - }) + @ManyToOne(() => Location, (l) => l.children, { nullable: true, onDelete: 'SET NULL' }) @JoinColumn({ name: 'parentId' }) parent?: Location; @@ -246,118 +136,68 @@ 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; - /** 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' }, - 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; - // ─── Legacy compatibility ─────────────────────────────────────────────────── - - /** - * Kept for backwards compatibility — prefer `status` for new code. - * Synced with status in the BeforeInsert / BeforeUpdate hook. - */ @Column({ default: true }) isActive: boolean; @@ -369,11 +209,6 @@ export class Location { @UpdateDateColumn({ type: 'timestamptz' }) 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; @@ -396,32 +231,16 @@ export class Location { 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; -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)); - } - /** - * 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); @@ -432,18 +251,10 @@ s @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(); - 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)) { @@ -453,21 +264,10 @@ s } } - // 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())), - ]; - 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() @@ -479,18 +279,8 @@ s 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`); } } } } - diff --git a/backend/src/opsce/mailer/mailer.module.ts b/backend/src/opsce/mailer/mailer.module.ts new file mode 100644 index 00000000..eba8e9c9 --- /dev/null +++ b/backend/src/opsce/mailer/mailer.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { MailerService } from './mailer.service'; + +@Module({ + providers: [MailerService], + exports: [MailerService], +}) +export class MailerModule {} diff --git a/backend/src/opsce/mailer/mailer.service.ts b/backend/src/opsce/mailer/mailer.service.ts new file mode 100644 index 00000000..672ece24 --- /dev/null +++ b/backend/src/opsce/mailer/mailer.service.ts @@ -0,0 +1,94 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as nodemailer from 'nodemailer'; +import { Transporter } from 'nodemailer'; +import { + PASSWORD_RESET_TEMPLATE, + MAINTENANCE_DUE_TEMPLATE, +} from './templates/email.templates'; + +@Injectable() +export class MailerService { + private readonly logger = new Logger(MailerService.name); + private readonly transporter: Transporter; + private readonly from: string; + + constructor(private readonly configService: ConfigService) { + const host = this.configService.get('SMTP_HOST', 'localhost'); + const port = this.configService.get('SMTP_PORT', 587); + const user = this.configService.get('SMTP_USER', ''); + const pass = this.configService.get('SMTP_PASS', ''); + this.from = this.configService.get( + 'SMTP_FROM', + 'noreply@assetsup.io', + ); + + this.transporter = nodemailer.createTransport({ + host, + port, + secure: port === 465, + auth: user && pass ? { user, pass } : undefined, + }); + } + + /** + * Send a password-reset email containing a tokenised reset link. + * Failures are caught and logged — they do not propagate to the caller. + * + * @param to Recipient email address + * @param token Password-reset token (appended to the reset URL) + */ + async sendPasswordReset(to: string, token: string): Promise { + const baseUrl = this.configService.get( + 'FRONTEND_URL', + 'http://localhost:3000', + ); + const resetLink = `${baseUrl}/auth/reset-password?token=${encodeURIComponent(token)}`; + + try { + await this.transporter.sendMail({ + from: this.from, + to, + subject: 'Reset your AssetsUp password', + html: PASSWORD_RESET_TEMPLATE(resetLink), + }); + this.logger.log(`Password-reset email sent to ${to}`); + } catch (error) { + this.logger.error( + `Failed to send password-reset email to ${to}: ${(error as Error).message}`, + (error as Error).stack, + ); + } + } + + /** + * Send a maintenance-due reminder email. + * Failures are caught and logged — they do not propagate to the caller. + * + * @param to Recipient email address + * @param assetName Human-readable name of the asset + * @param dueDate Formatted due date string (e.g. "2026-07-01") + */ + async sendMaintenanceDue( + to: string, + assetName: string, + dueDate: string, + ): Promise { + try { + await this.transporter.sendMail({ + from: this.from, + to, + subject: `Maintenance due: ${assetName}`, + html: MAINTENANCE_DUE_TEMPLATE(assetName, dueDate), + }); + this.logger.log( + `Maintenance-due email sent to ${to} for asset "${assetName}"`, + ); + } catch (error) { + this.logger.error( + `Failed to send maintenance-due email to ${to}: ${(error as Error).message}`, + (error as Error).stack, + ); + } + } +} diff --git a/backend/src/opsce/mailer/templates/email.templates.ts b/backend/src/opsce/mailer/templates/email.templates.ts new file mode 100644 index 00000000..be6f9d13 --- /dev/null +++ b/backend/src/opsce/mailer/templates/email.templates.ts @@ -0,0 +1,116 @@ +/** + * HTML email templates stored as string constants. + * Keep templates self-contained (inline styles) for maximum email-client compatibility. + */ + +export const PASSWORD_RESET_TEMPLATE = (resetLink: string): string => ` + + + + + + Password Reset + + + + + + +
+ + + + + + + + + + +
+

AssetsUp

+
+

Reset Your Password

+

+ We received a request to reset the password for your AssetsUp account. + Click the button below to choose a new password. This link expires in 1 hour. +

+ + + + +
+ + Reset Password + +
+

+ If you did not request a password reset, you can safely ignore this email. + Your password will not change until you click the link above. +

+
+

+ © ${new Date().getFullYear()} AssetsUp. All rights reserved. +

+
+
+ + +`; + +export const MAINTENANCE_DUE_TEMPLATE = ( + assetName: string, + dueDate: string, +): string => ` + + + + + + Maintenance Due Reminder + + + + + + +
+ + + + + + + + + + +
+

AssetsUp

+
+

⚠ Maintenance Due Reminder

+

+ This is a reminder that the following asset is due for maintenance: +

+ + + + +
+

ASSET NAME

+

${assetName}

+

MAINTENANCE DUE DATE

+

${dueDate}

+
+

+ Please schedule maintenance as soon as possible to avoid asset downtime or compliance issues. +

+
+

+ © ${new Date().getFullYear()} AssetsUp. All rights reserved. +

+
+
+ + +`; diff --git a/backend/src/opsce/opsce.module.ts b/backend/src/opsce/opsce.module.ts index 1f80c30f..57e95a38 100644 --- a/backend/src/opsce/opsce.module.ts +++ b/backend/src/opsce/opsce.module.ts @@ -5,13 +5,15 @@ import { AuditModule } from './audit/audit.module'; import { UsersModule } from './users/users.module'; import { LocationsModule } from './locations/locations.module'; import { StellarModule } from './stellar/stellar.module'; +import { MailerModule } from './mailer/mailer.module'; /** * OpsceModule * - * Aggregates all operational sub-modules: users, locations, audit, and - * departments. ConfigModule is already global (registered in AppModule), so - * every sub-module can inject ConfigService without re-importing it here. + * Aggregates all operational sub-modules: users, locations, audit, + * departments, assets, stellar, and mailer. + * ConfigModule is already global (registered in AppModule), so every + * sub-module can inject ConfigService without re-importing it here. */ @Module({ imports: [ @@ -21,15 +23,16 @@ import { StellarModule } from './stellar/stellar.module'; DepartmentsModule, AssetsModule, StellarModule, + MailerModule, ], exports: [ - AuthModule, UsersModule, LocationsModule, AuditModule, DepartmentsModule, AssetsModule, StellarModule, + MailerModule, ], }) -export class OpsceModule {} \ No newline at end of file +export class OpsceModule {}