feat: Goa GEL Blockchain e-Licensing Platform - Full Stack Implementation
Complete implementation of the Goa Government e-Licensing platform with: Backend: - NestJS API with JWT authentication - PostgreSQL database with Knex ORM - Redis caching and session management - MinIO document storage - Hyperledger Besu blockchain integration - Multi-department workflow system - Comprehensive API tests (266/282 passing) Frontend: - Angular 21 with standalone components - Angular Material + TailwindCSS UI - Visual workflow builder - Document upload with progress tracking - Blockchain explorer integration - Role-based dashboards (Admin, Department, Citizen) - E2E tests with Playwright (37 tests) Infrastructure: - Docker Compose orchestration - Blockscout blockchain explorer - Development and production configurations
This commit is contained in:
3
backend/src/modules/audit/CLAUDE.md
Normal file
3
backend/src/modules/audit/CLAUDE.md
Normal file
@@ -0,0 +1,3 @@
|
||||
<claude-mem-context>
|
||||
|
||||
</claude-mem-context>
|
||||
90
backend/src/modules/audit/audit.controller.ts
Normal file
90
backend/src/modules/audit/audit.controller.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
Param,
|
||||
UseGuards,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuditService, AuditQueryDto } from './audit.service';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
import { UserRole } from '../../common/enums';
|
||||
|
||||
@ApiTags('Audit')
|
||||
@Controller('audit')
|
||||
@ApiBearerAuth('BearerAuth')
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
export class AuditController {
|
||||
private readonly logger = new Logger(AuditController.name);
|
||||
|
||||
constructor(private readonly auditService: AuditService) {}
|
||||
|
||||
@Get('logs')
|
||||
@ApiOperation({
|
||||
summary: 'Query audit logs',
|
||||
description: 'Get paginated audit logs with optional filters by entity, action, actor, and date range',
|
||||
})
|
||||
@ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type (REQUEST, DOCUMENT, DEPARTMENT, etc.)' })
|
||||
@ApiQuery({ name: 'entityId', required: false, description: 'Filter by entity ID' })
|
||||
@ApiQuery({ name: 'action', required: false, description: 'Filter by action (CREATE, UPDATE, DELETE, APPROVE, REJECT, etc.)' })
|
||||
@ApiQuery({ name: 'actorType', required: false, description: 'Filter by actor type (APPLICANT, DEPARTMENT, SYSTEM, ADMIN)' })
|
||||
@ApiQuery({ name: 'actorId', required: false, description: 'Filter by actor ID' })
|
||||
@ApiQuery({ name: 'startDate', required: false, description: 'Filter from date (ISO 8601)' })
|
||||
@ApiQuery({ name: 'endDate', required: false, description: 'Filter to date (ISO 8601)' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 20)' })
|
||||
@ApiResponse({ status: 200, description: 'Paginated audit logs' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden - Admin only' })
|
||||
async findAll(@Query() query: AuditQueryDto) {
|
||||
this.logger.debug('Querying audit logs');
|
||||
return this.auditService.findAll(query);
|
||||
}
|
||||
|
||||
@Get('requests/:requestId')
|
||||
@ApiOperation({
|
||||
summary: 'Get audit trail for request',
|
||||
description: 'Get complete audit trail for a specific license request',
|
||||
})
|
||||
@ApiParam({ name: 'requestId', description: 'Request ID (UUID)' })
|
||||
@ApiResponse({ status: 200, description: 'Audit trail for request' })
|
||||
async findByRequest(@Param('requestId') requestId: string) {
|
||||
return this.auditService.findByEntity('REQUEST', requestId);
|
||||
}
|
||||
|
||||
@Get('entity/:entityType/:entityId')
|
||||
@ApiOperation({
|
||||
summary: 'Get audit trail for entity',
|
||||
description: 'Get complete audit trail for a specific entity (e.g., all changes to a request)',
|
||||
})
|
||||
@ApiParam({ name: 'entityType', description: 'Entity type (REQUEST, DOCUMENT, DEPARTMENT, etc.)' })
|
||||
@ApiParam({ name: 'entityId', description: 'Entity ID (UUID)' })
|
||||
@ApiResponse({ status: 200, description: 'Audit trail for entity' })
|
||||
async findByEntity(
|
||||
@Param('entityType') entityType: string,
|
||||
@Param('entityId') entityId: string,
|
||||
) {
|
||||
return this.auditService.findByEntity(entityType, entityId);
|
||||
}
|
||||
|
||||
@Get('metadata')
|
||||
@ApiOperation({
|
||||
summary: 'Get audit metadata',
|
||||
description: 'Get available audit actions, entity types, and actor types for filtering',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Audit metadata' })
|
||||
async getMetadata() {
|
||||
return this.auditService.getEntityActions();
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/audit/audit.module.ts
Normal file
10
backend/src/modules/audit/audit.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditController } from './audit.controller';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AuditController],
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
124
backend/src/modules/audit/audit.service.ts
Normal file
124
backend/src/modules/audit/audit.service.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { AuditLog } from '../../database/models/audit-log.model';
|
||||
import { AuditAction, ActorType, EntityType } from '../../common/enums';
|
||||
|
||||
export interface CreateAuditLogDto {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
action: string;
|
||||
actorType: string;
|
||||
actorId?: string;
|
||||
oldValue?: Record<string, unknown>;
|
||||
newValue?: Record<string, unknown>;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
correlationId?: string;
|
||||
}
|
||||
|
||||
export interface AuditQueryDto {
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
action?: string;
|
||||
actorType?: string;
|
||||
actorId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
private readonly logger = new Logger(AuditService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(AuditLog)
|
||||
private auditLogModel: typeof AuditLog,
|
||||
) {}
|
||||
|
||||
async record(dto: CreateAuditLogDto): Promise<AuditLog> {
|
||||
this.logger.debug(
|
||||
`Recording audit: ${dto.action} on ${dto.entityType}/${dto.entityId}`,
|
||||
);
|
||||
|
||||
return this.auditLogModel.query().insert({
|
||||
entityType: dto.entityType,
|
||||
entityId: dto.entityId,
|
||||
action: dto.action,
|
||||
actorType: dto.actorType,
|
||||
actorId: dto.actorId,
|
||||
oldValue: dto.oldValue as any,
|
||||
newValue: dto.newValue as any,
|
||||
ipAddress: dto.ipAddress,
|
||||
userAgent: dto.userAgent,
|
||||
correlationId: dto.correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(queryDto: AuditQueryDto) {
|
||||
const page = queryDto.page || 1;
|
||||
const limit = queryDto.limit || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const query = this.auditLogModel.query().orderBy('created_at', 'DESC');
|
||||
|
||||
if (queryDto.entityType) {
|
||||
query.where({ entityType: queryDto.entityType });
|
||||
}
|
||||
if (queryDto.entityId) {
|
||||
query.where({ entityId: queryDto.entityId });
|
||||
}
|
||||
if (queryDto.action) {
|
||||
query.where({ action: queryDto.action });
|
||||
}
|
||||
if (queryDto.actorType) {
|
||||
query.where({ actorType: queryDto.actorType });
|
||||
}
|
||||
if (queryDto.actorId) {
|
||||
query.where({ actorId: queryDto.actorId });
|
||||
}
|
||||
if (queryDto.startDate) {
|
||||
query.where('createdAt', '>=', queryDto.startDate);
|
||||
}
|
||||
if (queryDto.endDate) {
|
||||
query.where('createdAt', '<=', queryDto.endDate);
|
||||
}
|
||||
|
||||
const [results, total] = await Promise.all([
|
||||
query.clone().offset(offset).limit(limit),
|
||||
query.clone().resultSize(),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: results,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findByEntity(entityType: string, entityId: string): Promise<any[]> {
|
||||
const logs = await this.auditLogModel
|
||||
.query()
|
||||
.where('entity_type', entityType)
|
||||
.where('entity_id', entityId)
|
||||
.orderBy('created_at', 'DESC');
|
||||
|
||||
// Transform to add performedBy and details fields from newValue
|
||||
return logs.map((log) => ({
|
||||
...log,
|
||||
performedBy: (log.newValue as any)?.performedBy,
|
||||
details: (log.newValue as any)?.reason || (log.newValue as any)?.remarks ||
|
||||
(log.action === 'REQUEST_CANCELLED' ? (log.newValue as any)?.reason : undefined),
|
||||
}));
|
||||
}
|
||||
|
||||
async getEntityActions(): Promise<{ actions: string[]; entityTypes: string[]; actorTypes: string[] }> {
|
||||
return {
|
||||
actions: Object.values(AuditAction),
|
||||
entityTypes: Object.values(EntityType),
|
||||
actorTypes: Object.values(ActorType),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user