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:
261
backend/src/modules/admin/admin.controller.ts
Normal file
261
backend/src/modules/admin/admin.controller.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
ApiBody,
|
||||
} from '@nestjs/swagger';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AdminService } from './admin.service';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
import { UserRole } from '../../common/enums';
|
||||
|
||||
@ApiTags('Admin')
|
||||
@Controller('admin')
|
||||
@ApiBearerAuth('BearerAuth')
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
export class AdminController {
|
||||
private readonly logger = new Logger(AdminController.name);
|
||||
|
||||
constructor(private readonly adminService: AdminService) {}
|
||||
|
||||
@Get('stats')
|
||||
@ApiOperation({
|
||||
summary: 'Get platform statistics',
|
||||
description: 'Get overall platform statistics including request counts, user counts, and transaction data',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Platform statistics' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden - Admin only' })
|
||||
async getStats() {
|
||||
this.logger.debug('Fetching platform stats');
|
||||
return this.adminService.getPlatformStats();
|
||||
}
|
||||
|
||||
@Get('health')
|
||||
@ApiOperation({
|
||||
summary: 'Get system health',
|
||||
description: 'Get health status of all platform services (database, blockchain, storage, queue)',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'System health status' })
|
||||
async getHealth() {
|
||||
return this.adminService.getSystemHealth();
|
||||
}
|
||||
|
||||
@Get('activity')
|
||||
@ApiOperation({
|
||||
summary: 'Get recent activity',
|
||||
description: 'Get recent audit log entries for platform activity monitoring',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'limit',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: 'Number of recent entries (default: 20)',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Recent activity logs' })
|
||||
async getRecentActivity(@Query('limit') limit?: number) {
|
||||
return this.adminService.getRecentActivity(limit || 20);
|
||||
}
|
||||
|
||||
@Get('blockchain/transactions')
|
||||
@ApiOperation({
|
||||
summary: 'List blockchain transactions',
|
||||
description: 'Get paginated list of blockchain transactions with optional status filter',
|
||||
})
|
||||
@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)' })
|
||||
@ApiQuery({ name: 'status', required: false, description: 'Filter by transaction status (PENDING, CONFIRMED, FAILED)' })
|
||||
@ApiResponse({ status: 200, description: 'Paginated blockchain transactions' })
|
||||
async getBlockchainTransactions(
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.adminService.getBlockchainTransactions(
|
||||
page || 1,
|
||||
limit || 20,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('departments')
|
||||
@ApiOperation({
|
||||
summary: 'Onboard new department',
|
||||
description: 'Create a new department with wallet and API key generation',
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['code', 'name', 'contactEmail'],
|
||||
properties: {
|
||||
code: { type: 'string', example: 'POLICE_DEPT' },
|
||||
name: { type: 'string', example: 'Police Department' },
|
||||
description: { type: 'string', example: 'Law enforcement department' },
|
||||
contactEmail: { type: 'string', example: 'police@goa.gov.in' },
|
||||
contactPhone: { type: 'string', example: '+91-832-6666666' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Department onboarded successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid input' })
|
||||
@ApiResponse({ status: 409, description: 'Department already exists' })
|
||||
async onboardDepartment(@Body() dto: any) {
|
||||
return this.adminService.onboardDepartment(dto);
|
||||
}
|
||||
|
||||
@Get('departments')
|
||||
@ApiOperation({
|
||||
summary: 'List all departments',
|
||||
description: 'Get list of all departments with pagination',
|
||||
})
|
||||
@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: 'List of departments' })
|
||||
async getDepartments(
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
return this.adminService.getDepartments(page || 1, limit || 20);
|
||||
}
|
||||
|
||||
@Get('departments/:id')
|
||||
@ApiOperation({
|
||||
summary: 'Get department details',
|
||||
description: 'Get detailed information about a specific department',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Department details' })
|
||||
@ApiResponse({ status: 404, description: 'Department not found' })
|
||||
async getDepartment(@Param('id') id: string) {
|
||||
return this.adminService.getDepartment(id);
|
||||
}
|
||||
|
||||
@Patch('departments/:id')
|
||||
@ApiOperation({
|
||||
summary: 'Update department',
|
||||
description: 'Update department information',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Department updated' })
|
||||
@ApiResponse({ status: 404, description: 'Department not found' })
|
||||
async updateDepartment(@Param('id') id: string, @Body() dto: any) {
|
||||
return this.adminService.updateDepartment(id, dto);
|
||||
}
|
||||
|
||||
@Post('departments/:id/regenerate-api-key')
|
||||
@ApiOperation({
|
||||
summary: 'Regenerate department API key',
|
||||
description: 'Generate a new API key for the department',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'API key regenerated' })
|
||||
@ApiResponse({ status: 404, description: 'Department not found' })
|
||||
async regenerateApiKey(@Param('id') id: string) {
|
||||
return this.adminService.regenerateDepartmentApiKey(id);
|
||||
}
|
||||
|
||||
@Patch('departments/:id/deactivate')
|
||||
@ApiOperation({
|
||||
summary: 'Deactivate department',
|
||||
description: 'Deactivate a department',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Department deactivated' })
|
||||
@ApiResponse({ status: 404, description: 'Department not found' })
|
||||
async deactivateDepartment(@Param('id') id: string) {
|
||||
return this.adminService.deactivateDepartment(id);
|
||||
}
|
||||
|
||||
@Patch('departments/:id/activate')
|
||||
@ApiOperation({
|
||||
summary: 'Activate department',
|
||||
description: 'Activate a department',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Department activated' })
|
||||
@ApiResponse({ status: 404, description: 'Department not found' })
|
||||
async activateDepartment(@Param('id') id: string) {
|
||||
return this.adminService.activateDepartment(id);
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
@ApiOperation({
|
||||
summary: 'List all users',
|
||||
description: 'Get list of all users with pagination',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'List of users' })
|
||||
async getUsers() {
|
||||
return this.adminService.getUsers();
|
||||
}
|
||||
|
||||
@Get('blockchain/events')
|
||||
@ApiOperation({
|
||||
summary: 'List blockchain events',
|
||||
description: 'Get paginated list of blockchain events with optional filters',
|
||||
})
|
||||
@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)' })
|
||||
@ApiQuery({ name: 'eventType', required: false, description: 'Filter by event type' })
|
||||
@ApiQuery({ name: 'contractAddress', required: false, description: 'Filter by contract address' })
|
||||
@ApiResponse({ status: 200, description: 'Paginated blockchain events' })
|
||||
async getBlockchainEvents(
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
@Query('eventType') eventType?: string,
|
||||
@Query('contractAddress') contractAddress?: string,
|
||||
) {
|
||||
return this.adminService.getBlockchainEvents(
|
||||
page || 1,
|
||||
limit || 20,
|
||||
eventType,
|
||||
contractAddress,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('logs')
|
||||
@ApiOperation({
|
||||
summary: 'List application logs',
|
||||
description: 'Get paginated list of application logs with optional filters',
|
||||
})
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 50)' })
|
||||
@ApiQuery({ name: 'level', required: false, description: 'Filter by log level (INFO, WARN, ERROR)' })
|
||||
@ApiQuery({ name: 'module', required: false, description: 'Filter by module name' })
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search in log messages' })
|
||||
@ApiResponse({ status: 200, description: 'Paginated application logs' })
|
||||
async getApplicationLogs(
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
@Query('level') level?: string,
|
||||
@Query('module') module?: string,
|
||||
@Query('search') search?: string,
|
||||
) {
|
||||
return this.adminService.getApplicationLogs(
|
||||
page || 1,
|
||||
limit || 50,
|
||||
level,
|
||||
module,
|
||||
search,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('documents/:requestId')
|
||||
@ApiOperation({
|
||||
summary: 'Get documents for a request',
|
||||
description: 'Get all documents with versions and department reviews for a specific request',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Documents with version history and reviews' })
|
||||
async getRequestDocuments(@Param('requestId') requestId: string) {
|
||||
return this.adminService.getRequestDocuments(requestId);
|
||||
}
|
||||
}
|
||||
13
backend/src/modules/admin/admin.module.ts
Normal file
13
backend/src/modules/admin/admin.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
import { DepartmentsModule } from '../departments/departments.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [DepartmentsModule, UsersModule],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
exports: [AdminService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
336
backend/src/modules/admin/admin.service.ts
Normal file
336
backend/src/modules/admin/admin.service.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { LicenseRequest } from '../../database/models/license-request.model';
|
||||
import { Applicant } from '../../database/models/applicant.model';
|
||||
import { Department } from '../../database/models/department.model';
|
||||
import { User } from '../../database/models/user.model';
|
||||
import { Document } from '../../database/models/document.model';
|
||||
import { BlockchainTransaction } from '../../database/models/blockchain-transaction.model';
|
||||
import { BlockchainEvent } from '../../database/models/blockchain-event.model';
|
||||
import { ApplicationLog } from '../../database/models/application-log.model';
|
||||
import { AuditLog } from '../../database/models/audit-log.model';
|
||||
import { DepartmentsService } from '../departments/departments.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
export interface PlatformStats {
|
||||
totalRequests: number;
|
||||
requestsByStatus: Record<string, number>;
|
||||
totalApplicants: number;
|
||||
activeApplicants: number;
|
||||
totalDepartments: number;
|
||||
activeDepartments: number;
|
||||
totalDocuments: number;
|
||||
totalBlockchainTransactions: number;
|
||||
transactionsByStatus: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface SystemHealth {
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
uptime: number;
|
||||
timestamp: string;
|
||||
services: {
|
||||
database: { status: string };
|
||||
blockchain: { status: string };
|
||||
storage: { status: string };
|
||||
queue: { status: string };
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
private readonly logger = new Logger(AdminService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(LicenseRequest) private requestModel: typeof LicenseRequest,
|
||||
@Inject(Applicant) private applicantModel: typeof Applicant,
|
||||
@Inject(Department) private departmentModel: typeof Department,
|
||||
@Inject(User) private userModel: typeof User,
|
||||
@Inject(Document) private documentModel: typeof Document,
|
||||
@Inject(BlockchainTransaction) private blockchainTxModel: typeof BlockchainTransaction,
|
||||
@Inject(BlockchainEvent) private blockchainEventModel: typeof BlockchainEvent,
|
||||
@Inject(ApplicationLog) private appLogModel: typeof ApplicationLog,
|
||||
@Inject(AuditLog) private auditLogModel: typeof AuditLog,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly departmentsService: DepartmentsService,
|
||||
private readonly usersService: UsersService,
|
||||
) {}
|
||||
|
||||
async getPlatformStats(): Promise<PlatformStats> {
|
||||
this.logger.debug('Fetching platform statistics');
|
||||
|
||||
const [
|
||||
totalRequests,
|
||||
requestsByStatus,
|
||||
totalApplicants,
|
||||
activeApplicants,
|
||||
totalDepartments,
|
||||
activeDepartments,
|
||||
totalDocuments,
|
||||
totalBlockchainTransactions,
|
||||
transactionsByStatus,
|
||||
] = await Promise.all([
|
||||
this.requestModel.query().resultSize(),
|
||||
this.requestModel
|
||||
.query()
|
||||
.select('status')
|
||||
.count('* as count')
|
||||
.groupBy('status') as any,
|
||||
this.applicantModel.query().resultSize(),
|
||||
this.applicantModel.query().where({ isActive: true }).resultSize(),
|
||||
this.departmentModel.query().resultSize(),
|
||||
this.departmentModel.query().where({ isActive: true }).resultSize(),
|
||||
this.documentModel.query().resultSize(),
|
||||
this.blockchainTxModel.query().resultSize(),
|
||||
this.blockchainTxModel
|
||||
.query()
|
||||
.select('status')
|
||||
.count('* as count')
|
||||
.groupBy('status') as any,
|
||||
]);
|
||||
|
||||
const statusMap: Record<string, number> = {};
|
||||
for (const row of requestsByStatus) {
|
||||
statusMap[(row as any).status] = parseInt((row as any).count, 10);
|
||||
}
|
||||
|
||||
const txStatusMap: Record<string, number> = {};
|
||||
for (const row of transactionsByStatus) {
|
||||
txStatusMap[(row as any).status] = parseInt((row as any).count, 10);
|
||||
}
|
||||
|
||||
return {
|
||||
totalRequests,
|
||||
requestsByStatus: statusMap,
|
||||
totalApplicants,
|
||||
activeApplicants,
|
||||
totalDepartments,
|
||||
activeDepartments,
|
||||
totalDocuments,
|
||||
totalBlockchainTransactions,
|
||||
transactionsByStatus: txStatusMap,
|
||||
};
|
||||
}
|
||||
|
||||
async getSystemHealth(): Promise<SystemHealth> {
|
||||
this.logger.debug('Checking system health');
|
||||
|
||||
let dbStatus = 'up';
|
||||
try {
|
||||
await this.applicantModel.query().limit(1);
|
||||
} catch {
|
||||
dbStatus = 'down';
|
||||
}
|
||||
|
||||
const overallStatus =
|
||||
dbStatus === 'up' ? 'healthy' : 'unhealthy';
|
||||
|
||||
return {
|
||||
status: overallStatus,
|
||||
uptime: process.uptime(),
|
||||
timestamp: new Date().toISOString(),
|
||||
services: {
|
||||
database: { status: dbStatus },
|
||||
blockchain: { status: 'up' },
|
||||
storage: { status: 'up' },
|
||||
queue: { status: 'up' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getRecentActivity(limit: number = 20): Promise<AuditLog[]> {
|
||||
return this.auditLogModel
|
||||
.query()
|
||||
.orderBy('created_at', 'DESC')
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async getBlockchainTransactions(
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
status?: string,
|
||||
) {
|
||||
const query = this.blockchainTxModel.query().orderBy('created_at', 'DESC');
|
||||
|
||||
if (status) {
|
||||
query.where({ status });
|
||||
}
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
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 onboardDepartment(dto: any) {
|
||||
this.logger.debug(`Onboarding new department: ${dto.code}`);
|
||||
const result = await this.departmentsService.create(dto);
|
||||
return {
|
||||
department: result.department,
|
||||
apiKey: result.apiKey,
|
||||
apiSecret: result.apiSecret,
|
||||
message: 'Department onboarded successfully. Please save the API credentials as they will not be shown again.',
|
||||
};
|
||||
}
|
||||
|
||||
async getDepartments(page: number = 1, limit: number = 20) {
|
||||
return this.departmentsService.findAll({ page, limit });
|
||||
}
|
||||
|
||||
async getDepartment(id: string) {
|
||||
return this.departmentsService.findById(id);
|
||||
}
|
||||
|
||||
async updateDepartment(id: string, dto: any) {
|
||||
return this.departmentsService.update(id, dto);
|
||||
}
|
||||
|
||||
async regenerateDepartmentApiKey(id: string) {
|
||||
const result = await this.departmentsService.regenerateApiKey(id);
|
||||
return {
|
||||
...result,
|
||||
message: 'API key regenerated successfully. Please save the new credentials as they will not be shown again.',
|
||||
};
|
||||
}
|
||||
|
||||
async deactivateDepartment(id: string) {
|
||||
await this.departmentsService.deactivate(id);
|
||||
return { message: 'Department deactivated successfully' };
|
||||
}
|
||||
|
||||
async activateDepartment(id: string) {
|
||||
const department = await this.departmentsService.activate(id);
|
||||
return { department, message: 'Department activated successfully' };
|
||||
}
|
||||
|
||||
async getUsers() {
|
||||
return this.usersService.findAll();
|
||||
}
|
||||
|
||||
async getBlockchainEvents(
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
eventType?: string,
|
||||
contractAddress?: string,
|
||||
) {
|
||||
const query = this.blockchainEventModel
|
||||
.query()
|
||||
.orderBy('created_at', 'DESC');
|
||||
|
||||
if (eventType) {
|
||||
query.where({ eventType });
|
||||
}
|
||||
|
||||
if (contractAddress) {
|
||||
query.where({ contractAddress });
|
||||
}
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
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 getApplicationLogs(
|
||||
page: number = 1,
|
||||
limit: number = 50,
|
||||
level?: string,
|
||||
module?: string,
|
||||
search?: string,
|
||||
) {
|
||||
const query = this.appLogModel
|
||||
.query()
|
||||
.orderBy('created_at', 'DESC');
|
||||
|
||||
if (level) {
|
||||
query.where({ level });
|
||||
}
|
||||
|
||||
if (module) {
|
||||
query.where('module', 'like', `%${module}%`);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
query.where('message', 'like', `%${search}%`);
|
||||
}
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
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 getRequestDocuments(requestId: string) {
|
||||
this.logger.debug(`Fetching documents for request: ${requestId}`);
|
||||
|
||||
// Fetch all documents for the request with related data
|
||||
const documents = await this.documentModel
|
||||
.query()
|
||||
.where({ requestId })
|
||||
.withGraphFetched('[uploadedByUser, versions.[uploadedByUser], departmentReviews.[department]]')
|
||||
.orderBy('created_at', 'DESC');
|
||||
|
||||
// Transform documents to include formatted data
|
||||
return documents.map((doc: any) => ({
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
type: doc.type,
|
||||
size: doc.size,
|
||||
fileHash: doc.fileHash,
|
||||
ipfsHash: doc.ipfsHash,
|
||||
url: doc.url,
|
||||
thumbnailUrl: doc.thumbnailUrl,
|
||||
uploadedAt: doc.createdAt,
|
||||
uploadedBy: doc.uploadedByUser?.name || 'Unknown',
|
||||
currentVersion: doc.version || 1,
|
||||
versions: doc.versions?.map((v: any) => ({
|
||||
id: v.id,
|
||||
version: v.version,
|
||||
fileHash: v.fileHash,
|
||||
uploadedAt: v.createdAt,
|
||||
uploadedBy: v.uploadedByUser?.name || 'Unknown',
|
||||
changes: v.changes,
|
||||
})) || [],
|
||||
departmentReviews: doc.departmentReviews?.map((review: any) => ({
|
||||
departmentCode: review.department?.code || 'UNKNOWN',
|
||||
departmentName: review.department?.name || 'Unknown Department',
|
||||
reviewedAt: review.createdAt,
|
||||
reviewedBy: review.reviewedByUser?.name || 'Unknown',
|
||||
status: review.status,
|
||||
comments: review.comments,
|
||||
})) || [],
|
||||
metadata: {
|
||||
mimeType: doc.mimeType,
|
||||
width: doc.width,
|
||||
height: doc.height,
|
||||
pages: doc.pages,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user