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
48 changes: 19 additions & 29 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@ import { APP_GUARD } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';

import { AppController } from './app.controller';
import { SearchModule } from './search/search.module';
import { AnalyticsModule } from './analytics/analytics.module';
import { ShardingModule } from './sharding/sharding.module';

import { EmailModule } from './email-marketing/email.module';
import { IndexOptimizationModule } from './database/index-optimization/index-optimization.module';
import { RateLimitingModule } from './rate-limiting/rate-limiting.module';
Expand All @@ -21,92 +19,84 @@ import { DataPipelineModule } from './data-pipeline/data-pipeline.module';
import { CanaryModule } from './canary/canary.module';
import { IncidentManagementModule } from './incident-management/incident-management.module';
import { MonitoringModule } from './monitoring/monitoring.module';
import { GamificationModule } from './gamification/gamification.module';
import { I18nModule as AppI18nModule } from './i18n/i18n.module';
import { AchievementsModule } from './achievements/achievements.module';

import { RequestTimeoutInterceptor } from './common/interceptors/request-timeout.interceptor';
import { ModerationModule } from './moderation/moderation.module';
import { IdempotencyModule } from './common/modules/idempotency.module';
import { IdempotencyInterceptor } from './common/interceptors/idempotency.interceptor';
import { DeepLinkModule } from './deep-link/deep-link.module';
import { InvoicesModule } from './payments/invoices/invoices.module';
import { PaymentMethodsModule } from './payments/payment-methods/payment-methods.module';
import { ReportingModule } from './payments/reporting/reporting.module';
import { PayoutsModule } from './payments/payouts/payouts.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';
import { CachingModule } from './caching/caching.module';

import { SlackService } from './slack.service';

import { CoursesModule } from './courses/courses.module';
import { DataRetentionModule } from './data-retention/data-retention.module';
import { GatewayModule } from './gateway/gateway.module';
import { UsersModule } from './users/users.module';
import { NotificationsModule } from './notifications/notifications.module';
import { MessagingModule } from './messaging/messaging.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { GamificationModule } from './gamification/gamification.module';
import { RecommendationsModule } from './recommendations/recommendations.module';

import { GamificationModule } from './gamification/gamification.module';
import { I18nModule as AppI18nModule } from './i18n/i18n.module';
import { AchievementsModule } from './achievements/achievements.module';

const featureFlags = loadFeatureFlags();

@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRoot(getDatabaseConfig()),
ScheduleModule.forRoot(),

SessionModule,
SearchModule,
AnalyticsModule,
IndexOptimizationModule,

...(featureFlags.ENABLE_RATE_LIMITING ? [RateLimitingModule] : []),

DebuggingModule,
DataPipelineModule,
CanaryModule,
IncidentManagementModule,
MonitoringModule,
GamificationModule,
ShardingModule,

IdempotencyModule,
DeepLinkModule,

InvoicesModule,
PaymentMethodsModule,
NotificationsModule,
ReportingModule,
PayoutsModule,
NotificationsModule,
HealthModule,
...(featureFlags.ENABLE_MODERATION ? [ModerationModule] : []),
ForumModule,

// ✅ always include read replicas (or wrap if needed)
ReadReplicaModule,
...(featureFlags.ENABLE_MODERATION ? [ModerationModule] : []),

// ✅ feature-flagged caching
// database + infra
ReadReplicaModule,
...(featureFlags.ENABLE_CACHING ? [CachingModule] : []),
// i18n support

// core features
AppI18nModule,
AchievementsModule,

// ✅ courses module with enrollment and prerequisite enforcement
CoursesModule,

// ✅ data retention: archiving and purging
DataRetentionModule,

// ✅ API gateway: routing, rate limiting, transformation, caching
GatewayModule,

// ✅ Users module for profile and activity management
UsersModule,
NotificationsModule,
MessagingModule,
DashboardModule,
GamificationModule,
RecommendationsModule,
GamificationModule,
],
controllers: [AppController],
providers: featureFlags.ENABLE_RATE_LIMITING
Expand Down
10 changes: 10 additions & 0 deletions src/modules/moderation/dto/moderate-content.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class ModerateContentDto {
@ApiProperty({ description: 'Content to moderate', maxLength: 10000 })
@IsString()
@IsNotEmpty()
@MaxLength(10000)
content: string;
}
17 changes: 17 additions & 0 deletions src/modules/moderation/dto/moderation-result.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';

export type ModerationFlag = 'profanity' | 'spam' | 'openai_violation';

export class ModerationResultDto {
@ApiProperty({ description: 'Whether the content is allowed' })
allowed: boolean;

@ApiProperty({ description: 'Whether the content was auto-rejected' })
autoRejected: boolean;

@ApiProperty({ description: 'Flags triggered', type: [String] })
flags: ModerationFlag[];

@ApiProperty({ description: 'Human-readable reason if rejected', required: false })
reason?: string;
}
17 changes: 17 additions & 0 deletions src/modules/moderation/moderation.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { ModerationService } from './moderation.service';
import { ModerateContentDto } from './dto/moderate-content.dto';
import { ModerationResultDto } from './dto/moderation-result.dto';

