From 8463eebf11321476ebfa92ff6ac4c0998ad9489d Mon Sep 17 00:00:00 2001 From: bigben-7 Date: Sun, 28 Jun 2026 14:52:13 +0100 Subject: [PATCH] feat: add admin module and queue stats with job progress tracking - #795 BE-71: Admin document listing with pagination and search - #796 BE-72: Admin dashboard statistics - #797 BE-73: Queue stats endpoint (waiting, active, completed, failed) - #798 BE-74: Job progress tracking in document processor Closes #795 Closes #796 Closes #797 Closes #798 --- backend/src/admin/admin.controller.ts | 32 ++++++++++++ backend/src/admin/admin.module.ts | 21 ++++++++ backend/src/admin/admin.service.ts | 65 +++++++++++++++++++++++++ backend/src/app.module.ts | 2 + backend/src/queue/document.processor.ts | 6 +++ backend/src/queue/queue.service.ts | 11 +++++ 6 files changed, 137 insertions(+) create mode 100644 backend/src/admin/admin.controller.ts create mode 100644 backend/src/admin/admin.module.ts create mode 100644 backend/src/admin/admin.service.ts diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts new file mode 100644 index 0000000..e40f167 --- /dev/null +++ b/backend/src/admin/admin.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { AdminService } from './admin.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/guards/roles.decorator'; + +@Controller('admin') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('admin') +export class AdminController { + constructor(private readonly adminService: AdminService) {} + + @Get('stats') + async getStats() { + return this.adminService.getStats(); + } + + @Get('documents') + async getDocuments( + @Query('page') page?: number, + @Query('limit') limit?: number, + @Query('status') status?: string, + @Query('search') search?: string, + ) { + return this.adminService.getDocuments(page, limit, status, search); + } + + @Get('queue/stats') + async getQueueStats() { + return this.adminService.getQueueStats(); + } +} diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts new file mode 100644 index 0000000..51a87b8 --- /dev/null +++ b/backend/src/admin/admin.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AdminController } from './admin.controller'; +import { AdminService } from './admin.service'; +import { UsersModule } from '../users/users.module'; +import { DocumentsModule } from '../documents/documents.module'; +import { QueueModule } from '../queue/queue.module'; +import { User } from '../users/entities/user.entity'; +import { Document } from '../documents/entities/document.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([User, Document]), + UsersModule, + DocumentsModule, + QueueModule, + ], + controllers: [AdminController], + providers: [AdminService], +}) +export class AdminModule {} diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts new file mode 100644 index 0000000..917e5a8 --- /dev/null +++ b/backend/src/admin/admin.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Like } from 'typeorm'; +import { User } from '../users/entities/user.entity'; +import { Document, DocumentStatus } from '../documents/entities/document.entity'; +import { QueueService } from '../queue/queue.service'; + +@Injectable() +export class AdminService { + constructor( + @InjectRepository(User) private readonly userRepository: Repository, + @InjectRepository(Document) private readonly documentRepository: Repository, + private readonly queueService: QueueService, + ) {} + + async getStats() { + const totalUsers = await this.userRepository.count(); + const totalDocuments = await this.documentRepository.count(); + const verifiedDocuments = await this.documentRepository.count({ where: { status: DocumentStatus.VERIFIED } }); + const flaggedDocuments = await this.documentRepository.count({ where: { status: DocumentStatus.FLAGGED } }); + const rejectedDocuments = await this.documentRepository.count({ where: { status: DocumentStatus.REJECTED } }); + const verificationRate = totalDocuments > 0 + ? Math.round((verifiedDocuments / totalDocuments) * 100) + : 0; + + return { + totalUsers, + totalDocuments, + verifiedDocuments, + flaggedDocuments, + rejectedDocuments, + verificationRate, + }; + } + + async getDocuments( + page = 1, + limit = 20, + status?: string, + search?: string, + ) { + const where: any = {}; + if (status) where.status = status; + if (search) where.title = Like(`%${search}%`); + + const [data, total] = await this.documentRepository.findAndCount({ + where, + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async getQueueStats() { + return this.queueService.getQueueStats(); + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 49cbfd6..e6c36f8 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { WinstonModule } from 'nest-winston'; +import { AdminModule } from './admin/admin.module'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; @@ -48,6 +49,7 @@ import { ConfigValidationSchema } from './config/config.validation'; synchronize: true, }), }), + AdminModule, UsersModule, AuthModule, DocumentsModule, diff --git a/backend/src/queue/document.processor.ts b/backend/src/queue/document.processor.ts index 50269e3..fd1f1f4 100644 --- a/backend/src/queue/document.processor.ts +++ b/backend/src/queue/document.processor.ts @@ -26,11 +26,17 @@ export class DocumentProcessor implements OnModuleDestroy { this.queueService.queueName, async (job) => { if (job.name === 'analyze') { + await job.updateProgress(10); await this.riskService.assessDocument(job.data.documentId); + await job.updateProgress(50); + await this.documentsService.updateStatus(job.data.documentId, DocumentStatus.ANALYZING); + await job.updateProgress(100); return; } if (job.name === 'anchor') { + await job.updateProgress(10); await this.handleAnchor(job.data.documentId); + await job.updateProgress(100); } }, { connection }, diff --git a/backend/src/queue/queue.service.ts b/backend/src/queue/queue.service.ts index c1e875f..3523f04 100644 --- a/backend/src/queue/queue.service.ts +++ b/backend/src/queue/queue.service.ts @@ -40,6 +40,17 @@ export class QueueService implements OnModuleDestroy { return this.queue.add('anchor', { documentId }); } + async getQueueStats() { + const counts = await this.queue.getJobCounts(); + return { + waiting: counts.waiting || 0, + active: counts.active || 0, + completed: counts.completed || 0, + failed: counts.failed || 0, + delayed: counts.delayed || 0, + }; + } + async onModuleDestroy(): Promise { await this.queue.close(); }