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

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