diff --git a/backend/src/abdulrcrtw.spec.ts b/backend/src/abdulrcrtw.spec.ts index dd2d10e1..bf9f4d94 100644 --- a/backend/src/abdulrcrtw.spec.ts +++ b/backend/src/abdulrcrtw.spec.ts @@ -5,11 +5,17 @@ import { TransfersService } from './transfers/transfers.service'; describe('abdulrcrtw Modules (BE-92, BE-91, BE-90, BE-89)', () => { it('NotesDocsController adds and lists asset notes and documents', () => { const controller = new NotesDocsController(); - const note = controller.addNote('ast-1', 'Need battery replacement', { user: { id: 'u-1' } } as any); + const note = controller.addNote('ast-1', 'Need battery replacement', { + user: { id: 'u-1' }, + } as any); expect(note.content).toBe('Need battery replacement'); expect(controller.getNotes('ast-1').length).toBe(1); - const doc = controller.addDocument('ast-1', { title: 'Warranty.pdf', fileUrl: 'http://example.com/w.pdf' }, { user: { id: 'u-1' } } as any); + const doc = controller.addDocument( + 'ast-1', + { title: 'Warranty.pdf', fileUrl: 'http://example.com/w.pdf' }, + { user: { id: 'u-1' } } as any, + ); expect(doc.title).toBe('Warranty.pdf'); expect(controller.getDocuments('ast-1').length).toBe(1); }); @@ -17,10 +23,16 @@ describe('abdulrcrtw Modules (BE-92, BE-91, BE-90, BE-89)', () => { it('MaintenanceService creates maintenance records', async () => { const mockRepo = { create: jest.fn().mockImplementation((dto) => dto), - save: jest.fn().mockImplementation((dto) => Promise.resolve({ id: 'm-1', ...dto })), + save: jest + .fn() + .mockImplementation((dto) => Promise.resolve({ id: 'm-1', ...dto })), }; const service = new MaintenanceService(mockRepo as any); - const rec = await service.create({ assetId: 'ast-1', title: 'Screen repair', cost: 15000 }); + const rec = await service.create({ + assetId: 'ast-1', + title: 'Screen repair', + cost: 15000, + }); expect(rec.cost).toBe(15000); }); diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts index 265479a1..6dfc52a2 100644 --- a/backend/src/app.controller.ts +++ b/backend/src/app.controller.ts @@ -1,21 +1,16 @@ import { Controller, Get } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { AppService } from './app.service'; +@ApiTags('app') @Controller() export class AppController { constructor(private readonly appService: AppService) {} @Get() + @ApiOperation({ summary: 'Health check / welcome message' }) + @ApiResponse({ status: 200, description: 'Server is running' }) getHello(): string { return this.appService.getHello(); } - - @Get() - getHealth() { - return { - status: 'OK', - message: 'Server is running', - Timestamp: Date.now(), - }; - } } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index b36bd1e0..8b48554f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -23,6 +23,7 @@ import { PurchaseOrdersModule } from './purchase-orders/purchase-orders.module'; import { LicensesModule } from './licenses/licenses.module'; import { NotificationsModule } from './notifications/notifications.module'; import { GatewayModule } from './gateway/gateway.module'; +import { AuditLogsModule } from './audit-logs/audit-logs.module'; @Module({ imports: [ @@ -46,6 +47,7 @@ import { GatewayModule } from './gateway/gateway.module'; LicensesModule, NotificationsModule, GatewayModule, + AuditLogsModule, TypeOrmModule.forRootAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ diff --git a/backend/src/assets/asset-lifecycle.service.ts b/backend/src/assets/asset-lifecycle.service.ts index b4a94c31..e0bf9f6d 100644 --- a/backend/src/assets/asset-lifecycle.service.ts +++ b/backend/src/assets/asset-lifecycle.service.ts @@ -11,13 +11,37 @@ export enum AssetStatus { } const ALLOWED_TRANSITIONS: Record = { - [AssetStatus.AVAILABLE]: [AssetStatus.ASSIGNED, AssetStatus.IN_MAINTENANCE, AssetStatus.IN_TRANSIT, AssetStatus.RETIRED, AssetStatus.LOST], - [AssetStatus.ASSIGNED]: [AssetStatus.AVAILABLE, AssetStatus.IN_MAINTENANCE, AssetStatus.IN_TRANSIT, AssetStatus.RETIRED, AssetStatus.LOST], - [AssetStatus.IN_MAINTENANCE]: [AssetStatus.AVAILABLE, AssetStatus.ASSIGNED, AssetStatus.RETIRED], - [AssetStatus.IN_TRANSIT]: [AssetStatus.AVAILABLE, AssetStatus.ASSIGNED, AssetStatus.LOST], + [AssetStatus.AVAILABLE]: [ + AssetStatus.ASSIGNED, + AssetStatus.IN_MAINTENANCE, + AssetStatus.IN_TRANSIT, + AssetStatus.RETIRED, + AssetStatus.LOST, + ], + [AssetStatus.ASSIGNED]: [ + AssetStatus.AVAILABLE, + AssetStatus.IN_MAINTENANCE, + AssetStatus.IN_TRANSIT, + AssetStatus.RETIRED, + AssetStatus.LOST, + ], + [AssetStatus.IN_MAINTENANCE]: [ + AssetStatus.AVAILABLE, + AssetStatus.ASSIGNED, + AssetStatus.RETIRED, + ], + [AssetStatus.IN_TRANSIT]: [ + AssetStatus.AVAILABLE, + AssetStatus.ASSIGNED, + AssetStatus.LOST, + ], [AssetStatus.RETIRED]: [AssetStatus.DISPOSED], [AssetStatus.DISPOSED]: [], - [AssetStatus.LOST]: [AssetStatus.AVAILABLE, AssetStatus.RETIRED, AssetStatus.DISPOSED], + [AssetStatus.LOST]: [ + AssetStatus.AVAILABLE, + AssetStatus.RETIRED, + AssetStatus.DISPOSED, + ], }; @Injectable() @@ -28,12 +52,22 @@ export class AssetLifecycleService { if (fromStatus === toStatus) return true; const allowed = ALLOWED_TRANSITIONS[fromStatus] || []; if (!allowed.includes(toStatus)) { - throw new BadRequestException(`Cannot transition asset status from ${fromStatus} to ${toStatus}`); + throw new BadRequestException( + `Cannot transition asset status from ${fromStatus} to ${toStatus}`, + ); } return true; } - recordHistory(assetId: string, event: { eventType: string; actorUserId: string; note?: string; fieldChanges?: any }) { + recordHistory( + assetId: string, + event: { + eventType: string; + actorUserId: string; + note?: string; + fieldChanges?: any; + }, + ) { const list = this.history.get(assetId) || []; const entry = { id: `h_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, diff --git a/backend/src/assets/asset-status.controller.ts b/backend/src/assets/asset-status.controller.ts index 09a8456f..a5695372 100644 --- a/backend/src/assets/asset-status.controller.ts +++ b/backend/src/assets/asset-status.controller.ts @@ -1,32 +1,47 @@ import { Controller, Get, Patch, Param, Body, Req } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; import { AssetLifecycleService, AssetStatus } from './asset-lifecycle.service'; @ApiTags('assets') +@ApiBearerAuth('JWT-auth') @Controller('assets') export class AssetStatusController { constructor(private readonly lifecycleService: AssetLifecycleService) {} @Patch(':id/status') @ApiOperation({ summary: 'Update asset status' }) + @ApiResponse({ status: 200, description: 'Status updated' }) + @ApiResponse({ status: 400, description: 'Invalid status transition' }) updateStatus( @Param('id') id: string, - @Body() body: { currentStatus: AssetStatus; newStatus: AssetStatus; note?: string }, + @Body() + body: { currentStatus: AssetStatus; newStatus: AssetStatus; note?: string }, @Req() req: any, ) { - this.lifecycleService.validateTransition(body.currentStatus, body.newStatus); + this.lifecycleService.validateTransition( + body.currentStatus, + body.newStatus, + ); const actorId = req.user?.id || 'usr-1'; const entry = this.lifecycleService.recordHistory(id, { eventType: 'STATUS_CHANGED', actorUserId: actorId, note: body.note, - fieldChanges: { status: { from: body.currentStatus, to: body.newStatus } }, + fieldChanges: { + status: { from: body.currentStatus, to: body.newStatus }, + }, }); return { assetId: id, status: body.newStatus, historyEntry: entry }; } @Get(':id/history') @ApiOperation({ summary: 'Get asset history and audit trail' }) + @ApiResponse({ status: 200, description: 'Asset history' }) getHistory(@Param('id') id: string) { return this.lifecycleService.getHistory(id); } diff --git a/backend/src/assets/assets.controller.ts b/backend/src/assets/assets.controller.ts index d19b4d6b..97d5d62c 100644 --- a/backend/src/assets/assets.controller.ts +++ b/backend/src/assets/assets.controller.ts @@ -9,7 +9,12 @@ import { Query, UseGuards, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; import { AssetsService } from './assets.service'; import { BulkStatusDto } from './dto/bulk-status.dto'; import { BulkAssignDto } from './dto/bulk-assign.dto'; @@ -21,12 +26,14 @@ import { GetUser } from '../auth/decorators/get-user.decorator'; import { User } from '../users/entities/user.entity'; @ApiTags('assets') +@ApiBearerAuth('JWT-auth') @Controller('assets') export class AssetsController { constructor(private readonly assetsService: AssetsService) {} @Get() @ApiOperation({ summary: 'List all assets (paginated, filterable)' }) + @ApiResponse({ status: 200, description: 'Paginated list of assets' }) findAll( @Query('search') search?: string, @Query('categoryId') categoryId?: string, @@ -36,45 +43,58 @@ export class AssetsController { @Query('page') page?: number, @Query('limit') limit?: number, ) { - return this.assetsService.findAll({ search, categoryId, departmentId, locationId, status, page, limit }); + return this.assetsService.findAll({ + search, + categoryId, + departmentId, + locationId, + status, + page, + limit, + }); } @Post() @ApiOperation({ summary: 'Create an asset' }) + @ApiResponse({ status: 201, description: 'Asset created' }) create(@Body() dto: any) { return this.assetsService.create(dto); } @Get(':id') @ApiOperation({ summary: 'Get asset details' }) + @ApiResponse({ status: 200, description: 'Asset details' }) + @ApiResponse({ status: 404, description: 'Asset not found' }) findOne(@Param('id') id: string) { return this.assetsService.findById(id); } @Patch(':id') @ApiOperation({ summary: 'Update an asset' }) + @ApiResponse({ status: 200, description: 'Asset updated' }) update(@Param('id') id: string, @Body() dto: any) { return this.assetsService.update(id, dto); } @Delete(':id') @ApiOperation({ summary: 'Soft delete an asset' }) + @ApiResponse({ status: 200, description: 'Asset deleted' }) delete(@Param('id') id: string) { return this.assetsService.delete(id); } @Patch('bulk/status') @UseGuards(JwtAuthGuard) - @ApiBearerAuth() @ApiOperation({ summary: 'Bulk update asset status' }) + @ApiResponse({ status: 200, description: 'Bulk status update result' }) bulkStatus(@Body() dto: BulkStatusDto, @GetUser() user: User) { return this.assetsService.bulkStatus(dto, user.id); } @Patch('bulk/assign') @UseGuards(JwtAuthGuard) - @ApiBearerAuth() @ApiOperation({ summary: 'Bulk assign assets to user or department' }) + @ApiResponse({ status: 200, description: 'Bulk assignment result' }) bulkAssign(@Body() dto: BulkAssignDto, @GetUser() user: User) { return this.assetsService.bulkAssign(dto, user.id); } @@ -82,8 +102,9 @@ export class AssetsController { @Delete('bulk') @UseGuards(JwtAuthGuard, RolesGuard) @Roles('ADMIN') - @ApiBearerAuth() @ApiOperation({ summary: 'Bulk soft delete assets (ADMIN only)' }) + @ApiResponse({ status: 200, description: 'Bulk delete result' }) + @ApiResponse({ status: 403, description: 'Forbidden — requires ADMIN role' }) bulkDelete(@Body() dto: BulkDeleteDto, @GetUser() user: User) { return this.assetsService.bulkDelete(dto, user.id); } diff --git a/backend/src/assets/assets.service.spec.ts b/backend/src/assets/assets.service.spec.ts index 208f8371..ecc58ed0 100644 --- a/backend/src/assets/assets.service.spec.ts +++ b/backend/src/assets/assets.service.spec.ts @@ -66,7 +66,9 @@ describe('AssetsService', () => { jest.spyOn(repository, 'findOne').mockResolvedValue(mockAsset as any); jest.spyOn(repository, 'save').mockResolvedValue(updatedAsset as any); - const result = await service.update('ast-1', { name: 'MacBook Pro M3' } as any); + const result = await service.update('ast-1', { + name: 'MacBook Pro M3', + } as any); expect(result.name).toBe('MacBook Pro M3'); }); diff --git a/backend/src/assets/assets.service.ts b/backend/src/assets/assets.service.ts index e6bbbb3b..7439313a 100644 --- a/backend/src/assets/assets.service.ts +++ b/backend/src/assets/assets.service.ts @@ -52,9 +52,12 @@ export class AssetsService { { s: `%${query.search}%` }, ); } - if (query?.categoryId) qb.andWhere('asset.categoryId = :c', { c: query.categoryId }); - if (query?.departmentId) qb.andWhere('asset.departmentId = :d', { d: query.departmentId }); - if (query?.locationId) qb.andWhere('asset.locationId = :l', { l: query.locationId }); + if (query?.categoryId) + qb.andWhere('asset.categoryId = :c', { c: query.categoryId }); + if (query?.departmentId) + qb.andWhere('asset.departmentId = :d', { d: query.departmentId }); + if (query?.locationId) + qb.andWhere('asset.locationId = :l', { l: query.locationId }); if (query?.status) qb.andWhere('asset.status = :st', { st: query.status }); const [items, total] = await qb.getManyAndCount(); @@ -75,7 +78,8 @@ export class AssetsService { async create(dto: Partial) { const count = await this.assetRepo.count(); - const assetTag = dto.assetTag || `AST-${String(count + 1).padStart(5, '0')}`; + const assetTag = + dto.assetTag || `AST-${String(count + 1).padStart(5, '0')}`; const asset = this.assetRepo.create({ ...dto, assetTag }); return this.assetRepo.save(asset); } @@ -108,9 +112,15 @@ export class AssetsService { return; } try { - this.lifecycle.validateTransition(asset.status as AssetStatus, dto.status); + this.lifecycle.validateTransition( + asset.status as AssetStatus, + dto.status, + ); } catch (err: any) { - failed.push({ id, reason: err.message || 'Invalid status transition' }); + failed.push({ + id, + reason: err.message || 'Invalid status transition', + }); return; } const previousStatus = asset.status; @@ -128,9 +138,13 @@ export class AssetsService { note: `Status changed from ${previousStatus} to ${dto.status}`, fieldChanges: { previousStatus, newStatus: dto.status }, }); - this.audit.logAction('BULK_STATUS_UPDATE', 'Asset', id, actorId, { - previousStatus, - newStatus: dto.status, + this.audit.logAction({ + action: 'STATUS_CHANGED', + entityType: 'asset', + entityId: id, + actorId, + previousValue: { status: previousStatus }, + newValue: { status: dto.status }, }); succeeded.push(id); }); @@ -141,7 +155,9 @@ export class AssetsService { async bulkAssign(dto: BulkAssignDto, actorId?: string): Promise { if (!dto.userId && !dto.departmentId) { - throw new BadRequestException('At least one of userId or departmentId is required'); + throw new BadRequestException( + 'At least one of userId or departmentId is required', + ); } const succeeded: string[] = []; @@ -166,11 +182,20 @@ export class AssetsService { eventType: 'ASSIGNED', actorUserId: actorId || 'system', note: `Bulk assignment update`, - fieldChanges: { previous, new: { userId: dto.userId, departmentId: dto.departmentId } }, + fieldChanges: { + previous, + new: { userId: dto.userId, departmentId: dto.departmentId }, + }, }); - this.audit.logAction('BULK_ASSIGN', 'Asset', id, actorId, { - userId: dto.userId, - departmentId: dto.departmentId, + this.audit.logAction({ + action: 'UPDATED', + entityType: 'asset', + entityId: id, + actorId, + newValue: { + assignedToUserId: dto.userId, + departmentId: dto.departmentId, + }, }); succeeded.push(id); }); @@ -197,7 +222,12 @@ export class AssetsService { actorUserId: actorId || 'system', note: 'Bulk soft delete', }); - this.audit.logAction('BULK_DELETE', 'Asset', id, actorId); + this.audit.logAction({ + action: 'DELETED', + entityType: 'asset', + entityId: id, + actorId, + }); succeeded.push(id); }); } diff --git a/backend/src/assets/entities/asset-document.entity.ts b/backend/src/assets/entities/asset-document.entity.ts index 27025820..f4f627a8 100644 --- a/backend/src/assets/entities/asset-document.entity.ts +++ b/backend/src/assets/entities/asset-document.entity.ts @@ -1,4 +1,9 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, +} from 'typeorm'; @Entity('asset_documents') export class AssetDocument { diff --git a/backend/src/assets/entities/asset-note.entity.ts b/backend/src/assets/entities/asset-note.entity.ts index d09156a9..3b417451 100644 --- a/backend/src/assets/entities/asset-note.entity.ts +++ b/backend/src/assets/entities/asset-note.entity.ts @@ -1,4 +1,9 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, +} from 'typeorm'; @Entity('asset_notes') export class AssetNote { diff --git a/backend/src/assets/notes-docs.controller.ts b/backend/src/assets/notes-docs.controller.ts index 3f03313a..82d858e1 100644 --- a/backend/src/assets/notes-docs.controller.ts +++ b/backend/src/assets/notes-docs.controller.ts @@ -1,7 +1,13 @@ -import { Controller, Get, Post, Delete, Param, Body, Req } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { Controller, Get, Post, Param, Body, Req } from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; @ApiTags('assets') +@ApiBearerAuth('JWT-auth') @Controller('assets/:id') export class NotesDocsController { private notes = new Map(); @@ -9,13 +15,19 @@ export class NotesDocsController { @Get('notes') @ApiOperation({ summary: 'Get asset notes' }) + @ApiResponse({ status: 200, description: 'List of notes' }) getNotes(@Param('id') assetId: string) { return this.notes.get(assetId) || []; } @Post('notes') @ApiOperation({ summary: 'Add an asset note' }) - addNote(@Param('id') assetId: string, @Body('content') content: string, @Req() req: any) { + @ApiResponse({ status: 201, description: 'Note added' }) + addNote( + @Param('id') assetId: string, + @Body('content') content: string, + @Req() req: any, + ) { const list = this.notes.get(assetId) || []; const note = { id: `n_${Date.now()}`, @@ -31,15 +43,23 @@ export class NotesDocsController { @Get('documents') @ApiOperation({ summary: 'Get asset document attachments' }) + @ApiResponse({ status: 200, description: 'List of documents' }) getDocuments(@Param('id') assetId: string) { return this.docs.get(assetId) || []; } @Post('documents') @ApiOperation({ summary: 'Attach a document to an asset' }) + @ApiResponse({ status: 201, description: 'Document attached' }) addDocument( @Param('id') assetId: string, - @Body() body: { title: string; fileUrl: string; fileType?: string; fileSizeBytes?: number }, + @Body() + body: { + title: string; + fileUrl: string; + fileType?: string; + fileSizeBytes?: number; + }, @Req() req: any, ) { const list = this.docs.get(assetId) || []; diff --git a/backend/src/audit-logs/audit-logs.controller.ts b/backend/src/audit-logs/audit-logs.controller.ts new file mode 100644 index 00000000..e6e499df --- /dev/null +++ b/backend/src/audit-logs/audit-logs.controller.ts @@ -0,0 +1,58 @@ +import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; +import { AuditLogsService, AuditLogQuery } from './audit-logs.service'; +import { AuditAction } from './entities/audit-log.entity'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; + +@ApiTags('Audit Logs') +@ApiBearerAuth('JWT-auth') +@Controller('audit-logs') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('ADMIN') +export class AuditLogsController { + constructor(private readonly auditLogsService: AuditLogsService) {} + + @Get() + @ApiOperation({ + summary: 'List audit logs (searchable, filterable, paginated) — ADMIN only', + }) + @ApiResponse({ status: 200, description: 'Paginated list of audit logs' }) + @ApiResponse({ status: 403, description: 'Forbidden — requires ADMIN role' }) + findAll( + @Query('entityType') entityType?: string, + @Query('entityId') entityId?: string, + @Query('actorId') actorId?: string, + @Query('action') action?: AuditAction, + @Query('from') from?: string, + @Query('to') to?: string, + @Query('page') page?: number, + @Query('limit') limit?: number, + ) { + const query: AuditLogQuery = { + entityType, + entityId, + actorId, + action, + from: from ? new Date(from) : undefined, + to: to ? new Date(to) : undefined, + page: page ? Number(page) : undefined, + limit: limit ? Number(limit) : undefined, + }; + return this.auditLogsService.findAll(query); + } + + @Get(':id') + @ApiOperation({ summary: 'Get full detail of a single audit log entry' }) + @ApiResponse({ status: 200, description: 'Audit log detail' }) + @ApiResponse({ status: 404, description: 'Audit log not found' }) + findOne(@Param('id') id: string) { + return this.auditLogsService.findById(id); + } +} diff --git a/backend/src/audit-logs/audit-logs.module.ts b/backend/src/audit-logs/audit-logs.module.ts index e416bfcb..30086ae4 100644 --- a/backend/src/audit-logs/audit-logs.module.ts +++ b/backend/src/audit-logs/audit-logs.module.ts @@ -1,7 +1,16 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CacheModule } from '@nestjs/cache-manager'; +import { AuditLog } from './entities/audit-log.entity'; import { AuditLogsService } from './audit-logs.service'; +import { AuditLogsController } from './audit-logs.controller'; @Module({ + imports: [ + TypeOrmModule.forFeature([AuditLog]), + CacheModule.register({ ttl: 30000, max: 100 }), + ], + controllers: [AuditLogsController], providers: [AuditLogsService], exports: [AuditLogsService], }) diff --git a/backend/src/audit-logs/audit-logs.service.ts b/backend/src/audit-logs/audit-logs.service.ts index 6bb2d7b4..9f04b9c8 100644 --- a/backend/src/audit-logs/audit-logs.service.ts +++ b/backend/src/audit-logs/audit-logs.service.ts @@ -1,36 +1,99 @@ -import { Injectable } from '@nestjs/common'; - -export interface AuditLogEntry { - action: string; - entityType: string; - entityId: string; - performedBy?: string; - details?: Record; - createdAt: Date; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuditLog, AuditAction } from './entities/audit-log.entity'; +import { Cache, CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject } from '@nestjs/common'; + +export interface AuditLogQuery { + entityType?: string; + entityId?: string; + actorId?: string; + action?: AuditAction; + from?: Date; + to?: Date; + page?: number; + limit?: number; } @Injectable() export class AuditLogsService { - private logs: AuditLogEntry[] = []; - - logAction( - action: string, - entityType: string, - entityId: string, - performedBy?: string, - details?: Record, - ) { - this.logs.unshift({ - action, - entityType, - entityId, - performedBy, - details, - createdAt: new Date(), + constructor( + @InjectRepository(AuditLog) + private readonly auditLogRepo: Repository, + @Inject(CACHE_MANAGER) + private readonly cacheManager: Cache, + ) {} + + async logAction(params: { + action: AuditAction | string; + entityType: string; + entityId: string; + actorId?: string; + previousValue?: Record; + newValue?: Record; + ipAddress?: string; + userAgent?: string; + }) { + const entry = this.auditLogRepo.create({ + action: params.action as AuditAction, + entityType: params.entityType, + entityId: params.entityId, + actorId: params.actorId, + previousValue: params.previousValue, + newValue: params.newValue, + ipAddress: params.ipAddress, + userAgent: params.userAgent, }); + const saved = await this.auditLogRepo.save(entry); + // Invalidate list cache on write + await this.cacheManager.del('audit-logs:list'); + return saved; + } + + async findAll(query: AuditLogQuery) { + const page = query.page || 1; + const limit = query.limit || 20; + const cacheKey = `audit-logs:list:${JSON.stringify(query)}`; + + const cached = await this.cacheManager.get(cacheKey); + if (cached) return cached; + + const qb = this.auditLogRepo + .createQueryBuilder('log') + .orderBy('log.createdAt', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + if (query.entityType) + qb.andWhere('log.entityType = :entityType', { + entityType: query.entityType, + }); + if (query.entityId) + qb.andWhere('log.entityId = :entityId', { entityId: query.entityId }); + if (query.actorId) + qb.andWhere('log.actorId = :actorId', { actorId: query.actorId }); + if (query.action) + qb.andWhere('log.action = :action', { action: query.action }); + if (query.from) qb.andWhere('log.createdAt >= :from', { from: query.from }); + if (query.to) qb.andWhere('log.createdAt <= :to', { to: query.to }); + + const [items, total] = await qb.getManyAndCount(); + const result = { + items, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + + await this.cacheManager.set(cacheKey, result, 30000); // 30s TTL + return result; } - getRecent(limit = 100) { - return this.logs.slice(0, limit); + async findById(id: string) { + const log = await this.auditLogRepo.findOne({ where: { id } }); + if (!log) throw new NotFoundException(`Audit log ${id} not found`); + return log; } } diff --git a/backend/src/audit-logs/entities/audit-log.entity.ts b/backend/src/audit-logs/entities/audit-log.entity.ts new file mode 100644 index 00000000..aae9cf29 --- /dev/null +++ b/backend/src/audit-logs/entities/audit-log.entity.ts @@ -0,0 +1,61 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, +} from 'typeorm'; +import { ApiProperty } from '@nestjs/swagger'; + +export enum AuditAction { + CREATED = 'CREATED', + UPDATED = 'UPDATED', + DELETED = 'DELETED', + STATUS_CHANGED = 'STATUS_CHANGED', + ROLE_CHANGED = 'ROLE_CHANGED', +} + +@Entity('audit_logs') +@Index(['entityType', 'entityId']) +@Index(['actorId']) +@Index(['createdAt']) +export class AuditLog { + @ApiProperty({ description: 'Audit log UUID' }) + @PrimaryGeneratedColumn('uuid') + id: string; + + @ApiProperty({ example: 'asset', description: 'Type of entity changed' }) + @Column() + entityType: string; + + @ApiProperty({ description: 'UUID of the affected entity' }) + @Column() + entityId: string; + + @ApiProperty({ enum: AuditAction }) + @Column({ type: 'enum', enum: AuditAction }) + action: AuditAction; + + @ApiProperty({ description: 'UUID of the user who made the change' }) + @Column({ nullable: true }) + actorId?: string; + + @ApiProperty({ description: 'Previous value as JSON' }) + @Column({ type: 'jsonb', nullable: true }) + previousValue?: Record; + + @ApiProperty({ description: 'New value as JSON' }) + @Column({ type: 'jsonb', nullable: true }) + newValue?: Record; + + @ApiProperty({ example: '192.168.1.1' }) + @Column({ nullable: true }) + ipAddress?: string; + + @Column({ nullable: true }) + userAgent?: string; + + @ApiProperty() + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index da283f52..a99fc37f 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -7,7 +7,12 @@ import { Post, UseGuards, } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { User } from '../users/entities/user.entity'; import { AuthService } from './auth.service'; import { GetUser } from './decorators/get-user.decorator'; @@ -38,16 +43,18 @@ export class AuthController { } @Post('logout') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Invalidate current session' }) @ApiResponse({ status: 200, description: 'Logged out successfully' }) - async logout() { - return { message: 'Logged out successfully' }; + async logout(@GetUser() user: User) { + return this.authService.logout(user.id); } @Get('me') @UseGuards(JwtAuthGuard) - @ApiBearerAuth() + @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: "Get current authenticated user's profile" }) @ApiResponse({ status: 200, description: 'Profile retrieved' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @@ -63,12 +70,14 @@ export class AuthController { return this.authService.forgotPassword(email); } - @Post('refresh-token') + @Post('refresh') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Refresh access token' }) - @ApiResponse({ status: 200, description: 'Token refreshed' }) - @ApiResponse({ status: 401, description: 'Invalid refresh token' }) - async refreshToken(@Body('refreshToken') refreshToken: string) { + @ApiOperation({ + summary: 'Exchange refresh token for new access + refresh token pair', + }) + @ApiResponse({ status: 200, description: 'New token pair issued' }) + @ApiResponse({ status: 401, description: 'Invalid or reused refresh token' }) + async refresh(@Body('refreshToken') refreshToken: string) { return this.authService.refreshToken(refreshToken); } } diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index d1f45431..4c7aa710 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -9,19 +9,22 @@ import { User } from '../users/entities/user.entity'; import { UsersModule } from '../users/users.module'; import { JwtStrategy } from './strategies/jwt.strategy'; import { RolesGuard } from './guards/roles.guard'; +import { AuditLogsModule } from '../audit-logs/audit-logs.module'; @Module({ imports: [ ConfigModule, TypeOrmModule.forFeature([User]), UsersModule, + AuditLogsModule, JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (configService: ConfigService) => ({ secret: configService.get('JWT_SECRET', 'secretKey'), signOptions: { - expiresIn: (configService.get('JWT_EXPIRATION') ?? '7d') as any, + expiresIn: (configService.get('JWT_EXPIRATION') ?? + '7d') as any, }, }), }), diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts index 77f36bde..35bd8ea3 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; import { ConflictException, UnauthorizedException } from '@nestjs/common'; import { createMock } from '@golevelup/ts-jest'; import * as bcrypt from 'bcryptjs'; @@ -19,6 +20,7 @@ describe('AuthService', () => { firstName: 'John', lastName: 'Doe', role: UserRole.EMPLOYEE, + refreshTokenHash: null, }; beforeEach(async () => { @@ -33,6 +35,15 @@ describe('AuthService', () => { provide: JwtService, useValue: createMock(), }, + { + provide: ConfigService, + useValue: { + get: jest.fn( + (key: string, defaultValue?: string) => + defaultValue ?? 'mock-value', + ), + }, + }, ], }).compile(); @@ -43,7 +54,9 @@ describe('AuthService', () => { describe('register', () => { it('should throw ConflictException when email already exists', async () => { - jest.spyOn(usersService, 'findByEmail').mockResolvedValue(mockUser as any); + jest + .spyOn(usersService, 'findByEmail') + .mockResolvedValue(mockUser as any); await expect( authService.register({ @@ -74,11 +87,18 @@ describe('AuthService', () => { describe('login', () => { it('should throw UnauthorizedException on wrong password', async () => { - jest.spyOn(usersService, 'findByEmail').mockResolvedValue(mockUser as any); - jest.spyOn(bcrypt, 'compare').mockImplementation(() => Promise.resolve(false)); + jest + .spyOn(usersService, 'findByEmail') + .mockResolvedValue(mockUser as any); + jest + .spyOn(bcrypt, 'compare') + .mockImplementation(() => Promise.resolve(false)); await expect( - authService.login({ email: 'test@example.com', password: 'WrongPassword' }), + authService.login({ + email: 'test@example.com', + password: 'WrongPassword', + }), ).rejects.toThrow(UnauthorizedException); }); }); diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 7d77a40a..308b7a00 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -4,13 +4,17 @@ import { Injectable, NotFoundException, UnauthorizedException, + Optional, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; import * as bcrypt from 'bcryptjs'; import { UsersService } from '../users/users.service'; import { User, UserRole } from '../users/entities/user.entity'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; +import { AuditLogsService } from '../audit-logs/audit-logs.service'; +import * as crypto from 'crypto'; export interface AuthUser { id: string; @@ -25,6 +29,8 @@ export class AuthService { constructor( private readonly usersService: UsersService, private readonly jwtService: JwtService, + private readonly configService: ConfigService, + @Optional() private readonly auditLogsService?: AuditLogsService, ) {} private sanitizeUser(user: User): AuthUser { @@ -37,10 +43,34 @@ export class AuthService { }; } - private generateTokens(user: AuthUser) { + private generateAccessToken(user: AuthUser) { const payload = { sub: user.id, email: user.email, role: user.role }; - const accessToken = this.jwtService.sign(payload); - const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' }); + return this.jwtService.sign(payload, { + secret: this.configService.get('JWT_SECRET', 'secretKey'), + expiresIn: this.configService.get('JWT_EXPIRATION', '15m') as any, + }); + } + + private generateRefreshToken(user: AuthUser) { + const payload = { sub: user.id, email: user.email, type: 'refresh' }; + return this.jwtService.sign(payload, { + secret: this.configService.get( + 'JWT_REFRESH_SECRET', + 'refreshSecretKey', + ), + expiresIn: '7d' as any, + }); + } + + private hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); + } + + private async generateTokenPair(user: AuthUser) { + const accessToken = this.generateAccessToken(user); + const refreshToken = this.generateRefreshToken(user); + const hash = this.hashToken(refreshToken); + await this.usersService.setRefreshTokenHash(user.id, hash); return { accessToken, refreshToken }; } @@ -58,9 +88,17 @@ export class AuthService { role: UserRole.EMPLOYEE, }); - const tokens = this.generateTokens(user as AuthUser); + const authUser = this.sanitizeUser(user); + const tokens = await this.generateTokenPair(authUser); + await this.auditLogsService?.logAction({ + action: 'CREATED', + entityType: 'auth', + entityId: authUser.id, + actorId: authUser.id, + newValue: { event: 'register' }, + }); return { - user: this.sanitizeUser(user as User), + user: authUser, ...tokens, }; } @@ -76,9 +114,17 @@ export class AuthService { throw new UnauthorizedException('Invalid credentials'); } - const tokens = this.generateTokens(this.sanitizeUser(user)); + const authUser = this.sanitizeUser(user); + const tokens = await this.generateTokenPair(authUser); + await this.auditLogsService?.logAction({ + action: 'CREATED', + entityType: 'auth', + entityId: authUser.id, + actorId: authUser.id, + newValue: { event: 'login' }, + }); return { - user: this.sanitizeUser(user), + user: authUser, ...tokens, }; } @@ -89,15 +135,41 @@ export class AuthService { async refreshToken(refreshToken: string) { try { - const payload = this.jwtService.verify(refreshToken); + const payload = this.jwtService.verify(refreshToken, { + secret: this.configService.get( + 'JWT_REFRESH_SECRET', + 'refreshSecretKey', + ), + }); + const user = await this.usersService.findById(payload.sub); - const tokens = this.generateTokens(this.sanitizeUser(user)); + if (!user.refreshTokenHash) { + throw new UnauthorizedException('Refresh token has been revoked'); + } + + const submittedHash = this.hashToken(refreshToken); + if (submittedHash !== user.refreshTokenHash) { + // Token reuse detected — revoke session + await this.usersService.setRefreshTokenHash(user.id, null); + throw new UnauthorizedException( + 'Refresh token reuse detected — session revoked', + ); + } + + const authUser = this.sanitizeUser(user); + const tokens = await this.generateTokenPair(authUser); return tokens; - } catch { + } catch (err) { + if (err instanceof UnauthorizedException) throw err; throw new UnauthorizedException('Invalid or expired refresh token'); } } + async logout(userId: string) { + await this.usersService.setRefreshTokenHash(userId, null); + return { message: 'Logged out successfully' }; + } + async forgotPassword(email: string) { if (!email) { throw new BadRequestException('Email is required'); diff --git a/backend/src/auth/decorators/get-user.decorator.ts b/backend/src/auth/decorators/get-user.decorator.ts index 6dfaa18d..2a56a2ab 100644 --- a/backend/src/auth/decorators/get-user.decorator.ts +++ b/backend/src/auth/decorators/get-user.decorator.ts @@ -1,7 +1,12 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; export const GetUser = createParamDecorator( - (data: keyof import('../interfaces/auth-response.interface').AuthUser | undefined, ctx: ExecutionContext) => { + ( + data: + | keyof import('../interfaces/auth-response.interface').AuthUser + | undefined, + ctx: ExecutionContext, + ) => { const request = ctx.switchToHttp().getRequest(); const user = request.user; return data ? user?.[data] : user; diff --git a/backend/src/auth/dto/login.dto.ts b/backend/src/auth/dto/login.dto.ts index 99e13f35..7b1fd4eb 100644 --- a/backend/src/auth/dto/login.dto.ts +++ b/backend/src/auth/dto/login.dto.ts @@ -11,4 +11,4 @@ export class LoginDto { @IsString() @IsNotEmpty() password: string; -} \ No newline at end of file +} diff --git a/backend/src/auth/dto/register.dto.ts b/backend/src/auth/dto/register.dto.ts index ae59b82f..68909471 100644 --- a/backend/src/auth/dto/register.dto.ts +++ b/backend/src/auth/dto/register.dto.ts @@ -21,4 +21,4 @@ export class RegisterDto { @IsString() @MinLength(8, { message: 'Password must be at least 8 characters long' }) password: string; -} \ No newline at end of file +} diff --git a/backend/src/auth/guards/roles.guard.spec.ts b/backend/src/auth/guards/roles.guard.spec.ts index 8f6fe383..e2e7faca 100644 --- a/backend/src/auth/guards/roles.guard.spec.ts +++ b/backend/src/auth/guards/roles.guard.spec.ts @@ -53,4 +53,4 @@ describe('RolesGuard', () => { expect(guard.canActivate(context)).toBe(true); }); -}); \ No newline at end of file +}); diff --git a/backend/src/auth/guards/roles.guard.ts b/backend/src/auth/guards/roles.guard.ts index 8faa380a..a2c18a37 100644 --- a/backend/src/auth/guards/roles.guard.ts +++ b/backend/src/auth/guards/roles.guard.ts @@ -25,7 +25,9 @@ export class RolesGuard implements CanActivate { } const hasRole = requiredRoles.includes(user.role); if (!hasRole) { - throw new ForbiddenException(`Requires role: ${requiredRoles.join(', ')}`); + throw new ForbiddenException( + `Requires role: ${requiredRoles.join(', ')}`, + ); } return true; } diff --git a/backend/src/auth/interfaces/auth-response.interface.ts b/backend/src/auth/interfaces/auth-response.interface.ts index 240f4ae4..30b97527 100644 --- a/backend/src/auth/interfaces/auth-response.interface.ts +++ b/backend/src/auth/interfaces/auth-response.interface.ts @@ -14,4 +14,4 @@ export interface AuthResponse { export interface JwtPayload { sub: string; email: string; -} \ No newline at end of file +} diff --git a/backend/src/auth/strategies/jwt.strategy.ts b/backend/src/auth/strategies/jwt.strategy.ts index 33a73b73..49c12dcb 100644 --- a/backend/src/auth/strategies/jwt.strategy.ts +++ b/backend/src/auth/strategies/jwt.strategy.ts @@ -25,4 +25,4 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } return user; } -} \ No newline at end of file +} diff --git a/backend/src/branches/branches.controller.ts b/backend/src/branches/branches.controller.ts index 104bf469..11f19adf 100644 --- a/backend/src/branches/branches.controller.ts +++ b/backend/src/branches/branches.controller.ts @@ -1,26 +1,36 @@ import { Controller, Get, Post, Body, Param } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; import { BranchesService } from './branches.service'; @ApiTags('branches') +@ApiBearerAuth('JWT-auth') @Controller('branches') export class BranchesController { constructor(private readonly branchesService: BranchesService) {} @Get() @ApiOperation({ summary: 'List all branches' }) + @ApiResponse({ status: 200, description: 'List of branches' }) findAll() { return this.branchesService.findAll(); } @Post() @ApiOperation({ summary: 'Create a branch' }) + @ApiResponse({ status: 201, description: 'Branch created' }) create(@Body() dto: any) { return this.branchesService.create(dto); } @Get(':id') @ApiOperation({ summary: 'Get branch details' }) + @ApiResponse({ status: 200, description: 'Branch details' }) + @ApiResponse({ status: 404, description: 'Branch not found' }) findOne(@Param('id') id: string) { return this.branchesService.findById(id); } diff --git a/backend/src/branches/entities/branch.entity.ts b/backend/src/branches/entities/branch.entity.ts index 08fca77b..dd8736c9 100644 --- a/backend/src/branches/entities/branch.entity.ts +++ b/backend/src/branches/entities/branch.entity.ts @@ -1,4 +1,10 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; @Entity('branches') export class Branch { diff --git a/backend/src/categories/categories.controller.ts b/backend/src/categories/categories.controller.ts index 7d116eea..978f5c06 100644 --- a/backend/src/categories/categories.controller.ts +++ b/backend/src/categories/categories.controller.ts @@ -1,38 +1,58 @@ -import { Controller, Get, Post, Patch, Delete, Body, Param } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; import { CategoriesService } from './categories.service'; @ApiTags('categories') +@ApiBearerAuth('JWT-auth') @Controller('categories') export class CategoriesController { constructor(private readonly categoriesService: CategoriesService) {} @Get() @ApiOperation({ summary: 'List all categories' }) + @ApiResponse({ status: 200, description: 'List of categories' }) findAll() { return this.categoriesService.findAll(); } @Post() @ApiOperation({ summary: 'Create a category' }) + @ApiResponse({ status: 201, description: 'Category created' }) create(@Body() dto: any) { return this.categoriesService.create(dto); } @Get(':id') @ApiOperation({ summary: 'Get category details' }) + @ApiResponse({ status: 200, description: 'Category details' }) + @ApiResponse({ status: 404, description: 'Category not found' }) findOne(@Param('id') id: string) { return this.categoriesService.findById(id); } @Patch(':id') @ApiOperation({ summary: 'Update a category' }) + @ApiResponse({ status: 200, description: 'Category updated' }) update(@Param('id') id: string, @Body() dto: any) { return this.categoriesService.update(id, dto); } @Delete(':id') @ApiOperation({ summary: 'Delete a category' }) + @ApiResponse({ status: 200, description: 'Category deleted' }) delete(@Param('id') id: string) { return this.categoriesService.delete(id); } diff --git a/backend/src/categories/categories.service.ts b/backend/src/categories/categories.service.ts index 3f45fb03..943af660 100644 --- a/backend/src/categories/categories.service.ts +++ b/backend/src/categories/categories.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Category } from './entities/category.entity'; diff --git a/backend/src/categories/entities/category.entity.ts b/backend/src/categories/entities/category.entity.ts index cef6e6f5..fa7c7e2d 100644 --- a/backend/src/categories/entities/category.entity.ts +++ b/backend/src/categories/entities/category.entity.ts @@ -1,4 +1,10 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; @Entity('categories') export class Category { diff --git a/backend/src/common/dto/export-query.dto.ts b/backend/src/common/dto/export-query.dto.ts index c1c285d0..2f8c538f 100644 --- a/backend/src/common/dto/export-query.dto.ts +++ b/backend/src/common/dto/export-query.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { IsEnum, IsOptional } from 'class-validator'; export enum ExportFormat { CSV = 'csv', @@ -11,4 +11,4 @@ export class ExportQueryDto { @IsEnum(ExportFormat) @IsOptional() format?: ExportFormat = ExportFormat.CSV; -} \ No newline at end of file +} diff --git a/backend/src/common/interceptors/response-transform.interceptor.ts b/backend/src/common/interceptors/response-transform.interceptor.ts index 6392d883..6f60be3e 100644 --- a/backend/src/common/interceptors/response-transform.interceptor.ts +++ b/backend/src/common/interceptors/response-transform.interceptor.ts @@ -14,9 +14,10 @@ export interface ResponseEnvelope { } @Injectable() -export class ResponseTransformInterceptor - implements NestInterceptor> -{ +export class ResponseTransformInterceptor implements NestInterceptor< + T, + ResponseEnvelope +> { intercept( context: ExecutionContext, next: CallHandler, diff --git a/backend/src/common/services/export.service.ts b/backend/src/common/services/export.service.ts index 601c08c8..99d00387 100644 --- a/backend/src/common/services/export.service.ts +++ b/backend/src/common/services/export.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Response } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Response as ExpressResponse } from 'express'; import * as ExcelJS from 'exceljs'; import { Readable } from 'stream'; @@ -26,7 +26,8 @@ export class ExportService { res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); // Write header row - const headerRow = columns.map((col) => this.escapeCsvField(col.header)).join(',') + '\n'; + const headerRow = + columns.map((col) => this.escapeCsvField(col.header)).join(',') + '\n'; res.write(headerRow); // Stream rows dynamically @@ -86,13 +87,18 @@ export class ExportService { */ private escapeCsvField(val: any): string { if (val === null || val === undefined) return '""'; - + let str = typeof val === 'object' ? JSON.stringify(val) : String(val); - if (str.includes('"') || str.includes(',') || str.includes('\n') || str.includes('\r')) { + if ( + str.includes('"') || + str.includes(',') || + str.includes('\n') || + str.includes('\r') + ) { str = `"${str.replace(/"/g, '""')}"`; } else { str = `"${str}"`; } return str; } -} \ No newline at end of file +} diff --git a/backend/src/departments/departments.controller.ts b/backend/src/departments/departments.controller.ts index 679a47bd..2691bd5a 100644 --- a/backend/src/departments/departments.controller.ts +++ b/backend/src/departments/departments.controller.ts @@ -1,38 +1,58 @@ -import { Controller, Get, Post, Patch, Delete, Body, Param } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; import { DepartmentsService } from './departments.service'; @ApiTags('departments') +@ApiBearerAuth('JWT-auth') @Controller('departments') export class DepartmentsController { constructor(private readonly deptService: DepartmentsService) {} @Get() @ApiOperation({ summary: 'List all departments' }) + @ApiResponse({ status: 200, description: 'List of departments' }) findAll() { return this.deptService.findAll(); } @Post() @ApiOperation({ summary: 'Create a department' }) + @ApiResponse({ status: 201, description: 'Department created' }) create(@Body() dto: any) { return this.deptService.create(dto); } @Get(':id') @ApiOperation({ summary: 'Get department details' }) + @ApiResponse({ status: 200, description: 'Department details' }) + @ApiResponse({ status: 404, description: 'Department not found' }) findOne(@Param('id') id: string) { return this.deptService.findById(id); } @Patch(':id') @ApiOperation({ summary: 'Update a department' }) + @ApiResponse({ status: 200, description: 'Department updated' }) update(@Param('id') id: string, @Body() dto: any) { return this.deptService.update(id, dto); } @Delete(':id') @ApiOperation({ summary: 'Delete a department' }) + @ApiResponse({ status: 200, description: 'Department deleted' }) delete(@Param('id') id: string) { return this.deptService.delete(id); } diff --git a/backend/src/departments/departments.module.ts b/backend/src/departments/departments.module.ts index 2eb20d58..77cb05ed 100644 --- a/backend/src/departments/departments.module.ts +++ b/backend/src/departments/departments.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Department } from './entities/department.entity'; import { DepartmentsService } from './departments.service'; import { DepartmentsController } from './departments.controller'; +import { AuditLogsModule } from '../audit-logs/audit-logs.module'; @Module({ - imports: [TypeOrmModule.forFeature([Department])], + imports: [TypeOrmModule.forFeature([Department]), AuditLogsModule], providers: [DepartmentsService], controllers: [DepartmentsController], exports: [DepartmentsService], diff --git a/backend/src/departments/departments.service.ts b/backend/src/departments/departments.service.ts index d88a5153..aa9f3eca 100644 --- a/backend/src/departments/departments.service.ts +++ b/backend/src/departments/departments.service.ts @@ -1,13 +1,20 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + BadRequestException, + Optional, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Department } from './entities/department.entity'; +import { AuditLogsService } from '../audit-logs/audit-logs.service'; @Injectable() export class DepartmentsService { constructor( @InjectRepository(Department) private readonly deptRepo: Repository, + @Optional() private readonly auditLogsService?: AuditLogsService, ) {} async findAll() { @@ -20,25 +27,51 @@ export class DepartmentsService { return dept; } - async create(dto: Partial) { + async create(dto: Partial, actorId?: string) { if (dto.parentDepartmentId) { await this.findById(dto.parentDepartmentId); } const dept = this.deptRepo.create(dto); - return this.deptRepo.save(dept); + const saved = await this.deptRepo.save(dept); + await this.auditLogsService?.logAction({ + action: 'CREATED', + entityType: 'department', + entityId: saved.id, + actorId, + newValue: { name: saved.name, code: saved.code }, + }); + return saved; } - async update(id: string, dto: Partial) { + async update(id: string, dto: Partial, actorId?: string) { const dept = await this.findById(id); if (dto.parentDepartmentId && dto.parentDepartmentId === id) { throw new BadRequestException('A department cannot be its own parent'); } + const previous = { name: dept.name, code: dept.code }; Object.assign(dept, dto); - return this.deptRepo.save(dept); + const saved = await this.deptRepo.save(dept); + await this.auditLogsService?.logAction({ + action: 'UPDATED', + entityType: 'department', + entityId: id, + actorId, + previousValue: previous, + newValue: { name: saved.name, code: saved.code }, + }); + return saved; } - async delete(id: string) { + async delete(id: string, actorId?: string) { const dept = await this.findById(id); - return this.deptRepo.remove(dept); + await this.deptRepo.remove(dept); + await this.auditLogsService?.logAction({ + action: 'DELETED', + entityType: 'department', + entityId: id, + actorId, + previousValue: { name: dept.name, code: dept.code }, + }); + return dept; } } diff --git a/backend/src/departments/entities/department.entity.ts b/backend/src/departments/entities/department.entity.ts index a29cf024..92643ae0 100644 --- a/backend/src/departments/entities/department.entity.ts +++ b/backend/src/departments/entities/department.entity.ts @@ -1,4 +1,10 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; @Entity('departments') export class Department { diff --git a/backend/src/femaleotaku.spec.ts b/backend/src/femaleotaku.spec.ts index 0619cf04..08d7b231 100644 --- a/backend/src/femaleotaku.spec.ts +++ b/backend/src/femaleotaku.spec.ts @@ -1,12 +1,17 @@ import { LocationsService } from './locations/locations.service'; import { CategoriesService } from './categories/categories.service'; -import { AssetLifecycleService, AssetStatus } from './assets/asset-lifecycle.service'; +import { + AssetLifecycleService, + AssetStatus, +} from './assets/asset-lifecycle.service'; describe('femaleotaku Modules (BE-88, BE-87, BE-85, BE-84)', () => { it('LocationsService creates location', async () => { const mockRepo = { create: jest.fn().mockImplementation((dto) => dto), - save: jest.fn().mockImplementation((dto) => Promise.resolve({ id: 'loc-1', ...dto })), + save: jest + .fn() + .mockImplementation((dto) => Promise.resolve({ id: 'loc-1', ...dto })), }; const service = new LocationsService(mockRepo as any); const loc = await service.create({ name: 'Building A', code: 'BLD-A' }); @@ -16,21 +21,34 @@ describe('femaleotaku Modules (BE-88, BE-87, BE-85, BE-84)', () => { it('CategoriesService manages depreciation defaults', async () => { const mockRepo = { create: jest.fn().mockImplementation((dto) => dto), - save: jest.fn().mockImplementation((dto) => Promise.resolve({ id: 'cat-1', ...dto })), + save: jest + .fn() + .mockImplementation((dto) => Promise.resolve({ id: 'cat-1', ...dto })), }; const service = new CategoriesService(mockRepo as any); - const cat = await service.create({ name: 'Laptops', code: 'LAP', defaultDepreciationRate: 20, defaultUsefulLifeMonths: 36 }); + const cat = await service.create({ + name: 'Laptops', + code: 'LAP', + defaultDepreciationRate: 20, + defaultUsefulLifeMonths: 36, + }); expect(cat.defaultUsefulLifeMonths).toBe(36); }); it('AssetLifecycleService validates state transitions and records history', () => { const service = new AssetLifecycleService(); - expect(service.validateTransition(AssetStatus.AVAILABLE, AssetStatus.ASSIGNED)).toBe(true); - expect(() => service.validateTransition(AssetStatus.DISPOSED, AssetStatus.ASSIGNED)).toThrow( - 'Cannot transition asset status from DISPOSED to ASSIGNED', - ); + expect( + service.validateTransition(AssetStatus.AVAILABLE, AssetStatus.ASSIGNED), + ).toBe(true); + expect(() => + service.validateTransition(AssetStatus.DISPOSED, AssetStatus.ASSIGNED), + ).toThrow('Cannot transition asset status from DISPOSED to ASSIGNED'); - const history = service.recordHistory('asset-1', { eventType: 'STATUS_CHANGED', actorUserId: 'user-1', note: 'Assigned to Jane' }); + const history = service.recordHistory('asset-1', { + eventType: 'STATUS_CHANGED', + actorUserId: 'user-1', + note: 'Assigned to Jane', + }); expect(history.id).toBeDefined(); expect(service.getHistory('asset-1').length).toBe(1); }); diff --git a/backend/src/gateway/events.gateway.ts b/backend/src/gateway/events.gateway.ts index edcf30eb..b8e7a2e7 100644 --- a/backend/src/gateway/events.gateway.ts +++ b/backend/src/gateway/events.gateway.ts @@ -27,8 +27,11 @@ import { Notification } from '../notifications/entities/notification.entity'; */ @WebSocketGateway({ cors: (req, callback) => { - const configService = (EventsGateway as any).configService as ConfigService | undefined; - const origin = configService?.get('FRONTEND_URL') || 'http://localhost:3000'; + const configService = (EventsGateway as any).configService as + | ConfigService + | undefined; + const origin = + configService?.get('FRONTEND_URL') || 'http://localhost:3000'; callback(null, { origin, credentials: true }); }, }) @@ -80,13 +83,16 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { } const queryToken = client.handshake.query.token; if (typeof queryToken === 'string') return queryToken; - if (Array.isArray(queryToken) && queryToken.length > 0) return queryToken[0]; + if (Array.isArray(queryToken) && queryToken.length > 0) + return queryToken[0]; return undefined; } @OnEvent('notification.new') emitNotificationNew(notification: Notification) { - this.server.to(`user:${notification.userId}`).emit('notification.new', notification); + this.server + .to(`user:${notification.userId}`) + .emit('notification.new', notification); } @OnEvent('asset.status_changed') @@ -97,10 +103,14 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { newStatus: string; }) { if (payload.departmentId) { - this.server.to(`department:${payload.departmentId}`).emit('asset.status_changed', payload); + this.server + .to(`department:${payload.departmentId}`) + .emit('asset.status_changed', payload); } // Also notify any user watching the asset via a public asset room. - this.server.to(`asset:${payload.assetId}`).emit('asset.status_changed', payload); + this.server + .to(`asset:${payload.assetId}`) + .emit('asset.status_changed', payload); } @OnEvent('maintenance.due') @@ -112,7 +122,9 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { scheduledDate: string; }) { if (payload.departmentId) { - this.server.to(`department:${payload.departmentId}`).emit('maintenance.due', payload); + this.server + .to(`department:${payload.departmentId}`) + .emit('maintenance.due', payload); } this.server.to(`asset:${payload.assetId}`).emit('maintenance.due', payload); } diff --git a/backend/src/ibinola.spec.ts b/backend/src/ibinola.spec.ts index 6db203f2..ed1f44b0 100644 --- a/backend/src/ibinola.spec.ts +++ b/backend/src/ibinola.spec.ts @@ -23,7 +23,9 @@ describe('ibinola Modules (BE-96, BE-95, BE-94, BE-93)', () => { it('VendorsService creates vendor records', async () => { const mockRepo = { create: jest.fn().mockImplementation((dto) => dto), - save: jest.fn().mockImplementation((dto) => Promise.resolve({ id: 'v-1', ...dto })), + save: jest + .fn() + .mockImplementation((dto) => Promise.resolve({ id: 'v-1', ...dto })), }; const service = new VendorsService(mockRepo as any); const vendor = await service.create({ name: 'Acme Corp', code: 'ACME' }); diff --git a/backend/src/inventory/entities/inventory-item.entity.ts b/backend/src/inventory/entities/inventory-item.entity.ts index 506aa3bb..67221549 100644 --- a/backend/src/inventory/entities/inventory-item.entity.ts +++ b/backend/src/inventory/entities/inventory-item.entity.ts @@ -1,4 +1,10 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; @Entity('inventory_items') export class InventoryItem { diff --git a/backend/src/inventory/inventory.controller.ts b/backend/src/inventory/inventory.controller.ts index a9f7ab15..b8b59385 100644 --- a/backend/src/inventory/inventory.controller.ts +++ b/backend/src/inventory/inventory.controller.ts @@ -1,38 +1,60 @@ import { Controller, Get, Post, Param, Body, Req } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; import { InventoryService } from './inventory.service'; @ApiTags('inventory') +@ApiBearerAuth('JWT-auth') @Controller('inventory') export class InventoryController { constructor(private readonly inventoryService: InventoryService) {} @Get() @ApiOperation({ summary: 'List all inventory items' }) + @ApiResponse({ status: 200, description: 'List of inventory items' }) findAll() { return this.inventoryService.findAll(); } @Post() @ApiOperation({ summary: 'Create an inventory item' }) + @ApiResponse({ status: 201, description: 'Inventory item created' }) create(@Body() dto: any) { return this.inventoryService.create(dto); } @Get(':id') @ApiOperation({ summary: 'Get item details' }) + @ApiResponse({ status: 200, description: 'Item details' }) + @ApiResponse({ status: 404, description: 'Item not found' }) findOne(@Param('id') id: string) { return this.inventoryService.findById(id); } @Post(':id/movements') @ApiOperation({ summary: 'Record stock movement' }) + @ApiResponse({ status: 201, description: 'Movement recorded' }) recordMovement( @Param('id') id: string, - @Body() body: { type: 'IN' | 'OUT' | 'ADJUSTMENT'; quantity: number; reason?: string }, + @Body() + body: { + type: 'IN' | 'OUT' | 'ADJUSTMENT'; + quantity: number; + reason?: string; + }, @Req() req: any, ) { const userId = req.user?.id || 'usr-1'; - return this.inventoryService.recordMovement(id, body.type, body.quantity, body.reason, userId); + return this.inventoryService.recordMovement( + id, + body.type, + body.quantity, + body.reason, + userId, + ); } } diff --git a/backend/src/inventory/inventory.service.ts b/backend/src/inventory/inventory.service.ts index 2d18a63c..2d9b33ca 100644 --- a/backend/src/inventory/inventory.service.ts +++ b/backend/src/inventory/inventory.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { InventoryItem } from './entities/inventory-item.entity'; @@ -27,7 +31,13 @@ export class InventoryService { return this.itemRepo.save(item); } - async recordMovement(itemId: string, type: 'IN' | 'OUT' | 'ADJUSTMENT', quantity: number, reason?: string, userId?: string) { + async recordMovement( + itemId: string, + type: 'IN' | 'OUT' | 'ADJUSTMENT', + quantity: number, + reason?: string, + userId?: string, + ) { const item = await this.findById(itemId); let newQty = item.quantityOnHand; @@ -43,7 +53,15 @@ export class InventoryService { await this.itemRepo.save(item); const mList = this.movements.get(itemId) || []; - const mov = { id: `m_${Date.now()}`, itemId, type, quantity, reason, actorUserId: userId || 'usr-1', timestamp: new Date() }; + const mov = { + id: `m_${Date.now()}`, + itemId, + type, + quantity, + reason, + actorUserId: userId || 'usr-1', + timestamp: new Date(), + }; mList.unshift(mov); this.movements.set(itemId, mList); diff --git a/backend/src/kike-alt.spec.ts b/backend/src/kike-alt.spec.ts index afeedf7a..191f37e3 100644 --- a/backend/src/kike-alt.spec.ts +++ b/backend/src/kike-alt.spec.ts @@ -7,7 +7,9 @@ describe('kike-alt Modules (BE-79, BE-78, BE-77, BE-76)', () => { const mockRepo = { findOne: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation((dto) => dto), - save: jest.fn().mockImplementation((dto) => Promise.resolve({ id: 'u-1', ...dto })), + save: jest + .fn() + .mockImplementation((dto) => Promise.resolve({ id: 'u-1', ...dto })), }; const service = new UsersService(mockRepo as any); @@ -26,7 +28,9 @@ describe('kike-alt Modules (BE-79, BE-78, BE-77, BE-76)', () => { it('UsersService refuses self role change', async () => { const service = new UsersService({} as any); - await expect(service.updateRole('u-1', UserRole.ADMIN, 'u-1')).rejects.toThrow('Cannot change your own role'); + await expect( + service.updateRole('u-1', UserRole.ADMIN, 'u-1'), + ).rejects.toThrow('Cannot change your own role'); }); it('HealthController returns liveness and readiness status', () => { diff --git a/backend/src/licenses/entities/license.entity.ts b/backend/src/licenses/entities/license.entity.ts index d3d2b738..952b046c 100644 --- a/backend/src/licenses/entities/license.entity.ts +++ b/backend/src/licenses/entities/license.entity.ts @@ -1,4 +1,10 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; export enum LicenseType { PERPETUAL = 'PERPETUAL', @@ -19,7 +25,11 @@ export class License { @Column({ select: false }) licenseKey: string; - @Column({ type: 'enum', enum: LicenseType, default: LicenseType.SUBSCRIPTION }) + @Column({ + type: 'enum', + enum: LicenseType, + default: LicenseType.SUBSCRIPTION, + }) type: LicenseType; @Column({ type: 'integer', default: 1 }) diff --git a/backend/src/licenses/licenses.controller.ts b/backend/src/licenses/licenses.controller.ts index e6ede4f1..8e039922 100644 --- a/backend/src/licenses/licenses.controller.ts +++ b/backend/src/licenses/licenses.controller.ts @@ -1,32 +1,43 @@ import { Controller, Get, Post, Param, Body } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; import { LicensesService } from './licenses.service'; @ApiTags('licenses') +@ApiBearerAuth('JWT-auth') @Controller('licenses') export class LicensesController { constructor(private readonly licensesService: LicensesService) {} @Get() @ApiOperation({ summary: 'List software licenses (redacts license keys)' }) + @ApiResponse({ status: 200, description: 'List of licenses' }) findAll() { return this.licensesService.findAll(); } @Post() @ApiOperation({ summary: 'Create a software license' }) + @ApiResponse({ status: 201, description: 'License created' }) create(@Body() dto: any) { return this.licensesService.create(dto); } @Get(':id') @ApiOperation({ summary: 'Get license details' }) + @ApiResponse({ status: 200, description: 'License details' }) + @ApiResponse({ status: 404, description: 'License not found' }) findOne(@Param('id') id: string) { return this.licensesService.findById(id); } @Post(':id/assign') @ApiOperation({ summary: 'Assign a license seat' }) + @ApiResponse({ status: 201, description: 'License seat assigned' }) assign(@Param('id') id: string, @Body('assigneeId') assigneeId: string) { return this.licensesService.assign(id, assigneeId); } diff --git a/backend/src/licenses/licenses.service.ts b/backend/src/licenses/licenses.service.ts index 2adb6c3e..848d3d77 100644 --- a/backend/src/licenses/licenses.service.ts +++ b/backend/src/licenses/licenses.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + ConflictException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { License } from './entities/license.entity'; @@ -25,10 +29,13 @@ export class LicensesService { return this.licenseRepo.save(lic); } - async assign(id: string, userIdOrAssetId: string) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async assign(id: string, _userIdOrAssetId: string) { const lic = await this.findById(id); if (lic.seatsUsed >= lic.seatsTotal) { - throw new ConflictException('No available seats remaining for this license'); + throw new ConflictException( + 'No available seats remaining for this license', + ); } lic.seatsUsed += 1; return this.licenseRepo.save(lic); diff --git a/backend/src/locations/entities/location.entity.ts b/backend/src/locations/entities/location.entity.ts index d39b765e..961d4a98 100644 --- a/backend/src/locations/entities/location.entity.ts +++ b/backend/src/locations/entities/location.entity.ts @@ -1,4 +1,10 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; export enum LocationType { BUILDING = 'BUILDING', diff --git a/backend/src/locations/locations.controller.ts b/backend/src/locations/locations.controller.ts index 7194d2d2..c36655f9 100644 --- a/backend/src/locations/locations.controller.ts +++ b/backend/src/locations/locations.controller.ts @@ -1,38 +1,58 @@ -import { Controller, Get, Post, Patch, Delete, Body, Param } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; import { LocationsService } from './locations.service'; @ApiTags('locations') +@ApiBearerAuth('JWT-auth') @Controller('locations') export class LocationsController { constructor(private readonly locationsService: LocationsService) {} @Get() @ApiOperation({ summary: 'List all locations' }) + @ApiResponse({ status: 200, description: 'List of locations' }) findAll() { return this.locationsService.findAll(); } @Post() @ApiOperation({ summary: 'Create a location' }) + @ApiResponse({ status: 201, description: 'Location created' }) create(@Body() dto: any) { return this.locationsService.create(dto); } @Get(':id') @ApiOperation({ summary: 'Get location details' }) + @ApiResponse({ status: 200, description: 'Location details' }) + @ApiResponse({ status: 404, description: 'Location not found' }) findOne(@Param('id') id: string) { return this.locationsService.findById(id); } @Patch(':id') @ApiOperation({ summary: 'Update a location' }) + @ApiResponse({ status: 200, description: 'Location updated' }) update(@Param('id') id: string, @Body() dto: any) { return this.locationsService.update(id, dto); } @Delete(':id') @ApiOperation({ summary: 'Delete a location' }) + @ApiResponse({ status: 200, description: 'Location deleted' }) delete(@Param('id') id: string) { return this.locationsService.delete(id); } diff --git a/backend/src/locations/locations.service.ts b/backend/src/locations/locations.service.ts index 9d203284..8b03ee24 100644 --- a/backend/src/locations/locations.service.ts +++ b/backend/src/locations/locations.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Location } from './entities/location.entity'; diff --git a/backend/src/main.ts b/backend/src/main.ts index 7cf335fc..27ba7586 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -23,34 +23,39 @@ async function bootstrap() { }), ); - // Swagger Configuration - const config = new DocumentBuilder() - .setTitle('Your API Title') - .setDescription('Your API description with all available endpoints') - .setVersion('1.0') - .addBearerAuth( - { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - name: 'JWT', - description: 'Enter JWT token', - in: 'header', - }, - 'JWT-auth', // This name here is important for matching up with @ApiBearerAuth() in your controllers - ) - .build(); + // Swagger Configuration — only in non-production environments + if (process.env.NODE_ENV !== 'production') { + const config = new DocumentBuilder() + .setTitle('AssetsUp API') + .setDescription('Asset management platform API documentation') + .setVersion('1.0') + .addBearerAuth( + { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + name: 'JWT', + description: 'Enter JWT token', + in: 'header', + }, + 'JWT-auth', + ) + .build(); - const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api/docs', app, document, { - swaggerOptions: { - persistAuthorization: true, // Keeps auth token after page refresh - }, - }); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api/docs', app, document, { + swaggerOptions: { + persistAuthorization: true, + }, + jsonDocumentUrl: 'api/docs-json', + }); + } const port = process.env.PORT ?? 6003; await app.listen(port); console.log(`Application is running on: http://localhost:${port}`); - console.log(`Swagger docs available at: http://localhost:${port}/api/docs`); + if (process.env.NODE_ENV !== 'production') { + console.log(`Swagger docs available at: http://localhost:${port}/api/docs`); + } } bootstrap(); diff --git a/backend/src/maintenance/entities/maintenance-record.entity.ts b/backend/src/maintenance/entities/maintenance-record.entity.ts index 9bafdfd5..4990e209 100644 --- a/backend/src/maintenance/entities/maintenance-record.entity.ts +++ b/backend/src/maintenance/entities/maintenance-record.entity.ts @@ -1,4 +1,10 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; export enum MaintenanceStatus { SCHEDULED = 'SCHEDULED', @@ -21,7 +27,11 @@ export class MaintenanceRecord { @Column({ nullable: true }) description?: string; - @Column({ type: 'enum', enum: MaintenanceStatus, default: MaintenanceStatus.SCHEDULED }) + @Column({ + type: 'enum', + enum: MaintenanceStatus, + default: MaintenanceStatus.SCHEDULED, + }) status: MaintenanceStatus; @Column({ nullable: true }) diff --git a/backend/src/maintenance/maintenance.controller.ts b/backend/src/maintenance/maintenance.controller.ts index 6c8865a1..936b272a 100644 --- a/backend/src/maintenance/maintenance.controller.ts +++ b/backend/src/maintenance/maintenance.controller.ts @@ -1,32 +1,43 @@ import { Controller, Get, Post, Patch, Body, Param } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; import { MaintenanceService } from './maintenance.service'; @ApiTags('maintenance') +@ApiBearerAuth('JWT-auth') @Controller('maintenance') export class MaintenanceController { constructor(private readonly maintenanceService: MaintenanceService) {} @Get() @ApiOperation({ summary: 'List all maintenance records' }) + @ApiResponse({ status: 200, description: 'List of maintenance records' }) findAll() { return this.maintenanceService.findAll(); } @Post() @ApiOperation({ summary: 'Create a maintenance record' }) + @ApiResponse({ status: 201, description: 'Maintenance record created' }) create(@Body() dto: any) { return this.maintenanceService.create(dto); } @Get(':id') @ApiOperation({ summary: 'Get maintenance record details' }) + @ApiResponse({ status: 200, description: 'Maintenance record details' }) + @ApiResponse({ status: 404, description: 'Record not found' }) findOne(@Param('id') id: string) { return this.maintenanceService.findById(id); } @Patch(':id') @ApiOperation({ summary: 'Update a maintenance record' }) + @ApiResponse({ status: 200, description: 'Maintenance record updated' }) update(@Param('id') id: string, @Body() dto: any) { return this.maintenanceService.update(id, dto); } diff --git a/backend/src/maintenance/maintenance.service.ts b/backend/src/maintenance/maintenance.service.ts index 44ed1b91..bf4cfd83 100644 --- a/backend/src/maintenance/maintenance.service.ts +++ b/backend/src/maintenance/maintenance.service.ts @@ -16,7 +16,8 @@ export class MaintenanceService { async findById(id: string) { const record = await this.maintenanceRepo.findOne({ where: { id } }); - if (!record) throw new NotFoundException(`Maintenance record ${id} not found`); + if (!record) + throw new NotFoundException(`Maintenance record ${id} not found`); return record; } diff --git a/backend/src/notifications/entities/notification.entity.ts b/backend/src/notifications/entities/notification.entity.ts index d62cbfe2..6d0f8c6b 100644 --- a/backend/src/notifications/entities/notification.entity.ts +++ b/backend/src/notifications/entities/notification.entity.ts @@ -26,7 +26,11 @@ export class Notification { @Column({ nullable: true }) message?: string; - @Column({ type: 'enum', enum: NotificationType, default: NotificationType.INFO }) + @Column({ + type: 'enum', + enum: NotificationType, + default: NotificationType.INFO, + }) type: NotificationType; @Column({ default: false }) diff --git a/backend/src/purchase-orders/entities/purchase-order.entity.ts b/backend/src/purchase-orders/entities/purchase-order.entity.ts index fb2c3e76..4a414778 100644 --- a/backend/src/purchase-orders/entities/purchase-order.entity.ts +++ b/backend/src/purchase-orders/entities/purchase-order.entity.ts @@ -1,4 +1,10 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; export enum POStatus { DRAFT = 'DRAFT', diff --git a/backend/src/purchase-orders/purchase-orders.controller.ts b/backend/src/purchase-orders/purchase-orders.controller.ts index 36662de0..02f97722 100644 --- a/backend/src/purchase-orders/purchase-orders.controller.ts +++ b/backend/src/purchase-orders/purchase-orders.controller.ts @@ -1,32 +1,43 @@ import { Controller, Get, Post, Param, Body } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; import { PurchaseOrdersService } from './purchase-orders.service'; @ApiTags('purchase-orders') +@ApiBearerAuth('JWT-auth') @Controller('purchase-orders') export class PurchaseOrdersController { constructor(private readonly poService: PurchaseOrdersService) {} @Get() @ApiOperation({ summary: 'List all purchase orders' }) + @ApiResponse({ status: 200, description: 'List of purchase orders' }) findAll() { return this.poService.findAll(); } @Post() @ApiOperation({ summary: 'Create a purchase order' }) + @ApiResponse({ status: 201, description: 'Purchase order created' }) create(@Body() dto: any) { return this.poService.create(dto); } @Get(':id') @ApiOperation({ summary: 'Get purchase order details' }) + @ApiResponse({ status: 200, description: 'Purchase order details' }) + @ApiResponse({ status: 404, description: 'Purchase order not found' }) findOne(@Param('id') id: string) { return this.poService.findById(id); } @Post(':id/receive') @ApiOperation({ summary: 'Receive purchase order line items' }) + @ApiResponse({ status: 200, description: 'Purchase order received' }) receive(@Param('id') id: string) { return this.poService.receive(id); } diff --git a/backend/src/transfers/entities/asset-transfer.entity.ts b/backend/src/transfers/entities/asset-transfer.entity.ts index f20999a6..8e33b574 100644 --- a/backend/src/transfers/entities/asset-transfer.entity.ts +++ b/backend/src/transfers/entities/asset-transfer.entity.ts @@ -1,4 +1,10 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; export enum TransferStatus { PENDING = 'PENDING', @@ -28,7 +34,11 @@ export class AssetTransfer { @Column({ nullable: true }) approvedByUserId?: string; - @Column({ type: 'enum', enum: TransferStatus, default: TransferStatus.PENDING }) + @Column({ + type: 'enum', + enum: TransferStatus, + default: TransferStatus.PENDING, + }) status: TransferStatus; @Column({ nullable: true }) diff --git a/backend/src/transfers/transfers.controller.ts b/backend/src/transfers/transfers.controller.ts index 927fed13..0df801bf 100644 --- a/backend/src/transfers/transfers.controller.ts +++ b/backend/src/transfers/transfers.controller.ts @@ -1,32 +1,43 @@ import { Controller, Get, Post, Param, Body, Req } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; import { TransfersService } from './transfers.service'; @ApiTags('transfers') +@ApiBearerAuth('JWT-auth') @Controller('transfers') export class TransfersController { constructor(private readonly transfersService: TransfersService) {} @Get() @ApiOperation({ summary: 'List all asset transfers' }) + @ApiResponse({ status: 200, description: 'List of transfers' }) findAll() { return this.transfersService.findAll(); } @Post() @ApiOperation({ summary: 'Request an asset transfer' }) + @ApiResponse({ status: 201, description: 'Transfer request created' }) create(@Body() dto: any) { return this.transfersService.create(dto); } @Get(':id') @ApiOperation({ summary: 'Get transfer request details' }) + @ApiResponse({ status: 200, description: 'Transfer details' }) + @ApiResponse({ status: 404, description: 'Transfer not found' }) findOne(@Param('id') id: string) { return this.transfersService.findById(id); } @Post(':id/approve') @ApiOperation({ summary: 'Approve an asset transfer' }) + @ApiResponse({ status: 200, description: 'Transfer approved' }) approve(@Param('id') id: string, @Req() req: any) { const approverId = req.user?.id || 'usr-1'; return this.transfersService.approve(id, approverId); @@ -34,6 +45,7 @@ export class TransfersController { @Post(':id/reject') @ApiOperation({ summary: 'Reject an asset transfer' }) + @ApiResponse({ status: 200, description: 'Transfer rejected' }) reject(@Param('id') id: string, @Body('reason') reason: string) { return this.transfersService.reject(id, reason || 'Rejected by manager'); } diff --git a/backend/src/transfers/transfers.service.ts b/backend/src/transfers/transfers.service.ts index 48b0e9fc..7a6ec368 100644 --- a/backend/src/transfers/transfers.service.ts +++ b/backend/src/transfers/transfers.service.ts @@ -1,7 +1,14 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { AssetTransfer, TransferStatus } from './entities/asset-transfer.entity'; +import { + AssetTransfer, + TransferStatus, +} from './entities/asset-transfer.entity'; @Injectable() export class TransfersService { @@ -21,7 +28,10 @@ export class TransfersService { } async create(dto: Partial) { - const tr = this.transferRepo.create({ ...dto, status: TransferStatus.PENDING }); + const tr = this.transferRepo.create({ + ...dto, + status: TransferStatus.PENDING, + }); return this.transferRepo.save(tr); } diff --git a/backend/src/users/dto/update-profile.dto.ts b/backend/src/users/dto/update-profile.dto.ts new file mode 100644 index 00000000..56bc8c2a --- /dev/null +++ b/backend/src/users/dto/update-profile.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MinLength, IsOptional } from 'class-validator'; + +export class UpdateProfileDto { + @ApiProperty({ example: 'John', required: false }) + @IsString() + @IsOptional() + firstName?: string; + + @ApiProperty({ example: 'Doe', required: false }) + @IsString() + @IsOptional() + lastName?: string; + + @ApiProperty({ example: 'OldPassword123!', required: false }) + @IsString() + @IsOptional() + currentPassword?: string; + + @ApiProperty({ example: 'NewPassword456!', required: false, minimum: 8 }) + @IsString() + @MinLength(8, { message: 'Password must be at least 8 characters long' }) + @IsOptional() + newPassword?: string; +} diff --git a/backend/src/users/entities/user.entity.ts b/backend/src/users/entities/user.entity.ts index c0c55841..c2ebe572 100644 --- a/backend/src/users/entities/user.entity.ts +++ b/backend/src/users/entities/user.entity.ts @@ -1,5 +1,13 @@ -import { Entity, Column, Index, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + Column, + Index, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; import { Exclude } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; export enum UserRole { ADMIN = 'ADMIN', @@ -9,9 +17,11 @@ export enum UserRole { @Entity('users') export class User { + @ApiProperty({ description: 'User UUID' }) @PrimaryGeneratedColumn('uuid') id: string; + @ApiProperty({ example: 'user@example.com' }) @Index({ unique: true }) @Column() email: string; @@ -20,12 +30,15 @@ export class User { @Exclude({ toPlainOnly: true }) passwordHash: string; + @ApiProperty({ example: 'John' }) @Column() firstName: string; + @ApiProperty({ example: 'Doe' }) @Column() lastName: string; + @ApiProperty({ enum: UserRole }) @Column({ type: 'enum', enum: UserRole, default: UserRole.EMPLOYEE }) role: UserRole; @@ -44,6 +57,10 @@ export class User { @Column({ nullable: true }) lastLoginAt?: Date; + @Column({ type: 'text', nullable: true }) + @Exclude({ toPlainOnly: true }) + refreshTokenHash?: string; + @CreateDateColumn() createdAt: Date; diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index d34fe49d..d92596c0 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -6,10 +6,18 @@ import { Body, Query, Req, + UseGuards, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; import { UsersService } from './users.service'; import { UserRole } from './entities/user.entity'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { UpdateProfileDto } from './dto/update-profile.dto'; @ApiTags('users') @ApiBearerAuth('JWT-auth') @@ -19,6 +27,7 @@ export class UsersController { @Get() @ApiOperation({ summary: 'List users (paginated, searchable)' }) + @ApiResponse({ status: 200, description: 'List of users' }) findAll( @Query('search') search?: string, @Query('role') role?: UserRole, @@ -29,18 +38,38 @@ export class UsersController { } @Get('me') + @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Get current user profile' }) + @ApiResponse({ status: 200, description: 'Current user profile' }) async getProfile(@Req() req: any) { const userId = req.user?.id || req.user?.sub; if (!userId) { - return { id: 'demo-user-id', email: 'user@example.com', role: UserRole.ADMIN, firstName: 'Demo', lastName: 'User' }; + return { + id: 'demo-user-id', + email: 'user@example.com', + role: UserRole.ADMIN, + firstName: 'Demo', + lastName: 'User', + }; } const user = await this.usersService.findById(userId); return this.usersService.sanitize(user); } + @Patch('me') + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: 'Update own profile (firstName, lastName, password)', + }) + @ApiResponse({ status: 200, description: 'Profile updated' }) + updateMe(@Req() req: any, @Body() dto: UpdateProfileDto) { + const userId = req.user?.id || req.user?.sub; + return this.usersService.updateMe(userId, dto); + } + @Patch(':id/role') @ApiOperation({ summary: "Change a user's role" }) + @ApiResponse({ status: 200, description: 'Role updated' }) updateRole( @Param('id') id: string, @Body('role') role: UserRole, diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index 4aaf9ad4..4f4c69b0 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from './entities/user.entity'; import { UsersService } from './users.service'; import { UsersController } from './users.controller'; +import { AuditLogsModule } from '../audit-logs/audit-logs.module'; @Module({ - imports: [TypeOrmModule.forFeature([User])], + imports: [TypeOrmModule.forFeature([User]), AuditLogsModule], providers: [UsersService], controllers: [UsersController], exports: [UsersService], diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 37243efc..e6cadffd 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -3,28 +3,42 @@ import { NotFoundException, ConflictException, ForbiddenException, + BadRequestException, + Optional, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import * as bcrypt from 'bcryptjs'; import { User, UserRole } from './entities/user.entity'; +import { UpdateProfileDto } from './dto/update-profile.dto'; +import { AuditLogsService } from '../audit-logs/audit-logs.service'; @Injectable() export class UsersService { constructor( @InjectRepository(User) private readonly userRepository: Repository, + @Optional() private readonly auditLogsService?: AuditLogsService, ) {} - async findAll(query?: { search?: string; role?: UserRole; page?: number; limit?: number }) { + async findAll(query?: { + search?: string; + role?: UserRole; + page?: number; + limit?: number; + }) { const page = query?.page || 1; const limit = query?.limit || 20; - const qb = this.userRepository.createQueryBuilder('user') + const qb = this.userRepository + .createQueryBuilder('user') .skip((page - 1) * limit) .take(limit); if (query?.search) { - qb.andWhere('(user.email ILIKE :s OR user.firstName ILIKE :s OR user.lastName ILIKE :s)', { s: `%${query.search}%` }); + qb.andWhere( + '(user.email ILIKE :s OR user.firstName ILIKE :s OR user.lastName ILIKE :s)', + { s: `%${query.search}%` }, + ); } if (query?.role) { qb.andWhere('user.role = :role', { role: query.role }); @@ -50,7 +64,13 @@ export class UsersService { return this.userRepository.findOne({ where: { email } }); } - async create(dto: { email: string; password: string; firstName: string; lastName: string; role?: UserRole }) { + async create(dto: { + email: string; + password: string; + firstName: string; + lastName: string; + role?: UserRole; + }) { const existing = await this.findByEmail(dto.email); if (existing) throw new ConflictException('Email already in use'); @@ -63,6 +83,17 @@ export class UsersService { role: dto.role || UserRole.EMPLOYEE, }); const saved = await this.userRepository.save(user); + await this.auditLogsService?.logAction({ + action: 'CREATED', + entityType: 'user', + entityId: saved.id, + newValue: { + email: saved.email, + firstName: saved.firstName, + lastName: saved.lastName, + role: saved.role, + }, + }); return this.sanitize(saved); } @@ -71,8 +102,17 @@ export class UsersService { throw new ForbiddenException('Cannot change your own role'); } const user = await this.findById(id); + const previousRole = user.role; user.role = newRole; const saved = await this.userRepository.save(user); + await this.auditLogsService?.logAction({ + action: 'ROLE_CHANGED', + entityType: 'user', + entityId: id, + actorId: requestingUserId, + previousValue: { role: previousRole }, + newValue: { role: newRole }, + }); return this.sanitize(saved); } @@ -84,7 +124,49 @@ export class UsersService { } sanitize(user: User): User { - const { passwordHash, ...safe } = user; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { passwordHash, refreshTokenHash, ...safe } = user; return safe as User; } + + async updateMe(userId: string, dto: UpdateProfileDto) { + const user = await this.findById(userId); + + if (dto.firstName) user.firstName = dto.firstName; + if (dto.lastName) user.lastName = dto.lastName; + + if (dto.newPassword) { + if (!dto.currentPassword) { + throw new BadRequestException( + 'Current password is required to change password', + ); + } + const isMatch = await bcrypt.compare( + dto.currentPassword, + user.passwordHash, + ); + if (!isMatch) { + throw new BadRequestException('Current password is incorrect'); + } + user.passwordHash = await bcrypt.hash(dto.newPassword, 10); + } + + const saved = await this.userRepository.save(user); + await this.auditLogsService?.logAction({ + action: 'UPDATED', + entityType: 'user', + entityId: userId, + actorId: userId, + newValue: { + firstName: dto.firstName, + lastName: dto.lastName, + passwordChanged: !!dto.newPassword, + }, + }); + return this.sanitize(saved); + } + + async setRefreshTokenHash(userId: string, hash: string | null) { + await this.userRepository.update(userId, { refreshTokenHash: hash }); + } } diff --git a/backend/src/vendors/entities/vendor.entity.ts b/backend/src/vendors/entities/vendor.entity.ts index e001e1e9..e3e08be3 100644 --- a/backend/src/vendors/entities/vendor.entity.ts +++ b/backend/src/vendors/entities/vendor.entity.ts @@ -1,4 +1,10 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; @Entity('vendors') export class Vendor { diff --git a/backend/src/vendors/vendors.controller.ts b/backend/src/vendors/vendors.controller.ts index 9ba84b2d..91b80bc2 100644 --- a/backend/src/vendors/vendors.controller.ts +++ b/backend/src/vendors/vendors.controller.ts @@ -1,32 +1,43 @@ import { Controller, Get, Post, Patch, Body, Param } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; import { VendorsService } from './vendors.service'; @ApiTags('vendors') +@ApiBearerAuth('JWT-auth') @Controller('vendors') export class VendorsController { constructor(private readonly vendorsService: VendorsService) {} @Get() @ApiOperation({ summary: 'List all vendors/suppliers' }) + @ApiResponse({ status: 200, description: 'List of vendors' }) findAll() { return this.vendorsService.findAll(); } @Post() @ApiOperation({ summary: 'Create a vendor' }) + @ApiResponse({ status: 201, description: 'Vendor created' }) create(@Body() dto: any) { return this.vendorsService.create(dto); } @Get(':id') @ApiOperation({ summary: 'Get vendor details' }) + @ApiResponse({ status: 200, description: 'Vendor details' }) + @ApiResponse({ status: 404, description: 'Vendor not found' }) findOne(@Param('id') id: string) { return this.vendorsService.findById(id); } @Patch(':id') @ApiOperation({ summary: 'Update a vendor' }) + @ApiResponse({ status: 200, description: 'Vendor updated' }) update(@Param('id') id: string, @Body() dto: any) { return this.vendorsService.update(id, dto); } diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 68edb4bc..d890e47d 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -147,7 +147,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" dependencies = [ "num-traits", - "rand", + "rand 0.8.5", ] [[package]] @@ -161,6 +161,7 @@ dependencies = [ name = "assetsup" version = "0.1.0" dependencies = [ + "proptest", "soroban-sdk", ] @@ -188,6 +189,27 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + [[package]] name = "block-buffer" version = "0.10.4" @@ -307,7 +329,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -552,7 +574,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2", "subtle", @@ -577,7 +599,7 @@ dependencies = [ "ff", "generic-array", "group", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -589,6 +611,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "escape-bytes" version = "0.1.1" @@ -601,13 +633,19 @@ version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "ff" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -653,6 +691,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "group" version = "0.13.0" @@ -660,7 +710,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -851,6 +901,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "log" version = "0.4.28" @@ -878,6 +934,7 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" name = "multisig-transfer" version = "0.1.0" dependencies = [ + "assetsup", "soroban-sdk", ] @@ -885,6 +942,7 @@ dependencies = [ name = "multisig-wallet" version = "0.1.0" dependencies = [ + "proptest", "soroban-sdk", ] @@ -1010,6 +1068,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.40" @@ -1019,6 +1102,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.8.5" @@ -1026,8 +1115,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1037,7 +1136,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1046,7 +1155,25 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", ] [[package]] @@ -1069,6 +1196,12 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rfc6979" version = "0.4.0" @@ -1088,12 +1221,37 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.20" @@ -1264,7 +1422,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1329,7 +1487,7 @@ dependencies = [ "ed25519-dalek", "elliptic-curve", "generic-array", - "getrandom", + "getrandom 0.2.16", "hex-literal", "hmac", "k256", @@ -1337,8 +1495,8 @@ dependencies = [ "num-integer", "num-traits", "p256", - "rand", - "rand_chacha", + "rand 0.8.5", + "rand_chacha 0.3.1", "sec1", "sha2", "sha3", @@ -1391,7 +1549,7 @@ dependencies = [ "ctor", "derive_arbitrary", "ed25519-dalek", - "rand", + "rand 0.8.5", "rustc_version", "serde", "serde_json", @@ -1566,6 +1724,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1622,6 +1793,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.19" @@ -1645,12 +1822,30 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.104" @@ -1806,6 +2001,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "zerocopy" version = "0.8.27" diff --git a/contracts/assetsup/src/events.rs b/contracts/assetsup/src/events.rs index 476d63ad..71dec40b 100644 --- a/contracts/assetsup/src/events.rs +++ b/contracts/assetsup/src/events.rs @@ -329,6 +329,30 @@ pub struct LeaseExpired { pub timestamp: u64, } +// --------------------------------------------------------------------------- +// Upgrade and migration +// --------------------------------------------------------------------------- + +/// The contract WASM was upgraded. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractUpgraded { + #[topic] + pub admin: Address, + pub new_wasm_hash: BytesN<32>, + pub version: u32, + pub timestamp: u64, +} + +/// Storage was migrated from one layout version to another. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractMigrated { + pub from_version: u32, + pub to_version: u32, + pub timestamp: u64, +} + // --------------------------------------------------------------------------- // Emission helpers // --------------------------------------------------------------------------- @@ -617,3 +641,22 @@ pub fn lease_expired(env: &Env, lease_id: &BytesN<32>) { } .publish(env); } + +pub fn contract_upgraded(env: &Env, admin: &Address, new_wasm_hash: &BytesN<32>, version: u32) { + ContractUpgraded { + admin: admin.clone(), + new_wasm_hash: new_wasm_hash.clone(), + version, + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn contract_migrated(env: &Env, from_version: u32, to_version: u32) { + ContractMigrated { + from_version, + to_version, + timestamp: env.ledger().timestamp(), + } + .publish(env); +} diff --git a/contracts/assetsup/src/lib.rs b/contracts/assetsup/src/lib.rs index f2c18677..b30fdc1b 100644 --- a/contracts/assetsup/src/lib.rs +++ b/contracts/assetsup/src/lib.rs @@ -34,9 +34,7 @@ extern crate std; use crate::error::{handle_error, Error}; -use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, Address, BytesN, Env, String, Vec, -}; +use soroban_sdk::{contract, contractimpl, contracttype, Address, BytesN, Env, String, Vec}; // `Asset` is part of the contract's public ABI (it is a `register_asset` // argument), so the module is public for cross-contract integration tests. @@ -130,7 +128,9 @@ impl AssetUpContract { ttl::extend_persistent(&env, &DataKey::TotalAssetCount); ttl::extend_persistent(&env, &DataKey::ContractMetadata); ttl::extend_persistent(&env, &DataKey::StorageVersion); - ttl::extend_persistent(&env, &DataKey::AuthorizedRegistrar(admin)); + ttl::extend_persistent(&env, &DataKey::AuthorizedRegistrar(admin.clone())); + + events::contract_initialized(&env, &admin); Ok(()) } @@ -244,10 +244,7 @@ impl AssetUpContract { ); // Emit event - env.events().publish( - (symbol_short!("asset_reg"),), - (asset.owner, asset.id, env.ledger().timestamp()), - ); + events::asset_registered(&env, &asset.id, &asset.owner); Ok(()) } @@ -362,10 +359,7 @@ impl AssetUpContract { ); // Emit event - env.events().publish( - (symbol_short!("asset_upd"),), - (asset_id, caller, env.ledger().timestamp()), - ); + events::asset_updated(&env, &asset_id, &caller); Ok(()) } @@ -445,10 +439,7 @@ impl AssetUpContract { ); // Emit event - env.events().publish( - (symbol_short!("asset_tx"),), - (asset_id, old_owner, new_owner, env.ledger().timestamp()), - ); + events::asset_transferred(&env, &asset_id, &old_owner, &new_owner); Ok(()) } @@ -492,10 +483,7 @@ impl AssetUpContract { ); // Emit event - env.events().publish( - (symbol_short!("asset_ret"),), - (asset_id, caller, env.ledger().timestamp()), - ); + events::asset_retired(&env, &asset_id, &caller); Ok(()) } @@ -592,10 +580,7 @@ impl AssetUpContract { .persistent() .set(&DataKey::PendingAdmin, &new_admin); - env.events().publish( - (symbol_short!("adm_prop"),), - (current_admin, new_admin, env.ledger().timestamp()), - ); + events::admin_proposed(&env, ¤t_admin, &new_admin); Ok(()) } @@ -626,10 +611,7 @@ impl AssetUpContract { .persistent() .set(&DataKey::AuthorizedRegistrar(pending.clone()), &true); - env.events().publish( - (symbol_short!("admin_chg"),), - (old_admin, pending, env.ledger().timestamp()), - ); + events::admin_changed(&env, &old_admin, &pending); Ok(()) } @@ -647,10 +629,7 @@ impl AssetUpContract { env.storage().persistent().remove(&DataKey::PendingAdmin); - env.events().publish( - (symbol_short!("adm_cncl"),), - (current_admin, pending, env.ledger().timestamp()), - ); + events::admin_proposal_cancelled(&env, ¤t_admin, &pending); Ok(()) } @@ -711,7 +690,9 @@ impl AssetUpContract { env.storage() .persistent() - .set(&DataKey::AuthorizedRegistrar(registrar), &true); + .set(&DataKey::AuthorizedRegistrar(registrar.clone()), &true); + + events::registrar_added(&env, ®istrar); Ok(()) } @@ -728,7 +709,9 @@ impl AssetUpContract { env.storage() .persistent() - .set(&DataKey::AuthorizedRegistrar(registrar), &false); + .set(&DataKey::AuthorizedRegistrar(registrar.clone()), &false); + + events::registrar_removed(&env, ®istrar); Ok(()) } @@ -739,10 +722,7 @@ impl AssetUpContract { env.storage().persistent().set(&DataKey::Paused, &true); // Emit event - env.events().publish( - (symbol_short!("c_pause"),), - (admin, env.ledger().timestamp()), - ); + events::contract_paused(&env, &admin); Ok(()) } @@ -754,10 +734,7 @@ impl AssetUpContract { env.storage().persistent().set(&DataKey::Paused, &false); // Emit event - env.events().publish( - (symbol_short!("c_unpause"),), - (admin, env.ledger().timestamp()), - ); + events::contract_unpaused(&env, &admin); Ok(()) } diff --git a/contracts/assetsup/src/main.rs b/contracts/assetsup/src/main.rs deleted file mode 100644 index 3304088d..00000000 --- a/contracts/assetsup/src/main.rs +++ /dev/null @@ -1,1209 +0,0 @@ -#![no_std] -#![allow(clippy::too_many_arguments)] -//! # assetsup -//! -//! The primary AssetsUp asset registry. -//! -//! Assets are registered by authorized registrars, owned by an `Address`, and -//! can be transferred, retired, tokenized into fractional shares, leased, -//! insured, voted on, and detokenized. -//! -//! ## Invariants -//! -//! - An asset has exactly one owner at any time. -//! - An asset id is unique; re-registering fails with `Error::AssetAlreadyExists`. -//! - A retired asset cannot be transferred or updated. -//! - For a tokenized asset, holder balances sum to the total token supply. -//! -//! ## Two asset id spaces -//! -//! The registry keys assets by `BytesN<32>`, while tokenization, dividends, -//! voting, and detokenization key them by `u64`. The contract does not link the -//! two namespaces — callers maintain the mapping. -//! -//! ## Relationship to `contrib` -//! -//! `assetsup` and `contrib` are independent contracts with separate storage -//! that share several module names. See `contracts/README.md` for which crate -//! owns which concern. -//! -//! See [`README.md`](https://github.com/DistinctCodes/AssetsUp/blob/main/contracts/assetsup/README.md) -//! for the full entrypoint, storage, event, and error tables. - -#[cfg(test)] -extern crate std; - -use crate::error::{handle_error, Error}; -use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, Address, BytesN, Env, String, Vec, -}; - -// `Asset` is part of the contract's public ABI (it is a `register_asset` -// argument), so the module is public for cross-contract integration tests. -pub mod asset; -pub(crate) mod audit; -pub(crate) mod branch; -pub(crate) mod detokenization; -pub(crate) mod dividends; -pub(crate) mod error; -pub(crate) mod insurance; -pub(crate) mod lease; -pub(crate) mod math; -pub(crate) mod tokenization; -pub(crate) mod transfer_restrictions; -pub(crate) mod ttl; -pub(crate) mod types; -pub mod upgrade; -pub(crate) mod voting; - -#[cfg(test)] -mod tests; - -pub use types::*; - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum DataKey { - Admin, - Paused, - TotalAssetCount, - ContractMetadata, - AuthorizedRegistrar(Address), - /// Layout version the stored data conforms to. See `upgrade`. - StorageVersion, - /// Address that has been proposed as the next admin but has not yet - /// accepted. Absent when no transfer is in flight. - PendingAdmin, - ScheduledTransfer(BytesN<32>), - PendingApproval(BytesN<32>), -} - -#[contract] -pub struct AssetUpContract; - -#[contractimpl] -impl AssetUpContract { - pub fn initialize(env: Env, admin: Address) -> Result<(), Error> { - admin.require_auth(); - ttl::extend_instance(&env); - - if env.storage().persistent().has(&DataKey::Admin) { - handle_error(&env, Error::AlreadyInitialized) - } - - // Set admin - env.storage().persistent().set(&DataKey::Admin, &admin); - - // Initialize contract state - env.storage().persistent().set(&DataKey::Paused, &false); - env.storage() - .persistent() - .set(&DataKey::TotalAssetCount, &0u64); - - // Set contract metadata - let metadata = ContractMetadata { - version: String::from_str(&env, "1.0.0"), - name: String::from_str(&env, "AssetUp Registry"), - description: String::from_str(&env, "Professional asset registry smart contract"), - created_at: env.ledger().timestamp(), - }; - env.storage() - .persistent() - .set(&DataKey::ContractMetadata, &metadata); - - // Add admin as first authorized registrar - env.storage() - .persistent() - .set(&DataKey::AuthorizedRegistrar(admin.clone()), &true); - - // Stamp the layout version so a later upgrade knows what it is - // migrating from. - upgrade::set_version(&env, upgrade::CURRENT_VERSION); - - // Extend on write, not only on read. A freshly written entry gets the - // network's minimum lifetime, which is short; without this the whole - // contract configuration can be archived before anyone reads it, and a - // read cannot rescue an entry that is already gone. - ttl::extend_persistent(&env, &DataKey::Admin); - ttl::extend_persistent(&env, &DataKey::Paused); - ttl::extend_persistent(&env, &DataKey::TotalAssetCount); - ttl::extend_persistent(&env, &DataKey::ContractMetadata); - ttl::extend_persistent(&env, &DataKey::StorageVersion); - ttl::extend_persistent(&env, &DataKey::AuthorizedRegistrar(admin)); - - Ok(()) - } - - pub fn get_admin(env: Env) -> Result { - ttl::extend_instance(&env); - - let key = DataKey::Admin; - if !env.storage().persistent().has(&key) { - handle_error(&env, Error::AdminNotFound) - } - - // Contract-level configuration lives in persistent storage, so it - // needs extending like any other persistent entry — the instance bump - // alone does not cover it. - ttl::extend_persistent(&env, &key); - - let admin = env.storage().persistent().get(&key).unwrap(); - Ok(admin) - } - - pub fn is_paused(env: Env) -> Result { - ttl::extend_persistent(&env, &DataKey::Paused); - Ok(env - .storage() - .persistent() - .get(&DataKey::Paused) - .unwrap_or(false)) - } - - pub fn get_total_asset_count(env: Env) -> Result { - ttl::extend_persistent(&env, &DataKey::TotalAssetCount); - Ok(env - .storage() - .persistent() - .get(&DataKey::TotalAssetCount) - .unwrap_or(0u64)) - } - - pub fn get_contract_metadata(env: Env) -> Result { - ttl::extend_persistent(&env, &DataKey::ContractMetadata); - let metadata = env.storage().persistent().get(&DataKey::ContractMetadata); - match metadata { - Some(m) => Ok(m), - None => handle_error(&env, Error::ContractNotInitialized), - } - } - - pub fn is_authorized_registrar(env: Env, address: Address) -> Result { - let key = DataKey::AuthorizedRegistrar(address); - ttl::extend_persistent(&env, &key); - Ok(env.storage().persistent().get(&key).unwrap_or(false)) - } - - // Asset functions - pub fn register_asset(env: Env, asset: asset::Asset, caller: Address) -> Result<(), Error> { - ttl::extend_instance(&env); - - // Authenticate the caller before trusting `caller` for anything else. - // The registrar check below compares against a caller-supplied address, - // so without this any account could name an authorized registrar and - // pass it. - caller.require_auth(); - - // Check if contract is paused - if Self::is_paused(env.clone())? { - return Err(Error::ContractPaused); - } - - // Check if caller is authorized registrar - if !Self::is_authorized_registrar(env.clone(), caller.clone())? { - return Err(Error::Unauthorized); - } - - // Validate asset data - Self::validate_asset(&env, &asset)?; - - let key = asset::DataKey::Asset(asset.id.clone()); - let store = env.storage().persistent(); - - // Check if asset already exists - if store.has(&key) { - return Err(Error::AssetAlreadyExists); - } - - // Store asset - store.set(&key, &asset); - ttl::extend_persistent(&env, &key); - - // Update owner registry - let owner_key = asset::DataKey::OwnerRegistry(asset.owner.clone()); - let mut owner_assets: Vec> = - store.get(&owner_key).unwrap_or_else(|| Vec::new(&env)); - owner_assets.push_back(asset.id.clone()); - store.set(&owner_key, &owner_assets); - - // Update total asset count - let mut total_count = Self::get_total_asset_count(env.clone())?; - total_count += 1; - env.storage() - .persistent() - .set(&DataKey::TotalAssetCount, &total_count); - - // Append audit log - audit::append_audit_log( - &env, - &asset.id, - String::from_str(&env, "ASSET_REGISTERED"), - caller.clone(), - String::from_str(&env, "Asset registered by authorized registrar"), - ); - - // Emit event - env.events().publish( - (symbol_short!("asset_reg"),), - (asset.owner, asset.id, env.ledger().timestamp()), - ); - - Ok(()) - } - - /// Rejects the call if the contract is paused. - /// - /// Every mutating entrypoint calls this except the deliberate exemptions - /// documented in `contracts/PAUSE.md`: the pause controls themselves, the - /// admin transfer flow, and `claim_dividends`. - fn require_not_paused(env: &Env) -> Result<(), Error> { - if Self::is_paused(env.clone())? { - return Err(Error::ContractPaused); - } - Ok(()) - } - - fn validate_asset(env: &Env, asset: &asset::Asset) -> Result<(), Error> { - // Validate asset name length (3-100 characters) - if asset.name.len() < 3 || asset.name.len() > 100 { - return Err(Error::InvalidAssetName); - } - - // Validate purchase value is non-negative - if asset.purchase_value < 0 { - return Err(Error::InvalidPurchaseValue); - } - - // Validate metadata URI format (basic check for IPFS hash format) - if !asset.metadata_uri.is_empty() && !Self::is_valid_metadata_uri(&asset.metadata_uri) { - return Err(Error::InvalidMetadataUri); - } - - // Validate owner address is not zero address - let zero_address = Address::from_str( - env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - ); - if asset.owner == zero_address { - return Err(Error::InvalidOwnerAddress); - } - - Ok(()) - } - - fn is_valid_metadata_uri(uri: &String) -> bool { - // For Soroban String, we'll use a simple length check and basic pattern matching - // In a real implementation, you might want to convert to bytes for more detailed validation - let uri_len = uri.len(); - // Basic validation: check for reasonable length and common prefixes - uri_len > 10 && (uri_len < 500) - } - - pub fn update_asset_metadata( - env: Env, - asset_id: BytesN<32>, - new_description: Option, - new_metadata_uri: Option, - new_custom_attributes: Option>, - caller: Address, - ) -> Result<(), Error> { - ttl::extend_instance(&env); - - // Authenticate before the owner/admin comparison below, which would - // otherwise be satisfied by simply naming the owner's address. - caller.require_auth(); - - // Check if contract is paused - if Self::is_paused(env.clone())? { - return Err(Error::ContractPaused); - } - - let key = asset::DataKey::Asset(asset_id.clone()); - let store = env.storage().persistent(); - - let mut asset = match store.get::<_, asset::Asset>(&key) { - Some(a) => a, - None => return Err(Error::AssetNotFound), - }; - - // Only asset owner or admin can update metadata - let admin = Self::get_admin(env.clone())?; - if caller != asset.owner && caller != admin { - return Err(Error::Unauthorized); - } - - // Update metadata if provided - if let Some(description) = new_description { - asset.description = description; - } - - if let Some(metadata_uri) = new_metadata_uri { - if !metadata_uri.is_empty() && !Self::is_valid_metadata_uri(&metadata_uri) { - return Err(Error::InvalidMetadataUri); - } - asset.metadata_uri = metadata_uri; - } - - if let Some(custom_attributes) = new_custom_attributes { - asset.custom_attributes = custom_attributes; - } - - store.set(&key, &asset); - ttl::extend_persistent(&env, &key); - - // Append audit log - audit::append_audit_log( - &env, - &asset_id, - String::from_str(&env, "METADATA_UPDATED"), - caller.clone(), - String::from_str(&env, "Asset metadata updated"), - ); - - // Emit event - env.events().publish( - (symbol_short!("asset_upd"),), - (asset_id, caller, env.ledger().timestamp()), - ); - - Ok(()) - } - - pub fn transfer_asset_ownership( - env: Env, - asset_id: BytesN<32>, - new_owner: Address, - caller: Address, - ) -> Result<(), Error> { - ttl::extend_instance(&env); - - // Authenticate before the ownership comparison below. Without this, - // anyone could pass the current owner's address and take the asset — - // a direct asset-theft path. - caller.require_auth(); - - // Check if contract is paused - if Self::is_paused(env.clone())? { - return Err(Error::ContractPaused); - } - - // Validate new owner is not zero address - let zero_address = Address::from_str( - &env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - ); - if new_owner == zero_address { - return Err(Error::InvalidOwnerAddress); - } - - let key = asset::DataKey::Asset(asset_id.clone()); - let store = env.storage().persistent(); - - let mut asset = match store.get::<_, asset::Asset>(&key) { - Some(a) => a, - None => return Err(Error::AssetNotFound), - }; - - // Only current asset owner can transfer ownership - if caller != asset.owner { - return Err(Error::Unauthorized); - } - - let old_owner = asset.owner.clone(); - - // Remove asset from old owner's registry - let old_owner_key = asset::DataKey::OwnerRegistry(old_owner.clone()); - let mut old_owner_assets: Vec> = - store.get(&old_owner_key).unwrap_or_else(|| Vec::new(&env)); - if let Some(index) = old_owner_assets.iter().position(|x| x == asset_id) { - old_owner_assets.remove(index as u32); - } - store.set(&old_owner_key, &old_owner_assets); - - // Add asset to new owner's registry - let new_owner_key = asset::DataKey::OwnerRegistry(new_owner.clone()); - let mut new_owner_assets: Vec> = - store.get(&new_owner_key).unwrap_or_else(|| Vec::new(&env)); - new_owner_assets.push_back(asset_id.clone()); - store.set(&new_owner_key, &new_owner_assets); - - // Update asset - asset.owner = new_owner.clone(); - asset.last_transfer_timestamp = env.ledger().timestamp(); - asset.status = AssetStatus::Transferred; - store.set(&key, &asset); - ttl::extend_persistent(&env, &key); - - // Append audit log - audit::append_audit_log( - &env, - &asset_id, - String::from_str(&env, "OWNERSHIP_TRANSFERRED"), - caller.clone(), - String::from_str(&env, "Asset ownership transferred to new owner"), - ); - - // Emit event - env.events().publish( - (symbol_short!("asset_tx"),), - (asset_id, old_owner, new_owner, env.ledger().timestamp()), - ); - - Ok(()) - } - - pub fn retire_asset(env: Env, asset_id: BytesN<32>, caller: Address) -> Result<(), Error> { - ttl::extend_instance(&env); - - // Authenticate before the owner/admin comparison below. - caller.require_auth(); - - // Check if contract is paused - if Self::is_paused(env.clone())? { - return Err(Error::ContractPaused); - } - - let key = asset::DataKey::Asset(asset_id.clone()); - let store = env.storage().persistent(); - - let mut asset = match store.get::<_, asset::Asset>(&key) { - Some(a) => a, - None => return Err(Error::AssetNotFound), - }; - - // Only asset owner or admin can retire asset - let admin = Self::get_admin(env.clone())?; - if caller != asset.owner && caller != admin { - return Err(Error::Unauthorized); - } - - asset.status = AssetStatus::Retired; - store.set(&key, &asset); - ttl::extend_persistent(&env, &key); - - // Append audit log - audit::append_audit_log( - &env, - &asset_id, - String::from_str(&env, "ASSET_RETIRED"), - caller.clone(), - String::from_str(&env, "Asset retired from active use"), - ); - - // Emit event - env.events().publish( - (symbol_short!("asset_ret"),), - (asset_id, caller, env.ledger().timestamp()), - ); - - Ok(()) - } - - pub fn get_asset(env: Env, asset_id: BytesN<32>) -> Result { - ttl::extend_instance(&env); - - let key = asset::DataKey::Asset(asset_id); - let store = env.storage().persistent(); - match store.get::<_, asset::Asset>(&key) { - Some(a) => { - // Extend on read too. An asset that is only ever queried and - // never modified would otherwise be archived despite being in - // active use. - ttl::extend_persistent(&env, &key); - Ok(a) - } - None => Err(Error::AssetNotFound), - } - } - - pub fn get_assets_by_owner(env: Env, owner: Address) -> Result>, Error> { - let key = asset::DataKey::OwnerRegistry(owner); - let store = env.storage().persistent(); - match store.get(&key) { - Some(assets) => Ok(assets), - None => Ok(Vec::new(&env)), - } - } - - pub fn check_asset_exists(env: Env, asset_id: BytesN<32>) -> Result { - ttl::extend_instance(&env); - - let key = asset::DataKey::Asset(asset_id); - let store = env.storage().persistent(); - Ok(store.has(&key)) - } - - pub fn get_asset_info(env: Env, asset_id: BytesN<32>) -> Result { - let asset = Self::get_asset(env.clone(), asset_id.clone())?; - Ok(asset::AssetInfo { - id: asset.id, - name: asset.name, - category: asset.category, - owner: asset.owner, - status: asset.status, - }) - } - - pub fn batch_get_asset_info( - env: Env, - asset_ids: Vec>, - ) -> Result, Error> { - let mut results = Vec::new(&env); - for asset_id in asset_ids.iter() { - match Self::get_asset_info(env.clone(), asset_id.clone()) { - Ok(info) => results.push_back(info), - Err(_) => continue, // Skip assets that don't exist - } - } - Ok(results) - } - - // Admin functions - /// Step one of a two-step admin transfer: nominate `new_admin`. - /// - /// This does **not** change the admin. The proposal only takes effect when - /// the proposed address calls [`Self::accept_admin`], which proves it is - /// reachable and controlled. A single-step transfer means one typo - /// permanently bricks administration of a contract governing real asset - /// ownership, with no on-chain undo. - /// - /// Calling this again replaces any proposal already in flight. - pub fn propose_admin(env: Env, new_admin: Address) -> Result<(), Error> { - let current_admin = Self::get_admin(env.clone())?; - current_admin.require_auth(); - - // Validate new admin is not zero address - let zero_address = Address::from_str( - &env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - ); - if new_admin == zero_address { - return Err(Error::InvalidOwnerAddress); - } - - // Transferring to yourself is a no-op that would leave a confusing - // pending proposal behind. - if new_admin == current_admin { - return Err(Error::InvalidOwnerAddress); - } - - env.storage() - .persistent() - .set(&DataKey::PendingAdmin, &new_admin); - - env.events().publish( - (symbol_short!("adm_prop"),), - (current_admin, new_admin, env.ledger().timestamp()), - ); - - Ok(()) - } - - /// Step two: the proposed admin accepts, and only then does the role move. - /// - /// Authorized by the *incoming* address, which is the whole point — an - /// address that never accepts leaves the original admin in place forever. - pub fn accept_admin(env: Env) -> Result<(), Error> { - let pending: Address = env - .storage() - .persistent() - .get(&DataKey::PendingAdmin) - .ok_or(Error::AdminNotFound)?; - - pending.require_auth(); - - let old_admin = Self::get_admin(env.clone())?; - - env.storage().persistent().set(&DataKey::Admin, &pending); - env.storage().persistent().remove(&DataKey::PendingAdmin); - - // Move registrar rights along with the role. - env.storage() - .persistent() - .set(&DataKey::AuthorizedRegistrar(old_admin.clone()), &false); - env.storage() - .persistent() - .set(&DataKey::AuthorizedRegistrar(pending.clone()), &true); - - env.events().publish( - (symbol_short!("admin_chg"),), - (old_admin, pending, env.ledger().timestamp()), - ); - - Ok(()) - } - - /// Withdraws a pending proposal. Only the current admin may cancel. - pub fn cancel_admin_proposal(env: Env) -> Result<(), Error> { - let current_admin = Self::get_admin(env.clone())?; - current_admin.require_auth(); - - let pending: Address = env - .storage() - .persistent() - .get(&DataKey::PendingAdmin) - .ok_or(Error::AdminNotFound)?; - - env.storage().persistent().remove(&DataKey::PendingAdmin); - - env.events().publish( - (symbol_short!("adm_cncl"),), - (current_admin, pending, env.ledger().timestamp()), - ); - - Ok(()) - } - - /// The address currently nominated to become admin, if any. - pub fn get_pending_admin(env: Env) -> Option
{ - env.storage().persistent().get(&DataKey::PendingAdmin) - } - - /// Replaces this contract's WASM in place, keeping the contract id and all - /// storage. - /// - /// Admin-gated and emits an event. Deliberately **not** blocked by the - /// pause: an upgrade is how you fix the incident that caused the pause. - /// - /// This does not migrate storage. If the new build changes a stored - /// layout, call [`Self::migrate`] immediately afterwards — see - /// `contracts/UPGRADE.md`. - pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), Error> { - let admin = Self::get_admin(env.clone())?; - admin.require_auth(); - - env.deployer() - .update_current_contract_wasm(new_wasm_hash.clone()); - - upgrade::emit_upgraded(&env, &admin, &new_wasm_hash, upgrade::CURRENT_VERSION); - Ok(()) - } - - /// Brings stored data up to the layout this build expects. - /// - /// Idempotent: running it when already current is a no-op, so a retried or - /// duplicated migration transaction cannot corrupt state. - pub fn migrate(env: Env) -> Result { - let admin = Self::get_admin(env.clone())?; - admin.require_auth(); - - let from = upgrade::stored_version(&env); - let to = upgrade::migrate_from(&env, from)?; - - if from != to { - upgrade::emit_migrated(&env, from, to); - } - - Ok(to) - } - - /// The storage layout version the stored data currently conforms to. - pub fn storage_version(env: Env) -> u32 { - upgrade::stored_version(&env) - } - - pub fn add_authorized_registrar(env: Env, registrar: Address) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - let admin = Self::get_admin(env.clone())?; - admin.require_auth(); - - env.storage() - .persistent() - .set(&DataKey::AuthorizedRegistrar(registrar), &true); - Ok(()) - } - - pub fn remove_authorized_registrar(env: Env, registrar: Address) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - let admin = Self::get_admin(env.clone())?; - admin.require_auth(); - - // Cannot remove admin from authorized registrars - if registrar == admin { - return Err(Error::Unauthorized); - } - - env.storage() - .persistent() - .set(&DataKey::AuthorizedRegistrar(registrar), &false); - Ok(()) - } - - pub fn pause_contract(env: Env) -> Result<(), Error> { - let admin = Self::get_admin(env.clone())?; - admin.require_auth(); - - env.storage().persistent().set(&DataKey::Paused, &true); - - // Emit event - env.events().publish( - (symbol_short!("c_pause"),), - (admin, env.ledger().timestamp()), - ); - - Ok(()) - } - - pub fn unpause_contract(env: Env) -> Result<(), Error> { - let admin = Self::get_admin(env.clone())?; - admin.require_auth(); - - env.storage().persistent().set(&DataKey::Paused, &false); - - // Emit event - env.events().publish( - (symbol_short!("c_unpause"),), - (admin, env.ledger().timestamp()), - ); - - Ok(()) - } - - pub fn get_asset_audit_logs( - env: Env, - asset_id: BytesN<32>, - ) -> Result, Error> { - Ok(audit::get_asset_log(&env, &asset_id)) - } - - // ===================== - // Tokenization Functions - // ===================== - - /// Tokenize an asset with full supply to tokenizer - pub fn tokenize_asset( - env: Env, - asset_id: u64, - symbol: String, - total_supply: i128, - decimals: u32, - min_voting_threshold: i128, - tokenizer: Address, - name: String, - description: String, - asset_type: AssetType, - ) -> Result { - Self::require_not_paused(&env)?; - - tokenizer.require_auth(); - - let metadata = TokenMetadata { - name, - description, - asset_type, - ipfs_uri: None, - legal_docs_hash: None, - valuation_report_hash: None, - accredited_investor_required: false, - geographic_restrictions: Vec::new(&env), - }; - - tokenization::tokenize_asset( - &env, - asset_id, - symbol, - total_supply, - decimals, - min_voting_threshold, - tokenizer, - metadata, - ) - } - - /// Mint additional tokens (only tokenizer can call) - pub fn mint_tokens( - env: Env, - asset_id: u64, - amount: i128, - minter: Address, - ) -> Result { - Self::require_not_paused(&env)?; - - minter.require_auth(); - tokenization::mint_tokens(&env, asset_id, amount, minter) - } - - /// Burn tokens (only tokenizer can call) - pub fn burn_tokens( - env: Env, - asset_id: u64, - amount: i128, - burner: Address, - ) -> Result { - Self::require_not_paused(&env)?; - - burner.require_auth(); - tokenization::burn_tokens(&env, asset_id, amount, burner) - } - - /// Transfer tokens from one address to another - pub fn transfer_tokens( - env: Env, - asset_id: u64, - from: Address, - to: Address, - amount: i128, - ) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - from.require_auth(); - - // Validate transfer restrictions - transfer_restrictions::validate_transfer(&env, asset_id, from.clone(), to.clone())?; - - tokenization::transfer_tokens(&env, asset_id, from, to, amount) - } - - /// Get token balance for an address - pub fn get_token_balance(env: Env, asset_id: u64, holder: Address) -> Result { - tokenization::get_token_balance(&env, asset_id, holder) - } - - /// Get all token holders for an asset - pub fn get_token_holders(env: Env, asset_id: u64) -> Result, Error> { - tokenization::get_token_holders(&env, asset_id) - } - - /// Lock tokens until timestamp (only the asset tokenizer can call this) - pub fn lock_tokens( - env: Env, - asset_id: u64, - holder: Address, - until_timestamp: u64, - caller: Address, - ) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - caller.require_auth(); - tokenization::lock_tokens(&env, asset_id, holder, until_timestamp, caller) - } - - /// Unlock tokens - pub fn unlock_tokens(env: Env, asset_id: u64, holder: Address) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - tokenization::unlock_tokens(&env, asset_id, holder) - } - - /// Check if a holder's tokens are currently locked - pub fn is_tokens_locked(env: Env, asset_id: u64, holder: Address) -> bool { - tokenization::is_tokens_locked(&env, asset_id, holder) - } - - /// Get ownership percentage for a holder (in basis points) - pub fn get_ownership_percentage( - env: Env, - asset_id: u64, - holder: Address, - ) -> Result { - tokenization::calculate_ownership_percentage(&env, asset_id, holder) - } - - /// Get tokenized asset details - pub fn get_tokenized_asset(env: Env, asset_id: u64) -> Result { - tokenization::get_tokenized_asset(&env, asset_id) - } - - /// Update asset valuation - pub fn update_valuation(env: Env, asset_id: u64, new_valuation: i128) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - tokenization::update_valuation(&env, asset_id, new_valuation) - } - - // ===================== - // Dividend Functions - // ===================== - - /// Distribute dividends proportionally to all holders - pub fn distribute_dividends(env: Env, asset_id: u64, total_amount: i128) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - dividends::distribute_dividends(&env, asset_id, total_amount) - } - - /// Claim unclaimed dividends - pub fn claim_dividends(env: Env, asset_id: u64, holder: Address) -> Result { - holder.require_auth(); - dividends::claim_dividends(&env, asset_id, holder) - } - - /// Get unclaimed dividends for a holder - pub fn get_unclaimed_dividends( - env: Env, - asset_id: u64, - holder: Address, - ) -> Result { - dividends::get_unclaimed_dividends(&env, asset_id, holder) - } - - /// Enable revenue sharing for an asset - pub fn enable_revenue_sharing(env: Env, asset_id: u64) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - dividends::enable_revenue_sharing(&env, asset_id) - } - - /// Disable revenue sharing for an asset - pub fn disable_revenue_sharing(env: Env, asset_id: u64) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - dividends::disable_revenue_sharing(&env, asset_id) - } - - // ===================== - // Voting Functions - // ===================== - - /// Cast a vote on a proposal - pub fn cast_vote( - env: Env, - asset_id: u64, - proposal_id: u64, - voter: Address, - ) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - voter.require_auth(); - voting::cast_vote(&env, asset_id, proposal_id, voter) - } - - /// Get vote tally for a proposal - pub fn get_vote_tally(env: Env, asset_id: u64, proposal_id: u64) -> Result { - voting::get_vote_tally(&env, asset_id, proposal_id) - } - - /// Check if an address has voted - pub fn has_voted( - env: Env, - asset_id: u64, - proposal_id: u64, - voter: Address, - ) -> Result { - voting::has_voted(&env, asset_id, proposal_id, voter) - } - - /// Check if proposal passed - pub fn proposal_passed(env: Env, asset_id: u64, proposal_id: u64) -> Result { - voting::proposal_passed(&env, asset_id, proposal_id) - } - - // ===================== - // Transfer Restrictions - // ===================== - - /// Set transfer restrictions - pub fn set_transfer_restriction( - env: Env, - asset_id: u64, - require_accredited: bool, - ) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - transfer_restrictions::set_transfer_restriction( - &env, - asset_id, - TransferRestriction { - require_accredited, - geographic_allowed: Vec::new(&env), - }, - ) - } - - /// Add address to whitelist - pub fn add_to_whitelist(env: Env, asset_id: u64, address: Address) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - transfer_restrictions::add_to_whitelist(&env, asset_id, address) - } - - /// Remove address from whitelist - pub fn remove_from_whitelist(env: Env, asset_id: u64, address: Address) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - transfer_restrictions::remove_from_whitelist(&env, asset_id, address) - } - - /// Check if address is whitelisted - pub fn is_whitelisted(env: Env, asset_id: u64, address: Address) -> Result { - transfer_restrictions::is_whitelisted(&env, asset_id, address) - } - - /// Get whitelist - pub fn get_whitelist(env: Env, asset_id: u64) -> Result, Error> { - transfer_restrictions::get_whitelist(&env, asset_id) - } - - // ===================== - // Detokenization - // ===================== - - /// Propose detokenization - pub fn propose_detokenization( - env: Env, - asset_id: u64, - proposer: Address, - ) -> Result { - Self::require_not_paused(&env)?; - - proposer.require_auth(); - detokenization::propose_detokenization(&env, asset_id, proposer) - } - - /// Execute detokenization (if vote passed) - pub fn execute_detokenization(env: Env, asset_id: u64, proposal_id: u64) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - detokenization::execute_detokenization(&env, asset_id, proposal_id) - } - - /// Get detokenization proposal status - pub fn get_detokenization_proposal( - env: Env, - asset_id: u64, - ) -> Result { - detokenization::get_detokenization_proposal(&env, asset_id) - } - - /// Check if detokenization is active - pub fn is_detokenization_active(env: Env, asset_id: u64) -> Result { - detokenization::is_detokenization_active(&env, asset_id) - } - - // ===================== - // Insurance Policy Management - // ===================== - - /// Create a new insurance policy - pub fn create_insurance_policy( - env: Env, - policy: insurance::InsurancePolicy, - ) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - policy.insurer.require_auth(); - insurance::create_policy(env, policy) - } - - /// Cancel a policy (holder or insurer) - pub fn cancel_insurance_policy( - env: Env, - policy_id: BytesN<32>, - caller: Address, - ) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - caller.require_auth(); - insurance::cancel_policy(env, policy_id, caller) - } - - /// Suspend a policy (insurer only) - pub fn suspend_insurance_policy( - env: Env, - policy_id: BytesN<32>, - insurer: Address, - ) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - insurer.require_auth(); - insurance::suspend_policy(env, policy_id, insurer) - } - - /// Expire a policy (permissionless) - pub fn expire_insurance_policy(env: Env, policy_id: BytesN<32>) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - insurance::expire_policy(env, policy_id) - } - - /// Renew a policy (insurer only) - pub fn renew_insurance_policy( - env: Env, - policy_id: BytesN<32>, - new_end_date: u64, - new_premium: i128, - insurer: Address, - ) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - insurer.require_auth(); - insurance::renew_policy(env, policy_id, new_end_date, new_premium, insurer) - } - - /// Get a specific policy - pub fn get_insurance_policy( - env: Env, - policy_id: BytesN<32>, - ) -> Option { - insurance::get_policy(env, policy_id) - } - - /// Get all policies for an asset - pub fn get_asset_insurance_policies(env: Env, asset_id: BytesN<32>) -> Vec> { - insurance::get_asset_policies(env, asset_id) - } - - /// Create a new lease. Lessor authenticates; asset must not already be actively leased. - pub fn create_lease( - env: Env, - asset_id: BytesN<32>, - lease_id: BytesN<32>, - lessor: Address, - lessee: Address, - start: u64, - end: u64, - rent: i128, - deposit: i128, - ) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - lessor.require_auth(); - lease::create_lease( - &env, asset_id, lease_id, lessor, lessee, start, end, rent, deposit, - ) - } - - /// Return a leased asset. Callable by lessor or lessee. - pub fn return_leased_asset( - env: Env, - lease_id: BytesN<32>, - caller: Address, - ) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - caller.require_auth(); - lease::return_leased_asset(&env, lease_id, caller) - } - - /// Cancel a lease before it starts. Lessor only. - pub fn cancel_lease(env: Env, lease_id: BytesN<32>, caller: Address) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - caller.require_auth(); - lease::cancel_lease(&env, lease_id, caller) - } - - /// Expire a lease permissionlessly once end_timestamp has passed. - pub fn expire_lease(env: Env, lease_id: BytesN<32>) -> Result<(), Error> { - Self::require_not_paused(&env)?; - - lease::expire_lease(&env, lease_id) - } - - /// Fetch a lease by ID. - pub fn get_lease(env: Env, lease_id: BytesN<32>) -> Result { - lease::get_lease(&env, lease_id) - } - - /// Return the active lease for an asset, or None. - pub fn get_asset_active_lease(env: Env, asset_id: BytesN<32>) -> Option { - lease::get_asset_active_lease(&env, asset_id) - } - - /// Return all lease IDs for a given lessee. - pub fn get_lessee_leases(env: Env, lessee: Address) -> Vec> { - lease::get_lessee_leases(&env, lessee) - } -} diff --git a/contracts/assetsup/src/tests/pause.rs b/contracts/assetsup/src/tests/pause.rs index c6e635ec..a5e627dd 100644 --- a/contracts/assetsup/src/tests/pause.rs +++ b/contracts/assetsup/src/tests/pause.rs @@ -57,6 +57,18 @@ const PAUSE_EXEMPT: &[(&str, &str)] = &[ "claim_dividends", "user exit path: freezing it would trap funds users have already earned", ), + ( + "upgrade", + "an upgrade is how you fix the incident that caused the pause", + ), + ( + "migrate", + "must run after upgrade to bring storage up to date", + ), + ( + "storage_version", + "read-only: returns the current storage layout version", + ), ]; /// Reads never mutate, so they are not in scope for the pause. diff --git a/contracts/assetsup/src/upgrade.rs b/contracts/assetsup/src/upgrade.rs index 75e13951..de5c367a 100644 --- a/contracts/assetsup/src/upgrade.rs +++ b/contracts/assetsup/src/upgrade.rs @@ -21,9 +21,10 @@ //! each step needs, and is **idempotent**: running it twice is a no-op, so a //! retried or duplicated migration transaction cannot corrupt state. -use soroban_sdk::{symbol_short, Address, BytesN, Env}; +use soroban_sdk::{Address, BytesN, Env}; use crate::error::Error; +use crate::events; use crate::DataKey; /// The storage layout version this build of the contract expects. @@ -87,21 +88,10 @@ pub fn migrate_from(env: &Env, from: u32) -> Result { /// Emits the upgrade event. pub fn emit_upgraded(env: &Env, admin: &Address, new_wasm_hash: &BytesN<32>, version: u32) { - env.events().publish( - (symbol_short!("upgraded"),), - ( - admin.clone(), - new_wasm_hash.clone(), - version, - env.ledger().timestamp(), - ), - ); + events::contract_upgraded(env, admin, new_wasm_hash, version); } /// Emits the migration event. pub fn emit_migrated(env: &Env, from: u32, to: u32) { - env.events().publish( - (symbol_short!("migrated"),), - (from, to, env.ledger().timestamp()), - ); + events::contract_migrated(env, from, to); } diff --git a/contracts/multisig-transfer/src/registry.rs b/contracts/multisig-transfer/src/registry.rs index d4a5430a..ac6312e3 100644 --- a/contracts/multisig-transfer/src/registry.rs +++ b/contracts/multisig-transfer/src/registry.rs @@ -61,6 +61,7 @@ pub fn asset_is_retired( Ok(info.1 == STATUS_RETIRED) } +#[allow(dead_code)] pub fn get_owner( e: &Env, registry: &Address, diff --git a/frontend/components/dashboard/dashboard-widgets.tsx b/frontend/components/dashboard/dashboard-widgets.tsx index 970f6be6..e6559118 100644 --- a/frontend/components/dashboard/dashboard-widgets.tsx +++ b/frontend/components/dashboard/dashboard-widgets.tsx @@ -1,7 +1,7 @@ -'use client'; +"use client"; -import Link from 'next/link'; -import { format } from 'date-fns'; +import Link from "next/link"; +import { format } from "date-fns"; import { Package, CheckCircle2, @@ -13,19 +13,19 @@ import { BarChart3, Layers, Calendar, -} from 'lucide-react'; -import { StatusBadge } from '@/components/assets/status-badge'; -import { AssetStatus } from '@/lib/query/types/asset'; +} from "lucide-react"; +import { StatusBadge } from "@/components/assets/status-badge"; +import { AssetStatus } from "@/lib/query/types/asset"; export type WidgetId = - | 'summary_stats' - | 'assets_by_status' - | 'assets_by_category' - | 'recent_assets' - | 'upcoming_maintenance' - | 'my_assigned_assets' - | 'low_stock_alerts' - | 'overdue_checkouts'; + | "summary_stats" + | "assets_by_status" + | "assets_by_category" + | "recent_assets" + | "upcoming_maintenance" + | "my_assigned_assets" + | "low_stock_alerts" + | "overdue_checkouts"; export interface WidgetConfig { id: WidgetId; @@ -35,14 +35,54 @@ export interface WidgetConfig { } export const ALL_WIDGETS: WidgetConfig[] = [ - { id: 'summary_stats', title: 'Summary Stats', description: 'Total, active, assigned, and maintenance metrics', defaultVisible: true }, - { id: 'assets_by_status', title: 'Assets by Status', description: 'Visual breakdown chart of asset statuses', defaultVisible: true }, - { id: 'assets_by_category', title: 'Assets by Category', description: 'Distribution chart of assets across categories', defaultVisible: true }, - { id: 'recent_assets', title: 'Recent Assets', description: 'Table of recently registered assets', defaultVisible: true }, - { id: 'upcoming_maintenance', title: 'Upcoming Maintenance', description: 'Scheduled maintenance tasks and deadlines', defaultVisible: true }, - { id: 'my_assigned_assets', title: 'My Assigned Assets', description: 'Assets currently checked out to you', defaultVisible: true }, - { id: 'low_stock_alerts', title: 'Low Stock Alerts', description: 'Consumables and inventory items below threshold', defaultVisible: true }, - { id: 'overdue_checkouts', title: 'Overdue Checkouts', description: 'Assets past their expected return date', defaultVisible: true }, + { + id: "summary_stats", + title: "Summary Stats", + description: "Total, active, assigned, and maintenance metrics", + defaultVisible: true, + }, + { + id: "assets_by_status", + title: "Assets by Status", + description: "Visual breakdown chart of asset statuses", + defaultVisible: true, + }, + { + id: "assets_by_category", + title: "Assets by Category", + description: "Distribution chart of assets across categories", + defaultVisible: true, + }, + { + id: "recent_assets", + title: "Recent Assets", + description: "Table of recently registered assets", + defaultVisible: true, + }, + { + id: "upcoming_maintenance", + title: "Upcoming Maintenance", + description: "Scheduled maintenance tasks and deadlines", + defaultVisible: true, + }, + { + id: "my_assigned_assets", + title: "My Assigned Assets", + description: "Assets currently checked out to you", + defaultVisible: true, + }, + { + id: "low_stock_alerts", + title: "Low Stock Alerts", + description: "Consumables and inventory items below threshold", + defaultVisible: true, + }, + { + id: "overdue_checkouts", + title: "Overdue Checkouts", + description: "Assets past their expected return date", + defaultVisible: true, + }, ]; export const DEFAULT_WIDGET_ORDER: WidgetId[] = ALL_WIDGETS.map((w) => w.id); @@ -56,7 +96,7 @@ interface DashboardWidgetsProps { name: string; assetId: string; status: AssetStatus; - department?: { name: string }; + department?: { name: string } | null; createdAt: string; }>; }; @@ -65,10 +105,25 @@ interface DashboardWidgetsProps { } const statCards = [ - { label: 'Total Assets', key: 'total', icon: Package, status: null }, - { label: 'Active', key: 'active', icon: CheckCircle2, status: AssetStatus.ACTIVE }, - { label: 'Assigned', key: 'assigned', icon: UserCheck, status: AssetStatus.ASSIGNED }, - { label: 'In Maintenance', key: 'maintenance', icon: Wrench, status: AssetStatus.MAINTENANCE }, + { label: "Total Assets", key: "total", icon: Package, status: null }, + { + label: "Active", + key: "active", + icon: CheckCircle2, + status: AssetStatus.ACTIVE, + }, + { + label: "Assigned", + key: "assigned", + icon: UserCheck, + status: AssetStatus.ASSIGNED, + }, + { + label: "In Maintenance", + key: "maintenance", + icon: Wrench, + status: AssetStatus.MAINTENANCE, + }, ] as const; export function SummaryStatsWidget({ data, isLoading }: DashboardWidgetsProps) { @@ -83,7 +138,10 @@ export function SummaryStatsWidget({ data, isLoading }: DashboardWidgetsProps) {
{isLoading ? statCards.map((s) => ( -
+
@@ -91,7 +149,7 @@ export function SummaryStatsWidget({ data, isLoading }: DashboardWidgetsProps) { : statCards.map(({ label, key, icon: Icon, status }) => (
@@ -107,10 +165,26 @@ export function SummaryStatsWidget({ data, isLoading }: DashboardWidgetsProps) { export function AssetsByStatusWidget({ data }: DashboardWidgetsProps) { const statusData = [ - { label: 'Active', count: data?.byStatus?.[AssetStatus.ACTIVE] ?? 12, color: 'bg-emerald-500' }, - { label: 'Assigned', count: data?.byStatus?.[AssetStatus.ASSIGNED] ?? 8, color: 'bg-blue-500' }, - { label: 'Maintenance', count: data?.byStatus?.[AssetStatus.MAINTENANCE] ?? 3, color: 'bg-amber-500' }, - { label: 'Retired', count: data?.byStatus?.[AssetStatus.RETIRED] ?? 1, color: 'bg-slate-400' }, + { + label: "Active", + count: data?.byStatus?.[AssetStatus.ACTIVE] ?? 12, + color: "bg-emerald-500", + }, + { + label: "Assigned", + count: data?.byStatus?.[AssetStatus.ASSIGNED] ?? 8, + color: "bg-blue-500", + }, + { + label: "Maintenance", + count: data?.byStatus?.[AssetStatus.MAINTENANCE] ?? 3, + color: "bg-amber-500", + }, + { + label: "Retired", + count: data?.byStatus?.[AssetStatus.RETIRED] ?? 1, + color: "bg-slate-400", + }, ]; const total = statusData.reduce((acc, curr) => acc + curr.count, 0) || 1; @@ -120,7 +194,9 @@ export function AssetsByStatusWidget({ data }: DashboardWidgetsProps) {
-

Assets by Status

+

+ Assets by Status +

@@ -130,10 +206,15 @@ export function AssetsByStatusWidget({ data }: DashboardWidgetsProps) {
{label} - {count} ({pct}%) + + {count} ({pct}%) +
-
+
); @@ -145,10 +226,10 @@ export function AssetsByStatusWidget({ data }: DashboardWidgetsProps) { export function AssetsByCategoryWidget() { const categories = [ - { name: 'Laptops & Computers', count: 14, icon: Package }, - { name: 'Monitors & Displays', count: 9, icon: Layers }, - { name: 'Office Furniture', count: 6, icon: Layers }, - { name: 'Mobile Devices', count: 4, icon: Package }, + { name: "Laptops & Computers", count: 14, icon: Package }, + { name: "Monitors & Displays", count: 9, icon: Layers }, + { name: "Office Furniture", count: 6, icon: Layers }, + { name: "Mobile Devices", count: 4, icon: Package }, ]; return ( @@ -156,14 +237,21 @@ export function AssetsByCategoryWidget() {
-

Assets by Category

+

+ Assets by Category +

{categories.map((cat) => ( -
+
{cat.name} - {cat.count} items + + {cat.count} items +
))}
@@ -176,7 +264,10 @@ export function RecentAssetsWidget({ data, isLoading }: DashboardWidgetsProps) {

Recent Assets

- + View all assets
@@ -197,13 +288,18 @@ export function RecentAssetsWidget({ data, isLoading }: DashboardWidgetsProps) { Array.from({ length: 4 }).map((_, i) => ( {[1, 2, 3, 4, 5].map((j) => ( -
+ +
+ ))} )) ) : !data?.recent?.length ? ( - + No recent assets found. @@ -214,12 +310,18 @@ export function RecentAssetsWidget({ data, isLoading }: DashboardWidgetsProps) { className="border-b border-gray-100 last:border-0 hover:bg-gray-50 cursor-pointer" onClick={() => (window.location.href = `/assets/${asset.id}`)} > - {asset.name} + + {asset.name} + {asset.assetId} - - {asset.department?.name ?? '—'} + + + - {format(new Date(asset.createdAt), 'MMM d, yyyy')} + {asset.department?.name ?? "—"} + + + {format(new Date(asset.createdAt), "MMM d, yyyy")} )) @@ -233,8 +335,16 @@ export function RecentAssetsWidget({ data, isLoading }: DashboardWidgetsProps) { export function UpcomingMaintenanceWidget() { const items = [ - { title: 'MacBook Pro M2 - Battery Check', date: 'Tomorrow, 10:00 AM', status: 'Scheduled' }, - { title: 'Dell UltraSharp - Firmware Upgrade', date: 'Jul 30, 2026', status: 'Pending' }, + { + title: "MacBook Pro M2 - Battery Check", + date: "Tomorrow, 10:00 AM", + status: "Scheduled", + }, + { + title: "Dell UltraSharp - Firmware Upgrade", + date: "Jul 30, 2026", + status: "Pending", + }, ]; return ( @@ -242,17 +352,24 @@ export function UpcomingMaintenanceWidget() {
-

Upcoming Maintenance

+

+ Upcoming Maintenance +

{items.map((item) => ( -
+

{item.title}

{item.date}

- {item.status} + + {item.status} +
))}
@@ -262,8 +379,16 @@ export function UpcomingMaintenanceWidget() { export function MyAssignedAssetsWidget() { const items = [ - { name: 'MacBook Pro 16" (M2 Max)', assetId: 'AST-00102', date: 'Assigned Jan 15, 2026' }, - { name: 'Logitech MX Master 3S', assetId: 'AST-00451', date: 'Assigned Feb 01, 2026' }, + { + name: 'MacBook Pro 16" (M2 Max)', + assetId: "AST-00102", + date: "Assigned Jan 15, 2026", + }, + { + name: "Logitech MX Master 3S", + assetId: "AST-00451", + date: "Assigned Feb 01, 2026", + }, ]; return ( @@ -271,15 +396,22 @@ export function MyAssignedAssetsWidget() {
-

My Assigned Assets

+

+ My Assigned Assets +

{items.map((item) => ( -
+

{item.name}

-

{item.assetId}

+

+ {item.assetId} +

{item.date}
@@ -291,8 +423,8 @@ export function MyAssignedAssetsWidget() { export function LowStockAlertsWidget() { const items = [ - { item: 'USB-C Adapters', current: 2, threshold: 10 }, - { item: 'HDMI Cables (2m)', current: 4, threshold: 15 }, + { item: "USB-C Adapters", current: 2, threshold: 10 }, + { item: "HDMI Cables (2m)", current: 4, threshold: 15 }, ]; return ( @@ -300,17 +432,26 @@ export function LowStockAlertsWidget() {
-

Low Stock Alerts

+

+ Low Stock Alerts +

{items.map((item) => ( -
+

{item.item}

-

Threshold: {item.threshold} units

+

+ Threshold: {item.threshold} units +

- {item.current} remaining + + {item.current} remaining +
))}
@@ -320,7 +461,11 @@ export function LowStockAlertsWidget() { export function OverdueCheckoutsWidget() { const items = [ - { name: 'Sony WH-1000XM5 Headphones', borrower: 'Alex Rivera', overdueDays: 4 }, + { + name: "Sony WH-1000XM5 Headphones", + borrower: "Alex Rivera", + overdueDays: 4, + }, ]; return ( @@ -328,17 +473,26 @@ export function OverdueCheckoutsWidget() {
-

Overdue Checkouts

+

+ Overdue Checkouts +

{items.map((item) => ( -
+

{item.name}

-

Checked out to {item.borrower}

+

+ Checked out to {item.borrower} +

- {item.overdueDays} days overdue + + {item.overdueDays} days overdue +
))}
@@ -346,23 +500,31 @@ export function OverdueCheckoutsWidget() { ); } -export function WidgetRenderer({ id, data, isLoading }: { id: WidgetId; data?: DashboardWidgetsProps['data']; isLoading?: boolean }) { +export function WidgetRenderer({ + id, + data, + isLoading, +}: { + id: WidgetId; + data?: DashboardWidgetsProps["data"]; + isLoading?: boolean; +}) { switch (id) { - case 'summary_stats': + case "summary_stats": return ; - case 'assets_by_status': + case "assets_by_status": return ; - case 'assets_by_category': + case "assets_by_category": return ; - case 'recent_assets': + case "recent_assets": return ; - case 'upcoming_maintenance': + case "upcoming_maintenance": return ; - case 'my_assigned_assets': + case "my_assigned_assets": return ; - case 'low_stock_alerts': + case "low_stock_alerts": return ; - case 'overdue_checkouts': + case "overdue_checkouts": return ; default: return null; diff --git a/frontend/jest.config.js b/frontend/jest.config.js index 5342bfd6..23ee3c8f 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -1,14 +1,15 @@ -const nextJest = require('next/jest'); +/* eslint-disable @typescript-eslint/no-require-imports */ +const nextJest = require("next/jest"); const createJestConfig = nextJest({ - dir: './', + dir: "./", }); const customJestConfig = { - setupFilesAfterEnv: ['/jest.setup.js'], - testEnvironment: 'jest-environment-jsdom', + setupFilesAfterEnv: ["/jest.setup.js"], + testEnvironment: "jest-environment-jsdom", moduleNameMapper: { - '^@/(.*)$': '/$1', + "^@/(.*)$": "/$1", }, };