From e56227e147b21fe4fa344c0f42487aa7a28a7270 Mon Sep 17 00:00:00 2001 From: xaxxoo Date: Sat, 27 Jun 2026 17:44:24 +0100 Subject: [PATCH] feat: add content reporting escalation workflow to ModerationModule (#876) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ensure reports are never left unattended in PENDING status by introducing automatic assignment and SLA-based escalation. Changes: - ContentReport entity: add assignedModerator (ManyToOne User), assignedModeratorId (indexed FK), and escalatedAt (timestamp) fields - ReportAssignmentService (src/moderation/assignment/report-assignment.service.ts): * assignReport() — round-robin assigns new reports across the active moderator pool and sends an IN_APP notification to the assignee * escalateReport() — re-assigns to a random admin, sets escalatedAt, and sends an URGENT IN_APP notification * escalateOverdueReports() — @Cron every 5 min; finds PENDING/UNDER_REVIEW reports older than MODERATION_SLA_HOURS (default 24 h) without escalatedAt and escalates them * Notification failures are swallowed so they never block assignment - ContentReportingService: calls assignReport() after a report is queued - ModerationModule: registers ReportAssignmentService, imports User entity and NotificationsModule - 11 unit tests covering round-robin distribution, escalation, overdue sweep, empty moderator/admin pools, and notification failure isolation --- .../report-assignment.service.spec.ts | 216 ++++++++++++++++++ .../assignment/report-assignment.service.ts | 200 ++++++++++++++++ src/moderation/moderation.module.ts | 10 +- .../reports/content-report.entity.ts | 12 + .../reports/content-reporting.service.ts | 7 +- 5 files changed, 443 insertions(+), 2 deletions(-) create mode 100644 src/moderation/assignment/report-assignment.service.spec.ts create mode 100644 src/moderation/assignment/report-assignment.service.ts diff --git a/src/moderation/assignment/report-assignment.service.spec.ts b/src/moderation/assignment/report-assignment.service.spec.ts new file mode 100644 index 00000000..cdd2f7d1 --- /dev/null +++ b/src/moderation/assignment/report-assignment.service.spec.ts @@ -0,0 +1,216 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; +import { User, UserRole } from '../../users/entities/user.entity'; +import { ContentReport } from '../reports/content-report.entity'; +import { ContentReportStatus } from '../reports/content-report-status.enum'; +import { ContentReportReason } from '../reports/content-report-reason.enum'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { ReportAssignmentService } from './report-assignment.service'; + +// ─── Mock factories ──────────────────────────────────────────────────────────── + +function makeUser(id: string, role: UserRole = UserRole.MODERATOR): User { + return { id, roles: [{ name: role }] } as unknown as User; +} + +function makeReport(id: string = 'r-1'): ContentReport { + return { + id, + reason: ContentReportReason.SPAM, + contentType: 'course', + contentId: 'c-1', + status: ContentReportStatus.PENDING, + assignedModeratorId: undefined, + escalatedAt: undefined, + createdAt: new Date(), + } as unknown as ContentReport; +} + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +const mockUserRepo = { + createQueryBuilder: jest.fn(), +}; + +const mockReportRepo = { + save: jest.fn((r: ContentReport) => Promise.resolve(r)), + find: jest.fn().mockResolvedValue([]), +}; + +const mockNotificationsService: jest.Mocked> = { + send: jest.fn().mockResolvedValue({}), +}; + +const mockConfigService = { + get: jest.fn((key: string, fallback?: unknown) => fallback), +}; + +// ─── QueryBuilder helper ────────────────────────────────────────────────────── + +function buildQb(users: User[]) { + const qb: Record = { + innerJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(users), + }; + qb.innerJoin = jest.fn().mockReturnValue(qb); + qb.where = jest.fn().mockReturnValue(qb); + qb.orderBy = jest.fn().mockReturnValue(qb); + return qb; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('ReportAssignmentService', () => { + let service: ReportAssignmentService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ReportAssignmentService, + { provide: getRepositoryToken(User), useValue: mockUserRepo }, + { provide: getRepositoryToken(ContentReport), useValue: mockReportRepo }, + { provide: NotificationsService, useValue: mockNotificationsService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + service = module.get(ReportAssignmentService); + }); + + afterEach(() => jest.clearAllMocks()); + + // ─── assignReport ──────────────────────────────────────────────────────── + + describe('assignReport', () => { + it('assigns to the first available moderator', async () => { + const moderator = makeUser('mod-1'); + mockUserRepo.createQueryBuilder.mockReturnValue(buildQb([moderator])); + + const report = makeReport(); + const result = await service.assignReport(report); + + expect(result.assignedModeratorId).toBe('mod-1'); + expect(result.status).toBe(ContentReportStatus.UNDER_REVIEW); + }); + + it('sends an assignment notification to the moderator', async () => { + const moderator = makeUser('mod-1'); + mockUserRepo.createQueryBuilder.mockReturnValue(buildQb([moderator])); + + await service.assignReport(makeReport()); + + expect(mockNotificationsService.send).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'mod-1' }), + ); + }); + + it('distributes reports round-robin across moderators', async () => { + const mod1 = makeUser('mod-1'); + const mod2 = makeUser('mod-2'); + mockUserRepo.createQueryBuilder + .mockReturnValueOnce(buildQb([mod1, mod2])) + .mockReturnValueOnce(buildQb([mod1, mod2])); + + const r1 = await service.assignReport(makeReport('r-1')); + const r2 = await service.assignReport(makeReport('r-2')); + + expect(r1.assignedModeratorId).toBe('mod-1'); + expect(r2.assignedModeratorId).toBe('mod-2'); + }); + + it('does not throw and leaves report unassigned when no moderators exist', async () => { + mockUserRepo.createQueryBuilder.mockReturnValue(buildQb([])); + + const report = makeReport(); + const result = await service.assignReport(report); + + expect(result.assignedModeratorId).toBeUndefined(); + expect(mockReportRepo.save).not.toHaveBeenCalled(); + }); + }); + + // ─── escalateReport ────────────────────────────────────────────────────── + + describe('escalateReport', () => { + it('reassigns report to an admin and sets escalatedAt', async () => { + const admin = makeUser('admin-1', UserRole.ADMIN); + mockUserRepo.createQueryBuilder.mockReturnValue(buildQb([admin])); + + const report = makeReport(); + const result = await service.escalateReport(report); + + expect(result.assignedModeratorId).toBe('admin-1'); + expect(result.escalatedAt).toBeInstanceOf(Date); + }); + + it('sends an URGENT escalation notification to the admin', async () => { + const admin = makeUser('admin-1', UserRole.ADMIN); + mockUserRepo.createQueryBuilder.mockReturnValue(buildQb([admin])); + + await service.escalateReport(makeReport()); + + expect(mockNotificationsService.send).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'admin-1', priority: 'urgent' }), + ); + }); + + it('does not throw when no admins exist', async () => { + mockUserRepo.createQueryBuilder.mockReturnValue(buildQb([])); + + const report = makeReport(); + const result = await service.escalateReport(report); + + expect(result.escalatedAt).toBeUndefined(); + expect(mockReportRepo.save).not.toHaveBeenCalled(); + }); + }); + + // ─── escalateOverdueReports ────────────────────────────────────────────── + + describe('escalateOverdueReports', () => { + it('escalates overdue reports found by the repository', async () => { + const overdueReport = makeReport('r-overdue'); + mockReportRepo.find.mockResolvedValueOnce([overdueReport]); + + const admin = makeUser('admin-1', UserRole.ADMIN); + mockUserRepo.createQueryBuilder.mockReturnValue(buildQb([admin])); + + await service.escalateOverdueReports(); + + expect(mockReportRepo.save).toHaveBeenCalled(); + expect(mockNotificationsService.send).toHaveBeenCalled(); + }); + + it('does nothing when there are no overdue reports', async () => { + mockReportRepo.find.mockResolvedValueOnce([]); + + await service.escalateOverdueReports(); + + expect(mockReportRepo.save).not.toHaveBeenCalled(); + expect(mockNotificationsService.send).not.toHaveBeenCalled(); + }); + }); + + // ─── notification failure isolation ───────────────────────────────────── + + describe('notification failure isolation', () => { + it('does not throw when notification send fails during assignment', async () => { + const moderator = makeUser('mod-1'); + mockUserRepo.createQueryBuilder.mockReturnValue(buildQb([moderator])); + mockNotificationsService.send.mockRejectedValueOnce(new Error('SMTP down')); + + await expect(service.assignReport(makeReport())).resolves.not.toThrow(); + }); + + it('does not throw when notification send fails during escalation', async () => { + const admin = makeUser('admin-1', UserRole.ADMIN); + mockUserRepo.createQueryBuilder.mockReturnValue(buildQb([admin])); + mockNotificationsService.send.mockRejectedValueOnce(new Error('SMTP down')); + + await expect(service.escalateReport(makeReport())).resolves.not.toThrow(); + }); + }); +}); diff --git a/src/moderation/assignment/report-assignment.service.ts b/src/moderation/assignment/report-assignment.service.ts new file mode 100644 index 00000000..0265457c --- /dev/null +++ b/src/moderation/assignment/report-assignment.service.ts @@ -0,0 +1,200 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { LessThan, Repository } from 'typeorm'; +import { User, UserRole } from '../../users/entities/user.entity'; +import { ContentReport } from '../reports/content-report.entity'; +import { ContentReportStatus } from '../reports/content-report-status.enum'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { + NotificationPriority, + NotificationType, +} from '../../notifications/entities/notification.entity'; + +/** + * Manages report assignment and escalation for the moderation queue. + * + * Assignment strategy: round-robin over the active moderator pool. + * Escalation: any report still in PENDING or UNDER_REVIEW status after + * `MODERATION_SLA_HOURS` (default 24 h) is re-assigned to a random admin. + */ +@Injectable() +export class ReportAssignmentService { + private readonly logger = new Logger(ReportAssignmentService.name); + + /** Default SLA in hours before a report is escalated. */ + static readonly DEFAULT_SLA_HOURS = 24; + + /** Round-robin cursor — index into the sorted moderator pool. */ + private rrCursor = 0; + + constructor( + @InjectRepository(User) + private readonly userRepo: Repository, + @InjectRepository(ContentReport) + private readonly reportRepo: Repository, + private readonly notificationsService: NotificationsService, + private readonly configService: ConfigService, + ) {} + + /** + * Assigns `report` to the next available moderator using round-robin. + * + * Sends an IN_APP notification to the assigned moderator. + * No-ops silently when no moderators exist (report remains unassigned). + */ + async assignReport(report: ContentReport): Promise { + const moderators = await this.getActiveModerators(); + + if (moderators.length === 0) { + this.logger.warn(`No active moderators found; report ${report.id} left unassigned`); + return report; + } + + const moderator = moderators[this.rrCursor % moderators.length]; + this.rrCursor = (this.rrCursor + 1) % moderators.length; + + report.assignedModeratorId = moderator.id; + report.status = ContentReportStatus.UNDER_REVIEW; + const saved = await this.reportRepo.save(report); + + this.logger.log(`Report ${report.id} assigned to moderator ${moderator.id}`); + + await this.sendAssignmentNotification(moderator, saved); + + return saved; + } + + /** + * Escalates `report` to a random admin and marks `escalatedAt`. + * + * Sends a HIGH-priority IN_APP notification to the escalation recipient. + * No-ops when no admins exist. + */ + async escalateReport(report: ContentReport): Promise { + const admins = await this.getActiveAdmins(); + + if (admins.length === 0) { + this.logger.warn(`No active admins found; report ${report.id} cannot be escalated`); + return report; + } + + const admin = admins[Math.floor(Math.random() * admins.length)]; + + report.assignedModeratorId = admin.id; + report.escalatedAt = new Date(); + const saved = await this.reportRepo.save(report); + + this.logger.warn(`Report ${report.id} escalated to admin ${admin.id}`); + + await this.sendEscalationNotification(admin, saved); + + return saved; + } + + /** + * Scheduled task — runs every 5 minutes. + * + * Finds all PENDING/UNDER_REVIEW reports whose `createdAt` exceeds the + * configured SLA and have not yet been escalated, then escalates them. + */ + @Cron(CronExpression.EVERY_5_MINUTES) + async escalateOverdueReports(): Promise { + const slaHours = this.configService.get( + 'MODERATION_SLA_HOURS', + ReportAssignmentService.DEFAULT_SLA_HOURS, + ); + + const slaThreshold = new Date(); + slaThreshold.setHours(slaThreshold.getHours() - slaHours); + + const overdueReports = await this.reportRepo.find({ + where: [ + { + status: ContentReportStatus.PENDING, + escalatedAt: undefined, + createdAt: LessThan(slaThreshold), + }, + { + status: ContentReportStatus.UNDER_REVIEW, + escalatedAt: undefined, + createdAt: LessThan(slaThreshold), + }, + ], + }); + + if (overdueReports.length === 0) { + return; + } + + this.logger.warn( + `Escalating ${overdueReports.length} overdue report(s) (SLA: ${slaHours}h)`, + ); + + for (const report of overdueReports) { + await this.escalateReport(report); + } + } + + // ─── Private helpers ─────────────────────────────────────────────────────── + + private async getActiveModerators(): Promise { + return this.userRepo + .createQueryBuilder('user') + .innerJoin('user.roles', 'role') + .where('role.name = :role', { role: UserRole.MODERATOR }) + .orderBy('user.id', 'ASC') + .getMany(); + } + + private async getActiveAdmins(): Promise { + return this.userRepo + .createQueryBuilder('user') + .innerJoin('user.roles', 'role') + .where('role.name = :role', { role: UserRole.ADMIN }) + .orderBy('user.id', 'ASC') + .getMany(); + } + + private async sendAssignmentNotification( + moderator: User, + report: ContentReport, + ): Promise { + try { + await this.notificationsService.send({ + userId: moderator.id, + title: 'New content report assigned', + content: `You have been assigned a new ${report.reason} report for ${report.contentType} content. Please review it promptly.`, + type: NotificationType.IN_APP, + priority: NotificationPriority.HIGH, + metadata: { reportId: report.id, contentType: report.contentType, reason: report.reason }, + }); + } catch (err) { + this.logger.error( + `Failed to send assignment notification to moderator ${moderator.id}`, + err, + ); + } + } + + private async sendEscalationNotification(admin: User, report: ContentReport): Promise { + try { + await this.notificationsService.send({ + userId: admin.id, + title: 'Escalated content report requires urgent review', + content: `A ${report.reason} report (ID: ${report.id}) has exceeded the SLA and has been escalated to you for immediate review.`, + type: NotificationType.IN_APP, + priority: NotificationPriority.URGENT, + metadata: { + reportId: report.id, + contentType: report.contentType, + reason: report.reason, + escalatedAt: report.escalatedAt?.toISOString(), + }, + }); + } catch (err) { + this.logger.error(`Failed to send escalation notification to admin ${admin.id}`, err); + } + } +} diff --git a/src/moderation/moderation.module.ts b/src/moderation/moderation.module.ts index 01dc322c..f461d3e0 100644 --- a/src/moderation/moderation.module.ts +++ b/src/moderation/moderation.module.ts @@ -9,12 +9,18 @@ import { ModerationEvent } from './analytics/moderation-event.entity'; import { ContentReport } from './reports/content-report.entity'; import { ContentReportingService } from './reports/content-reporting.service'; import { ContentReportsController } from './reports/content-reports.controller'; +import { ReportAssignmentService } from './assignment/report-assignment.service'; +import { User } from '../users/entities/user.entity'; +import { NotificationsModule } from '../notifications/notifications.module'; /** * Registers the moderation module, exposing content safety and review services. */ @Module({ - imports: [TypeOrmModule.forFeature([ReviewItem, ModerationEvent, ContentReport])], + imports: [ + TypeOrmModule.forFeature([ReviewItem, ModerationEvent, ContentReport, User]), + NotificationsModule, + ], controllers: [ContentReportsController], providers: [ ContentSafetyService, @@ -22,6 +28,7 @@ import { ContentReportsController } from './reports/content-reports.controller'; ManualReviewService, ModerationAnalyticsService, ContentReportingService, + ReportAssignmentService, ], exports: [ ContentSafetyService, @@ -29,6 +36,7 @@ import { ContentReportsController } from './reports/content-reports.controller'; ManualReviewService, ModerationAnalyticsService, ContentReportingService, + ReportAssignmentService, ], }) export class ModerationModule {} diff --git a/src/moderation/reports/content-report.entity.ts b/src/moderation/reports/content-report.entity.ts index a74672b5..02a4b31b 100644 --- a/src/moderation/reports/content-report.entity.ts +++ b/src/moderation/reports/content-report.entity.ts @@ -53,6 +53,18 @@ export class ContentReport { @Column({ name: 'reviewer_id', nullable: true }) reviewerId?: string; + /** The moderator currently assigned to handle this report (round-robin assigned). */ + @ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' }) + assignedModerator?: User; + + @Column({ name: 'assigned_moderator_id', nullable: true }) + @Index() + assignedModeratorId?: string; + + /** Set when the report is escalated to a senior moderator after SLA breach. */ + @Column({ type: 'timestamp', nullable: true }) + escalatedAt?: Date; + @Column({ type: 'enum', enum: ContentReportStatus, diff --git a/src/moderation/reports/content-reporting.service.ts b/src/moderation/reports/content-reporting.service.ts index b3646959..726feb2d 100644 --- a/src/moderation/reports/content-reporting.service.ts +++ b/src/moderation/reports/content-reporting.service.ts @@ -9,6 +9,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { FindOptionsWhere, Repository } from 'typeorm'; import { User } from '../../users/entities/user.entity'; import { ManualReviewService } from '../manual/manual-review.service'; +import { ReportAssignmentService } from '../assignment/report-assignment.service'; import { ContentReportReason } from './content-report-reason.enum'; import { ContentReportStatus } from './content-report-status.enum'; import { ContentReport } from './content-report.entity'; @@ -30,6 +31,7 @@ export class ContentReportingService { @InjectRepository(ContentReport) private readonly reportRepo: Repository, private readonly manualReviewService: ManualReviewService, + private readonly assignmentService: ReportAssignmentService, ) {} async reportContent(dto: CreateContentReportDto, reporter: User): Promise { @@ -61,7 +63,10 @@ export class ContentReportingService { `Content report ${linkedReport.id} queued for ${linkedReport.contentType}:${linkedReport.contentId} by ${reporter.id}`, ); - return linkedReport; + // Assign the new report to a moderator via round-robin (best-effort). + const assignedReport = await this.assignmentService.assignReport(linkedReport); + + return assignedReport; } async listReports(