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
11 changes: 11 additions & 0 deletions backend/src/assets/asset-history.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetHistoryEvent } from './entities/asset-history-event.entity';
import { AssetHistoryService } from './asset-history.service';

@Module({
imports: [TypeOrmModule.forFeature([AssetHistoryEvent])],
providers: [AssetHistoryService],
exports: [AssetHistoryService],
})
export class AssetHistoryModule {}
77 changes: 77 additions & 0 deletions backend/src/assets/asset-history.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Between, EntityManager, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
import {
AssetHistoryAction,
AssetHistoryEvent,
} from './entities/asset-history-event.entity';

export interface RecordHistoryInput {
assetId: string;
action: AssetHistoryAction;
description: string;
previousValue?: Record<string, unknown> | null;
newValue?: Record<string, unknown> | null;
performedById?: string;
}

export interface AssetHistoryFilters {
action?: AssetHistoryAction;
startDate?: string;
endDate?: string;
search?: string;
}

@Injectable()
export class AssetHistoryService {
constructor(
@InjectRepository(AssetHistoryEvent)
private readonly historyRepo: Repository<AssetHistoryEvent>,
) {}

/**
* Persist a history event. Pass `manager` to enlist the write in the caller's
* transaction so the event and the asset mutation commit together.
*/
async record(
input: RecordHistoryInput,
manager?: EntityManager,
): Promise<AssetHistoryEvent> {
const repo = manager ? manager.getRepository(AssetHistoryEvent) : this.historyRepo;
const event = repo.create({
assetId: input.assetId,
action: input.action,
description: input.description,
previousValue: input.previousValue ?? null,
newValue: input.newValue ?? null,
performedById: input.performedById,
});
return repo.save(event);
}

async findByAsset(
assetId: string,
filters?: AssetHistoryFilters,
): Promise<AssetHistoryEvent[]> {
const where: Record<string, unknown> = { assetId };

if (filters?.action) where.action = filters.action;

const start = filters?.startDate ? new Date(filters.startDate) : undefined;
const end = filters?.endDate ? new Date(filters.endDate) : undefined;
if (start && end) where.createdAt = Between(start, end);
else if (start) where.createdAt = MoreThanOrEqual(start);
else if (end) where.createdAt = LessThanOrEqual(end);

const events = await this.historyRepo.find({
where,
relations: ['performedBy'],
order: { createdAt: 'DESC' },
});

if (!filters?.search) return events;

const needle = filters.search.toLowerCase();
return events.filter((e) => e.description?.toLowerCase().includes(needle));
}
}
39 changes: 39 additions & 0 deletions backend/src/assets/asset-lifecycle.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,27 @@ export enum AssetStatus {
LOST = 'LOST',
}

/**
* Status values accepted on the wire: the canonical statuses plus the aliases
* the frontend AssetStatus enum uses (ACTIVE, MAINTENANCE).
*/
export enum AssetStatusInput {
AVAILABLE = 'AVAILABLE',
ACTIVE = 'ACTIVE',
ASSIGNED = 'ASSIGNED',
IN_MAINTENANCE = 'IN_MAINTENANCE',
MAINTENANCE = 'MAINTENANCE',
IN_TRANSIT = 'IN_TRANSIT',
RETIRED = 'RETIRED',
DISPOSED = 'DISPOSED',
LOST = 'LOST',
}

const STATUS_ALIASES: Record<string, AssetStatus> = {
ACTIVE: AssetStatus.AVAILABLE,
MAINTENANCE: AssetStatus.IN_MAINTENANCE,
};

