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, } from "../types/CoursesStudent.types"; export class CoursesStudentService { 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 enrollment = await prisma.enrollment.create({ data: { course_id, user_id: decoded.id, status: 'ENROLLED', enrolled_at: new Date(), }, }); 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 = enrollments.map(enrollment => ({ 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: enrollment.course.thumbnail_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; 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: course.thumbnail_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 }; // 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 const lesson = await prisma.lesson.findUnique({ where: { id: lesson_id }, include: { attachments: { orderBy: { sort_order: 'asc' }, }, quiz: true, 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; 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, attachments: lesson.attachments.map(att => ({ id: att.id, file_name: att.file_name, file_path: att.file_path, file_size: att.file_size, mime_type: att.mime_type, description: att.description as { th: string; en: string } | null, })), quiz: lesson.quiz ? { 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, } : 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; } } }