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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue