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

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

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

View file

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