feat: Implement lesson creation with file uploads (video, attachments) and quiz data, integrating MinIO for storage.
This commit is contained in:
parent
4851182f4a
commit
04e2da43c4
6 changed files with 715 additions and 4 deletions
111
Backend/src/config/minio.ts
Normal file
111
Backend/src/config/minio.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { Client } from 'minio';
|
||||
import { config } from './index';
|
||||
import { logger } from './logger';
|
||||
|
||||
/**
|
||||
* MinIO Client Configuration
|
||||
* Used for uploading videos and attachments to S3-compatible storage
|
||||
*/
|
||||
export const minioClient = new Client({
|
||||
endPoint: new URL(config.s3.endpoint).hostname,
|
||||
port: parseInt(new URL(config.s3.endpoint).port) || (config.s3.useSSL ? 443 : 9000),
|
||||
useSSL: config.s3.useSSL,
|
||||
accessKey: config.s3.accessKey,
|
||||
secretKey: config.s3.secretKey,
|
||||
});
|
||||
|
||||
/**
|
||||
* Ensure bucket exists, create if not
|
||||
*/
|
||||
export async function ensureBucketExists(): Promise<void> {
|
||||
try {
|
||||
const exists = await minioClient.bucketExists(config.s3.bucket);
|
||||
if (!exists) {
|
||||
await minioClient.makeBucket(config.s3.bucket, 'us-east-1');
|
||||
logger.info(`Bucket '${config.s3.bucket}' created successfully`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error ensuring bucket exists: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique file path for storage
|
||||
*/
|
||||
export function generateFilePath(
|
||||
courseId: number,
|
||||
lessonId: number,
|
||||
fileType: 'video' | 'attachment',
|
||||
originalFilename: string
|
||||
): string {
|
||||
const timestamp = Date.now();
|
||||
const uniqueId = Math.random().toString(36).substring(2, 15);
|
||||
const extension = originalFilename.split('.').pop() || '';
|
||||
const safeFilename = `${timestamp}-${uniqueId}.${extension}`;
|
||||
|
||||
if (fileType === 'video') {
|
||||
return `courses/${courseId}/lessons/${lessonId}/video/${safeFilename}`;
|
||||
}
|
||||
return `courses/${courseId}/lessons/${lessonId}/attachments/${safeFilename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to MinIO
|
||||
*/
|
||||
export async function uploadFile(
|
||||
filePath: string,
|
||||
fileBuffer: Buffer,
|
||||
mimeType: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
await ensureBucketExists();
|
||||
|
||||
await minioClient.putObject(
|
||||
config.s3.bucket,
|
||||
filePath,
|
||||
fileBuffer,
|
||||
fileBuffer.length,
|
||||
{ 'Content-Type': mimeType }
|
||||
);
|
||||
|
||||
logger.info(`File uploaded successfully: ${filePath}`);
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
logger.error(`Error uploading file: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from MinIO
|
||||
*/
|
||||
export async function deleteFile(filePath: string): Promise<void> {
|
||||
try {
|
||||
await minioClient.removeObject(config.s3.bucket, filePath);
|
||||
logger.info(`File deleted successfully: ${filePath}`);
|
||||
} catch (error) {
|
||||
logger.error(`Error deleting file: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a presigned URL for file access
|
||||
*/
|
||||
export async function getPresignedUrl(
|
||||
filePath: string,
|
||||
expirySeconds: number = 3600
|
||||
): Promise<string> {
|
||||
try {
|
||||
const url = await minioClient.presignedGetObject(
|
||||
config.s3.bucket,
|
||||
filePath,
|
||||
expirySeconds
|
||||
);
|
||||
return url;
|
||||
} catch (error) {
|
||||
logger.error(`Error generating presigned URL: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
139
Backend/src/controllers/LessonsController.ts
Normal file
139
Backend/src/controllers/LessonsController.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ChaptersLessonService } from '../services/ChaptersLesson.service';
|
||||
import { ValidationError } from '../middleware/errorHandler';
|
||||
import {
|
||||
lessonUpload,
|
||||
LessonUploadRequest,
|
||||
validateTotalAttachmentSize,
|
||||
validateVideoSize
|
||||
} from '../middleware/upload';
|
||||
import { UploadedFileInfo, CreateLessonInput } from '../types/ChaptersLesson.typs';
|
||||
|
||||
const chaptersLessonService = new ChaptersLessonService();
|
||||
|
||||
/**
|
||||
* Controller for handling lesson CRUD operations with file uploads
|
||||
*/
|
||||
export class LessonsController {
|
||||
/**
|
||||
* สร้างบทเรียนใหม่พร้อมไฟล์แนบ
|
||||
* Create a new lesson with optional video and attachments
|
||||
*
|
||||
* @route POST /api/instructors/courses/:courseId/chapters/:chapterId/lessons
|
||||
* @contentType multipart/form-data
|
||||
*
|
||||
* @param {number} courseId - รหัสคอร์ส / Course ID
|
||||
* @param {number} chapterId - รหัสบท / Chapter ID
|
||||
* @param {string} title - ชื่อบทเรียน (JSON: { th: "", en: "" })
|
||||
* @param {string} [content] - เนื้อหาบทเรียน (JSON: { th: "", en: "" })
|
||||
* @param {string} type - ประเภทบทเรียน (VIDEO | QUIZ)
|
||||
* @param {number} [sort_order] - ลำดับการแสดงผล
|
||||
* @param {File} [video] - ไฟล์วิดีโอ (สำหรับ type=VIDEO เท่านั้น)
|
||||
* @param {File[]} [attachments] - ไฟล์แนบ (PDFs, เอกสาร, รูปภาพ)
|
||||
*/
|
||||
async createLesson(req: LessonUploadRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new ValidationError('No token provided');
|
||||
}
|
||||
|
||||
const courseId = parseInt(req.params.courseId, 10);
|
||||
const chapterId = parseInt(req.params.chapterId, 10);
|
||||
|
||||
if (isNaN(courseId) || isNaN(chapterId)) {
|
||||
throw new ValidationError('Invalid course ID or chapter ID');
|
||||
}
|
||||
|
||||
// Parse JSON fields from multipart form
|
||||
const title = JSON.parse(req.body.title || '{}');
|
||||
const content = req.body.content ? JSON.parse(req.body.content) : undefined;
|
||||
const type = req.body.type as 'VIDEO' | 'QUIZ';
|
||||
const sortOrder = req.body.sort_order ? parseInt(req.body.sort_order, 10) : undefined;
|
||||
|
||||
if (!type || !['VIDEO', 'QUIZ'].includes(type)) {
|
||||
throw new ValidationError('Invalid lesson type. Must be VIDEO or QUIZ');
|
||||
}
|
||||
|
||||
if (!title.th || !title.en) {
|
||||
throw new ValidationError('Title must have both Thai (th) and English (en) values');
|
||||
}
|
||||
|
||||
// Process uploaded files
|
||||
const files = req.files as { video?: Express.Multer.File[]; attachments?: Express.Multer.File[] } | undefined;
|
||||
|
||||
let video: UploadedFileInfo | undefined;
|
||||
let attachments: UploadedFileInfo[] | undefined;
|
||||
|
||||
if (files?.video && files.video.length > 0) {
|
||||
const videoFile = files.video[0];
|
||||
validateVideoSize(videoFile);
|
||||
video = {
|
||||
originalname: videoFile.originalname,
|
||||
mimetype: videoFile.mimetype,
|
||||
size: videoFile.size,
|
||||
buffer: videoFile.buffer,
|
||||
};
|
||||
}
|
||||
|
||||
if (files?.attachments && files.attachments.length > 0) {
|
||||
validateTotalAttachmentSize(files.attachments);
|
||||
attachments = files.attachments.map(file => ({
|
||||
originalname: file.originalname,
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
buffer: file.buffer,
|
||||
}));
|
||||
}
|
||||
|
||||
// Validate VIDEO type must have video file (optional - can be uploaded later)
|
||||
// if (type === 'VIDEO' && !video) {
|
||||
// throw new ValidationError('Video file is required for VIDEO type lessons');
|
||||
// }
|
||||
|
||||
const input: CreateLessonInput = {
|
||||
token,
|
||||
course_id: courseId,
|
||||
chapter_id: chapterId,
|
||||
title,
|
||||
content,
|
||||
type,
|
||||
sort_order: sortOrder,
|
||||
video,
|
||||
attachments,
|
||||
};
|
||||
|
||||
const result = await chaptersLessonService.createLesson(input);
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Express router middleware wrapper for file upload
|
||||
* Use this in routes like:
|
||||
*
|
||||
* router.post(
|
||||
* '/api/instructors/courses/:courseId/chapters/:chapterId/lessons',
|
||||
* authenticateMiddleware,
|
||||
* handleLessonUpload,
|
||||
* lessonsController.createLesson.bind(lessonsController)
|
||||
* );
|
||||
*/
|
||||
export const handleLessonUpload = (req: Request, res: Response, next: NextFunction) => {
|
||||
lessonUpload(req, res, (err) => {
|
||||
if (err) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: 'FILE_UPLOAD_ERROR',
|
||||
message: err.message,
|
||||
}
|
||||
});
|
||||
}
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
export const lessonsController = new LessonsController();
|
||||
121
Backend/src/middleware/upload.ts
Normal file
121
Backend/src/middleware/upload.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import multer from 'multer';
|
||||
import { Request } from 'express';
|
||||
import { config } from '../config';
|
||||
import { ValidationError } from './errorHandler';
|
||||
|
||||
/**
|
||||
* Allowed MIME types for different file categories
|
||||
*/
|
||||
export const ALLOWED_VIDEO_TYPES = [
|
||||
'video/mp4',
|
||||
'video/quicktime',
|
||||
'video/x-msvideo',
|
||||
'video/webm',
|
||||
'video/x-matroska',
|
||||
];
|
||||
|
||||
export const ALLOWED_ATTACHMENT_TYPES = [
|
||||
// Documents
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
// Text
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
// Images
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
// Archives
|
||||
'application/zip',
|
||||
'application/x-rar-compressed',
|
||||
'application/x-7z-compressed',
|
||||
];
|
||||
|
||||
/**
|
||||
* Custom file filter for lesson uploads
|
||||
*/
|
||||
const fileFilter = (
|
||||
req: Request,
|
||||
file: Express.Multer.File,
|
||||
callback: multer.FileFilterCallback
|
||||
) => {
|
||||
if (file.fieldname === 'video') {
|
||||
if (ALLOWED_VIDEO_TYPES.includes(file.mimetype)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new ValidationError(`Invalid video file type: ${file.mimetype}. Allowed types: ${ALLOWED_VIDEO_TYPES.join(', ')}`));
|
||||
}
|
||||
} else if (file.fieldname === 'attachments') {
|
||||
if (ALLOWED_ATTACHMENT_TYPES.includes(file.mimetype)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new ValidationError(`Invalid attachment file type: ${file.mimetype}. Allowed types: PDF, Word, Excel, PowerPoint, images, and archives`));
|
||||
}
|
||||
} else {
|
||||
callback(new ValidationError(`Unknown field: ${file.fieldname}`));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Multer configuration for lesson file uploads
|
||||
* Stores files in memory for direct upload to MinIO
|
||||
*/
|
||||
export const lessonUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: config.upload.maxVideoSize, // Max file size (500MB for videos)
|
||||
files: config.upload.maxAttachmentsPerLesson + 1, // +1 for video
|
||||
},
|
||||
fileFilter,
|
||||
}).fields([
|
||||
{ name: 'video', maxCount: 1 },
|
||||
{ name: 'attachments', maxCount: config.upload.maxAttachmentsPerLesson },
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extended Request interface for uploaded files
|
||||
*/
|
||||
export interface LessonUploadRequest extends Request {
|
||||
files?: {
|
||||
video?: Express.Multer.File[];
|
||||
attachments?: Express.Multer.File[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate total attachment size
|
||||
*/
|
||||
export function validateTotalAttachmentSize(
|
||||
files: Express.Multer.File[] | undefined
|
||||
): void {
|
||||
if (!files) return;
|
||||
|
||||
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
|
||||
const maxTotalSize = config.upload.maxAttachmentSize * config.upload.maxAttachmentsPerLesson;
|
||||
|
||||
if (totalSize > maxTotalSize) {
|
||||
throw new ValidationError(
|
||||
`Total attachment size (${(totalSize / 1024 / 1024).toFixed(2)} MB) exceeds maximum allowed (${(maxTotalSize / 1024 / 1024).toFixed(2)} MB)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate video file size
|
||||
*/
|
||||
export function validateVideoSize(file: Express.Multer.File | undefined): void {
|
||||
if (!file) return;
|
||||
|
||||
if (file.size > config.upload.maxVideoSize) {
|
||||
throw new ValidationError(
|
||||
`Video file size (${(file.size / 1024 / 1024).toFixed(2)} MB) exceeds maximum allowed (${(config.upload.maxVideoSize / 1024 / 1024).toFixed(2)} MB)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { prisma } from '../config/database';
|
|||
import { Prisma } from '@prisma/client';
|
||||
import { config } from '../config';
|
||||
import { logger } from '../config/logger';
|
||||
import { generateFilePath, uploadFile } from '../config/minio';
|
||||
import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import {
|
||||
|
|
@ -35,6 +36,7 @@ import {
|
|||
DeleteLessonResponse,
|
||||
ReorderLessonsResponse,
|
||||
} from "../types/ChaptersLesson.typs";
|
||||
import { CoursesInstructorService } from './CoursesInstructor.service';
|
||||
|
||||
export class ChaptersLessonService {
|
||||
async listChapters(request: ChaptersRequest): Promise<ListChaptersResponse> {
|
||||
|
|
@ -45,7 +47,10 @@ export class ChaptersLessonService {
|
|||
if (!user) {
|
||||
throw new UnauthorizedError('Invalid token');
|
||||
}
|
||||
const chapters = await prisma.chapter.findMany({ where: { course_id } });
|
||||
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}`);
|
||||
|
|
@ -53,4 +58,240 @@ export class ChaptersLessonService {
|
|||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const { token, course_id, chapter_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 reorder chapter');
|
||||
}
|
||||
const chapter = await prisma.chapter.update({ where: { id: chapter_id }, data: { sort_order } });
|
||||
return { code: 200, message: 'Chapter reordered successfully', data: [chapter as ChapterData] };
|
||||
} catch (error) {
|
||||
logger.error(`Error reordering chapter: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createLesson(request: CreateLessonInput): Promise<CreateLessonResponse> {
|
||||
try {
|
||||
const { token, course_id, chapter_id, title, content, type, sort_order, 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 create lesson');
|
||||
}
|
||||
|
||||
// Create the lesson first
|
||||
const lesson = await prisma.lesson.create({
|
||||
data: { chapter_id, title, content, type, sort_order }
|
||||
});
|
||||
|
||||
const uploadedAttachments: { file_name: string; file_path: string; file_size: number; mime_type: string }[] = [];
|
||||
|
||||
// Handle video upload for VIDEO type lessons
|
||||
if (type === 'VIDEO' && video) {
|
||||
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,
|
||||
}
|
||||
});
|
||||
|
||||
uploadedAttachments.push({
|
||||
file_name: video.originalname,
|
||||
file_path: videoPath,
|
||||
file_size: video.size,
|
||||
mime_type: video.mimetype,
|
||||
});
|
||||
|
||||
// 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, // Start from 1 since video is 0
|
||||
}
|
||||
});
|
||||
|
||||
uploadedAttachments.push({
|
||||
file_name: attachment.originalname,
|
||||
file_path: attachmentPath,
|
||||
file_size: attachment.size,
|
||||
mime_type: attachment.mimetype,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (type === 'QUIZ' && request.quiz_data) {
|
||||
// Create Quiz with Questions and Choices
|
||||
const { quiz_data } = request;
|
||||
const userId = decodedToken.id;
|
||||
|
||||
// Create the quiz
|
||||
const quiz = await prisma.quiz.create({
|
||||
data: {
|
||||
lesson_id: lesson.id,
|
||||
title: quiz_data.title,
|
||||
description: quiz_data.description,
|
||||
passing_score: quiz_data.passing_score ?? 60,
|
||||
time_limit: quiz_data.time_limit,
|
||||
shuffle_questions: quiz_data.shuffle_questions ?? false,
|
||||
shuffle_choices: quiz_data.shuffle_choices ?? false,
|
||||
show_answers_after_completion: quiz_data.show_answers_after_completion ?? true,
|
||||
created_by: userId,
|
||||
}
|
||||
});
|
||||
|
||||
// Create questions with choices
|
||||
if (quiz_data.questions && quiz_data.questions.length > 0) {
|
||||
for (let i = 0; i < quiz_data.questions.length; i++) {
|
||||
const questionInput = quiz_data.questions[i];
|
||||
|
||||
// Create the question
|
||||
const question = await prisma.question.create({
|
||||
data: {
|
||||
quiz_id: quiz.id,
|
||||
question: questionInput.question,
|
||||
explanation: questionInput.explanation,
|
||||
question_type: questionInput.question_type,
|
||||
score: questionInput.score ?? 1,
|
||||
sort_order: questionInput.sort_order ?? i,
|
||||
}
|
||||
});
|
||||
|
||||
// Create choices for this question
|
||||
if (questionInput.choices && questionInput.choices.length > 0) {
|
||||
for (let j = 0; j < questionInput.choices.length; j++) {
|
||||
const choiceInput = questionInput.choices[j];
|
||||
await prisma.choice.create({
|
||||
data: {
|
||||
question_id: question.id,
|
||||
text: choiceInput.text,
|
||||
is_correct: choiceInput.is_correct,
|
||||
sort_order: choiceInput.sort_order ?? j,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Fetch the complete lesson with attachments and quiz
|
||||
const completeLesson = 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' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { code: 200, message: 'Lesson created successfully', data: completeLesson as LessonData };
|
||||
} catch (error) {
|
||||
logger.error(`Error creating lesson: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { prisma } from '../config/database';
|
|||
import { Prisma } from '@prisma/client';
|
||||
import { config } from '../config';
|
||||
import { logger } from '../config/logger';
|
||||
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
|
||||
import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import {
|
||||
CreateCourseInput,
|
||||
|
|
@ -190,6 +190,14 @@ export class CoursesInstructorService {
|
|||
submitted_by: decoded.id,
|
||||
}
|
||||
});
|
||||
await prisma.course.update({
|
||||
where: {
|
||||
id: sendCourseForReview.course_id
|
||||
},
|
||||
data: {
|
||||
status: 'PENDING'
|
||||
}
|
||||
});
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Course sent for review successfully',
|
||||
|
|
@ -288,7 +296,7 @@ export class CoursesInstructorService {
|
|||
}
|
||||
}
|
||||
|
||||
private static async validateCourseInstructor(token: string, courseId: number): Promise<{ user_id: number; is_primary: boolean }> {
|
||||
static async validateCourseInstructor(token: string, courseId: number): Promise<{ user_id: number; is_primary: boolean }> {
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
|
||||
const courseInstructor = await prisma.courseInstructor.findFirst({
|
||||
where: {
|
||||
|
|
@ -301,4 +309,14 @@ export class CoursesInstructorService {
|
|||
throw new ForbiddenError('You are not an instructor of this course');
|
||||
} else return { user_id: courseInstructor.user_id, is_primary: courseInstructor.is_primary };
|
||||
}
|
||||
|
||||
static async validateCourseStatus(courseId: number): Promise<void> {
|
||||
const course = await prisma.course.findUnique({ where: { id: courseId } });
|
||||
if (!course) {
|
||||
throw new NotFoundError('Course not found');
|
||||
}
|
||||
if (course.status === 'APPROVED') {
|
||||
throw new ForbiddenError('Course is already approved Cannot Edit');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,8 @@ export interface GetChapterRequest {
|
|||
}
|
||||
|
||||
export interface CreateChapterInput {
|
||||
token: string;
|
||||
course_id: number;
|
||||
title: MultiLanguageText;
|
||||
description?: MultiLanguageText;
|
||||
sort_order?: number;
|
||||
|
|
@ -118,6 +120,9 @@ export interface CreateChapterRequest {
|
|||
}
|
||||
|
||||
export interface UpdateChapterInput {
|
||||
token: string;
|
||||
course_id: number;
|
||||
chapter_id: number;
|
||||
title?: MultiLanguageText;
|
||||
description?: MultiLanguageText;
|
||||
sort_order?: number;
|
||||
|
|
@ -140,7 +145,8 @@ export interface DeleteChapterRequest {
|
|||
export interface ReorderChapterRequest {
|
||||
token: string;
|
||||
course_id: number;
|
||||
chapter_ids: number[]; // Ordered array of chapter IDs
|
||||
chapter_id: number;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
|
@ -212,7 +218,20 @@ export interface GetLessonRequest {
|
|||
lesson_id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploaded file information from multer
|
||||
*/
|
||||
export interface UploadedFileInfo {
|
||||
originalname: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface CreateLessonInput {
|
||||
token: string;
|
||||
course_id: number;
|
||||
chapter_id: number;
|
||||
title: MultiLanguageText;
|
||||
content?: MultiLanguageText;
|
||||
type: 'VIDEO' | 'QUIZ';
|
||||
|
|
@ -222,6 +241,68 @@ export interface CreateLessonInput {
|
|||
prerequisite_lesson_ids?: number[];
|
||||
require_pass_quiz?: boolean;
|
||||
is_published?: boolean;
|
||||
// File upload fields
|
||||
video?: UploadedFileInfo;
|
||||
attachments?: UploadedFileInfo[];
|
||||
// Quiz data for QUIZ type lessons (one quiz per lesson)
|
||||
quiz_data?: {
|
||||
title: MultiLanguageText;
|
||||
description?: MultiLanguageText;
|
||||
passing_score?: number;
|
||||
time_limit?: number;
|
||||
shuffle_questions?: boolean;
|
||||
shuffle_choices?: boolean;
|
||||
show_answers_after_completion?: boolean;
|
||||
questions: CreateQuizQuestionInput[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for creating quiz questions (matches Prisma QuestionType enum)
|
||||
*/
|
||||
export interface CreateQuizQuestionInput {
|
||||
question: MultiLanguageText;
|
||||
explanation?: MultiLanguageText;
|
||||
question_type: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER';
|
||||
score?: number;
|
||||
sort_order?: number;
|
||||
choices?: CreateQuizChoiceInput[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for creating quiz choices (matches Prisma Choice model)
|
||||
*/
|
||||
export interface CreateQuizChoiceInput {
|
||||
text: MultiLanguageText; // "text" matches Prisma field name
|
||||
is_correct: boolean;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response data for quiz questions (with IDs after creation)
|
||||
*/
|
||||
export interface QuizQuestionData {
|
||||
id: number;
|
||||
quiz_id: number;
|
||||
question: MultiLanguageText;
|
||||
explanation: MultiLanguageText | null;
|
||||
question_type: 'MULTIPLE_CHOICE' | 'TRUE_FALSE' | 'SHORT_ANSWER';
|
||||
score: number;
|
||||
sort_order: number;
|
||||
created_at: Date;
|
||||
updated_at: Date | null;
|
||||
choices?: QuizChoiceData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Response data for quiz choices (matches Prisma Choice model - no timestamps)
|
||||
*/
|
||||
export interface QuizChoiceData {
|
||||
id: number;
|
||||
question_id: number;
|
||||
text: MultiLanguageText; // "text" matches Prisma field name
|
||||
is_correct: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface CreateLessonRequest {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue