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,
|
||||
ReorderQuestionResponse,
|
||||
ReorderQuestionBody,
|
||||
UpdateQuizResponse,
|
||||
UpdateQuizBody,
|
||||
} from '../types/ChaptersLesson.typs';
|
||||
|
||||
const chaptersLessonService = new ChaptersLessonService();
|
||||
|
|
@ -352,4 +354,28 @@ export class ChaptersLessonInstructorController {
|
|||
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 { 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();
|
||||
|
||||
|
|
@ -10,30 +17,28 @@ const chaptersLessonService = new ChaptersLessonService();
|
|||
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 chapterId Chapter 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')
|
||||
@SuccessResponse('200', 'Video uploaded successfully')
|
||||
@Response('400', 'Validation error - Video already exists')
|
||||
@Response('401', 'Unauthorized')
|
||||
@Response('403', 'Forbidden')
|
||||
@Response('404', 'Lesson not found')
|
||||
public async addVideoToLesson(
|
||||
public async uploadVideo(
|
||||
@Request() request: any,
|
||||
@Path() courseId: number,
|
||||
@Path() chapterId: number,
|
||||
@Path() lessonId: number,
|
||||
@UploadedFile() video: Express.Multer.File,
|
||||
@UploadedFiles() attachments?: Express.Multer.File[]
|
||||
): Promise<CreateLessonResponse> {
|
||||
@UploadedFile() video: Express.Multer.File
|
||||
): Promise<VideoOperationResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) throw new ValidationError('No token provided');
|
||||
|
||||
|
|
@ -41,7 +46,6 @@ export class LessonsController {
|
|||
throw new ValidationError('Video file is required');
|
||||
}
|
||||
|
||||
// Transform files to UploadedFileInfo
|
||||
const videoInfo: UploadedFileInfo = {
|
||||
originalname: video.originalname,
|
||||
mimetype: video.mimetype,
|
||||
|
|
@ -49,34 +53,22 @@ export class LessonsController {
|
|||
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({
|
||||
return await chaptersLessonService.uploadVideo({
|
||||
token,
|
||||
course_id: courseId,
|
||||
lesson_id: lessonId,
|
||||
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 chapterId Chapter ID
|
||||
* @param lessonId Lesson ID
|
||||
* @param video ไฟล์วิดีโอใหม่
|
||||
* @param attachments ไฟล์แนบใหม่
|
||||
* @param video ไฟล์วิดีโอใหม่ (required)
|
||||
*/
|
||||
@Put('{lessonId}/video')
|
||||
@Security('jwt', ['instructor'])
|
||||
|
|
@ -85,45 +77,111 @@ export class LessonsController {
|
|||
@Response('401', 'Unauthorized')
|
||||
@Response('403', 'Forbidden')
|
||||
@Response('404', 'Lesson not found')
|
||||
public async updateVideoLesson(
|
||||
public async updateVideo(
|
||||
@Request() request: any,
|
||||
@Path() courseId: number,
|
||||
@Path() chapterId: number,
|
||||
@Path() lessonId: number,
|
||||
@UploadedFile() video?: Express.Multer.File,
|
||||
@UploadedFiles() attachments?: Express.Multer.File[]
|
||||
): Promise<UpdateLessonResponse> {
|
||||
@UploadedFile() video: Express.Multer.File
|
||||
): Promise<VideoOperationResponse> {
|
||||
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 (!video) {
|
||||
throw new ValidationError('Video file is required');
|
||||
}
|
||||
|
||||
if (attachments && attachments.length > 0) {
|
||||
attachmentsInfo = attachments.map(file => ({
|
||||
originalname: file.originalname,
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
buffer: file.buffer,
|
||||
}));
|
||||
}
|
||||
const videoInfo: UploadedFileInfo = {
|
||||
originalname: video.originalname,
|
||||
mimetype: video.mimetype,
|
||||
size: video.size,
|
||||
buffer: video.buffer,
|
||||
};
|
||||
|
||||
return await chaptersLessonService.updateVideoLesson({
|
||||
return await chaptersLessonService.updateVideo({
|
||||
token,
|
||||
course_id: courseId,
|
||||
lesson_id: lessonId,
|
||||
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,
|
||||
DeleteLessonResponse,
|
||||
ReorderLessonsResponse,
|
||||
AddVideoToLessonInput,
|
||||
UpdateVideoLessonInput,
|
||||
AddQuestionInput,
|
||||
AddQuestionResponse,
|
||||
UpdateQuestionInput,
|
||||
|
|
@ -39,6 +37,17 @@ import {
|
|||
QuizQuestionData,
|
||||
ReorderQuestionInput,
|
||||
ReorderQuestionResponse,
|
||||
UploadVideoInput,
|
||||
UpdateVideoInput,
|
||||
UploadAttachmentInput,
|
||||
DeleteAttachmentInput,
|
||||
VideoOperationResponse,
|
||||
AttachmentOperationResponse,
|
||||
DeleteAttachmentResponse,
|
||||
LessonAttachmentData,
|
||||
UpdateQuizInput,
|
||||
UpdateQuizResponse,
|
||||
QuizData,
|
||||
} from "../types/ChaptersLesson.typs";
|
||||
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 {
|
||||
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 };
|
||||
await CoursesInstructorService.validateCourseStatus(course_id);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedError('Invalid token');
|
||||
}
|
||||
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');
|
||||
}
|
||||
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');
|
||||
if (!lesson) throw new NotFoundError('Lesson not found');
|
||||
if (lesson.type !== 'VIDEO') throw new ValidationError('Cannot add video to non-VIDEO type lesson');
|
||||
|
||||
// Check if video already exists
|
||||
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
|
||||
const videoPath = generateFilePath(course_id, lesson_id, 'video', video.originalname);
|
||||
await uploadFile(videoPath, video.buffer, video.mimetype);
|
||||
|
||||
// Save video as attachment
|
||||
// Save video as attachment (sort_order = 0 for video)
|
||||
await prisma.lessonAttachment.create({
|
||||
data: {
|
||||
lesson_id: lesson_id,
|
||||
lesson_id,
|
||||
file_name: video.originalname,
|
||||
file_size: video.size,
|
||||
mime_type: video.mimetype,
|
||||
|
|
@ -628,144 +641,220 @@ export class ChaptersLessonService {
|
|||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
// Get presigned URL
|
||||
const video_url = await getPresignedUrl(videoPath, 3600);
|
||||
|
||||
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,
|
||||
}
|
||||
});
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Video uploaded successfully',
|
||||
data: {
|
||||
lesson_id,
|
||||
video_url,
|
||||
file_name: video.originalname,
|
||||
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) {
|
||||
logger.error(`Error adding video to lesson: ${error}`);
|
||||
logger.error(`Error uploading video: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateVideoLesson(request: UpdateVideoLessonInput): Promise<UpdateLessonResponse> {
|
||||
/**
|
||||
* อัพเดต (เปลี่ยน) วิดีโอของบทเรียน
|
||||
* Update (replace) video in lesson
|
||||
*/
|
||||
async updateVideo(request: UpdateVideoInput): Promise<VideoOperationResponse> {
|
||||
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 };
|
||||
await CoursesInstructorService.validateCourseStatus(course_id);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
|
||||
logger.info(`User: ${user}`);
|
||||
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 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' } } }
|
||||
// Find existing video
|
||||
const existingVideo = await prisma.lessonAttachment.findFirst({
|
||||
where: { lesson_id, sort_order: 0 }
|
||||
});
|
||||
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -773,10 +862,11 @@ export class ChaptersLessonService {
|
|||
/**
|
||||
* เพิ่มคำถามทีละข้อให้ Quiz Lesson
|
||||
* Add a single question with choices to an existing QUIZ lesson
|
||||
* คะแนนจะถูกคำนวณอัตโนมัติ (100 คะแนน / จำนวนข้อ)
|
||||
*/
|
||||
async addQuestion(request: AddQuestionInput): Promise<AddQuestionResponse> {
|
||||
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 };
|
||||
await CoursesInstructorService.validateCourseStatus(course_id);
|
||||
|
||||
|
|
@ -802,14 +892,14 @@ export class ChaptersLessonService {
|
|||
});
|
||||
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({
|
||||
data: {
|
||||
quiz_id: lesson.quiz.id,
|
||||
question: question,
|
||||
explanation: explanation,
|
||||
question_type: question_type,
|
||||
score: score ?? 1,
|
||||
score: 1, // Temporary, will be recalculated
|
||||
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({
|
||||
where: { id: newQuestion.id },
|
||||
include: { choices: { orderBy: { sort_order: 'asc' } } }
|
||||
|
|
@ -845,10 +938,11 @@ export class ChaptersLessonService {
|
|||
/**
|
||||
* อัปเดตคำถาม
|
||||
* 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 { 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 };
|
||||
await CoursesInstructorService.validateCourseStatus(course_id);
|
||||
|
||||
|
|
@ -873,14 +967,13 @@ export class ChaptersLessonService {
|
|||
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({
|
||||
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,
|
||||
}
|
||||
});
|
||||
|
|
@ -1001,6 +1094,7 @@ export class ChaptersLessonService {
|
|||
/**
|
||||
* ลบคำถาม
|
||||
* Delete a question and all its choices
|
||||
* คะแนนจะถูกคำนวณใหม่หลังจากลบ
|
||||
*/
|
||||
async deleteQuestion(request: DeleteQuestionInput): Promise<DeleteQuestionResponse> {
|
||||
try {
|
||||
|
|
@ -1032,6 +1126,9 @@ export class ChaptersLessonService {
|
|||
// Delete the question (CASCADE will delete choices)
|
||||
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' };
|
||||
} catch (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;
|
||||
}
|
||||
|
||||
export interface UpdateVideoLessonInput {
|
||||
|
||||
|
||||
// ============================================
|
||||
// Separate Video/Attachment API Types
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Input for uploading video to a lesson
|
||||
*/
|
||||
export interface UploadVideoInput {
|
||||
token: string;
|
||||
course_id: number;
|
||||
lesson_id: number;
|
||||
video?: UploadedFileInfo;
|
||||
attachments?: UploadedFileInfo[];
|
||||
video: 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 {
|
||||
code: number;
|
||||
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
|
||||
|
|
@ -430,7 +487,6 @@ export interface AddQuestionInput {
|
|||
question: MultiLanguageText;
|
||||
explanation?: MultiLanguageText;
|
||||
question_type: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER';
|
||||
score?: number;
|
||||
sort_order?: number;
|
||||
choices?: CreateQuizChoiceInput[];
|
||||
}
|
||||
|
|
@ -455,7 +511,6 @@ export interface UpdateQuestionInput {
|
|||
question?: MultiLanguageText;
|
||||
explanation?: MultiLanguageText;
|
||||
question_type?: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER';
|
||||
score?: number;
|
||||
sort_order?: number;
|
||||
choices?: CreateQuizChoiceInput[]; // Replace all choices if provided
|
||||
}
|
||||
|
|
@ -501,6 +556,31 @@ export interface DeleteQuestionResponse {
|
|||
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
|
||||
// ============================================
|
||||
|
|
@ -550,7 +630,6 @@ export interface AddQuestionBody {
|
|||
question: MultiLanguageText;
|
||||
explanation?: MultiLanguageText;
|
||||
question_type: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER';
|
||||
score?: number;
|
||||
sort_order?: number;
|
||||
choices?: CreateQuizChoiceInput[];
|
||||
}
|
||||
|
|
@ -559,7 +638,16 @@ export interface UpdateQuestionBody {
|
|||
question?: MultiLanguageText;
|
||||
explanation?: MultiLanguageText;
|
||||
question_type?: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER';
|
||||
score?: number;
|
||||
sort_order?: number;
|
||||
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