diff --git a/backend/package-lock.json b/backend/package-lock.json index fc3a1a5..66af251 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -21,6 +21,7 @@ "@nestjs/swagger": "^7.3.0", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^10.0.2", + "@nestjs/websockets": "^10.4.15", "@types/multer": "^2.0.0", "@types/speakeasy": "^2.0.10", "@types/uuid": "^10.0.0", @@ -1963,6 +1964,29 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/@nestjs/websockets": { + "version": "10.4.15", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.15.tgz", + "integrity": "sha512-OmCUJwvtagzXfMVko595O98UI3M9zg+URL+/HV7vd3QPMCZ3uGCKSq15YYJ99LHJn9NyK4e4Szm2KnHtUg2QzA==", + "license": "MIT", + "dependencies": { + "iterare": "1.2.1", + "object-hash": "3.0.0", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/platform-socket.io": "^10.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/platform-socket.io": { + "optional": true + } + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -2218,6 +2242,12 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@sqltools/formatter": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", @@ -2360,6 +2390,15 @@ "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", "dev": true }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -3504,6 +3543,15 @@ } ] }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz", @@ -4517,6 +4565,44 @@ "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", + "integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -7474,6 +7560,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -8959,6 +9054,64 @@ "node": ">=8" } }, + "node_modules/socket.io": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -10421,6 +10574,27 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index dec37fd..7d861ce 100644 --- a/backend/package.json +++ b/backend/package.json @@ -32,6 +32,7 @@ "@nestjs/swagger": "^7.3.0", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^10.0.2", + "@nestjs/websockets": "^10.4.15", "@types/multer": "^2.0.0", "@types/speakeasy": "^2.0.10", "@types/uuid": "^10.0.0", @@ -55,6 +56,7 @@ "redis": "^5.10.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", + "socket.io": "^4.8.1", "speakeasy": "^2.0.0", "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.27" diff --git a/backend/src/asset-transfers/asset-transfers.controller.ts b/backend/src/asset-transfers/asset-transfers.controller.ts new file mode 100644 index 0000000..18fc8ac --- /dev/null +++ b/backend/src/asset-transfers/asset-transfers.controller.ts @@ -0,0 +1,93 @@ +import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common'; +import { AssetTransfersService } from './asset-transfers.service'; +import { CreateTransferDto } from './dto/create-transfer.dto'; +import { ApproveTransferDto, RejectTransferDto } from './dto/approve-transfer.dto'; +import { TransferFilterDto } from './dto/transfer-filter.dto'; + +// Mock decorator since we don't have auth implemented yet +const AuthGuard = () => { + return (target: any, key?: string | symbol, descriptor?: PropertyDescriptor) => { + // Mock implementation - in real auth, this would validate JWT + }; +}; +const GetUser = () => { + return (target: any, propertyKey: string, parameterIndex: number) => { + // Mock implementation - in real auth, this would extract user from request + }; +}; + +@Controller('transfers') +export class AssetTransfersController { + constructor(private readonly assetTransfersService: AssetTransfersService) {} + + @Post() + @UseGuards(AuthGuard()) + async createTransfer( + @Body() createTransferDto: CreateTransferDto, + @GetUser() user: any + ) { + return await this.assetTransfersService.createTransfer(createTransferDto, user.id); + } + + @Get() + @UseGuards(AuthGuard()) + async getTransfers( + @Query() filterDto: TransferFilterDto, + @GetUser() user: any + ) { + return await this.assetTransfersService.getTransfers(filterDto, user.id); + } + + @Get(':id') + @UseGuards(AuthGuard()) + async getTransferById(@Param('id') id: string) { + return await this.assetTransfersService.getTransferById(id); + } + + @Put(':id/approve') + @UseGuards(AuthGuard()) + async approveTransfer( + @Param('id') id: string, + @Body() approveDto: ApproveTransferDto, + @GetUser() user: any + ) { + return await this.assetTransfersService.approveTransfer(id, { + ...approveDto, + approvedById: user.id + }); + } + + @Put(':id/reject') + @UseGuards(AuthGuard()) + async rejectTransfer( + @Param('id') id: string, + @Body() rejectDto: RejectTransferDto, + @GetUser() user: any + ) { + return await this.assetTransfersService.rejectTransfer(id, { + ...rejectDto, + rejectedById: user.id + }); + } + + @Delete(':id') + @UseGuards(AuthGuard()) + async cancelTransfer(@Param('id') id: string, @GetUser() user: any) { + return await this.assetTransfersService.cancelTransfer(id, user.id); + } + + @Get('notifications') + @UseGuards(AuthGuard()) + async getNotifications(@GetUser() user: any) { + return await this.assetTransfersService.getNotifications(user.id); + } + + @Put('notifications/:id/read') + @UseGuards(AuthGuard()) + async markNotificationAsRead( + @Param('id') notificationId: string, + @GetUser() user: any + ) { + return await this.assetTransfersService.markNotificationAsRead(notificationId, user.id); + } +} \ No newline at end of file diff --git a/backend/src/asset-transfers/asset-transfers.module.ts b/backend/src/asset-transfers/asset-transfers.module.ts new file mode 100644 index 0000000..e0686e4 --- /dev/null +++ b/backend/src/asset-transfers/asset-transfers.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AssetTransfersController } from './asset-transfers.controller'; +import { AssetTransfersService } from './asset-transfers.service'; +import { AssetTransfer } from './entities/asset-transfer.entity'; +import { Notification } from './entities/notification.entity'; +import { TransferHistory } from './entities/transfer-history.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([AssetTransfer, Notification, TransferHistory]), + ], + controllers: [AssetTransfersController], + providers: [AssetTransfersService], + exports: [AssetTransfersService], +}) +export class AssetTransfersModule {} \ No newline at end of file diff --git a/backend/src/asset-transfers/asset-transfers.service.ts b/backend/src/asset-transfers/asset-transfers.service.ts new file mode 100644 index 0000000..9ebf62e --- /dev/null +++ b/backend/src/asset-transfers/asset-transfers.service.ts @@ -0,0 +1,299 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThanOrEqual } from 'typeorm'; +import { AssetTransfer, TransferStatus, TransferType } from './entities/asset-transfer.entity'; +import { Notification, NotificationType, NotificationPriority } from './entities/notification.entity'; +import { TransferHistory, HistoryAction } from './entities/transfer-history.entity'; +import { CreateTransferDto } from './dto/create-transfer.dto'; +import { ApproveTransferDto, RejectTransferDto } from './dto/approve-transfer.dto'; +import { TransferFilterDto } from './dto/transfer-filter.dto'; + +@Injectable() +export class AssetTransfersService { + constructor( + @InjectRepository(AssetTransfer) + private readonly transferRepository: Repository, + @InjectRepository(Notification) + private readonly notificationRepository: Repository, + @InjectRepository(TransferHistory) + private readonly historyRepository: Repository, + ) {} + + async createTransfer(createTransferDto: CreateTransferDto, userId: string): Promise { + // Validate that assets exist and user has permission + // This would integrate with your asset service + + const transfer = this.transferRepository.create({ + ...createTransferDto, + createdBy: userId, + status: createTransferDto.approvalRequired ? TransferStatus.PENDING : TransferStatus.APPROVED, + }); + + const savedTransfer = await this.transferRepository.save(transfer); + + // Create history record + await this.createHistoryRecord(savedTransfer.id, savedTransfer.assetIds[0], userId, HistoryAction.CREATED, 'Transfer request created'); + + // Send notifications + if (createTransferDto.approvalRequired) { + await this.sendNotification( + savedTransfer.createdBy, + 'Transfer Request Created', + `Your transfer request for ${savedTransfer.assetIds.length} asset(s) has been submitted for approval.`, + NotificationType.TRANSFER_REQUEST, + NotificationPriority.MEDIUM, + savedTransfer.id + ); + + // Notify approvers (this would be more sophisticated in a real implementation) + await this.sendNotificationToApprovers(savedTransfer); + } else { + // Execute immediately if no approval required + await this.executeTransfer(savedTransfer.id, userId); + } + + return savedTransfer; + } + + async getTransfers(filterDto: TransferFilterDto, userId: string): Promise<[AssetTransfer[], number]> { + const queryBuilder = this.transferRepository.createQueryBuilder('transfer') + .leftJoinAndSelect('transfer.assets', 'asset') + .leftJoinAndSelect('transfer.sourceUser', 'sourceUser') + .leftJoinAndSelect('transfer.destinationUser', 'destinationUser') + .leftJoinAndSelect('transfer.creator', 'creator') + .leftJoinAndSelect('transfer.approvedBy', 'approvedBy'); + + // Apply filters + if (filterDto.status) { + queryBuilder.andWhere('transfer.status = :status', { status: filterDto.status }); + } + + if (filterDto.createdBy) { + queryBuilder.andWhere('transfer.createdBy = :createdBy', { createdBy: filterDto.createdBy }); + } + + if (filterDto.departmentId) { + queryBuilder.andWhere('(transfer.sourceDepartmentId = :deptId OR transfer.destinationDepartmentId = :deptId)', + { deptId: filterDto.departmentId }); + } + + if (filterDto.startDate) { + queryBuilder.andWhere('transfer.createdAt >= :startDate', { startDate: new Date(filterDto.startDate) }); + } + + if (filterDto.endDate) { + queryBuilder.andWhere('transfer.createdAt <= :endDate', { endDate: new Date(filterDto.endDate) }); + } + + const page = filterDto.page || 1; + const limit = filterDto.limit || 10; + const skip = (page - 1) * limit; + + queryBuilder.skip(skip).take(limit).orderBy('transfer.createdAt', 'DESC'); + + return await queryBuilder.getManyAndCount(); + } + + async getTransferById(id: string): Promise { + const transfer = await this.transferRepository.findOne({ + where: { id }, + relations: ['assets', 'sourceUser', 'destinationUser', 'creator', 'approvedBy'] + }); + + if (!transfer) { + throw new NotFoundException('Transfer not found'); + } + + return transfer; + } + + async approveTransfer(id: string, approveDto: ApproveTransferDto): Promise { + const transfer = await this.getTransferById(id); + + if (transfer.status !== TransferStatus.PENDING) { + throw new BadRequestException('Transfer is not in pending status'); + } + + transfer.status = TransferStatus.APPROVED; + transfer.approvedById = approveDto.approvedById; + + const updatedTransfer = await this.transferRepository.save(transfer); + + // Create history record + await this.createHistoryRecord(id, transfer.assetIds[0], approveDto.approvedById, HistoryAction.APPROVED, approveDto.notes); + + // Send notifications + await this.sendNotification( + transfer.createdBy, + 'Transfer Approved', + `Your transfer request has been approved.`, + NotificationType.TRANSFER_APPROVED, + NotificationPriority.MEDIUM, + id + ); + + // Execute the transfer + await this.executeTransfer(id, approveDto.approvedById); + + return updatedTransfer; + } + + async rejectTransfer(id: string, rejectDto: RejectTransferDto): Promise { + const transfer = await this.getTransferById(id); + + if (transfer.status !== TransferStatus.PENDING) { + throw new BadRequestException('Transfer is not in pending status'); + } + + transfer.status = TransferStatus.REJECTED; + transfer.rejectionReason = rejectDto.rejectionReason; + + const updatedTransfer = await this.transferRepository.save(transfer); + + // Create history record + await this.createHistoryRecord(id, transfer.assetIds[0], rejectDto.rejectedById, HistoryAction.REJECTED, rejectDto.rejectionReason); + + // Send notifications + await this.sendNotification( + transfer.createdBy, + 'Transfer Rejected', + `Your transfer request has been rejected. Reason: ${rejectDto.rejectionReason}`, + NotificationType.TRANSFER_REJECTED, + NotificationPriority.HIGH, + id + ); + + return updatedTransfer; + } + + async cancelTransfer(id: string, userId: string): Promise { + const transfer = await this.getTransferById(id); + + if (transfer.status !== TransferStatus.PENDING && transfer.status !== TransferStatus.APPROVED) { + throw new BadRequestException('Transfer cannot be cancelled in current status'); + } + + transfer.status = TransferStatus.CANCELLED; + + const updatedTransfer = await this.transferRepository.save(transfer); + + // Create history record + await this.createHistoryRecord(id, transfer.assetIds[0], userId, HistoryAction.CANCELLED, 'Transfer cancelled by user'); + + // Send notifications + await this.sendNotification( + transfer.createdBy, + 'Transfer Cancelled', + `Your transfer request has been cancelled.`, + NotificationType.TRANSFER_CANCELLED, + NotificationPriority.MEDIUM, + id + ); + + return updatedTransfer; + } + + private async executeTransfer(transferId: string, executorId: string): Promise { + const transfer = await this.getTransferById(transferId); + + // Update asset records in the database + // This would integrate with your asset service to update asset ownership/department/location + + transfer.status = TransferStatus.EXECUTED; + await this.transferRepository.save(transfer); + + // Create history record + await this.createHistoryRecord(transferId, transfer.assetIds[0], executorId, HistoryAction.EXECUTED, 'Transfer executed'); + + // Send notification + await this.sendNotification( + transfer.createdBy, + 'Transfer Executed', + `Your transfer has been successfully executed.`, + NotificationType.TRANSFER_EXECUTED, + NotificationPriority.LOW, + transferId + ); + } + + async getNotifications(userId: string): Promise { + return await this.notificationRepository.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + take: 50 + }); + } + + async markNotificationAsRead(notificationId: string, userId: string): Promise { + const notification = await this.notificationRepository.findOne({ + where: { id: notificationId, userId } + }); + + if (!notification) { + throw new NotFoundException('Notification not found'); + } + + notification.isRead = true; + return await this.notificationRepository.save(notification); + } + + private async sendNotification( + userId: string, + title: string, + message: string, + type: NotificationType, + priority: NotificationPriority, + relatedTransferId?: string + ): Promise { + const notification = this.notificationRepository.create({ + userId, + title, + message, + type, + priority, + relatedTransferId, + isRead: false + }); + + await this.notificationRepository.save(notification); + } + + private async sendNotificationToApprovers(transfer: AssetTransfer): Promise { + // In a real implementation, this would identify and notify appropriate approvers + // based on department, role, or configured approval chains + console.log(`Would notify approvers for transfer ${transfer.id}`); + } + + private async createHistoryRecord( + transferId: string, + assetId: string, + actorId: string, + action: HistoryAction, + notes?: string + ): Promise { + const history = this.historyRepository.create({ + transferId, + assetId, + actorId, + action, + notes + }); + + await this.historyRepository.save(history); + } + + // Scheduled job to execute scheduled transfers + async executeScheduledTransfers(): Promise { + const now = new Date(); + const scheduledTransfers = await this.transferRepository.find({ + where: { + status: TransferStatus.SCHEDULED, + scheduledDate: LessThanOrEqual(now) + } + }); + + for (const transfer of scheduledTransfers) { + await this.executeTransfer(transfer.id, 'system'); + } + } +} \ No newline at end of file diff --git a/backend/src/asset-transfers/dto/approve-transfer.dto.ts b/backend/src/asset-transfers/dto/approve-transfer.dto.ts new file mode 100644 index 0000000..a4f89ce --- /dev/null +++ b/backend/src/asset-transfers/dto/approve-transfer.dto.ts @@ -0,0 +1,9 @@ +export class ApproveTransferDto { + approvedById: string; + notes?: string; +} + +export class RejectTransferDto { + rejectedById: string; + rejectionReason: string; +} \ No newline at end of file diff --git a/backend/src/asset-transfers/dto/create-transfer.dto.ts b/backend/src/asset-transfers/dto/create-transfer.dto.ts new file mode 100644 index 0000000..27a124b --- /dev/null +++ b/backend/src/asset-transfers/dto/create-transfer.dto.ts @@ -0,0 +1,21 @@ +export enum TransferType { + CHANGE_USER = 'change_user', + CHANGE_DEPARTMENT = 'change_department', + CHANGE_LOCATION = 'change_location', + ALL = 'all' +} + +export class CreateTransferDto { + assetIds: string[]; + transferType: TransferType; + sourceUserId?: string; + destinationUserId?: string; + sourceDepartmentId?: number; + destinationDepartmentId?: number; + sourceLocation?: string; + destinationLocation?: string; + reason: string; + notes?: string; + approvalRequired: boolean; + scheduledDate?: string; +} \ No newline at end of file diff --git a/backend/src/asset-transfers/dto/transfer-filter.dto.ts b/backend/src/asset-transfers/dto/transfer-filter.dto.ts new file mode 100644 index 0000000..62f65c1 --- /dev/null +++ b/backend/src/asset-transfers/dto/transfer-filter.dto.ts @@ -0,0 +1,18 @@ +export enum TransferStatus { + PENDING = 'pending', + APPROVED = 'approved', + REJECTED = 'rejected', + CANCELLED = 'cancelled', + EXECUTED = 'executed', + SCHEDULED = 'scheduled' +} + +export class TransferFilterDto { + status?: TransferStatus; + createdBy?: string; + departmentId?: number; + startDate?: string; + endDate?: string; + page?: number; + limit?: number; +} \ No newline at end of file diff --git a/backend/src/asset-transfers/email.service.ts b/backend/src/asset-transfers/email.service.ts new file mode 100644 index 0000000..8ddc351 --- /dev/null +++ b/backend/src/asset-transfers/email.service.ts @@ -0,0 +1,89 @@ +export class EmailService { + private transporter: any; // Would be nodemailer transporter + + constructor() { + // Initialize email transporter + // this.transporter = nodemailer.createTransporter({...}); + } + + async sendTransferRequestEmail(recipientEmail: string, transferData: any): Promise { + const subject = 'Asset Transfer Request Submitted'; + const html = ` +

