feat: Implement lesson creation with file uploads (video, attachments) and quiz data, integrating MinIO for storage.

This commit is contained in:
JakkrapartXD 2026-01-20 16:51:42 +07:00
parent 4851182f4a
commit 04e2da43c4
6 changed files with 715 additions and 4 deletions

111
Backend/src/config/minio.ts Normal file
View 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;
}
}

View 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();

View 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)`
);
}
}

View file

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

View file

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

View file

@ -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 {