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
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 21 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
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';

Check failure on line 33 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / ESLint

'@nestjs/core' import is duplicated
import { CustomThrottleGuard } from './common/guards/throttle.guard';

@Module({
imports: [
Expand Down Expand Up @@ -67,6 +70,20 @@
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',

Check failure on line 79 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Check

Object literal may only specify known properties, and 'type' does not exist in type 'ThrottlerStorage'.
options: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
},
},
}),
}),
HealthModule,
SyncModule,
MediaModule,
Expand All @@ -88,6 +105,10 @@
provide: APP_INTERCEPTOR,
useClass: MonitoringInterceptor,
},
{
provide: APP_GUARD,
useClass: CustomThrottleGuard,
},
],
})
export class AppModule {}
3 changes: 3 additions & 0 deletions src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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);
Expand Down
27 changes: 24 additions & 3 deletions src/common/guards/throttle.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand All @@ -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 };
}
}
3 changes: 3 additions & 0 deletions src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
6 changes: 5 additions & 1 deletion src/courses/entities/course-module.entity.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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[];
}
6 changes: 6 additions & 0 deletions src/courses/entities/course.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
UpdateDateColumn,
ManyToOne,
OneToMany,
Index,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { CourseModule } from './course-module.entity';
Expand All @@ -26,6 +27,7 @@ export class Course {
price: number;

@Column({ default: 'draft' }) // draft, published, archived
@Index()
status: string;

@Column({ nullable: true })
Expand All @@ -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[];

Expand Down
12 changes: 12 additions & 0 deletions src/courses/entities/enrollment.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,37 @@ 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;

@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()
Expand Down
6 changes: 5 additions & 1 deletion src/courses/entities/lesson.entity.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -23,4 +23,8 @@ export class Lesson {

@ManyToOne(() => CourseModule, (module) => module.lessons, { onDelete: 'CASCADE' })
module: CourseModule;

@Column({ name: 'module_id' })
@Index()
moduleId: string;
}
5 changes: 5 additions & 0 deletions src/payments/entities/payment.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -28,6 +29,7 @@ export enum PaymentMethod {
}

@Entity('payments')
@Index(['userId', 'status'])
export class Payment {
@PrimaryGeneratedColumn('uuid')
id: string;
Expand All @@ -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 })
Expand All @@ -58,13 +61,15 @@ export class Payment {
user: User;

@Column({ name: 'user_id' })
@Index()
userId: string;

@ManyToOne(() => Course, (course) => course.id)
@JoinColumn({ name: 'course_id' })
course: Course;

@Column({ name: 'course_id', nullable: true })
@Index()
courseId: string;

@Column({ type: 'boolean', default: false })
Expand Down
4 changes: 4 additions & 0 deletions src/payments/entities/subscription.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';

Expand All @@ -26,6 +27,7 @@ export enum SubscriptionInterval {
}

@Entity('subscriptions')
@Index(['userId', 'status'])
export class Subscription {
@PrimaryGeneratedColumn('uuid')
id: string;
Expand All @@ -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 })
Expand Down Expand Up @@ -68,6 +71,7 @@ export class Subscription {
user: User;

@Column({ name: 'user_id' })
@Index()
userId: string;

@CreateDateColumn()
Expand Down
3 changes: 3 additions & 0 deletions src/payments/payments.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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' })
Expand All @@ -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' })
Expand Down
Loading