Asset Transfer Request

+

Your request to transfer ${transferData.assetIds.length} asset(s) has been submitted.

+

Transfer Type: ${transferData.transferType}

+

Reason: ${transferData.reason}

+ ${transferData.approvalRequired ? '

This transfer requires approval.

' : '

This transfer will be executed immediately.

'} +

View details in the application.

+ `; + + await this.sendEmail(recipientEmail, subject, html); + } + + async sendTransferApprovalEmail(recipientEmail: string, transferData: any): Promise { + const subject = 'Asset Transfer Approved'; + const html = ` +

Transfer Approved

+

Your asset transfer request has been approved.

+

Transfer ID: ${transferData.id}

+

Assets: ${transferData.assetIds.length} item(s)

+

The transfer will be executed shortly.

+ `; + + await this.sendEmail(recipientEmail, subject, html); + } + + async sendTransferRejectionEmail(recipientEmail: string, transferData: any): Promise { + const subject = 'Asset Transfer Rejected'; + const html = ` +

Transfer Rejected

+

Your asset transfer request has been rejected.

+

Transfer ID: ${transferData.id}

+

Reason: ${transferData.rejectionReason}

+

You can review and resubmit the transfer request if needed.

+ `; + + await this.sendEmail(recipientEmail, subject, html); + } + + async sendTransferExecutionEmail(recipientEmail: string, transferData: any): Promise { + const subject = 'Asset Transfer Completed'; + const html = ` +

Transfer Executed

+

Your asset transfer has been successfully completed.

+

Transfer ID: ${transferData.id}

+

Assets transferred: ${transferData.assetIds.length} item(s)

+

The asset records have been updated accordingly.

+ `; + + await this.sendEmail(recipientEmail, subject, html); + } + + async sendApprovalRequestEmail(approverEmail: string, transferData: any): Promise { + const subject = 'Action Required: Asset Transfer Approval'; + const html = ` +

Transfer Approval Required

+

A new asset transfer request requires your approval.

+

Requested by: ${transferData.creator?.name || 'Unknown User'}

+

Assets: ${transferData.assetIds.length} item(s)

+

Transfer Type: ${transferData.transferType}

+

Reason: ${transferData.reason}

+

Please review and approve or reject this request in the application.

+ `; + + await this.sendEmail(approverEmail, subject, html); + } + + private async sendEmail(to: string, subject: string, html: string): Promise { + // In a real implementation: + // await this.transporter.sendMail({ + // from: process.env.EMAIL_FROM, + // to, + // subject, + // html + // }); + + console.log(`Email would be sent to ${to}: ${subject}`); + console.log(html); + } +} \ No newline at end of file diff --git a/backend/src/asset-transfers/entities/asset-transfer.entity.ts b/backend/src/asset-transfers/entities/asset-transfer.entity.ts new file mode 100644 index 0000000..8d3f1e4 --- /dev/null +++ b/backend/src/asset-transfers/entities/asset-transfer.entity.ts @@ -0,0 +1,106 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { User } from 'src/users/entities/user.entity'; +import { Asset } from '../../assets/entities/assest.entity'; + +export enum TransferStatus { + PENDING = 'pending', + APPROVED = 'approved', + REJECTED = 'rejected', + CANCELLED = 'cancelled', + EXECUTED = 'executed', + SCHEDULED = 'scheduled' +} + +export enum TransferType { + CHANGE_USER = 'change_user', + CHANGE_DEPARTMENT = 'change_department', + CHANGE_LOCATION = 'change_location', + ALL = 'all' +} + +@Entity('asset_transfers') +export class AssetTransfer { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid', { array: true }) + assetIds: string[]; + + @ManyToOne(() => Asset, { eager: true }) + @JoinColumn({ name: 'assetIds' }) + assets: Asset[]; + + @Column({ + type: 'enum', + enum: TransferType + }) + transferType: TransferType; + + @Column({ nullable: true }) + sourceUserId: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'sourceUserId' }) + sourceUser: User; + + @Column({ nullable: true }) + destinationUserId: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'destinationUserId' }) + destinationUser: User; + + @Column({ nullable: true }) + sourceDepartmentId: number; + + @Column({ nullable: true }) + destinationDepartmentId: number; + + @Column({ nullable: true }) + sourceLocation: string; + + @Column({ nullable: true }) + destinationLocation: string; + + @Column() + reason: string; + + @Column({ nullable: true }) + notes: string; + + @Column({ + type: 'enum', + enum: TransferStatus, + default: TransferStatus.PENDING + }) + status: TransferStatus; + + @Column({ default: false }) + approvalRequired: boolean; + + @Column({ type: 'timestamp', nullable: true }) + scheduledDate: Date; + + @Column() + createdBy: string; + + @ManyToOne(() => User) + @JoinColumn({ name: 'createdBy' }) + creator: User; + + @Column({ nullable: true }) + approvedById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'approvedById' }) + approvedBy: User; + + @Column({ nullable: true }) + rejectionReason: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/backend/src/asset-transfers/entities/notification.entity.ts b/backend/src/asset-transfers/entities/notification.entity.ts new file mode 100644 index 0000000..f7b62c5 --- /dev/null +++ b/backend/src/asset-transfers/entities/notification.entity.ts @@ -0,0 +1,55 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; + +export enum NotificationType { + TRANSFER_REQUEST = 'transfer_request', + TRANSFER_APPROVED = 'transfer_approved', + TRANSFER_REJECTED = 'transfer_rejected', + TRANSFER_CANCELLED = 'transfer_cancelled', + TRANSFER_EXECUTED = 'transfer_executed' +} + +export enum NotificationPriority { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high' +} + +@Entity('notifications') +export class Notification { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + userId: string; + + @Column() + title: string; + + @Column() + message: string; + + @Column({ + type: 'enum', + enum: NotificationType + }) + type: NotificationType; + + @Column({ + type: 'enum', + enum: NotificationPriority, + default: NotificationPriority.MEDIUM + }) + priority: NotificationPriority; + + @Column({ default: false }) + isRead: boolean; + + @Column({ nullable: true }) + relatedTransferId: string; + + @Column({ type: 'json', nullable: true }) + metadata: any; + + @CreateDateColumn() + createdAt: Date; +} \ No newline at end of file diff --git a/backend/src/asset-transfers/entities/transfer-history.entity.ts b/backend/src/asset-transfers/entities/transfer-history.entity.ts new file mode 100644 index 0000000..2f92153 --- /dev/null +++ b/backend/src/asset-transfers/entities/transfer-history.entity.ts @@ -0,0 +1,40 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; + +export enum HistoryAction { + CREATED = 'created', + APPROVED = 'approved', + REJECTED = 'rejected', + CANCELLED = 'cancelled', + EXECUTED = 'executed', + MODIFIED = 'modified' +} + +@Entity('transfer_history') +export class TransferHistory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + transferId: string; + + @Column() + assetId: string; + + @Column() + actorId: string; + + @Column({ + type: 'enum', + enum: HistoryAction + }) + action: HistoryAction; + + @Column({ type: 'json', nullable: true }) + changes: any; + + @Column({ nullable: true }) + notes: string; + + @CreateDateColumn() + timestamp: Date; +} \ No newline at end of file diff --git a/backend/src/asset-transfers/gateways/notifications.gateway.ts b/backend/src/asset-transfers/gateways/notifications.gateway.ts new file mode 100644 index 0000000..77575f7 --- /dev/null +++ b/backend/src/asset-transfers/gateways/notifications.gateway.ts @@ -0,0 +1,82 @@ +import { WebSocketGateway, WebSocketServer, SubscribeMessage, OnGatewayConnection, OnGatewayDisconnect, ConnectedSocket } from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; + +@WebSocketGateway({ + cors: { + origin: '*', + }, +}) +export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() + server: Server; + + private userSockets = new Map(); + + handleConnection(client: Socket) { + console.log(`Client connected: ${client.id}`); + + // In a real implementation, you would authenticate the user + // and associate the socket with their user ID + const userId = client.handshake.query.userId as string; + if (userId) { + this.userSockets.set(userId, client.id); + } + } + + handleDisconnect(client: Socket) { + console.log(`Client disconnected: ${client.id}`); + + // Remove user from sockets map + for (const [userId, socketId] of this.userSockets.entries()) { + if (socketId === client.id) { + this.userSockets.delete(userId); + break; + } + } + } + + @SubscribeMessage('join_room') + handleJoinRoom(client: Socket, payload: { userId: string }) { + client.join(`user_${payload.userId}`); + this.userSockets.set(payload.userId, client.id); + return { success: true }; + } + + @SubscribeMessage('leave_room') + handleLeaveRoom(client: Socket, payload: { userId: string }) { + client.leave(`user_${payload.userId}`); + this.userSockets.delete(payload.userId); + return { success: true }; + } + + // Methods to emit notifications to users + sendTransferCreated(userId: string, transferData: any) { + this.server.to(`user_${userId}`).emit('transfer_created', transferData); + } + + sendTransferApproved(userId: string, transferData: any) { + this.server.to(`user_${userId}`).emit('transfer_approved', transferData); + } + + sendTransferRejected(userId: string, transferData: any) { + this.server.to(`user_${userId}`).emit('transfer_rejected', transferData); + } + + sendTransferCancelled(userId: string, transferData: any) { + this.server.to(`user_${userId}`).emit('transfer_cancelled', transferData); + } + + sendTransferExecuted(userId: string, transferData: any) { + this.server.to(`user_${userId}`).emit('transfer_executed', transferData); + } + + // Broadcast to all users in a department + broadcastToDepartment(departmentId: number, event: string, data: any) { + this.server.to(`department_${departmentId}`).emit(event, data); + } + + // Send notification to specific user + sendNotification(userId: string, notification: any) { + this.server.to(`user_${userId}`).emit('notification', notification); + } +} \ No newline at end of file diff --git a/backend/src/assets/entities/assest.entity.ts b/backend/src/assets/entities/assest.entity.ts new file mode 100644 index 0000000..63e9643 --- /dev/null +++ b/backend/src/assets/entities/assest.entity.ts @@ -0,0 +1,37 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('assets') +export class Asset { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column({ nullable: true }) + description: string; + + @Column({ nullable: true }) + serialNumber: string; + + @Column({ nullable: true }) + category: string; + + @Column({ nullable: true }) + department: string; + + @Column({ nullable: true }) + location: string; + + @Column({ nullable: true }) + assignedTo: string; + + @Column({ default: 'active' }) + status: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/backend/src/users/entities/user.entity.ts b/backend/src/users/entities/user.entity.ts index 2b8c001..f40b00e 100644 --- a/backend/src/users/entities/user.entity.ts +++ b/backend/src/users/entities/user.entity.ts @@ -30,12 +30,9 @@ export class User { @Column({ default: true }) isActive: boolean; - @CreateDateColumn({ name: 'created_at' }) + @CreateDateColumn() createdAt: Date; - @UpdateDateColumn({ name: 'updated_at' }) + @UpdateDateColumn() updatedAt: Date; - - @DeleteDateColumn({ name: 'deleted_at' }) - deletedAt: Date; } diff --git a/contracts/assetsup/src/lib.rs b/contracts/assetsup/src/lib.rs index 910a34a..40ed5a3 100644 --- a/contracts/assetsup/src/lib.rs +++ b/contracts/assetsup/src/lib.rs @@ -15,32 +15,8 @@ pub use types::*; #[derive(Clone, Debug, Eq, PartialEq)] pub enum DataKey { Admin, - Paused, - ContractVersion, - ContractMetadata, - AuthorizedRegistrar(Address), - TotalAssetCount, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ContractMetadata { - pub version: String, - pub name: String, - pub description: String, - pub created_at: u64, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum Event { - AssetRegistered(Address, BytesN<32>, u64), - AssetTransferred(BytesN<32>, Address, Address, u64), - AssetUpdated(BytesN<32>, Address, u64), - AssetRetired(BytesN<32>, Address, u64), - AdminChanged(Address, Address, u64), - ContractPaused(Address, u64), - ContractUnpaused(Address, u64), + ScheduledTransfer(BytesN<32>), + PendingApproval(BytesN<32>), } #[contract] diff --git a/frontend/components/transfer/NotificationCenter.tsx b/frontend/components/transfer/NotificationCenter.tsx new file mode 100644 index 0000000..1de4d13 --- /dev/null +++ b/frontend/components/transfer/NotificationCenter.tsx @@ -0,0 +1,235 @@ +'use client'; + +import React, { useState, useRef, useEffect } from 'react'; + +interface Notification { + id: string; + title: string; + message: string; + type: 'transfer_request' | 'transfer_approved' | 'transfer_rejected' | 'transfer_cancelled' | 'transfer_executed'; + priority: 'low' | 'medium' | 'high'; + isRead: boolean; + createdAt: string; + relatedTransferId?: string; +} + +interface NotificationCenterProps { + notifications: Notification[]; + unreadCount: number; + onMarkAsRead: (id: string) => void; + onMarkAllAsRead: () => void; + onClearAll: () => void; + isOpen: boolean; + onClose: () => void; +} + +const NotificationCenter: React.FC = ({ + notifications, + unreadCount, + onMarkAsRead, + onMarkAllAsRead, + onClearAll, + isOpen, + onClose +}) => { + const [showDropdown, setShowDropdown] = useState(false); + const dropdownRef = useRef(null); + + const getTypeIcon = (type: string) => { + switch (type) { + case 'transfer_request': + return ( + + + + ); + case 'transfer_approved': + return ( + + + + ); + case 'transfer_rejected': + return ( + + + + ); + case 'transfer_cancelled': + return ( + + + + ); + case 'transfer_executed': + return ( + + + + ); + default: + return ( + + + + ); + } + }; + + const getPriorityColor = (priority: string) => { + switch (priority) { + case 'high': + return 'border-l-red-500'; + case 'medium': + return 'border-l-yellow-500'; + default: + return 'border-l-gray-300'; + } + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + const now = new Date(); + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (diffInSeconds < 60) return 'Just now'; + if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`; + if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`; + return date.toLocaleDateString(); + }; + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setShowDropdown(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+
+

Notifications

+
+ {unreadCount > 0 && ( + + {unreadCount} unread + + )} + +
+
+ + {notifications.length > 0 && ( +
+ + +
+ )} +
+ + {/* Notifications List */} +
+ {notifications.length === 0 ? ( +
+ + + +

No notifications

+

You're all caught up!

+
+ ) : ( +
+ {notifications.map((notification) => ( +
!notification.isRead && onMarkAsRead(notification.id)} + > +
+
+ {getTypeIcon(notification.type)} +
+
+
+

+ {notification.title} +

+ {!notification.isRead && ( + + )} +
+

+ {notification.message} +

+
+

+ {formatDate(notification.createdAt)} +

+ {!notification.isRead && ( + + )} +
+
+
+
+ ))} +
+ )} +
+ + {/* Footer */} + {notifications.length > 0 && ( +
+

+ Showing {notifications.length} notification{notifications.length !== 1 ? 's' : ''} +

+
+ )} +
+
+ ); +}; + +export default NotificationCenter; \ No newline at end of file diff --git a/frontend/components/transfer/TransferForm.tsx b/frontend/components/transfer/TransferForm.tsx new file mode 100644 index 0000000..be295d9 --- /dev/null +++ b/frontend/components/transfer/TransferForm.tsx @@ -0,0 +1,488 @@ +'use client'; + +import React, { useState } from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { Asset } from '@/lib/query/types'; + +interface TransferFormData { + assetIds: string[]; + transferType: 'change_user' | 'change_department' | 'change_location' | 'all'; + destinationUserId?: string; + destinationDepartmentId?: number; + destinationLocation?: string; + reason: string; + notes?: string; + approvalRequired: boolean; + scheduledDate?: string; +} + +interface TransferFormProps { + assets: Asset[]; + users: any[]; + departments: any[]; + onSubmit: (data: TransferFormData) => void; + onCancel: () => void; + isLoading?: boolean; +} + +const TransferForm: React.FC = ({ + assets, + users, + departments, + onSubmit, + onCancel, + isLoading = false +}) => { + const [currentStep, setCurrentStep] = useState(1); + const [selectedAssets, setSelectedAssets] = useState([]); + + const { control, handleSubmit, watch, formState: { errors } } = useForm({ + defaultValues: { + assetIds: [], + transferType: 'change_user', + approvalRequired: false + } + }); + + const transferType = watch('transferType'); + const approvalRequired = watch('approvalRequired'); + + const totalSteps = 4; + + const nextStep = () => { + if (currentStep < totalSteps) { + setCurrentStep(currentStep + 1); + } + }; + + const prevStep = () => { + if (currentStep > 1) { + setCurrentStep(currentStep - 1); + } + }; + + const handleAssetToggle = (assetId: string) => { + setSelectedAssets(prev => + prev.includes(assetId) + ? prev.filter(id => id !== assetId) + : [...prev, assetId] + ); + }; + + const renderStepContent = () => { + switch (currentStep) { + case 1: + return ( +
+
+

Select Assets

+
+ {assets.map(asset => ( +
handleAssetToggle(asset.id)} + > +
+ {}} + className="mr-3 h-4 w-4 text-blue-600" + /> +
+

{asset.name}

+

{asset.category}

+

ID: {asset.id.substring(0, 8)}...

+
+
+
+ ))} +
+ {errors.assetIds && ( +

{errors.assetIds.message}

+ )} +
+
+ ); + + case 2: + return ( +
+
+

Transfer Details

+ +
+
+ + ( + + )} + /> +
+ + {transferType === 'change_user' && ( +
+ + ( + + )} + /> +
+ )} + + {transferType === 'change_department' && ( +
+ + ( + + )} + /> +
+ )} + + {transferType === 'change_location' && ( +
+ + ( + + )} + /> +
+ )} + +
+ + ( + + )} + /> +
+
+
+
+ ); + + case 3: + return ( +
+
+

Reason and Notes

+ +
+
+ + ( +