From 2144d443c0388f33ed4155301148e27edfa4f59f Mon Sep 17 00:00:00 2001 From: ajulaybeeb Date: Tue, 2 Jun 2026 07:36:58 +0100 Subject: [PATCH] feat: Implement discussion forum with moderation queue --- src/app.module.ts | 2 + src/forum/entities/forum-comment.entity.ts | 39 +++++++ src/forum/entities/forum-thread.entity.ts | 35 ++++++ src/forum/entities/forum-vote.entity.ts | 23 ++++ src/forum/forum.controller.ts | 41 +++++++ src/forum/forum.module.ts | 19 ++++ src/forum/forum.service.ts | 123 +++++++++++++++++++++ 7 files changed, 282 insertions(+) create mode 100644 src/forum/entities/forum-comment.entity.ts create mode 100644 src/forum/entities/forum-thread.entity.ts create mode 100644 src/forum/entities/forum-vote.entity.ts create mode 100644 src/forum/forum.controller.ts create mode 100644 src/forum/forum.module.ts create mode 100644 src/forum/forum.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 86fe5ff..cff59ac 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -30,6 +30,7 @@ import { ReportingModule } from './payments/reporting/reporting.module'; import { NotificationsModule } from './notifications/notifications.module'; import { HealthModule } from './health/health.module'; import { ModerationModule } from './moderation/moderation.module'; +import { ForumModule } from './forum/forum.module'; // ✅ keep BOTH modules import { ReadReplicaModule } from './database/read-replica'; @@ -70,6 +71,7 @@ const featureFlags = loadFeatureFlags(); ReportingModule, HealthModule, ...(featureFlags.ENABLE_MODERATION ? [ModerationModule] : []), + ForumModule, // ✅ always include read replicas (or wrap if needed) ReadReplicaModule, diff --git a/src/forum/entities/forum-comment.entity.ts b/src/forum/entities/forum-comment.entity.ts new file mode 100644 index 0000000..49608c8 --- /dev/null +++ b/src/forum/entities/forum-comment.entity.ts @@ -0,0 +1,39 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { ForumThread } from './forum-thread.entity'; + +@Entity('forum_comments') +export class ForumComment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + threadId: string; + + @ManyToOne(() => ForumThread, (thread) => thread.comments, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'threadId' }) + thread: ForumThread; + + @Column({ nullable: true }) + parentId: string; + + @Column({ type: 'text' }) + content: string; + + @Column() + authorId: string; + + @Column({ default: 'active' }) + status: string; + + @Column({ default: 0 }) + upvotes: number; + + @Column({ default: 0 }) + downvotes: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/forum/entities/forum-thread.entity.ts b/src/forum/entities/forum-thread.entity.ts new file mode 100644 index 0000000..2e3c516 --- /dev/null +++ b/src/forum/entities/forum-thread.entity.ts @@ -0,0 +1,35 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm'; +import { ForumComment } from './forum-comment.entity'; + +@Entity('forum_threads') +export class ForumThread { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + title: string; + + @Column({ type: 'text' }) + content: string; + + @Column() + authorId: string; + + @Column({ default: 'active' }) + status: string; // 'active', 'flagged', 'hidden' + + @Column({ default: 0 }) + upvotes: number; + + @Column({ default: 0 }) + downvotes: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @OneToMany(() => ForumComment, (comment) => comment.thread) + comments: ForumComment[]; +} diff --git a/src/forum/entities/forum-vote.entity.ts b/src/forum/entities/forum-vote.entity.ts new file mode 100644 index 0000000..fc65a92 --- /dev/null +++ b/src/forum/entities/forum-vote.entity.ts @@ -0,0 +1,23 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Unique } from 'typeorm'; + +@Entity('forum_votes') +@Unique(['entityType', 'entityId', 'authorId']) +export class ForumVote { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + entityType: string; // 'thread' | 'comment' + + @Column() + entityId: string; + + @Column() + authorId: string; + + @Column({ type: 'int' }) + value: number; // 1 or -1 + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/forum/forum.controller.ts b/src/forum/forum.controller.ts new file mode 100644 index 0000000..26c1516 --- /dev/null +++ b/src/forum/forum.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Post, Get, Body, Param, Req } from '@nestjs/common'; +import { ForumService } from './forum.service'; + +@Controller('forums') +export class ForumController { + constructor(private readonly forumService: ForumService) {} + + @Post('threads') + createThread(@Body() body: { title: string; content: string }, @Req() req: any) { + const authorId = req.user?.id || 'anonymous'; + return this.forumService.createThread(body.title, body.content, authorId); + } + + @Get('threads') + getThreads() { + return this.forumService.getThreads(); + } + + @Get('threads/:id') + getThread(@Param('id') id: string) { + return this.forumService.getThread(id); + } + + @Post('threads/:id/comments') + addComment(@Param('id') threadId: string, @Body() body: { content: string; parentId?: string }, @Req() req: any) { + const authorId = req.user?.id || 'anonymous'; + return this.forumService.addComment(threadId, body.content, authorId, body.parentId); + } + + @Post('threads/:id/vote') + voteThread(@Param('id') id: string, @Body() body: { value: number }, @Req() req: any) { + const authorId = req.user?.id || 'anonymous'; + return this.forumService.vote('thread', id, authorId, body.value); + } + + @Post('comments/:id/vote') + voteComment(@Param('id') id: string, @Body() body: { value: number }, @Req() req: any) { + const authorId = req.user?.id || 'anonymous'; + return this.forumService.vote('comment', id, authorId, body.value); + } +} diff --git a/src/forum/forum.module.ts b/src/forum/forum.module.ts new file mode 100644 index 0000000..290b64a --- /dev/null +++ b/src/forum/forum.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ForumController } from './forum.controller'; +import { ForumService } from './forum.service'; +import { ForumThread } from './entities/forum-thread.entity'; +import { ForumComment } from './entities/forum-comment.entity'; +import { ForumVote } from './entities/forum-vote.entity'; +import { ModerationModule } from '../moderation/moderation.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ForumThread, ForumComment, ForumVote]), + ModerationModule, + ], + controllers: [ForumController], + providers: [ForumService], + exports: [ForumService], +}) +export class ForumModule {} diff --git a/src/forum/forum.service.ts b/src/forum/forum.service.ts new file mode 100644 index 0000000..79eabe3 --- /dev/null +++ b/src/forum/forum.service.ts @@ -0,0 +1,123 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ForumThread } from './entities/forum-thread.entity'; +import { ForumComment } from './entities/forum-comment.entity'; +import { ForumVote } from './entities/forum-vote.entity'; +import { AutoModerationService } from '../moderation/auto/auto-moderation.service'; +import { ManualReviewService } from '../moderation/manual/manual-review.service'; + +@Injectable() +export class ForumService { + constructor( + @InjectRepository(ForumThread) + private readonly threadRepo: Repository, + @InjectRepository(ForumComment) + private readonly commentRepo: Repository, + @InjectRepository(ForumVote) + private readonly voteRepo: Repository, + private readonly autoModService: AutoModerationService, + private readonly manualReviewService: ManualReviewService, + ) {} + + async createThread(title: string, content: string, authorId: string): Promise { + const analysis = await this.autoModService.analyze(title + ' ' + content); + let status = 'active'; + + if (analysis.flagged) { + status = 'flagged'; + } + + const thread = this.threadRepo.create({ + title, + content, + authorId, + status, + }); + + const saved = await this.threadRepo.save(thread); + + if (analysis.flagged) { + await this.manualReviewService.enqueue( + title + '\n' + content, + analysis.score, + { sourceType: 'forum_thread', sourceId: saved.id } + ); + } + + return saved; + } + + async getThreads(): Promise { + return this.threadRepo.find({ where: { status: 'active' }, order: { createdAt: 'DESC' } }); + } + + async getThread(id: string): Promise { + const thread = await this.threadRepo.findOne({ where: { id, status: 'active' }, relations: ['comments'] }); + if (!thread) throw new NotFoundException('Thread not found'); + return thread; + } + + async addComment(threadId: string, content: string, authorId: string, parentId?: string): Promise { + const thread = await this.threadRepo.findOne({ where: { id: threadId, status: 'active' } }); + if (!thread) throw new NotFoundException('Thread not found'); + + const analysis = await this.autoModService.analyze(content); + let status = 'active'; + if (analysis.flagged) { + status = 'flagged'; + } + + const comment = this.commentRepo.create({ + threadId, + content, + authorId, + parentId, + status, + }); + + const saved = await this.commentRepo.save(comment); + + if (analysis.flagged) { + await this.manualReviewService.enqueue( + content, + analysis.score, + { sourceType: 'forum_comment', sourceId: saved.id } + ); + } + + return saved; + } + + async vote(entityType: 'thread' | 'comment', entityId: string, authorId: string, value: number) { + if (value !== 1 && value !== -1) throw new BadRequestException('Vote value must be 1 or -1'); + + const existing = await this.voteRepo.findOne({ where: { entityType, entityId, authorId } }); + if (existing) { + if (existing.value === value) { + return; + } + + existing.value = value; + await this.voteRepo.save(existing); + + await this.updateVoteTotals(entityType, entityId); + return; + } + + const vote = this.voteRepo.create({ entityType, entityId, authorId, value }); + await this.voteRepo.save(vote); + await this.updateVoteTotals(entityType, entityId); + } + + private async updateVoteTotals(entityType: 'thread' | 'comment', entityId: string) { + const upvotes = await this.voteRepo.count({ where: { entityType, entityId, value: 1 } }); + const downvotes = await this.voteRepo.count({ where: { entityType, entityId, value: -1 } }); + + if (entityType === 'thread') { + await this.threadRepo.update(entityId, { upvotes, downvotes }); + } else { + await this.commentRepo.update(entityId, { upvotes, downvotes }); + } + } +}