From e8b2eb6924f604dd76a8b2032c693f3e51268174 Mon Sep 17 00:00:00 2001 From: Vvictor-commits Date: Mon, 27 Apr 2026 13:27:15 +0100 Subject: [PATCH] feat(lessons): add GET /:id endpoint with hasQuiz flag and fix N+1 on course list - Added GET /lessons/:id protected by JwtAuthGuard, returns full lesson with hasQuiz and quizId - Added LessonsService.findOneWithQuizFlag(id) fetches lesson then single quiz lookup by lessonId - Updated findAllByCoursePaginated to batch-fetch all quizzes for the course in one IN query (no N+1) - LessonResponseDto now includes hasQuiz: boolean and quizId: string | null - Injected Quiz repository into LessonsModule and LessonsService - Route order preserved: /course/:courseId declared before /:id to prevent shadowing - Added @ApiTags and @ApiOperation Swagger decorators --- .../src/lessons/dto/lesson-response.dto.ts | 4 ++ backend/src/lessons/lessons.controller.ts | 8 +++- backend/src/lessons/lessons.module.ts | 3 +- backend/src/lessons/lessons.service.ts | 39 +++++++++++++++++-- 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/backend/src/lessons/dto/lesson-response.dto.ts b/backend/src/lessons/dto/lesson-response.dto.ts index f83a659..986a008 100644 --- a/backend/src/lessons/dto/lesson-response.dto.ts +++ b/backend/src/lessons/dto/lesson-response.dto.ts @@ -16,6 +16,8 @@ export class LessonResponseDto { videoStartTimestamp: number | null; order: number; courseId: string; + hasQuiz: boolean; + quizId: string | null; createdAt: Date; updatedAt: Date; @@ -27,6 +29,8 @@ export class LessonResponseDto { this.videoStartTimestamp = lesson.videoStartTimestamp || null; this.order = lesson.order; this.courseId = lesson.courseId; + this.hasQuiz = lesson.hasQuiz ?? false; + this.quizId = lesson.quizId ?? null; this.createdAt = lesson.createdAt; this.updatedAt = lesson.updatedAt; } diff --git a/backend/src/lessons/lessons.controller.ts b/backend/src/lessons/lessons.controller.ts index 1e9b44f..b43d426 100644 --- a/backend/src/lessons/lessons.controller.ts +++ b/backend/src/lessons/lessons.controller.ts @@ -11,8 +11,8 @@ import { HttpStatus, Query, } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { LessonsService } from './lessons.service'; - import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { RolesGuard } from '../common/guards/roles.guard'; import { PaginationDto } from '../common/dto/pagination.dto'; @@ -22,6 +22,7 @@ import { CreateLessonDto } from './dto/create-lesson.dto'; import { LessonResponseDto } from './dto/lesson-response.dto'; import { UpdateLessonDto } from './dto/update-lesson.dto'; +@ApiTags('lessons') @Controller('lessons') export class LessonsController { constructor(private readonly lessonsService: LessonsService) {} @@ -53,6 +54,7 @@ export class LessonsController { }; } + // NOTE: /course/:courseId must be declared BEFORE /:id to avoid shadowing @Get('course/:courseId') async findByCourse( @Param('courseId') courseId: string, @@ -78,8 +80,10 @@ export class LessonsController { } @Get(':id') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Get a lesson by ID with hasQuiz flag' }) async findOne(@Param('id') id: string): Promise { - const lesson = await this.lessonsService.findOne(id); + const lesson = await this.lessonsService.findOneWithQuizFlag(id); return new LessonResponseDto(lesson); } diff --git a/backend/src/lessons/lessons.module.ts b/backend/src/lessons/lessons.module.ts index f6b0632..43bade2 100644 --- a/backend/src/lessons/lessons.module.ts +++ b/backend/src/lessons/lessons.module.ts @@ -3,13 +3,14 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { PassportModule } from '@nestjs/passport'; import { Course } from 'src/courses/entities/course.entity'; import { Lesson } from './entities/lesson.entity'; +import { Quiz } from '../quizzes/entities/quiz.entity'; import { LessonsController } from './lessons.controller'; import { LessonsService } from './lessons.service'; import { AuthModule } from '../auth/auth.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Lesson, Course]), + TypeOrmModule.forFeature([Lesson, Course, Quiz]), PassportModule, forwardRef(() => AuthModule), ], diff --git a/backend/src/lessons/lessons.service.ts b/backend/src/lessons/lessons.service.ts index 40c0784..9c6c30a 100644 --- a/backend/src/lessons/lessons.service.ts +++ b/backend/src/lessons/lessons.service.ts @@ -1,8 +1,9 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Course } from 'src/courses/entities/course.entity'; +import { Quiz } from 'src/quizzes/entities/quiz.entity'; import { Lesson } from './entities/lesson.entity'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { PaginationService } from '../common/services/pagination.service'; import { PaginatedResult } from '../common/services/pagination.service'; import { CreateLessonDto } from './dto/create-lesson.dto'; @@ -15,6 +16,8 @@ export class LessonsService { private lessonRepository: Repository, @InjectRepository(Course) private courseRepository: Repository, + @InjectRepository(Quiz) + private quizRepository: Repository, private readonly paginationService: PaginationService, ) {} @@ -76,7 +79,7 @@ export class LessonsService { courseId: string, page: number, limit: number, - ): Promise> { + ): Promise> { const course = await this.courseRepository.findOne({ where: { id: courseId }, }); @@ -85,7 +88,7 @@ export class LessonsService { throw new NotFoundException(`Course with ID ${courseId} not found`); } - return this.paginationService.paginate( + const result = await this.paginationService.paginate( this.lessonRepository, { page, limit }, { @@ -93,6 +96,21 @@ export class LessonsService { order: { order: 'ASC', createdAt: 'ASC' }, }, ); + + const lessonIds = result.data.map((l) => l.id); + const quizzes = lessonIds.length + ? await this.quizRepository.find({ where: { lessonId: In(lessonIds) }, select: ['id', 'lessonId'] }) + : []; + const quizMap = new Map(quizzes.map((q) => [q.lessonId, q.id])); + + return { + ...result, + data: result.data.map((l) => ({ + ...l, + hasQuiz: quizMap.has(l.id), + quizId: quizMap.get(l.id) ?? null, + })), + }; } async findOne(id: string): Promise { @@ -108,6 +126,21 @@ export class LessonsService { return lesson; } + async findOneWithQuizFlag(id: string): Promise { + const lesson = await this.lessonRepository.findOne({ + where: { id }, + relations: ['course'], + }); + + if (!lesson) { + throw new NotFoundException(`Lesson with ID ${id} not found`); + } + + const quiz = await this.quizRepository.findOne({ where: { lessonId: id }, select: ['id'] }); + + return { ...lesson, hasQuiz: !!quiz, quizId: quiz?.id ?? null }; + } + async update(id: string, updateLessonDto: UpdateLessonDto): Promise { const lesson = await this.lessonRepository.findOne({ where: { id },