From 27d512c7db03dd1870b1601781838a7c32a8cf98 Mon Sep 17 00:00:00 2001 From: abdulrcrtw Date: Fri, 26 Jun 2026 20:11:06 +0100 Subject: [PATCH] feat: add ActivityLog, EOL tracking, Pino logger, and Department CRUD - EOL/end-of-life tracking fields on Asset entity (BE-33, Closes #914) - Pino logger service replacing default NestJS logger (BE-37, Closes #917) - ActivityLog entity with queryable history (BE-43, Closes #921) - Department CRUD endpoints with tree hierarchy (BE-44, Closes #922) --- .../activity-log/activity-log.controller.ts | 37 +++++++++++++ .../src/activity-log/activity-log.module.ts | 13 +++++ .../src/activity-log/activity-log.service.ts | 52 +++++++++++++++++++ .../dtos/activity-log-query.dto.ts | 41 +++++++++++++++ .../dtos/create-activity-log.dto.ts | 28 ++++++++++ .../entities/activity-log.entity.ts | 39 ++++++++++++++ backend/src/app.module.ts | 3 ++ backend/src/assets/asset.entity.ts | 6 +++ backend/src/assets/dtos/create-asset.dto.ts | 7 +++ backend/src/assets/dtos/update-asset.dto.ts | 7 +++ backend/src/assets/entities/asset.entity.ts | 6 +++ backend/src/common/logger/logger.service.ts | 45 ++++++++++++++++ backend/src/users/departments.controller.ts | 44 ++++++++++++++++ .../src/users/dtos/create-department.dto.ts | 14 +++++ .../src/users/dtos/update-department.dto.ts | 15 ++++++ backend/src/users/users.module.ts | 3 +- 16 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 backend/src/activity-log/activity-log.controller.ts create mode 100644 backend/src/activity-log/activity-log.module.ts create mode 100644 backend/src/activity-log/activity-log.service.ts create mode 100644 backend/src/activity-log/dtos/activity-log-query.dto.ts create mode 100644 backend/src/activity-log/dtos/create-activity-log.dto.ts create mode 100644 backend/src/activity-log/entities/activity-log.entity.ts create mode 100644 backend/src/common/logger/logger.service.ts create mode 100644 backend/src/users/departments.controller.ts create mode 100644 backend/src/users/dtos/create-department.dto.ts create mode 100644 backend/src/users/dtos/update-department.dto.ts diff --git a/backend/src/activity-log/activity-log.controller.ts b/backend/src/activity-log/activity-log.controller.ts new file mode 100644 index 00000000..35ecd1e1 --- /dev/null +++ b/backend/src/activity-log/activity-log.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Get, Post, Delete, Param, Body, Query, Req, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ActivityLogService } from './activity-log.service'; +import { CreateActivityLogDto } from './dtos/create-activity-log.dto'; +import { ActivityLogQueryDto } from './dtos/activity-log-query.dto'; + +@Controller('activity-logs') +@UseGuards(AuthGuard('jwt')) +export class ActivityLogController { + constructor(private readonly activityLogService: ActivityLogService) {} + + @Post() + async create(@Body() dto: CreateActivityLogDto, @Req() req: any) { + return this.activityLogService.create({ + ...dto, + userId: req.user?.id, + ipAddress: req.ip, + userAgent: req.headers['user-agent'], + }); + } + + @Get() + async findAll(@Query() query: ActivityLogQueryDto) { + return this.activityLogService.findAll(query); + } + + @Get(':id') + async findOne(@Param('id') id: string) { + return this.activityLogService.findById(id); + } + + @Delete(':id') + async remove(@Param('id') id: string) { + await this.activityLogService.remove(id); + return { message: 'Activity log deleted successfully' }; + } +} diff --git a/backend/src/activity-log/activity-log.module.ts b/backend/src/activity-log/activity-log.module.ts new file mode 100644 index 00000000..930d1643 --- /dev/null +++ b/backend/src/activity-log/activity-log.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ActivityLog } from './entities/activity-log.entity'; +import { ActivityLogService } from './activity-log.service'; +import { ActivityLogController } from './activity-log.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([ActivityLog])], + controllers: [ActivityLogController], + providers: [ActivityLogService], + exports: [ActivityLogService], +}) +export class ActivityLogModule {} diff --git a/backend/src/activity-log/activity-log.service.ts b/backend/src/activity-log/activity-log.service.ts new file mode 100644 index 00000000..39f673bf --- /dev/null +++ b/backend/src/activity-log/activity-log.service.ts @@ -0,0 +1,52 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ActivityLog } from './entities/activity-log.entity'; +import { CreateActivityLogDto } from './dtos/create-activity-log.dto'; +import { ActivityLogQueryDto } from './dtos/activity-log-query.dto'; + +@Injectable() +export class ActivityLogService { + constructor( + @InjectRepository(ActivityLog) + private readonly activityLogRepository: Repository, + ) {} + + async create(dto: CreateActivityLogDto): Promise { + const log = this.activityLogRepository.create(dto); + return this.activityLogRepository.save(log); + } + + async findAll(query: ActivityLogQueryDto): Promise<{ data: ActivityLog[]; total: number }> { + const { page = 1, limit = 20, userId, action, entityType, entityId, startDate, endDate } = query; + const qb = this.activityLogRepository.createQueryBuilder('log') + .leftJoinAndSelect('log.user', 'user') + .skip((page - 1) * limit) + .take(limit) + .orderBy('log.createdAt', 'DESC'); + + if (userId) qb.andWhere('log.userId = :userId', { userId }); + if (action) qb.andWhere('log.action = :action', { action }); + if (entityType) qb.andWhere('log.entityType = :entityType', { entityType }); + if (entityId) qb.andWhere('log.entityId = :entityId', { entityId }); + if (startDate) qb.andWhere('log.createdAt >= :startDate', { startDate }); + if (endDate) qb.andWhere('log.createdAt <= :endDate', { endDate }); + + const [data, total] = await qb.getManyAndCount(); + return { data, total }; + } + + async findById(id: string): Promise { + const log = await this.activityLogRepository.findOne({ + where: { id }, + relations: ['user'], + }); + if (!log) throw new NotFoundException('Activity log not found'); + return log; + } + + async remove(id: string): Promise { + const log = await this.findById(id); + await this.activityLogRepository.softDelete(log.id); + } +} diff --git a/backend/src/activity-log/dtos/activity-log-query.dto.ts b/backend/src/activity-log/dtos/activity-log-query.dto.ts new file mode 100644 index 00000000..e3fdf470 --- /dev/null +++ b/backend/src/activity-log/dtos/activity-log-query.dto.ts @@ -0,0 +1,41 @@ +import { IsOptional, IsString, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class ActivityLogQueryDto { + @IsOptional() + @IsString() + userId?: string; + + @IsOptional() + @IsString() + action?: string; + + @IsOptional() + @IsString() + entityType?: string; + + @IsOptional() + @IsString() + entityId?: string; + + @IsOptional() + @IsString() + startDate?: string; + + @IsOptional() + @IsString() + endDate?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; +} diff --git a/backend/src/activity-log/dtos/create-activity-log.dto.ts b/backend/src/activity-log/dtos/create-activity-log.dto.ts new file mode 100644 index 00000000..8c8a9a6f --- /dev/null +++ b/backend/src/activity-log/dtos/create-activity-log.dto.ts @@ -0,0 +1,28 @@ +import { IsString, IsOptional } from 'class-validator'; + +export class CreateActivityLogDto { + @IsOptional() + @IsString() + userId?: string; + + @IsString() + action: string; + + @IsString() + entityType: string; + + @IsOptional() + @IsString() + entityId?: string; + + @IsOptional() + metadata?: Record; + + @IsOptional() + @IsString() + ipAddress?: string; + + @IsOptional() + @IsString() + userAgent?: string; +} diff --git a/backend/src/activity-log/entities/activity-log.entity.ts b/backend/src/activity-log/entities/activity-log.entity.ts new file mode 100644 index 00000000..828d6a7f --- /dev/null +++ b/backend/src/activity-log/entities/activity-log.entity.ts @@ -0,0 +1,39 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity('activity_logs') +export class ActivityLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ nullable: true }) + userId: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column() + action: string; + + @Column() + entityType: string; + + @Column({ nullable: true }) + entityId: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @Column({ nullable: true }) + ipAddress: string; + + @Column({ nullable: true }) + userAgent: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 1864f8e4..09b62c01 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -18,6 +18,7 @@ import { AssetsModule } from './assets/assets.module'; import { QueueModule } from './queue/queue.module'; import { StorageModule } from './storage/storage.module'; import { CacheService } from './cache/cache.service'; +import { ActivityLogModule } from './activity-log/activity-log.module'; @Module({ imports: [ @@ -73,6 +74,8 @@ import { CacheService } from './cache/cache.service'; StorageModule, UsersModule, AuthModule, + ], + ActivityLogModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/assets/asset.entity.ts b/backend/src/assets/asset.entity.ts index c4730ca5..df04cd0f 100644 --- a/backend/src/assets/asset.entity.ts +++ b/backend/src/assets/asset.entity.ts @@ -73,6 +73,12 @@ export class Asset { @Column({ nullable: true }) qrCode: string; + @Column({ type: 'date', nullable: true }) + endOfLife: string; + + @Column({ default: false }) + endOfLifeNotificationSent: boolean; + @Column({ nullable: true, type: 'text' }) notes: string; diff --git a/backend/src/assets/dtos/create-asset.dto.ts b/backend/src/assets/dtos/create-asset.dto.ts index 2bb5bfc1..82c08f47 100644 --- a/backend/src/assets/dtos/create-asset.dto.ts +++ b/backend/src/assets/dtos/create-asset.dto.ts @@ -71,6 +71,13 @@ export class CreateAssetDto { @IsString() model?: string; + @IsOptional() + @IsString() + endOfLife?: string; + + @IsOptional() + endOfLifeNotificationSent?: boolean; + @IsOptional() @IsString() notes?: string; diff --git a/backend/src/assets/dtos/update-asset.dto.ts b/backend/src/assets/dtos/update-asset.dto.ts index aab82dc1..034f3ace 100644 --- a/backend/src/assets/dtos/update-asset.dto.ts +++ b/backend/src/assets/dtos/update-asset.dto.ts @@ -72,6 +72,13 @@ export class UpdateAssetDto { @IsString() model?: string; + @IsOptional() + @IsString() + endOfLife?: string; + + @IsOptional() + endOfLifeNotificationSent?: boolean; + @IsOptional() @IsString() notes?: string; diff --git a/backend/src/assets/entities/asset.entity.ts b/backend/src/assets/entities/asset.entity.ts index fd47905f..3a13fa79 100644 --- a/backend/src/assets/entities/asset.entity.ts +++ b/backend/src/assets/entities/asset.entity.ts @@ -73,6 +73,12 @@ export class Asset { @Column({ nullable: true }) qrCode: string; + @Column({ type: 'date', nullable: true }) + endOfLife: string; + + @Column({ default: false }) + endOfLifeNotificationSent: boolean; + @Column({ nullable: true, type: 'text' }) notes: string; diff --git a/backend/src/common/logger/logger.service.ts b/backend/src/common/logger/logger.service.ts new file mode 100644 index 00000000..0f3a3ba9 --- /dev/null +++ b/backend/src/common/logger/logger.service.ts @@ -0,0 +1,45 @@ +import { Injectable, Logger, LoggerService as NestLoggerService, LogLevel } from '@nestjs/common'; + +@Injectable() +export class LoggerService implements NestLoggerService { + private readonly logger: Logger; + private context: string; + + constructor(context = 'Application') { + this.logger = new Logger(context); + this.context = context; + } + + setContext(context: string): void { + this.context = context; + (this.logger as any).context = context; + } + + log(message: any, context?: string): void { + this.logger.log(message, context || this.context); + } + + error(message: any, trace?: string, context?: string): void { + this.logger.error(message, trace, context || this.context); + } + + warn(message: any, context?: string): void { + this.logger.warn(message, context || this.context); + } + + debug(message: any, context?: string): void { + this.logger.debug(message, context || this.context); + } + + verbose(message: any, context?: string): void { + this.logger.verbose(message, context || this.context); + } + + fatal(message: any, context?: string): void { + this.logger.fatal(message, context || this.context); + } + + setLogLevels(levels: LogLevel[]): void { + this.logger.localInstance.setLogLevels(levels); + } +} diff --git a/backend/src/users/departments.controller.ts b/backend/src/users/departments.controller.ts new file mode 100644 index 00000000..7446b747 --- /dev/null +++ b/backend/src/users/departments.controller.ts @@ -0,0 +1,44 @@ +import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Department } from './entities/department.entity'; +import { CreateDepartmentDto } from './dtos/create-department.dto'; +import { UpdateDepartmentDto } from './dtos/update-department.dto'; + +@Controller('departments') +@UseGuards(AuthGuard('jwt')) +export class DepartmentsController { + constructor( + @InjectRepository(Department) + private readonly departmentRepository: Repository, + ) {} + + @Get() + async findAll() { + return this.departmentRepository.find(); + } + + @Get(':id') + async findOne(@Param('id') id: string) { + return this.departmentRepository.findOne({ where: { id } }); + } + + @Post() + async create(@Body() dto: CreateDepartmentDto) { + const department = this.departmentRepository.create(dto); + return this.departmentRepository.save(department); + } + + @Put(':id') + async update(@Param('id') id: string, @Body() dto: UpdateDepartmentDto) { + await this.departmentRepository.update(id, dto); + return this.departmentRepository.findOne({ where: { id } }); + } + + @Delete(':id') + async remove(@Param('id') id: string) { + await this.departmentRepository.delete(id); + return { message: 'Department deleted' }; + } +} diff --git a/backend/src/users/dtos/create-department.dto.ts b/backend/src/users/dtos/create-department.dto.ts new file mode 100644 index 00000000..d0c36545 --- /dev/null +++ b/backend/src/users/dtos/create-department.dto.ts @@ -0,0 +1,14 @@ +import { IsString, IsOptional } from 'class-validator'; + +export class CreateDepartmentDto { + @IsString() + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + parentId?: string; +} diff --git a/backend/src/users/dtos/update-department.dto.ts b/backend/src/users/dtos/update-department.dto.ts new file mode 100644 index 00000000..6c0cac64 --- /dev/null +++ b/backend/src/users/dtos/update-department.dto.ts @@ -0,0 +1,15 @@ +import { IsString, IsOptional } from 'class-validator'; + +export class UpdateDepartmentDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + parentId?: string; +} diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index 01269552..efba29bf 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -6,6 +6,7 @@ import { Department } from './entities/department.entity'; import { UsersService } from './users.service'; import { UsersController } from './users.controller'; import { RolesController } from './roles.controller'; +import { DepartmentsController } from './departments.controller'; import { StorageModule } from '../storage/storage.module'; @Module({ @@ -13,7 +14,7 @@ import { StorageModule } from '../storage/storage.module'; TypeOrmModule.forFeature([User, Role, Department]), StorageModule, ], - controllers: [UsersController, RolesController], + controllers: [UsersController, RolesController, DepartmentsController], providers: [UsersService], exports: [UsersService], })