import { prisma } from '../config/database'; import { Prisma } from '@prisma/client'; import { config } from '../config'; import { logger } from '../config/logger'; import { deleteFile, generateFilePath, uploadFile, getPresignedUrl, listObjects, getVideoFolder, getAttachmentsFolder } from '../config/minio'; import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler'; import jwt from 'jsonwebtoken'; import { LessonData, ChapterData, CreateLessonInput, CreateChapterInput, UpdateChapterInput, ChaptersRequest, DeleteChapterRequest, ReorderChapterRequest, ListChaptersResponse, CreateChapterResponse, UpdateChapterResponse, DeleteChapterResponse, ReorderChapterResponse, GetLessonRequest, UpdateLessonRequest, DeleteLessonRequest, ReorderLessonsRequest, GetLessonResponse, CreateLessonResponse, UpdateLessonResponse, DeleteLessonResponse, ReorderLessonsResponse, AddQuestionInput, AddQuestionResponse, UpdateQuestionInput, UpdateQuestionResponse, DeleteQuestionInput, DeleteQuestionResponse, QuizQuestionData, ReorderQuestionInput, ReorderQuestionResponse, UploadVideoInput, UpdateVideoInput, SetYouTubeVideoInput, YouTubeVideoResponse, UploadAttachmentInput, DeleteAttachmentInput, VideoOperationResponse, AttachmentOperationResponse, DeleteAttachmentResponse, LessonAttachmentData, UpdateQuizInput, UpdateQuizResponse, QuizData, } from "../types/ChaptersLesson.typs"; import { CoursesInstructorService } from './CoursesInstructor.service'; import { auditService } from './audit.service'; import { AuditAction } from '@prisma/client'; /** * ตรวจสอบสิทธิ์เข้าถึง 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 { const { token, course_id } = request; const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); if (!user) { throw new UnauthorizedError('Invalid token'); } const chapters = await prisma.chapter.findMany({ where: { course_id }, orderBy: { sort_order: 'asc' }, include: { lessons: { orderBy: { sort_order: 'asc' } } } }); return { code: 200, message: 'Chapters fetched successfully', data: chapters as ChapterData[], total: chapters.length }; } catch (error) { logger.error(`Error fetching chapters: ${error}`); throw error; } } async createChapter(request: CreateChapterInput): Promise { try { const { token, course_id, title, description, 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 } }); 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 create chapter'); } const chapter = await prisma.chapter.create({ data: { course_id, title, description, sort_order } }); // Audit log - CREATE Chapter auditService.log({ userId: decodedToken.id, action: AuditAction.CREATE, entityType: 'Chapter', entityId: chapter.id, newValue: { course_id, title, sort_order } }); return { code: 200, message: 'Chapter created successfully', data: chapter as ChapterData }; } catch (error) { logger.error(`Error creating chapter: ${error}`); const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ userId: decodedToken?.id || 0, action: AuditAction.ERROR, entityType: 'Chapter', entityId: 0, metadata: { operation: 'create_chapter', error: error instanceof Error ? error.message : String(error) } }); throw error; } } async updateChapter(request: UpdateChapterInput): Promise { try { const { token, course_id, chapter_id, title, description, 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 } }); 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 chapter'); } const chapter = await prisma.chapter.update({ where: { id: chapter_id }, data: { title, description, sort_order } }); return { code: 200, message: 'Chapter updated successfully', data: chapter as ChapterData }; } catch (error) { logger.error(`Error updating chapter: ${error}`); const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ userId: decodedToken?.id || 0, action: AuditAction.ERROR, entityType: 'Chapter', entityId: request.chapter_id, metadata: { operation: 'update_chapter', error: error instanceof Error ? error.message : String(error) } }); throw error; } } async deleteChapter(request: DeleteChapterRequest): Promise { try { const { token, course_id, chapter_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 chapter'); } await prisma.chapter.delete({ where: { id: chapter_id } }); // Audit log - DELETE Chapter auditService.log({ userId: decodedToken.id, action: AuditAction.DELETE, entityType: 'Chapter', entityId: chapter_id, oldValue: { course_id, chapter_id } }); // Normalize sort_order for remaining chapters (fill gaps) await this.normalizeChapterSortOrder(course_id); return { code: 200, message: 'Chapter deleted successfully' }; } catch (error) { logger.error(`Error deleting chapter: ${error}`); const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ userId: decodedToken?.id || 0, action: AuditAction.ERROR, entityType: 'Chapter', entityId: request.chapter_id, metadata: { operation: 'delete_chapter', error: error instanceof Error ? error.message : String(error) } }); throw error; } } async reorderChapter(request: ReorderChapterRequest): Promise { try { const { token, course_id, chapter_id, sort_order: newSortOrder } = 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 chapter'); } // Get current chapter to find its current sort_order const currentChapter = await prisma.chapter.findUnique({ where: { id: chapter_id } }); if (!currentChapter) { throw new NotFoundError('Chapter not found'); } // Validate chapter belongs to the specified course if (currentChapter.course_id !== course_id) { throw new NotFoundError('Chapter not found in this course'); } // First, normalize sort_order to fix any gaps or duplicates await this.normalizeChapterSortOrder(course_id); // Re-fetch the chapter to get updated sort_order after normalization const normalizedChapter = await prisma.chapter.findUnique({ where: { id: chapter_id } }); if (!normalizedChapter) throw new NotFoundError('Chapter not found'); const oldSortOrder = normalizedChapter.sort_order; // Get total chapter count to validate sort_order (1-based) const chapterCount = await prisma.chapter.count({ where: { course_id } }); const validNewSortOrder = Math.max(1, Math.min(newSortOrder, chapterCount)); // If same position, no need to reorder if (oldSortOrder === validNewSortOrder) { const chapters = await prisma.chapter.findMany({ where: { course_id }, orderBy: { sort_order: 'asc' } }); return { code: 200, message: 'Chapter reordered successfully', data: chapters as ChapterData[] }; } // Shift other chapters to make room for the insert if (oldSortOrder > validNewSortOrder) { // Moving up: shift chapters between newSortOrder and oldSortOrder-1 down by 1 await prisma.chapter.updateMany({ where: { course_id, sort_order: { gte: validNewSortOrder, lt: oldSortOrder } }, data: { sort_order: { increment: 1 } } }); } else { // Moving down: shift chapters between oldSortOrder+1 and newSortOrder up by 1 await prisma.chapter.updateMany({ where: { course_id, sort_order: { gt: oldSortOrder, lte: validNewSortOrder } }, data: { sort_order: { decrement: 1 } } }); } // Update the target chapter to the new position await prisma.chapter.update({ where: { id: chapter_id }, data: { sort_order: validNewSortOrder } }); // Fetch all chapters with updated order const chapters = await prisma.chapter.findMany({ where: { course_id }, orderBy: { sort_order: 'asc' } }); return { code: 200, message: 'Chapter reordered successfully', data: chapters as ChapterData[] }; } catch (error) { logger.error(`Error reordering chapter: ${error}`); const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ userId: decodedToken?.id || 0, action: AuditAction.ERROR, entityType: 'Chapter', entityId: request.chapter_id, metadata: { operation: 'reorder_chapter', error: error instanceof Error ? error.message : String(error) } }); throw error; } } /** * สร้างบทเรียนเปล่า (ยังไม่มีเนื้อหา) * 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 } = 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 create lesson'); } // Create the lesson const lesson = await prisma.lesson.create({ data: { chapter_id, title, content, type, sort_order } }); // If QUIZ type, create empty Quiz shell if (type === 'QUIZ') { const userId = decodedToken.id; await prisma.quiz.create({ data: { lesson_id: lesson.id, 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, } }); // Fetch complete lesson with quiz const completeLesson = await prisma.lesson.findUnique({ where: { id: lesson.id }, include: { quiz: true } }); // Audit log - CREATE Lesson (QUIZ) auditService.log({ userId: decodedToken.id, action: AuditAction.CREATE, entityType: 'Lesson', entityId: lesson.id, newValue: { chapter_id, title, type: 'QUIZ', sort_order } }); return { code: 200, message: 'Lesson created successfully', data: completeLesson as LessonData }; } // Audit log - CREATE Lesson auditService.log({ userId: decodedToken.id, action: AuditAction.CREATE, entityType: 'Lesson', entityId: lesson.id, newValue: { chapter_id, title, type, sort_order } }); return { code: 200, message: 'Lesson created successfully', data: lesson as LessonData }; } catch (error) { logger.error(`Error creating lesson: ${error}`); const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ userId: decodedToken?.id || 0, action: AuditAction.ERROR, entityType: 'Lesson', entityId: 0, metadata: { operation: 'create_lesson', error: error instanceof Error ? error.message : String(error) } }); throw error; } } /** * ดึงข้อมูลบทเรียนพร้อม 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' } } } } } } } }); 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'); } // Get video URL - check for YouTube or MinIO let video_url: string | null = null; 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}`); } } } // Get attachments with presigned URLs from MinIO const attachmentsWithUrls: { id: number; lesson_id: number; file_name: string; file_path: string; file_size: number; mime_type: string; sort_order: number; created_at: Date; presigned_url: string | null; }[] = []; try { const attachmentsPrefix = getAttachmentsFolder(course_id, lesson_id); const attachmentFiles = await listObjects(attachmentsPrefix); for (const file of attachmentFiles) { let presigned_url: string | null = null; try { presigned_url = await getPresignedUrl(file.name, 3600); } catch (err) { logger.error(`Failed to generate presigned URL for ${file.name}: ${err}`); } // Extract filename from path const fileName = file.name.split('/').pop() || file.name; // Guess mime type from extension const ext = fileName.split('.').pop()?.toLowerCase() || ''; const mimeTypes: { [key: string]: string } = { 'pdf': 'application/pdf', 'doc': 'application/msword', 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'ppt': 'application/vnd.ms-powerpoint', 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'xls': 'application/vnd.ms-excel', 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif', 'mp4': 'video/mp4', 'zip': 'application/zip', }; const mime_type = mimeTypes[ext] || 'application/octet-stream'; // Find matching attachment from database or create entry const dbAttachment = lesson.attachments?.find(a => a.file_path === file.name); attachmentsWithUrls.push({ id: dbAttachment?.id || 0, lesson_id: lesson_id, file_name: fileName, file_path: file.name, file_size: file.size, mime_type: dbAttachment?.mime_type || mime_type, sort_order: dbAttachment?.sort_order || attachmentsWithUrls.length, created_at: dbAttachment?.created_at || file.lastModified, presigned_url, }); } } catch (err) { logger.error(`Failed to list attachments from MinIO: ${err}`); } // Build response with MinIO URLs const lessonData = { ...lesson, video_url, attachments: attachmentsWithUrls.length > 0 ? attachmentsWithUrls : lesson.attachments, }; return { code: 200, message: 'Lesson fetched successfully', data: lessonData as LessonData }; } catch (error) { logger.error(`Error fetching lesson: ${error}`); const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ userId: decodedToken?.id || 0, action: AuditAction.ERROR, entityType: 'Lesson', entityId: request.lesson_id, metadata: { operation: 'get_lesson', error: error instanceof Error ? error.message : String(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}`); const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ userId: decodedToken?.id || 0, action: AuditAction.ERROR, entityType: 'Lesson', entityId: request.lesson_id, metadata: { operation: 'update_lesson', error: error instanceof Error ? error.message : String(error) } }); throw error; } } /** * เรียงลำดับบทเรียนใหม่ * Reorder lessons within a chapter */ async reorderLessons(request: ReorderLessonsRequest): Promise { try { const { token, course_id, chapter_id, lesson_id, sort_order: newSortOrder } = 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'); } // Get current lesson to find its current sort_order const currentLesson = await prisma.lesson.findUnique({ where: { id: lesson_id } }); if (!currentLesson) { throw new NotFoundError('Lesson not found'); } if (currentLesson.chapter_id !== chapter_id) { throw new NotFoundError('Lesson not found in this chapter'); } // First, normalize sort_order to fix any gaps or duplicates await this.normalizeLessonSortOrder(chapter_id); // Re-fetch the lesson to get updated sort_order after normalization const normalizedLesson = await prisma.lesson.findUnique({ where: { id: lesson_id } }); if (!normalizedLesson) throw new NotFoundError('Lesson not found'); const oldSortOrder = normalizedLesson.sort_order; // Get total lesson count to validate sort_order (1-based) const lessonCount = await prisma.lesson.count({ where: { chapter_id } }); const validNewSortOrder = Math.max(1, Math.min(newSortOrder, lessonCount)); // If same position, no need to reorder if (oldSortOrder === validNewSortOrder) { const lessons = await prisma.lesson.findMany({ where: { chapter_id }, orderBy: { sort_order: 'asc' } }); return { code: 200, message: 'Lessons reordered successfully', data: lessons as LessonData[] }; } // Shift other lessons to make room for the insert if (oldSortOrder > validNewSortOrder) { // Moving up: shift lessons between newSortOrder and oldSortOrder-1 down by 1 await prisma.lesson.updateMany({ where: { chapter_id, sort_order: { gte: validNewSortOrder, lt: oldSortOrder } }, data: { sort_order: { increment: 1 } } }); } else { // Moving down: shift lessons between oldSortOrder+1 and newSortOrder up by 1 await prisma.lesson.updateMany({ where: { chapter_id, sort_order: { gt: oldSortOrder, lte: validNewSortOrder } }, data: { sort_order: { decrement: 1 } } }); } // Update the target lesson to the new position await prisma.lesson.update({ where: { id: lesson_id }, data: { sort_order: validNewSortOrder } }) // Fetch all lessons with updated order const lessons = await prisma.lesson.findMany({ where: { 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}`); const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ userId: decodedToken?.id || 0, action: AuditAction.ERROR, entityType: 'Lesson', entityId: request.lesson_id, metadata: { operation: 'reorder_lessons', error: error instanceof Error ? error.message : String(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'); // 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}`); } } } // Get chapter_id before deletion for normalization const chapterId = lesson.chapter_id; // Delete lesson (CASCADE will delete: attachments, quiz, questions, choices) // Based on Prisma schema: onDelete: Cascade await prisma.lesson.delete({ where: { id: lesson_id } }); // Audit log - DELETE Lesson auditService.log({ userId: decodedToken.id, action: AuditAction.DELETE, entityType: 'Lesson', entityId: lesson_id, oldValue: { chapter_id: chapterId, title: lesson.title, type: lesson.type } }); // Normalize sort_order for remaining lessons (fill gaps) await this.normalizeLessonSortOrder(chapterId); return { code: 200, message: 'Lesson deleted successfully' }; } catch (error) { logger.error(`Error deleting lesson: ${error}`); const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ userId: decodedToken?.id || 0, action: AuditAction.ERROR, entityType: 'Lesson', entityId: request.lesson_id, metadata: { operation: 'delete_lesson', error: error instanceof Error ? error.message : String(error) } }); throw error; } } // ============================================ // Separate Video/Attachment APIs // ============================================ /** * อัพโหลดวิดีโอใหม่ให้บทเรียน (ครั้งแรก) * Upload video to lesson (first time) */ async uploadVideo(request: UploadVideoInput): Promise { try { const { token, course_id, lesson_id, video } = 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'); // Check if video already exists const existingVideo = await prisma.lessonAttachment.findFirst({ where: { lesson_id, sort_order: 0 } }); if (existingVideo) { throw new ValidationError('Video already exists. Use update video API instead.'); } // 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 (sort_order = 0 for video) await prisma.lessonAttachment.create({ data: { lesson_id, file_name: video.originalname, file_size: video.size, mime_type: video.mimetype, file_path: videoPath, sort_order: 0, } }); // Get presigned URL const video_url = await getPresignedUrl(videoPath, 3600); // Audit log - UPLOAD_FILE (Video) auditService.log({ userId: decodedToken.id, action: AuditAction.UPLOAD_FILE, entityType: 'Lesson', entityId: lesson_id, newValue: { file_name: video.originalname, file_size: video.size, mime_type: video.mimetype } }); return { code: 200, message: 'Video uploaded successfully', data: { lesson_id, video_url, file_name: video.originalname, file_size: video.size, mime_type: video.mimetype, } }; } catch (error) { logger.error(`Error uploading video: ${error}`); const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ userId: decodedToken?.id || 0, action: AuditAction.ERROR, entityType: 'Lesson', entityId: request.lesson_id, metadata: { operation: 'upload_video', error: error instanceof Error ? error.message : String(error) } }); throw error; } } /** * อัพเดต (เปลี่ยน) วิดีโอของบทเรียน * Update (replace) video in lesson */ async updateVideo(request: UpdateVideoInput): Promise { try { const { token, course_id, lesson_id, video } = 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 update video for non-VIDEO type lesson'); // Find existing video const existingVideo = await prisma.lessonAttachment.findFirst({ where: { lesson_id, sort_order: 0 } }); // Upload new video to MinIO const newVideoPath = generateFilePath(course_id, lesson_id, 'video', video.originalname); await uploadFile(newVideoPath, video.buffer, video.mimetype); if (existingVideo) { // Delete old video from MinIO try { await deleteFile(existingVideo.file_path); } catch (err) { logger.warn(`Failed to delete old video: ${existingVideo.file_path}`); } // 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 await prisma.lessonAttachment.create({ data: { lesson_id, file_name: video.originalname, file_size: video.size, mime_type: video.mimetype, file_path: newVideoPath, sort_order: 0, } }); } // Get presigned URL const video_url = await getPresignedUrl(newVideoPath, 3600); return { code: 200, message: 'Video updated successfully', data: { lesson_id, video_url, file_name: video.originalname, file_size: video.size, mime_type: video.mimetype, } }; } catch (error) { logger.error(`Error updating video: ${error}`); const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ userId: decodedToken?.id || 0, action: AuditAction.ERROR, entityType: 'Lesson', entityId: request.lesson_id, metadata: { operation: 'update_video', error: error instanceof Error ? error.message : String(error) } }); throw error; } } /** * ตั้งค่าวิดีโอ YouTube ให้บทเรียน * Set YouTube video for a lesson (replaces existing video if any) */ async setYouTubeVideo(request: SetYouTubeVideoInput): Promise { 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}`); const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ userId: decodedToken?.id || 0, action: AuditAction.ERROR, entityType: 'Lesson', entityId: request.lesson_id, metadata: { operation: 'set_youtube_video', error: error instanceof Error ? error.message : String(error) } }); throw error; } } /** * อัพโหลดไฟล์แนบทีละไฟล์ * Upload a single attachment to lesson */ async uploadAttachment(request: UploadAttachmentInput): Promise { try { const { token, course_id, lesson_id, attachment } = 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 const lesson = await prisma.lesson.findUnique({ where: { id: lesson_id } }); if (!lesson) throw new NotFoundError('Lesson not found'); // Get current max sort_order for attachments (excluding video at sort_order 0) const maxSortOrder = await prisma.lessonAttachment.aggregate({ where: { lesson_id, sort_order: { gt: 0 } }, _max: { sort_order: true } }); const nextSortOrder = (maxSortOrder._max.sort_order ?? 0) + 1; // Upload attachment to MinIO const attachmentPath = generateFilePath(course_id, lesson_id, 'attachment', attachment.originalname); await uploadFile(attachmentPath, attachment.buffer, attachment.mimetype); // Save attachment to database const newAttachment = await prisma.lessonAttachment.create({ data: { lesson_id, file_name: attachment.originalname, file_size: attachment.size, mime_type: attachment.mimetype, file_path: attachmentPath, sort_order: nextSortOrder, } }); // Get presigned URL const presigned_url = await getPresignedUrl(attachmentPath, 3600); // Audit log - UPLOAD_FILE (Attachment) auditService.log({ userId: decodedToken.id, action: AuditAction.UPLOAD_FILE, entityType: 'LessonAttachment', entityId: newAttachment.id, newValue: { lesson_id, file_name: attachment.originalname, file_size: attachment.size } }); return { code: 200, message: 'Attachment uploaded successfully', data: { id: newAttachment.id, lesson_id: newAttachment.lesson_id, file_name: newAttachment.file_name, file_path: newAttachment.file_path, file_size: newAttachment.file_size, mime_type: newAttachment.mime_type, sort_order: newAttachment.sort_order, created_at: newAttachment.created_at, presigned_url, } as LessonAttachmentData }; } catch (error) { logger.error(`Error uploading attachment: ${error}`); const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ userId: decodedToken?.id || 0, action: AuditAction.ERROR, entityType: 'LessonAttachment', entityId: request.lesson_id, metadata: { operation: 'upload_attachment', error: error instanceof Error ? error.message : String(error) } }); throw error; } } /** * ลบไฟล์แนบทีละไฟล์ * Delete a single attachment from lesson */ async deleteAttachment(request: DeleteAttachmentInput): Promise { try { const { token, course_id, lesson_id, attachment_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 const lesson = await prisma.lesson.findUnique({ where: { id: lesson_id } }); if (!lesson) throw new NotFoundError('Lesson not found'); // Find attachment const attachment = await prisma.lessonAttachment.findUnique({ where: { id: attachment_id } }); if (!attachment) throw new NotFoundError('Attachment not found'); if (attachment.lesson_id !== lesson_id) throw new NotFoundError('Attachment not found in this lesson'); // Don't allow deleting video (sort_order = 0) if (attachment.sort_order === 0) { throw new ValidationError('Cannot delete video using this API. Use delete video API instead.'); } // Delete file from MinIO try { await deleteFile(attachment.file_path); } catch (err) { logger.warn(`Failed to delete file from MinIO: ${attachment.file_path}`); } // Delete attachment record from database await prisma.lessonAttachment.delete({ where: { id: attachment_id } }); // Audit log - DELETE_FILE (Attachment) auditService.log({ userId: decodedToken.id, action: AuditAction.DELETE_FILE, entityType: 'LessonAttachment', entityId: attachment_id, oldValue: { lesson_id, file_name: attachment.file_name, file_path: attachment.file_path } }); return { code: 200, message: 'Attachment deleted successfully' }; } catch (error) { logger.error(`Error deleting attachment: ${error}`); const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ userId: decodedToken?.id || 0, action: AuditAction.ERROR, entityType: 'LessonAttachment', entityId: request.attachment_id, metadata: { operation: 'delete_attachment', error: error instanceof Error ? error.message : String(error) } }); throw error; } } /** * เพิ่มคำถามทีละข้อให้ Quiz Lesson * Add a single question with choices to an existing QUIZ lesson * คะแนนจะถูกคำนวณอัตโนมัติ (100 คะแนน / จำนวนข้อ) */ async addQuestion(request: AddQuestionInput): Promise { try { const { token, course_id, lesson_id, question, explanation, question_type, 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 with temporary score (will be recalculated) const newQuestion = await prisma.question.create({ data: { quiz_id: lesson.quiz.id, question: question, explanation: explanation, question_type: question_type, score: 1, // Temporary, will be recalculated 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, } }); } } // Recalculate scores for all questions (100 points total) await this.recalculateQuestionScores(lesson.quiz.id); // Fetch complete question with choices (with updated score) 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}`); const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ userId: decodedToken?.id || 0, action: AuditAction.ERROR, entityType: 'Question', entityId: 0, metadata: { operation: 'add_question', error: error instanceof Error ? error.message : String(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, 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 (score is NOT updated - it's auto-calculated) 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, 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}`); const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ userId: decodedToken?.id || 0, action: AuditAction.ERROR, entityType: 'Question', entityId: request.question_id, metadata: { operation: 'update_question', error: error instanceof Error ? error.message : String(error) } }); throw error; } } async reorderQuestion(request: ReorderQuestionInput): Promise { try { const { token, course_id, lesson_id, question_id, 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 } }); 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 reorder 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'); } const quizId = lesson.quiz.id; // First, normalize sort_order to fix any gaps or duplicates await this.normalizeQuestionSortOrder(quizId); // Re-fetch the question to get updated sort_order after normalization const normalizedQuestion = await prisma.question.findUnique({ where: { id: question_id } }); if (!normalizedQuestion) throw new NotFoundError('Question not found'); const oldSortOrder = normalizedQuestion.sort_order; // Get total question count to validate sort_order (1-based) const questionCount = await prisma.question.count({ where: { quiz_id: quizId } }); const newSortOrder = Math.max(1, Math.min(sort_order, questionCount)); // If same position, no need to reorder if (oldSortOrder === newSortOrder) { const questions = await prisma.question.findMany({ where: { quiz_id: quizId }, orderBy: { sort_order: 'asc' }, include: { choices: { orderBy: { sort_order: 'asc' } } } }); return { code: 200, message: 'Question reordered successfully', data: questions as QuizQuestionData[] }; } // Shift other questions to make room for the insert if (oldSortOrder > newSortOrder) { // Moving up: shift questions between newSortOrder and oldSortOrder-1 down by 1 await prisma.question.updateMany({ where: { quiz_id: quizId, sort_order: { gte: newSortOrder, lt: oldSortOrder } }, data: { sort_order: { increment: 1 } } }); } else { // Moving down: shift questions between oldSortOrder+1 and newSortOrder up by 1 await prisma.question.updateMany({ where: { quiz_id: quizId, sort_order: { gt: oldSortOrder, lte: newSortOrder } }, data: { sort_order: { decrement: 1 } } }); } // Update the question's sort order await prisma.question.update({ where: { id: question_id }, data: { sort_order: newSortOrder } }); // Fetch all questions with updated order const questions = await prisma.question.findMany({ where: { quiz_id: quizId }, orderBy: { sort_order: 'asc' }, include: { choices: { orderBy: { sort_order: 'asc' } } } }); return { code: 200, message: 'Question reordered successfully', data: questions as QuizQuestionData[] }; } catch (error) { logger.error(`Error reordering question: ${error}`); const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ userId: decodedToken?.id || 0, action: AuditAction.ERROR, entityType: 'Question', entityId: request.question_id, metadata: { operation: 'reorder_question', error: error instanceof Error ? error.message : String(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 } }); // Normalize sort_order for remaining questions (fill gaps) await this.normalizeQuestionSortOrder(lesson.quiz.id); // Recalculate scores for remaining questions await this.recalculateQuestionScores(lesson.quiz.id); return { code: 200, message: 'Question deleted successfully' }; } catch (error) { logger.error(`Error deleting question: ${error}`); const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ userId: decodedToken?.id || 0, action: AuditAction.ERROR, entityType: 'Question', entityId: request.question_id, metadata: { operation: 'delete_question', error: error instanceof Error ? error.message : String(error) } }); throw error; } } /** * คำนวณคะแนนใหม่สำหรับทุกคำถามใน Quiz * Recalculate scores for all questions in a quiz (100 points total) * Distributes points as integers, handling remainders to ensure exact 100 total. * * @param quizId Quiz ID to recalculate */ private async recalculateQuestionScores(quizId: number): Promise { // Get all questions in this quiz const questions = await prisma.question.findMany({ where: { quiz_id: quizId }, orderBy: { sort_order: 'asc' }, select: { id: true } }); const questionCount = questions.length; if (questionCount === 0) return; // Calculate base score and remainder const baseScore = Math.floor(100 / questionCount); const remainder = 100 % questionCount; // Prepare updates const updates: any[] = []; for (let i = 0; i < questionCount; i++) { // Distribute remainder to the first 'remainder' questions const score = i < remainder ? baseScore + 1 : baseScore; updates.push( prisma.question.update({ where: { id: questions[i].id }, data: { score: score } }) ); } // Execute all updates in a transaction if (updates.length > 0) { await prisma.$transaction(updates); } logger.info(`Recalculated quiz ${quizId}: ${questionCount} questions. Base: ${baseScore}, Remainder: ${remainder}`); } /** * Normalize sort_order for all questions in a quiz * Ensures sort_order is sequential starting from 0 with no gaps or duplicates * * @param quizId Quiz ID to normalize */ private async normalizeQuestionSortOrder(quizId: number): Promise { // Get all questions ordered by current sort_order const questions = await prisma.question.findMany({ where: { quiz_id: quizId }, orderBy: { sort_order: 'asc' }, select: { id: true, sort_order: true } }); if (questions.length === 0) return; // Update each question with sequential sort_order starting from 1 const updates = questions.map((question, index) => prisma.question.update({ where: { id: question.id }, data: { sort_order: index + 1 } }) ); await prisma.$transaction(updates); logger.info(`Normalized sort_order for quiz ${quizId}: ${questions.length} questions`); } /** * Normalize sort_order for all lessons in a chapter * Ensures sort_order is sequential starting from 1 with no gaps or duplicates * * @param chapterId Chapter ID to normalize */ private async normalizeLessonSortOrder(chapterId: number): Promise { // Get all lessons ordered by current sort_order const lessons = await prisma.lesson.findMany({ where: { chapter_id: chapterId }, orderBy: { sort_order: 'asc' }, select: { id: true, sort_order: true } }); if (lessons.length === 0) return; // Update each lesson with sequential sort_order starting from 1 const updates = lessons.map((lesson, index) => prisma.lesson.update({ where: { id: lesson.id }, data: { sort_order: index + 1 } }) ); await prisma.$transaction(updates); logger.info(`Normalized sort_order for chapter ${chapterId}: ${lessons.length} lessons`); } /** * Normalize sort_order for all chapters in a course * Ensures sort_order is sequential starting from 1 with no gaps or duplicates * * @param courseId Course ID to normalize */ private async normalizeChapterSortOrder(courseId: number): Promise { // Get all chapters ordered by current sort_order const chapters = await prisma.chapter.findMany({ where: { course_id: courseId }, orderBy: { sort_order: 'asc' }, select: { id: true, sort_order: true } }); if (chapters.length === 0) return; // Update each chapter with sequential sort_order starting from 1 const updates = chapters.map((chapter, index) => prisma.chapter.update({ where: { id: chapter.id }, data: { sort_order: index + 1 } }) ); await prisma.$transaction(updates); logger.info(`Normalized sort_order for course ${courseId}: ${chapters.length} chapters`); } /** * อัพเดตการตั้งค่า Quiz * Update quiz settings (title, passing_score, time_limit, etc.) */ async updateQuiz(request: UpdateQuizInput): Promise { try { const { token, course_id, lesson_id, title, description, passing_score, time_limit, shuffle_questions, shuffle_choices, show_answers_after_completion, is_skippable, allow_multiple_attempts } = 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: { include: { questions: { include: { choices: { orderBy: { sort_order: 'asc' } } }, orderBy: { sort_order: 'asc' } } } } } }); 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'); // Build update data (only include provided fields) const updateData: any = { updated_by: user.id, }; if (title !== undefined) updateData.title = title; if (description !== undefined) updateData.description = description; if (passing_score !== undefined) updateData.passing_score = passing_score; if (time_limit !== undefined) updateData.time_limit = time_limit; if (shuffle_questions !== undefined) updateData.shuffle_questions = shuffle_questions; if (shuffle_choices !== undefined) updateData.shuffle_choices = shuffle_choices; if (show_answers_after_completion !== undefined) updateData.show_answers_after_completion = show_answers_after_completion; if (is_skippable !== undefined) updateData.is_skippable = is_skippable; if (allow_multiple_attempts !== undefined) updateData.allow_multiple_attempts = allow_multiple_attempts; // Update the quiz const updatedQuiz = await prisma.quiz.update({ where: { id: lesson.quiz.id }, data: updateData, include: { questions: { include: { choices: { orderBy: { sort_order: 'asc' } } }, orderBy: { sort_order: 'asc' } } } }); return { code: 200, message: 'Quiz updated successfully', data: updatedQuiz as unknown as QuizData }; } catch (error) { logger.error(`Error updating quiz: ${error}`); throw error; } } }