1730 lines
No EOL
76 KiB
TypeScript
1730 lines
No EOL
76 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,
|
|
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<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 } });
|
|
|
|
// 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<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}`);
|
|
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<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 } });
|
|
|
|
// 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<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');
|
|
}
|
|
|
|
// 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<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 }
|
|
});
|
|
|
|
// 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<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 - 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<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}`);
|
|
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<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');
|
|
}
|
|
|
|
// 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<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}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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<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);
|
|
|
|
// 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<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}`);
|
|
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<YouTubeVideoResponse> {
|
|
try {
|
|
const { token, course_id, lesson_id, youtube_video_id, video_title } = request;
|
|
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
|
|
await CoursesInstructorService.validateCourseStatus(course_id);
|
|
|
|
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
|
|
if (!user) throw new UnauthorizedError('Invalid token');
|
|
|
|
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
|
|
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
|
|
|
|
// Verify lesson exists and is VIDEO type
|
|
const lesson = await prisma.lesson.findUnique({ where: { id: lesson_id } });
|
|
if (!lesson) throw new NotFoundError('Lesson not found');
|
|
if (lesson.type !== 'VIDEO') throw new ValidationError('Cannot add video to non-VIDEO type lesson');
|
|
|
|
// Find existing video attachment (sort_order = 0)
|
|
const existingVideo = await prisma.lessonAttachment.findFirst({
|
|
where: { lesson_id, sort_order: 0 }
|
|
});
|
|
|
|
if (existingVideo) {
|
|
// If existing video is from MinIO (not YouTube), delete it
|
|
if (existingVideo.mime_type !== 'video/youtube') {
|
|
try {
|
|
await deleteFile(existingVideo.file_path);
|
|
logger.info(`Deleted old MinIO video: ${existingVideo.file_path}`);
|
|
} catch (err) {
|
|
logger.warn(`Failed to delete old video from MinIO: ${existingVideo.file_path}`);
|
|
}
|
|
}
|
|
|
|
// Update existing attachment to YouTube
|
|
await prisma.lessonAttachment.update({
|
|
where: { id: existingVideo.id },
|
|
data: {
|
|
file_name: video_title,
|
|
file_path: youtube_video_id,
|
|
file_size: 0,
|
|
mime_type: 'video/youtube',
|
|
}
|
|
});
|
|
} else {
|
|
// Create new YouTube video attachment
|
|
await prisma.lessonAttachment.create({
|
|
data: {
|
|
lesson_id,
|
|
file_name: video_title,
|
|
file_path: youtube_video_id,
|
|
file_size: 0,
|
|
mime_type: 'video/youtube',
|
|
sort_order: 0,
|
|
}
|
|
});
|
|
}
|
|
|
|
// Build YouTube URL
|
|
const video_url = `https://www.youtube.com/watch?v=${youtube_video_id}`;
|
|
|
|
return {
|
|
code: 200,
|
|
message: 'YouTube video set successfully',
|
|
data: {
|
|
lesson_id,
|
|
video_url,
|
|
video_id: youtube_video_id,
|
|
video_title,
|
|
mime_type: 'video/youtube',
|
|
}
|
|
};
|
|
} catch (error) {
|
|
logger.error(`Error setting YouTube video: ${error}`);
|
|
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<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);
|
|
|
|
// 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<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 } });
|
|
|
|
// 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<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}`);
|
|
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<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}`);
|
|
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<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 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<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 } });
|
|
|
|
// 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<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}`);
|
|
}
|
|
|
|
/**
|
|
* 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<void> {
|
|
// 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<void> {
|
|
// 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<void> {
|
|
// 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<UpdateQuizResponse> {
|
|
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;
|
|
}
|
|
}
|
|
|
|
} |