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:
JakkrapartXD 2026-02-02 18:02:19 +07:00
parent a648c41b72
commit 80d7372dfa
6 changed files with 832 additions and 1 deletions

View file

@ -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,
});
}
}

View file

@ -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,
});
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}

View file

@ -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[];
}

View file

@ -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;
};
}