feat: add enrolled student detail endpoint and reorder student endpoints

Add getEnrolledStudentDetail endpoint to retrieve individual student's lesson progress across all course chapters. Move searchStudents endpoint before getEnrolledStudentDetail to prevent route conflicts. Remove correct_choice_id and correct_choice_text from quiz attempt detail answers review. Fix selected_choice_id mapping to use choice_id from student answers.
This commit is contained in:
JakkrapartXD 2026-02-03 14:42:45 +07:00
parent 12e71c48b4
commit 7749a39be7
3 changed files with 252 additions and 34 deletions

View file

@ -32,6 +32,8 @@ import {
GetQuizAttemptDetailResponse,
SearchStudentsInput,
SearchStudentsResponse,
GetEnrolledStudentDetailInput,
GetEnrolledStudentDetailResponse,
} from "../types/CoursesInstructor.types";
export class CoursesInstructorService {
@ -858,8 +860,7 @@ export class CoursesInstructorService {
// Build answers review
const answersReview = lesson.quiz.questions.map(question => {
const studentAnswer = studentAnswers.find((a: any) => a.question_id === question.id);
const selectedChoiceId = studentAnswer?.selected_choice_id || null;
const correctChoice = question.choices.find(c => c.is_correct);
const selectedChoiceId = studentAnswer?.choice_id || null;
const selectedChoice = selectedChoiceId ? question.choices.find(c => c.id === selectedChoiceId) : null;
return {
@ -868,8 +869,6 @@ export class CoursesInstructorService {
question_text: question.question as { th: string; en: string },
selected_choice_id: selectedChoiceId,
selected_choice_text: selectedChoice ? selectedChoice.text as { th: string; en: string } : null,
correct_choice_id: correctChoice?.id || 0,
correct_choice_text: correctChoice?.text as { th: string; en: string } || { th: '', en: '' },
is_correct: studentAnswer?.is_correct || false,
score: studentAnswer?.score || 0,
question_score: question.score,
@ -957,4 +956,141 @@ export class CoursesInstructorService {
throw error;
}
}
/**
*
* Get enrolled student detail with lesson progress
*/
static async getEnrolledStudentDetail(input: GetEnrolledStudentDetailInput): Promise<GetEnrolledStudentDetailResponse> {
try {
const { token, course_id, student_id } = input;
// Validate instructor
await this.validateCourseInstructor(token, course_id);
// Get student info
const student = await prisma.user.findUnique({
where: { id: student_id },
include: { profile: true },
});
if (!student) throw new NotFoundError('Student not found');
// Get enrollment
const enrollment = await prisma.enrollment.findUnique({
where: {
unique_enrollment: {
user_id: student_id,
course_id,
},
},
});
if (!enrollment) throw new NotFoundError('Student is not enrolled in this course');
// Get course with chapters and lessons
const course = await prisma.course.findUnique({
where: { id: course_id },
include: {
chapters: {
orderBy: { sort_order: 'asc' },
include: {
lessons: {
orderBy: { sort_order: 'asc' },
},
},
},
},
});
if (!course) throw new NotFoundError('Course not found');
// Get all lesson progress for this student in this course
const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id));
const lessonProgressList = await prisma.lessonProgress.findMany({
where: {
user_id: student_id,
lesson_id: { in: lessonIds },
},
});
// Create a map for quick lookup
const progressMap = new Map(lessonProgressList.map(p => [p.lesson_id, p]));
// Build chapters with lesson progress
let totalCompletedLessons = 0;
let totalLessons = 0;
const chaptersData = course.chapters.map(chapter => {
const lessonsData = chapter.lessons.map(lesson => {
const progress = progressMap.get(lesson.id);
totalLessons++;
if (progress?.is_completed) {
totalCompletedLessons++;
}
return {
lesson_id: lesson.id,
lesson_title: lesson.title as { th: string; en: string },
lesson_type: lesson.type,
sort_order: lesson.sort_order,
is_completed: progress?.is_completed || false,
completed_at: progress?.completed_at || null,
video_progress_seconds: progress?.video_progress_seconds || null,
video_duration_seconds: progress?.video_duration_seconds || null,
video_progress_percentage: progress?.video_progress_percentage ? Number(progress.video_progress_percentage) : null,
last_watched_at: progress?.last_watched_at || null,
};
});
const completedLessons = lessonsData.filter(l => l.is_completed).length;
return {
chapter_id: chapter.id,
chapter_title: chapter.title as { th: string; en: string },
sort_order: chapter.sort_order,
lessons: lessonsData,
completed_lessons: completedLessons,
total_lessons: lessonsData.length,
};
});
// Get avatar URL
let avatarUrl: string | null = null;
if (student.profile?.avatar_url) {
try {
avatarUrl = await getPresignedUrl(student.profile.avatar_url, 3600);
} catch (err) {
logger.warn(`Failed to generate presigned URL for avatar: ${err}`);
}
}
return {
code: 200,
message: 'Enrolled student detail retrieved successfully',
data: {
student: {
user_id: student.id,
username: student.username,
email: student.email,
first_name: student.profile?.first_name || null,
last_name: student.profile?.last_name || null,
avatar_url: avatarUrl,
},
enrollment: {
enrolled_at: enrollment.enrolled_at,
progress_percentage: Number(enrollment.progress_percentage) || 0,
status: enrollment.status,
},
chapters: chaptersData,
total_completed_lessons: totalCompletedLessons,
total_lessons: totalLessons,
},
};
} catch (error) {
logger.error(`Error getting enrolled student detail: ${error}`);
throw error;
}
}
}