import { prisma } from '../config/database'; import { Prisma } from '@prisma/client'; import { config } from '../config'; import { logger } from '../config/logger'; import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler'; import jwt from 'jsonwebtoken'; import { EnrollCourseInput, EnrollCourseResponse, ListEnrolledCoursesInput, ListEnrolledCoursesResponse, GetCourseLearningInput, GetCourseLearningResponse, GetLessonContentInput, GetLessonContentResponse, CheckLessonAccessInput, CheckLessonAccessResponse, SaveVideoProgressInput, SaveVideoProgressResponse, GetVideoProgressInput, GetVideoProgressResponse, CompleteLessonInput, CompleteLessonResponse, SubmitQuizInput, SubmitQuizResponse, GetQuizAttemptsInput, GetQuizAttemptsResponse, } from "../types/CoursesStudent.types"; import { getPresignedUrl, listObjects, getVideoFolder, getAttachmentsFolder } from '../config/minio'; import { auditService } from './audit.service'; import { AuditAction } from '@prisma/client'; export class CoursesStudentService { /** * Mark lesson as complete and update enrollment progress * Shared function for submitQuiz, saveVideoProgress, completeLesson */ private async markLessonComplete(userId: number, lessonId: number, courseId: number): Promise<{ lessonProgress: { lesson_id: number; is_completed: boolean; completed_at: Date | null }; enrollmentProgress: { progress_percentage: number; is_course_completed: boolean }; }> { const now = new Date(); // Upsert lesson progress const lessonProgress = await prisma.lessonProgress.upsert({ where: { user_id_lesson_id: { user_id: userId, lesson_id: lessonId, }, }, create: { user_id: userId, lesson_id: lessonId, is_completed: true, completed_at: now, }, update: { is_completed: true, completed_at: now, }, }); // Get all lessons in the course const course = await prisma.course.findUnique({ where: { id: courseId }, include: { chapters: { include: { lessons: { where: { is_published: true }, select: { id: true }, }, }, }, }, }); if (!course) { throw new NotFoundError('Course not found'); } const allLessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id)); const totalLessons = allLessonIds.length; // Count completed lessons const completedLessons = await prisma.lessonProgress.count({ where: { user_id: userId, lesson_id: { in: allLessonIds }, is_completed: true, }, }); // Calculate progress percentage const progressPercentage = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0; const isCourseCompleted = completedLessons >= totalLessons; // Update enrollment await prisma.enrollment.update({ where: { unique_enrollment: { user_id: userId, course_id: courseId, }, }, data: { progress_percentage: progressPercentage, ...(isCourseCompleted ? { status: 'COMPLETED', completed_at: now, } : {}), }, }); return { lessonProgress: { lesson_id: lessonProgress.lesson_id, is_completed: lessonProgress.is_completed, completed_at: lessonProgress.completed_at, }, enrollmentProgress: { progress_percentage: progressPercentage, is_course_completed: isCourseCompleted, }, }; } async enrollCourse(input: EnrollCourseInput): Promise { try { const { course_id } = input; const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number; type: string }; const course = await prisma.course.findUnique({ where: { id: course_id }, }); if (!course) throw new NotFoundError('Course not found'); if (course.status !== 'APPROVED') throw new ForbiddenError('Cannot enroll in this course. Course is not available.'); const existingEnrollment = await prisma.enrollment.findUnique({ where: { unique_enrollment: { user_id: decoded.id, course_id, }, }, }); if (existingEnrollment) { throw new ValidationError('Already enrolled in this course'); } const enrollment = await prisma.enrollment.create({ data: { course_id, user_id: decoded.id, status: 'ENROLLED', enrolled_at: new Date(), }, }); // Audit log - ENROLL auditService.log({ userId: decoded.id, action: AuditAction.ENROLL, entityType: 'Enrollment', entityId: enrollment.id, newValue: { course_id, user_id: decoded.id, status: 'ENROLLED' } }); return { code: 200, message: 'Enrollment successful', data: { enrollment_id: enrollment.id, course_id: enrollment.course_id, user_id: enrollment.user_id, status: enrollment.status, enrolled_at: enrollment.enrolled_at, }, }; } catch (error) { logger.error(error); throw error; } } async GetEnrolledCourses(input: ListEnrolledCoursesInput): Promise { try { const { token } = input; const page = input.page ?? 1; const limit = input.limit ?? 20; const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; const enrollments = await prisma.enrollment.findMany({ where: { user_id: decoded.id, }, include: { course: { select: { id: true, title: true, slug: true, thumbnail_url: true, description: true, } } }, skip: (page - 1) * limit, take: limit, }); const total = await prisma.enrollment.count({ where: { user_id: decoded.id, }, }); const data = await Promise.all( enrollments.map(async (enrollment) => { let thumbnail_presigned_url: string | null = null; if (enrollment.course.thumbnail_url) { try { thumbnail_presigned_url = await getPresignedUrl(enrollment.course.thumbnail_url, 3600); } catch (err) { logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`); } } return { id: enrollment.id, course_id: enrollment.course_id, course: { id: enrollment.course.id, title: enrollment.course.title as { th: string; en: string }, slug: enrollment.course.slug, thumbnail_url: thumbnail_presigned_url, description: enrollment.course.description as { th: string; en: string }, }, status: enrollment.status, progress_percentage: enrollment.progress_percentage, enrolled_at: enrollment.enrolled_at, started_at: enrollment.started_at, completed_at: enrollment.completed_at, last_accessed_at: enrollment.last_accessed_at, }; }) ); return { code: 200, message: 'Enrollments retrieved successfully', data, total, page, limit, }; } catch (error) { logger.error(error); throw error; } } async getCourseLearning(input: GetCourseLearningInput): Promise { try { const { token, course_id } = input; const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; // Get course with chapters and lessons (basic info only) const course = await prisma.course.findUnique({ where: { id: course_id }, include: { chapters: { where: { is_published: true }, orderBy: { sort_order: 'asc' }, include: { lessons: { where: { is_published: true }, orderBy: { sort_order: 'asc' }, select: { id: true, chapter_id: true, title: true, type: true, duration_minutes: true, sort_order: true, is_published: true, is_sequential: true, prerequisite_lesson_ids: true, require_pass_quiz: true, }, }, }, }, }, }); if (!course) { throw new ForbiddenError('Course not found'); } // Get 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 all lesson progress for this user and course const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id)); const lessonProgress = await prisma.lessonProgress.findMany({ where: { user_id: decoded.id, lesson_id: { in: lessonIds }, }, }); const progressMap = new Map(lessonProgress.map(p => [p.lesson_id, p])); // Build chapters with lesson lock status const chapters = course.chapters.map(chapter => ({ id: chapter.id, title: chapter.title as { th: string; en: string }, description: chapter.description as { th: string; en: string } | null, sort_order: chapter.sort_order, is_published: chapter.is_published, lessons: chapter.lessons.map(lesson => { const progress = progressMap.get(lesson.id); const isCompleted = progress?.is_completed ?? false; // Check lock status let isLocked = false; let lockReason: string | undefined; if (lesson.is_sequential) { const prereqIds = lesson.prerequisite_lesson_ids as number[] | null; if (prereqIds && prereqIds.length > 0) { const allPrereqCompleted = prereqIds.every(id => { const prereqProgress = progressMap.get(id); return prereqProgress?.is_completed; }); if (!allPrereqCompleted) { isLocked = true; lockReason = 'Complete prerequisite lessons first'; } } } return { id: lesson.id, chapter_id: lesson.chapter_id, title: lesson.title as { th: string; en: string }, type: lesson.type as 'VIDEO' | 'QUIZ', duration_minutes: lesson.duration_minutes, sort_order: lesson.sort_order, is_published: lesson.is_published, is_sequential: lesson.is_sequential, prerequisite_lesson_ids: lesson.prerequisite_lesson_ids as number[] | null, require_pass_quiz: lesson.require_pass_quiz, // Lock status is_locked: isLocked, lock_reason: lockReason, // Progress is_completed: isCompleted, video_progress_percentage: progress?.video_progress_percentage ? Number(progress.video_progress_percentage) : undefined, }; }), })); const total_lessons = lessonIds.length; const completed_lessons = lessonProgress.filter(p => p.is_completed).length; // Generate presigned URL for thumbnail let thumbnail_presigned_url: string | null = null; if (course.thumbnail_url) { try { thumbnail_presigned_url = await getPresignedUrl(course.thumbnail_url, 3600); } catch (err) { logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`); } } return { code: 200, message: 'Course learning retrieved successfully', data: { course: { id: course.id, title: course.title as { th: string; en: string }, slug: course.slug, description: course.description as { th: string; en: string }, thumbnail_url: thumbnail_presigned_url, have_certificate: course.have_certificate, }, enrollment: { status: enrollment.status, progress_percentage: enrollment.progress_percentage, enrolled_at: enrollment.enrolled_at, started_at: enrollment.started_at, completed_at: enrollment.completed_at, }, chapters, total_lessons, completed_lessons, }, }; } catch (error) { logger.error(error); throw error; } } async getlessonContent(input: GetLessonContentInput): Promise { try { const { token, course_id, lesson_id } = input; const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; // Import MinIO functions // 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 lesson with attachments and quiz (including questions and choices) const lesson = await prisma.lesson.findUnique({ where: { id: lesson_id }, include: { attachments: { orderBy: { sort_order: 'asc' }, }, quiz: { include: { questions: { orderBy: { sort_order: 'asc' }, include: { choices: { orderBy: { sort_order: 'asc' }, }, }, }, }, }, chapter: { include: { lessons: { where: { is_published: true }, orderBy: { sort_order: 'asc' }, select: { id: true, sort_order: true }, }, }, }, }, }); if (!lesson) { throw new NotFoundError('Lesson not found'); } // Get user's progress for this lesson const lessonProgress = await prisma.lessonProgress.findUnique({ where: { user_id_lesson_id: { user_id: decoded.id, lesson_id, }, }, }); // Calculate prev/next lesson IDs const allLessons = lesson.chapter.lessons; const currentIndex = allLessons.findIndex(l => l.id === lesson_id); const prevLessonId = currentIndex > 0 ? allLessons[currentIndex - 1].id : null; const nextLessonId = currentIndex < allLessons.length - 1 ? allLessons[currentIndex + 1].id : null; // Get course_id from chapter const chapter_course_id = lesson.chapter.course_id; // Import additional MinIO functions // Using MinIO functions imported above // Get video URL - check for YouTube or MinIO let video_url: string | null = null; const videoAttachment = await prisma.lessonAttachment.findFirst({ where: { lesson_id, sort_order: 0 } }); if (videoAttachment) { if (videoAttachment.mime_type === 'video/youtube') { // YouTube video - build URL from video ID stored in file_path video_url = `https://www.youtube.com/watch?v=${videoAttachment.file_path}`; } else { // MinIO video - get presigned URL try { video_url = await getPresignedUrl(videoAttachment.file_path, 3600); } catch (err) { logger.error(`Failed to get video from MinIO: ${err}`); } } } else { // Fallback: try to get video from MinIO folder (legacy support) try { const videoPrefix = getVideoFolder(chapter_course_id, lesson_id); const videoFiles = await listObjects(videoPrefix); if (videoFiles.length > 0) { video_url = await getPresignedUrl(videoFiles[0].name, 3600); } } catch (err) { logger.error(`Failed to get video from MinIO: ${err}`); } } // Get attachments from MinIO folder const attachmentsWithUrls: { file_name: string; file_path: string; file_size: number; mime_type: string; presigned_url: string | null; }[] = []; try { const attachmentsPrefix = getAttachmentsFolder(chapter_course_id, lesson_id); const attachmentFiles = await listObjects(attachmentsPrefix); for (const file of attachmentFiles) { let presigned_url: string | null = null; try { presigned_url = await getPresignedUrl(file.name, 3600); } catch (err) { logger.error(`Failed to generate presigned URL for ${file.name}: ${err}`); } // Extract filename from path const fileName = file.name.split('/').pop() || file.name; // Guess mime type from extension const ext = fileName.split('.').pop()?.toLowerCase() || ''; const mimeTypes: { [key: string]: string } = { 'pdf': 'application/pdf', 'doc': 'application/msword', 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'ppt': 'application/vnd.ms-powerpoint', 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'xls': 'application/vnd.ms-excel', 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif', 'mp4': 'video/mp4', 'zip': 'application/zip', }; const mime_type = mimeTypes[ext] || 'application/octet-stream'; attachmentsWithUrls.push({ file_name: fileName, file_path: file.name, file_size: file.size, mime_type, presigned_url, }); } } catch (err) { logger.error(`Failed to list attachments from MinIO: ${err}`); } // Check quiz attempts if this is a QUIZ lesson let latestQuizAttempt = null; let shouldReturnQuestions = true; if (lesson.quiz) { // Get latest quiz attempt for this user latestQuizAttempt = await prisma.quizAttempt.findFirst({ where: { user_id: decoded.id, quiz_id: lesson.quiz.id, }, orderBy: { started_at: 'desc', }, }); // If allow_multiple_attempts is false AND user has attempted if (!lesson.quiz.allow_multiple_attempts && latestQuizAttempt) { shouldReturnQuestions = false; } } return { code: 200, message: 'Lesson retrieved successfully', data: { id: lesson.id, chapter_id: lesson.chapter_id, title: lesson.title as { th: string; en: string }, content: lesson.content as { th: string; en: string } | null, type: lesson.type as 'VIDEO' | 'QUIZ', duration_minutes: lesson.duration_minutes, is_sequential: lesson.is_sequential, prerequisite_lesson_ids: lesson.prerequisite_lesson_ids as number[] | null, require_pass_quiz: lesson.require_pass_quiz, video_url, // Presigned URL for video attachments: attachmentsWithUrls, quiz: lesson.quiz ? (shouldReturnQuestions ? { id: lesson.quiz.id, title: lesson.quiz.title as { th: string; en: string }, description: lesson.quiz.description as { th: string; en: string } | null, passing_score: lesson.quiz.passing_score, time_limit: lesson.quiz.time_limit, shuffle_questions: lesson.quiz.shuffle_questions, shuffle_choices: lesson.quiz.shuffle_choices, is_skippable: lesson.quiz.is_skippable, show_answers_after_completion: lesson.quiz.show_answers_after_completion, allow_multiple_attempts: lesson.quiz.allow_multiple_attempts, latest_attempt: latestQuizAttempt ? { score: latestQuizAttempt.score, is_passed: latestQuizAttempt.is_passed, attempt_number: latestQuizAttempt.attempt_number, started_at: latestQuizAttempt.started_at, completed_at: latestQuizAttempt.completed_at, } : undefined, questions: lesson.quiz.questions.map(q => ({ id: q.id, question: q.question as { th: string; en: string }, question_type: q.question_type, score: q.score, sort_order: q.sort_order, choices: q.choices.map(c => ({ id: c.id, text: c.text as { th: string; en: string }, sort_order: c.sort_order, })), })), } : { // Only return quiz metadata and latest score, no questions id: lesson.quiz.id, title: lesson.quiz.title as { th: string; en: string }, description: lesson.quiz.description as { th: string; en: string } | null, passing_score: lesson.quiz.passing_score, allow_multiple_attempts: lesson.quiz.allow_multiple_attempts, latest_attempt: latestQuizAttempt ? { score: latestQuizAttempt.score, is_passed: latestQuizAttempt.is_passed, attempt_number: latestQuizAttempt.attempt_number, started_at: latestQuizAttempt.started_at, completed_at: latestQuizAttempt.completed_at, } : undefined, }) : null, prev_lesson_id: prevLessonId, next_lesson_id: nextLessonId, }, progress: lessonProgress ? { is_completed: lessonProgress.is_completed, video_progress_seconds: lessonProgress.video_progress_seconds, video_duration_seconds: lessonProgress.video_duration_seconds, video_progress_percentage: lessonProgress.video_progress_percentage ? Number(lessonProgress.video_progress_percentage) : null, last_watched_at: lessonProgress.last_watched_at, } : undefined, }; } catch (error) { logger.error(error); 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 >= 95% OR when video_progress_seconds >= video_duration_seconds const isCompleted = (progressPercentage !== null && progressPercentage >= 95) || !!(video_duration_seconds && video_progress_seconds >= video_duration_seconds); // Save video progress (without marking complete yet) 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: false, last_watched_at: new Date(), }, update: { video_progress_seconds, video_duration_seconds: video_duration_seconds ?? null, video_progress_percentage: progressPercentage, last_watched_at: new Date(), }, }); // If video completed, mark lesson as complete and update enrollment progress let enrollmentProgress: { progress_percentage: number; is_course_completed: boolean } | undefined; if (isCompleted) { const result = await this.markLessonComplete(decoded.id, lesson_id, course_id); enrollmentProgress = result.enrollmentProgress; } 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: isCompleted || progress.is_completed, last_watched_at: progress.last_watched_at!, course_progress_percentage: enrollmentProgress?.progress_percentage, is_course_completed: enrollmentProgress?.is_course_completed, }, }; } 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'); } // Mark lesson as complete and update enrollment progress const { lessonProgress, enrollmentProgress } = await this.markLessonComplete(decoded.id, lesson_id, course_id); const { progress_percentage: course_progress_percentage, is_course_completed } = enrollmentProgress; // 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; } } } // 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: lessonProgress.lesson_id, is_completed: lessonProgress.is_completed, completed_at: lessonProgress.completed_at!, course_progress_percentage, is_course_completed, next_lesson_id, certificate_issued, }, }; } catch (error) { logger.error(error); throw error; } } /** * ส่งคำตอบ Quiz และคำนวณคะแนน * Submit quiz answers and calculate score */ async submitQuiz(input: SubmitQuizInput): Promise { try { const { token, course_id, lesson_id, answers } = input; const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; // 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 lesson and verify it's a QUIZ type const lesson = await prisma.lesson.findUnique({ where: { id: lesson_id }, include: { quiz: { include: { questions: { include: { choices: true, }, }, }, }, chapter: true, }, }); if (!lesson) { throw new NotFoundError('Lesson not found'); } if (lesson.type !== 'QUIZ') { throw new ValidationError('This lesson is not a quiz'); } if (!lesson.quiz) { throw new NotFoundError('Quiz not found for this lesson'); } // Verify lesson belongs to the course if (lesson.chapter.course_id !== course_id) { throw new NotFoundError('Lesson not found in this course'); } const quiz = lesson.quiz; // Get previous attempt count const previousAttempts = await prisma.quizAttempt.count({ where: { user_id: decoded.id, quiz_id: quiz.id, }, }); const attemptNumber = previousAttempts + 1; // Calculate score let totalScore = 0; let earnedScore = 0; let correctAnswers = 0; const answersReview: { question_id: number; selected_choice_id: number; correct_choice_id: number; is_correct: boolean; score: number; }[] = []; for (const question of quiz.questions) { totalScore += question.score; // Find the correct choice for this question const correctChoice = question.choices.find(c => c.is_correct); const correctChoiceId = correctChoice?.id ?? 0; // Find student's answer for this question const studentAnswer = answers.find(a => a.question_id === question.id); const selectedChoiceId = studentAnswer?.choice_id ?? 0; // Check if answer is correct const isCorrect = selectedChoiceId === correctChoiceId; if (isCorrect) { earnedScore += question.score; correctAnswers++; } answersReview.push({ question_id: question.id, selected_choice_id: selectedChoiceId, correct_choice_id: correctChoiceId, is_correct: isCorrect, score: isCorrect ? question.score : 0, }); } // Calculate percentage and check if passed const scorePercentage = totalScore > 0 ? (earnedScore / totalScore) * 100 : 0; const isPassed = scorePercentage >= quiz.passing_score; // Create quiz attempt record const now = new Date(); const quizAttempt = await prisma.quizAttempt.create({ data: { user_id: decoded.id, quiz_id: quiz.id, score: earnedScore, total_questions: quiz.questions.length, correct_answers: correctAnswers, is_passed: isPassed, attempt_number: attemptNumber, answers: answers as any, // Store student answers as JSON started_at: now, completed_at: now, }, }); // If passed, mark lesson as complete and update enrollment progress let enrollmentProgress: { progress_percentage: number; is_course_completed: boolean } | undefined; if (isPassed) { const result = await this.markLessonComplete(decoded.id, lesson_id, course_id); enrollmentProgress = result.enrollmentProgress; } // Build response based on show_answers_after_completion setting const showAnswers = quiz.show_answers_after_completion; return { code: 200, message: isPassed ? 'Quiz passed!' : 'Quiz completed', data: { attempt_id: quizAttempt.id, quiz_id: quiz.id, score: earnedScore, total_score: totalScore, total_questions: quiz.questions.length, correct_answers: showAnswers ? correctAnswers : undefined, is_passed: isPassed, passing_score: quiz.passing_score, attempt_number: attemptNumber, started_at: quizAttempt.started_at, completed_at: quizAttempt.completed_at!, answers_review: showAnswers ? answersReview : undefined, course_progress_percentage: enrollmentProgress?.progress_percentage, is_course_completed: enrollmentProgress?.is_course_completed, }, }; } catch (error) { logger.error(error); throw error; } } /** * ดึงคะแนน Quiz ที่เคยทำของนักเรียน * Get student's quiz attempts for a lesson */ async getQuizAttempts(input: GetQuizAttemptsInput): Promise { try { const { token, course_id, lesson_id } = input; const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; // 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 lesson and verify it's a QUIZ type const lesson = await prisma.lesson.findUnique({ where: { id: lesson_id }, include: { quiz: { include: { questions: true, }, }, chapter: true, }, }); if (!lesson) { throw new NotFoundError('Lesson not found'); } if (lesson.type !== 'QUIZ') { throw new ValidationError('This lesson is not a quiz'); } if (!lesson.quiz) { throw new NotFoundError('Quiz not found for this lesson'); } // Verify lesson belongs to the course if (lesson.chapter.course_id !== course_id) { throw new NotFoundError('Lesson not found in this course'); } // Get all quiz attempts for this user const attempts = await prisma.quizAttempt.findMany({ where: { user_id: decoded.id, quiz_id: lesson.quiz.id, }, orderBy: { attempt_number: 'desc' }, }); // Calculate total score from questions const totalScore = lesson.quiz.questions.reduce((sum, q) => sum + q.score, 0); // Format attempts data const attemptsData = attempts.map(attempt => ({ id: attempt.id, quiz_id: attempt.quiz_id, score: attempt.score, total_score: totalScore, total_questions: attempt.total_questions, correct_answers: attempt.correct_answers, is_passed: attempt.is_passed, attempt_number: attempt.attempt_number, started_at: attempt.started_at, completed_at: attempt.completed_at, })); // Find best score and latest attempt const bestScore = attempts.length > 0 ? Math.max(...attempts.map(a => a.score)) : null; const latestAttempt = attemptsData.length > 0 ? attemptsData[0] : null; return { code: 200, message: 'Quiz attempts retrieved successfully', data: { lesson_id: lesson.id, lesson_title: lesson.title as { th: string; en: string }, quiz_id: lesson.quiz.id, quiz_title: lesson.quiz.title as { th: string; en: string }, passing_score: lesson.quiz.passing_score, attempts: attemptsData, best_score: bestScore, latest_attempt: latestAttempt, }, }; } catch (error) { logger.error(error); throw error; } } }