diff --git a/package.json b/package.json index 613e45a8..61dd1002 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,7 @@ "csurf": "^1.2.2", "dataloader": "^2.2.3", "express": "^5.2.1", + "express-openapi-validator": "^5.3.9", "express-session": "^1.19.0", "fast-xml-parser": "^5.2.5", "fluent-ffmpeg": "^2.1.3", diff --git a/src/app.controller.ts b/src/app.controller.ts index 8ed9f082..367c8511 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -19,6 +19,10 @@ export class AppController { }, }) getStatus() { - return { message: 'TeachLink API is running', timestamp: new Date().toISOString() }; + return { + success: true, + message: 'TeachLink API is running', + data: { timestamp: new Date().toISOString() } + }; } } diff --git a/src/courses/dto/bulk-enrollment.dto.ts b/src/courses/dto/bulk-enrollment.dto.ts new file mode 100644 index 00000000..81d854a2 --- /dev/null +++ b/src/courses/dto/bulk-enrollment.dto.ts @@ -0,0 +1,25 @@ +import { IsArray, ArrayMaxSize, ArrayMinSize, ValidateNested, IsUUID, IsNotEmpty } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +export class EnrollmentItemDto { + @ApiProperty({ description: 'User ID to enroll' }) + @IsUUID('4', { message: 'userId must be a valid UUID v4' }) + @IsNotEmpty() + userId: string; + + @ApiProperty({ description: 'Course ID to enroll into' }) + @IsUUID('4', { message: 'courseId must be a valid UUID v4' }) + @IsNotEmpty() + courseId: string; +} + +export class BulkEnrollmentDto { + @ApiProperty({ type: [EnrollmentItemDto], description: 'Array of enrollments' }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(500, { message: 'Cannot enroll more than 500 users at once' }) + @ValidateNested({ each: true }) + @Type(() => EnrollmentItemDto) + enrollments: EnrollmentItemDto[]; +} diff --git a/src/courses/enrollments.controller.ts b/src/courses/enrollments.controller.ts index f8f4aa0b..a8f20a6b 100644 --- a/src/courses/enrollments.controller.ts +++ b/src/courses/enrollments.controller.ts @@ -14,6 +14,8 @@ import { import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { EnrollmentsService } from './enrollments.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { Throttle } from '@nestjs/throttler'; +import { BulkEnrollmentDto } from './dto/bulk-enrollment.dto'; @ApiTags('enrollments') @Controller('enrollments') @@ -22,6 +24,15 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; export class EnrollmentsController { constructor(private readonly enrollmentsService: EnrollmentsService) {} + @Post('bulk') + @Throttle({ default: { limit: 1, ttl: 60000 } }) + @ApiOperation({ summary: 'Bulk enroll users into courses' }) + @ApiResponse({ status: 201, description: 'Bulk enrollment processed' }) + @ApiResponse({ status: 400, description: 'Validation failed' }) + async bulkEnroll(@Body() bulkDto: BulkEnrollmentDto) { + return this.enrollmentsService.bulkEnroll(bulkDto.enrollments); + } + @Post(':courseId') @ApiOperation({ summary: 'Enroll in a course' }) @ApiResponse({ status: 201, description: 'Successfully enrolled in course' }) diff --git a/src/courses/enrollments.service.bulk.spec.ts b/src/courses/enrollments.service.bulk.spec.ts new file mode 100644 index 00000000..adb19a95 --- /dev/null +++ b/src/courses/enrollments.service.bulk.spec.ts @@ -0,0 +1,113 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EnrollmentsService } from './enrollments.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { Enrollment } from './entities/enrollment.entity'; +import { Course, CourseStatus } from './entities/course.entity'; +import { EventEmitter2 } from '@nestjs/event-emitter'; + +describe('EnrollmentsService - Bulk Enroll', () => { + let service: EnrollmentsService; + let mockQueryRunner: any; + let mockDataSource: any; + + beforeEach(async () => { + mockQueryRunner = { + connect: jest.fn(), + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn(), + release: jest.fn(), + manager: { + getRepository: jest.fn(), + }, + }; + + mockDataSource = { + createQueryRunner: jest.fn().mockReturnValue(mockQueryRunner), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EnrollmentsService, + { + provide: getRepositoryToken(Enrollment), + useValue: {}, + }, + { + provide: getRepositoryToken(Course), + useValue: {}, + }, + { + provide: EventEmitter2, + useValue: { + emit: jest.fn(), + }, + }, + { + provide: DataSource, + useValue: mockDataSource, + }, + ], + }).compile(); + + service = module.get(EnrollmentsService); + }); + + it('should enroll users and commit transaction on success', async () => { + const mockEnrollmentRepo = { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn().mockImplementation((dto) => dto), + save: jest.fn().mockResolvedValue({ id: 'enr1' }), + }; + + const mockCourseRepo = { + findOne: jest.fn().mockResolvedValue({ id: 'c1', status: CourseStatus.PUBLISHED }), + }; + + mockQueryRunner.manager.getRepository.mockImplementation((entity: any) => { + if (entity === Enrollment) return mockEnrollmentRepo; + if (entity === Course) return mockCourseRepo; + return null; + }); + + const result = await service.bulkEnroll([{ userId: 'u1', courseId: 'c1' }]); + + expect(result.enrolled).toBe(1); + expect(result.failed).toBe(0); + expect(mockQueryRunner.commitTransaction).toHaveBeenCalled(); + expect(mockQueryRunner.rollbackTransaction).not.toHaveBeenCalled(); + }); + + it('should rollback transaction on partial failure', async () => { + const mockEnrollmentRepo = { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn().mockImplementation((dto) => dto), + save: jest.fn().mockResolvedValue({ id: 'enr1' }), + }; + + const mockCourseRepo = { + findOne: jest.fn().mockImplementation(({ where }) => { + if (where.id === 'c1') return Promise.resolve({ id: 'c1', status: CourseStatus.PUBLISHED }); + return Promise.resolve(null); // c2 fails + }), + }; + + mockQueryRunner.manager.getRepository.mockImplementation((entity: any) => { + if (entity === Enrollment) return mockEnrollmentRepo; + if (entity === Course) return mockCourseRepo; + return null; + }); + + const result = await service.bulkEnroll([ + { userId: 'u1', courseId: 'c1' }, + { userId: 'u2', courseId: 'c2' }, // will fail because course not found + ]); + + expect(result.enrolled).toBe(0); + expect(result.failed).toBe(1); + expect(result.errors).toHaveLength(1); + expect(mockQueryRunner.rollbackTransaction).toHaveBeenCalled(); + expect(mockQueryRunner.commitTransaction).not.toHaveBeenCalled(); + }); +}); diff --git a/src/courses/enrollments.service.ts b/src/courses/enrollments.service.ts index d7421ddc..42223e6d 100644 --- a/src/courses/enrollments.service.ts +++ b/src/courses/enrollments.service.ts @@ -94,6 +94,101 @@ export class EnrollmentsService { }); } + /** + * Bulk enroll users. + */ + async bulkEnroll(enrollments: { userId: string; courseId: string }[]): Promise<{ enrolled: number; skipped: number; failed: number; errors: any[] }> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + let enrolledCount = 0; + let skippedCount = 0; + let failedCount = 0; + const errors: any[] = []; + const successfulEnrollments: any[] = []; + + try { + const enrollmentRepo = queryRunner.manager.getRepository(Enrollment); + const courseRepo = queryRunner.manager.getRepository(Course); + + for (const item of enrollments) { + const { userId, courseId } = item; + try { + const course = await courseRepo.findOne({ + where: { id: courseId }, + relations: ['prerequisite'], + }); + + if (!course) { + failedCount++; + errors.push({ userId, courseId, error: `Course ${courseId} not found` }); + continue; + } + + if (course.status !== CourseStatus.PUBLISHED) { + failedCount++; + errors.push({ userId, courseId, error: `Cannot enroll in course with status "${course.status}".` }); + continue; + } + + const existing = await enrollmentRepo.findOne({ + where: { userId, courseId }, + }); + + if (existing) { + skippedCount++; + errors.push({ userId, courseId, error: 'User is already enrolled in this course' }); + continue; + } + + await this.validatePrerequisites(userId, course, enrollmentRepo); + + const enrollment = enrollmentRepo.create({ + userId, + courseId, + status: APP_CONSTANTS.ENROLLMENT_STATUS.ACTIVE, + progress: 0, + }); + + const saved = await enrollmentRepo.save(enrollment); + enrolledCount++; + successfulEnrollments.push(saved); + + } catch (error) { + failedCount++; + errors.push({ userId, courseId, error: error instanceof Error ? error.message : 'Unknown error' }); + } + } + + if (failedCount > 0) { + await queryRunner.rollbackTransaction(); + enrolledCount = 0; + } else { + await queryRunner.commitTransaction(); + + // Emit events for successful enrollments after commit + for (const saved of successfulEnrollments) { + this.eventEmitter.emit(CACHE_EVENTS.ENROLLMENT_CREATED, { id: saved.id }); + this.eventEmitter.emit(APP_EVENTS.COURSE_ENROLLED, { + userId: saved.userId, + courseId: saved.courseId, + }); + } + if (enrolledCount > 0) { + this.logger.log(`Bulk enrolled ${enrolledCount} users successfully`); + } + } + + return { enrolled: enrolledCount, skipped: skippedCount, failed: failedCount, errors }; + } catch (err) { + await queryRunner.rollbackTransaction(); + throw err; + } finally { + await queryRunner.release(); + } + } + /** * Validate prerequisite completion. */ diff --git a/src/main.ts b/src/main.ts index ad3bdec5..a993801c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,6 +9,8 @@ import { RedisStore } from 'connect-redis'; import Redis from 'ioredis'; import { AppModule } from './app.module'; +import * as OpenApiValidator from 'express-openapi-validator'; +import { join } from 'path'; import './tracing/opentelemetry'; import { CorrelationIdMiddleware } from './middleware/correlation-id'; @@ -311,6 +313,40 @@ async function bootstrapWorker(): Promise { // ========================= app.useGlobalInterceptors(new LocaleInterceptor(), new PaginationInterceptor()); + // ========================= + // OPENAPI VALIDATION + // ========================= + const apiSpecPath = join(process.cwd(), 'docs/api/openapi-spec.json'); + app.use( + OpenApiValidator.middleware({ + apiSpec: apiSpecPath, + validateRequests: true, + validateResponses: process.env.NODE_ENV !== 'production', + ignorePaths: /.*\/api\/docs.*/, // ignore swagger docs + }), + ); + + app.use((err: any, req: Request, res: Response, next: NextFunction) => { + if (err.status === 400 && err.errors) { + return res.status(400).json({ + success: false, + message: 'Validation failed', + errors: err.errors.map((e: any) => ({ + field: e.path, + message: e.message, + })), + }); + } + if (err.status === 500 && err.errors && typeof err.message === 'string' && err.message.toLowerCase().includes('response')) { + logger.warn(`Response validation deviation: ${JSON.stringify(err.errors)}`); + return res.status(500).json({ + success: false, + message: 'Internal server error', + }); + } + next(err); + }); + // ========================= // SWAGGER // =========================