feat: add search and filter capabilities to student and quiz endpoints, implement YouTube video support for lessons
Add search and status filter parameters to getEnrolledStudents endpoint to filter students by name/email/username and enrollment status. Add search and isPassed filter parameters to getQuizScores endpoint to filter quiz results by student details and pass status. Remove separate searchStudents endpoint as its functionality is now integrated into getEnrolledStudents. Add setYouTubeVideo endpoint to
This commit is contained in:
parent
e8a10e5024
commit
ff841c7638
6 changed files with 246 additions and 125 deletions
|
|
@ -19,7 +19,6 @@ import {
|
|||
GetEnrolledStudentsResponse,
|
||||
GetQuizScoresResponse,
|
||||
GetQuizAttemptDetailResponse,
|
||||
SearchStudentsResponse,
|
||||
GetEnrolledStudentDetailResponse,
|
||||
} from '../types/CoursesInstructor.types';
|
||||
import { CreateCourseValidator } from "../validators/CoursesInstructor.validator";
|
||||
|
|
@ -279,6 +278,8 @@ export class CoursesInstructorController {
|
|||
* @param courseId - รหัสคอร์ส / Course ID
|
||||
* @param page - หน้าที่ต้องการ / Page number
|
||||
* @param limit - จำนวนต่อหน้า / Items per page
|
||||
* @param search - ค้นหาด้วย firstname, lastname, email, username
|
||||
* @param status - กรองตามสถานะ (ENROLLED, COMPLETED)
|
||||
*/
|
||||
@Get('{courseId}/students')
|
||||
@Security('jwt', ['instructor'])
|
||||
|
|
@ -289,7 +290,9 @@ export class CoursesInstructorController {
|
|||
@Request() request: any,
|
||||
@Path() courseId: number,
|
||||
@Query() page?: number,
|
||||
@Query() limit?: number
|
||||
@Query() limit?: number,
|
||||
@Query() search?: string,
|
||||
@Query() status?: 'ENROLLED' | 'COMPLETED'
|
||||
): Promise<GetEnrolledStudentsResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) throw new ValidationError('No token provided');
|
||||
|
|
@ -298,34 +301,8 @@ export class CoursesInstructorController {
|
|||
course_id: courseId,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ค้นหานักเรียนในคอร์ส
|
||||
* 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,
|
||||
search,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -362,6 +339,8 @@ export class CoursesInstructorController {
|
|||
* @param lessonId - รหัสบทเรียน / Lesson ID
|
||||
* @param page - หน้าที่ต้องการ / Page number
|
||||
* @param limit - จำนวนต่อหน้า / Items per page
|
||||
* @param search - ค้นหาด้วย firstname, lastname, email, username
|
||||
* @param isPassed - กรองตามผลสอบ (true = ผ่าน, false = ไม่ผ่าน)
|
||||
*/
|
||||
@Get('{courseId}/lessons/{lessonId}/quiz/scores')
|
||||
@Security('jwt', ['instructor'])
|
||||
|
|
@ -374,7 +353,9 @@ export class CoursesInstructorController {
|
|||
@Path() courseId: number,
|
||||
@Path() lessonId: number,
|
||||
@Query() page?: number,
|
||||
@Query() limit?: number
|
||||
@Query() limit?: number,
|
||||
@Query() search?: string,
|
||||
@Query() isPassed?: boolean
|
||||
): Promise<GetQuizScoresResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) throw new ValidationError('No token provided');
|
||||
|
|
@ -384,6 +365,8 @@ export class CoursesInstructorController {
|
|||
lesson_id: lessonId,
|
||||
page,
|
||||
limit,
|
||||
search,
|
||||
is_passed: isPassed,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Delete, Path, Post, Put, Request, Response, Route, Security, SuccessResponse, Tags, UploadedFile } from 'tsoa';
|
||||
import { Body, Delete, Path, Post, Put, Request, Response, Route, Security, SuccessResponse, Tags, UploadedFile } from 'tsoa';
|
||||
import { ValidationError } from '../middleware/errorHandler';
|
||||
import { ChaptersLessonService } from '../services/ChaptersLesson.service';
|
||||
import {
|
||||
|
|
@ -7,7 +7,9 @@ import {
|
|||
UpdateLessonResponse,
|
||||
VideoOperationResponse,
|
||||
AttachmentOperationResponse,
|
||||
DeleteAttachmentResponse
|
||||
DeleteAttachmentResponse,
|
||||
YouTubeVideoResponse,
|
||||
SetYouTubeVideoBody,
|
||||
} from '../types/ChaptersLesson.typs';
|
||||
|
||||
const chaptersLessonService = new ChaptersLessonService();
|
||||
|
|
@ -184,4 +186,46 @@ export class LessonsController {
|
|||
attachment_id: attachmentId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ตั้งค่าวิดีโอ YouTube ให้บทเรียน (แทนที่วิดีโอเดิมถ้ามี)
|
||||
* Set YouTube video for a lesson (replaces existing video if any)
|
||||
*
|
||||
* @param courseId Course ID
|
||||
* @param chapterId Chapter ID
|
||||
* @param lessonId Lesson ID
|
||||
* @param body YouTube video info
|
||||
*/
|
||||
@Post('{lessonId}/youtube-video')
|
||||
@Security('jwt', ['instructor'])
|
||||
@SuccessResponse('200', 'YouTube video set successfully')
|
||||
@Response('400', 'Validation error')
|
||||
@Response('401', 'Unauthorized')
|
||||
@Response('403', 'Forbidden')
|
||||
@Response('404', 'Lesson not found')
|
||||
public async setYouTubeVideo(
|
||||
@Request() request: any,
|
||||
@Path() courseId: number,
|
||||
@Path() chapterId: number,
|
||||
@Path() lessonId: number,
|
||||
@Body() body: SetYouTubeVideoBody
|
||||
): Promise<YouTubeVideoResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) throw new ValidationError('No token provided');
|
||||
|
||||
if (!body.youtube_video_id) {
|
||||
throw new ValidationError('YouTube video ID is required');
|
||||
}
|
||||
if (!body.video_title) {
|
||||
throw new ValidationError('Video title is required');
|
||||
}
|
||||
|
||||
return await chaptersLessonService.setYouTubeVideo({
|
||||
token,
|
||||
course_id: courseId,
|
||||
lesson_id: lessonId,
|
||||
youtube_video_id: body.youtube_video_id,
|
||||
video_title: body.video_title,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ import {
|
|||
ReorderQuestionResponse,
|
||||
UploadVideoInput,
|
||||
UpdateVideoInput,
|
||||
SetYouTubeVideoInput,
|
||||
YouTubeVideoResponse,
|
||||
UploadAttachmentInput,
|
||||
DeleteAttachmentInput,
|
||||
VideoOperationResponse,
|
||||
|
|
@ -358,16 +360,24 @@ export class ChaptersLessonService {
|
|||
throw new ForbiddenError('This lesson is not available yet');
|
||||
}
|
||||
|
||||
// Get video URL from MinIO
|
||||
// Get video URL - check for YouTube or MinIO
|
||||
let video_url: string | null = null;
|
||||
try {
|
||||
const videoPrefix = getVideoFolder(course_id, lesson_id);
|
||||
const videoFiles = await listObjects(videoPrefix);
|
||||
if (videoFiles.length > 0) {
|
||||
video_url = await getPresignedUrl(videoFiles[0].name, 3600);
|
||||
const videoAttachment = await prisma.lessonAttachment.findFirst({
|
||||
where: { lesson_id, sort_order: 0 }
|
||||
});
|
||||
|
||||
if (videoAttachment) {
|
||||
if (videoAttachment.mime_type === 'video/youtube') {
|
||||
// YouTube video - build URL from video ID stored in file_path
|
||||
video_url = `https://www.youtube.com/watch?v=${videoAttachment.file_path}`;
|
||||
} else {
|
||||
// MinIO video - get presigned URL
|
||||
try {
|
||||
video_url = await getPresignedUrl(videoAttachment.file_path, 3600);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to get video from MinIO: ${err}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Failed to get video from MinIO: ${err}`);
|
||||
}
|
||||
|
||||
// Get attachments with presigned URLs from MinIO
|
||||
|
|
@ -772,6 +782,87 @@ export class ChaptersLessonService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ตั้งค่าวิดีโอ YouTube ให้บทเรียน
|
||||
* Set YouTube video for a lesson (replaces existing video if any)
|
||||
*/
|
||||
async setYouTubeVideo(request: SetYouTubeVideoInput): Promise<YouTubeVideoResponse> {
|
||||
try {
|
||||
const { token, course_id, lesson_id, youtube_video_id, video_title } = request;
|
||||
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
|
||||
await CoursesInstructorService.validateCourseStatus(course_id);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
|
||||
if (!user) throw new UnauthorizedError('Invalid token');
|
||||
|
||||
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
|
||||
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
|
||||
|
||||
// Verify lesson exists and is VIDEO type
|
||||
const lesson = await prisma.lesson.findUnique({ where: { id: lesson_id } });
|
||||
if (!lesson) throw new NotFoundError('Lesson not found');
|
||||
if (lesson.type !== 'VIDEO') throw new ValidationError('Cannot add video to non-VIDEO type lesson');
|
||||
|
||||
// Find existing video attachment (sort_order = 0)
|
||||
const existingVideo = await prisma.lessonAttachment.findFirst({
|
||||
where: { lesson_id, sort_order: 0 }
|
||||
});
|
||||
|
||||
if (existingVideo) {
|
||||
// If existing video is from MinIO (not YouTube), delete it
|
||||
if (existingVideo.mime_type !== 'video/youtube') {
|
||||
try {
|
||||
await deleteFile(existingVideo.file_path);
|
||||
logger.info(`Deleted old MinIO video: ${existingVideo.file_path}`);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to delete old video from MinIO: ${existingVideo.file_path}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update existing attachment to YouTube
|
||||
await prisma.lessonAttachment.update({
|
||||
where: { id: existingVideo.id },
|
||||
data: {
|
||||
file_name: video_title,
|
||||
file_path: youtube_video_id,
|
||||
file_size: 0,
|
||||
mime_type: 'video/youtube',
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Create new YouTube video attachment
|
||||
await prisma.lessonAttachment.create({
|
||||
data: {
|
||||
lesson_id,
|
||||
file_name: video_title,
|
||||
file_path: youtube_video_id,
|
||||
file_size: 0,
|
||||
mime_type: 'video/youtube',
|
||||
sort_order: 0,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Build YouTube URL
|
||||
const video_url = `https://www.youtube.com/watch?v=${youtube_video_id}`;
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'YouTube video set successfully',
|
||||
data: {
|
||||
lesson_id,
|
||||
video_url,
|
||||
video_id: youtube_video_id,
|
||||
video_title,
|
||||
mime_type: 'video/youtube',
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Error setting YouTube video: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* อัพโหลดไฟล์แนบทีละไฟล์
|
||||
* Upload a single attachment to lesson
|
||||
|
|
|
|||
|
|
@ -30,8 +30,6 @@ import {
|
|||
GetQuizScoresResponse,
|
||||
GetQuizAttemptDetailInput,
|
||||
GetQuizAttemptDetailResponse,
|
||||
SearchStudentsInput,
|
||||
SearchStudentsResponse,
|
||||
GetEnrolledStudentDetailInput,
|
||||
GetEnrolledStudentDetailResponse,
|
||||
} from "../types/CoursesInstructor.types";
|
||||
|
|
@ -591,17 +589,35 @@ export class CoursesInstructorService {
|
|||
*/
|
||||
static async getEnrolledStudents(input: GetEnrolledStudentsInput): Promise<GetEnrolledStudentsResponse> {
|
||||
try {
|
||||
const { token, course_id, page = 1, limit = 20 } = input;
|
||||
const { token, course_id, page = 1, limit = 20, search, status } = input;
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
|
||||
|
||||
// Validate instructor
|
||||
await this.validateCourseInstructor(token, course_id);
|
||||
|
||||
// Build where clause
|
||||
const whereClause: any = { course_id };
|
||||
|
||||
// Add status filter
|
||||
if (status) {
|
||||
whereClause.status = status;
|
||||
}
|
||||
|
||||
// Add search filter
|
||||
if (search) {
|
||||
whereClause.OR = [
|
||||
{ user: { username: { contains: search, mode: 'insensitive' } } },
|
||||
{ user: { email: { contains: search, mode: 'insensitive' } } },
|
||||
{ user: { profile: { first_name: { contains: search, mode: 'insensitive' } } } },
|
||||
{ user: { profile: { last_name: { contains: search, mode: 'insensitive' } } } },
|
||||
];
|
||||
}
|
||||
|
||||
// Get enrollments with user data
|
||||
const skip = (page - 1) * limit;
|
||||
const [enrollments, total] = await Promise.all([
|
||||
prisma.enrollment.findMany({
|
||||
where: { course_id },
|
||||
where: whereClause,
|
||||
include: {
|
||||
user: {
|
||||
include: {
|
||||
|
|
@ -613,7 +629,7 @@ export class CoursesInstructorService {
|
|||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.enrollment.count({ where: { course_id } }),
|
||||
prisma.enrollment.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
// Format response with presigned URLs for avatars
|
||||
|
|
@ -662,7 +678,7 @@ export class CoursesInstructorService {
|
|||
*/
|
||||
static async getQuizScores(input: GetQuizScoresInput): Promise<GetQuizScoresResponse> {
|
||||
try {
|
||||
const { token, course_id, lesson_id, page = 1, limit = 20 } = input;
|
||||
const { token, course_id, lesson_id, page = 1, limit = 20, search, is_passed } = input;
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
|
||||
|
||||
// Validate instructor
|
||||
|
|
@ -727,10 +743,36 @@ export class CoursesInstructorService {
|
|||
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);
|
||||
// Get unique users and apply filters
|
||||
let filteredUserIds = Array.from(userAttemptsMap.keys());
|
||||
|
||||
// Apply search filter
|
||||
if (search) {
|
||||
filteredUserIds = filteredUserIds.filter(userId => {
|
||||
const userAttempts = userAttemptsMap.get(userId)!;
|
||||
const user = userAttempts[0].user;
|
||||
const searchLower = search.toLowerCase();
|
||||
return (
|
||||
user.username.toLowerCase().includes(searchLower) ||
|
||||
user.email.toLowerCase().includes(searchLower) ||
|
||||
(user.profile?.first_name?.toLowerCase().includes(searchLower)) ||
|
||||
(user.profile?.last_name?.toLowerCase().includes(searchLower))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply is_passed filter
|
||||
if (is_passed !== undefined) {
|
||||
filteredUserIds = filteredUserIds.filter(userId => {
|
||||
const userAttempts = userAttemptsMap.get(userId)!;
|
||||
const userIsPassed = userAttempts.some(a => a.is_passed);
|
||||
return userIsPassed === is_passed;
|
||||
});
|
||||
}
|
||||
|
||||
// Paginate
|
||||
const total = filteredUserIds.length;
|
||||
const paginatedUserIds = filteredUserIds.slice(skip, skip + limit);
|
||||
|
||||
// Format response
|
||||
const studentsData = await Promise.all(
|
||||
|
|
@ -906,57 +948,6 @@ export class CoursesInstructorService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ค้นหานักเรียนในคอร์สโดย 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึงรายละเอียดการเรียนของนักเรียนแต่ละคน
|
||||
* Get enrolled student detail with lesson progress
|
||||
|
|
|
|||
|
|
@ -380,6 +380,32 @@ export interface UpdateVideoInput {
|
|||
video: UploadedFileInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for setting YouTube video to a lesson
|
||||
*/
|
||||
export interface SetYouTubeVideoInput {
|
||||
token: string;
|
||||
course_id: number;
|
||||
lesson_id: number;
|
||||
youtube_video_id: string;
|
||||
video_title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for YouTube video operation
|
||||
*/
|
||||
export interface YouTubeVideoResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
lesson_id: number;
|
||||
video_url: string;
|
||||
video_id: string;
|
||||
video_title: string;
|
||||
mime_type: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for uploading a single attachment to a lesson
|
||||
*/
|
||||
|
|
@ -625,6 +651,11 @@ export interface ReorderLessonsBody {
|
|||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface SetYouTubeVideoBody {
|
||||
youtube_video_id: string;
|
||||
video_title: string;
|
||||
}
|
||||
|
||||
export interface ReorderQuestionBody {
|
||||
sort_order: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,6 +209,8 @@ export interface GetEnrolledStudentsInput {
|
|||
course_id: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
status?: 'ENROLLED' | 'COMPLETED';
|
||||
}
|
||||
|
||||
export interface EnrolledStudentData {
|
||||
|
|
@ -242,6 +244,8 @@ export interface GetQuizScoresInput {
|
|||
lesson_id: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
is_passed?: boolean;
|
||||
}
|
||||
|
||||
export interface StudentQuizScoreData {
|
||||
|
|
@ -386,26 +390,3 @@ export interface GetEnrolledStudentDetailResponse {
|
|||
data: EnrolledStudentDetailData;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 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[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue