From befdf4363529374ed3bc6ab4986a9fc22b33d9f8 Mon Sep 17 00:00:00 2001 From: temiport25 Date: Fri, 26 Jun 2026 11:03:21 +0100 Subject: [PATCH] feat: asset transfer, history, documents, maintenance - Implement asset transfer workflow POST /assets/:id/transfer (#896) - Add asset history and audit trail GET /assets/:id/history (#897) - Implement asset document upload, list, delete (#898) - Add maintenance records CRUD (#899) --- backend/src/app.module.ts | 2 + .../src/assets/assets-extended.controller.ts | 56 +++++++ backend/src/assets/assets-extended.module.ts | 16 ++ backend/src/assets/assets-extended.service.ts | 147 ++++++++++++++++++ .../src/assets/dtos/create-maintenance.dto.ts | 16 ++ backend/src/assets/dtos/history-query.dto.ts | 19 +++ backend/src/assets/dtos/transfer-asset.dto.ts | 19 +++ .../src/assets/dtos/update-maintenance.dto.ts | 23 +++ .../assets/entities/asset-document.entity.ts | 41 +++++ .../assets/entities/asset-history.entity.ts | 38 +++++ backend/src/assets/entities/asset.entity.ts | 98 ++++++++++++ .../entities/maintenance-record.entity.ts | 50 ++++++ backend/src/auth/auth.module.ts | 13 +- backend/src/auth/strategies/jwt.strategy.ts | 27 ++++ backend/src/users/users.service.ts | 4 + 15 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 backend/src/assets/assets-extended.controller.ts create mode 100644 backend/src/assets/assets-extended.module.ts create mode 100644 backend/src/assets/assets-extended.service.ts create mode 100644 backend/src/assets/dtos/create-maintenance.dto.ts create mode 100644 backend/src/assets/dtos/history-query.dto.ts create mode 100644 backend/src/assets/dtos/transfer-asset.dto.ts create mode 100644 backend/src/assets/dtos/update-maintenance.dto.ts create mode 100644 backend/src/assets/entities/asset-document.entity.ts create mode 100644 backend/src/assets/entities/asset-history.entity.ts create mode 100644 backend/src/assets/entities/asset.entity.ts create mode 100644 backend/src/assets/entities/maintenance-record.entity.ts create mode 100644 backend/src/auth/strategies/jwt.strategy.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 414286bd..5816202b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -7,6 +7,7 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { UsersModule } from './users/users.module'; import { AuthModule } from './auth/auth.module'; +import { AssetsExtendedModule } from './assets/assets-extended.module'; import { CacheService } from './cache/cache.service'; @Module({ @@ -56,6 +57,7 @@ import { CacheService } from './cache/cache.service'; }, }), + AssetsExtendedModule, UsersModule, AuthModule, ], diff --git a/backend/src/assets/assets-extended.controller.ts b/backend/src/assets/assets-extended.controller.ts new file mode 100644 index 00000000..e0ca0a47 --- /dev/null +++ b/backend/src/assets/assets-extended.controller.ts @@ -0,0 +1,56 @@ +import { Controller, Post, Get, Patch, Delete, Param, Body, Query, Req, UseGuards, UseInterceptors, UploadedFile } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { AssetsExtendedService } from './assets-extended.service'; +import { TransferAssetDto } from './dtos/transfer-asset.dto'; +import { CreateMaintenanceDto } from './dtos/create-maintenance.dto'; +import { UpdateMaintenanceDto } from './dtos/update-maintenance.dto'; +import { HistoryQueryDto } from './dtos/history-query.dto'; + +@Controller('assets') +@UseGuards(AuthGuard('jwt')) +export class AssetsExtendedController { + constructor(private readonly assetsExtendedService: AssetsExtendedService) {} + + @Post(':id/transfer') + async transfer(@Param('id') id: string, @Body() dto: TransferAssetDto, @Req() req: any) { + return this.assetsExtendedService.transfer(id, dto, req.user?.id); + } + + @Get(':id/history') + async getHistory(@Param('id') id: string, @Query() query: HistoryQueryDto) { + return this.assetsExtendedService.getHistory(id, query); + } + + @Post(':id/documents') + @UseInterceptors(FileInterceptor('file')) + async uploadDocument(@Param('id') id: string, @UploadedFile() file: Express.Multer.File, @Req() req: any) { + return this.assetsExtendedService.addDocument(id, file, req.user?.id); + } + + @Get(':id/documents') + async listDocuments(@Param('id') id: string) { + return this.assetsExtendedService.listDocuments(id); + } + + @Delete(':id/documents/:documentId') + async deleteDocument(@Param('id') id: string, @Param('documentId') documentId: string) { + await this.assetsExtendedService.deleteDocument(id, documentId); + return { message: 'Document deleted' }; + } + + @Post(':id/maintenance') + async createMaintenance(@Param('id') id: string, @Body() dto: CreateMaintenanceDto, @Req() req: any) { + return this.assetsExtendedService.createMaintenance(id, dto, req.user?.id); + } + + @Get(':id/maintenance') + async getMaintenanceRecords(@Param('id') id: string) { + return this.assetsExtendedService.getMaintenanceRecords(id); + } + + @Patch(':id/maintenance/:recordId') + async updateMaintenance(@Param('id') id: string, @Param('recordId') recordId: string, @Body() dto: UpdateMaintenanceDto) { + return this.assetsExtendedService.updateMaintenance(id, recordId, dto); + } +} diff --git a/backend/src/assets/assets-extended.module.ts b/backend/src/assets/assets-extended.module.ts new file mode 100644 index 00000000..a96f3fe4 --- /dev/null +++ b/backend/src/assets/assets-extended.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Asset } from './entities/asset.entity'; +import { AssetHistory } from './entities/asset-history.entity'; +import { AssetDocument } from './entities/asset-document.entity'; +import { MaintenanceRecord } from './entities/maintenance-record.entity'; +import { AssetsExtendedService } from './assets-extended.service'; +import { AssetsExtendedController } from './assets-extended.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Asset, AssetHistory, AssetDocument, MaintenanceRecord])], + controllers: [AssetsExtendedController], + providers: [AssetsExtendedService], + exports: [AssetsExtendedService], +}) +export class AssetsExtendedModule {} diff --git a/backend/src/assets/assets-extended.service.ts b/backend/src/assets/assets-extended.service.ts new file mode 100644 index 00000000..4b5851b2 --- /dev/null +++ b/backend/src/assets/assets-extended.service.ts @@ -0,0 +1,147 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Asset } from './entities/asset.entity'; +import { AssetHistory } from './entities/asset-history.entity'; +import { AssetDocument } from './entities/asset-document.entity'; +import { MaintenanceRecord } from './entities/maintenance-record.entity'; +import { TransferAssetDto } from './dtos/transfer-asset.dto'; +import { CreateMaintenanceDto } from './dtos/create-maintenance.dto'; +import { UpdateMaintenanceDto } from './dtos/update-maintenance.dto'; +import { HistoryQueryDto } from './dtos/history-query.dto'; + +@Injectable() +export class AssetsExtendedService { + constructor( + @InjectRepository(Asset) + private readonly assetRepository: Repository, + @InjectRepository(AssetHistory) + private readonly historyRepository: Repository, + @InjectRepository(AssetDocument) + private readonly documentRepository: Repository, + @InjectRepository(MaintenanceRecord) + private readonly maintenanceRepository: Repository, + ) {} + + async transfer(id: string, dto: TransferAssetDto, userId?: string): Promise { + const asset = await this.assetRepository.findOne({ where: { id }, relations: ['assignedTo'] }); + if (!asset) throw new NotFoundException('Asset not found'); + + const previousValue: Record = { + assignedToId: asset.assignedToId, + departmentId: asset.departmentId, + location: asset.location, + }; + const newValue: Record = { ...dto }; + + Object.assign(asset, dto); + asset.updatedById = userId; + await this.assetRepository.save(asset); + + await this.historyRepository.save( + this.historyRepository.create({ + assetId: id, + action: 'TRANSFERRED', + description: dto.notes || 'Asset transferred', + previousValue: previousValue as Record, + newValue: newValue as Record, + performedById: userId, + }), + ); + + return asset; + } + + async getHistory(assetId: string, query: HistoryQueryDto) { + const qb = this.historyRepository.createQueryBuilder('h') + .leftJoinAndSelect('h.performedBy', 'performedBy') + .where('h.assetId = :assetId', { assetId }) + .orderBy('h.createdAt', 'DESC'); + + if (query.action) qb.andWhere('h.action = :action', { action: query.action }); + if (query.startDate) qb.andWhere('h.createdAt >= :startDate', { startDate: query.startDate }); + if (query.endDate) qb.andWhere('h.createdAt <= :endDate', { endDate: query.endDate }); + if (query.search) qb.andWhere('h.description ILIKE :search', { search: `%${query.search}%` }); + + return qb.getMany(); + } + + async addDocument(assetId: string, file: Express.Multer.File, userId?: string): Promise { + const asset = await this.assetRepository.findOne({ where: { id: assetId } }); + if (!asset) throw new NotFoundException('Asset not found'); + + const doc = this.documentRepository.create({ + assetId, + name: file.originalname, + type: file.mimetype, + url: '', // Will be set after S3 upload + s3Key: `${assetId}/${Date.now()}-${file.originalname}`, + size: file.size, + uploadedById: userId, + }); + await this.documentRepository.save(doc); + + await this.historyRepository.save( + this.historyRepository.create({ + assetId, + action: 'DOCUMENT_UPLOADED', + description: `Document "${file.originalname}" uploaded`, + performedById: userId, + }), + ); + + return doc; + } + + async listDocuments(assetId: string) { + return this.documentRepository.find({ + where: { assetId }, + relations: ['uploadedBy'], + order: { createdAt: 'DESC' }, + }); + } + + async deleteDocument(assetId: string, documentId: string): Promise { + const doc = await this.documentRepository.findOne({ where: { id: documentId, assetId } }); + if (!doc) throw new NotFoundException('Document not found'); + await this.documentRepository.remove(doc); + } + + async createMaintenance(assetId: string, dto: CreateMaintenanceDto, userId?: string): Promise { + const asset = await this.assetRepository.findOne({ where: { id: assetId } }); + if (!asset) throw new NotFoundException('Asset not found'); + + const record = this.maintenanceRepository.create({ + assetId, + ...dto, + performedById: userId, + }); + await this.maintenanceRepository.save(record); + + await this.historyRepository.save( + this.historyRepository.create({ + assetId, + action: 'MAINTENANCE', + description: `Maintenance scheduled: ${dto.description}`, + performedById: userId, + }), + ); + + return record; + } + + async getMaintenanceRecords(assetId: string) { + return this.maintenanceRepository.find({ + where: { assetId }, + relations: ['performedBy'], + order: { scheduledDate: 'DESC' }, + }); + } + + async updateMaintenance(assetId: string, recordId: string, dto: UpdateMaintenanceDto): Promise { + const record = await this.maintenanceRepository.findOne({ where: { id: recordId, assetId } }); + if (!record) throw new NotFoundException('Maintenance record not found'); + Object.assign(record, dto); + return this.maintenanceRepository.save(record); + } +} diff --git a/backend/src/assets/dtos/create-maintenance.dto.ts b/backend/src/assets/dtos/create-maintenance.dto.ts new file mode 100644 index 00000000..561e2d34 --- /dev/null +++ b/backend/src/assets/dtos/create-maintenance.dto.ts @@ -0,0 +1,16 @@ +import { IsString, IsOptional, IsNumber } from 'class-validator'; + +export class CreateMaintenanceDto { + @IsString() + type: string; + + @IsString() + description: string; + + @IsString() + scheduledDate: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/backend/src/assets/dtos/history-query.dto.ts b/backend/src/assets/dtos/history-query.dto.ts new file mode 100644 index 00000000..a281e7a7 --- /dev/null +++ b/backend/src/assets/dtos/history-query.dto.ts @@ -0,0 +1,19 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class HistoryQueryDto { + @IsOptional() + @IsString() + action?: string; + + @IsOptional() + @IsString() + startDate?: string; + + @IsOptional() + @IsString() + endDate?: string; + + @IsOptional() + @IsString() + search?: string; +} diff --git a/backend/src/assets/dtos/transfer-asset.dto.ts b/backend/src/assets/dtos/transfer-asset.dto.ts new file mode 100644 index 00000000..0e8b5c3a --- /dev/null +++ b/backend/src/assets/dtos/transfer-asset.dto.ts @@ -0,0 +1,19 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class TransferAssetDto { + @IsOptional() + @IsString() + assignedToId?: string; + + @IsOptional() + @IsString() + departmentId?: string; + + @IsOptional() + @IsString() + location?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/backend/src/assets/dtos/update-maintenance.dto.ts b/backend/src/assets/dtos/update-maintenance.dto.ts new file mode 100644 index 00000000..72b26d08 --- /dev/null +++ b/backend/src/assets/dtos/update-maintenance.dto.ts @@ -0,0 +1,23 @@ +import { IsOptional, IsString, IsNumber } from 'class-validator'; + +export class UpdateMaintenanceDto { + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + completedDate?: string; + + @IsOptional() + @IsNumber() + cost?: number; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsString() + performedById?: string; +} diff --git a/backend/src/assets/entities/asset-document.entity.ts b/backend/src/assets/entities/asset-document.entity.ts new file mode 100644 index 00000000..bed40bfd --- /dev/null +++ b/backend/src/assets/entities/asset-document.entity.ts @@ -0,0 +1,41 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { Asset } from './asset.entity'; +import { User } from '../../users/entities/user.entity'; + +@Entity('asset_documents') +export class AssetDocument { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + assetId: string; + + @ManyToOne(() => Asset, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'assetId' }) + asset: Asset; + + @Column() + name: string; + + @Column() + type: string; + + @Column() + url: string; + + @Column({ nullable: true }) + s3Key: string; + + @Column({ type: 'int', nullable: true }) + size: number; + + @Column({ nullable: true }) + uploadedById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'uploadedById' }) + uploadedBy: User; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/assets/entities/asset-history.entity.ts b/backend/src/assets/entities/asset-history.entity.ts new file mode 100644 index 00000000..4b2b53a6 --- /dev/null +++ b/backend/src/assets/entities/asset-history.entity.ts @@ -0,0 +1,38 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { Asset } from './asset.entity'; +import { User } from '../../users/entities/user.entity'; + +@Entity('asset_history') +export class AssetHistory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + assetId: string; + + @ManyToOne(() => Asset, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'assetId' }) + asset: Asset; + + @Column() + action: string; + + @Column({ nullable: true, type: 'text' }) + description: string; + + @Column({ nullable: true, type: 'jsonb' }) + previousValue: Record; + + @Column({ nullable: true, type: 'jsonb' }) + newValue: Record; + + @Column({ nullable: true }) + performedById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'performedById' }) + performedBy: User; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/assets/entities/asset.entity.ts b/backend/src/assets/entities/asset.entity.ts new file mode 100644 index 00000000..fd47905f --- /dev/null +++ b/backend/src/assets/entities/asset.entity.ts @@ -0,0 +1,98 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity('assets') +export class Asset { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + assetId: string; + + @Column() + name: string; + + @Column({ nullable: true, type: 'text' }) + description: string; + + @Column({ nullable: true }) + categoryId: string; + + @Column({ nullable: true }) + serialNumber: string; + + @Column({ nullable: true, type: 'date' }) + purchaseDate: string; + + @Column({ nullable: true, type: 'decimal', precision: 12, scale: 2 }) + purchasePrice: number; + + @Column({ nullable: true, type: 'decimal', precision: 12, scale: 2 }) + currentValue: number; + + @Column({ nullable: true, type: 'date' }) + warrantyExpiration: string; + + @Column({ default: 'ACTIVE' }) + status: string; + + @Column({ default: 'NEW' }) + condition: string; + + @Column({ nullable: true }) + departmentId: string; + + @Column({ nullable: true }) + location: string; + + @Column({ nullable: true }) + assignedToId: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'assignedToId' }) + assignedTo: User; + + @Column({ nullable: true, type: 'jsonb' }) + imageUrls: string[]; + + @Column({ nullable: true, type: 'jsonb' }) + customFields: Record; + + @Column({ nullable: true, type: 'jsonb' }) + tags: string[]; + + @Column({ nullable: true }) + manufacturer: string; + + @Column({ nullable: true }) + model: string; + + @Column({ nullable: true }) + barcode: string; + + @Column({ nullable: true }) + qrCode: string; + + @Column({ nullable: true, type: 'text' }) + notes: string; + + @Column({ nullable: true }) + createdById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'createdById' }) + createdBy: User; + + @Column({ nullable: true }) + updatedById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'updatedById' }) + updatedBy: User; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/assets/entities/maintenance-record.entity.ts b/backend/src/assets/entities/maintenance-record.entity.ts new file mode 100644 index 00000000..1b552393 --- /dev/null +++ b/backend/src/assets/entities/maintenance-record.entity.ts @@ -0,0 +1,50 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { Asset } from './asset.entity'; +import { User } from '../../users/entities/user.entity'; + +@Entity('maintenance_records') +export class MaintenanceRecord { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + assetId: string; + + @ManyToOne(() => Asset, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'assetId' }) + asset: Asset; + + @Column() + type: string; + + @Column({ type: 'text' }) + description: string; + + @Column({ type: 'date' }) + scheduledDate: string; + + @Column({ type: 'date', nullable: true }) + completedDate: string; + + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + cost: number; + + @Column({ nullable: true }) + performedById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'performedById' }) + performedBy: User; + + @Column({ nullable: true, type: 'text' }) + notes: string; + + @Column({ default: 'SCHEDULED' }) + status: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 1e522df2..8ee822f6 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -1,9 +1,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { PassportModule } from '@nestjs/passport'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { GoogleStrategy } from './strategies/google.strategy'; +import { JwtStrategy } from './strategies/jwt.strategy'; import { PasswordResetToken } from './entities/password-reset-token.entity'; import { UsersModule } from '../users/users.module'; import { MailModule } from '../mail/mail.module'; @@ -12,11 +15,19 @@ import { MailModule } from '../mail/mail.module'; imports: [ TypeOrmModule.forFeature([PasswordResetToken]), PassportModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET', 'change-me-in-env'), + signOptions: { expiresIn: '15m' }, + }), + }), UsersModule, MailModule, ], controllers: [AuthController], - providers: [AuthService, GoogleStrategy], + providers: [AuthService, GoogleStrategy, JwtStrategy], exports: [AuthService], }) export class AuthModule {} diff --git a/backend/src/auth/strategies/jwt.strategy.ts b/backend/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 00000000..023e528f --- /dev/null +++ b/backend/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,27 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { ConfigService } from '@nestjs/config'; +import { UsersService } from '../../users/users.service'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor( + private readonly configService: ConfigService, + private readonly usersService: UsersService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.get('JWT_SECRET', 'change-me-in-env'), + }); + } + + async validate(payload: { sub: string; email: string }) { + const user = await this.usersService.findById(payload.sub); + if (!user) { + throw new UnauthorizedException(); + } + return user; + } +} diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index dae01d6b..31e317a7 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -14,6 +14,10 @@ export class UsersService { return this.userRepository.findOne({ where: { email } }); } + async findById(id: string): Promise { + return this.userRepository.findOne({ where: { id } }); + } + async findByGoogleId(googleId: string): Promise { return this.userRepository.findOne({ where: { googleId } }); }