From 80d7372dfa593bb83f9dd4bf0250bb2cdcded9fa Mon Sep 17 00:00:00 2001 From: JakkrapartXD Date: Mon, 2 Feb 2026 18:02:19 +0700 Subject: [PATCH] feat: add instructor endpoints for student progress tracking and quiz score management Add four new instructor endpoints: getEnrolledStudents to view all enrolled students with progress, getQuizScores to view quiz scores for all students in a lesson, getQuizAttemptDetail to view detailed quiz attempt for a specific student, and searchStudents to search enrolled students by name/email/username. Add getQuizAttempts endpoint for students to retrieve their own quiz attempt history. All endpoints include --- .../CoursesInstructorController.ts | 123 +++++- .../controllers/CoursesStudentController.ts | 29 ++ .../src/services/CoursesInstructor.service.ts | 382 ++++++++++++++++++ .../src/services/CoursesStudent.service.ts | 107 +++++ Backend/src/types/CoursesInstructor.types.ts | 154 +++++++ Backend/src/types/CoursesStudent.types.ts | 38 ++ 6 files changed, 832 insertions(+), 1 deletion(-) diff --git a/Backend/src/controllers/CoursesInstructorController.ts b/Backend/src/controllers/CoursesInstructorController.ts index ebb1ca38..034971aa 100644 --- a/Backend/src/controllers/CoursesInstructorController.ts +++ b/Backend/src/controllers/CoursesInstructorController.ts @@ -15,7 +15,11 @@ import { submitCourseResponse, listinstructorCourseResponse, GetCourseApprovalsResponse, - SearchInstructorResponse + SearchInstructorResponse, + GetEnrolledStudentsResponse, + GetQuizScoresResponse, + GetQuizAttemptDetailResponse, + SearchStudentsResponse, } from '../types/CoursesInstructor.types'; import { CreateCourseValidator } from "../validators/CoursesInstructor.validator"; @@ -262,4 +266,121 @@ export class CoursesInstructorController { if (!token) throw new ValidationError('No token provided') return await CoursesInstructorService.setPrimaryInstructor({ token, course_id: courseId, user_id: userId }); } + + /** + * ดึงรายชื่อนักเรียนที่ลงทะเบียนในคอร์สพร้อม progress + * Get all enrolled students with their progress + * @param courseId - รหัสคอร์ส / Course ID + * @param page - หน้าที่ต้องการ / Page number + * @param limit - จำนวนต่อหน้า / Items per page + */ + @Get('{courseId}/students') + @Security('jwt', ['instructor']) + @SuccessResponse('200', 'Enrolled students retrieved successfully') + @Response('401', 'Invalid or expired token') + @Response('403', 'Not an instructor of this course') + public async getEnrolledStudents( + @Request() request: any, + @Path() courseId: number, + @Query() page?: number, + @Query() limit?: number + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new ValidationError('No token provided'); + return await CoursesInstructorService.getEnrolledStudents({ + token, + course_id: courseId, + page, + limit, + }); + } + + /** + * ดึงคะแนนสอบของนักเรียนทุกคนในแต่ละ lesson quiz + * Get quiz scores of all students for a specific lesson + * @param courseId - รหัสคอร์ส / Course ID + * @param lessonId - รหัสบทเรียน / Lesson ID + * @param page - หน้าที่ต้องการ / Page number + * @param limit - จำนวนต่อหน้า / Items per page + */ + @Get('{courseId}/lessons/{lessonId}/quiz/scores') + @Security('jwt', ['instructor']) + @SuccessResponse('200', 'Quiz scores retrieved successfully') + @Response('401', 'Invalid or expired token') + @Response('403', 'Not an instructor of this course') + @Response('404', 'Lesson or quiz not found') + public async getQuizScores( + @Request() request: any, + @Path() courseId: number, + @Path() lessonId: number, + @Query() page?: number, + @Query() limit?: number + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new ValidationError('No token provided'); + return await CoursesInstructorService.getQuizScores({ + token, + course_id: courseId, + lesson_id: lessonId, + page, + limit, + }); + } + + /** + * ดูรายละเอียดการทำข้อสอบล่าสุดของนักเรียนแต่ละคน + * Get latest quiz attempt detail for a specific student + * @param courseId - รหัสคอร์ส / Course ID + * @param lessonId - รหัสบทเรียน / Lesson ID + * @param studentId - รหัสนักเรียน / Student ID + */ + @Get('{courseId}/lessons/{lessonId}/quiz/students/{studentId}') + @Security('jwt', ['instructor']) + @SuccessResponse('200', 'Quiz attempt detail retrieved successfully') + @Response('401', 'Invalid or expired token') + @Response('403', 'Not an instructor of this course') + @Response('404', 'Student or quiz attempt not found') + public async getQuizAttemptDetail( + @Request() request: any, + @Path() courseId: number, + @Path() lessonId: number, + @Path() studentId: number + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new ValidationError('No token provided'); + return await CoursesInstructorService.getQuizAttemptDetail({ + token, + course_id: courseId, + lesson_id: lessonId, + 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/controllers/CoursesStudentController.ts b/Backend/src/controllers/CoursesStudentController.ts index f9e05e24..afcf80b0 100644 --- a/Backend/src/controllers/CoursesStudentController.ts +++ b/Backend/src/controllers/CoursesStudentController.ts @@ -13,6 +13,7 @@ import { CompleteLessonResponse, SubmitQuizResponse, SubmitQuizBody, + GetQuizAttemptsResponse, } from '../types/CoursesStudent.types'; import { EnrollmentStatus } from '@prisma/client'; @@ -234,4 +235,32 @@ export class CoursesStudentController { answers: body.answers, }); } + + /** + * ดึงคะแนน Quiz ที่เคยทำ + * Get quiz attempts and scores for a lesson + * @param courseId - รหัสคอร์ส / Course ID + * @param lessonId - รหัสบทเรียน / Lesson ID + */ + @Get('courses/{courseId}/lessons/{lessonId}/quiz/attempts') + @Security('jwt', ['student']) + @SuccessResponse('200', 'Quiz attempts retrieved successfully') + @Response('401', 'Invalid or expired token') + @Response('403', 'Not enrolled in this course') + @Response('404', 'Lesson or quiz not found') + public async getQuizAttempts( + @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.getQuizAttempts({ + token, + course_id: courseId, + lesson_id: lessonId, + }); + } } diff --git a/Backend/src/services/CoursesInstructor.service.ts b/Backend/src/services/CoursesInstructor.service.ts index 58afb7b1..48f92ade 100644 --- a/Backend/src/services/CoursesInstructor.service.ts +++ b/Backend/src/services/CoursesInstructor.service.ts @@ -24,6 +24,14 @@ import { listinstructorCourse, SearchInstructorInput, SearchInstructorResponse, + GetEnrolledStudentsInput, + GetEnrolledStudentsResponse, + GetQuizScoresInput, + GetQuizScoresResponse, + GetQuizAttemptDetailInput, + GetQuizAttemptDetailResponse, + SearchStudentsInput, + SearchStudentsResponse, } from "../types/CoursesInstructor.types"; export class CoursesInstructorService { @@ -564,4 +572,378 @@ export class CoursesInstructorService { 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 } = input; + const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; + + // Validate instructor + await this.validateCourseInstructor(token, course_id); + + // Get enrollments with user data + const skip = (page - 1) * limit; + const [enrollments, total] = await Promise.all([ + prisma.enrollment.findMany({ + where: { course_id }, + include: { + user: { + include: { + profile: true, + }, + }, + }, + orderBy: { enrolled_at: 'desc' }, + skip, + take: limit, + }), + prisma.enrollment.count({ where: { course_id } }), + ]); + + // 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 } = 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 paginated unique users + const uniqueUserIds = Array.from(userAttemptsMap.keys()); + const total = uniqueUserIds.length; + const paginatedUserIds = uniqueUserIds.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?.selected_choice_id || null; + const correctChoice = question.choices.find(c => c.is_correct); + const selectedChoice = selectedChoiceId ? question.choices.find(c => c.id === selectedChoiceId) : null; + + return { + question_id: question.id, + 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, + }; + }); + + 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; + } + } + + /** + * ค้นหานักเรียนในคอร์สโดย firstname, lastname, email, username + * Search students in course by name, email, or username + */ + static async searchStudentsInCourse(input: SearchStudentsInput): Promise { + try { + const { token, course_id, query, limit = 10 } = input; + const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; + + // Validate instructor + await this.validateCourseInstructor(token, course_id); + + // Search enrolled students + const enrollments = await prisma.enrollment.findMany({ + where: { + course_id, + OR: [ + { user: { username: { contains: query, mode: 'insensitive' } } }, + { user: { email: { contains: query, mode: 'insensitive' } } }, + { user: { profile: { first_name: { contains: query, mode: 'insensitive' } } } }, + { user: { profile: { last_name: { contains: query, mode: 'insensitive' } } } }, + ], + }, + include: { + user: { + include: { + profile: true, + }, + }, + }, + take: limit, + }); + + const studentsData = enrollments.map(enrollment => ({ + user_id: enrollment.user.id, + first_name: enrollment.user.profile?.first_name || null, + last_name: enrollment.user.profile?.last_name || null, + email: enrollment.user.email, + })); + + return { + code: 200, + message: 'Students found successfully', + data: studentsData, + }; + } catch (error) { + logger.error(`Error searching students: ${error}`); + throw error; + } + } } diff --git a/Backend/src/services/CoursesStudent.service.ts b/Backend/src/services/CoursesStudent.service.ts index 3ff904ef..bd739055 100644 --- a/Backend/src/services/CoursesStudent.service.ts +++ b/Backend/src/services/CoursesStudent.service.ts @@ -23,6 +23,8 @@ import { CompleteLessonResponse, SubmitQuizInput, SubmitQuizResponse, + GetQuizAttemptsInput, + GetQuizAttemptsResponse, } from "../types/CoursesStudent.types"; import { getPresignedUrl, listObjects, getVideoFolder, getAttachmentsFolder } from '../config/minio'; @@ -1260,4 +1262,109 @@ export class CoursesStudentService { 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; + } + } } \ No newline at end of file diff --git a/Backend/src/types/CoursesInstructor.types.ts b/Backend/src/types/CoursesInstructor.types.ts index 94cb5b12..8fbd3446 100644 --- a/Backend/src/types/CoursesInstructor.types.ts +++ b/Backend/src/types/CoursesInstructor.types.ts @@ -198,3 +198,157 @@ export interface GetCourseApprovalsResponse { data: CourseApprovalData[]; total: number; } + +// ============================================ +// Enrolled Students (Instructor) +// ============================================ + +export interface GetEnrolledStudentsInput { + token: string; + course_id: number; + page?: number; + limit?: number; +} + +export interface EnrolledStudentData { + user_id: number; + username: string; + email: string; + first_name: string | null; + last_name: string | null; + avatar_url: string | null; + enrolled_at: Date; + progress_percentage: number; + status: string; +} + +export interface GetEnrolledStudentsResponse { + code: number; + message: string; + data: EnrolledStudentData[]; + total: number; + page: number; + limit: number; +} + +// ============================================ +// Quiz Scores by Lesson (Instructor) +// ============================================ + +export interface GetQuizScoresInput { + token: string; + course_id: number; + lesson_id: number; + page?: number; + limit?: number; +} + +export interface StudentQuizScoreData { + user_id: number; + username: string; + email: string; + first_name: string | null; + last_name: string | null; + avatar_url: string | null; + latest_attempt: { + id: number; + score: number; + total_score: number; + is_passed: boolean; + attempt_number: number; + completed_at: Date | null; + } | null; + best_score: number | null; + total_attempts: number; + is_passed: boolean; +} + +export interface GetQuizScoresResponse { + code: number; + message: string; + data: { + lesson_id: number; + lesson_title: MultiLanguageText; + quiz_id: number; + quiz_title: MultiLanguageText; + passing_score: number; + total_score: number; + students: StudentQuizScoreData[]; + }; + total: number; + page: number; + limit: number; +} + +// ============================================ +// Quiz Attempt Detail (Instructor) +// ============================================ + +export interface GetQuizAttemptDetailInput { + token: string; + course_id: number; + lesson_id: number; + student_id: number; +} + +export interface QuizAttemptDetailData { + attempt_id: number; + quiz_id: number; + student: { + user_id: number; + username: string; + email: string; + first_name: string | null; + last_name: string | null; + }; + score: number; + total_score: number; + total_questions: number; + correct_answers: number; + is_passed: boolean; + passing_score: number; + attempt_number: number; + started_at: Date; + completed_at: Date | null; + answers_review: { + question_id: number; + 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; + }[]; +} + +export interface GetQuizAttemptDetailResponse { + code: number; + message: string; + data: QuizAttemptDetailData; +} + +// ============================================ +// Search Students in Course (Instructor) +// ============================================ + +export interface SearchStudentsInput { + token: string; + course_id: number; + query: string; + limit?: number; +} + +export interface SearchStudentData { + user_id: number; + first_name: string | null; + last_name: string | null; + email: string; +} + +export interface SearchStudentsResponse { + code: number; + message: string; + data: SearchStudentData[]; +} diff --git a/Backend/src/types/CoursesStudent.types.ts b/Backend/src/types/CoursesStudent.types.ts index 9d514dcc..50c410a9 100644 --- a/Backend/src/types/CoursesStudent.types.ts +++ b/Backend/src/types/CoursesStudent.types.ts @@ -369,3 +369,41 @@ export interface SubmitQuizResponse { is_course_completed?: boolean; }; } + +// ============================================ +// Get Quiz Attempts (Student) +// ============================================ + +export interface GetQuizAttemptsInput { + token: string; + course_id: number; + lesson_id: number; +} + +export interface QuizAttemptData { + id: number; + quiz_id: number; + score: number; + total_score: number; + total_questions: number; + correct_answers: number; + is_passed: boolean; + attempt_number: number; + started_at: Date; + completed_at: Date | null; +} + +export interface GetQuizAttemptsResponse { + code: number; + message: string; + data: { + lesson_id: number; + lesson_title: MultiLangText; + quiz_id: number; + quiz_title: MultiLangText; + passing_score: number; + attempts: QuizAttemptData[]; + best_score: number | null; + latest_attempt: QuizAttemptData | null; + }; +}