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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -69,7 +69,7 @@ const featureFlags = loadFeatureFlags();
NotificationsModule,
ReportingModule,
HealthModule,
AuditLogModule,
...(featureFlags.ENABLE_MODERATION ? [ModerationModule] : []),

// ✅ always include read replicas (or wrap if needed)
ReadReplicaModule,
Expand Down
15 changes: 8 additions & 7 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,13 +278,14 @@ async function bootstrapWorker(): Promise<void> {
.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')
Expand Down
31 changes: 30 additions & 1 deletion src/moderation/manual/manual-review.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,43 @@ 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',
safetyScore: 0.9,
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',
});
});
});

Expand Down
20 changes: 17 additions & 3 deletions src/moderation/manual/manual-review.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReviewItem> {
const item = this.reviewRepo.create({
content,
safetyScore,
status: 'pending',
...source,
});
return this.reviewRepo.save(item);
}

/**
Expand Down
9 changes: 9 additions & 0 deletions src/moderation/manual/review-item.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
8 changes: 7 additions & 1 deletion src/moderation/moderation.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
6 changes: 6 additions & 0 deletions src/moderation/reports/content-report-reason.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export enum ContentReportReason {
SPAM = 'spam',
ABUSE = 'abuse',
INAPPROPRIATE = 'inappropriate',
}

7 changes: 7 additions & 0 deletions src/moderation/reports/content-report-status.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export enum ContentReportStatus {
PENDING = 'pending',
UNDER_REVIEW = 'under_review',
RESOLVED = 'resolved',
DISMISSED = 'dismissed',
}

80 changes: 80 additions & 0 deletions src/moderation/reports/content-report.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Loading
Loading