elearning/Backend/src/services/ChaptersLesson.service.ts

959 lines
43 KiB
TypeScript
Raw Normal View History

import { prisma } from '../config/database';
import { Prisma } from '@prisma/client';
import { config } from '../config';
import { logger } from '../config/logger';
import { deleteFile, generateFilePath, uploadFile } from '../config/minio';
import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler';
import jwt from 'jsonwebtoken';
import {
LessonData,
ChapterData,
CreateLessonInput,
CreateChapterInput,
UpdateChapterInput,
ChaptersRequest,
DeleteChapterRequest,
ReorderChapterRequest,
ListChaptersResponse,
CreateChapterResponse,
UpdateChapterResponse,
DeleteChapterResponse,
ReorderChapterResponse,
GetLessonRequest,
UpdateLessonRequest,
DeleteLessonRequest,
ReorderLessonsRequest,
GetLessonResponse,
CreateLessonResponse,
UpdateLessonResponse,
DeleteLessonResponse,
ReorderLessonsResponse,
AddVideoToLessonInput,
UpdateVideoLessonInput,
AddQuestionInput,
AddQuestionResponse,
UpdateQuestionInput,
UpdateQuestionResponse,
DeleteQuestionInput,
DeleteQuestionResponse,
QuizQuestionData,
ReorderQuestionInput,
ReorderQuestionResponse,
} from "../types/ChaptersLesson.typs";
import { CoursesInstructorService } from './CoursesInstructor.service';
/**
* Course ( Instructor Student)
* Returns: { hasAccess: boolean, role: 'INSTRUCTOR' | 'STUDENT' | null, userId: number }
*/
async function validateCourseAccess(token: string, course_id: number): Promise<{
hasAccess: boolean;
role: 'INSTRUCTOR' | 'STUDENT' | null;
userId: number;
}> {
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
const userId = decodedToken.id;
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new UnauthorizedError('Invalid token');
}
// Check if user is an instructor for this course
const courseInstructor = await prisma.courseInstructor.findFirst({
where: { course_id, user_id: userId }
});
if (courseInstructor) {
return { hasAccess: true, role: 'INSTRUCTOR', userId };
}
// Check if user is enrolled in this course
const enrollment = await prisma.enrollment.findFirst({
where: {
course_id,
user_id: userId,
status: { in: ['ENROLLED', 'IN_PROGRESS', 'COMPLETED'] }
}
});
if (enrollment) {
return { hasAccess: true, role: 'STUDENT', userId };
}
return { hasAccess: false, role: null, userId };
}
export class ChaptersLessonService {
async listChapters(request: ChaptersRequest): Promise<ListChaptersResponse> {
try {
const { token, course_id } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) {
throw new UnauthorizedError('Invalid token');
}
const chapters = await prisma.chapter.findMany({
where: { course_id }, orderBy: { sort_order: 'asc' },
include: { lessons: { orderBy: { sort_order: 'asc' } } }
});
return { code: 200, message: 'Chapters fetched successfully', data: chapters as ChapterData[], total: chapters.length };
} catch (error) {
logger.error(`Error fetching chapters: ${error}`);
throw error;
}
}
async createChapter(request: CreateChapterInput): Promise<CreateChapterResponse> {
try {
const { token, course_id, title, description, sort_order } = 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 create chapter');
}
const chapter = await prisma.chapter.create({ data: { course_id, title, description, sort_order } });
return { code: 200, message: 'Chapter created successfully', data: chapter as ChapterData };
} catch (error) {
logger.error(`Error creating chapter: ${error}`);
throw error;
}
}
async updateChapter(request: UpdateChapterInput): Promise<UpdateChapterResponse> {
try {
const { token, course_id, chapter_id, title, description, sort_order } = 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 update chapter');
}
const chapter = await prisma.chapter.update({ where: { id: chapter_id }, data: { title, description, sort_order } });
return { code: 200, message: 'Chapter updated successfully', data: chapter as ChapterData };
} catch (error) {
logger.error(`Error updating chapter: ${error}`);
throw error;
}
}
async deleteChapter(request: DeleteChapterRequest): Promise<DeleteChapterResponse> {
try {
const { token, course_id, chapter_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 delete chapter');
}
await prisma.chapter.delete({ where: { id: chapter_id } });
return { code: 200, message: 'Chapter deleted successfully' };
} catch (error) {
logger.error(`Error deleting chapter: ${error}`);
throw error;
}
}
async reorderChapter(request: ReorderChapterRequest): Promise<ReorderChapterResponse> {
try {
2026-01-22 15:56:56 +07:00
const { token, course_id, chapter_id, sort_order: newSortOrder } = 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 reorder chapter');
}
2026-01-22 15:56:56 +07:00
// Get current chapter to find its current sort_order
const currentChapter = await prisma.chapter.findUnique({ where: { id: chapter_id } });
if (!currentChapter) {
throw new NotFoundError('Chapter not found');
}
2026-01-22 16:22:33 +07:00
// Validate chapter belongs to the specified course
if (currentChapter.course_id !== course_id) {
throw new NotFoundError('Chapter not found in this course');
}
2026-01-22 15:56:56 +07:00
const oldSortOrder = currentChapter.sort_order;
// If same position, no need to reorder
if (oldSortOrder === newSortOrder) {
const chapters = await prisma.chapter.findMany({
where: { course_id },
orderBy: { sort_order: 'asc' }
});
return { code: 200, message: 'Chapter reordered successfully', data: chapters as ChapterData[] };
}
// Shift other chapters to make room for the insert
if (oldSortOrder > newSortOrder) {
// Moving up: shift chapters between newSortOrder and oldSortOrder-1 down by 1
await prisma.chapter.updateMany({
where: {
course_id,
sort_order: { gte: newSortOrder, lt: oldSortOrder }
},
data: { sort_order: { increment: 1 } }
});
} else {
// Moving down: shift chapters between oldSortOrder+1 and newSortOrder up by 1
await prisma.chapter.updateMany({
where: {
course_id,
sort_order: { gt: oldSortOrder, lte: newSortOrder }
},
data: { sort_order: { decrement: 1 } }
});
}
// Update the target chapter to the new position
await prisma.chapter.update({ where: { id: chapter_id }, data: { sort_order: newSortOrder } });
// Fetch all chapters with updated order
const chapters = await prisma.chapter.findMany({
where: { course_id },
orderBy: { sort_order: 'asc' }
});
return { code: 200, message: 'Chapter reordered successfully', data: chapters as ChapterData[] };
} catch (error) {
logger.error(`Error reordering chapter: ${error}`);
throw error;
}
}
/**
* ()
* Create an empty lesson with basic information (no content yet)
* QUIZ type Quiz shell
*/
async createLesson(request: CreateLessonInput): Promise<CreateLessonResponse> {
try {
const { token, course_id, chapter_id, title, content, type, sort_order } = 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 create lesson');
}
// Create the lesson
const lesson = await prisma.lesson.create({
data: { chapter_id, title, content, type, sort_order }
});
// If QUIZ type, create empty Quiz shell
if (type === 'QUIZ') {
const userId = decodedToken.id;
await prisma.quiz.create({
data: {
lesson_id: lesson.id,
title: title, // Use lesson title as quiz title
passing_score: 60,
shuffle_questions: false,
shuffle_choices: false,
show_answers_after_completion: true,
created_by: userId,
}
});
// Fetch complete lesson with quiz
const completeLesson = await prisma.lesson.findUnique({
where: { id: lesson.id },
include: { quiz: true }
});
return { code: 200, message: 'Lesson created successfully', data: completeLesson as LessonData };
}
return { code: 200, message: 'Lesson created successfully', data: lesson as LessonData };
} catch (error) {
logger.error(`Error creating lesson: ${error}`);
throw error;
}
}
/**
* attachments quiz
* Get lesson with attachments and quiz (if QUIZ type)
*/
async getLesson(request: GetLessonRequest): Promise<GetLessonResponse> {
try {
const { token, course_id, lesson_id } = request;
// Check access for both instructor and enrolled student
const access = await validateCourseAccess(token, course_id);
if (!access.hasAccess) {
throw new ForbiddenError('You do not have access to this course');
}
const lesson = await prisma.lesson.findUnique({
where: { id: lesson_id },
include: {
attachments: { orderBy: { sort_order: 'asc' } },
quiz: {
include: {
questions: {
orderBy: { sort_order: 'asc' },
include: { choices: { orderBy: { sort_order: 'asc' } } }
}
}
}
}
});
if (!lesson) throw new NotFoundError('Lesson not found');
// Verify lesson belongs to the course
const chapter = await prisma.chapter.findUnique({ where: { id: lesson.chapter_id } });
if (!chapter || chapter.course_id !== course_id) {
throw new NotFoundError('Lesson not found in this course');
}
// For students, check if lesson is published
if (access.role === 'STUDENT' && !lesson.is_published) {
throw new ForbiddenError('This lesson is not available yet');
}
return { code: 200, message: 'Lesson fetched successfully', data: lesson as LessonData };
} catch (error) {
logger.error(`Error fetching lesson: ${error}`);
throw error;
}
}
async updateLesson(request: UpdateLessonRequest): Promise<UpdateLessonResponse> {
try {
const { token, course_id, lesson_id, data } = 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 update lesson');
}
const lesson = await prisma.lesson.update({ where: { id: lesson_id }, data });
return { code: 200, message: 'Lesson updated successfully', data: lesson as LessonData };
} catch (error) {
logger.error(`Error updating lesson: ${error}`);
throw error;
}
}
/**
*
* Reorder lessons within a chapter
*/
async reorderLessons(request: ReorderLessonsRequest): Promise<ReorderLessonsResponse> {
try {
2026-01-22 15:56:56 +07:00
const { token, course_id, chapter_id, lesson_id, sort_order: newSortOrder } = 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 reorder lessons');
// Verify chapter exists and belongs to the course
const chapter = await prisma.chapter.findUnique({ where: { id: chapter_id } });
if (!chapter || chapter.course_id !== course_id) {
throw new NotFoundError('Chapter not found');
}
2026-01-22 15:56:56 +07:00
// Get current lesson to find its current sort_order
const currentLesson = await prisma.lesson.findUnique({ where: { id: lesson_id } });
if (!currentLesson) {
throw new NotFoundError('Lesson not found');
}
if (currentLesson.chapter_id !== chapter_id) {
throw new NotFoundError('Lesson not found in this chapter');
}
const oldSortOrder = currentLesson.sort_order;
// If same position, no need to reorder
if (oldSortOrder === newSortOrder) {
const lessons = await prisma.lesson.findMany({
where: { chapter_id },
orderBy: { sort_order: 'asc' }
});
return { code: 200, message: 'Lessons reordered successfully', data: lessons as LessonData[] };
}
// Shift other lessons to make room for the insert
if (oldSortOrder > newSortOrder) {
// Moving up: shift lessons between newSortOrder and oldSortOrder-1 down by 1
await prisma.lesson.updateMany({
where: {
chapter_id,
sort_order: { gte: newSortOrder, lt: oldSortOrder }
},
data: { sort_order: { increment: 1 } }
});
} else {
// Moving down: shift lessons between oldSortOrder+1 and newSortOrder up by 1
await prisma.lesson.updateMany({
where: {
chapter_id,
sort_order: { gt: oldSortOrder, lte: newSortOrder }
},
data: { sort_order: { decrement: 1 } }
});
}
2026-01-22 15:56:56 +07:00
// Update the target lesson to the new position
await prisma.lesson.update({ where: { id: lesson_id }, data: { sort_order: newSortOrder } });
// Fetch all lessons with updated order
const lessons = await prisma.lesson.findMany({
2026-01-22 15:56:56 +07:00
where: { chapter_id },
orderBy: { sort_order: 'asc' }
});
return { code: 200, message: 'Lessons reordered successfully', data: lessons as LessonData[] };
} catch (error) {
logger.error(`Error reordering lessons: ${error}`);
throw error;
}
}
/**
*
* Delete lesson with all related data (quiz, questions, choices, attachments)
* publish
*/
async deleteLesson(request: DeleteLessonRequest): Promise<DeleteLessonResponse> {
try {
const { token, course_id, lesson_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 delete this lesson');
// Fetch lesson with all related data
const lesson = await prisma.lesson.findUnique({
where: { id: lesson_id },
include: {
attachments: true,
quiz: {
include: {
questions: {
include: { choices: true }
}
}
}
}
});
if (!lesson) throw new NotFoundError('Lesson not found');
// Check if lesson is published
if (lesson.is_published) {
throw new ValidationError('Cannot delete published lesson. Please unpublish first.');
}
// Delete attachment files from MinIO
if (lesson.attachments && lesson.attachments.length > 0) {
for (const attachment of lesson.attachments) {
try {
await deleteFile(attachment.file_path);
} catch (err) {
logger.warn(`Failed to delete file from MinIO: ${attachment.file_path}`);
}
}
}
// Delete lesson (CASCADE will delete: attachments, quiz, questions, choices)
// Based on Prisma schema: onDelete: Cascade
await prisma.lesson.delete({ where: { id: lesson_id } });
return { code: 200, message: 'Lesson deleted successfully' };
} catch (error) {
logger.error(`Error deleting lesson: ${error}`);
throw error;
}
}
/**
* VIDEO
* Add video and attachments to an existing VIDEO type lesson
*/
async addVideoLesson(request: AddVideoToLessonInput): Promise<CreateLessonResponse> {
try {
const { token, course_id, lesson_id, video, attachments } = 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 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');
}
// 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
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,
}
});
// 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);
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 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}`);
throw error;
}
}
async updateVideoLesson(request: UpdateVideoLessonInput): Promise<UpdateLessonResponse> {
try {
const { token, course_id, lesson_id, video, attachments } = 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 } });
2026-01-22 15:56:56 +07:00
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');
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' } } }
});
return { code: 200, message: 'Video lesson updated successfully', data: updatedLesson as LessonData };
} catch (error) {
logger.error(`Error updating video lesson: ${error}`);
throw error;
}
}
/**
* Quiz Lesson
* Add a single question with choices to an existing QUIZ lesson
*/
async addQuestion(request: AddQuestionInput): Promise<AddQuestionResponse> {
try {
const { token, course_id, lesson_id, question, explanation, question_type, score, sort_order, choices } = 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: true }
});
if (!lesson) throw new NotFoundError('Lesson not found');
if (lesson.type !== 'QUIZ') throw new ValidationError('Cannot add question to non-QUIZ type lesson');
if (!lesson.quiz) throw new NotFoundError('Quiz not found for this lesson');
// Get current max sort_order
const maxSortOrder = await prisma.question.aggregate({
where: { quiz_id: lesson.quiz.id },
_max: { sort_order: true }
});
const nextSortOrder = sort_order ?? ((maxSortOrder._max.sort_order ?? -1) + 1);
// Create the question
const newQuestion = await prisma.question.create({
data: {
quiz_id: lesson.quiz.id,
question: question,
explanation: explanation,
question_type: question_type,
score: score ?? 1,
sort_order: nextSortOrder,
}
});
// Create choices if provided
if (choices && choices.length > 0) {
for (let i = 0; i < choices.length; i++) {
const choiceInput = choices[i];
await prisma.choice.create({
data: {
question_id: newQuestion.id,
text: choiceInput.text,
is_correct: choiceInput.is_correct,
sort_order: choiceInput.sort_order ?? i,
}
});
}
}
// Fetch complete question with choices
const completeQuestion = await prisma.question.findUnique({
where: { id: newQuestion.id },
include: { choices: { orderBy: { sort_order: 'asc' } } }
});
return { code: 200, message: 'Question added successfully', data: completeQuestion as QuizQuestionData };
} catch (error) {
logger.error(`Error adding question: ${error}`);
throw error;
}
}
/**
*
* 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 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: true }
});
if (!lesson) throw new NotFoundError('Lesson not found');
if (lesson.type !== 'QUIZ') throw new ValidationError('Cannot update question in non-QUIZ type lesson');
if (!lesson.quiz) throw new NotFoundError('Quiz not found for this lesson');
// Verify question exists and belongs to this quiz
const existingQuestion = await prisma.question.findUnique({ where: { id: question_id } });
if (!existingQuestion || existingQuestion.quiz_id !== lesson.quiz.id) {
throw new NotFoundError('Question not found in this quiz');
}
// Update the question
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,
}
});
// If choices provided, replace all choices
if (choices && choices.length > 0) {
// Delete existing choices
await prisma.choice.deleteMany({ where: { question_id: question_id } });
// Create new choices
for (let i = 0; i < choices.length; i++) {
const choiceInput = choices[i];
await prisma.choice.create({
data: {
question_id: question_id,
text: choiceInput.text,
is_correct: choiceInput.is_correct,
sort_order: choiceInput.sort_order ?? i,
}
});
}
}
// Fetch complete question with choices
const completeQuestion = await prisma.question.findUnique({
where: { id: question_id },
include: { choices: { orderBy: { sort_order: 'asc' } } }
});
return { code: 200, message: 'Question updated successfully', data: completeQuestion as QuizQuestionData };
} catch (error) {
logger.error(`Error updating question: ${error}`);
throw error;
}
}
async reorderQuestion(request: ReorderQuestionInput): Promise<ReorderQuestionResponse> {
try {
const { token, course_id, lesson_id, question_id, sort_order } = 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: true }
});
if (!lesson) throw new NotFoundError('Lesson not found');
if (lesson.type !== 'QUIZ') throw new ValidationError('Cannot reorder question in non-QUIZ type lesson');
if (!lesson.quiz) throw new NotFoundError('Quiz not found for this lesson');
// Verify question exists and belongs to this quiz
const existingQuestion = await prisma.question.findUnique({ where: { id: question_id } });
if (!existingQuestion || existingQuestion.quiz_id !== lesson.quiz.id) {
throw new NotFoundError('Question not found in this quiz');
}
const oldSortOrder = existingQuestion.sort_order;
const quizId = lesson.quiz.id;
// If same position, no need to reorder
if (oldSortOrder === sort_order) {
const questions = await prisma.question.findMany({
where: { quiz_id: quizId },
orderBy: { sort_order: 'asc' },
include: { choices: { orderBy: { sort_order: 'asc' } } }
});
return { code: 200, message: 'Question reordered successfully', data: questions as QuizQuestionData[] };
}
// Shift other questions to make room for the insert
if (oldSortOrder > sort_order) {
// Moving up: shift questions between newSortOrder and oldSortOrder-1 down by 1
await prisma.question.updateMany({
where: {
quiz_id: quizId,
sort_order: { gte: sort_order, lt: oldSortOrder }
},
data: { sort_order: { increment: 1 } }
});
} else {
// Moving down: shift questions between oldSortOrder+1 and newSortOrder up by 1
await prisma.question.updateMany({
where: {
quiz_id: quizId,
sort_order: { gt: oldSortOrder, lte: sort_order }
},
data: { sort_order: { decrement: 1 } }
});
}
// Update the question's sort order
await prisma.question.update({
where: { id: question_id },
data: { sort_order }
});
// Fetch all questions with updated order
const questions = await prisma.question.findMany({
where: { quiz_id: quizId },
orderBy: { sort_order: 'asc' },
include: { choices: { orderBy: { sort_order: 'asc' } } }
});
return { code: 200, message: 'Question reordered successfully', data: questions as QuizQuestionData[] };
} catch (error) {
logger.error(`Error reordering question: ${error}`);
throw error;
}
}
/**
*
* Delete a question and all its choices
*/
async deleteQuestion(request: DeleteQuestionInput): Promise<DeleteQuestionResponse> {
try {
const { token, course_id, lesson_id, question_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 and is QUIZ type
const lesson = await prisma.lesson.findUnique({
where: { id: lesson_id },
include: { quiz: true }
});
if (!lesson) throw new NotFoundError('Lesson not found');
if (lesson.type !== 'QUIZ') throw new ValidationError('Cannot delete question from non-QUIZ type lesson');
if (!lesson.quiz) throw new NotFoundError('Quiz not found for this lesson');
// Verify question exists and belongs to this quiz
const existingQuestion = await prisma.question.findUnique({ where: { id: question_id } });
if (!existingQuestion || existingQuestion.quiz_id !== lesson.quiz.id) {
throw new NotFoundError('Question not found in this quiz');
}
// Delete the question (CASCADE will delete choices)
await prisma.question.delete({ where: { id: question_id } });
return { code: 200, message: 'Question deleted successfully' };
} catch (error) {
logger.error(`Error deleting question: ${error}`);
throw error;
}
}
}