feat: Implement granular API for video upload/update and attachment management with dedicated types and endpoints.
This commit is contained in:
parent
e082c77946
commit
be7348c74d
4 changed files with 580 additions and 215 deletions
|
|
@ -24,6 +24,8 @@ import {
|
||||||
UpdateQuestionBody,
|
UpdateQuestionBody,
|
||||||
ReorderQuestionResponse,
|
ReorderQuestionResponse,
|
||||||
ReorderQuestionBody,
|
ReorderQuestionBody,
|
||||||
|
UpdateQuizResponse,
|
||||||
|
UpdateQuizBody,
|
||||||
} from '../types/ChaptersLesson.typs';
|
} from '../types/ChaptersLesson.typs';
|
||||||
|
|
||||||
const chaptersLessonService = new ChaptersLessonService();
|
const chaptersLessonService = new ChaptersLessonService();
|
||||||
|
|
@ -352,4 +354,28 @@ export class ChaptersLessonInstructorController {
|
||||||
question_id: questionId,
|
question_id: questionId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* อัพเดตการตั้งค่า Quiz
|
||||||
|
* Update quiz settings
|
||||||
|
*/
|
||||||
|
@Put('chapters/{chapterId}/lessons/{lessonId}/quiz')
|
||||||
|
@Security('jwt', ['instructor'])
|
||||||
|
@SuccessResponse('200', 'Quiz updated successfully')
|
||||||
|
public async updateQuiz(
|
||||||
|
@Request() request: any,
|
||||||
|
@Path() courseId: number,
|
||||||
|
@Path() chapterId: number,
|
||||||
|
@Path() lessonId: number,
|
||||||
|
@Body() body: UpdateQuizBody
|
||||||
|
): Promise<UpdateQuizResponse> {
|
||||||
|
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||||
|
if (!token) throw new ValidationError('No token provided');
|
||||||
|
return await chaptersLessonService.updateQuiz({
|
||||||
|
token,
|
||||||
|
course_id: courseId,
|
||||||
|
lesson_id: lessonId,
|
||||||
|
...body,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
import { Path, Post, Put, Request, Response, Route, Security, SuccessResponse, Tags, UploadedFile, UploadedFiles } from 'tsoa';
|
import { Delete, Path, Post, Put, Request, Response, Route, Security, SuccessResponse, Tags, UploadedFile } from 'tsoa';
|
||||||
import { ValidationError } from '../middleware/errorHandler';
|
import { ValidationError } from '../middleware/errorHandler';
|
||||||
import { ChaptersLessonService } from '../services/ChaptersLesson.service';
|
import { ChaptersLessonService } from '../services/ChaptersLesson.service';
|
||||||
import { UploadedFileInfo, CreateLessonResponse, UpdateLessonResponse } from '../types/ChaptersLesson.typs';
|
import {
|
||||||
|
UploadedFileInfo,
|
||||||
|
CreateLessonResponse,
|
||||||
|
UpdateLessonResponse,
|
||||||
|
VideoOperationResponse,
|
||||||
|
AttachmentOperationResponse,
|
||||||
|
DeleteAttachmentResponse
|
||||||
|
} from '../types/ChaptersLesson.typs';
|
||||||
|
|
||||||
const chaptersLessonService = new ChaptersLessonService();
|
const chaptersLessonService = new ChaptersLessonService();
|
||||||
|
|
||||||
|
|
@ -10,30 +17,28 @@ const chaptersLessonService = new ChaptersLessonService();
|
||||||
export class LessonsController {
|
export class LessonsController {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* เพิ่มวิดีโอและไฟล์แนบให้บทเรียน VIDEO ที่มีอยู่แล้ว
|
* อัพโหลดวิดีโอใหม่ให้บทเรียน (ครั้งแรก)
|
||||||
* Add video and attachments to an existing VIDEO type lesson
|
* Upload video to lesson (first time)
|
||||||
*
|
*
|
||||||
* @param courseId Course ID
|
* @param courseId Course ID
|
||||||
* @param chapterId Chapter ID
|
* @param chapterId Chapter ID
|
||||||
* @param lessonId Lesson ID
|
* @param lessonId Lesson ID
|
||||||
* @param video ไฟล์วิดีโอ (required)
|
* @param video ไฟล์วิดีโอ (required)
|
||||||
* @param attachments ไฟล์แนบ
|
|
||||||
*/
|
*/
|
||||||
@Post('{lessonId}/video')
|
@Post('{lessonId}/video')
|
||||||
@Security('jwt', ['instructor'])
|
@Security('jwt', ['instructor'])
|
||||||
@SuccessResponse('200', 'Video added successfully')
|
@SuccessResponse('200', 'Video uploaded successfully')
|
||||||
@Response('400', 'Validation error')
|
@Response('400', 'Validation error - Video already exists')
|
||||||
@Response('401', 'Unauthorized')
|
@Response('401', 'Unauthorized')
|
||||||
@Response('403', 'Forbidden')
|
@Response('403', 'Forbidden')
|
||||||
@Response('404', 'Lesson not found')
|
@Response('404', 'Lesson not found')
|
||||||
public async addVideoToLesson(
|
public async uploadVideo(
|
||||||
@Request() request: any,
|
@Request() request: any,
|
||||||
@Path() courseId: number,
|
@Path() courseId: number,
|
||||||
@Path() chapterId: number,
|
@Path() chapterId: number,
|
||||||
@Path() lessonId: number,
|
@Path() lessonId: number,
|
||||||
@UploadedFile() video: Express.Multer.File,
|
@UploadedFile() video: Express.Multer.File
|
||||||
@UploadedFiles() attachments?: Express.Multer.File[]
|
): Promise<VideoOperationResponse> {
|
||||||
): Promise<CreateLessonResponse> {
|
|
||||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||||
if (!token) throw new ValidationError('No token provided');
|
if (!token) throw new ValidationError('No token provided');
|
||||||
|
|
||||||
|
|
@ -41,7 +46,6 @@ export class LessonsController {
|
||||||
throw new ValidationError('Video file is required');
|
throw new ValidationError('Video file is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform files to UploadedFileInfo
|
|
||||||
const videoInfo: UploadedFileInfo = {
|
const videoInfo: UploadedFileInfo = {
|
||||||
originalname: video.originalname,
|
originalname: video.originalname,
|
||||||
mimetype: video.mimetype,
|
mimetype: video.mimetype,
|
||||||
|
|
@ -49,34 +53,22 @@ export class LessonsController {
|
||||||
buffer: video.buffer,
|
buffer: video.buffer,
|
||||||
};
|
};
|
||||||
|
|
||||||
let attachmentsInfo: UploadedFileInfo[] | undefined;
|
return await chaptersLessonService.uploadVideo({
|
||||||
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,
|
token,
|
||||||
course_id: courseId,
|
course_id: courseId,
|
||||||
lesson_id: lessonId,
|
lesson_id: lessonId,
|
||||||
video: videoInfo,
|
video: videoInfo,
|
||||||
attachments: attachmentsInfo,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* อัปเดตวิดีโอและไฟล์แนบของบทเรียน VIDEO
|
* อัพเดต (เปลี่ยน) วิดีโอของบทเรียน
|
||||||
* Update video and attachments of an existing VIDEO type lesson
|
* Update (replace) video in lesson
|
||||||
*
|
*
|
||||||
* @param courseId Course ID
|
* @param courseId Course ID
|
||||||
* @param chapterId Chapter ID
|
* @param chapterId Chapter ID
|
||||||
* @param lessonId Lesson ID
|
* @param lessonId Lesson ID
|
||||||
* @param video ไฟล์วิดีโอใหม่
|
* @param video ไฟล์วิดีโอใหม่ (required)
|
||||||
* @param attachments ไฟล์แนบใหม่
|
|
||||||
*/
|
*/
|
||||||
@Put('{lessonId}/video')
|
@Put('{lessonId}/video')
|
||||||
@Security('jwt', ['instructor'])
|
@Security('jwt', ['instructor'])
|
||||||
|
|
@ -85,45 +77,111 @@ export class LessonsController {
|
||||||
@Response('401', 'Unauthorized')
|
@Response('401', 'Unauthorized')
|
||||||
@Response('403', 'Forbidden')
|
@Response('403', 'Forbidden')
|
||||||
@Response('404', 'Lesson not found')
|
@Response('404', 'Lesson not found')
|
||||||
public async updateVideoLesson(
|
public async updateVideo(
|
||||||
@Request() request: any,
|
@Request() request: any,
|
||||||
@Path() courseId: number,
|
@Path() courseId: number,
|
||||||
@Path() chapterId: number,
|
@Path() chapterId: number,
|
||||||
@Path() lessonId: number,
|
@Path() lessonId: number,
|
||||||
@UploadedFile() video?: Express.Multer.File,
|
@UploadedFile() video: Express.Multer.File
|
||||||
@UploadedFiles() attachments?: Express.Multer.File[]
|
): Promise<VideoOperationResponse> {
|
||||||
): Promise<UpdateLessonResponse> {
|
|
||||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||||
if (!token) throw new ValidationError('No token provided');
|
if (!token) throw new ValidationError('No token provided');
|
||||||
|
|
||||||
// Transform files to UploadedFileInfo
|
if (!video) {
|
||||||
let videoInfo: UploadedFileInfo | undefined;
|
throw new ValidationError('Video file is required');
|
||||||
let attachmentsInfo: UploadedFileInfo[] | undefined;
|
|
||||||
|
|
||||||
if (video) {
|
|
||||||
videoInfo = {
|
|
||||||
originalname: video.originalname,
|
|
||||||
mimetype: video.mimetype,
|
|
||||||
size: video.size,
|
|
||||||
buffer: video.buffer,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachments && attachments.length > 0) {
|
const videoInfo: UploadedFileInfo = {
|
||||||
attachmentsInfo = attachments.map(file => ({
|
originalname: video.originalname,
|
||||||
originalname: file.originalname,
|
mimetype: video.mimetype,
|
||||||
mimetype: file.mimetype,
|
size: video.size,
|
||||||
size: file.size,
|
buffer: video.buffer,
|
||||||
buffer: file.buffer,
|
};
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return await chaptersLessonService.updateVideoLesson({
|
return await chaptersLessonService.updateVideo({
|
||||||
token,
|
token,
|
||||||
course_id: courseId,
|
course_id: courseId,
|
||||||
lesson_id: lessonId,
|
lesson_id: lessonId,
|
||||||
video: videoInfo,
|
video: videoInfo,
|
||||||
attachments: attachmentsInfo,
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* อัพโหลดไฟล์แนบทีละไฟล์
|
||||||
|
* Upload a single attachment to lesson
|
||||||
|
*
|
||||||
|
* @param courseId Course ID
|
||||||
|
* @param chapterId Chapter ID
|
||||||
|
* @param lessonId Lesson ID
|
||||||
|
* @param attachment ไฟล์แนบ (required)
|
||||||
|
*/
|
||||||
|
@Post('{lessonId}/attachments')
|
||||||
|
@Security('jwt', ['instructor'])
|
||||||
|
@SuccessResponse('200', 'Attachment uploaded successfully')
|
||||||
|
@Response('400', 'Validation error')
|
||||||
|
@Response('401', 'Unauthorized')
|
||||||
|
@Response('403', 'Forbidden')
|
||||||
|
@Response('404', 'Lesson not found')
|
||||||
|
public async uploadAttachment(
|
||||||
|
@Request() request: any,
|
||||||
|
@Path() courseId: number,
|
||||||
|
@Path() chapterId: number,
|
||||||
|
@Path() lessonId: number,
|
||||||
|
@UploadedFile() attachment: Express.Multer.File
|
||||||
|
): Promise<AttachmentOperationResponse> {
|
||||||
|
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||||
|
if (!token) throw new ValidationError('No token provided');
|
||||||
|
|
||||||
|
if (!attachment) {
|
||||||
|
throw new ValidationError('Attachment file is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentInfo: UploadedFileInfo = {
|
||||||
|
originalname: attachment.originalname,
|
||||||
|
mimetype: attachment.mimetype,
|
||||||
|
size: attachment.size,
|
||||||
|
buffer: attachment.buffer,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await chaptersLessonService.uploadAttachment({
|
||||||
|
token,
|
||||||
|
course_id: courseId,
|
||||||
|
lesson_id: lessonId,
|
||||||
|
attachment: attachmentInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ลบไฟล์แนบทีละไฟล์
|
||||||
|
* Delete a single attachment from lesson
|
||||||
|
*
|
||||||
|
* @param courseId Course ID
|
||||||
|
* @param chapterId Chapter ID
|
||||||
|
* @param lessonId Lesson ID
|
||||||
|
* @param attachmentId Attachment ID
|
||||||
|
*/
|
||||||
|
@Delete('{lessonId}/attachments/{attachmentId}')
|
||||||
|
@Security('jwt', ['instructor'])
|
||||||
|
@SuccessResponse('200', 'Attachment deleted successfully')
|
||||||
|
@Response('400', 'Validation error')
|
||||||
|
@Response('401', 'Unauthorized')
|
||||||
|
@Response('403', 'Forbidden')
|
||||||
|
@Response('404', 'Attachment not found')
|
||||||
|
public async deleteAttachment(
|
||||||
|
@Request() request: any,
|
||||||
|
@Path() courseId: number,
|
||||||
|
@Path() chapterId: number,
|
||||||
|
@Path() lessonId: number,
|
||||||
|
@Path() attachmentId: number
|
||||||
|
): Promise<DeleteAttachmentResponse> {
|
||||||
|
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||||
|
if (!token) throw new ValidationError('No token provided');
|
||||||
|
|
||||||
|
return await chaptersLessonService.deleteAttachment({
|
||||||
|
token,
|
||||||
|
course_id: courseId,
|
||||||
|
lesson_id: lessonId,
|
||||||
|
attachment_id: attachmentId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,6 @@ import {
|
||||||
UpdateLessonResponse,
|
UpdateLessonResponse,
|
||||||
DeleteLessonResponse,
|
DeleteLessonResponse,
|
||||||
ReorderLessonsResponse,
|
ReorderLessonsResponse,
|
||||||
AddVideoToLessonInput,
|
|
||||||
UpdateVideoLessonInput,
|
|
||||||
AddQuestionInput,
|
AddQuestionInput,
|
||||||
AddQuestionResponse,
|
AddQuestionResponse,
|
||||||
UpdateQuestionInput,
|
UpdateQuestionInput,
|
||||||
|
|
@ -39,6 +37,17 @@ import {
|
||||||
QuizQuestionData,
|
QuizQuestionData,
|
||||||
ReorderQuestionInput,
|
ReorderQuestionInput,
|
||||||
ReorderQuestionResponse,
|
ReorderQuestionResponse,
|
||||||
|
UploadVideoInput,
|
||||||
|
UpdateVideoInput,
|
||||||
|
UploadAttachmentInput,
|
||||||
|
DeleteAttachmentInput,
|
||||||
|
VideoOperationResponse,
|
||||||
|
AttachmentOperationResponse,
|
||||||
|
DeleteAttachmentResponse,
|
||||||
|
LessonAttachmentData,
|
||||||
|
UpdateQuizInput,
|
||||||
|
UpdateQuizResponse,
|
||||||
|
QuizData,
|
||||||
} from "../types/ChaptersLesson.typs";
|
} from "../types/ChaptersLesson.typs";
|
||||||
import { CoursesInstructorService } from './CoursesInstructor.service';
|
import { CoursesInstructorService } from './CoursesInstructor.service';
|
||||||
|
|
||||||
|
|
@ -583,43 +592,47 @@ export class ChaptersLessonService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Separate Video/Attachment APIs
|
||||||
|
// ============================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* เพิ่มวิดีโอและไฟล์แนบให้บทเรียนประเภท VIDEO
|
* อัพโหลดวิดีโอใหม่ให้บทเรียน (ครั้งแรก)
|
||||||
* Add video and attachments to an existing VIDEO type lesson
|
* Upload video to lesson (first time)
|
||||||
*/
|
*/
|
||||||
async addVideoLesson(request: AddVideoToLessonInput): Promise<CreateLessonResponse> {
|
async uploadVideo(request: UploadVideoInput): Promise<VideoOperationResponse> {
|
||||||
try {
|
try {
|
||||||
const { token, course_id, lesson_id, video, attachments } = request;
|
const { token, course_id, lesson_id, video } = request;
|
||||||
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
|
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
|
||||||
await CoursesInstructorService.validateCourseStatus(course_id);
|
await CoursesInstructorService.validateCourseStatus(course_id);
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
|
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
|
||||||
if (!user) {
|
if (!user) throw new UnauthorizedError('Invalid token');
|
||||||
throw new UnauthorizedError('Invalid token');
|
|
||||||
}
|
|
||||||
|
|
||||||
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
|
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
|
||||||
if (!courseInstructor) {
|
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
|
||||||
throw new ForbiddenError('You are not permitted to modify this lesson');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify lesson exists and is VIDEO type
|
// Verify lesson exists and is VIDEO type
|
||||||
const lesson = await prisma.lesson.findUnique({ where: { id: lesson_id } });
|
const lesson = await prisma.lesson.findUnique({ where: { id: lesson_id } });
|
||||||
if (!lesson) {
|
if (!lesson) throw new NotFoundError('Lesson not found');
|
||||||
throw new NotFoundError('Lesson not found');
|
if (lesson.type !== 'VIDEO') throw new ValidationError('Cannot add video to non-VIDEO type lesson');
|
||||||
}
|
|
||||||
if (lesson.type !== 'VIDEO') {
|
// Check if video already exists
|
||||||
throw new ValidationError('Cannot add video to non-VIDEO type lesson');
|
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
|
// Upload video to MinIO
|
||||||
const videoPath = generateFilePath(course_id, lesson_id, 'video', video.originalname);
|
const videoPath = generateFilePath(course_id, lesson_id, 'video', video.originalname);
|
||||||
await uploadFile(videoPath, video.buffer, video.mimetype);
|
await uploadFile(videoPath, video.buffer, video.mimetype);
|
||||||
|
|
||||||
// Save video as attachment
|
// Save video as attachment (sort_order = 0 for video)
|
||||||
await prisma.lessonAttachment.create({
|
await prisma.lessonAttachment.create({
|
||||||
data: {
|
data: {
|
||||||
lesson_id: lesson_id,
|
lesson_id,
|
||||||
file_name: video.originalname,
|
file_name: video.originalname,
|
||||||
file_size: video.size,
|
file_size: video.size,
|
||||||
mime_type: video.mimetype,
|
mime_type: video.mimetype,
|
||||||
|
|
@ -628,144 +641,220 @@ export class ChaptersLessonService {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle additional attachments (PDFs, documents, etc.)
|
// Get presigned URL
|
||||||
if (attachments && attachments.length > 0) {
|
const video_url = await getPresignedUrl(videoPath, 3600);
|
||||||
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({
|
return {
|
||||||
data: {
|
code: 200,
|
||||||
lesson_id: lesson_id,
|
message: 'Video uploaded successfully',
|
||||||
file_name: attachment.originalname,
|
data: {
|
||||||
file_size: attachment.size,
|
lesson_id,
|
||||||
mime_type: attachment.mimetype,
|
video_url,
|
||||||
file_path: attachmentPath,
|
file_name: video.originalname,
|
||||||
sort_order: i + 1,
|
file_size: video.size,
|
||||||
}
|
mime_type: video.mimetype,
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// 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) {
|
} catch (error) {
|
||||||
logger.error(`Error adding video to lesson: ${error}`);
|
logger.error(`Error uploading video: ${error}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateVideoLesson(request: UpdateVideoLessonInput): Promise<UpdateLessonResponse> {
|
/**
|
||||||
|
* อัพเดต (เปลี่ยน) วิดีโอของบทเรียน
|
||||||
|
* Update (replace) video in lesson
|
||||||
|
*/
|
||||||
|
async updateVideo(request: UpdateVideoInput): Promise<VideoOperationResponse> {
|
||||||
try {
|
try {
|
||||||
const { token, course_id, lesson_id, video, attachments } = request;
|
const { token, course_id, lesson_id, video } = request;
|
||||||
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
|
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
|
||||||
await CoursesInstructorService.validateCourseStatus(course_id);
|
await CoursesInstructorService.validateCourseStatus(course_id);
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
|
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
|
||||||
logger.info(`User: ${user}`);
|
|
||||||
if (!user) throw new UnauthorizedError('Invalid token');
|
if (!user) throw new UnauthorizedError('Invalid token');
|
||||||
|
|
||||||
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
|
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
|
||||||
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
|
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 } });
|
const lesson = await prisma.lesson.findUnique({ where: { id: lesson_id } });
|
||||||
if (!lesson) throw new NotFoundError('Lesson not found');
|
if (!lesson) throw new NotFoundError('Lesson not found');
|
||||||
if (lesson.type !== 'VIDEO') throw new ValidationError('Cannot update video for non-VIDEO type lesson');
|
if (lesson.type !== 'VIDEO') throw new ValidationError('Cannot update video for non-VIDEO type lesson');
|
||||||
|
|
||||||
// Update video if provided
|
// Find existing video
|
||||||
if (video) {
|
const existingVideo = await prisma.lessonAttachment.findFirst({
|
||||||
// Find existing video attachment (sort_order = 0 is video)
|
where: { lesson_id, sort_order: 0 }
|
||||||
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 };
|
// 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) {
|
} catch (error) {
|
||||||
logger.error(`Error updating video lesson: ${error}`);
|
logger.error(`Error updating video: ${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);
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
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 } });
|
||||||
|
|
||||||
|
return { code: 200, message: 'Attachment deleted successfully' };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error deleting attachment: ${error}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -773,10 +862,11 @@ export class ChaptersLessonService {
|
||||||
/**
|
/**
|
||||||
* เพิ่มคำถามทีละข้อให้ Quiz Lesson
|
* เพิ่มคำถามทีละข้อให้ Quiz Lesson
|
||||||
* Add a single question with choices to an existing QUIZ lesson
|
* Add a single question with choices to an existing QUIZ lesson
|
||||||
|
* คะแนนจะถูกคำนวณอัตโนมัติ (100 คะแนน / จำนวนข้อ)
|
||||||
*/
|
*/
|
||||||
async addQuestion(request: AddQuestionInput): Promise<AddQuestionResponse> {
|
async addQuestion(request: AddQuestionInput): Promise<AddQuestionResponse> {
|
||||||
try {
|
try {
|
||||||
const { token, course_id, lesson_id, question, explanation, question_type, score, sort_order, choices } = request;
|
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 };
|
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
|
||||||
await CoursesInstructorService.validateCourseStatus(course_id);
|
await CoursesInstructorService.validateCourseStatus(course_id);
|
||||||
|
|
||||||
|
|
@ -802,14 +892,14 @@ export class ChaptersLessonService {
|
||||||
});
|
});
|
||||||
const nextSortOrder = sort_order ?? ((maxSortOrder._max.sort_order ?? -1) + 1);
|
const nextSortOrder = sort_order ?? ((maxSortOrder._max.sort_order ?? -1) + 1);
|
||||||
|
|
||||||
// Create the question
|
// Create the question with temporary score (will be recalculated)
|
||||||
const newQuestion = await prisma.question.create({
|
const newQuestion = await prisma.question.create({
|
||||||
data: {
|
data: {
|
||||||
quiz_id: lesson.quiz.id,
|
quiz_id: lesson.quiz.id,
|
||||||
question: question,
|
question: question,
|
||||||
explanation: explanation,
|
explanation: explanation,
|
||||||
question_type: question_type,
|
question_type: question_type,
|
||||||
score: score ?? 1,
|
score: 1, // Temporary, will be recalculated
|
||||||
sort_order: nextSortOrder,
|
sort_order: nextSortOrder,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -829,7 +919,10 @@ export class ChaptersLessonService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch complete question with choices
|
// 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({
|
const completeQuestion = await prisma.question.findUnique({
|
||||||
where: { id: newQuestion.id },
|
where: { id: newQuestion.id },
|
||||||
include: { choices: { orderBy: { sort_order: 'asc' } } }
|
include: { choices: { orderBy: { sort_order: 'asc' } } }
|
||||||
|
|
@ -845,10 +938,11 @@ export class ChaptersLessonService {
|
||||||
/**
|
/**
|
||||||
* อัปเดตคำถาม
|
* อัปเดตคำถาม
|
||||||
* Update a question and optionally replace all choices
|
* Update a question and optionally replace all choices
|
||||||
|
* หมายเหตุ: คะแนนจะถูกคำนวณอัตโนมัติ ไม่สามารถเปลี่ยนได้
|
||||||
*/
|
*/
|
||||||
async updateQuestion(request: UpdateQuestionInput): Promise<UpdateQuestionResponse> {
|
async updateQuestion(request: UpdateQuestionInput): Promise<UpdateQuestionResponse> {
|
||||||
try {
|
try {
|
||||||
const { token, course_id, lesson_id, question_id, question, explanation, question_type, score, sort_order, choices } = request;
|
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 };
|
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
|
||||||
await CoursesInstructorService.validateCourseStatus(course_id);
|
await CoursesInstructorService.validateCourseStatus(course_id);
|
||||||
|
|
||||||
|
|
@ -873,14 +967,13 @@ export class ChaptersLessonService {
|
||||||
throw new NotFoundError('Question not found in this quiz');
|
throw new NotFoundError('Question not found in this quiz');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the question
|
// Update the question (score is NOT updated - it's auto-calculated)
|
||||||
await prisma.question.update({
|
await prisma.question.update({
|
||||||
where: { id: question_id },
|
where: { id: question_id },
|
||||||
data: {
|
data: {
|
||||||
question: (question ?? existingQuestion.question) as Prisma.InputJsonValue,
|
question: (question ?? existingQuestion.question) as Prisma.InputJsonValue,
|
||||||
explanation: (explanation ?? existingQuestion.explanation) as Prisma.InputJsonValue,
|
explanation: (explanation ?? existingQuestion.explanation) as Prisma.InputJsonValue,
|
||||||
question_type: question_type ?? existingQuestion.question_type,
|
question_type: question_type ?? existingQuestion.question_type,
|
||||||
score: score ?? existingQuestion.score,
|
|
||||||
sort_order: sort_order ?? existingQuestion.sort_order,
|
sort_order: sort_order ?? existingQuestion.sort_order,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -1001,6 +1094,7 @@ export class ChaptersLessonService {
|
||||||
/**
|
/**
|
||||||
* ลบคำถาม
|
* ลบคำถาม
|
||||||
* Delete a question and all its choices
|
* Delete a question and all its choices
|
||||||
|
* คะแนนจะถูกคำนวณใหม่หลังจากลบ
|
||||||
*/
|
*/
|
||||||
async deleteQuestion(request: DeleteQuestionInput): Promise<DeleteQuestionResponse> {
|
async deleteQuestion(request: DeleteQuestionInput): Promise<DeleteQuestionResponse> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1032,6 +1126,9 @@ export class ChaptersLessonService {
|
||||||
// Delete the question (CASCADE will delete choices)
|
// Delete the question (CASCADE will delete choices)
|
||||||
await prisma.question.delete({ where: { id: question_id } });
|
await prisma.question.delete({ where: { id: question_id } });
|
||||||
|
|
||||||
|
// Recalculate scores for remaining questions
|
||||||
|
await this.recalculateQuestionScores(lesson.quiz.id);
|
||||||
|
|
||||||
return { code: 200, message: 'Question deleted successfully' };
|
return { code: 200, message: 'Question deleted successfully' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error deleting question: ${error}`);
|
logger.error(`Error deleting question: ${error}`);
|
||||||
|
|
@ -1039,4 +1136,100 @@ export class ChaptersLessonService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* คำนวณคะแนนใหม่สำหรับทุกคำถามใน 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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* อัพเดตการตั้งค่า 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 } = 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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -353,14 +353,85 @@ export interface UpdateLessonResponse {
|
||||||
data: LessonData;
|
data: LessonData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateVideoLessonInput {
|
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Separate Video/Attachment API Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for uploading video to a lesson
|
||||||
|
*/
|
||||||
|
export interface UploadVideoInput {
|
||||||
token: string;
|
token: string;
|
||||||
course_id: number;
|
course_id: number;
|
||||||
lesson_id: number;
|
lesson_id: number;
|
||||||
video?: UploadedFileInfo;
|
video: UploadedFileInfo;
|
||||||
attachments?: UploadedFileInfo[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for updating (replacing) video in a lesson
|
||||||
|
*/
|
||||||
|
export interface UpdateVideoInput {
|
||||||
|
token: string;
|
||||||
|
course_id: number;
|
||||||
|
lesson_id: number;
|
||||||
|
video: UploadedFileInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for uploading a single attachment to a lesson
|
||||||
|
*/
|
||||||
|
export interface UploadAttachmentInput {
|
||||||
|
token: string;
|
||||||
|
course_id: number;
|
||||||
|
lesson_id: number;
|
||||||
|
attachment: UploadedFileInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for deleting an attachment from a lesson
|
||||||
|
*/
|
||||||
|
export interface DeleteAttachmentInput {
|
||||||
|
token: string;
|
||||||
|
course_id: number;
|
||||||
|
lesson_id: number;
|
||||||
|
attachment_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response for video operations
|
||||||
|
*/
|
||||||
|
export interface VideoOperationResponse {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: {
|
||||||
|
lesson_id: number;
|
||||||
|
video_url: string | null;
|
||||||
|
file_name: string;
|
||||||
|
file_size: number;
|
||||||
|
mime_type: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response for attachment operations
|
||||||
|
*/
|
||||||
|
export interface AttachmentOperationResponse {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: LessonAttachmentData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response for delete attachment operation
|
||||||
|
*/
|
||||||
|
export interface DeleteAttachmentResponse {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface DeleteLessonResponse {
|
export interface DeleteLessonResponse {
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
|
|
@ -386,20 +457,6 @@ export interface LessonWithDetailsResponse {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 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
|
* Input for adding quiz to an existing QUIZ lesson
|
||||||
|
|
@ -430,7 +487,6 @@ export interface AddQuestionInput {
|
||||||
question: MultiLanguageText;
|
question: MultiLanguageText;
|
||||||
explanation?: MultiLanguageText;
|
explanation?: MultiLanguageText;
|
||||||
question_type: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER';
|
question_type: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER';
|
||||||
score?: number;
|
|
||||||
sort_order?: number;
|
sort_order?: number;
|
||||||
choices?: CreateQuizChoiceInput[];
|
choices?: CreateQuizChoiceInput[];
|
||||||
}
|
}
|
||||||
|
|
@ -455,7 +511,6 @@ export interface UpdateQuestionInput {
|
||||||
question?: MultiLanguageText;
|
question?: MultiLanguageText;
|
||||||
explanation?: MultiLanguageText;
|
explanation?: MultiLanguageText;
|
||||||
question_type?: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER';
|
question_type?: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER';
|
||||||
score?: number;
|
|
||||||
sort_order?: number;
|
sort_order?: number;
|
||||||
choices?: CreateQuizChoiceInput[]; // Replace all choices if provided
|
choices?: CreateQuizChoiceInput[]; // Replace all choices if provided
|
||||||
}
|
}
|
||||||
|
|
@ -501,6 +556,31 @@ export interface DeleteQuestionResponse {
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for updating quiz settings
|
||||||
|
*/
|
||||||
|
export interface UpdateQuizInput {
|
||||||
|
token: string;
|
||||||
|
course_id: number;
|
||||||
|
lesson_id: number;
|
||||||
|
title?: MultiLanguageText;
|
||||||
|
description?: MultiLanguageText;
|
||||||
|
passing_score?: number;
|
||||||
|
time_limit?: number;
|
||||||
|
shuffle_questions?: boolean;
|
||||||
|
shuffle_choices?: boolean;
|
||||||
|
show_answers_after_completion?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response for updating quiz
|
||||||
|
*/
|
||||||
|
export interface UpdateQuizResponse {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: QuizData;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Controller Body Request Types
|
// Controller Body Request Types
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
@ -550,7 +630,6 @@ export interface AddQuestionBody {
|
||||||
question: MultiLanguageText;
|
question: MultiLanguageText;
|
||||||
explanation?: MultiLanguageText;
|
explanation?: MultiLanguageText;
|
||||||
question_type: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER';
|
question_type: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER';
|
||||||
score?: number;
|
|
||||||
sort_order?: number;
|
sort_order?: number;
|
||||||
choices?: CreateQuizChoiceInput[];
|
choices?: CreateQuizChoiceInput[];
|
||||||
}
|
}
|
||||||
|
|
@ -559,7 +638,16 @@ export interface UpdateQuestionBody {
|
||||||
question?: MultiLanguageText;
|
question?: MultiLanguageText;
|
||||||
explanation?: MultiLanguageText;
|
explanation?: MultiLanguageText;
|
||||||
question_type?: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER';
|
question_type?: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER';
|
||||||
score?: number;
|
|
||||||
sort_order?: number;
|
sort_order?: number;
|
||||||
choices?: CreateQuizChoiceInput[];
|
choices?: CreateQuizChoiceInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateQuizBody {
|
||||||
|
title?: MultiLanguageText;
|
||||||
|
description?: MultiLanguageText;
|
||||||
|
passing_score?: number;
|
||||||
|
time_limit?: number;
|
||||||
|
shuffle_questions?: boolean;
|
||||||
|
shuffle_choices?: boolean;
|
||||||
|
show_answers_after_completion?: boolean;
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue