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
This commit is contained in:
parent
a648c41b72
commit
80d7372dfa
6 changed files with 832 additions and 1 deletions
|
|
@ -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<GetEnrolledStudentsResponse> {
|
||||
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<GetQuizScoresResponse> {
|
||||
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<GetQuizAttemptDetailResponse> {
|
||||
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<SearchStudentsResponse> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<GetQuizAttemptsResponse> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<GetEnrolledStudentsResponse> {
|
||||
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<GetQuizScoresResponse> {
|
||||
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<number, typeof quizAttempts>();
|
||||
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<GetQuizAttemptDetailResponse> {
|
||||
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<SearchStudentsResponse> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<GetQuizAttemptsResponse> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue