Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/caching/cache-management.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../../auth/guards/roles.guard';
import { Roles, UserRole } from '../../users/entities/user.entity';
import {
CacheAnalyticsService,
CacheAnalyticsReport,
Expand All @@ -13,6 +16,8 @@ import {

@ApiTags('Cache Management')
@Controller('cache')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
export class CacheManagementController {
constructor(
private readonly analyticsService: CacheAnalyticsService,
Expand Down
88 changes: 77 additions & 11 deletions src/moderation/assignment/report-assignment.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ 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';
import { AdminSelectionStrategy, ReportAssignmentService } from './report-assignment.service';

// ─── Mock factories ────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -34,6 +34,7 @@ const mockUserRepo = {
};

const mockReportRepo = {
createQueryBuilder: jest.fn(),
save: jest.fn((r: ContentReport) => Promise.resolve(r)),
find: jest.fn().mockResolvedValue([]),
};
Expand All @@ -46,7 +47,7 @@ const mockConfigService = {
get: jest.fn((key: string, fallback?: unknown) => fallback),
};

// ─── QueryBuilder helper ──────────────────────────────────────────────────────
// ─── QueryBuilder helpers ──────────────────────────────────────────────────────

function buildQb(users: User[]) {
const qb: Record<string, jest.Mock> = {
Expand All @@ -61,12 +62,36 @@ function buildQb(users: User[]) {
return qb;
}

function buildReportQb(loadRows: Record<string, number>[]) {
const qb: Record<string, jest.Mock> = {
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue(loadRows),
};
qb.select = jest.fn().mockReturnValue(qb);
qb.addSelect = jest.fn().mockReturnValue(qb);
qb.where = jest.fn().mockReturnValue(qb);
qb.andWhere = jest.fn().mockReturnValue(qb);
qb.groupBy = jest.fn().mockReturnValue(qb);
return qb;
}

// ─── Tests ────────────────────────────────────────────────────────────────────

describe('ReportAssignmentService', () => {
let service: ReportAssignmentService;

beforeEach(async () => {
mockUserRepo.createQueryBuilder.mockClear();
mockReportRepo.createQueryBuilder.mockClear();
mockReportRepo.save.mockClear();
mockReportRepo.find.mockClear();
mockNotificationsService.send.mockClear();
mockConfigService.get.mockClear();

const module: TestingModule = await Test.createTestingModule({
providers: [
ReportAssignmentService,
Expand Down Expand Up @@ -134,26 +159,35 @@ describe('ReportAssignmentService', () => {

// ─── 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]));
describe('escalateReport with least_loaded strategy', () => {
it('reassigns report to the least-loaded admin and sets escalatedAt', async () => {
const admin2 = makeUser('admin-2', UserRole.ADMIN);
const admin1 = makeUser('admin-1', UserRole.ADMIN);
const admin3 = makeUser('admin-3', UserRole.ADMIN);
mockUserRepo.createQueryBuilder.mockReturnValue(buildQb([admin1, admin2, admin3]));
mockReportRepo.createQueryBuilder.mockReturnValue(
buildReportQb([{ moderatorId: 'admin-1', count: '5' }, { moderatorId: 'admin-2', count: '1' }, { moderatorId: 'admin-3', count: '3' }]),
);

const report = makeReport();
const result = await service.escalateReport(report);

expect(result.assignedModeratorId).toBe('admin-1');
expect(result.assignedModeratorId).toBe('admin-2');
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]));
it('sends an URGENT escalation notification to the selected admin', async () => {
const admin2 = makeUser('admin-2', UserRole.ADMIN);
const admin1 = makeUser('admin-1', UserRole.ADMIN);
mockUserRepo.createQueryBuilder.mockReturnValue(buildQb([admin1, admin2]));
mockReportRepo.createQueryBuilder.mockReturnValue(
buildReportQb([{ moderatorId: 'admin-1', count: '5' }, { moderatorId: 'admin-2', count: '1' }]),
);

await service.escalateReport(makeReport());

expect(mockNotificationsService.send).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'admin-1', priority: 'urgent' }),
expect.objectContaining({ userId: 'admin-2', priority: 'urgent' }),
);
});

Expand All @@ -165,6 +199,32 @@ describe('ReportAssignmentService', () => {

expect(result.escalatedAt).toBeUndefined();
expect(mockReportRepo.save).not.toHaveBeenCalled();
expect(mockReportRepo.createQueryBuilder).not.toHaveBeenCalled();
});
});

// ─── selectEscalationTarget ───────────────────────────────────────────────

describe('selectEscalationTarget', () => {
it('selects the admin with the lowest open report count', () => {
const admins = [makeUser('admin-1'), makeUser('admin-2'), makeUser('admin-3')];
const loadMap = { 'admin-1': 5, 'admin-2': 1, 'admin-3': 3 };

expect(ReportAssignmentService.selectEscalationTarget(admins, loadMap).id).toBe('admin-2');
});

it('breaks loads ties by lowest admin id', () => {
const admins = [makeUser('admin-1'), makeUser('admin-2'), makeUser('admin-3')];
const loadMap = { 'admin-1': 1, 'admin-2': 1, 'admin-3': 2 };

expect(ReportAssignmentService.selectEscalationTarget(admins, loadMap).id).toBe('admin-1');
});

it('treats missing admins in loadMap as zero load', () => {
const admins = [makeUser('admin-1'), makeUser('admin-2'), makeUser('admin-3')];
const loadMap = { 'admin-2': 2 };

expect(ReportAssignmentService.selectEscalationTarget(admins, loadMap).id).toBe('admin-1');
});
});

Expand All @@ -177,6 +237,9 @@ describe('ReportAssignmentService', () => {

const admin = makeUser('admin-1', UserRole.ADMIN);
mockUserRepo.createQueryBuilder.mockReturnValue(buildQb([admin]));
mockReportRepo.createQueryBuilder.mockReturnValue(
buildReportQb([{ moderatorId: 'admin-1', count: '3' }]),
);

await service.escalateOverdueReports();

Expand Down Expand Up @@ -208,6 +271,9 @@ describe('ReportAssignmentService', () => {
it('does not throw when notification send fails during escalation', async () => {
const admin = makeUser('admin-1', UserRole.ADMIN);
mockUserRepo.createQueryBuilder.mockReturnValue(buildQb([admin]));
mockReportRepo.createQueryBuilder.mockReturnValue(
buildReportQb([{ moderatorId: 'admin-1', count: '3' }]),
);
mockNotificationsService.send.mockRejectedValueOnce(new Error('SMTP down'));

await expect(service.escalateReport(makeReport())).resolves.not.toThrow();
Expand Down
81 changes: 74 additions & 7 deletions src/moderation/assignment/report-assignment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,18 @@ import {
NotificationType,
} from '../../notifications/entities/notification.entity';

export enum AdminSelectionStrategy {
LEAST_LOADED = 'least_loaded',
ROUND_ROBIN = 'round_robin',
}

/**
* 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.
* `MODERATION_SLA_HOURS` (default 24 h) is re-assigned to an admin using
* the configured `ADMIN_SELECTION_STRATEGY` (default `least_loaded`).
*/
@Injectable()
export class ReportAssignmentService {
Expand All @@ -29,14 +35,29 @@ export class ReportAssignmentService {
/** Round-robin cursor — index into the sorted moderator pool. */
private rrCursor = 0;

/** Round-robin cursor for admin escalation when ROUND_ROBIN strategy is active. */
private adminRrCursor = 0;

/** Configurable strategy for selecting the escalation target admin. */
private readonly adminSelectionStrategy: AdminSelectionStrategy;

constructor(
@InjectRepository(User)
private readonly userRepo: Repository<User>,
@InjectRepository(ContentReport)
private readonly reportRepo: Repository<ContentReport>,
private readonly notificationsService: NotificationsService,
private readonly configService: ConfigService,
) {}
) {
const strategy = this.configService.get<string>(
'ADMIN_SELECTION_STRATEGY',
AdminSelectionStrategy.LEAST_LOADED,
);
this.adminSelectionStrategy =
strategy in AdminSelectionStrategy
? (strategy as AdminSelectionStrategy)
: AdminSelectionStrategy.LEAST_LOADED;
}

/**
* Assigns `report` to the next available moderator using round-robin.
Expand Down Expand Up @@ -67,7 +88,8 @@ export class ReportAssignmentService {
}

/**
* Escalates `report` to a random admin and marks `escalatedAt`.
* Escalates `report` to an admin using the configured selection strategy
* (default: least-loaded by open report count) and marks `escalatedAt`.
*
* Sends a HIGH-priority IN_APP notification to the escalation recipient.
* No-ops when no admins exist.
Expand All @@ -80,15 +102,39 @@ export class ReportAssignmentService {
return report;
}

const admin = admins[Math.floor(Math.random() * admins.length)];
let selectedAdmin: User;

if (this.adminSelectionStrategy === AdminSelectionStrategy.LEAST_LOADED) {
const adminIds = admins.map(a => a.id);
const loadRows = await this.reportRepo
.createQueryBuilder('report')
.select('report.assignedModeratorId', 'moderatorId')
.addSelect('COUNT(report.id)', 'count')
.where('report.assignedModeratorId IN (:...ids)', { ids: adminIds })
.andWhere('report.status IN (:...openStatuses)', {
openStatuses: [ContentReportStatus.PENDING, ContentReportStatus.UNDER_REVIEW],
})
.groupBy('report.assignedModeratorId')
.getRawMany();

const loadMap: Record<string, number> = {};
for (const row of loadRows) {
loadMap[row.moderatorId] = parseInt(row.count, 10);
}

selectedAdmin = ReportAssignmentService.selectEscalationTarget(admins, loadMap);
} else {
selectedAdmin = admins[this.adminRrCursor % admins.length];
this.adminRrCursor = (this.adminRrCursor + 1) % admins.length;
}

report.assignedModeratorId = admin.id;
report.assignedModeratorId = selectedAdmin.id;
report.escalatedAt = new Date();
const saved = await this.reportRepo.save(report);

this.logger.warn(`Report ${report.id} escalated to admin ${admin.id}`);
this.logger.warn(`Report ${report.id} escalated to admin ${selectedAdmin.id}`);

await this.sendEscalationNotification(admin, saved);
await this.sendEscalationNotification(selectedAdmin, saved);

return saved;
}
Expand Down Expand Up @@ -155,6 +201,27 @@ export class ReportAssignmentService {
.getMany();
}

/**
* Pure helper that selects the least-loaded admin from a candidate list.
*
* @param admins - Active admins ordered by `id` ascending (lowest id first).
* @param loadMap - Map of admin ID to the number of open/assigned reports.
* Admins not present in the map are treated as having zero load.
* @returns The admin with the fewest open reports; ties are broken by
* lowest admin ID.
*/
static selectEscalationTarget(admins: readonly User[], loadMap: Record<string, number>): User {
if (admins.length === 0) {
throw new Error('Cannot select escalation target from empty admin list');
}

return admins.reduce((best, admin) => {
const bestLoad = loadMap[best.id] ?? 0;
const adminLoad = loadMap[admin.id] ?? 0;
return adminLoad < bestLoad ? admin : best;
});
}

private async sendAssignmentNotification(moderator: User, report: ContentReport): Promise<void> {
try {
await this.notificationsService.send({
Expand Down
7 changes: 6 additions & 1 deletion src/payments/reporting/reporting.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { Controller, Get, Query, BadRequestException } from '@nestjs/common';
import { Controller, Get, Query, BadRequestException, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../../../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../../../auth/guards/roles.guard';
import { Roles, UserRole } from '../../../users/entities/user.entity';
import { ReportingService } from './reporting.service';

@Controller('reports')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
export class ReportingController {
constructor(private readonly reportingService: ReportingService) {}

Expand Down
Loading