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:
parent
12e71c48b4
commit
7749a39be7
3 changed files with 252 additions and 34 deletions
|
|
@ -20,6 +20,7 @@ import {
|
||||||
GetQuizScoresResponse,
|
GetQuizScoresResponse,
|
||||||
GetQuizAttemptDetailResponse,
|
GetQuizAttemptDetailResponse,
|
||||||
SearchStudentsResponse,
|
SearchStudentsResponse,
|
||||||
|
GetEnrolledStudentDetailResponse,
|
||||||
} from '../types/CoursesInstructor.types';
|
} from '../types/CoursesInstructor.types';
|
||||||
import { CreateCourseValidator } from "../validators/CoursesInstructor.validator";
|
import { CreateCourseValidator } from "../validators/CoursesInstructor.validator";
|
||||||
|
|
||||||
|
|
@ -300,6 +301,60 @@ export class CoursesInstructorController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ค้นหานักเรียนในคอร์ส
|
||||||
|
* 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ดึงรายละเอียดการเรียนของนักเรียนแต่ละคน (progress ของแต่ละ lesson)
|
||||||
|
* Get enrolled student detail with lesson progress
|
||||||
|
* @param courseId - รหัสคอร์ส / Course ID
|
||||||
|
* @param studentId - รหัสนักเรียน / Student ID
|
||||||
|
*/
|
||||||
|
@Get('{courseId}/students/{studentId}')
|
||||||
|
@Security('jwt', ['instructor'])
|
||||||
|
@SuccessResponse('200', 'Enrolled student detail retrieved successfully')
|
||||||
|
@Response('401', 'Invalid or expired token')
|
||||||
|
@Response('403', 'Not an instructor of this course')
|
||||||
|
@Response('404', 'Student not found or not enrolled')
|
||||||
|
public async getEnrolledStudentDetail(
|
||||||
|
@Request() request: any,
|
||||||
|
@Path() courseId: number,
|
||||||
|
@Path() studentId: number
|
||||||
|
): Promise<GetEnrolledStudentDetailResponse> {
|
||||||
|
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||||
|
if (!token) throw new ValidationError('No token provided');
|
||||||
|
return await CoursesInstructorService.getEnrolledStudentDetail({
|
||||||
|
token,
|
||||||
|
course_id: courseId,
|
||||||
|
student_id: studentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ดึงคะแนนสอบของนักเรียนทุกคนในแต่ละ lesson quiz
|
* ดึงคะแนนสอบของนักเรียนทุกคนในแต่ละ lesson quiz
|
||||||
* Get quiz scores of all students for a specific lesson
|
* Get quiz scores of all students for a specific lesson
|
||||||
|
|
@ -360,32 +415,4 @@ export class CoursesInstructorController {
|
||||||
student_id: studentId,
|
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -32,6 +32,8 @@ import {
|
||||||
GetQuizAttemptDetailResponse,
|
GetQuizAttemptDetailResponse,
|
||||||
SearchStudentsInput,
|
SearchStudentsInput,
|
||||||
SearchStudentsResponse,
|
SearchStudentsResponse,
|
||||||
|
GetEnrolledStudentDetailInput,
|
||||||
|
GetEnrolledStudentDetailResponse,
|
||||||
} from "../types/CoursesInstructor.types";
|
} from "../types/CoursesInstructor.types";
|
||||||
|
|
||||||
export class CoursesInstructorService {
|
export class CoursesInstructorService {
|
||||||
|
|
@ -858,8 +860,7 @@ export class CoursesInstructorService {
|
||||||
// Build answers review
|
// Build answers review
|
||||||
const answersReview = lesson.quiz.questions.map(question => {
|
const answersReview = lesson.quiz.questions.map(question => {
|
||||||
const studentAnswer = studentAnswers.find((a: any) => a.question_id === question.id);
|
const studentAnswer = studentAnswers.find((a: any) => a.question_id === question.id);
|
||||||
const selectedChoiceId = studentAnswer?.selected_choice_id || null;
|
const selectedChoiceId = studentAnswer?.choice_id || null;
|
||||||
const correctChoice = question.choices.find(c => c.is_correct);
|
|
||||||
const selectedChoice = selectedChoiceId ? question.choices.find(c => c.id === selectedChoiceId) : null;
|
const selectedChoice = selectedChoiceId ? question.choices.find(c => c.id === selectedChoiceId) : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -868,8 +869,6 @@ export class CoursesInstructorService {
|
||||||
question_text: question.question as { th: string; en: string },
|
question_text: question.question as { th: string; en: string },
|
||||||
selected_choice_id: selectedChoiceId,
|
selected_choice_id: selectedChoiceId,
|
||||||
selected_choice_text: selectedChoice ? selectedChoice.text as { th: string; en: string } : null,
|
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,
|
is_correct: studentAnswer?.is_correct || false,
|
||||||
score: studentAnswer?.score || 0,
|
score: studentAnswer?.score || 0,
|
||||||
question_score: question.score,
|
question_score: question.score,
|
||||||
|
|
@ -957,4 +956,141 @@ export class CoursesInstructorService {
|
||||||
throw error;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -317,8 +317,6 @@ export interface QuizAttemptDetailData {
|
||||||
question_text: MultiLanguageText;
|
question_text: MultiLanguageText;
|
||||||
selected_choice_id: number | null;
|
selected_choice_id: number | null;
|
||||||
selected_choice_text: MultiLanguageText | null;
|
selected_choice_text: MultiLanguageText | null;
|
||||||
correct_choice_id: number;
|
|
||||||
correct_choice_text: MultiLanguageText;
|
|
||||||
is_correct: boolean;
|
is_correct: boolean;
|
||||||
score: number;
|
score: number;
|
||||||
question_score: number;
|
question_score: number;
|
||||||
|
|
@ -331,6 +329,63 @@ export interface GetQuizAttemptDetailResponse {
|
||||||
data: QuizAttemptDetailData;
|
data: QuizAttemptDetailData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Enrolled Student Detail (Instructor)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface GetEnrolledStudentDetailInput {
|
||||||
|
token: string;
|
||||||
|
course_id: number;
|
||||||
|
student_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LessonProgressDetail {
|
||||||
|
lesson_id: number;
|
||||||
|
lesson_title: MultiLanguageText;
|
||||||
|
lesson_type: string;
|
||||||
|
sort_order: number;
|
||||||
|
is_completed: boolean;
|
||||||
|
completed_at: Date | null;
|
||||||
|
video_progress_seconds: number | null;
|
||||||
|
video_duration_seconds: number | null;
|
||||||
|
video_progress_percentage: number | null;
|
||||||
|
last_watched_at: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChapterProgressDetail {
|
||||||
|
chapter_id: number;
|
||||||
|
chapter_title: MultiLanguageText;
|
||||||
|
sort_order: number;
|
||||||
|
lessons: LessonProgressDetail[];
|
||||||
|
completed_lessons: number;
|
||||||
|
total_lessons: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnrolledStudentDetailData {
|
||||||
|
student: {
|
||||||
|
user_id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
|
};
|
||||||
|
enrollment: {
|
||||||
|
enrolled_at: Date;
|
||||||
|
progress_percentage: number;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
chapters: ChapterProgressDetail[];
|
||||||
|
total_completed_lessons: number;
|
||||||
|
total_lessons: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetEnrolledStudentDetailResponse {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: EnrolledStudentDetailData;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Search Students in Course (Instructor)
|
// Search Students in Course (Instructor)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue