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

// ✅ always include read replicas (or wrap if needed)
ReadReplicaModule,
Expand Down
39 changes: 39 additions & 0 deletions src/forum/entities/forum-comment.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
35 changes: 35 additions & 0 deletions src/forum/entities/forum-thread.entity.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
23 changes: 23 additions & 0 deletions src/forum/entities/forum-vote.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
41 changes: 41 additions & 0 deletions src/forum/forum.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
19 changes: 19 additions & 0 deletions src/forum/forum.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
123 changes: 123 additions & 0 deletions src/forum/forum.service.ts
Original file line number Diff line number Diff line change
@@ -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<ForumThread>,
@InjectRepository(ForumComment)
private readonly commentRepo: Repository<ForumComment>,
@InjectRepository(ForumVote)
private readonly voteRepo: Repository<ForumVote>,
private readonly autoModService: AutoModerationService,
private readonly manualReviewService: ManualReviewService,
) {}

async createThread(title: string, content: string, authorId: string): Promise<ForumThread> {
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<ForumThread[]> {
return this.threadRepo.find({ where: { status: 'active' }, order: { createdAt: 'DESC' } });
}

async getThread(id: string): Promise<ForumThread> {
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<ForumComment> {
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 });
}
}
}
Loading