Skip to content
Open
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 @@
},
})
getStatus() {
return { message: 'TeachLink API is running', timestamp: new Date().toISOString() };
return {

Check failure on line 22 in src/app.controller.ts

View workflow job for this annotation

GitHub Actions / validate

Delete `·`
success: true,

Check failure on line 23 in src/app.controller.ts

View workflow job for this annotation

GitHub Actions / validate

Delete `·`
message: 'TeachLink API is running',

Check failure on line 24 in src/app.controller.ts

View workflow job for this annotation

GitHub Actions / validate

Delete `·`
data: { timestamp: new Date().toISOString() }

Check failure on line 25 in src/app.controller.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `·` with `,`
};
}
}
25 changes: 25 additions & 0 deletions src/courses/dto/bulk-enrollment.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { IsArray, ArrayMaxSize, ArrayMinSize, ValidateNested, IsUUID, IsNotEmpty } from 'class-validator';

Check failure on line 1 in src/courses/dto/bulk-enrollment.dto.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `·IsArray,·ArrayMaxSize,·ArrayMinSize,·ValidateNested,·IsUUID,·IsNotEmpty·` with `⏎··IsArray,⏎··ArrayMaxSize,⏎··ArrayMinSize,⏎··ValidateNested,⏎··IsUUID,⏎··IsNotEmpty,⏎`
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();
});
});
95 changes: 95 additions & 0 deletions src/courses/enrollments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,101 @@
});
}

/**
* Bulk enroll users.
*/
async bulkEnroll(enrollments: { userId: string; courseId: string }[]): Promise<{ enrolled: number; skipped: number; failed: number; errors: any[] }> {

Check failure on line 100 in src/courses/enrollments.service.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `enrollments:·{·userId:·string;·courseId:·string·}[]` with `⏎····enrollments:·{·userId:·string;·courseId:·string·}[],⏎··`
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}".` });

Check failure on line 131 in src/courses/enrollments.service.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `·userId,·courseId,·error:·`Cannot·enroll·in·course·with·status·"${course.status}".`` with `⏎··············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);

Check failure on line 157 in src/courses/enrollments.service.ts

View workflow job for this annotation

GitHub Actions / validate

Delete `⏎`
} catch (error) {
failedCount++;
errors.push({ userId, courseId, error: error instanceof Error ? error.message : 'Unknown error' });

Check failure on line 160 in src/courses/enrollments.service.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `·userId,·courseId,·error:·error·instanceof·Error·?·error.message·:·'Unknown·error'` with `⏎············userId,⏎············courseId,⏎············error:·error·instanceof·Error·?·error.message·:·'Unknown·error',⏎·········`
}
}

if (failedCount > 0) {
await queryRunner.rollbackTransaction();
enrolledCount = 0;
} else {
await queryRunner.commitTransaction();

Check failure on line 169 in src/courses/enrollments.service.ts

View workflow job for this annotation

GitHub Actions / validate

Delete `········`
// 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
36 changes: 36 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,40 @@ 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
Loading