diff --git a/README.md b/README.md index db230d51..92e92584 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ TeachLink Backend provides secure and scalable APIs to power features such as: - ๐ŸŽ–๏ธ Gamified reputation and contribution tracking - ๐Ÿ”” Real-time notifications via WebSockets - ๐Ÿ“Š Analytics and activity insights +- ๐Ÿงญ User reporting and moderation queue processing for inappropriate content - ๐Ÿงพ DAO integration for content moderation and governance ## ๐Ÿ”€ API Versioning diff --git a/src/app.module.ts b/src/app.module.ts index c9fb3f99..86fe5ffe 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -29,7 +29,7 @@ import { PaymentMethodsModule } from './payments/payment-methods/payment-methods import { ReportingModule } from './payments/reporting/reporting.module'; import { NotificationsModule } from './notifications/notifications.module'; import { HealthModule } from './health/health.module'; -import { AuditLogModule } from './audit-log/audit-log.module'; +import { ModerationModule } from './moderation/moderation.module'; // โœ… keep BOTH modules import { ReadReplicaModule } from './database/read-replica'; @@ -69,7 +69,7 @@ const featureFlags = loadFeatureFlags(); NotificationsModule, ReportingModule, HealthModule, - AuditLogModule, + ...(featureFlags.ENABLE_MODERATION ? [ModerationModule] : []), // โœ… always include read replicas (or wrap if needed) ReadReplicaModule, diff --git a/src/main.ts b/src/main.ts index 9744085a..77b7d652 100644 --- a/src/main.ts +++ b/src/main.ts @@ -278,13 +278,14 @@ async function bootstrapWorker(): Promise { .setDescription('The TeachLink API documentation - Unified System.') .setVersion('1.0') .addBearerAuth() - .addTag('gamification') - .addTag('Email Marketing - Campaigns') - .addTag('Email Marketing - Templates') - .addTag('Email Marketing - Automation') - .addTag('Email Marketing - Segments') - .addTag('Email Marketing - A/B Testing') - .addTag('Email Marketing - Analytics') + .addTag('gamification', 'Gamification and user rewards') + .addTag('Email Marketing - Campaigns', 'Create and manage email campaigns') + .addTag('Email Marketing - Templates', 'Email template management') + .addTag('Email Marketing - Automation', 'Automation workflows') + .addTag('Email Marketing - Segments', 'Audience segmentation') + .addTag('Email Marketing - A/B Testing', 'A/B testing for campaigns') + .addTag('Email Marketing - Analytics', 'Campaign analytics and reporting') + .addTag('moderation', 'User reports and moderation queue') .addTag('App') .addTag('Quota') .addTag('Quota Management') diff --git a/src/moderation/manual/manual-review.service.spec.ts b/src/moderation/manual/manual-review.service.spec.ts index c8207bae..c1e983ab 100644 --- a/src/moderation/manual/manual-review.service.spec.ts +++ b/src/moderation/manual/manual-review.service.spec.ts @@ -36,7 +36,7 @@ describe('ManualReviewService', () => { mockRepo.create.mockReturnValue(item); mockRepo.save.mockResolvedValue({ id: 1, ...item }); - await service.enqueue('bad content', 0.9); + const result = await service.enqueue('bad content', 0.9); expect(mockRepo.create).toHaveBeenCalledWith({ content: 'bad content', @@ -44,6 +44,35 @@ describe('ManualReviewService', () => { status: 'pending', }); expect(mockRepo.save).toHaveBeenCalledWith(item); + expect(result).toEqual({ id: 1, ...item }); + }); + + it('should persist source metadata when provided', async () => { + const item = { + content: 'reported content', + safetyScore: 1, + status: 'pending', + sourceType: 'content-report', + sourceId: 'report-1', + reportId: 'report-1', + }; + mockRepo.create.mockReturnValue(item); + mockRepo.save.mockResolvedValue(item); + + await service.enqueue('reported content', 1, { + sourceType: 'content-report', + sourceId: 'report-1', + reportId: 'report-1', + }); + + expect(mockRepo.create).toHaveBeenCalledWith({ + content: 'reported content', + safetyScore: 1, + status: 'pending', + sourceType: 'content-report', + sourceId: 'report-1', + reportId: 'report-1', + }); }); }); diff --git a/src/moderation/manual/manual-review.service.ts b/src/moderation/manual/manual-review.service.ts index 8c0c3324..fb5bd522 100644 --- a/src/moderation/manual/manual-review.service.ts +++ b/src/moderation/manual/manual-review.service.ts @@ -17,11 +17,25 @@ export class ManualReviewService { * Executes enqueue. * @param content The content. * @param safetyScore The safety score. + * @param source Optional metadata linking the queue item back to the source report or content. * @returns The operation result. */ - async enqueue(content: string, safetyScore: number) { - const item = this.reviewRepo.create({ content, safetyScore, status: 'pending' }); - await this.reviewRepo.save(item); + async enqueue( + content: string, + safetyScore: number, + source?: { + sourceType?: string; + sourceId?: string; + reportId?: string; + }, + ): Promise { + const item = this.reviewRepo.create({ + content, + safetyScore, + status: 'pending', + ...source, + }); + return this.reviewRepo.save(item); } /** diff --git a/src/moderation/manual/review-item.entity.ts b/src/moderation/manual/review-item.entity.ts index dd67be0a..04736a60 100644 --- a/src/moderation/manual/review-item.entity.ts +++ b/src/moderation/manual/review-item.entity.ts @@ -17,6 +17,15 @@ export class ReviewItem { @Column('float') safetyScore: number; + @Column({ nullable: true }) + sourceType?: string; + + @Column({ nullable: true }) + sourceId?: string; + + @Column({ nullable: true }) + reportId?: string; + @Column({ default: 'pending' }) status: 'pending' | 'reviewed'; diff --git a/src/moderation/moderation.module.ts b/src/moderation/moderation.module.ts index 56d91349..01dc322c 100644 --- a/src/moderation/moderation.module.ts +++ b/src/moderation/moderation.module.ts @@ -6,23 +6,29 @@ import { ManualReviewService } from './manual/manual-review.service'; import { ReviewItem } from './manual/review-item.entity'; import { ModerationAnalyticsService } from './analytics/moderation-analytics.service'; 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'; /** * Registers the moderation module, exposing content safety and review services. */ @Module({ - imports: [TypeOrmModule.forFeature([ReviewItem, ModerationEvent])], + imports: [TypeOrmModule.forFeature([ReviewItem, ModerationEvent, ContentReport])], + controllers: [ContentReportsController], providers: [ ContentSafetyService, AutoModerationService, ManualReviewService, ModerationAnalyticsService, + ContentReportingService, ], exports: [ ContentSafetyService, AutoModerationService, ManualReviewService, ModerationAnalyticsService, + ContentReportingService, ], }) export class ModerationModule {} diff --git a/src/moderation/reports/content-report-reason.enum.ts b/src/moderation/reports/content-report-reason.enum.ts new file mode 100644 index 00000000..f4ff144e --- /dev/null +++ b/src/moderation/reports/content-report-reason.enum.ts @@ -0,0 +1,6 @@ +export enum ContentReportReason { + SPAM = 'spam', + ABUSE = 'abuse', + INAPPROPRIATE = 'inappropriate', +} + diff --git a/src/moderation/reports/content-report-status.enum.ts b/src/moderation/reports/content-report-status.enum.ts new file mode 100644 index 00000000..825265a7 --- /dev/null +++ b/src/moderation/reports/content-report-status.enum.ts @@ -0,0 +1,7 @@ +export enum ContentReportStatus { + PENDING = 'pending', + UNDER_REVIEW = 'under_review', + RESOLVED = 'resolved', + DISMISSED = 'dismissed', +} + diff --git a/src/moderation/reports/content-report.entity.ts b/src/moderation/reports/content-report.entity.ts new file mode 100644 index 00000000..cd387ced --- /dev/null +++ b/src/moderation/reports/content-report.entity.ts @@ -0,0 +1,80 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + VersionColumn, + ManyToOne, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { ContentReportReason } from './content-report-reason.enum'; +import { ContentReportStatus } from './content-report-status.enum'; + +/** + * Tracks each user report for content and how moderation resolves it. + */ +@Entity('content_reports') +export class ContentReport { + @PrimaryGeneratedColumn('uuid') + id: string; + + @VersionColumn() + version: number; + + @Column() + @Index() + contentType: string; + + @Column() + @Index() + contentId: string; + + @Column({ + type: 'enum', + enum: ContentReportReason, + }) + reason: ContentReportReason; + + @Column({ type: 'text', nullable: true }) + details?: string; + + @ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' }) + reporter?: User; + + @Column({ name: 'reporter_id', nullable: true }) + @Index() + reporterId?: string; + + @ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' }) + reviewer?: User; + + @Column({ name: 'reviewer_id', nullable: true }) + reviewerId?: string; + + @Column({ + type: 'enum', + enum: ContentReportStatus, + default: ContentReportStatus.PENDING, + }) + @Index() + status: ContentReportStatus; + + @Column({ name: 'moderation_item_id', nullable: true }) + @Index() + moderationItemId?: number; + + @Column({ type: 'text', nullable: true }) + resolutionNote?: string; + + @Column({ type: 'timestamp', nullable: true }) + resolvedAt?: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} + diff --git a/src/moderation/reports/content-reporting.service.spec.ts b/src/moderation/reports/content-reporting.service.spec.ts new file mode 100644 index 00000000..c96c2973 --- /dev/null +++ b/src/moderation/reports/content-reporting.service.spec.ts @@ -0,0 +1,199 @@ +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ManualReviewService } from '../manual/manual-review.service'; +import { ContentReportReason } from './content-report-reason.enum'; +import { ContentReportStatus } from './content-report-status.enum'; +import { ContentReport } from './content-report.entity'; +import { ContentReportingService } from './content-reporting.service'; +import { ContentReportDisposition } from './dto/review-content-report.dto'; + +const mockRepo = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), +}; + +const mockManualReviewService = { + enqueue: jest.fn(), + markReviewed: jest.fn(), +}; + +describe('ContentReportingService', () => { + let service: ContentReportingService; + + const reporter = { + id: 'reporter-1', + roles: [{ name: 'student' }], + } as any; + + const moderator = { + id: 'moderator-1', + roles: [{ name: 'moderator' }], + } as any; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ContentReportingService, + { provide: getRepositoryToken(ContentReport), useValue: mockRepo }, + { provide: ManualReviewService, useValue: mockManualReviewService }, + ], + }).compile(); + + service = module.get(ContentReportingService); + }); + + afterEach(() => jest.clearAllMocks()); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('reportContent', () => { + it('should persist the report and enqueue it for moderation', async () => { + const savedReport = { + id: 'report-1', + contentType: 'course', + contentId: 'course-1', + reason: ContentReportReason.SPAM, + details: 'Spam links', + reporterId: reporter.id, + status: ContentReportStatus.PENDING, + }; + + mockRepo.create.mockReturnValue(savedReport); + mockRepo.save.mockResolvedValueOnce(savedReport).mockResolvedValueOnce({ + ...savedReport, + moderationItemId: 42, + }); + mockManualReviewService.enqueue.mockResolvedValue({ id: 42 }); + + const result = await service.reportContent( + { + contentType: 'course', + contentId: 'course-1', + reason: ContentReportReason.SPAM, + details: 'Spam links', + }, + reporter, + ); + + expect(mockRepo.create).toHaveBeenCalledWith({ + contentType: 'course', + contentId: 'course-1', + reason: ContentReportReason.SPAM, + details: 'Spam links', + reporterId: reporter.id, + status: ContentReportStatus.PENDING, + }); + expect(mockManualReviewService.enqueue).toHaveBeenCalledWith( + expect.stringContaining('type=course'), + expect.any(Number), + { + sourceType: 'content-report', + sourceId: 'report-1', + reportId: 'report-1', + }, + ); + expect(mockRepo.save).toHaveBeenCalledTimes(2); + expect(result).toEqual({ ...savedReport, moderationItemId: 42 }); + }); + }); + + describe('listReports', () => { + it('should return reports for privileged users', async () => { + mockRepo.find.mockResolvedValue([{ id: 'report-1' }]); + + const result = await service.listReports( + { status: ContentReportStatus.PENDING, limit: 25 }, + moderator, + ); + + expect(mockRepo.find).toHaveBeenCalledWith({ + where: { status: ContentReportStatus.PENDING }, + order: { createdAt: 'DESC' }, + take: 25, + }); + expect(result).toEqual([{ id: 'report-1' }]); + }); + + it('should reject non-moderators', async () => { + await expect(service.listReports({ limit: 10 }, reporter)).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); + }); + + describe('getReportById', () => { + it('should return the report for the owner', async () => { + mockRepo.findOne.mockResolvedValue({ + id: 'report-1', + reporterId: reporter.id, + }); + + const result = await service.getReportById('report-1', reporter); + + expect(result).toEqual({ + id: 'report-1', + reporterId: reporter.id, + }); + }); + + it('should throw when the report does not exist', async () => { + mockRepo.findOne.mockResolvedValue(null); + + await expect(service.getReportById('missing', reporter)).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + }); + + describe('reviewReport', () => { + it('should finalize the report and mark the queue item reviewed', async () => { + mockRepo.findOne.mockResolvedValue({ + id: 'report-1', + status: ContentReportStatus.PENDING, + moderationItemId: 42, + }); + mockRepo.save.mockResolvedValue({ + id: 'report-1', + status: ContentReportStatus.RESOLVED, + moderationItemId: 42, + reviewerId: moderator.id, + }); + + const result = await service.reviewReport( + 'report-1', + { + disposition: ContentReportDisposition.RESOLVE, + resolutionNote: 'Confirmed spam.', + }, + moderator, + ); + + expect(mockManualReviewService.markReviewed).toHaveBeenCalledWith(42); + expect(result.status).toBe(ContentReportStatus.RESOLVED); + expect(result.reviewerId).toBe(moderator.id); + }); + + it('should reject finalized reports', async () => { + mockRepo.findOne.mockResolvedValue({ + id: 'report-1', + status: ContentReportStatus.RESOLVED, + moderationItemId: 42, + }); + + await expect( + service.reviewReport( + 'report-1', + { + disposition: ContentReportDisposition.DISMISS, + }, + moderator, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); +}); diff --git a/src/moderation/reports/content-reporting.service.ts b/src/moderation/reports/content-reporting.service.ts new file mode 100644 index 00000000..8c236745 --- /dev/null +++ b/src/moderation/reports/content-reporting.service.ts @@ -0,0 +1,175 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +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 { ContentReportReason } from './content-report-reason.enum'; +import { ContentReportStatus } from './content-report-status.enum'; +import { ContentReport } from './content-report.entity'; +import { CreateContentReportDto } from './dto/create-content-report.dto'; +import { + ContentReportDisposition, + ReviewContentReportDto, +} from './dto/review-content-report.dto'; +import { ListContentReportsQueryDto } from './dto/list-content-reports-query.dto'; + +const REPORT_REASON_SCORES: Record = { + [ContentReportReason.SPAM]: 0.85, + [ContentReportReason.ABUSE]: 0.95, + [ContentReportReason.INAPPROPRIATE]: 0.75, +}; + +@Injectable() +export class ContentReportingService { + private readonly logger = new Logger(ContentReportingService.name); + + constructor( + @InjectRepository(ContentReport) + private readonly reportRepo: Repository, + private readonly manualReviewService: ManualReviewService, + ) {} + + async reportContent(dto: CreateContentReportDto, reporter: User): Promise { + const report = this.reportRepo.create({ + contentType: dto.contentType.trim(), + contentId: dto.contentId.trim(), + reason: dto.reason, + details: dto.details?.trim() || undefined, + reporterId: reporter.id, + status: ContentReportStatus.PENDING, + }); + + const saved = await this.reportRepo.save(report); + + const queueItem = await this.manualReviewService.enqueue( + this.buildQueueSummary(saved), + REPORT_REASON_SCORES[saved.reason], + { + sourceType: 'content-report', + sourceId: saved.id, + reportId: saved.id, + }, + ); + + saved.moderationItemId = queueItem.id; + const linkedReport = await this.reportRepo.save(saved); + + this.logger.log( + `Content report ${linkedReport.id} queued for ${linkedReport.contentType}:${linkedReport.contentId} by ${reporter.id}`, + ); + + return linkedReport; + } + + async listReports( + query: ListContentReportsQueryDto, + requestingUser: User, + ): Promise { + this.assertModerator(requestingUser); + + const where: FindOptionsWhere = {}; + if (query.status) where.status = query.status; + if (query.reason) where.reason = query.reason; + if (query.contentType) where.contentType = query.contentType; + + return this.reportRepo.find({ + where, + order: { createdAt: 'DESC' }, + take: query.limit ?? 50, + }); + } + + async getQueue(requestingUser: User): Promise { + this.assertModerator(requestingUser); + return this.reportRepo.find({ + where: [ + { status: ContentReportStatus.PENDING }, + { status: ContentReportStatus.UNDER_REVIEW }, + ], + order: { createdAt: 'ASC' }, + }); + } + + async getReportById(id: string, requestingUser: User): Promise { + const report = await this.reportRepo.findOne({ where: { id } }); + + if (!report) { + throw new NotFoundException(`Content report ${id} not found`); + } + + if (!this.canViewReport(report, requestingUser)) { + throw new ForbiddenException('You are not allowed to view this report.'); + } + + return report; + } + + async reviewReport( + id: string, + dto: ReviewContentReportDto, + reviewer: User, + ): Promise { + this.assertModerator(reviewer); + + const report = await this.reportRepo.findOne({ where: { id } }); + if (!report) { + throw new NotFoundException(`Content report ${id} not found`); + } + + if (report.status === ContentReportStatus.RESOLVED || report.status === ContentReportStatus.DISMISSED) { + throw new BadRequestException(`Content report ${id} has already been finalized.`); + } + + report.status = + dto.disposition === ContentReportDisposition.RESOLVE + ? ContentReportStatus.RESOLVED + : ContentReportStatus.DISMISSED; + report.reviewerId = reviewer.id; + report.resolutionNote = dto.resolutionNote?.trim() || undefined; + report.resolvedAt = new Date(); + + const saved = await this.reportRepo.save(report); + + if (saved.moderationItemId) { + await this.manualReviewService.markReviewed(saved.moderationItemId); + } + + this.logger.log( + `Content report ${saved.id} finalized as ${saved.status} by ${reviewer.id}`, + ); + + return saved; + } + + private assertModerator(user: User): void { + const isPrivileged = + user.roles?.some((role) => ['admin', 'moderator'].includes(role.name)) ?? false; + if (!isPrivileged) { + throw new ForbiddenException('Only admins or moderators may access the reporting queue.'); + } + } + + private canViewReport(report: ContentReport, user: User): boolean { + const isPrivileged = + user.roles?.some((role) => ['admin', 'moderator'].includes(role.name)) ?? false; + return isPrivileged || report.reporterId === user.id; + } + + private buildQueueSummary(report: ContentReport): string { + return [ + `Content report`, + `type=${report.contentType}`, + `id=${report.contentId}`, + `reason=${report.reason}`, + report.details ? `details=${report.details}` : undefined, + ] + .filter(Boolean) + .join(' | '); + } +} diff --git a/src/moderation/reports/content-reports.controller.ts b/src/moderation/reports/content-reports.controller.ts new file mode 100644 index 00000000..8bbb0142 --- /dev/null +++ b/src/moderation/reports/content-reports.controller.ts @@ -0,0 +1,72 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Query, + Request, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { ContentReportingService } from './content-reporting.service'; +import { CreateContentReportDto } from './dto/create-content-report.dto'; +import { ListContentReportsQueryDto } from './dto/list-content-reports-query.dto'; +import { ReviewContentReportDto } from './dto/review-content-report.dto'; + +@ApiTags('moderation') +@Controller('moderation/reports') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class ContentReportsController { + constructor(private readonly reportingService: ContentReportingService) {} + + @Post() + @ApiOperation({ summary: 'Report inappropriate content' }) + @ApiResponse({ status: 201, description: 'Content reported successfully' }) + async reportContent(@Body() dto: CreateContentReportDto, @Request() req) { + return this.reportingService.reportContent(dto, req.user); + } + + @Get() + @ApiOperation({ summary: 'List reports for moderation tracking' }) + @ApiResponse({ status: 200, description: 'Returns reports visible to moderators' }) + async listReports(@Query() query: ListContentReportsQueryDto, @Request() req) { + return this.reportingService.listReports(query, req.user); + } + + @Get('queue') + @ApiOperation({ summary: 'Get pending reports in the moderation queue' }) + @ApiResponse({ status: 200, description: 'Returns queued reports' }) + async getQueue(@Request() req) { + return this.reportingService.getQueue(req.user); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a single report' }) + @ApiResponse({ status: 200, description: 'Returns the report details' }) + @ApiResponse({ status: 404, description: 'Report not found' }) + async getReportById(@Param('id') id: string, @Request() req) { + return this.reportingService.getReportById(id, req.user); + } + + @Post(':id/review') + @ApiOperation({ summary: 'Resolve or dismiss a report' }) + @ApiResponse({ status: 200, description: 'Report reviewed successfully' }) + @ApiResponse({ status: 400, description: 'Report already finalized' }) + @ApiResponse({ status: 404, description: 'Report not found' }) + async reviewReport( + @Param('id') id: string, + @Body() dto: ReviewContentReportDto, + @Request() req, + ) { + return this.reportingService.reviewReport(id, dto, req.user); + } +} + diff --git a/src/moderation/reports/dto/create-content-report.dto.ts b/src/moderation/reports/dto/create-content-report.dto.ts new file mode 100644 index 00000000..f29f336b --- /dev/null +++ b/src/moderation/reports/dto/create-content-report.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; +import { ContentReportReason } from '../content-report-reason.enum'; + +export class CreateContentReportDto { + @ApiProperty({ + description: 'The type of content being reported.', + example: 'course', + }) + @IsString() + @MinLength(2) + @MaxLength(64) + contentType: string; + + @ApiProperty({ + description: 'The identifier of the reported content.', + example: '9f5b6f1f-6d7a-4a4a-a1d8-8f6c4d5c9c11', + }) + @IsString() + @MinLength(1) + @MaxLength(128) + contentId: string; + + @ApiProperty({ + enum: ContentReportReason, + description: 'Why the content is being reported.', + example: ContentReportReason.SPAM, + }) + @IsEnum(ContentReportReason) + reason: ContentReportReason; + + @ApiProperty({ + description: 'Optional details to help moderators understand the report.', + required: false, + example: 'The course description contains repeated promotional links.', + }) + @IsString() + @IsOptional() + @MaxLength(2000) + details?: string; +} + diff --git a/src/moderation/reports/dto/list-content-reports-query.dto.ts b/src/moderation/reports/dto/list-content-reports-query.dto.ts new file mode 100644 index 00000000..c11d05c6 --- /dev/null +++ b/src/moderation/reports/dto/list-content-reports-query.dto.ts @@ -0,0 +1,42 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsEnum, IsOptional, IsString, Max, Min } from 'class-validator'; +import { ContentReportReason } from '../content-report-reason.enum'; +import { ContentReportStatus } from '../content-report-status.enum'; + +export class ListContentReportsQueryDto { + @ApiPropertyOptional({ + enum: ContentReportStatus, + description: 'Filter reports by moderation status.', + }) + @IsEnum(ContentReportStatus) + @IsOptional() + status?: ContentReportStatus; + + @ApiPropertyOptional({ + enum: ContentReportReason, + description: 'Filter reports by reason.', + }) + @IsEnum(ContentReportReason) + @IsOptional() + reason?: ContentReportReason; + + @ApiPropertyOptional({ + description: 'Filter reports by content type.', + example: 'course', + }) + @IsString() + @IsOptional() + contentType?: string; + + @ApiPropertyOptional({ + description: 'Maximum number of reports to return.', + example: 50, + }) + @Type(() => Number) + @Min(1) + @Max(200) + @IsOptional() + limit?: number; +} + diff --git a/src/moderation/reports/dto/review-content-report.dto.ts b/src/moderation/reports/dto/review-content-report.dto.ts new file mode 100644 index 00000000..1cea68ba --- /dev/null +++ b/src/moderation/reports/dto/review-content-report.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; + +export enum ContentReportDisposition { + RESOLVE = 'resolve', + DISMISS = 'dismiss', +} + +export class ReviewContentReportDto { + @ApiProperty({ + enum: ContentReportDisposition, + description: 'The final moderation outcome for the report.', + example: ContentReportDisposition.RESOLVE, + }) + @IsEnum(ContentReportDisposition) + disposition: ContentReportDisposition; + + @ApiProperty({ + description: 'Optional note explaining the moderation decision.', + required: false, + example: 'Confirmed spam content, report resolved.', + }) + @IsString() + @IsOptional() + @MaxLength(5000) + resolutionNote?: string; +} +