@ApiTags('moderation')
@Controller('moderation')
export class ModerationController {
constructor(private readonly moderationService: ModerationService) {}

@Post('check')
@ApiOperation({ summary: 'Check content for policy violations' })
check(@Body() dto: ModerateContentDto): Promise<ModerationResultDto> {
return this.moderationService.moderate(dto.content);
}
}
12 changes: 12 additions & 0 deletions src/modules/moderation/moderation.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { ModerationService } from './moderation.service';
import { ModerationController } from './moderation.controller';

@Module({
imports: [HttpModule],
controllers: [ModerationController],
providers: [ModerationService],
exports: [ModerationService],
})
export class ModerationModule {}
144 changes: 144 additions & 0 deletions src/modules/moderation/moderation.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { of, throwError } from 'rxjs';
import { ModerationService } from './moderation.service';

const mockHttpService = { post: jest.fn() };
const mockConfigService = { get: jest.fn() };

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

beforeEach(async () => {
jest.clearAllMocks();
mockConfigService.get.mockReturnValue(''); // no API key by default

const module: TestingModule = await Test.createTestingModule({
providers: [
ModerationService,
{ provide: HttpService, useValue: mockHttpService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();

service = module.get<ModerationService>(ModerationService);
});

describe('clean content', () => {
it('allows clean content', async () => {
const result = await service.moderate('This is a great course!');
expect(result.allowed).toBe(true);
expect(result.autoRejected).toBe(false);
expect(result.flags).toHaveLength(0);
expect(result.reason).toBeUndefined();
});
});

describe('profanity filter', () => {
it('flags content with profanity', async () => {
const result = await service.moderate('This is fucking terrible');
expect(result.allowed).toBe(false);
expect(result.autoRejected).toBe(true);
expect(result.flags).toContain('profanity');
});

it('is case-insensitive', async () => {
const result = await service.moderate('SHIT happens');
expect(result.flags).toContain('profanity');
});
});

describe('spam detection', () => {
it('flags repeated characters', async () => {
const result = await service.moderate('heeeeeeeeeeello world');
expect(result.flags).toContain('spam');
});

it('flags 3 or more URLs', async () => {
const result = await service.moderate(
'Visit http://a.com and http://b.com and http://c.com',
);
expect(result.flags).toContain('spam');
});

it('allows content with fewer than 3 URLs', async () => {
const result = await service.moderate('Check http://a.com for details');
expect(result.flags).not.toContain('spam');
});

it('flags known spam phrases', async () => {
const result = await service.moderate('Buy now and make money fast!');
expect(result.flags).toContain('spam');
});

it('flags excessive uppercase', async () => {
const result = await service.moderate('THIS IS ALL CAPS SHOUTING AT YOU');
expect(result.flags).toContain('spam');
});
});

describe('OpenAI integration', () => {
async function makeServiceWithKey(key: string): Promise<ModerationService> {
mockConfigService.get.mockReturnValue(key);
const mod = await Test.createTestingModule({
providers: [
ModerationService,
{ provide: HttpService, useValue: mockHttpService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
return mod.get<ModerationService>(ModerationService);
}

it('flags content when OpenAI returns flagged=true', async () => {
const svc = await makeServiceWithKey('sk-test-key');
mockHttpService.post.mockReturnValue(
of({ data: { results: [{ flagged: true }] } }),
);
const result = await svc.moderate('some harmful content');
expect(result.flags).toContain('openai_violation');
expect(result.allowed).toBe(false);
});

it('allows content when OpenAI returns flagged=false', async () => {
const svc = await makeServiceWithKey('sk-test-key');
mockHttpService.post.mockReturnValue(
of({ data: { results: [{ flagged: false }] } }),
);
const result = await svc.moderate('normal content here');
expect(result.flags).not.toContain('openai_violation');
expect(result.allowed).toBe(true);
});

it('does not reject when OpenAI call fails (graceful degradation)', async () => {
const svc = await makeServiceWithKey('sk-test-key');
mockHttpService.post.mockReturnValue(throwError(() => new Error('network error')));
const result = await svc.moderate('normal content here');
expect(result.flags).not.toContain('openai_violation');
expect(result.allowed).toBe(true);
});

it('skips OpenAI check when no API key configured', async () => {
const svc = await makeServiceWithKey('');
await svc.moderate('clean content');
expect(mockHttpService.post).not.toHaveBeenCalled();
});
});

describe('auto-reject', () => {
it('auto-rejects and includes reason when any flag is set', async () => {
const result = await service.moderate('buy now and make money fast!');
expect(result.autoRejected).toBe(true);
expect(result.reason).toBeTruthy();
});

it('accumulates multiple flags', async () => {
// profanity + spam phrase
const result = await service.moderate('buy now you fucking idiot');
expect(result.flags).toContain('profanity');
expect(result.flags).toContain('spam');
expect(result.flags.length).toBeGreaterThanOrEqual(2);
});
});
});
Loading
Loading