Remove validation that prevented deletion of published lessons. Lessons can now be deleted regardless of their published status without requiring unpublishing first.
1231 lines
No EOL
55 KiB
TypeScript
1231 lines
No EOL
55 KiB
TypeScript
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,
|
|
UploadAttachmentInput,
|
|
DeleteAttachmentInput,
|
|
VideoOperationResponse,
|
|
AttachmentOperationResponse,
|
|
DeleteAttachmentResponse,
|
|
LessonAttachmentData,
|
|
UpdateQuizInput,
|
|
UpdateQuizResponse,
|
|
QuizData,
|
|
} 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<ListChaptersResponse> {
|
|
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<CreateChapterResponse> {
|
|
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 } });
|
|
return { code: 200, message: 'Chapter created successfully', data: chapter as ChapterData };
|
|
} catch (error) {
|
|
logger.error(`Error creating chapter: ${error}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async updateChapter(request: UpdateChapterInput): Promise<UpdateChapterResponse> {
|
|
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}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async deleteChapter(request: DeleteChapterRequest): Promise<DeleteChapterResponse> {
|
|
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 } });
|
|
return { code: 200, message: 'Chapter deleted successfully' };
|
|
} catch (error) {
|
|
logger.error(`Error deleting chapter: ${error}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async reorderChapter(request: ReorderChapterRequest): Promise<ReorderChapterResponse> {
|
|
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');
|
|
}
|
|
const oldSortOrder = currentChapter.sort_order;
|
|
|
|
// If same position, no need to reorder
|
|
if (oldSortOrder === newSortOrder) {
|
|
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 > newSortOrder) {
|
|
// Moving up: shift chapters between newSortOrder and oldSortOrder-1 down by 1
|
|
await prisma.chapter.updateMany({
|
|
where: {
|
|
course_id,
|
|
sort_order: { gte: newSortOrder, 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: newSortOrder }
|
|
},
|
|
data: { sort_order: { decrement: 1 } }
|
|
});
|
|
}
|
|
|
|
// Update the target chapter to the new position
|
|
await prisma.chapter.update({ where: { id: chapter_id }, data: { sort_order: newSortOrder } });
|
|
|
|
// 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}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* สร้างบทเรียนเปล่า (ยังไม่มีเนื้อหา)
|
|
* Create an empty lesson with basic information (no content yet)
|
|
* สำหรับ QUIZ type จะสร้าง Quiz shell ไว้ด้วย แต่ยังไม่มีคำถาม
|
|
*/
|
|
async createLesson(request: CreateLessonInput): Promise<CreateLessonResponse> {
|
|
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 }
|
|
});
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ดึงข้อมูลบทเรียนพร้อม attachments และ quiz
|
|
* Get lesson with attachments and quiz (if QUIZ type)
|
|
*/
|
|
async getLesson(request: GetLessonRequest): Promise<GetLessonResponse> {
|
|
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 from MinIO
|
|
let video_url: string | null = null;
|
|
try {
|
|
const videoPrefix = getVideoFolder(course_id, lesson_id);
|
|
const videoFiles = await listObjects(videoPrefix);
|
|
if (videoFiles.length > 0) {
|
|
video_url = await getPresignedUrl(videoFiles[0].name, 3600);
|
|
}
|
|
} 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}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async updateLesson(request: UpdateLessonRequest): Promise<UpdateLessonResponse> {
|
|
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<ReorderLessonsResponse> {
|
|
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');
|
|
}
|
|
const oldSortOrder = currentLesson.sort_order;
|
|
|
|
// If same position, no need to reorder
|
|
if (oldSortOrder === newSortOrder) {
|
|
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 > newSortOrder) {
|
|
// Moving up: shift lessons between newSortOrder and oldSortOrder-1 down by 1
|
|
await prisma.lesson.updateMany({
|
|
where: {
|
|
chapter_id,
|
|
sort_order: { gte: newSortOrder, 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: newSortOrder }
|
|
},
|
|
data: { sort_order: { decrement: 1 } }
|
|
});
|
|
}
|
|
|
|
// Update the target lesson to the new position
|
|
await prisma.lesson.update({ where: { id: lesson_id }, data: { sort_order: newSortOrder } });
|
|
|
|
// 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}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ลบบทเรียนพร้อมข้อมูลที่เกี่ยวข้องทั้งหมด
|
|
* Delete lesson with all related data (quiz, questions, choices, attachments)
|
|
* ไม่สามารถลบได้ถ้าบทเรียนถูก publish แล้ว
|
|
*/
|
|
async deleteLesson(request: DeleteLessonRequest): Promise<DeleteLessonResponse> {
|
|
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}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Separate Video/Attachment APIs
|
|
// ============================================
|
|
|
|
/**
|
|
* อัพโหลดวิดีโอใหม่ให้บทเรียน (ครั้งแรก)
|
|
* Upload video to lesson (first time)
|
|
*/
|
|
async uploadVideo(request: UploadVideoInput): Promise<VideoOperationResponse> {
|
|
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);
|
|
|
|
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}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* อัพเดต (เปลี่ยน) วิดีโอของบทเรียน
|
|
* Update (replace) video in lesson
|
|
*/
|
|
async updateVideo(request: UpdateVideoInput): Promise<VideoOperationResponse> {
|
|
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}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* อัพโหลดไฟล์แนบทีละไฟล์
|
|
* Upload a single attachment to lesson
|
|
*/
|
|
async uploadAttachment(request: UploadAttachmentInput): Promise<AttachmentOperationResponse> {
|
|
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);
|
|
|
|
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}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ลบไฟล์แนบทีละไฟล์
|
|
* Delete a single attachment from lesson
|
|
*/
|
|
async deleteAttachment(request: DeleteAttachmentInput): Promise<DeleteAttachmentResponse> {
|
|
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 } });
|
|
|
|
return { code: 200, message: 'Attachment deleted successfully' };
|
|
} catch (error) {
|
|
logger.error(`Error deleting attachment: ${error}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* เพิ่มคำถามทีละข้อให้ Quiz Lesson
|
|
* Add a single question with choices to an existing QUIZ lesson
|
|
* คะแนนจะถูกคำนวณอัตโนมัติ (100 คะแนน / จำนวนข้อ)
|
|
*/
|
|
async addQuestion(request: AddQuestionInput): Promise<AddQuestionResponse> {
|
|
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}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* อัปเดตคำถาม
|
|
* Update a question and optionally replace all choices
|
|
* หมายเหตุ: คะแนนจะถูกคำนวณอัตโนมัติ ไม่สามารถเปลี่ยนได้
|
|
*/
|
|
async updateQuestion(request: UpdateQuestionInput): Promise<UpdateQuestionResponse> {
|
|
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}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async reorderQuestion(request: ReorderQuestionInput): Promise<ReorderQuestionResponse> {
|
|
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 oldSortOrder = existingQuestion.sort_order;
|
|
const quizId = lesson.quiz.id;
|
|
|
|
// If same position, no need to reorder
|
|
if (oldSortOrder === sort_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[] };
|
|
}
|
|
|
|
// Shift other questions to make room for the insert
|
|
if (oldSortOrder > sort_order) {
|
|
// Moving up: shift questions between newSortOrder and oldSortOrder-1 down by 1
|
|
await prisma.question.updateMany({
|
|
where: {
|
|
quiz_id: quizId,
|
|
sort_order: { gte: sort_order, 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: sort_order }
|
|
},
|
|
data: { sort_order: { decrement: 1 } }
|
|
});
|
|
}
|
|
|
|
// Update the question's sort order
|
|
await prisma.question.update({
|
|
where: { id: question_id },
|
|
data: { sort_order }
|
|
});
|
|
|
|
// 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}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ลบคำถาม
|
|
* Delete a question and all its choices
|
|
* คะแนนจะถูกคำนวณใหม่หลังจากลบ
|
|
*/
|
|
async deleteQuestion(request: DeleteQuestionInput): Promise<DeleteQuestionResponse> {
|
|
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 } });
|
|
|
|
// 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}`);
|
|
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<void> {
|
|
// 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}`);
|
|
}
|
|
|
|
/**
|
|
* อัพเดตการตั้งค่า Quiz
|
|
* Update quiz settings (title, passing_score, time_limit, etc.)
|
|
*/
|
|
async updateQuiz(request: UpdateQuizInput): Promise<UpdateQuizResponse> {
|
|
try {
|
|
const { token, course_id, lesson_id, title, description, passing_score, time_limit, shuffle_questions, shuffle_choices, show_answers_after_completion, is_skippable } = 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;
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
} |