feat: Add student and instructor chapter/lesson controllers and refactor instructor lesson file upload endpoints with TSOA.
This commit is contained in:
parent
0c369d1197
commit
9a7eb50d17
3 changed files with 949 additions and 249 deletions
|
|
@ -1,35 +1,27 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ChaptersLessonService } from '../services/ChaptersLesson.service';
|
||||
import { Body, FormField, Path, Post, Put, Request, Response, Route, Security, SuccessResponse, Tags, UploadedFile, UploadedFiles } from 'tsoa';
|
||||
import { ValidationError } from '../middleware/errorHandler';
|
||||
import {
|
||||
lessonUpload,
|
||||
LessonUploadRequest,
|
||||
validateTotalAttachmentSize,
|
||||
validateVideoSize
|
||||
} from '../middleware/upload';
|
||||
import { UploadedFileInfo, CreateLessonInput } from '../types/ChaptersLesson.typs';
|
||||
import { ChaptersLessonService } from '../services/ChaptersLesson.service';
|
||||
import { MultiLanguageText } from '../types/index';
|
||||
import { UploadedFileInfo, CreateLessonInput, CreateLessonResponse, UpdateLessonResponse } from '../types/ChaptersLesson.typs';
|
||||
|
||||
const chaptersLessonService = new ChaptersLessonService();
|
||||
|
||||
/**
|
||||
* Controller for handling lesson CRUD operations with file uploads
|
||||
*/
|
||||
@Route('api/instructors/courses/{courseId}/chapters/{chapterId}/lessons')
|
||||
@Tags('Lessons - File Upload')
|
||||
export class LessonsController {
|
||||
|
||||
/**
|
||||
* สร้างบทเรียนใหม่พร้อมไฟล์แนบ
|
||||
* Create a new lesson with optional video and attachments
|
||||
*
|
||||
* @route POST /api/instructors/courses/:courseId/chapters/:chapterId/lessons
|
||||
* @contentType multipart/form-data
|
||||
*
|
||||
* @param {number} courseId - รหัสคอร์ส / Course ID
|
||||
* @param {number} chapterId - รหัสบท / Chapter ID
|
||||
* @param {string} title - ชื่อบทเรียน (JSON: { th: "", en: "" })
|
||||
* @param {string} [content] - เนื้อหาบทเรียน (JSON: { th: "", en: "" })
|
||||
* @param {string} type - ประเภทบทเรียน (VIDEO | QUIZ)
|
||||
* @param {number} [sort_order] - ลำดับการแสดงผล
|
||||
* @param {File} [video] - ไฟล์วิดีโอ (สำหรับ type=VIDEO เท่านั้น)
|
||||
* @param {File[]} [attachments] - ไฟล์แนบ (PDFs, เอกสาร, รูปภาพ)
|
||||
* @param courseId Course ID
|
||||
* @param chapterId Chapter ID
|
||||
* @param title ชื่อบทเรียน (JSON string: { th: "", en: "" })
|
||||
* @param type ประเภทบทเรียน (VIDEO | QUIZ)
|
||||
* @param content เนื้อหาบทเรียน (JSON string: { th: "", en: "" })
|
||||
* @param sort_order ลำดับการแสดงผล
|
||||
* @param video ไฟล์วิดีโอ (สำหรับ type=VIDEO เท่านั้น)
|
||||
* @param attachments ไฟล์แนบ (PDFs, เอกสาร, รูปภาพ)
|
||||
*/
|
||||
async createLesson(req: LessonUploadRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
|
|
@ -38,8 +30,8 @@ export class LessonsController {
|
|||
throw new ValidationError('No token provided');
|
||||
}
|
||||
|
||||
const courseId = parseInt(req.params.courseId as string, 10);
|
||||
const chapterId = parseInt(req.params.chapterId as string, 10);
|
||||
const courseId = parseInt(req.params.courseId, 10);
|
||||
const chapterId = parseInt(req.params.chapterId, 10);
|
||||
|
||||
if (isNaN(courseId) || isNaN(chapterId)) {
|
||||
throw new ValidationError('Invalid course ID or chapter ID');
|
||||
|
|
@ -51,89 +43,162 @@ export class LessonsController {
|
|||
const type = req.body.type as 'VIDEO' | 'QUIZ';
|
||||
const sortOrder = req.body.sort_order ? parseInt(req.body.sort_order, 10) : undefined;
|
||||
|
||||
if (!type || !['VIDEO', 'QUIZ'].includes(type)) {
|
||||
throw new ValidationError('Invalid lesson type. Must be VIDEO or QUIZ');
|
||||
}
|
||||
|
||||
if (!title.th || !title.en) {
|
||||
throw new ValidationError('Title must have both Thai (th) and English (en) values');
|
||||
}
|
||||
|
||||
// Process uploaded files
|
||||
const files = req.files as { video?: Express.Multer.File[]; attachments?: Express.Multer.File[] } | undefined;
|
||||
|
||||
let video: UploadedFileInfo | undefined;
|
||||
let attachments: UploadedFileInfo[] | undefined;
|
||||
|
||||
if (files?.video && files.video.length > 0) {
|
||||
const videoFile = files.video[0];
|
||||
validateVideoSize(videoFile);
|
||||
video = {
|
||||
originalname: videoFile.originalname,
|
||||
mimetype: videoFile.mimetype,
|
||||
size: videoFile.size,
|
||||
buffer: videoFile.buffer,
|
||||
};
|
||||
}
|
||||
|
||||
if (files?.attachments && files.attachments.length > 0) {
|
||||
validateTotalAttachmentSize(files.attachments);
|
||||
attachments = files.attachments.map(file => ({
|
||||
originalname: file.originalname,
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
buffer: file.buffer,
|
||||
}));
|
||||
}
|
||||
|
||||
// Validate VIDEO type must have video file (optional - can be uploaded later)
|
||||
// if (type === 'VIDEO' && !video) {
|
||||
// throw new ValidationError('Video file is required for VIDEO type lessons');
|
||||
// }
|
||||
|
||||
const input: CreateLessonInput = {
|
||||
token,
|
||||
course_id: courseId,
|
||||
chapter_id: chapterId,
|
||||
title,
|
||||
content,
|
||||
type,
|
||||
sort_order: sortOrder,
|
||||
video,
|
||||
attachments,
|
||||
};
|
||||
|
||||
const result = await chaptersLessonService.createLesson(input);
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
if (!parsedTitle.th || !parsedTitle.en) {
|
||||
throw new ValidationError('Title must have both Thai (th) and English (en) values');
|
||||
}
|
||||
|
||||
// Transform files to UploadedFileInfo
|
||||
let videoInfo: UploadedFileInfo | undefined;
|
||||
let attachmentsInfo: UploadedFileInfo[] | undefined;
|
||||
|
||||
if (video) {
|
||||
videoInfo = {
|
||||
originalname: video.originalname,
|
||||
mimetype: video.mimetype,
|
||||
size: video.size,
|
||||
buffer: video.buffer,
|
||||
};
|
||||
}
|
||||
|
||||
if (attachments && attachments.length > 0) {
|
||||
attachmentsInfo = attachments.map(file => ({
|
||||
originalname: file.originalname,
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
buffer: file.buffer,
|
||||
}));
|
||||
}
|
||||
|
||||
const input: CreateLessonInput = {
|
||||
token,
|
||||
course_id: courseId,
|
||||
chapter_id: chapterId,
|
||||
title: parsedTitle,
|
||||
content: parsedContent,
|
||||
type,
|
||||
sort_order: sortOrder,
|
||||
video: videoInfo,
|
||||
attachments: attachmentsInfo,
|
||||
};
|
||||
|
||||
return await chaptersLessonService.createLesson(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* เพิ่มวิดีโอและไฟล์แนบให้บทเรียน VIDEO ที่มีอยู่แล้ว
|
||||
* Add video and attachments to an existing VIDEO type lesson
|
||||
*
|
||||
* @param courseId Course ID
|
||||
* @param chapterId Chapter ID
|
||||
* @param lessonId Lesson ID
|
||||
* @param video ไฟล์วิดีโอ (required)
|
||||
* @param attachments ไฟล์แนบ
|
||||
*/
|
||||
@Post('{lessonId}/video')
|
||||
@Security('jwt', ['instructor'])
|
||||
@SuccessResponse('200', 'Video added successfully')
|
||||
@Response('400', 'Validation error')
|
||||
@Response('401', 'Unauthorized')
|
||||
@Response('403', 'Forbidden')
|
||||
@Response('404', 'Lesson not found')
|
||||
public async addVideoToLesson(
|
||||
@Request() request: any,
|
||||
@Path() courseId: number,
|
||||
@Path() chapterId: number,
|
||||
@Path() lessonId: number,
|
||||
@UploadedFile() video: Express.Multer.File,
|
||||
@UploadedFiles() attachments?: Express.Multer.File[]
|
||||
): Promise<CreateLessonResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) throw new ValidationError('No token provided');
|
||||
|
||||
if (!video) {
|
||||
throw new ValidationError('Video file is required');
|
||||
}
|
||||
|
||||
// Transform files to UploadedFileInfo
|
||||
const videoInfo: UploadedFileInfo = {
|
||||
originalname: video.originalname,
|
||||
mimetype: video.mimetype,
|
||||
size: video.size,
|
||||
buffer: video.buffer,
|
||||
};
|
||||
|
||||
let attachmentsInfo: UploadedFileInfo[] | undefined;
|
||||
if (attachments && attachments.length > 0) {
|
||||
attachmentsInfo = attachments.map(file => ({
|
||||
originalname: file.originalname,
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
buffer: file.buffer,
|
||||
}));
|
||||
}
|
||||
|
||||
return await chaptersLessonService.addVideoLesson({
|
||||
token,
|
||||
course_id: courseId,
|
||||
lesson_id: lessonId,
|
||||
video: videoInfo,
|
||||
attachments: attachmentsInfo,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* อัปเดตวิดีโอและไฟล์แนบของบทเรียน VIDEO
|
||||
* Update video and attachments of an existing VIDEO type lesson
|
||||
*
|
||||
* @param courseId Course ID
|
||||
* @param chapterId Chapter ID
|
||||
* @param lessonId Lesson ID
|
||||
* @param video ไฟล์วิดีโอใหม่
|
||||
* @param attachments ไฟล์แนบใหม่
|
||||
*/
|
||||
@Put('{lessonId}/video')
|
||||
@Security('jwt', ['instructor'])
|
||||
@SuccessResponse('200', 'Video updated successfully')
|
||||
@Response('400', 'Validation error')
|
||||
@Response('401', 'Unauthorized')
|
||||
@Response('403', 'Forbidden')
|
||||
@Response('404', 'Lesson not found')
|
||||
public async updateVideoLesson(
|
||||
@Request() request: any,
|
||||
@Path() courseId: number,
|
||||
@Path() chapterId: number,
|
||||
@Path() lessonId: number,
|
||||
@UploadedFile() video?: Express.Multer.File,
|
||||
@UploadedFiles() attachments?: Express.Multer.File[]
|
||||
): Promise<UpdateLessonResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) throw new ValidationError('No token provided');
|
||||
|
||||
// Transform files to UploadedFileInfo
|
||||
let videoInfo: UploadedFileInfo | undefined;
|
||||
let attachmentsInfo: UploadedFileInfo[] | undefined;
|
||||
|
||||
if (video) {
|
||||
videoInfo = {
|
||||
originalname: video.originalname,
|
||||
mimetype: video.mimetype,
|
||||
size: video.size,
|
||||
buffer: video.buffer,
|
||||
};
|
||||
}
|
||||
|
||||
if (attachments && attachments.length > 0) {
|
||||
attachmentsInfo = attachments.map(file => ({
|
||||
originalname: file.originalname,
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
buffer: file.buffer,
|
||||
}));
|
||||
}
|
||||
|
||||
return await chaptersLessonService.updateVideoLesson({
|
||||
token,
|
||||
course_id: courseId,
|
||||
lesson_id: lessonId,
|
||||
video: videoInfo,
|
||||
attachments: attachmentsInfo,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Express router middleware wrapper for file upload
|
||||
* Use this in routes like:
|
||||
*
|
||||
* router.post(
|
||||
* '/api/instructors/courses/:courseId/chapters/:chapterId/lessons',
|
||||
* authenticateMiddleware,
|
||||
* handleLessonUpload,
|
||||
* lessonsController.createLesson.bind(lessonsController)
|
||||
* );
|
||||
*/
|
||||
export const handleLessonUpload = (req: Request, res: Response, next: NextFunction) => {
|
||||
lessonUpload(req, res, (err) => {
|
||||
if (err) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: 'FILE_UPLOAD_ERROR',
|
||||
message: err.message,
|
||||
}
|
||||
});
|
||||
}
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
export const lessonsController = new LessonsController();
|
||||
|
|
|
|||
|
|
@ -2,42 +2,84 @@ import { prisma } from '../config/database';
|
|||
import { Prisma } from '@prisma/client';
|
||||
import { config } from '../config';
|
||||
import { logger } from '../config/logger';
|
||||
import { generateFilePath, uploadFile } from '../config/minio';
|
||||
import { deleteFile, generateFilePath, uploadFile } from '../config/minio';
|
||||
import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import {
|
||||
LessonAttachmentData,
|
||||
LessonData,
|
||||
ChapterData,
|
||||
CreateLessonInput,
|
||||
UpdateLessonInput,
|
||||
CreateChapterInput,
|
||||
UpdateChapterInput,
|
||||
ChaptersRequest,
|
||||
DeleteChapterRequest,
|
||||
ReorderChapterRequest,
|
||||
ListChaptersResponse,
|
||||
GetChapterResponse,
|
||||
CreateChapterResponse,
|
||||
UpdateChapterResponse,
|
||||
DeleteChapterResponse,
|
||||
ReorderChapterResponse,
|
||||
ChapterWithLessonsResponse,
|
||||
ListLessonsRequest,
|
||||
GetLessonRequest,
|
||||
CreateLessonRequest,
|
||||
UpdateLessonRequest,
|
||||
DeleteLessonRequest,
|
||||
ReorderLessonsRequest,
|
||||
ListLessonsResponse,
|
||||
GetLessonResponse,
|
||||
CreateLessonResponse,
|
||||
UpdateLessonResponse,
|
||||
DeleteLessonResponse,
|
||||
ReorderLessonsResponse,
|
||||
AddVideoToLessonInput,
|
||||
UpdateVideoLessonInput,
|
||||
AddQuestionInput,
|
||||
AddQuestionResponse,
|
||||
UpdateQuestionInput,
|
||||
UpdateQuestionResponse,
|
||||
DeleteQuestionInput,
|
||||
DeleteQuestionResponse,
|
||||
QuizQuestionData,
|
||||
} from "../types/ChaptersLesson.typs";
|
||||
import { CoursesInstructorService } from './CoursesInstructor.service';
|
||||
|
||||
/**
|
||||
* ตรวจสอบสิทธิ์เข้าถึง Course (สำหรับทั้ง Instructor และ Student)
|
||||
* Returns: { hasAccess: boolean, role: 'INSTRUCTOR' | 'STUDENT' | null, userId: number }
|
||||
*/
|
||||
async function validateCourseAccess(token: string, course_id: number): Promise<{
|
||||
hasAccess: boolean;
|
||||
role: 'INSTRUCTOR' | 'STUDENT' | null;
|
||||
userId: number;
|
||||
}> {
|
||||
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
|
||||
const userId = decodedToken.id;
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedError('Invalid token');
|
||||
}
|
||||
|
||||
// Check if user is an instructor for this course
|
||||
const courseInstructor = await prisma.courseInstructor.findFirst({
|
||||
where: { course_id, user_id: userId }
|
||||
});
|
||||
if (courseInstructor) {
|
||||
return { hasAccess: true, role: 'INSTRUCTOR', userId };
|
||||
}
|
||||
|
||||
// Check if user is enrolled in this course
|
||||
const enrollment = await prisma.enrollment.findFirst({
|
||||
where: {
|
||||
course_id,
|
||||
user_id: userId,
|
||||
status: { in: ['ENROLLED', 'IN_PROGRESS', 'COMPLETED'] }
|
||||
}
|
||||
});
|
||||
if (enrollment) {
|
||||
return { hasAccess: true, role: 'STUDENT', userId };
|
||||
}
|
||||
|
||||
return { hasAccess: false, role: null, userId };
|
||||
}
|
||||
|
||||
export class ChaptersLessonService {
|
||||
async listChapters(request: ChaptersRequest): Promise<ListChaptersResponse> {
|
||||
try {
|
||||
|
|
@ -142,9 +184,14 @@ export class ChaptersLessonService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* สร้างบทเรียนเปล่า (ยังไม่มีเนื้อหา)
|
||||
* 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, video, attachments } = request;
|
||||
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 } });
|
||||
|
|
@ -156,142 +203,588 @@ export class ChaptersLessonService {
|
|||
throw new ForbiddenError('You are not permitted to create lesson');
|
||||
}
|
||||
|
||||
// Create the lesson first
|
||||
// Create the lesson
|
||||
const lesson = await prisma.lesson.create({
|
||||
data: { chapter_id, title, content, type, sort_order }
|
||||
});
|
||||
|
||||
const uploadedAttachments: { file_name: string; file_path: string; file_size: number; mime_type: string }[] = [];
|
||||
|
||||
// Handle video upload for VIDEO type lessons
|
||||
if (type === 'VIDEO' && video) {
|
||||
const videoPath = generateFilePath(course_id, lesson.id, 'video', video.originalname);
|
||||
await uploadFile(videoPath, video.buffer, video.mimetype);
|
||||
|
||||
// Save video as attachment
|
||||
await prisma.lessonAttachment.create({
|
||||
data: {
|
||||
lesson_id: lesson.id,
|
||||
file_name: video.originalname,
|
||||
file_size: video.size,
|
||||
mime_type: video.mimetype,
|
||||
file_path: videoPath,
|
||||
sort_order: 0,
|
||||
}
|
||||
});
|
||||
|
||||
uploadedAttachments.push({
|
||||
file_name: video.originalname,
|
||||
file_path: videoPath,
|
||||
file_size: video.size,
|
||||
mime_type: video.mimetype,
|
||||
});
|
||||
|
||||
// Handle additional attachments (PDFs, documents, etc.)
|
||||
if (attachments && attachments.length > 0) {
|
||||
for (let i = 0; i < attachments.length; i++) {
|
||||
const attachment = attachments[i];
|
||||
const attachmentPath = generateFilePath(course_id, lesson.id, 'attachment', attachment.originalname);
|
||||
await uploadFile(attachmentPath, attachment.buffer, attachment.mimetype);
|
||||
|
||||
await prisma.lessonAttachment.create({
|
||||
data: {
|
||||
lesson_id: lesson.id,
|
||||
file_name: attachment.originalname,
|
||||
file_size: attachment.size,
|
||||
mime_type: attachment.mimetype,
|
||||
file_path: attachmentPath,
|
||||
sort_order: i + 1, // Start from 1 since video is 0
|
||||
}
|
||||
});
|
||||
|
||||
uploadedAttachments.push({
|
||||
file_name: attachment.originalname,
|
||||
file_path: attachmentPath,
|
||||
file_size: attachment.size,
|
||||
mime_type: attachment.mimetype,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (type === 'QUIZ' && request.quiz_data) {
|
||||
// Create Quiz with Questions and Choices
|
||||
const { quiz_data } = request;
|
||||
// If QUIZ type, create empty Quiz shell
|
||||
if (type === 'QUIZ') {
|
||||
const userId = decodedToken.id;
|
||||
|
||||
// Create the quiz
|
||||
const quiz = await prisma.quiz.create({
|
||||
await prisma.quiz.create({
|
||||
data: {
|
||||
lesson_id: lesson.id,
|
||||
title: quiz_data.title,
|
||||
description: quiz_data.description,
|
||||
passing_score: quiz_data.passing_score ?? 60,
|
||||
time_limit: quiz_data.time_limit,
|
||||
shuffle_questions: quiz_data.shuffle_questions ?? false,
|
||||
shuffle_choices: quiz_data.shuffle_choices ?? false,
|
||||
show_answers_after_completion: quiz_data.show_answers_after_completion ?? true,
|
||||
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,
|
||||
}
|
||||
});
|
||||
|
||||
// Create questions with choices
|
||||
if (quiz_data.questions && quiz_data.questions.length > 0) {
|
||||
for (let i = 0; i < quiz_data.questions.length; i++) {
|
||||
const questionInput = quiz_data.questions[i];
|
||||
|
||||
// Create the question
|
||||
const question = await prisma.question.create({
|
||||
data: {
|
||||
quiz_id: quiz.id,
|
||||
question: questionInput.question,
|
||||
explanation: questionInput.explanation,
|
||||
question_type: questionInput.question_type,
|
||||
score: questionInput.score ?? 1,
|
||||
sort_order: questionInput.sort_order ?? i,
|
||||
}
|
||||
});
|
||||
|
||||
// Create choices for this question
|
||||
if (questionInput.choices && questionInput.choices.length > 0) {
|
||||
for (let j = 0; j < questionInput.choices.length; j++) {
|
||||
const choiceInput = questionInput.choices[j];
|
||||
await prisma.choice.create({
|
||||
data: {
|
||||
question_id: question.id,
|
||||
text: choiceInput.text,
|
||||
is_correct: choiceInput.is_correct,
|
||||
sort_order: choiceInput.sort_order ?? j,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fetch complete lesson with quiz
|
||||
const completeLesson = await prisma.lesson.findUnique({
|
||||
where: { id: lesson.id },
|
||||
include: { quiz: true }
|
||||
});
|
||||
return { code: 200, message: 'Lesson created successfully', data: completeLesson as LessonData };
|
||||
}
|
||||
|
||||
|
||||
return { code: 200, message: 'Lesson created successfully', data: lesson as LessonData };
|
||||
} catch (error) {
|
||||
logger.error(`Error creating lesson: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the complete lesson with attachments and quiz
|
||||
const completeLesson = await prisma.lesson.findUnique({
|
||||
where: { id: lesson.id },
|
||||
/**
|
||||
* ดึงข้อมูลบทเรียนพร้อม 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' } }
|
||||
}
|
||||
include: { choices: { orderBy: { sort_order: 'asc' } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { code: 200, message: 'Lesson created successfully', data: completeLesson as LessonData };
|
||||
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');
|
||||
}
|
||||
|
||||
return { code: 200, message: 'Lesson fetched successfully', data: lesson as LessonData };
|
||||
} catch (error) {
|
||||
logger.error(`Error creating lesson: ${error}`);
|
||||
logger.error(`Error fetching lesson: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateLesson(request: UpdateLessonRequest): Promise<UpdateLessonResponse> {
|
||||
try {
|
||||
const { token, course_id, lesson_id, data } = request;
|
||||
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
|
||||
await CoursesInstructorService.validateCourseStatus(course_id);
|
||||
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedError('Invalid token');
|
||||
}
|
||||
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
|
||||
if (!courseInstructor) {
|
||||
throw new ForbiddenError('You are not permitted to update lesson');
|
||||
}
|
||||
const lesson = await prisma.lesson.update({ where: { id: lesson_id }, data });
|
||||
return { code: 200, message: 'Lesson updated successfully', data: lesson as LessonData };
|
||||
} catch (error) {
|
||||
logger.error(`Error updating lesson: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* เรียงลำดับบทเรียนใหม่
|
||||
* Reorder lessons within a chapter
|
||||
*/
|
||||
async reorderLessons(request: ReorderLessonsRequest): Promise<ReorderLessonsResponse> {
|
||||
try {
|
||||
const { token, course_id, chapter_id, lesson_ids } = 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');
|
||||
}
|
||||
|
||||
// Update sort_order for each lesson
|
||||
for (let i = 0; i < lesson_ids.length; i++) {
|
||||
await prisma.lesson.update({
|
||||
where: { id: lesson_ids[i] },
|
||||
data: { sort_order: i }
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch reordered lessons
|
||||
const lessons = await prisma.lesson.findMany({
|
||||
where: { chapter_id: chapter_id },
|
||||
orderBy: { sort_order: 'asc' }
|
||||
});
|
||||
|
||||
return { code: 200, message: 'Lessons reordered successfully', data: lessons as LessonData[] };
|
||||
} catch (error) {
|
||||
logger.error(`Error reordering lessons: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ลบบทเรียนพร้อมข้อมูลที่เกี่ยวข้องทั้งหมด
|
||||
* Delete lesson with all related data (quiz, questions, choices, attachments)
|
||||
* ไม่สามารถลบได้ถ้าบทเรียนถูก publish แล้ว
|
||||
*/
|
||||
async deleteLesson(request: DeleteLessonRequest): Promise<DeleteLessonResponse> {
|
||||
try {
|
||||
const { token, course_id, lesson_id } = request;
|
||||
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
|
||||
await CoursesInstructorService.validateCourseStatus(course_id);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
|
||||
if (!user) throw new UnauthorizedError('Invalid token');
|
||||
|
||||
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
|
||||
if (!courseInstructor) throw new ForbiddenError('You are not permitted to delete this lesson');
|
||||
|
||||
// Fetch lesson with all related data
|
||||
const lesson = await prisma.lesson.findUnique({
|
||||
where: { id: lesson_id },
|
||||
include: {
|
||||
attachments: true,
|
||||
quiz: {
|
||||
include: {
|
||||
questions: {
|
||||
include: { choices: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!lesson) throw new NotFoundError('Lesson not found');
|
||||
|
||||
// Check if lesson is published
|
||||
if (lesson.is_published) {
|
||||
throw new ValidationError('Cannot delete published lesson. Please unpublish first.');
|
||||
}
|
||||
|
||||
// Delete attachment files from MinIO
|
||||
if (lesson.attachments && lesson.attachments.length > 0) {
|
||||
for (const attachment of lesson.attachments) {
|
||||
try {
|
||||
await deleteFile(attachment.file_path);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to delete file from MinIO: ${attachment.file_path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete lesson (CASCADE will delete: attachments, quiz, questions, choices)
|
||||
// Based on Prisma schema: onDelete: Cascade
|
||||
await prisma.lesson.delete({ where: { id: lesson_id } });
|
||||
|
||||
return { code: 200, message: 'Lesson deleted successfully' };
|
||||
} catch (error) {
|
||||
logger.error(`Error deleting lesson: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* เพิ่มวิดีโอและไฟล์แนบให้บทเรียนประเภท VIDEO
|
||||
* Add video and attachments to an existing VIDEO type lesson
|
||||
*/
|
||||
async addVideoLesson(request: AddVideoToLessonInput): Promise<CreateLessonResponse> {
|
||||
try {
|
||||
const { token, course_id, lesson_id, video, attachments } = 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');
|
||||
}
|
||||
|
||||
// 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
|
||||
await prisma.lessonAttachment.create({
|
||||
data: {
|
||||
lesson_id: lesson_id,
|
||||
file_name: video.originalname,
|
||||
file_size: video.size,
|
||||
mime_type: video.mimetype,
|
||||
file_path: videoPath,
|
||||
sort_order: 0,
|
||||
}
|
||||
});
|
||||
|
||||
// Handle additional attachments (PDFs, documents, etc.)
|
||||
if (attachments && attachments.length > 0) {
|
||||
for (let i = 0; i < attachments.length; i++) {
|
||||
const attachment = attachments[i];
|
||||
const attachmentPath = generateFilePath(course_id, lesson_id, 'attachment', attachment.originalname);
|
||||
await uploadFile(attachmentPath, attachment.buffer, attachment.mimetype);
|
||||
|
||||
await prisma.lessonAttachment.create({
|
||||
data: {
|
||||
lesson_id: lesson_id,
|
||||
file_name: attachment.originalname,
|
||||
file_size: attachment.size,
|
||||
mime_type: attachment.mimetype,
|
||||
file_path: attachmentPath,
|
||||
sort_order: i + 1,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the complete lesson with attachments
|
||||
const completeLesson = await prisma.lesson.findUnique({
|
||||
where: { id: lesson_id },
|
||||
include: { attachments: { orderBy: { sort_order: 'asc' } } }
|
||||
});
|
||||
|
||||
return { code: 200, message: 'Video added to lesson successfully', data: completeLesson as LessonData };
|
||||
} catch (error) {
|
||||
logger.error(`Error adding video to lesson: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateVideoLesson(request: UpdateVideoLessonInput): Promise<UpdateLessonResponse> {
|
||||
try {
|
||||
const { token, course_id, lesson_id, video, attachments } = 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');
|
||||
|
||||
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');
|
||||
|
||||
// Update video if provided
|
||||
if (video) {
|
||||
// Find existing video attachment (sort_order = 0 is video)
|
||||
const existingVideo = await prisma.lessonAttachment.findFirst({
|
||||
where: { lesson_id: lesson_id, sort_order: 0 }
|
||||
});
|
||||
|
||||
if (existingVideo) {
|
||||
// Delete old video from MinIO
|
||||
await deleteFile(existingVideo.file_path);
|
||||
|
||||
// Upload new video to MinIO
|
||||
const newVideoPath = generateFilePath(course_id, lesson_id, 'video', video.originalname);
|
||||
await uploadFile(newVideoPath, video.buffer, video.mimetype);
|
||||
|
||||
// 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
|
||||
const videoPath = generateFilePath(course_id, lesson_id, 'video', video.originalname);
|
||||
await uploadFile(videoPath, video.buffer, video.mimetype);
|
||||
|
||||
await prisma.lessonAttachment.create({
|
||||
data: {
|
||||
lesson_id: lesson_id,
|
||||
file_name: video.originalname,
|
||||
file_size: video.size,
|
||||
mime_type: video.mimetype,
|
||||
file_path: videoPath,
|
||||
sort_order: 0,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update attachments if provided
|
||||
if (attachments && attachments.length > 0) {
|
||||
// Find and delete existing attachments (sort_order > 0)
|
||||
const existingAttachments = await prisma.lessonAttachment.findMany({
|
||||
where: { lesson_id: lesson_id, sort_order: { gt: 0 } }
|
||||
});
|
||||
|
||||
// Delete old attachment files from MinIO
|
||||
for (const att of existingAttachments) {
|
||||
await deleteFile(att.file_path);
|
||||
}
|
||||
|
||||
// Delete old attachment records from database
|
||||
await prisma.lessonAttachment.deleteMany({
|
||||
where: { lesson_id: lesson_id, sort_order: { gt: 0 } }
|
||||
});
|
||||
|
||||
// Upload new attachments
|
||||
for (let i = 0; i < attachments.length; i++) {
|
||||
const attachment = attachments[i];
|
||||
const attachmentPath = generateFilePath(course_id, lesson_id, 'attachment', attachment.originalname);
|
||||
await uploadFile(attachmentPath, attachment.buffer, attachment.mimetype);
|
||||
|
||||
await prisma.lessonAttachment.create({
|
||||
data: {
|
||||
lesson_id: lesson_id,
|
||||
file_name: attachment.originalname,
|
||||
file_size: attachment.size,
|
||||
mime_type: attachment.mimetype,
|
||||
file_path: attachmentPath,
|
||||
sort_order: i + 1,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch updated lesson with attachments
|
||||
const updatedLesson = await prisma.lesson.findUnique({
|
||||
where: { id: lesson_id },
|
||||
include: { attachments: { orderBy: { sort_order: 'asc' } } }
|
||||
});
|
||||
|
||||
return { code: 200, message: 'Video lesson updated successfully', data: updatedLesson as LessonData };
|
||||
} catch (error) {
|
||||
logger.error(`Error updating video lesson: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* เพิ่มคำถามทีละข้อให้ Quiz Lesson
|
||||
* Add a single question with choices to an existing QUIZ lesson
|
||||
*/
|
||||
async addQuestion(request: AddQuestionInput): Promise<AddQuestionResponse> {
|
||||
try {
|
||||
const { token, course_id, lesson_id, question, explanation, question_type, score, 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
|
||||
const newQuestion = await prisma.question.create({
|
||||
data: {
|
||||
quiz_id: lesson.quiz.id,
|
||||
question: question,
|
||||
explanation: explanation,
|
||||
question_type: question_type,
|
||||
score: score ?? 1,
|
||||
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,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch complete question with choices
|
||||
const completeQuestion = await prisma.question.findUnique({
|
||||
where: { id: newQuestion.id },
|
||||
include: { choices: { orderBy: { sort_order: 'asc' } } }
|
||||
});
|
||||
|
||||
return { code: 200, message: 'Question added successfully', data: completeQuestion as QuizQuestionData };
|
||||
} catch (error) {
|
||||
logger.error(`Error adding question: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* อัปเดตคำถาม
|
||||
* Update a question and optionally replace all choices
|
||||
*/
|
||||
async updateQuestion(request: UpdateQuestionInput): Promise<UpdateQuestionResponse> {
|
||||
try {
|
||||
const { token, course_id, lesson_id, question_id, question, explanation, question_type, score, 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
|
||||
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,
|
||||
score: score ?? existingQuestion.score,
|
||||
sort_order: sort_order ?? existingQuestion.sort_order,
|
||||
}
|
||||
});
|
||||
|
||||
// If choices provided, replace all choices
|
||||
if (choices && choices.length > 0) {
|
||||
// Delete existing choices
|
||||
await prisma.choice.deleteMany({ where: { question_id: question_id } });
|
||||
|
||||
// Create new choices
|
||||
for (let i = 0; i < choices.length; i++) {
|
||||
const choiceInput = choices[i];
|
||||
await prisma.choice.create({
|
||||
data: {
|
||||
question_id: question_id,
|
||||
text: choiceInput.text,
|
||||
is_correct: choiceInput.is_correct,
|
||||
sort_order: choiceInput.sort_order ?? i,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch complete question with choices
|
||||
const completeQuestion = await prisma.question.findUnique({
|
||||
where: { id: question_id },
|
||||
include: { choices: { orderBy: { sort_order: 'asc' } } }
|
||||
});
|
||||
|
||||
return { code: 200, message: 'Question updated successfully', data: completeQuestion as QuizQuestionData };
|
||||
} catch (error) {
|
||||
logger.error(`Error updating question: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ลบคำถาม
|
||||
* 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 } });
|
||||
|
||||
return { code: 200, message: 'Question deleted successfully' };
|
||||
} catch (error) {
|
||||
logger.error(`Error deleting question: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -190,27 +190,10 @@ export interface ReorderChapterResponse {
|
|||
}
|
||||
|
||||
// ============================================
|
||||
// Chapter with Full Lessons (for detailed view)
|
||||
// ============================================
|
||||
|
||||
export interface ChapterWithLessonsResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: ChapterData & {
|
||||
lessons: LessonData[];
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Lesson Request Types
|
||||
// ============================================
|
||||
|
||||
export interface ListLessonsRequest {
|
||||
token: string;
|
||||
course_id: number;
|
||||
chapter_id: number;
|
||||
}
|
||||
|
||||
export interface GetLessonRequest {
|
||||
token: string;
|
||||
course_id: number;
|
||||
|
|
@ -315,7 +298,6 @@ export interface CreateLessonRequest {
|
|||
export interface UpdateLessonInput {
|
||||
title?: MultiLanguageText;
|
||||
content?: MultiLanguageText;
|
||||
type?: 'VIDEO' | 'QUIZ';
|
||||
duration_minutes?: number;
|
||||
sort_order?: number;
|
||||
is_sequential?: boolean;
|
||||
|
|
@ -350,13 +332,6 @@ export interface ReorderLessonsRequest {
|
|||
// Lesson Response Types
|
||||
// ============================================
|
||||
|
||||
export interface ListLessonsResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: LessonData[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface GetLessonResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
|
|
@ -375,6 +350,14 @@ export interface UpdateLessonResponse {
|
|||
data: LessonData;
|
||||
}
|
||||
|
||||
export interface UpdateVideoLessonInput {
|
||||
token: string;
|
||||
course_id: number;
|
||||
lesson_id: number;
|
||||
video?: UploadedFileInfo;
|
||||
attachments?: UploadedFileInfo[];
|
||||
}
|
||||
|
||||
export interface DeleteLessonResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
|
|
@ -398,4 +381,163 @@ export interface LessonWithDetailsResponse {
|
|||
quiz: QuizData | null;
|
||||
progress: LessonProgressData[];
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Add Video/Quiz to Lesson Input Types
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Input for adding video and attachments to an existing VIDEO lesson
|
||||
*/
|
||||
export interface AddVideoToLessonInput {
|
||||
token: string;
|
||||
course_id: number;
|
||||
lesson_id: number;
|
||||
video: UploadedFileInfo;
|
||||
attachments?: UploadedFileInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for adding quiz to an existing QUIZ lesson
|
||||
*/
|
||||
export interface AddQuizToLessonInput {
|
||||
token: string;
|
||||
course_id: number;
|
||||
lesson_id: number;
|
||||
quiz_data: {
|
||||
title: MultiLanguageText;
|
||||
description?: MultiLanguageText;
|
||||
passing_score?: number;
|
||||
time_limit?: number;
|
||||
shuffle_questions?: boolean;
|
||||
shuffle_choices?: boolean;
|
||||
show_answers_after_completion?: boolean;
|
||||
questions: CreateQuizQuestionInput[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for adding a single question to a quiz lesson
|
||||
*/
|
||||
export interface AddQuestionInput {
|
||||
token: string;
|
||||
course_id: number;
|
||||
lesson_id: number;
|
||||
question: MultiLanguageText;
|
||||
explanation?: MultiLanguageText;
|
||||
question_type: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER';
|
||||
score?: number;
|
||||
sort_order?: number;
|
||||
choices?: CreateQuizChoiceInput[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for adding a question
|
||||
*/
|
||||
export interface AddQuestionResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: QuizQuestionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for updating a question
|
||||
*/
|
||||
export interface UpdateQuestionInput {
|
||||
token: string;
|
||||
course_id: number;
|
||||
lesson_id: number;
|
||||
question_id: number;
|
||||
question?: MultiLanguageText;
|
||||
explanation?: MultiLanguageText;
|
||||
question_type?: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER';
|
||||
score?: number;
|
||||
sort_order?: number;
|
||||
choices?: CreateQuizChoiceInput[]; // Replace all choices if provided
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for updating a question
|
||||
*/
|
||||
export interface UpdateQuestionResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: QuizQuestionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for deleting a question
|
||||
*/
|
||||
export interface DeleteQuestionInput {
|
||||
token: string;
|
||||
course_id: number;
|
||||
lesson_id: number;
|
||||
question_id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for deleting a question
|
||||
*/
|
||||
export interface DeleteQuestionResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Controller Body Request Types
|
||||
// ============================================
|
||||
|
||||
export interface CreateChapterBody {
|
||||
title: MultiLanguageText;
|
||||
description?: MultiLanguageText;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateChapterBody {
|
||||
title?: MultiLanguageText;
|
||||
description?: MultiLanguageText;
|
||||
sort_order?: number;
|
||||
is_published?: boolean;
|
||||
}
|
||||
|
||||
export interface ReorderChapterBody {
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface CreateLessonBody {
|
||||
title: MultiLanguageText;
|
||||
content?: MultiLanguageText;
|
||||
type: 'VIDEO' | 'QUIZ';
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateLessonBody {
|
||||
title?: MultiLanguageText;
|
||||
content?: MultiLanguageText;
|
||||
duration_minutes?: number;
|
||||
sort_order?: number;
|
||||
is_published?: boolean;
|
||||
}
|
||||
|
||||
export interface ReorderLessonsBody {
|
||||
lesson_ids: number[];
|
||||
}
|
||||
|
||||
export interface AddQuestionBody {
|
||||
question: MultiLanguageText;
|
||||
explanation?: MultiLanguageText;
|
||||
question_type: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER';
|
||||
score?: number;
|
||||
sort_order?: number;
|
||||
choices?: CreateQuizChoiceInput[];
|
||||
}
|
||||
|
||||
export interface UpdateQuestionBody {
|
||||
question?: MultiLanguageText;
|
||||
explanation?: MultiLanguageText;
|
||||
question_type?: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER';
|
||||
score?: number;
|
||||
sort_order?: number;
|
||||
choices?: CreateQuizChoiceInput[];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue