feat: Implement granular API for video upload/update and attachment management with dedicated types and endpoints.

This commit is contained in:
JakkrapartXD 2026-01-26 17:23:26 +07:00
parent e082c77946
commit be7348c74d
4 changed files with 580 additions and 215 deletions

View file

@ -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,
});
}
} }

View file

@ -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,
}); });
} }
} }

View file

@ -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;
}
}
} }

View file

@ -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;
} }