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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion src/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
};
}
}
32 changes: 32 additions & 0 deletions src/courses/dto/bulk-enrollment.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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[];
}
11 changes: 11 additions & 0 deletions src/courses/enrollments.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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' })
Expand Down
113 changes: 113 additions & 0 deletions src/courses/enrollments.service.bulk.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(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();
});
});
104 changes: 104 additions & 0 deletions src/courses/enrollments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,110 @@ 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.
*/
Expand Down
41 changes: 41 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -311,6 +313,45 @@ async function bootstrapWorker(): Promise<void> {
// =========================
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
// =========================
Expand Down
2 changes: 1 addition & 1 deletion src/workers/base/base.worker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Logger, Inject } from '@nestjs/common';
import { Logger } from '@nestjs/common';
import { Job } from 'bull';
import Redis from 'ioredis';
import { getSharedRedisClient } from '../../config/cache.config';
Expand Down
2 changes: 1 addition & 1 deletion src/workers/orchestration/worker-orchestration.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy, Inject } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Job } from 'bull';
import { BaseWorker } from '../base/base.worker';
Expand Down
Loading