Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions backend/src/abdulrcrtw.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,34 @@ 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);
});

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);
});

Expand Down
13 changes: 4 additions & 9 deletions backend/src/app.controller.ts
Original file line number Diff line number Diff line change
@@ -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(),
};
}
}
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -46,6 +47,7 @@ import { GatewayModule } from './gateway/gateway.module';
LicensesModule,
NotificationsModule,
GatewayModule,
AuditLogsModule,
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
Expand Down
48 changes: 41 additions & 7 deletions backend/src/assets/asset-lifecycle.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,37 @@ export enum AssetStatus {
}

const ALLOWED_TRANSITIONS: Record<AssetStatus, AssetStatus[]> = {
[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()
Expand All @@ -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)}`,
Expand Down
23 changes: 19 additions & 4 deletions backend/src/assets/asset-status.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Expand Down
31 changes: 26 additions & 5 deletions backend/src/assets/assets.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -36,54 +43,68 @@ 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);
}

@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);
}
Expand Down
4 changes: 3 additions & 1 deletion backend/src/assets/assets.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down
Loading
Loading