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 { uploadFile, deleteFile, getPresignedUrl } from '../config/minio'; import { CreateCourseInput, UpdateCourseInput, createCourseResponse, GetMyCourseResponse, ListMyCourseResponse, addinstructorCourse, addinstructorCourseResponse, removeinstructorCourse, removeinstructorCourseResponse, setprimaryCourseInstructor, setprimaryCourseInstructorResponse, submitCourseResponse, listinstructorCourseResponse, sendCourseForReview, getmyCourse, listinstructorCourse, SearchInstructorInput, SearchInstructorResponse, GetEnrolledStudentsInput, GetEnrolledStudentsResponse, GetQuizScoresInput, GetQuizScoresResponse, GetQuizAttemptDetailInput, GetQuizAttemptDetailResponse, GetEnrolledStudentDetailInput, GetEnrolledStudentDetailResponse, } from "../types/CoursesInstructor.types"; export class CoursesInstructorService { static async createCourse(courseData: CreateCourseInput, userId: number, thumbnailFile?: Express.Multer.File): Promise { try { let thumbnailUrl: string | undefined; // Upload thumbnail to MinIO if provided if (thumbnailFile) { const timestamp = Date.now(); const uniqueId = Math.random().toString(36).substring(2, 15); const extension = thumbnailFile.originalname.split('.').pop() || 'jpg'; const safeFilename = `${timestamp}-${uniqueId}.${extension}`; const filePath = `courses/thumbnails/${safeFilename}`; await uploadFile(filePath, thumbnailFile.buffer, thumbnailFile.mimetype || 'image/jpeg'); thumbnailUrl = filePath; } // Use transaction to create course and instructor together const result = await prisma.$transaction(async (tx) => { // Create the course const courseCreated = await tx.course.create({ data: { category_id: courseData.category_id, title: courseData.title, slug: courseData.slug, description: courseData.description, thumbnail_url: thumbnailUrl, price: courseData.price || 0, is_free: courseData.is_free ?? false, have_certificate: courseData.have_certificate ?? false, created_by: userId, status: 'DRAFT' } }); // Add creator as primary instructor await tx.courseInstructor.create({ data: { course_id: courseCreated.id, user_id: userId, is_primary: true, } }); return courseCreated; }); return { code: 201, message: 'Course created successfully', data: result }; } catch (error) { logger.error('Failed to create course', { error }); throw error; } } static async listMyCourses(token: string): Promise { try { const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; const courseInstructors = await prisma.courseInstructor.findMany({ where: { user_id: decoded.id }, include: { course: true } }); const courses = await Promise.all( courseInstructors.map(async (ci) => { let thumbnail_presigned_url: string | null = null; if (ci.course.thumbnail_url) { try { thumbnail_presigned_url = await getPresignedUrl(ci.course.thumbnail_url, 3600); } catch (err) { logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`); } } return { ...ci.course, thumbnail_url: thumbnail_presigned_url, }; }) ); return { code: 200, message: 'Courses retrieved successfully', data: courses, total: courses.length }; } catch (error) { logger.error('Failed to retrieve courses', { error }); throw error; } } static async getmyCourse(getmyCourse: getmyCourse): Promise { try { const decoded = jwt.verify(getmyCourse.token, config.jwt.secret) as { id: number; type: string }; // Check if user is instructor of this course const courseInstructor = await prisma.courseInstructor.findFirst({ where: { user_id: decoded.id, course_id: getmyCourse.course_id }, include: { course: { include: { chapters: { include: { lessons: { include: { attachments: true, progress: true, quiz: true } } } } } } } }); if (!courseInstructor) { throw new ForbiddenError('You are not an instructor of this course'); } // Generate presigned URL for thumbnail let thumbnail_presigned_url: string | null = null; if (courseInstructor.course.thumbnail_url) { try { thumbnail_presigned_url = await getPresignedUrl(courseInstructor.course.thumbnail_url, 3600); } catch (err) { logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`); } } return { code: 200, message: 'Course retrieved successfully', data: { ...courseInstructor.course, thumbnail_url: thumbnail_presigned_url, } }; } catch (error) { logger.error('Failed to retrieve course', { error }); throw error; } } static async updateCourse(token: string, courseId: number, courseData: UpdateCourseInput): Promise { try { await this.validateCourseInstructor(token, courseId); const course = await prisma.course.update({ where: { id: courseId }, data: courseData }); return { code: 200, message: 'Course updated successfully', data: course }; } catch (error) { logger.error('Failed to update course', { error }); throw error; } } static async uploadThumbnail(token: string, courseId: number, file: Express.Multer.File): Promise<{ code: number; message: string; data: { course_id: number; thumbnail_url: string } }> { try { await this.validateCourseInstructor(token, courseId); // Get current course to check for existing thumbnail const currentCourse = await prisma.course.findUnique({ where: { id: courseId } }); // Delete old thumbnail if exists if (currentCourse?.thumbnail_url) { try { await deleteFile(currentCourse.thumbnail_url); logger.info(`Deleted old thumbnail: ${currentCourse.thumbnail_url}`); } catch (error) { logger.warn(`Failed to delete old thumbnail: ${error}`); } } // Generate unique filename const timestamp = Date.now(); const uniqueId = Math.random().toString(36).substring(2, 15); const extension = file.originalname.split('.').pop() || 'jpg'; const safeFilename = `${timestamp}-${uniqueId}.${extension}`; const filePath = `courses/${courseId}/thumbnail/${safeFilename}`; // Upload to MinIO await uploadFile(filePath, file.buffer, file.mimetype || 'image/jpeg'); logger.info(`Uploaded thumbnail: ${filePath}`); // Update course with new thumbnail path await prisma.course.update({ where: { id: courseId }, data: { thumbnail_url: filePath } }); // Generate presigned URL for response const presignedUrl = await getPresignedUrl(filePath, 3600); return { code: 200, message: 'Thumbnail uploaded successfully', data: { course_id: courseId, thumbnail_url: presignedUrl } }; } catch (error) { logger.error('Failed to upload thumbnail', { error }); throw error; } } static async deleteCourse(token: string, courseId: number): Promise { try { const courseInstructorId = await this.validateCourseInstructor(token, courseId); if (!courseInstructorId.is_primary) { throw new ForbiddenError('You have no permission to delete this course'); } const course = await prisma.course.delete({ where: { id: courseId } }); return { code: 200, message: 'Course deleted successfully', data: course }; } catch (error) { logger.error('Failed to delete course', { error }); throw error; } } static async sendCourseForReview(sendCourseForReview: sendCourseForReview): Promise { try { const decoded = jwt.verify(sendCourseForReview.token, config.jwt.secret) as { id: number; type: string }; await prisma.courseApproval.create({ data: { course_id: sendCourseForReview.course_id, submitted_by: decoded.id, } }); await prisma.course.update({ where: { id: sendCourseForReview.course_id }, data: { status: 'PENDING' } }); return { code: 200, message: 'Course sent for review successfully', }; } catch (error) { logger.error('Failed to send course for review', { error }); throw error; } } static async getCourseApprovals(token: string, courseId: number): Promise<{ code: number; message: string; data: any[]; total: number; }> { try { const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; // Validate instructor access await this.validateCourseInstructor(token, courseId); const approvals = await prisma.courseApproval.findMany({ where: { course_id: courseId }, orderBy: { created_at: 'desc' }, include: { submitter: { select: { id: true, username: true, email: true } }, reviewer: { select: { id: true, username: true, email: true } } } }); return { code: 200, message: 'Course approvals retrieved successfully', data: approvals, total: approvals.length, }; } catch (error) { logger.error('Failed to retrieve course approvals', { error }); throw error; } } static async searchInstructors(input: SearchInstructorInput): Promise { try { const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number }; // Get existing instructors in the course const existingInstructors = await prisma.courseInstructor.findMany({ where: { course_id: input.course_id }, select: { user_id: true }, }); const existingInstructorIds = existingInstructors.map(i => i.user_id); // Search all instructors by email or username, excluding self and existing course instructors const users = await prisma.user.findMany({ where: { OR: [ { email: { contains: input.query, mode: 'insensitive' } }, { username: { contains: input.query, mode: 'insensitive' } }, ], role: { code: 'INSTRUCTOR' }, id: { notIn: [decoded.id, ...existingInstructorIds], }, }, include: { profile: true }, take: 10 }); const results = await Promise.all(users.map(async (user) => { let avatar_url: string | null = null; if (user.profile?.avatar_url) { try { avatar_url = await getPresignedUrl(user.profile.avatar_url, 3600); } catch (err) { logger.warn(`Failed to generate presigned URL for avatar: ${err}`); } } return { id: user.id, username: user.username, email: user.email, first_name: user.profile?.first_name || null, last_name: user.profile?.last_name || null, avatar_url, }; })); return { code: 200, message: 'Instructors found', data: results, }; } catch (error) { logger.error('Failed to search instructors', { error }); throw error; } } static async addInstructorToCourse(addinstructorCourse: addinstructorCourse): Promise { try { // Validate user is instructor of this course await this.validateCourseInstructor(addinstructorCourse.token, addinstructorCourse.course_id); // Find user by email or username const user = await prisma.user.findFirst({ where: { OR: [ { email: addinstructorCourse.email_or_username }, { username: addinstructorCourse.email_or_username }, ], role: { code: 'INSTRUCTOR' } } }); if (!user) { throw new NotFoundError('Instructor not found with this email or username'); } // Check if already added const existing = await prisma.courseInstructor.findUnique({ where: { course_id_user_id: { course_id: addinstructorCourse.course_id, user_id: user.id, } } }); if (existing) { throw new ValidationError('This instructor is already added to the course'); } await prisma.courseInstructor.create({ data: { course_id: addinstructorCourse.course_id, user_id: user.id, } }); return { code: 200, message: 'Instructor added to course successfully', }; } catch (error) { logger.error('Failed to add instructor to course', { error }); throw error; } } static async removeInstructorFromCourse(removeinstructorCourse: removeinstructorCourse): Promise { try { const decoded = jwt.verify(removeinstructorCourse.token, config.jwt.secret) as { id: number; type: string }; await prisma.courseInstructor.delete({ where: { course_id_user_id: { course_id: removeinstructorCourse.course_id, user_id: removeinstructorCourse.user_id, }, } }); return { code: 200, message: 'Instructor removed from course successfully', }; } catch (error) { logger.error('Failed to remove instructor from course', { error }); throw error; } } static async listInstructorsOfCourse(listinstructorCourse: listinstructorCourse): Promise { try { const decoded = jwt.verify(listinstructorCourse.token, config.jwt.secret) as { id: number; type: string }; const courseInstructors = await prisma.courseInstructor.findMany({ where: { course_id: listinstructorCourse.course_id, }, include: { user: { include: { profile: true } }, } }); const data = await Promise.all(courseInstructors.map(async (ci) => { let avatar_url: string | null = null; if (ci.user.profile?.avatar_url) { try { avatar_url = await getPresignedUrl(ci.user.profile.avatar_url, 3600); } catch (err) { logger.warn(`Failed to generate presigned URL for avatar: ${err}`); } } return { user_id: ci.user_id, is_primary: ci.is_primary, user: { id: ci.user.id, username: ci.user.username, email: ci.user.email, first_name: ci.user.profile?.first_name || null, last_name: ci.user.profile?.last_name || null, avatar_url, }, }; })); return { code: 200, message: 'Instructors retrieved successfully', data, }; } catch (error) { logger.error('Failed to retrieve instructors of course', { error }); throw error; } } static async setPrimaryInstructor(setprimaryCourseInstructor: setprimaryCourseInstructor): Promise { try { const decoded = jwt.verify(setprimaryCourseInstructor.token, config.jwt.secret) as { id: number; type: string }; await prisma.courseInstructor.update({ where: { course_id_user_id: { course_id: setprimaryCourseInstructor.course_id, user_id: setprimaryCourseInstructor.user_id, }, }, data: { is_primary: true, } }); return { code: 200, message: 'Primary instructor set successfully', }; } catch (error) { logger.error('Failed to set primary instructor', { error }); throw error; } } static async validateCourseInstructor(token: string, courseId: number): Promise<{ user_id: number; is_primary: boolean }> { const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; const courseInstructor = await prisma.courseInstructor.findFirst({ where: { user_id: decoded.id, course_id: courseId } }); if (!courseInstructor) { throw new ForbiddenError('You are not an instructor of this course'); } else return { user_id: courseInstructor.user_id, is_primary: courseInstructor.is_primary }; } static async validateCourseStatus(courseId: number): Promise { const course = await prisma.course.findUnique({ where: { id: courseId } }); if (!course) { throw new NotFoundError('Course not found'); } if (course.status === 'APPROVED' || course.status === 'PENDING') { throw new ForbiddenError('Course is already approved Cannot Edit'); } } /** * ดึงรายชื่อนักเรียนที่ลงทะเบียนในคอร์สพร้อม progress * Get all enrolled students with their progress */ static async getEnrolledStudents(input: GetEnrolledStudentsInput): Promise { try { const { token, course_id, page = 1, limit = 20, search, status } = input; const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; // Validate instructor await this.validateCourseInstructor(token, course_id); // Build where clause const whereClause: any = { course_id }; // Add status filter if (status) { whereClause.status = status; } // Add search filter if (search) { whereClause.OR = [ { user: { username: { contains: search, mode: 'insensitive' } } }, { user: { email: { contains: search, mode: 'insensitive' } } }, { user: { profile: { first_name: { contains: search, mode: 'insensitive' } } } }, { user: { profile: { last_name: { contains: search, mode: 'insensitive' } } } }, ]; } // Get enrollments with user data const skip = (page - 1) * limit; const [enrollments, total] = await Promise.all([ prisma.enrollment.findMany({ where: whereClause, include: { user: { include: { profile: true, }, }, }, orderBy: { enrolled_at: 'desc' }, skip, take: limit, }), prisma.enrollment.count({ where: whereClause }), ]); // Format response with presigned URLs for avatars const studentsData = await Promise.all( enrollments.map(async (enrollment) => { let avatarUrl: string | null = null; if (enrollment.user.profile?.avatar_url) { try { avatarUrl = await getPresignedUrl(enrollment.user.profile.avatar_url, 3600); } catch (err) { logger.warn(`Failed to generate presigned URL for avatar: ${err}`); } } return { user_id: enrollment.user.id, username: enrollment.user.username, email: enrollment.user.email, first_name: enrollment.user.profile?.first_name || null, last_name: enrollment.user.profile?.last_name || null, avatar_url: avatarUrl, enrolled_at: enrollment.enrolled_at, progress_percentage: Number(enrollment.progress_percentage) || 0, status: enrollment.status, }; }) ); return { code: 200, message: 'Enrolled students retrieved successfully', data: studentsData, total, page, limit, }; } catch (error) { logger.error(`Error getting enrolled students: ${error}`); throw error; } } /** * ดึงคะแนนสอบของนักเรียนทุกคนในแต่ละ lesson quiz * Get quiz scores of all students for a specific lesson */ static async getQuizScores(input: GetQuizScoresInput): Promise { try { const { token, course_id, lesson_id, page = 1, limit = 20, search, is_passed } = input; const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; // Validate instructor await this.validateCourseInstructor(token, course_id); // 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'); } // Calculate total score from questions const totalScore = lesson.quiz.questions.reduce((sum, q) => sum + q.score, 0); // Get all enrolled students who have attempted this quiz const skip = (page - 1) * limit; // Get unique users who attempted this quiz const quizAttempts = await prisma.quizAttempt.findMany({ where: { quiz_id: lesson.quiz.id }, include: { user: { include: { profile: true, }, }, }, orderBy: { completed_at: 'desc' }, }); // Group attempts by user const userAttemptsMap = new Map(); for (const attempt of quizAttempts) { const userId = attempt.user_id; if (!userAttemptsMap.has(userId)) { userAttemptsMap.set(userId, []); } userAttemptsMap.get(userId)!.push(attempt); } // Get unique users and apply filters let filteredUserIds = Array.from(userAttemptsMap.keys()); // Apply search filter if (search) { filteredUserIds = filteredUserIds.filter(userId => { const userAttempts = userAttemptsMap.get(userId)!; const user = userAttempts[0].user; const searchLower = search.toLowerCase(); return ( user.username.toLowerCase().includes(searchLower) || user.email.toLowerCase().includes(searchLower) || (user.profile?.first_name?.toLowerCase().includes(searchLower)) || (user.profile?.last_name?.toLowerCase().includes(searchLower)) ); }); } // Apply is_passed filter if (is_passed !== undefined) { filteredUserIds = filteredUserIds.filter(userId => { const userAttempts = userAttemptsMap.get(userId)!; const userIsPassed = userAttempts.some(a => a.is_passed); return userIsPassed === is_passed; }); } // Paginate const total = filteredUserIds.length; const paginatedUserIds = filteredUserIds.slice(skip, skip + limit); // Format response const studentsData = await Promise.all( paginatedUserIds.map(async (userId) => { const userAttempts = userAttemptsMap.get(userId)!; const user = userAttempts[0].user; let avatarUrl: string | null = null; if (user.profile?.avatar_url) { try { avatarUrl = await getPresignedUrl(user.profile.avatar_url, 3600); } catch (err) { logger.warn(`Failed to generate presigned URL for avatar: ${err}`); } } // Get latest attempt (sorted by attempt_number desc, first one is latest) const latestAttempt = userAttempts[0]; const bestScore = Math.max(...userAttempts.map(a => a.score)); const isPassed = userAttempts.some(a => a.is_passed); return { user_id: user.id, username: user.username, email: user.email, first_name: user.profile?.first_name || null, last_name: user.profile?.last_name || null, avatar_url: avatarUrl, latest_attempt: latestAttempt ? { id: latestAttempt.id, score: latestAttempt.score, total_score: totalScore, is_passed: latestAttempt.is_passed, attempt_number: latestAttempt.attempt_number, completed_at: latestAttempt.completed_at, } : null, best_score: bestScore, total_attempts: userAttempts.length, is_passed: isPassed, }; }) ); return { code: 200, message: 'Quiz scores 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, total_score: totalScore, students: studentsData, }, total, page, limit, }; } catch (error) { logger.error(`Error getting quiz scores: ${error}`); throw error; } } /** * ดูรายละเอียดการทำข้อสอบของนักเรียนแต่ละคน * Get quiz attempt detail for a specific student */ static async getQuizAttemptDetail(input: GetQuizAttemptDetailInput): Promise { try { const { token, course_id, lesson_id, student_id } = input; const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; // Validate instructor await this.validateCourseInstructor(token, course_id); // 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, }, orderBy: { sort_order: 'asc' }, }, }, }, 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'); if (lesson.chapter.course_id !== course_id) throw new NotFoundError('Lesson not found in this course'); // Get student info const student = await prisma.user.findUnique({ where: { id: student_id }, include: { profile: true }, }); if (!student) throw new NotFoundError('Student not found'); // Get latest quiz attempt const quizAttempt = await prisma.quizAttempt.findFirst({ where: { user_id: student_id, quiz_id: lesson.quiz.id, }, orderBy: { attempt_number: 'desc' }, }); if (!quizAttempt) throw new NotFoundError('Quiz attempt not found'); // Calculate total score from questions const totalScore = lesson.quiz.questions.reduce((sum, q) => sum + q.score, 0); // Parse answers from quiz attempt const studentAnswers = (quizAttempt.answers as any[]) || []; // Build answers review const answersReview = lesson.quiz.questions.map(question => { const studentAnswer = studentAnswers.find((a: any) => a.question_id === question.id); const selectedChoiceId = studentAnswer?.choice_id || null; const selectedChoice = selectedChoiceId ? question.choices.find(c => c.id === selectedChoiceId) : null; // Check if selected choice is correct from Choice model const isCorrect = selectedChoice?.is_correct || false; // Score is question.score if correct, otherwise 0 const earnedScore = isCorrect ? question.score : 0; return { question_id: question.id, sort_order: question.sort_order, question_text: question.question as { th: string; en: string }, selected_choice_id: selectedChoiceId, selected_choice_text: selectedChoice ? selectedChoice.text as { th: string; en: string } : null, is_correct: isCorrect, score: earnedScore, question_score: question.score, }; }); return { code: 200, message: 'Quiz attempt detail retrieved successfully', data: { attempt_id: quizAttempt.id, quiz_id: quizAttempt.quiz_id, student: { user_id: student.id, username: student.username, email: student.email, first_name: student.profile?.first_name || null, last_name: student.profile?.last_name || null, }, score: quizAttempt.score, total_score: totalScore, total_questions: quizAttempt.total_questions, correct_answers: quizAttempt.correct_answers, is_passed: quizAttempt.is_passed, passing_score: lesson.quiz.passing_score, attempt_number: quizAttempt.attempt_number, started_at: quizAttempt.started_at, completed_at: quizAttempt.completed_at, answers_review: answersReview, }, }; } catch (error) { logger.error(`Error getting quiz attempt detail: ${error}`); throw error; } } /** * ดึงรายละเอียดการเรียนของนักเรียนแต่ละคน * Get enrolled student detail with lesson progress */ static async getEnrolledStudentDetail(input: GetEnrolledStudentDetailInput): Promise { try { const { token, course_id, student_id } = input; // Validate instructor await this.validateCourseInstructor(token, course_id); // Get student info const student = await prisma.user.findUnique({ where: { id: student_id }, include: { profile: true }, }); if (!student) throw new NotFoundError('Student not found'); // Get enrollment const enrollment = await prisma.enrollment.findUnique({ where: { unique_enrollment: { user_id: student_id, course_id, }, }, }); if (!enrollment) throw new NotFoundError('Student is not enrolled in this course'); // Get course with chapters and lessons const course = await prisma.course.findUnique({ where: { id: course_id }, include: { chapters: { orderBy: { sort_order: 'asc' }, include: { lessons: { orderBy: { sort_order: 'asc' }, }, }, }, }, }); if (!course) throw new NotFoundError('Course not found'); // Get all lesson progress for this student in this course const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id)); const lessonProgressList = await prisma.lessonProgress.findMany({ where: { user_id: student_id, lesson_id: { in: lessonIds }, }, }); // Create a map for quick lookup const progressMap = new Map(lessonProgressList.map(p => [p.lesson_id, p])); // Build chapters with lesson progress let totalCompletedLessons = 0; let totalLessons = 0; const chaptersData = course.chapters.map(chapter => { const lessonsData = chapter.lessons.map(lesson => { const progress = progressMap.get(lesson.id); totalLessons++; if (progress?.is_completed) { totalCompletedLessons++; } return { lesson_id: lesson.id, lesson_title: lesson.title as { th: string; en: string }, lesson_type: lesson.type, sort_order: lesson.sort_order, is_completed: progress?.is_completed || false, completed_at: progress?.completed_at || null, video_progress_seconds: progress?.video_progress_seconds || null, video_duration_seconds: progress?.video_duration_seconds || null, video_progress_percentage: progress?.video_progress_percentage ? Number(progress.video_progress_percentage) : null, last_watched_at: progress?.last_watched_at || null, }; }); const completedLessons = lessonsData.filter(l => l.is_completed).length; return { chapter_id: chapter.id, chapter_title: chapter.title as { th: string; en: string }, sort_order: chapter.sort_order, lessons: lessonsData, completed_lessons: completedLessons, total_lessons: lessonsData.length, }; }); // Get avatar URL let avatarUrl: string | null = null; if (student.profile?.avatar_url) { try { avatarUrl = await getPresignedUrl(student.profile.avatar_url, 3600); } catch (err) { logger.warn(`Failed to generate presigned URL for avatar: ${err}`); } } return { code: 200, message: 'Enrolled student detail retrieved successfully', data: { student: { user_id: student.id, username: student.username, email: student.email, first_name: student.profile?.first_name || null, last_name: student.profile?.last_name || null, avatar_url: avatarUrl, }, enrollment: { enrolled_at: enrollment.enrolled_at, progress_percentage: Number(enrollment.progress_percentage) || 0, status: enrollment.status, }, chapters: chaptersData, total_completed_lessons: totalCompletedLessons, total_lessons: totalLessons, }, }; } catch (error) { logger.error(`Error getting enrolled student detail: ${error}`); throw error; } } }