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,
|
submitCourseResponse,
|
||||||
listinstructorCourseResponse,
|
listinstructorCourseResponse,
|
||||||
GetCourseApprovalsResponse,
|
GetCourseApprovalsResponse,
|
||||||
SearchInstructorResponse
|
SearchInstructorResponse,
|
||||||
|
GetEnrolledStudentsResponse,
|
||||||
|
GetQuizScoresResponse,
|
||||||
|
GetQuizAttemptDetailResponse,
|
||||||
|
SearchStudentsResponse,
|
||||||
} from '../types/CoursesInstructor.types';
|
} from '../types/CoursesInstructor.types';
|
||||||
import { CreateCourseValidator } from "../validators/CoursesInstructor.validator";
|
import { CreateCourseValidator } from "../validators/CoursesInstructor.validator";
|
||||||
|
|
||||||
|
|
@ -262,4 +266,121 @@ export class CoursesInstructorController {
|
||||||
if (!token) throw new ValidationError('No token provided')
|
if (!token) throw new ValidationError('No token provided')
|
||||||
return await CoursesInstructorService.setPrimaryInstructor({ token, course_id: courseId, user_id: userId });
|
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,
|
CompleteLessonResponse,
|
||||||
SubmitQuizResponse,
|
SubmitQuizResponse,
|
||||||
SubmitQuizBody,
|
SubmitQuizBody,
|
||||||
|
GetQuizAttemptsResponse,
|
||||||
} from '../types/CoursesStudent.types';
|
} from '../types/CoursesStudent.types';
|
||||||
import { EnrollmentStatus } from '@prisma/client';
|
import { EnrollmentStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
|
@ -234,4 +235,32 @@ export class CoursesStudentController {
|
||||||
answers: body.answers,
|
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,
|
listinstructorCourse,
|
||||||
SearchInstructorInput,
|
SearchInstructorInput,
|
||||||
SearchInstructorResponse,
|
SearchInstructorResponse,
|
||||||
|
GetEnrolledStudentsInput,
|
||||||
|
GetEnrolledStudentsResponse,
|
||||||
|
GetQuizScoresInput,
|
||||||
|
GetQuizScoresResponse,
|
||||||
|
GetQuizAttemptDetailInput,
|
||||||
|
GetQuizAttemptDetailResponse,
|
||||||
|
SearchStudentsInput,
|
||||||
|
SearchStudentsResponse,
|
||||||
} from "../types/CoursesInstructor.types";
|
} from "../types/CoursesInstructor.types";
|
||||||
|
|
||||||
export class CoursesInstructorService {
|
export class CoursesInstructorService {
|
||||||
|
|
@ -564,4 +572,378 @@ export class CoursesInstructorService {
|
||||||
throw new ForbiddenError('Course is already approved Cannot Edit');
|
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,
|
CompleteLessonResponse,
|
||||||
SubmitQuizInput,
|
SubmitQuizInput,
|
||||||
SubmitQuizResponse,
|
SubmitQuizResponse,
|
||||||
|
GetQuizAttemptsInput,
|
||||||
|
GetQuizAttemptsResponse,
|
||||||
} from "../types/CoursesStudent.types";
|
} from "../types/CoursesStudent.types";
|
||||||
import { getPresignedUrl, listObjects, getVideoFolder, getAttachmentsFolder } from '../config/minio';
|
import { getPresignedUrl, listObjects, getVideoFolder, getAttachmentsFolder } from '../config/minio';
|
||||||
|
|
||||||
|
|
@ -1260,4 +1262,109 @@ export class CoursesStudentService {
|
||||||
throw error;
|
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[];
|
data: CourseApprovalData[];
|
||||||
total: number;
|
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;
|
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