diff --git a/README.md b/README.md index d9dd095f..057d5fa4 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,42 @@ TeachLink Backend provides secure and scalable APIs to power features such as: | File Upload | Cloudinary | | Documentation | Swagger | -## 🚀 Deployment +## �️ Database + +### Index Strategy + +The application uses strategic database indexes to optimize query performance, especially for frequently accessed data and pagination operations. + +#### Single Column Indexes +- **User.email**: Unique index for authentication lookups +- **User.username**: Index for profile searches +- **User.tenantId**: Index for multi-tenant queries +- **Payment.status**: Index for payment status filtering +- **Payment.userId**: Index for user payment history +- **Payment.courseId**: Index for course revenue queries +- **Subscription.status**: Index for active subscription queries +- **Subscription.userId**: Index for user subscription management +- **Course.status**: Index for published course listings +- **Course.instructorId**: Index for instructor course queries +- **Enrollment.userId**: Index for user enrollment history +- **Enrollment.courseId**: Index for course enrollment counts +- **Enrollment.status**: Index for active enrollment filtering +- **CourseModule.courseId**: Index for course module queries +- **Lesson.moduleId**: Index for module lesson queries + +#### Composite Indexes +- **Enrollment (userId, status)**: Optimized for user enrollment status queries +- **Enrollment (courseId, status)**: Optimized for course enrollment analytics +- **Payment (userId, status)**: Optimized for user payment status filtering +- **Subscription (userId, status)**: Optimized for user subscription status queries + +#### Performance Considerations +- Indexes are added to foreign key columns to improve JOIN performance +- Composite indexes support common query patterns (e.g., filtering by user + status) +- Partial indexes are used where applicable for better selectivity +- Index maintenance overhead is monitored to ensure write performance is not negatively impacted + +## �🚀 Deployment ### Prerequisites diff --git a/src/app.module.ts b/src/app.module.ts index 1bb971a4..5a91461a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -29,6 +29,9 @@ import { RateLimitingModule } from './rate-limiting/services/rate-limiting.modul import * as redisStore from 'cache-manager-redis-store'; import { envValidationSchema } from './config/env.validation'; import { HealthModule } from './health/health.module'; +import { ThrottlerModule } from '@nestjs/throttler'; +import { APP_GUARD } from '@nestjs/core'; +import { CustomThrottleGuard } from './common/guards/throttle.guard'; @Module({ imports: [ @@ -67,6 +70,20 @@ import { HealthModule } from './health/health.module'; host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379'), }), + ThrottlerModule.forRootAsync({ + imports: [ConfigModule], + useFactory: () => ({ + ttl: parseInt(process.env.THROTTLE_TTL || '60'), + limit: parseInt(process.env.THROTTLE_LIMIT || '10'), + storage: { + type: 'redis', + options: { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379'), + }, + }, + }), + }), HealthModule, SyncModule, MediaModule, @@ -88,6 +105,10 @@ import { HealthModule } from './health/health.module'; provide: APP_INTERCEPTOR, useClass: MonitoringInterceptor, }, + { + provide: APP_GUARD, + useClass: CustomThrottleGuard, + }, ], }) export class AppModule {} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index d54aeacc..0f6aed3e 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,5 +1,6 @@ import { Controller, Post, Body, UseGuards, Req } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; import { AuthService } from './auth.service'; import { RegisterDto, @@ -19,12 +20,14 @@ export class AuthController { constructor(private readonly authService: AuthService) {} @Post('register') + @Throttle({ default: { limit: 3, ttl: 3600000 } }) // 3 requests per hour @ApiOperation({ summary: 'Register a new user' }) async register(@Body() registerDto: RegisterDto) { return this.authService.register(registerDto); } @Post('login') + @Throttle({ default: { limit: 5, ttl: 900000 } }) // 5 requests per 15 minutes @ApiOperation({ summary: 'Login user and get tokens' }) async login(@Body() loginDto: LoginDto) { return this.authService.login(loginDto); diff --git a/src/common/guards/throttle.guard.ts b/src/common/guards/throttle.guard.ts index f82f45b0..3c6663d0 100644 --- a/src/common/guards/throttle.guard.ts +++ b/src/common/guards/throttle.guard.ts @@ -24,16 +24,21 @@ export class CustomThrottleGuard extends ThrottlerGuard { this.logger.warn(`Rate limit exceeded: ip=${ip} method=${request.method} route=${route}`); + // Get throttle options from the decorator or default + const throttleOptions = this.getThrottleOptions(context); + // Inject standard rate-limit headers so clients can back off gracefully - response.setHeader('Retry-After', '60'); - response.setHeader('X-RateLimit-Exceeded', 'true'); + response.setHeader('Retry-After', throttleOptions.ttl); + response.setHeader('X-RateLimit-Limit', throttleOptions.limit); + response.setHeader('X-RateLimit-Remaining', 0); + response.setHeader('X-RateLimit-Reset', Math.floor(Date.now() / 1000) + throttleOptions.ttl); throw new HttpException( { statusCode: HttpStatus.TOO_MANY_REQUESTS, error: 'Too Many Requests', message: 'You have exceeded the request rate limit. Please wait before retrying.', - retryAfterSeconds: 60, + retryAfterSeconds: throttleOptions.ttl, }, HttpStatus.TOO_MANY_REQUESTS, ); @@ -44,4 +49,20 @@ export class CustomThrottleGuard extends ThrottlerGuard { if (typeof forwarded === 'string') return forwarded.split(',')[0].trim(); return request.ip ?? request.socket?.remoteAddress ?? 'unknown'; } + + private getThrottleOptions(context: ExecutionContext): { limit: number; ttl: number } { + // Try to get throttle options from the decorator + const handler = context.getHandler(); + const throttleDecorator = Reflect.getMetadata('__throttler__', handler); + + if (throttleDecorator) { + return { + limit: throttleDecorator.limit || 10, + ttl: throttleDecorator.ttl || 60, + }; + } + + // Fallback to default options + return { limit: 10, ttl: 60 }; + } } diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 4fe7027f..e951ef96 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -14,5 +14,8 @@ export const envValidationSchema = Joi.object({ REDIS_HOST: Joi.string().required(), REDIS_PORT: Joi.number().required(), + THROTTLE_TTL: Joi.number().default(60), + THROTTLE_LIMIT: Joi.number().default(10), + JWT_SECRET: Joi.string().min(10).required(), }); diff --git a/src/courses/entities/course-module.entity.ts b/src/courses/entities/course-module.entity.ts index 3de865bf..3d3d7a42 100644 --- a/src/courses/entities/course-module.entity.ts +++ b/src/courses/entities/course-module.entity.ts @@ -1,4 +1,4 @@ -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany } from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, Index } from 'typeorm'; import { Course } from './course.entity'; import { Lesson } from './lesson.entity'; @@ -16,6 +16,10 @@ export class CourseModule { @ManyToOne(() => Course, (course) => course.modules, { onDelete: 'CASCADE' }) course: Course; + @Column({ name: 'course_id' }) + @Index() + courseId: string; + @OneToMany(() => Lesson, (lesson) => lesson.module) lessons: Lesson[]; } diff --git a/src/courses/entities/course.entity.ts b/src/courses/entities/course.entity.ts index bcd659b8..170e0a9b 100644 --- a/src/courses/entities/course.entity.ts +++ b/src/courses/entities/course.entity.ts @@ -6,6 +6,7 @@ import { UpdateDateColumn, ManyToOne, OneToMany, + Index, } from 'typeorm'; import { User } from '../../users/entities/user.entity'; import { CourseModule } from './course-module.entity'; @@ -26,6 +27,7 @@ export class Course { price: number; @Column({ default: 'draft' }) // draft, published, archived + @Index() status: string; @Column({ nullable: true }) @@ -34,6 +36,10 @@ export class Course { @ManyToOne(() => User, (user) => user.courses) instructor: User; + @Column({ name: 'instructor_id' }) + @Index() + instructorId: string; + @OneToMany(() => CourseModule, (module) => module.course) modules: CourseModule[]; diff --git a/src/courses/entities/enrollment.entity.ts b/src/courses/entities/enrollment.entity.ts index f628e5d9..a0a83bc2 100644 --- a/src/courses/entities/enrollment.entity.ts +++ b/src/courses/entities/enrollment.entity.ts @@ -5,11 +5,14 @@ import { ManyToOne, CreateDateColumn, UpdateDateColumn, + Index, } from 'typeorm'; import { User } from '../../users/entities/user.entity'; import { Course } from './course.entity'; @Entity() +@Index(['userId', 'status']) +@Index(['courseId', 'status']) export class Enrollment { @PrimaryGeneratedColumn('uuid') id: string; @@ -17,13 +20,22 @@ export class Enrollment { @ManyToOne(() => User, (user) => user.enrollments, { onDelete: 'CASCADE' }) user: User; + @Column({ name: 'user_id' }) + @Index() + userId: string; + @ManyToOne(() => Course, (course) => course.enrollments, { onDelete: 'CASCADE' }) course: Course; + @Column({ name: 'course_id' }) + @Index() + courseId: string; + @Column({ type: 'float', default: 0 }) progress: number; // 0 to 100 @Column({ default: 'active' }) // active, completed, dropped + @Index() status: string; @CreateDateColumn() diff --git a/src/courses/entities/lesson.entity.ts b/src/courses/entities/lesson.entity.ts index b45aea4a..03ea7d86 100644 --- a/src/courses/entities/lesson.entity.ts +++ b/src/courses/entities/lesson.entity.ts @@ -1,4 +1,4 @@ -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, Index } from 'typeorm'; import { CourseModule } from './course-module.entity'; @Entity() @@ -23,4 +23,8 @@ export class Lesson { @ManyToOne(() => CourseModule, (module) => module.lessons, { onDelete: 'CASCADE' }) module: CourseModule; + + @Column({ name: 'module_id' }) + @Index() + moduleId: string; } diff --git a/src/payments/entities/payment.entity.ts b/src/payments/entities/payment.entity.ts index 3745d5da..d4165fe7 100644 --- a/src/payments/entities/payment.entity.ts +++ b/src/payments/entities/payment.entity.ts @@ -6,6 +6,7 @@ import { UpdateDateColumn, ManyToOne, JoinColumn, + Index, } from 'typeorm'; import { User } from '../../users/entities/user.entity'; import { Course } from '../../courses/entities/course.entity'; @@ -28,6 +29,7 @@ export enum PaymentMethod { } @Entity('payments') +@Index(['userId', 'status']) export class Payment { @PrimaryGeneratedColumn('uuid') id: string; @@ -39,6 +41,7 @@ export class Payment { currency: string; @Column({ type: 'enum', enum: PaymentStatus, default: PaymentStatus.PENDING }) + @Index() status: PaymentStatus; @Column({ type: 'enum', enum: PaymentMethod }) @@ -58,6 +61,7 @@ export class Payment { user: User; @Column({ name: 'user_id' }) + @Index() userId: string; @ManyToOne(() => Course, (course) => course.id) @@ -65,6 +69,7 @@ export class Payment { course: Course; @Column({ name: 'course_id', nullable: true }) + @Index() courseId: string; @Column({ type: 'boolean', default: false }) diff --git a/src/payments/entities/subscription.entity.ts b/src/payments/entities/subscription.entity.ts index 5410da4d..1f43de91 100644 --- a/src/payments/entities/subscription.entity.ts +++ b/src/payments/entities/subscription.entity.ts @@ -6,6 +6,7 @@ import { UpdateDateColumn, ManyToOne, JoinColumn, + Index, } from 'typeorm'; import { User } from '../../users/entities/user.entity'; @@ -26,6 +27,7 @@ export enum SubscriptionInterval { } @Entity('subscriptions') +@Index(['userId', 'status']) export class Subscription { @PrimaryGeneratedColumn('uuid') id: string; @@ -34,6 +36,7 @@ export class Subscription { providerSubscriptionId: string; @Column({ type: 'enum', enum: SubscriptionStatus, default: SubscriptionStatus.ACTIVE }) + @Index() status: SubscriptionStatus; @Column({ type: 'enum', enum: SubscriptionInterval }) @@ -68,6 +71,7 @@ export class Subscription { user: User; @Column({ name: 'user_id' }) + @Index() userId: string; @CreateDateColumn() diff --git a/src/payments/payments.controller.ts b/src/payments/payments.controller.ts index 659cdcd4..8743bf8d 100644 --- a/src/payments/payments.controller.ts +++ b/src/payments/payments.controller.ts @@ -11,6 +11,7 @@ import { HttpStatus, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; import { PaymentsService } from './payments.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; @@ -27,6 +28,7 @@ export class PaymentsController { constructor(private readonly paymentsService: PaymentsService) {} @Post('create-intent') + @Throttle({ default: { limit: 10, ttl: 3600000 } }) // 10 requests per hour @Roles(UserRole.STUDENT, UserRole.TEACHER) @ApiOperation({ summary: 'Create a payment intent for course purchase' }) @ApiResponse({ status: 201, description: 'Payment intent created' }) @@ -35,6 +37,7 @@ export class PaymentsController { } @Post('subscriptions') + @Throttle({ default: { limit: 5, ttl: 3600000 } }) // 5 requests per hour @Roles(UserRole.STUDENT, UserRole.TEACHER) @ApiOperation({ summary: 'Create a subscription for premium course' }) @ApiResponse({ status: 201, description: 'Subscription created' })