feat: implement course cloning functionality including chapters, lessons, quizzes, and attachments for instructors.
This commit is contained in:
parent
5442f1beb6
commit
c5aa195b13
4 changed files with 283 additions and 0 deletions
|
|
@ -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<CloneCourseResponse> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<CloneCourseResponse> {
|
||||
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<number, number>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue