From be1aa435825a181b3dab46b9ab6e8f603091d0df Mon Sep 17 00:00:00 2001 From: harouns-ux Date: Mon, 30 Mar 2026 10:31:48 +0100 Subject: [PATCH] feat: document stats, websocket gateway, notification entity, admin stats closes #309 closes #310 closes #311 closes #312 --- .../src/harouns-ux/admin-stats.controller.ts | 48 ++++++++++++++ .../harouns-ux/document-stats.controller.ts | 44 +++++++++++++ backend/src/harouns-ux/notification.entity.ts | 52 +++++++++++++++ backend/src/harouns-ux/processing.gateway.ts | 63 +++++++++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 backend/src/harouns-ux/admin-stats.controller.ts create mode 100644 backend/src/harouns-ux/document-stats.controller.ts create mode 100644 backend/src/harouns-ux/notification.entity.ts create mode 100644 backend/src/harouns-ux/processing.gateway.ts diff --git a/backend/src/harouns-ux/admin-stats.controller.ts b/backend/src/harouns-ux/admin-stats.controller.ts new file mode 100644 index 0000000..d522051 --- /dev/null +++ b/backend/src/harouns-ux/admin-stats.controller.ts @@ -0,0 +1,48 @@ +// [BE-52] Add admin dashboard statistics endpoint +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { Document, DocumentStatus } from '../documents/entities/document.entity'; +import { User, UserRole } from '../users/entities/user.entity'; + +@Controller('admin/stats') +@UseGuards(JwtAuthGuard) +export class AdminStatsController { + constructor( + @InjectRepository(Document) + private readonly documentRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + ) {} + + @Get() + async getDashboardStats() { + const [ + totalUsers, + totalDocuments, + pendingDocuments, + verifiedDocuments, + flaggedDocuments, + rejectedDocuments, + ] = await Promise.all([ + this.userRepo.count({ where: { role: UserRole.USER } }), + this.documentRepo.count(), + this.documentRepo.count({ where: { status: DocumentStatus.PENDING } }), + this.documentRepo.count({ where: { status: DocumentStatus.VERIFIED } }), + this.documentRepo.count({ where: { status: DocumentStatus.FLAGGED } }), + this.documentRepo.count({ where: { status: DocumentStatus.REJECTED } }), + ]); + + return { + users: { total: totalUsers }, + documents: { + total: totalDocuments, + pending: pendingDocuments, + verified: verifiedDocuments, + flagged: flaggedDocuments, + rejected: rejectedDocuments, + }, + }; + } +} diff --git a/backend/src/harouns-ux/document-stats.controller.ts b/backend/src/harouns-ux/document-stats.controller.ts new file mode 100644 index 0000000..89153ab --- /dev/null +++ b/backend/src/harouns-ux/document-stats.controller.ts @@ -0,0 +1,44 @@ +// [BE-49] Add document statistics endpoint +import { Controller, Get, Param, UseGuards } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { Document, DocumentStatus } from '../documents/entities/document.entity'; + +@Controller('documents/stats') +@UseGuards(JwtAuthGuard) +export class DocumentStatsController { + constructor( + @InjectRepository(Document) + private readonly documentRepo: Repository, + ) {} + + @Get() + async getOverallStats() { + const total = await this.documentRepo.count(); + const byStatus = await this.documentRepo + .createQueryBuilder('doc') + .select('doc.status', 'status') + .addSelect('COUNT(*)', 'count') + .groupBy('doc.status') + .getRawMany(); + + return { total, byStatus }; + } + + @Get('owner/:ownerId') + async getStatsByOwner(@Param('ownerId') ownerId: string) { + const total = await this.documentRepo.count({ where: { ownerId } }); + const verified = await this.documentRepo.count({ + where: { ownerId, status: DocumentStatus.VERIFIED }, + }); + const flagged = await this.documentRepo.count({ + where: { ownerId, status: DocumentStatus.FLAGGED }, + }); + const pending = await this.documentRepo.count({ + where: { ownerId, status: DocumentStatus.PENDING }, + }); + + return { ownerId, total, verified, flagged, pending }; + } +} diff --git a/backend/src/harouns-ux/notification.entity.ts b/backend/src/harouns-ux/notification.entity.ts new file mode 100644 index 0000000..ca959dc --- /dev/null +++ b/backend/src/harouns-ux/notification.entity.ts @@ -0,0 +1,52 @@ +// [BE-51] Add in-app notification entity and endpoints +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../users/entities/user.entity'; + +export enum NotificationType { + DOCUMENT_VERIFIED = 'document_verified', + DOCUMENT_FLAGGED = 'document_flagged', + DOCUMENT_REJECTED = 'document_rejected', + PROCESSING_COMPLETE = 'processing_complete', + SYSTEM = 'system', +} + +@Entity('notifications') +@Index('IDX_NOTIFICATION_USER_ID', ['userId']) +@Index('IDX_NOTIFICATION_IS_READ', ['isRead']) +export class Notification { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ type: 'enum', enum: NotificationType }) + type: NotificationType; + + @Column() + title: string; + + @Column({ type: 'text' }) + message: string; + + @Column({ name: 'is_read', default: false }) + isRead: boolean; + + @Column({ name: 'document_id', nullable: true }) + documentId?: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/harouns-ux/processing.gateway.ts b/backend/src/harouns-ux/processing.gateway.ts new file mode 100644 index 0000000..a97aa09 --- /dev/null +++ b/backend/src/harouns-ux/processing.gateway.ts @@ -0,0 +1,63 @@ +// [BE-50] Add WebSocket gateway for real-time document processing updates +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + MessageBody, + ConnectedSocket, + OnGatewayConnection, + OnGatewayDisconnect, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; +import { UseGuards } from '@nestjs/common'; + +export interface ProcessingUpdate { + documentId: string; + status: string; + progress?: number; + message?: string; +} + +@WebSocketGateway({ cors: { origin: '*' }, namespace: '/processing' }) +export class ProcessingGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() + server: Server; + + handleConnection(client: Socket) { + console.log(`Client connected: ${client.id}`); + } + + handleDisconnect(client: Socket) { + console.log(`Client disconnected: ${client.id}`); + } + + @SubscribeMessage('subscribe') + handleSubscribe( + @MessageBody() documentId: string, + @ConnectedSocket() client: Socket, + ) { + client.join(`document:${documentId}`); + return { event: 'subscribed', documentId }; + } + + @SubscribeMessage('unsubscribe') + handleUnsubscribe( + @MessageBody() documentId: string, + @ConnectedSocket() client: Socket, + ) { + client.leave(`document:${documentId}`); + return { event: 'unsubscribed', documentId }; + } + + emitProcessingUpdate(update: ProcessingUpdate) { + this.server + .to(`document:${update.documentId}`) + .emit('processing:update', update); + } + + emitProcessingComplete(documentId: string, status: string) { + this.server + .to(`document:${documentId}`) + .emit('processing:complete', { documentId, status }); + } +}