From 0308995d8e79b72e6cb0012f7496b70a60c64d23 Mon Sep 17 00:00:00 2001 From: JakkrapartXD Date: Mon, 19 Jan 2026 17:08:06 +0700 Subject: [PATCH] feat: Implement lesson access control logic including enrollment, prerequisite, and quiz completion checks. --- .DS_Store | Bin 6148 -> 6148 bytes .../controllers/CoursesStudentController.ts | 205 +++++++ .../src/services/CoursesStudent.service.ts | 535 ++++++++++++++++++ Backend/src/types/CoursesStudent.types.ts | 21 +- 4 files changed, 760 insertions(+), 1 deletion(-) diff --git a/.DS_Store b/.DS_Store index 53552f984ebd54d2300e1903e09928af2047ed92..70b525a0cfaf652b6fd0eda46b722cc6315640a9 100644 GIT binary patch delta 398 zcmZoMXfc=|#>B!ku~2NHo}wrR0|Nsi1A_nqLn=caLyBikesWUIW<}=Z%)THgb_OSg zM22LBY-H*DB%lmX2g7!A4ToZP(pZm?ktj6ipR0WXw>Q9VEgn#0P1i}G^v^U{Gb zjGK9wgc<7v7~B|&81jLhDuKA2L6^Y?$Vvo?<^ky=$8%G&KG347otv zc|e>F)SnABwFJ!+S(qtI|Nn0+bYa}g&cV+C3~gYDerKM{FXG4n^b`|N5yR#Pku}T! D%v4$N delta 87 zcmZoMXfc=|#>B)qu~2NHo}wr-0|Nsi1A_pAXHI@{QcivnkT0;Ya5*C*^JaZkVaClO m9KtLU8?J6<=iui6YTGQx@tt`xzlb9TP$5Vs%jO7?HOv4@84`v7 diff --git a/Backend/src/controllers/CoursesStudentController.ts b/Backend/src/controllers/CoursesStudentController.ts index e69de29b..45c12da8 100644 --- a/Backend/src/controllers/CoursesStudentController.ts +++ b/Backend/src/controllers/CoursesStudentController.ts @@ -0,0 +1,205 @@ +import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Security, Path, Request, Query } from 'tsoa'; +import { ValidationError } from '../middleware/errorHandler'; +import { CoursesStudentService } from '../services/CoursesStudent.service'; +import { + EnrollCourseResponse, + ListEnrolledCoursesResponse, + GetCourseLearningResponse, + GetLessonContentResponse, + CheckLessonAccessResponse, + SaveVideoProgressResponse, + SaveVideoProgressBody, + GetVideoProgressResponse, + CompleteLessonResponse, +} from '../types/CoursesStudent.types'; +import { EnrollmentStatus } from '@prisma/client'; + +@Route('api/students') +@Tags('CoursesStudent') +export class CoursesStudentController { + + private service = new CoursesStudentService(); + + /** + * ลงทะเบียนเรียนในคอร์ส + * Enroll in a course + * @param courseId - รหัสคอร์ส / Course ID + */ + @Post('courses/{courseId}/enroll') + @Security('jwt', ['student']) + @SuccessResponse('200', 'Enrolled successfully') + @Response('401', 'Invalid or expired token') + @Response('404', 'Course not found') + @Response('409', 'Already enrolled in this course') + public async enrollCourse(@Request() request: any, @Path() courseId: number): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) { + throw new ValidationError('No token provided'); + } + return await this.service.enrollCourse({ token, course_id: courseId }); + } + + /** + * ดึงรายการคอร์สที่ลงทะเบียนเรียน + * Get list of enrolled courses + * @param page - หน้าที่ต้องการดึง / Page number + * @param limit - จำนวนรายการต่อหน้า / Items per page + * @param status - สถานะการลงทะเบียน / Enrollment status + */ + @Get('courses') + @Security('jwt', ['student']) + @SuccessResponse('200', 'Enrolled courses retrieved successfully') + @Response('401', 'Invalid or expired token') + public async getEnrolledCourses( + @Request() request: any, + @Query() page?: number, + @Query() limit?: number, + @Query() status?: EnrollmentStatus + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) { + throw new ValidationError('No token provided'); + } + return await this.service.GetEnrolledCourses({ token, page, limit, status }); + } + + /** + * ดึงข้อมูลหน้าเรียนคอร์ส (พร้อมสถานะการล็อคบทเรียน) + * Get course learning page with lesson lock status + * @param courseId - รหัสคอร์ส / Course ID + */ + @Get('courses/{courseId}/learn') + @Security('jwt', ['student']) + @SuccessResponse('200', 'Course learning data retrieved successfully') + @Response('401', 'Invalid or expired token') + @Response('403', 'Not enrolled in this course') + @Response('404', 'Course not found') + public async getCourseLearning(@Request() request: any, @Path() courseId: number): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) { + throw new ValidationError('No token provided'); + } + return await this.service.getCourseLearning({ token, course_id: courseId }); + } + + /** + * ดึงเนื้อหาบทเรียน (ตรวจสอบเงื่อนไขก่อนหน้า) + * Get lesson content (checks prerequisites) + * @param courseId - รหัสคอร์ส / Course ID + * @param lessonId - รหัสบทเรียน / Lesson ID + */ + @Get('courses/{courseId}/lessons/{lessonId}') + @Security('jwt', ['student']) + @SuccessResponse('200', 'Lesson content retrieved successfully') + @Response('401', 'Invalid or expired token') + @Response('403', 'Not enrolled in this course or lesson is locked') + @Response('404', 'Lesson not found') + public async getLessonContent( + @Request() request: any, + @Path() courseId: number, + @Path() lessonId: number + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) { + throw new ValidationError('No token provided'); + } + return await this.service.getlessonContent({ token, course_id: courseId, lesson_id: lessonId }); + } + + /** + * ตรวจสอบสิทธิ์เข้าถึงบทเรียน (ไม่โหลดเนื้อหา) + * Check lesson access without loading content + * @param courseId - รหัสคอร์ส / Course ID + * @param lessonId - รหัสบทเรียน / Lesson ID + */ + @Get('courses/{courseId}/lessons/{lessonId}/access-check') + @Security('jwt', ['student']) + @SuccessResponse('200', 'Access check completed') + @Response('401', 'Invalid or expired token') + @Response('404', 'Lesson not found') + public async checkLessonAccess( + @Request() request: any, + @Path() courseId: number, + @Path() lessonId: number + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) { + throw new ValidationError('No token provided'); + } + return await this.service.checkAccessLesson({ token, course_id: courseId, lesson_id: lessonId }); + } + + /** + * บันทึกความคืบหน้าการดูวิดีโอ + * Save video progress + * @param lessonId - รหัสบทเรียน / Lesson ID + */ + @Post('lessons/{lessonId}/progress') + @Security('jwt', ['student']) + @SuccessResponse('200', 'Video progress saved successfully') + @Response('401', 'Invalid or expired token') + @Response('403', 'Not enrolled in this course') + @Response('404', 'Lesson not found') + public async saveVideoProgress( + @Request() request: any, + @Path() lessonId: number, + @Body() body: SaveVideoProgressBody + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) { + throw new ValidationError('No token provided'); + } + return await this.service.saveVideoProgress({ + token, + lesson_id: lessonId, + video_progress_seconds: body.video_progress_seconds, + video_duration_seconds: body.video_duration_seconds, + }); + } + + /** + * ดึงความคืบหน้าการดูวิดีโอ + * Get video progress + * @param lessonId - รหัสบทเรียน / Lesson ID + */ + @Get('lessons/{lessonId}/progress') + @Security('jwt', ['student']) + @SuccessResponse('200', 'Video progress retrieved successfully') + @Response('401', 'Invalid or expired token') + @Response('403', 'Not enrolled in this course') + @Response('404', 'Lesson not found') + public async getVideoProgress( + @Request() request: any, + @Path() lessonId: number + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) { + throw new ValidationError('No token provided'); + } + return await this.service.getVideoProgress({ token, lesson_id: lessonId }); + } + + /** + * ทำเครื่องหมายบทเรียนว่าเรียนจบ + * Mark lesson as complete + * @param courseId - รหัสคอร์ส / Course ID + * @param lessonId - รหัสบทเรียน / Lesson ID + */ + @Post('courses/{courseId}/lessons/{lessonId}/complete') + @Security('jwt', ['student']) + @SuccessResponse('200', 'Lesson marked as complete') + @Response('401', 'Invalid or expired token') + @Response('403', 'Not enrolled in this course') + @Response('404', 'Lesson not found') + public async completeLesson( + @Request() request: any, + @Path() courseId: number, + @Path() lessonId: number + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) { + throw new ValidationError('No token provided'); + } + return await this.service.completeLesson({ token, lesson_id: lessonId }); + } +} diff --git a/Backend/src/services/CoursesStudent.service.ts b/Backend/src/services/CoursesStudent.service.ts index 28a7a661..90d1c5a8 100644 --- a/Backend/src/services/CoursesStudent.service.ts +++ b/Backend/src/services/CoursesStudent.service.ts @@ -19,6 +19,8 @@ import { SaveVideoProgressResponse, GetVideoProgressInput, GetVideoProgressResponse, + CompleteLessonInput, + CompleteLessonResponse, } from "../types/CoursesStudent.types"; export class CoursesStudentService { @@ -363,4 +365,537 @@ export class CoursesStudentService { throw error; } } + + async checkAccessLesson(input: CheckLessonAccessInput): Promise { + try { + const { token, course_id, lesson_id } = input; + const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; + + // Check enrollment + const enrollment = await prisma.enrollment.findUnique({ + where: { + unique_enrollment: { + user_id: decoded.id, + course_id, + }, + }, + }); + + if (!enrollment) { + return { + code: 200, + message: 'Not enrolled in this course', + data: { + is_accessible: false, + is_enrolled: false, + is_locked: true, + lock_reason: 'You are not enrolled in this course', + }, + }; + } + + // Get lesson with prerequisite info + const lesson = await prisma.lesson.findUnique({ + where: { id: lesson_id }, + select: { + id: true, + title: true, + is_sequential: true, + prerequisite_lesson_ids: true, + require_pass_quiz: true, + chapter: { + select: { + course_id: true, + }, + }, + }, + }); + + if (!lesson) { + throw new NotFoundError('Lesson not found'); + } + + // Verify lesson belongs to the course + if (lesson.chapter.course_id !== course_id) { + throw new ForbiddenError('Lesson does not belong to this course'); + } + + // If not sequential, allow access + if (!lesson.is_sequential) { + return { + code: 200, + message: 'Lesson is accessible', + data: { + is_accessible: true, + is_enrolled: true, + is_locked: false, + }, + }; + } + + const prerequisiteIds = lesson.prerequisite_lesson_ids as number[] | null; + + // If no prerequisites, allow access + if (!prerequisiteIds || prerequisiteIds.length === 0) { + return { + code: 200, + message: 'Lesson is accessible', + data: { + is_accessible: true, + is_enrolled: true, + is_locked: false, + }, + }; + } + + // Get prerequisite lessons info + const prerequisiteLessons = await prisma.lesson.findMany({ + where: { + id: { in: prerequisiteIds }, + }, + select: { + id: true, + title: true, + require_pass_quiz: true, + quiz: { + select: { + id: true, + title: true, + }, + }, + }, + }); + + // Get user's progress for prerequisite lessons + const prerequisiteProgress = await prisma.lessonProgress.findMany({ + where: { + user_id: decoded.id, + lesson_id: { in: prerequisiteIds }, + }, + }); + + const progressMap = new Map(prerequisiteProgress.map(p => [p.lesson_id, p])); + + // Check if all prerequisites are completed + const requiredLessons: { id: number; title: { th: string; en: string }; is_completed: boolean }[] = []; + let allCompleted = true; + + for (const prereqLesson of prerequisiteLessons) { + const progress = progressMap.get(prereqLesson.id); + const isCompleted = progress?.is_completed ?? false; + + if (!isCompleted) { + allCompleted = false; + } + + requiredLessons.push({ + id: prereqLesson.id, + title: prereqLesson.title as { th: string; en: string }, + is_completed: isCompleted, + }); + } + + // Check if any prerequisite requires passing quiz + let requiredQuizPass: { lesson_id: number; quiz_id: number; title: { th: string; en: string }; is_passed: boolean } | undefined; + + for (const prereqLesson of prerequisiteLessons) { + if (prereqLesson.require_pass_quiz && prereqLesson.quiz) { + // Check if user passed the quiz + const quizAttempt = await prisma.quizAttempt.findFirst({ + where: { + user_id: decoded.id, + quiz_id: prereqLesson.quiz.id, + is_passed: true, + }, + }); + + if (!quizAttempt) { + allCompleted = false; + requiredQuizPass = { + lesson_id: prereqLesson.id, + quiz_id: prereqLesson.quiz.id, + title: prereqLesson.quiz.title as { th: string; en: string }, + is_passed: false, + }; + break; + } + } + } + + if (allCompleted) { + return { + code: 200, + message: 'Lesson is accessible', + data: { + is_accessible: true, + is_enrolled: true, + is_locked: false, + }, + }; + } + + // Not all prerequisites completed + return { + code: 200, + message: 'Lesson is locked', + data: { + is_accessible: false, + is_enrolled: true, + is_locked: true, + lock_reason: 'กรุณาเรียนบทเรียนก่อนหน้าให้ครบก่อน', + required_lessons: requiredLessons, + required_quiz_pass: requiredQuizPass, + }, + }; + } catch (error) { + logger.error(error); + throw error; + } + } + + async getVideoProgress(input: GetVideoProgressInput): Promise { + try { + const { token, lesson_id } = input; + const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; + + // Get lesson to find course_id + const lesson = await prisma.lesson.findUnique({ + where: { id: lesson_id }, + select: { + id: true, + chapter: { + select: { course_id: true }, + }, + }, + }); + + if (!lesson) { + throw new NotFoundError('Lesson not found'); + } + + const course_id = lesson.chapter.course_id; + + // Check enrollment + const enrollment = await prisma.enrollment.findUnique({ + where: { + unique_enrollment: { + user_id: decoded.id, + course_id, + }, + }, + }); + + if (!enrollment) { + throw new ForbiddenError('You are not enrolled in this course'); + } + + // Get progress + const progress = await prisma.lessonProgress.findUnique({ + where: { + user_id_lesson_id: { + user_id: decoded.id, + lesson_id, + }, + }, + }); + + // Return null if no progress found + if (!progress) { + return { + code: 200, + message: 'No video progress found', + data: null, + }; + } + + return { + code: 200, + message: 'Video progress retrieved successfully', + data: { + lesson_id: progress.lesson_id, + video_progress_seconds: progress.video_progress_seconds, + video_duration_seconds: progress.video_duration_seconds, + video_progress_percentage: progress.video_progress_percentage ? Number(progress.video_progress_percentage) : null, + is_completed: progress.is_completed, + completed_at: progress.completed_at, + last_watched_at: progress.last_watched_at, + }, + }; + } catch (error) { + logger.error(error); + throw error; + } + } + + async saveVideoProgress(input: SaveVideoProgressInput): Promise { + try { + const { token, lesson_id, video_progress_seconds, video_duration_seconds } = input; + const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; + + // Get lesson to find course_id + const lesson = await prisma.lesson.findUnique({ + where: { id: lesson_id }, + select: { + id: true, + chapter: { + select: { course_id: true }, + }, + }, + }); + + if (!lesson) { + throw new NotFoundError('Lesson not found'); + } + + const course_id = lesson.chapter.course_id; + + // Check enrollment + const enrollment = await prisma.enrollment.findUnique({ + where: { + unique_enrollment: { + user_id: decoded.id, + course_id, + }, + }, + }); + + if (!enrollment) { + throw new ForbiddenError('You are not enrolled in this course'); + } + + // Calculate progress percentage (avoid division by zero) + const progressPercentage = video_duration_seconds && video_duration_seconds > 0 + ? (video_progress_seconds / video_duration_seconds) * 100 + : null; + + // Auto-complete at >= 90% + const isCompleted = progressPercentage !== null && progressPercentage >= 90; + + // Save progress + const progress = await prisma.lessonProgress.upsert({ + where: { + user_id_lesson_id: { + user_id: decoded.id, + lesson_id, + }, + }, + create: { + user_id: decoded.id, + lesson_id, + video_progress_seconds, + video_duration_seconds: video_duration_seconds ?? null, + video_progress_percentage: progressPercentage, + is_completed: isCompleted, + completed_at: isCompleted ? new Date() : null, + last_watched_at: new Date(), + }, + update: { + video_progress_seconds, + video_duration_seconds: video_duration_seconds ?? null, + video_progress_percentage: progressPercentage, + // Only set completed if not already completed + is_completed: isCompleted ? true : undefined, + completed_at: isCompleted ? new Date() : undefined, + last_watched_at: new Date(), + }, + }); + + return { + code: 200, + message: 'Video progress saved successfully', + data: { + lesson_id: progress.lesson_id, + video_progress_seconds: progress.video_progress_seconds, + video_duration_seconds: progress.video_duration_seconds, + video_progress_percentage: progress.video_progress_percentage ? Number(progress.video_progress_percentage) : null, + is_completed: progress.is_completed, + last_watched_at: progress.last_watched_at!, + }, + }; + } catch (error) { + logger.error(error); + throw error; + } + } + + async completeLesson(input: CompleteLessonInput): Promise { + try { + const { token, lesson_id } = input; + const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; + + // Get lesson with chapter and course info + const lesson = await prisma.lesson.findUnique({ + where: { id: lesson_id }, + select: { + id: true, + sort_order: true, + chapter: { + select: { + id: true, + course_id: true, + sort_order: true, + lessons: { + where: { is_published: true }, + orderBy: { sort_order: 'asc' }, + select: { id: true, sort_order: true }, + }, + course: { + select: { + have_certificate: true, + chapters: { + where: { is_published: true }, + orderBy: { sort_order: 'asc' }, + include: { + lessons: { + where: { is_published: true }, + orderBy: { sort_order: 'asc' }, + select: { id: true }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!lesson) { + throw new NotFoundError('Lesson not found'); + } + + const course_id = lesson.chapter.course_id; + + // Check enrollment + const enrollment = await prisma.enrollment.findUnique({ + where: { + unique_enrollment: { + user_id: decoded.id, + course_id, + }, + }, + }); + + if (!enrollment) { + throw new ForbiddenError('You are not enrolled in this course'); + } + + // Complete lesson + const progress = await prisma.lessonProgress.upsert({ + where: { + user_id_lesson_id: { + user_id: decoded.id, + lesson_id, + }, + }, + create: { + user_id: decoded.id, + lesson_id, + is_completed: true, + completed_at: new Date(), + }, + update: { + is_completed: true, + completed_at: new Date(), + }, + }); + + // Get all lesson IDs in the course + const allLessons = lesson.chapter.course.chapters.flatMap(ch => ch.lessons.map(l => l.id)); + const totalLessons = allLessons.length; + + // Get completed lessons count + const completedLessons = await prisma.lessonProgress.count({ + where: { + user_id: decoded.id, + lesson_id: { in: allLessons }, + is_completed: true, + }, + }); + + // Calculate course progress percentage + const course_progress_percentage = totalLessons > 0 + ? Math.round((completedLessons / totalLessons) * 100) + : 0; + + // Check if course is completed + const is_course_completed = completedLessons >= totalLessons; + + // Find next lesson + const currentChapterLessons = lesson.chapter.lessons; + const currentLessonIndex = currentChapterLessons.findIndex(l => l.id === lesson_id); + let next_lesson_id: number | null = null; + + if (currentLessonIndex < currentChapterLessons.length - 1) { + // Next lesson in current chapter + next_lesson_id = currentChapterLessons[currentLessonIndex + 1].id; + } else { + // Find next chapter's first lesson + const allChapters = lesson.chapter.course.chapters; + const currentChapterIndex = allChapters.findIndex(ch => ch.id === lesson.chapter.id); + if (currentChapterIndex < allChapters.length - 1) { + const nextChapter = allChapters[currentChapterIndex + 1]; + if (nextChapter.lessons.length > 0) { + next_lesson_id = nextChapter.lessons[0].id; + } + } + } + + // Update enrollment progress + await prisma.enrollment.update({ + where: { id: enrollment.id }, + data: { + progress_percentage: course_progress_percentage, + ...(is_course_completed ? { + status: 'COMPLETED', + completed_at: new Date(), + } : {}), + }, + }); + + // Issue certificate if course completed and has certificate + let certificate_issued: boolean | undefined; + if (is_course_completed && lesson.chapter.course.have_certificate) { + // Check if certificate already exists + const existingCertificate = await prisma.certificate.findFirst({ + where: { + user_id: decoded.id, + course_id, + }, + }); + + if (!existingCertificate) { + await prisma.certificate.create({ + data: { + user_id: decoded.id, + course_id, + enrollment_id: enrollment.id, + file_path: `certificates/${course_id}/${decoded.id}/${Date.now()}.pdf`, + issued_at: new Date(), + }, + }); + certificate_issued = true; + } else { + certificate_issued = false; + } + } + + return { + code: 200, + message: 'Lesson completed successfully', + data: { + lesson_id: progress.lesson_id, + is_completed: progress.is_completed, + completed_at: progress.completed_at!, + course_progress_percentage, + is_course_completed, + next_lesson_id, + certificate_issued, + }, + }; + } catch (error) { + logger.error(error); + throw error; + } + } } \ No newline at end of file diff --git a/Backend/src/types/CoursesStudent.types.ts b/Backend/src/types/CoursesStudent.types.ts index d0d769ec..9efa163a 100644 --- a/Backend/src/types/CoursesStudent.types.ts +++ b/Backend/src/types/CoursesStudent.types.ts @@ -286,4 +286,23 @@ export interface SaveVideoProgressBody { export interface EnrollCourseBody { course_id: number; -} \ No newline at end of file +} + +export interface CompleteLessonInput { + token: string; + lesson_id: number; +} + +export interface CompleteLessonResponse { + code: number; + message: string; + data?: { + lesson_id: number; + is_completed: boolean; + completed_at: Date; + course_progress_percentage: number; + is_course_completed: boolean; + next_lesson_id: number | null; + certificate_issued?: boolean; + }; +}