diff --git a/Backend/src/controllers/CoursesInstructorController.ts b/Backend/src/controllers/CoursesInstructorController.ts index 2b0a7b37..b8a1e3a3 100644 --- a/Backend/src/controllers/CoursesInstructorController.ts +++ b/Backend/src/controllers/CoursesInstructorController.ts @@ -20,6 +20,7 @@ import { GetQuizScoresResponse, GetQuizAttemptDetailResponse, SearchStudentsResponse, + GetEnrolledStudentDetailResponse, } from '../types/CoursesInstructor.types'; import { CreateCourseValidator } from "../validators/CoursesInstructor.validator"; @@ -300,6 +301,60 @@ export class CoursesInstructorController { }); } + /** + * ค้นหานักเรียนในคอร์ส + * Search students in course by firstname, lastname, email, or username + * @param courseId - รหัสคอร์ส / Course ID + * @param query - คำค้นหา / Search query + * @param limit - จำนวนผลลัพธ์สูงสุด / Max results + */ + @Get('{courseId}/students/search') + @Security('jwt', ['instructor']) + @SuccessResponse('200', 'Students found successfully') + @Response('401', 'Invalid or expired token') + @Response('403', 'Not an instructor of this course') + public async searchStudents( + @Request() request: any, + @Path() courseId: number, + @Query() query: string, + @Query() limit?: number + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new ValidationError('No token provided'); + return await CoursesInstructorService.searchStudentsInCourse({ + token, + course_id: courseId, + query, + limit, + }); + } + + /** + * ดึงรายละเอียดการเรียนของนักเรียนแต่ละคน (progress ของแต่ละ lesson) + * Get enrolled student detail with lesson progress + * @param courseId - รหัสคอร์ส / Course ID + * @param studentId - รหัสนักเรียน / Student ID + */ + @Get('{courseId}/students/{studentId}') + @Security('jwt', ['instructor']) + @SuccessResponse('200', 'Enrolled student detail retrieved successfully') + @Response('401', 'Invalid or expired token') + @Response('403', 'Not an instructor of this course') + @Response('404', 'Student not found or not enrolled') + public async getEnrolledStudentDetail( + @Request() request: any, + @Path() courseId: number, + @Path() studentId: number + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new ValidationError('No token provided'); + return await CoursesInstructorService.getEnrolledStudentDetail({ + token, + course_id: courseId, + student_id: studentId, + }); + } + /** * ดึงคะแนนสอบของนักเรียนทุกคนในแต่ละ lesson quiz * Get quiz scores of all students for a specific lesson @@ -360,32 +415,4 @@ export class CoursesInstructorController { student_id: studentId, }); } - - /** - * ค้นหานักเรียนในคอร์ส - * Search students in course by firstname, lastname, email, or username - * @param courseId - รหัสคอร์ส / Course ID - * @param query - คำค้นหา / Search query - * @param limit - จำนวนผลลัพธ์สูงสุด / Max results - */ - @Get('{courseId}/students/search') - @Security('jwt', ['instructor']) - @SuccessResponse('200', 'Students found successfully') - @Response('401', 'Invalid or expired token') - @Response('403', 'Not an instructor of this course') - public async searchStudents( - @Request() request: any, - @Path() courseId: number, - @Query() query: string, - @Query() limit?: number - ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - return await CoursesInstructorService.searchStudentsInCourse({ - token, - course_id: courseId, - query, - limit, - }); - } } \ No newline at end of file diff --git a/Backend/src/services/CoursesInstructor.service.ts b/Backend/src/services/CoursesInstructor.service.ts index a1ba6751..72b9b662 100644 --- a/Backend/src/services/CoursesInstructor.service.ts +++ b/Backend/src/services/CoursesInstructor.service.ts @@ -32,6 +32,8 @@ import { GetQuizAttemptDetailResponse, SearchStudentsInput, SearchStudentsResponse, + GetEnrolledStudentDetailInput, + GetEnrolledStudentDetailResponse, } from "../types/CoursesInstructor.types"; export class CoursesInstructorService { @@ -858,8 +860,7 @@ export class CoursesInstructorService { // Build answers review const answersReview = lesson.quiz.questions.map(question => { const studentAnswer = studentAnswers.find((a: any) => a.question_id === question.id); - const selectedChoiceId = studentAnswer?.selected_choice_id || null; - const correctChoice = question.choices.find(c => c.is_correct); + const selectedChoiceId = studentAnswer?.choice_id || null; const selectedChoice = selectedChoiceId ? question.choices.find(c => c.id === selectedChoiceId) : null; return { @@ -868,8 +869,6 @@ export class CoursesInstructorService { 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, - correct_choice_id: correctChoice?.id || 0, - correct_choice_text: correctChoice?.text as { th: string; en: string } || { th: '', en: '' }, is_correct: studentAnswer?.is_correct || false, score: studentAnswer?.score || 0, question_score: question.score, @@ -957,4 +956,141 @@ export class CoursesInstructorService { 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; + } + } } diff --git a/Backend/src/types/CoursesInstructor.types.ts b/Backend/src/types/CoursesInstructor.types.ts index 85673958..caa8970e 100644 --- a/Backend/src/types/CoursesInstructor.types.ts +++ b/Backend/src/types/CoursesInstructor.types.ts @@ -317,8 +317,6 @@ export interface QuizAttemptDetailData { question_text: MultiLanguageText; selected_choice_id: number | null; selected_choice_text: MultiLanguageText | null; - correct_choice_id: number; - correct_choice_text: MultiLanguageText; is_correct: boolean; score: number; question_score: number; @@ -331,6 +329,63 @@ export interface GetQuizAttemptDetailResponse { data: QuizAttemptDetailData; } +// ============================================ +// Enrolled Student Detail (Instructor) +// ============================================ + +export interface GetEnrolledStudentDetailInput { + token: string; + course_id: number; + student_id: number; +} + +export interface LessonProgressDetail { + lesson_id: number; + lesson_title: MultiLanguageText; + lesson_type: string; + sort_order: number; + is_completed: boolean; + completed_at: Date | null; + video_progress_seconds: number | null; + video_duration_seconds: number | null; + video_progress_percentage: number | null; + last_watched_at: Date | null; +} + +export interface ChapterProgressDetail { + chapter_id: number; + chapter_title: MultiLanguageText; + sort_order: number; + lessons: LessonProgressDetail[]; + completed_lessons: number; + total_lessons: number; +} + +export interface EnrolledStudentDetailData { + student: { + user_id: number; + username: string; + email: string; + first_name: string | null; + last_name: string | null; + avatar_url: string | null; + }; + enrollment: { + enrolled_at: Date; + progress_percentage: number; + status: string; + }; + chapters: ChapterProgressDetail[]; + total_completed_lessons: number; + total_lessons: number; +} + +export interface GetEnrolledStudentDetailResponse { + code: number; + message: string; + data: EnrolledStudentDetailData; +} + // ============================================ // Search Students in Course (Instructor) // ============================================