From 9a7eb50d17e12efe443b91739e0e3282a13cfca7 Mon Sep 17 00:00:00 2001 From: JakkrapartXD Date: Wed, 21 Jan 2026 16:52:38 +0700 Subject: [PATCH] feat: Add student and instructor chapter/lesson controllers and refactor instructor lesson file upload endpoints with TSOA. --- Backend/src/controllers/LessonsController.ts | 281 ++++--- .../src/services/ChaptersLesson.service.ts | 725 +++++++++++++++--- Backend/src/types/ChaptersLesson.typs.ts | 192 ++++- 3 files changed, 949 insertions(+), 249 deletions(-) diff --git a/Backend/src/controllers/LessonsController.ts b/Backend/src/controllers/LessonsController.ts index 8207057c..d6256141 100644 --- a/Backend/src/controllers/LessonsController.ts +++ b/Backend/src/controllers/LessonsController.ts @@ -1,35 +1,27 @@ -import { Request, Response, NextFunction } from 'express'; -import { ChaptersLessonService } from '../services/ChaptersLesson.service'; +import { Body, FormField, Path, Post, Put, Request, Response, Route, Security, SuccessResponse, Tags, UploadedFile, UploadedFiles } from 'tsoa'; import { ValidationError } from '../middleware/errorHandler'; -import { - lessonUpload, - LessonUploadRequest, - validateTotalAttachmentSize, - validateVideoSize -} from '../middleware/upload'; -import { UploadedFileInfo, CreateLessonInput } from '../types/ChaptersLesson.typs'; +import { ChaptersLessonService } from '../services/ChaptersLesson.service'; +import { MultiLanguageText } from '../types/index'; +import { UploadedFileInfo, CreateLessonInput, CreateLessonResponse, UpdateLessonResponse } from '../types/ChaptersLesson.typs'; const chaptersLessonService = new ChaptersLessonService(); -/** - * Controller for handling lesson CRUD operations with file uploads - */ +@Route('api/instructors/courses/{courseId}/chapters/{chapterId}/lessons') +@Tags('Lessons - File Upload') export class LessonsController { + /** * สร้างบทเรียนใหม่พร้อมไฟล์แนบ * Create a new lesson with optional video and attachments * - * @route POST /api/instructors/courses/:courseId/chapters/:chapterId/lessons - * @contentType multipart/form-data - * - * @param {number} courseId - รหัสคอร์ส / Course ID - * @param {number} chapterId - รหัสบท / Chapter ID - * @param {string} title - ชื่อบทเรียน (JSON: { th: "", en: "" }) - * @param {string} [content] - เนื้อหาบทเรียน (JSON: { th: "", en: "" }) - * @param {string} type - ประเภทบทเรียน (VIDEO | QUIZ) - * @param {number} [sort_order] - ลำดับการแสดงผล - * @param {File} [video] - ไฟล์วิดีโอ (สำหรับ type=VIDEO เท่านั้น) - * @param {File[]} [attachments] - ไฟล์แนบ (PDFs, เอกสาร, รูปภาพ) + * @param courseId Course ID + * @param chapterId Chapter ID + * @param title ชื่อบทเรียน (JSON string: { th: "", en: "" }) + * @param type ประเภทบทเรียน (VIDEO | QUIZ) + * @param content เนื้อหาบทเรียน (JSON string: { th: "", en: "" }) + * @param sort_order ลำดับการแสดงผล + * @param video ไฟล์วิดีโอ (สำหรับ type=VIDEO เท่านั้น) + * @param attachments ไฟล์แนบ (PDFs, เอกสาร, รูปภาพ) */ async createLesson(req: LessonUploadRequest, res: Response, next: NextFunction): Promise { try { @@ -38,8 +30,8 @@ export class LessonsController { throw new ValidationError('No token provided'); } - const courseId = parseInt(req.params.courseId as string, 10); - const chapterId = parseInt(req.params.chapterId as string, 10); + const courseId = parseInt(req.params.courseId, 10); + const chapterId = parseInt(req.params.chapterId, 10); if (isNaN(courseId) || isNaN(chapterId)) { throw new ValidationError('Invalid course ID or chapter ID'); @@ -51,89 +43,162 @@ export class LessonsController { const type = req.body.type as 'VIDEO' | 'QUIZ'; const sortOrder = req.body.sort_order ? parseInt(req.body.sort_order, 10) : undefined; - if (!type || !['VIDEO', 'QUIZ'].includes(type)) { - throw new ValidationError('Invalid lesson type. Must be VIDEO or QUIZ'); - } - - if (!title.th || !title.en) { - throw new ValidationError('Title must have both Thai (th) and English (en) values'); - } - - // Process uploaded files - const files = req.files as { video?: Express.Multer.File[]; attachments?: Express.Multer.File[] } | undefined; - - let video: UploadedFileInfo | undefined; - let attachments: UploadedFileInfo[] | undefined; - - if (files?.video && files.video.length > 0) { - const videoFile = files.video[0]; - validateVideoSize(videoFile); - video = { - originalname: videoFile.originalname, - mimetype: videoFile.mimetype, - size: videoFile.size, - buffer: videoFile.buffer, - }; - } - - if (files?.attachments && files.attachments.length > 0) { - validateTotalAttachmentSize(files.attachments); - attachments = files.attachments.map(file => ({ - originalname: file.originalname, - mimetype: file.mimetype, - size: file.size, - buffer: file.buffer, - })); - } - - // Validate VIDEO type must have video file (optional - can be uploaded later) - // if (type === 'VIDEO' && !video) { - // throw new ValidationError('Video file is required for VIDEO type lessons'); - // } - - const input: CreateLessonInput = { - token, - course_id: courseId, - chapter_id: chapterId, - title, - content, - type, - sort_order: sortOrder, - video, - attachments, - }; - - const result = await chaptersLessonService.createLesson(input); - res.status(201).json(result); - } catch (error) { - next(error); + if (!parsedTitle.th || !parsedTitle.en) { + throw new ValidationError('Title must have both Thai (th) and English (en) values'); } + + // Transform files to UploadedFileInfo + let videoInfo: UploadedFileInfo | undefined; + let attachmentsInfo: UploadedFileInfo[] | undefined; + + if (video) { + videoInfo = { + originalname: video.originalname, + mimetype: video.mimetype, + size: video.size, + buffer: video.buffer, + }; + } + + if (attachments && attachments.length > 0) { + attachmentsInfo = attachments.map(file => ({ + originalname: file.originalname, + mimetype: file.mimetype, + size: file.size, + buffer: file.buffer, + })); + } + + const input: CreateLessonInput = { + token, + course_id: courseId, + chapter_id: chapterId, + title: parsedTitle, + content: parsedContent, + type, + sort_order: sortOrder, + video: videoInfo, + attachments: attachmentsInfo, + }; + + return await chaptersLessonService.createLesson(input); + } + + /** + * เพิ่มวิดีโอและไฟล์แนบให้บทเรียน VIDEO ที่มีอยู่แล้ว + * Add video and attachments to an existing VIDEO type lesson + * + * @param courseId Course ID + * @param chapterId Chapter ID + * @param lessonId Lesson ID + * @param video ไฟล์วิดีโอ (required) + * @param attachments ไฟล์แนบ + */ + @Post('{lessonId}/video') + @Security('jwt', ['instructor']) + @SuccessResponse('200', 'Video added successfully') + @Response('400', 'Validation error') + @Response('401', 'Unauthorized') + @Response('403', 'Forbidden') + @Response('404', 'Lesson not found') + public async addVideoToLesson( + @Request() request: any, + @Path() courseId: number, + @Path() chapterId: number, + @Path() lessonId: number, + @UploadedFile() video: Express.Multer.File, + @UploadedFiles() attachments?: Express.Multer.File[] + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new ValidationError('No token provided'); + + if (!video) { + throw new ValidationError('Video file is required'); + } + + // Transform files to UploadedFileInfo + const videoInfo: UploadedFileInfo = { + originalname: video.originalname, + mimetype: video.mimetype, + size: video.size, + buffer: video.buffer, + }; + + let attachmentsInfo: UploadedFileInfo[] | undefined; + if (attachments && attachments.length > 0) { + attachmentsInfo = attachments.map(file => ({ + originalname: file.originalname, + mimetype: file.mimetype, + size: file.size, + buffer: file.buffer, + })); + } + + return await chaptersLessonService.addVideoLesson({ + token, + course_id: courseId, + lesson_id: lessonId, + video: videoInfo, + attachments: attachmentsInfo, + }); + } + + /** + * อัปเดตวิดีโอและไฟล์แนบของบทเรียน VIDEO + * Update video and attachments of an existing VIDEO type lesson + * + * @param courseId Course ID + * @param chapterId Chapter ID + * @param lessonId Lesson ID + * @param video ไฟล์วิดีโอใหม่ + * @param attachments ไฟล์แนบใหม่ + */ + @Put('{lessonId}/video') + @Security('jwt', ['instructor']) + @SuccessResponse('200', 'Video updated successfully') + @Response('400', 'Validation error') + @Response('401', 'Unauthorized') + @Response('403', 'Forbidden') + @Response('404', 'Lesson not found') + public async updateVideoLesson( + @Request() request: any, + @Path() courseId: number, + @Path() chapterId: number, + @Path() lessonId: number, + @UploadedFile() video?: Express.Multer.File, + @UploadedFiles() attachments?: Express.Multer.File[] + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new ValidationError('No token provided'); + + // Transform files to UploadedFileInfo + let videoInfo: UploadedFileInfo | undefined; + let attachmentsInfo: UploadedFileInfo[] | undefined; + + if (video) { + videoInfo = { + originalname: video.originalname, + mimetype: video.mimetype, + size: video.size, + buffer: video.buffer, + }; + } + + if (attachments && attachments.length > 0) { + attachmentsInfo = attachments.map(file => ({ + originalname: file.originalname, + mimetype: file.mimetype, + size: file.size, + buffer: file.buffer, + })); + } + + return await chaptersLessonService.updateVideoLesson({ + token, + course_id: courseId, + lesson_id: lessonId, + video: videoInfo, + attachments: attachmentsInfo, + }); } } - -/** - * Express router middleware wrapper for file upload - * Use this in routes like: - * - * router.post( - * '/api/instructors/courses/:courseId/chapters/:chapterId/lessons', - * authenticateMiddleware, - * handleLessonUpload, - * lessonsController.createLesson.bind(lessonsController) - * ); - */ -export const handleLessonUpload = (req: Request, res: Response, next: NextFunction) => { - lessonUpload(req, res, (err) => { - if (err) { - return res.status(400).json({ - error: { - code: 'FILE_UPLOAD_ERROR', - message: err.message, - } - }); - } - next(); - }); -}; - -export const lessonsController = new LessonsController(); diff --git a/Backend/src/services/ChaptersLesson.service.ts b/Backend/src/services/ChaptersLesson.service.ts index 10d88e8a..7a30c02e 100644 --- a/Backend/src/services/ChaptersLesson.service.ts +++ b/Backend/src/services/ChaptersLesson.service.ts @@ -2,42 +2,84 @@ import { prisma } from '../config/database'; import { Prisma } from '@prisma/client'; import { config } from '../config'; import { logger } from '../config/logger'; -import { generateFilePath, uploadFile } from '../config/minio'; +import { deleteFile, generateFilePath, uploadFile } from '../config/minio'; import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler'; import jwt from 'jsonwebtoken'; import { - LessonAttachmentData, LessonData, ChapterData, CreateLessonInput, - UpdateLessonInput, CreateChapterInput, UpdateChapterInput, ChaptersRequest, DeleteChapterRequest, ReorderChapterRequest, ListChaptersResponse, - GetChapterResponse, CreateChapterResponse, UpdateChapterResponse, DeleteChapterResponse, ReorderChapterResponse, - ChapterWithLessonsResponse, - ListLessonsRequest, GetLessonRequest, - CreateLessonRequest, UpdateLessonRequest, DeleteLessonRequest, ReorderLessonsRequest, - ListLessonsResponse, GetLessonResponse, CreateLessonResponse, UpdateLessonResponse, DeleteLessonResponse, ReorderLessonsResponse, + AddVideoToLessonInput, + UpdateVideoLessonInput, + AddQuestionInput, + AddQuestionResponse, + UpdateQuestionInput, + UpdateQuestionResponse, + DeleteQuestionInput, + DeleteQuestionResponse, + QuizQuestionData, } from "../types/ChaptersLesson.typs"; import { CoursesInstructorService } from './CoursesInstructor.service'; +/** + * ตรวจสอบสิทธิ์เข้าถึง Course (สำหรับทั้ง Instructor และ Student) + * Returns: { hasAccess: boolean, role: 'INSTRUCTOR' | 'STUDENT' | null, userId: number } + */ +async function validateCourseAccess(token: string, course_id: number): Promise<{ + hasAccess: boolean; + role: 'INSTRUCTOR' | 'STUDENT' | null; + userId: number; +}> { + const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; + const userId = decodedToken.id; + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new UnauthorizedError('Invalid token'); + } + + // Check if user is an instructor for this course + const courseInstructor = await prisma.courseInstructor.findFirst({ + where: { course_id, user_id: userId } + }); + if (courseInstructor) { + return { hasAccess: true, role: 'INSTRUCTOR', userId }; + } + + // Check if user is enrolled in this course + const enrollment = await prisma.enrollment.findFirst({ + where: { + course_id, + user_id: userId, + status: { in: ['ENROLLED', 'IN_PROGRESS', 'COMPLETED'] } + } + }); + if (enrollment) { + return { hasAccess: true, role: 'STUDENT', userId }; + } + + return { hasAccess: false, role: null, userId }; +} + export class ChaptersLessonService { async listChapters(request: ChaptersRequest): Promise { try { @@ -142,9 +184,14 @@ export class ChaptersLessonService { } } + /** + * สร้างบทเรียนเปล่า (ยังไม่มีเนื้อหา) + * Create an empty lesson with basic information (no content yet) + * สำหรับ QUIZ type จะสร้าง Quiz shell ไว้ด้วย แต่ยังไม่มีคำถาม + */ async createLesson(request: CreateLessonInput): Promise { try { - const { token, course_id, chapter_id, title, content, type, sort_order, video, attachments } = request; + const { token, course_id, chapter_id, title, content, type, sort_order } = 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 } }); @@ -156,142 +203,588 @@ export class ChaptersLessonService { throw new ForbiddenError('You are not permitted to create lesson'); } - // Create the lesson first + // Create the lesson const lesson = await prisma.lesson.create({ data: { chapter_id, title, content, type, sort_order } }); - const uploadedAttachments: { file_name: string; file_path: string; file_size: number; mime_type: string }[] = []; - - // Handle video upload for VIDEO type lessons - if (type === 'VIDEO' && video) { - const videoPath = generateFilePath(course_id, lesson.id, 'video', video.originalname); - await uploadFile(videoPath, video.buffer, video.mimetype); - - // Save video as attachment - await prisma.lessonAttachment.create({ - data: { - lesson_id: lesson.id, - file_name: video.originalname, - file_size: video.size, - mime_type: video.mimetype, - file_path: videoPath, - sort_order: 0, - } - }); - - uploadedAttachments.push({ - file_name: video.originalname, - file_path: videoPath, - file_size: video.size, - mime_type: video.mimetype, - }); - - // Handle additional attachments (PDFs, documents, etc.) - if (attachments && attachments.length > 0) { - for (let i = 0; i < attachments.length; i++) { - const attachment = attachments[i]; - const attachmentPath = generateFilePath(course_id, lesson.id, 'attachment', attachment.originalname); - await uploadFile(attachmentPath, attachment.buffer, attachment.mimetype); - - await prisma.lessonAttachment.create({ - data: { - lesson_id: lesson.id, - file_name: attachment.originalname, - file_size: attachment.size, - mime_type: attachment.mimetype, - file_path: attachmentPath, - sort_order: i + 1, // Start from 1 since video is 0 - } - }); - - uploadedAttachments.push({ - file_name: attachment.originalname, - file_path: attachmentPath, - file_size: attachment.size, - mime_type: attachment.mimetype, - }); - } - } - } else if (type === 'QUIZ' && request.quiz_data) { - // Create Quiz with Questions and Choices - const { quiz_data } = request; + // If QUIZ type, create empty Quiz shell + if (type === 'QUIZ') { const userId = decodedToken.id; - // Create the quiz - const quiz = await prisma.quiz.create({ + await prisma.quiz.create({ data: { lesson_id: lesson.id, - title: quiz_data.title, - description: quiz_data.description, - passing_score: quiz_data.passing_score ?? 60, - time_limit: quiz_data.time_limit, - shuffle_questions: quiz_data.shuffle_questions ?? false, - shuffle_choices: quiz_data.shuffle_choices ?? false, - show_answers_after_completion: quiz_data.show_answers_after_completion ?? true, + title: title, // Use lesson title as quiz title + passing_score: 60, + shuffle_questions: false, + shuffle_choices: false, + show_answers_after_completion: true, created_by: userId, } }); - // Create questions with choices - if (quiz_data.questions && quiz_data.questions.length > 0) { - for (let i = 0; i < quiz_data.questions.length; i++) { - const questionInput = quiz_data.questions[i]; - - // Create the question - const question = await prisma.question.create({ - data: { - quiz_id: quiz.id, - question: questionInput.question, - explanation: questionInput.explanation, - question_type: questionInput.question_type, - score: questionInput.score ?? 1, - sort_order: questionInput.sort_order ?? i, - } - }); - - // Create choices for this question - if (questionInput.choices && questionInput.choices.length > 0) { - for (let j = 0; j < questionInput.choices.length; j++) { - const choiceInput = questionInput.choices[j]; - await prisma.choice.create({ - data: { - question_id: question.id, - text: choiceInput.text, - is_correct: choiceInput.is_correct, - sort_order: choiceInput.sort_order ?? j, - } - }); - } - } - } - } + // Fetch complete lesson with quiz + const completeLesson = await prisma.lesson.findUnique({ + where: { id: lesson.id }, + include: { quiz: true } + }); + return { code: 200, message: 'Lesson created successfully', data: completeLesson as LessonData }; } - + return { code: 200, message: 'Lesson created successfully', data: lesson as LessonData }; + } catch (error) { + logger.error(`Error creating lesson: ${error}`); + throw error; + } + } - // Fetch the complete lesson with attachments and quiz - const completeLesson = await prisma.lesson.findUnique({ - where: { id: lesson.id }, + /** + * ดึงข้อมูลบทเรียนพร้อม attachments และ quiz + * Get lesson with attachments and quiz (if QUIZ type) + */ + async getLesson(request: GetLessonRequest): Promise { + try { + const { token, course_id, lesson_id } = request; + + // Check access for both instructor and enrolled student + const access = await validateCourseAccess(token, course_id); + if (!access.hasAccess) { + throw new ForbiddenError('You do not have access to this course'); + } + + const lesson = await prisma.lesson.findUnique({ + where: { id: lesson_id }, include: { attachments: { orderBy: { sort_order: 'asc' } }, quiz: { include: { questions: { orderBy: { sort_order: 'asc' }, - include: { - choices: { orderBy: { sort_order: 'asc' } } - } + include: { choices: { orderBy: { sort_order: 'asc' } } } } } } } }); - return { code: 200, message: 'Lesson created successfully', data: completeLesson as LessonData }; + if (!lesson) throw new NotFoundError('Lesson not found'); + + // Verify lesson belongs to the course + const chapter = await prisma.chapter.findUnique({ where: { id: lesson.chapter_id } }); + if (!chapter || chapter.course_id !== course_id) { + throw new NotFoundError('Lesson not found in this course'); + } + + // For students, check if lesson is published + if (access.role === 'STUDENT' && !lesson.is_published) { + throw new ForbiddenError('This lesson is not available yet'); + } + + return { code: 200, message: 'Lesson fetched successfully', data: lesson as LessonData }; } catch (error) { - logger.error(`Error creating lesson: ${error}`); + logger.error(`Error fetching lesson: ${error}`); throw error; } } + + async updateLesson(request: UpdateLessonRequest): Promise { + try { + const { token, course_id, lesson_id, data } = 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 update lesson'); + } + const lesson = await prisma.lesson.update({ where: { id: lesson_id }, data }); + return { code: 200, message: 'Lesson updated successfully', data: lesson as LessonData }; + } catch (error) { + logger.error(`Error updating lesson: ${error}`); + throw error; + } + } + + /** + * เรียงลำดับบทเรียนใหม่ + * Reorder lessons within a chapter + */ + async reorderLessons(request: ReorderLessonsRequest): Promise { + try { + const { token, course_id, chapter_id, lesson_ids } = 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 reorder lessons'); + + // Verify chapter exists and belongs to the course + const chapter = await prisma.chapter.findUnique({ where: { id: chapter_id } }); + if (!chapter || chapter.course_id !== course_id) { + throw new NotFoundError('Chapter not found'); + } + + // Update sort_order for each lesson + for (let i = 0; i < lesson_ids.length; i++) { + await prisma.lesson.update({ + where: { id: lesson_ids[i] }, + data: { sort_order: i } + }); + } + + // Fetch reordered lessons + const lessons = await prisma.lesson.findMany({ + where: { chapter_id: chapter_id }, + orderBy: { sort_order: 'asc' } + }); + + return { code: 200, message: 'Lessons reordered successfully', data: lessons as LessonData[] }; + } catch (error) { + logger.error(`Error reordering lessons: ${error}`); + throw error; + } + } + + /** + * ลบบทเรียนพร้อมข้อมูลที่เกี่ยวข้องทั้งหมด + * Delete lesson with all related data (quiz, questions, choices, attachments) + * ไม่สามารถลบได้ถ้าบทเรียนถูก publish แล้ว + */ + async deleteLesson(request: DeleteLessonRequest): Promise { + try { + const { token, course_id, lesson_id } = 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 delete this lesson'); + + // Fetch lesson with all related data + const lesson = await prisma.lesson.findUnique({ + where: { id: lesson_id }, + include: { + attachments: true, + quiz: { + include: { + questions: { + include: { choices: true } + } + } + } + } + }); + + if (!lesson) throw new NotFoundError('Lesson not found'); + + // Check if lesson is published + if (lesson.is_published) { + throw new ValidationError('Cannot delete published lesson. Please unpublish first.'); + } + + // Delete attachment files from MinIO + if (lesson.attachments && lesson.attachments.length > 0) { + for (const attachment of lesson.attachments) { + try { + await deleteFile(attachment.file_path); + } catch (err) { + logger.warn(`Failed to delete file from MinIO: ${attachment.file_path}`); + } + } + } + + // Delete lesson (CASCADE will delete: attachments, quiz, questions, choices) + // Based on Prisma schema: onDelete: Cascade + await prisma.lesson.delete({ where: { id: lesson_id } }); + + return { code: 200, message: 'Lesson deleted successfully' }; + } catch (error) { + logger.error(`Error deleting lesson: ${error}`); + throw error; + } + } + + /** + * เพิ่มวิดีโอและไฟล์แนบให้บทเรียนประเภท VIDEO + * Add video and attachments to an existing VIDEO type lesson + */ + async addVideoLesson(request: AddVideoToLessonInput): Promise { + try { + const { token, course_id, lesson_id, video, attachments } = 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'); + } + + // Upload video to MinIO + const videoPath = generateFilePath(course_id, lesson_id, 'video', video.originalname); + await uploadFile(videoPath, video.buffer, video.mimetype); + + // Save video as attachment + await prisma.lessonAttachment.create({ + data: { + lesson_id: lesson_id, + file_name: video.originalname, + file_size: video.size, + mime_type: video.mimetype, + file_path: videoPath, + sort_order: 0, + } + }); + + // Handle additional attachments (PDFs, documents, etc.) + if (attachments && attachments.length > 0) { + for (let i = 0; i < attachments.length; i++) { + const attachment = attachments[i]; + const attachmentPath = generateFilePath(course_id, lesson_id, 'attachment', attachment.originalname); + await uploadFile(attachmentPath, attachment.buffer, attachment.mimetype); + + await prisma.lessonAttachment.create({ + data: { + lesson_id: lesson_id, + file_name: attachment.originalname, + file_size: attachment.size, + mime_type: attachment.mimetype, + file_path: attachmentPath, + sort_order: i + 1, + } + }); + } + } + + // Fetch the complete lesson with attachments + const completeLesson = await prisma.lesson.findUnique({ + where: { id: lesson_id }, + include: { attachments: { orderBy: { sort_order: 'asc' } } } + }); + + return { code: 200, message: 'Video added to lesson successfully', data: completeLesson as LessonData }; + } catch (error) { + logger.error(`Error adding video to lesson: ${error}`); + throw error; + } + } + + async updateVideoLesson(request: UpdateVideoLessonInput): Promise { + try { + const { token, course_id, lesson_id, video, attachments } = 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'); + + 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 update video for non-VIDEO type lesson'); + + // Update video if provided + if (video) { + // Find existing video attachment (sort_order = 0 is video) + const existingVideo = await prisma.lessonAttachment.findFirst({ + where: { lesson_id: lesson_id, sort_order: 0 } + }); + + if (existingVideo) { + // Delete old video from MinIO + await deleteFile(existingVideo.file_path); + + // Upload new video to MinIO + const newVideoPath = generateFilePath(course_id, lesson_id, 'video', video.originalname); + await uploadFile(newVideoPath, video.buffer, video.mimetype); + + // Update lessonAttachment with new video info + await prisma.lessonAttachment.update({ + where: { id: existingVideo.id }, + data: { + file_name: video.originalname, + file_size: video.size, + mime_type: video.mimetype, + file_path: newVideoPath, + } + }); + } else { + // No existing video, create new one + const videoPath = generateFilePath(course_id, lesson_id, 'video', video.originalname); + await uploadFile(videoPath, video.buffer, video.mimetype); + + await prisma.lessonAttachment.create({ + data: { + lesson_id: lesson_id, + file_name: video.originalname, + file_size: video.size, + mime_type: video.mimetype, + file_path: videoPath, + sort_order: 0, + } + }); + } + } + + // Update attachments if provided + if (attachments && attachments.length > 0) { + // Find and delete existing attachments (sort_order > 0) + const existingAttachments = await prisma.lessonAttachment.findMany({ + where: { lesson_id: lesson_id, sort_order: { gt: 0 } } + }); + + // Delete old attachment files from MinIO + for (const att of existingAttachments) { + await deleteFile(att.file_path); + } + + // Delete old attachment records from database + await prisma.lessonAttachment.deleteMany({ + where: { lesson_id: lesson_id, sort_order: { gt: 0 } } + }); + + // Upload new attachments + for (let i = 0; i < attachments.length; i++) { + const attachment = attachments[i]; + const attachmentPath = generateFilePath(course_id, lesson_id, 'attachment', attachment.originalname); + await uploadFile(attachmentPath, attachment.buffer, attachment.mimetype); + + await prisma.lessonAttachment.create({ + data: { + lesson_id: lesson_id, + file_name: attachment.originalname, + file_size: attachment.size, + mime_type: attachment.mimetype, + file_path: attachmentPath, + sort_order: i + 1, + } + }); + } + } + + // Fetch updated lesson with attachments + const updatedLesson = await prisma.lesson.findUnique({ + where: { id: lesson_id }, + include: { attachments: { orderBy: { sort_order: 'asc' } } } + }); + + return { code: 200, message: 'Video lesson updated successfully', data: updatedLesson as LessonData }; + } catch (error) { + logger.error(`Error updating video lesson: ${error}`); + throw error; + } + } + + /** + * เพิ่มคำถามทีละข้อให้ Quiz Lesson + * Add a single question with choices to an existing QUIZ lesson + */ + async addQuestion(request: AddQuestionInput): Promise { + try { + const { token, course_id, lesson_id, question, explanation, question_type, score, sort_order, choices } = 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 QUIZ type + const lesson = await prisma.lesson.findUnique({ + where: { id: lesson_id }, + include: { quiz: true } + }); + if (!lesson) throw new NotFoundError('Lesson not found'); + if (lesson.type !== 'QUIZ') throw new ValidationError('Cannot add question to non-QUIZ type lesson'); + if (!lesson.quiz) throw new NotFoundError('Quiz not found for this lesson'); + + // Get current max sort_order + const maxSortOrder = await prisma.question.aggregate({ + where: { quiz_id: lesson.quiz.id }, + _max: { sort_order: true } + }); + const nextSortOrder = sort_order ?? ((maxSortOrder._max.sort_order ?? -1) + 1); + + // Create the question + const newQuestion = await prisma.question.create({ + data: { + quiz_id: lesson.quiz.id, + question: question, + explanation: explanation, + question_type: question_type, + score: score ?? 1, + sort_order: nextSortOrder, + } + }); + + // Create choices if provided + if (choices && choices.length > 0) { + for (let i = 0; i < choices.length; i++) { + const choiceInput = choices[i]; + await prisma.choice.create({ + data: { + question_id: newQuestion.id, + text: choiceInput.text, + is_correct: choiceInput.is_correct, + sort_order: choiceInput.sort_order ?? i, + } + }); + } + } + + // Fetch complete question with choices + const completeQuestion = await prisma.question.findUnique({ + where: { id: newQuestion.id }, + include: { choices: { orderBy: { sort_order: 'asc' } } } + }); + + return { code: 200, message: 'Question added successfully', data: completeQuestion as QuizQuestionData }; + } catch (error) { + logger.error(`Error adding question: ${error}`); + throw error; + } + } + + /** + * อัปเดตคำถาม + * Update a question and optionally replace all choices + */ + async updateQuestion(request: UpdateQuestionInput): Promise { + try { + const { token, course_id, lesson_id, question_id, question, explanation, question_type, score, sort_order, choices } = 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 QUIZ type + const lesson = await prisma.lesson.findUnique({ + where: { id: lesson_id }, + include: { quiz: true } + }); + if (!lesson) throw new NotFoundError('Lesson not found'); + if (lesson.type !== 'QUIZ') throw new ValidationError('Cannot update question in non-QUIZ type lesson'); + if (!lesson.quiz) throw new NotFoundError('Quiz not found for this lesson'); + + // Verify question exists and belongs to this quiz + const existingQuestion = await prisma.question.findUnique({ where: { id: question_id } }); + if (!existingQuestion || existingQuestion.quiz_id !== lesson.quiz.id) { + throw new NotFoundError('Question not found in this quiz'); + } + + // Update the question + await prisma.question.update({ + where: { id: question_id }, + data: { + question: (question ?? existingQuestion.question) as Prisma.InputJsonValue, + explanation: (explanation ?? existingQuestion.explanation) as Prisma.InputJsonValue, + question_type: question_type ?? existingQuestion.question_type, + score: score ?? existingQuestion.score, + sort_order: sort_order ?? existingQuestion.sort_order, + } + }); + + // If choices provided, replace all choices + if (choices && choices.length > 0) { + // Delete existing choices + await prisma.choice.deleteMany({ where: { question_id: question_id } }); + + // Create new choices + for (let i = 0; i < choices.length; i++) { + const choiceInput = choices[i]; + await prisma.choice.create({ + data: { + question_id: question_id, + text: choiceInput.text, + is_correct: choiceInput.is_correct, + sort_order: choiceInput.sort_order ?? i, + } + }); + } + } + + // Fetch complete question with choices + const completeQuestion = await prisma.question.findUnique({ + where: { id: question_id }, + include: { choices: { orderBy: { sort_order: 'asc' } } } + }); + + return { code: 200, message: 'Question updated successfully', data: completeQuestion as QuizQuestionData }; + } catch (error) { + logger.error(`Error updating question: ${error}`); + throw error; + } + } + + /** + * ลบคำถาม + * Delete a question and all its choices + */ + async deleteQuestion(request: DeleteQuestionInput): Promise { + try { + const { token, course_id, lesson_id, question_id } = 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 QUIZ type + const lesson = await prisma.lesson.findUnique({ + where: { id: lesson_id }, + include: { quiz: true } + }); + if (!lesson) throw new NotFoundError('Lesson not found'); + if (lesson.type !== 'QUIZ') throw new ValidationError('Cannot delete question from non-QUIZ type lesson'); + if (!lesson.quiz) throw new NotFoundError('Quiz not found for this lesson'); + + // Verify question exists and belongs to this quiz + const existingQuestion = await prisma.question.findUnique({ where: { id: question_id } }); + if (!existingQuestion || existingQuestion.quiz_id !== lesson.quiz.id) { + throw new NotFoundError('Question not found in this quiz'); + } + + // Delete the question (CASCADE will delete choices) + await prisma.question.delete({ where: { id: question_id } }); + + return { code: 200, message: 'Question deleted successfully' }; + } catch (error) { + logger.error(`Error deleting question: ${error}`); + throw error; + } + } + } \ No newline at end of file diff --git a/Backend/src/types/ChaptersLesson.typs.ts b/Backend/src/types/ChaptersLesson.typs.ts index 237df861..92f6d127 100644 --- a/Backend/src/types/ChaptersLesson.typs.ts +++ b/Backend/src/types/ChaptersLesson.typs.ts @@ -190,27 +190,10 @@ export interface ReorderChapterResponse { } // ============================================ -// Chapter with Full Lessons (for detailed view) -// ============================================ - -export interface ChapterWithLessonsResponse { - code: number; - message: string; - data: ChapterData & { - lessons: LessonData[]; - }; -} - // ============================================ // Lesson Request Types // ============================================ -export interface ListLessonsRequest { - token: string; - course_id: number; - chapter_id: number; -} - export interface GetLessonRequest { token: string; course_id: number; @@ -315,7 +298,6 @@ export interface CreateLessonRequest { export interface UpdateLessonInput { title?: MultiLanguageText; content?: MultiLanguageText; - type?: 'VIDEO' | 'QUIZ'; duration_minutes?: number; sort_order?: number; is_sequential?: boolean; @@ -350,13 +332,6 @@ export interface ReorderLessonsRequest { // Lesson Response Types // ============================================ -export interface ListLessonsResponse { - code: number; - message: string; - data: LessonData[]; - total: number; -} - export interface GetLessonResponse { code: number; message: string; @@ -375,6 +350,14 @@ export interface UpdateLessonResponse { data: LessonData; } +export interface UpdateVideoLessonInput { + token: string; + course_id: number; + lesson_id: number; + video?: UploadedFileInfo; + attachments?: UploadedFileInfo[]; +} + export interface DeleteLessonResponse { code: number; message: string; @@ -398,4 +381,163 @@ export interface LessonWithDetailsResponse { quiz: QuizData | null; progress: LessonProgressData[]; }; +} + +// ============================================ +// Add Video/Quiz to Lesson Input Types +// ============================================ + +/** + * Input for adding video and attachments to an existing VIDEO lesson + */ +export interface AddVideoToLessonInput { + token: string; + course_id: number; + lesson_id: number; + video: UploadedFileInfo; + attachments?: UploadedFileInfo[]; +} + +/** + * Input for adding quiz to an existing QUIZ lesson + */ +export interface AddQuizToLessonInput { + token: string; + course_id: number; + lesson_id: number; + quiz_data: { + title: MultiLanguageText; + description?: MultiLanguageText; + passing_score?: number; + time_limit?: number; + shuffle_questions?: boolean; + shuffle_choices?: boolean; + show_answers_after_completion?: boolean; + questions: CreateQuizQuestionInput[]; + }; +} + +/** + * Input for adding a single question to a quiz lesson + */ +export interface AddQuestionInput { + token: string; + course_id: number; + lesson_id: number; + question: MultiLanguageText; + explanation?: MultiLanguageText; + question_type: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER'; + score?: number; + sort_order?: number; + choices?: CreateQuizChoiceInput[]; +} + +/** + * Response for adding a question + */ +export interface AddQuestionResponse { + code: number; + message: string; + data: QuizQuestionData; +} + +/** + * Input for updating a question + */ +export interface UpdateQuestionInput { + token: string; + course_id: number; + lesson_id: number; + question_id: number; + question?: MultiLanguageText; + explanation?: MultiLanguageText; + question_type?: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER'; + score?: number; + sort_order?: number; + choices?: CreateQuizChoiceInput[]; // Replace all choices if provided +} + +/** + * Response for updating a question + */ +export interface UpdateQuestionResponse { + code: number; + message: string; + data: QuizQuestionData; +} + +/** + * Input for deleting a question + */ +export interface DeleteQuestionInput { + token: string; + course_id: number; + lesson_id: number; + question_id: number; +} + +/** + * Response for deleting a question + */ +export interface DeleteQuestionResponse { + code: number; + message: string; +} + +// ============================================ +// Controller Body Request Types +// ============================================ + +export interface CreateChapterBody { + title: MultiLanguageText; + description?: MultiLanguageText; + sort_order?: number; +} + +export interface UpdateChapterBody { + title?: MultiLanguageText; + description?: MultiLanguageText; + sort_order?: number; + is_published?: boolean; +} + +export interface ReorderChapterBody { + sort_order: number; +} + +export interface CreateLessonBody { + title: MultiLanguageText; + content?: MultiLanguageText; + type: 'VIDEO' | 'QUIZ'; + sort_order?: number; +} + +export interface UpdateLessonBody { + title?: MultiLanguageText; + content?: MultiLanguageText; + duration_minutes?: number; + sort_order?: number; + is_published?: boolean; +} + +export interface ReorderLessonsBody { + lesson_ids: number[]; +} + +export interface AddQuestionBody { + question: MultiLanguageText; + explanation?: MultiLanguageText; + question_type: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER'; + score?: number; + sort_order?: number; + choices?: CreateQuizChoiceInput[]; +} + +export interface UpdateQuestionBody { + question?: MultiLanguageText; + explanation?: MultiLanguageText; + question_type?: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER'; + score?: number; + sort_order?: number; + choices?: CreateQuizChoiceInput[]; } \ No newline at end of file