const ALLOWED_TRANSITIONS: Record<AssetStatus, AssetStatus[]> = {
[AssetStatus.AVAILABLE]: [
AssetStatus.ASSIGNED,
Expand Down Expand Up @@ -48,6 +69,24 @@ const ALLOWED_TRANSITIONS: Record<AssetStatus, AssetStatus[]> = {
export class AssetLifecycleService {
private history = new Map<string, any[]>();

/**
* Resolve a wire status value to its canonical AssetStatus, rejecting anything
* outside the enum with a 400.
*/
normalizeStatus(status: string): AssetStatus {
if (!status) {
throw new BadRequestException('status is required');
}
const upper = String(status).toUpperCase();
const resolved = STATUS_ALIASES[upper] ?? (upper as AssetStatus);
if (!Object.values(AssetStatus).includes(resolved)) {
throw new BadRequestException(
`Invalid asset status "${status}". Allowed values: ${Object.values(AssetStatusInput).join(', ')}`,
);
}
return resolved;
}

validateTransition(fromStatus: AssetStatus, toStatus: AssetStatus) {
if (fromStatus === toStatus) return true;
const allowed = ALLOWED_TRANSITIONS[fromStatus] || [];
Expand Down
48 changes: 0 additions & 48 deletions backend/src/assets/asset-status.controller.ts

This file was deleted.

2 changes: 0 additions & 2 deletions backend/src/assets/assets-lifecycle.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { Module } from '@nestjs/common';
import { AssetLifecycleService } from './asset-lifecycle.service';
import { AssetStatusController } from './asset-status.controller';

@Module({
providers: [AssetLifecycleService],
controllers: [AssetStatusController],
exports: [AssetLifecycleService],
})
export class AssetsLifecycleModule {}
62 changes: 58 additions & 4 deletions backend/src/assets/assets.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,17 @@ import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiBadRequestResponse,
ApiResponse,
} from '@nestjs/swagger';
import { AssetsService } from './assets.service';
import { AssetHistoryService } from './asset-history.service';
import { AssetHistoryAction } from './entities/asset-history-event.entity';
import { BulkStatusDto } from './dto/bulk-status.dto';
import { BulkAssignDto } from './dto/bulk-assign.dto';
import { BulkDeleteDto } from './dto/bulk-delete.dto';
import { UpdateAssetStatusDto } from './dto/update-asset-status.dto';
import { TransferAssetDto } from './dto/transfer-asset.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
Expand All @@ -29,7 +34,10 @@ import { User } from '../users/entities/user.entity';
@ApiBearerAuth('JWT-auth')
@Controller('assets')
export class AssetsController {
constructor(private readonly assetsService: AssetsService) {}
constructor(
private readonly assetsService: AssetsService,
private readonly assetHistoryService: AssetHistoryService,
) {}

@Get()
@ApiOperation({ summary: 'List all assets (paginated, filterable)' })
Expand Down Expand Up @@ -66,14 +74,15 @@ export class AssetsController {
@ApiResponse({ status: 200, description: 'Asset details' })
@ApiResponse({ status: 404, description: 'Asset not found' })
findOne(@Param('id') id: string) {
return this.assetsService.findById(id);
return this.assetsService.findDetail(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);
async update(@Param('id') id: string, @Body() dto: any) {
await this.assetsService.update(id, dto);
return this.assetsService.findDetail(id);
}

@Delete(':id')
Expand All @@ -83,6 +92,51 @@ export class AssetsController {
return this.assetsService.delete(id);
}

@Get(':id/history')
@ApiOperation({ summary: 'Get an asset history and audit trail' })
getHistory(
@Param('id') id: string,
@Query('action') action?: AssetHistoryAction,
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string,
@Query('search') search?: string,
) {
return this.assetHistoryService.findByAsset(id, {
action,
startDate,
endDate,
search,
});
}

@Patch(':id/status')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Change an asset status, with an optional reason' })
@ApiBadRequestResponse({ description: 'Invalid status value or illegal transition' })
updateStatus(
@Param('id') id: string,
@Body() dto: UpdateAssetStatusDto,
@GetUser() user: User,
) {
return this.assetsService.updateStatus(id, dto, user?.id);
}

@Post(':id/transfer')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Transfer an asset to another department and/or user' })
@ApiBadRequestResponse({
description: 'No transfer target given, or department/user does not exist',
})
transfer(
@Param('id') id: string,
@Body() dto: TransferAssetDto,
@GetUser() user: User,
) {
return this.assetsService.transfer(id, dto, user?.id);
}

@Patch('bulk/status')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Bulk update asset status' })
Expand Down
6 changes: 5 additions & 1 deletion backend/src/assets/assets.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ import { Asset } from './entities/asset.entity';
import { AssetsService } from './assets.service';
import { AssetsController } from './assets.controller';
import { AssetsLifecycleModule } from './assets-lifecycle.module';
import { AssetHistoryModule } from './asset-history.module';
import { AuditLogsModule } from '../audit-logs/audit-logs.module';
import { Department } from '../departments/entities/department.entity';
import { User } from '../users/entities/user.entity';

@Module({
imports: [
TypeOrmModule.forFeature([Asset]),
TypeOrmModule.forFeature([Asset, Department, User]),
AssetsLifecycleModule,
AssetHistoryModule,
AuditLogsModule,
],
providers: [AssetsService],
Expand Down
5 changes: 5 additions & 0 deletions backend/src/assets/assets.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { AssetsService } from './assets.service';
import { Asset } from './entities/asset.entity';
import { AssetLifecycleService, AssetStatus } from './asset-lifecycle.service';
import { AuditLogsService } from '../audit-logs/audit-logs.service';
import { AssetHistoryService } from './asset-history.service';

describe('AssetsService', () => {
let service: AssetsService;
Expand Down Expand Up @@ -35,6 +36,10 @@ describe('AssetsService', () => {
provide: AuditLogsService,
useValue: createMock<AuditLogsService>(),
},
{
provide: AssetHistoryService,
useValue: createMock<AssetHistoryService>(),
},
{
provide: DataSource,
useValue: createMock<DataSource>(),
Expand Down
Loading
Loading