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,
|
GetEnrolledStudentDetailResponse,
|
||||||
GetCourseApprovalHistoryResponse,
|
GetCourseApprovalHistoryResponse,
|
||||||
setCourseDraftResponse,
|
setCourseDraftResponse,
|
||||||
|
CloneCourseResponse,
|
||||||
} from '../types/CoursesInstructor.types';
|
} from '../types/CoursesInstructor.types';
|
||||||
import { CreateCourseValidator } from "../validators/CoursesInstructor.validator";
|
import { CreateCourseValidator } from "../validators/CoursesInstructor.validator";
|
||||||
|
|
||||||
|
|
@ -178,6 +179,33 @@ export class CoursesInstructorController {
|
||||||
return await CoursesInstructorService.deleteCourse(token, courseId);
|
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
|
* Submit course for admin review and approval
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ import {
|
||||||
GetEnrolledStudentDetailInput,
|
GetEnrolledStudentDetailInput,
|
||||||
GetEnrolledStudentDetailResponse,
|
GetEnrolledStudentDetailResponse,
|
||||||
GetCourseApprovalHistoryResponse,
|
GetCourseApprovalHistoryResponse,
|
||||||
|
CloneCourseInput,
|
||||||
|
CloneCourseResponse,
|
||||||
setCourseDraft,
|
setCourseDraft,
|
||||||
setCourseDraftResponse,
|
setCourseDraftResponse,
|
||||||
} from "../types/CoursesInstructor.types";
|
} from "../types/CoursesInstructor.types";
|
||||||
|
|
@ -1446,4 +1448,228 @@ export class CoursesInstructorService {
|
||||||
throw error;
|
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: {
|
where: {
|
||||||
id,
|
id,
|
||||||
status: 'APPROVED' // Only show approved courses to students
|
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[];
|
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