diff --git a/Backend/src/controllers/CoursesInstructorController.ts b/Backend/src/controllers/CoursesInstructorController.ts index a0f956c7..3be4ee47 100644 --- a/Backend/src/controllers/CoursesInstructorController.ts +++ b/Backend/src/controllers/CoursesInstructorController.ts @@ -23,6 +23,7 @@ import { GetEnrolledStudentDetailResponse, GetCourseApprovalHistoryResponse, setCourseDraftResponse, + CloneCourseResponse, } from '../types/CoursesInstructor.types'; import { CreateCourseValidator } from "../validators/CoursesInstructor.validator"; @@ -178,6 +179,33 @@ export class CoursesInstructorController { return await CoursesInstructorService.deleteCourse(token, courseId); } + /** + * คัดลอกคอร์ส (Clone Course) + * Clone an existing course to a new one with copied chapters, lessons, quizzes, and attachments + * @param courseId - รหัสคอร์สต้นฉบับ / Source Course ID + * @param body - ชื่อคอร์สใหม่ / New course title + */ + @Post('{courseId}/clone') + @Security('jwt', ['instructor']) + @SuccessResponse('201', 'Course cloned successfully') + @Response('401', 'Invalid or expired token') + @Response('403', 'Not an instructor of this course') + @Response('404', 'Course not found') + public async cloneCourse( + @Request() request: any, + @Path() courseId: number, + @Body() body: { title: { th: string; en: string } } + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new ValidationError('No token provided'); + + return await CoursesInstructorService.cloneCourse({ + token, + course_id: courseId, + title: body.title + }); + } + /** * ส่งคอร์สเพื่อขออนุมัติจากแอดมิน * Submit course for admin review and approval diff --git a/Backend/src/services/CoursesInstructor.service.ts b/Backend/src/services/CoursesInstructor.service.ts index a2530033..e1b40d0c 100644 --- a/Backend/src/services/CoursesInstructor.service.ts +++ b/Backend/src/services/CoursesInstructor.service.ts @@ -34,6 +34,8 @@ import { GetEnrolledStudentDetailInput, GetEnrolledStudentDetailResponse, GetCourseApprovalHistoryResponse, + CloneCourseInput, + CloneCourseResponse, setCourseDraft, setCourseDraftResponse, } from "../types/CoursesInstructor.types"; @@ -1446,4 +1448,228 @@ export class CoursesInstructorService { throw error; } } + + /** + * Clone a course (including chapters, lessons, quizzes, attachments) + */ + static async cloneCourse(input: CloneCourseInput): Promise { + try { + const { token, course_id, title } = input; + const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; + + // Validate instructor + const courseInstructor = await this.validateCourseInstructor(token, course_id); + if (!courseInstructor) { + throw new ForbiddenError('You are not an instructor of this course'); + } + + // Fetch original course with all relations + const originalCourse = await prisma.course.findUnique({ + where: { id: course_id }, + include: { + chapters: { + orderBy: { sort_order: 'asc' }, + include: { + lessons: { + orderBy: { sort_order: 'asc' }, + include: { + attachments: true, + quiz: { + include: { + questions: { + include: { + choices: true + } + } + } + } + } + } + } + } + } + }); + + if (!originalCourse) { + throw new NotFoundError('Course not found'); + } + + // Use transaction for atomic creation + const newCourse = await prisma.$transaction(async (tx) => { + // 1. Create new Course + const createdCourse = await tx.course.create({ + data: { + title: title as Prisma.InputJsonValue, + slug: `${originalCourse.slug}-clone-${Date.now()}`, // Temporary slug + description: originalCourse.description ?? Prisma.JsonNull, + thumbnail_url: originalCourse.thumbnail_url, + category_id: originalCourse.category_id, + price: originalCourse.price, + is_free: originalCourse.is_free, + have_certificate: originalCourse.have_certificate, + status: 'DRAFT', // Reset status + created_by: decoded.id + } + }); + + // 2. Add Instructor (Requester as primary) + await tx.courseInstructor.create({ + data: { + course_id: createdCourse.id, + user_id: decoded.id, + is_primary: true + } + }); + + // Mapping for oldLessonId -> newLessonId for prerequisites + const lessonIdMap = new Map(); + const lessonsToUpdatePrerequisites: { newLessonId: number; oldPrerequisites: number[] }[] = []; + + // 3. Clone Chapters and Lessons + for (const chapter of originalCourse.chapters) { + const newChapter = await tx.chapter.create({ + data: { + course_id: createdCourse.id, + title: chapter.title as Prisma.InputJsonValue, + description: chapter.description ?? Prisma.JsonNull, + sort_order: chapter.sort_order, + is_published: chapter.is_published + } + }); + + for (const lesson of chapter.lessons) { + const newLesson = await tx.lesson.create({ + data: { + chapter_id: newChapter.id, + title: lesson.title as Prisma.InputJsonValue, + content: lesson.content ?? Prisma.JsonNull, + type: lesson.type, + sort_order: lesson.sort_order, + is_published: lesson.is_published, + duration_minutes: lesson.duration_minutes, + prerequisite_lesson_ids: Prisma.JsonNull // Will update later + } + }); + + lessonIdMap.set(lesson.id, newLesson.id); + + // Store prerequisites for later update + if (Array.isArray(lesson.prerequisite_lesson_ids) && lesson.prerequisite_lesson_ids.length > 0) { + lessonsToUpdatePrerequisites.push({ + newLessonId: newLesson.id, + oldPrerequisites: lesson.prerequisite_lesson_ids as number[] + }); + } + + // Clone Attachments + if (lesson.attachments && lesson.attachments.length > 0) { + await tx.lessonAttachment.createMany({ + data: lesson.attachments.map(att => ({ + lesson_id: newLesson.id, + file_name: att.file_name, + file_path: att.file_path, // Reuse file path + file_size: att.file_size, + mime_type: att.mime_type, + sort_order: att.sort_order, + description: att.description ?? Prisma.JsonNull + })) + }); + } + + // Clone Quiz + if (lesson.quiz) { + const newQuiz = await tx.quiz.create({ + data: { + lesson_id: newLesson.id, + title: lesson.quiz.title as Prisma.InputJsonValue, + description: lesson.quiz.description ?? Prisma.JsonNull, + passing_score: lesson.quiz.passing_score, + allow_multiple_attempts: lesson.quiz.allow_multiple_attempts, + time_limit: lesson.quiz.time_limit, + shuffle_questions: lesson.quiz.shuffle_questions, + shuffle_choices: lesson.quiz.shuffle_choices, + show_answers_after_completion: lesson.quiz.show_answers_after_completion, + created_by: decoded.id + } + }); + + for (const question of lesson.quiz.questions) { + await tx.question.create({ + data: { + quiz_id: newQuiz.id, + question: question.question as Prisma.InputJsonValue, + explanation: question.explanation ?? Prisma.JsonNull, + question_type: question.question_type, + score: question.score, + sort_order: question.sort_order, + choices: { + create: question.choices.map(choice => ({ + text: choice.text as Prisma.InputJsonValue, + is_correct: choice.is_correct, + sort_order: choice.sort_order + })) + } + } + }); + } + } + } + } + + // 4. Update Prerequisites + for (const item of lessonsToUpdatePrerequisites) { + const newPrerequisites = item.oldPrerequisites + .map(oldId => lessonIdMap.get(oldId)) + .filter((id): id is number => id !== undefined); + + if (newPrerequisites.length > 0) { + await tx.lesson.update({ + where: { id: item.newLessonId }, + data: { + prerequisite_lesson_ids: newPrerequisites + } + }); + } + } + + return createdCourse; + }); + + await auditService.logSync({ + userId: decoded.id, + action: AuditAction.CREATE, + entityType: 'Course', + entityId: newCourse.id, + metadata: { + operation: 'clone_course', + original_course_id: course_id, + new_course_id: newCourse.id + } + }); + + return { + code: 201, + message: 'Course cloned successfully', + data: { + id: newCourse.id, + title: newCourse.title as { th: string; en: string } + } + }; + + } catch (error) { + logger.error(`Error cloning course: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: input.course_id, + metadata: { + operation: 'clone_course', + error: error instanceof Error ? error.message : String(error) + } + }); + throw error; + } + } } diff --git a/Backend/src/services/courses.service.ts b/Backend/src/services/courses.service.ts index 0700a7fa..a0d96a45 100644 --- a/Backend/src/services/courses.service.ts +++ b/Backend/src/services/courses.service.ts @@ -104,6 +104,20 @@ export class CoursesService { where: { id, status: 'APPROVED' // Only show approved courses to students + }, + include: { + chapters: { + select: { + id: true, + title: true, + lessons: { + select: { + id: true, + title: true, + } + } + } + } } }); diff --git a/Backend/src/types/CoursesInstructor.types.ts b/Backend/src/types/CoursesInstructor.types.ts index e31e80fc..cc4aa149 100644 --- a/Backend/src/types/CoursesInstructor.types.ts +++ b/Backend/src/types/CoursesInstructor.types.ts @@ -433,3 +433,18 @@ export interface GetCourseApprovalHistoryResponse { approval_history: ApprovalHistoryItem[]; }; } + +export interface CloneCourseInput { + token: string; + course_id: number; + title: MultiLanguageText; +} + +export interface CloneCourseResponse { + code: number; + message: string; + data: { + id: number; + title: MultiLanguageText; + }; +}