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 { Prisma } from '@prisma/client';
|
||||||
import { config } from '../config';
|
import { config } from '../config';
|
||||||
import { logger } from '../config/logger';
|
import { logger } from '../config/logger';
|
||||||
|
import { generateFilePath, uploadFile } from '../config/minio';
|
||||||
import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler';
|
import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import {
|
import {
|
||||||
|
|
@ -35,6 +36,7 @@ import {
|
||||||
DeleteLessonResponse,
|
DeleteLessonResponse,
|
||||||
ReorderLessonsResponse,
|
ReorderLessonsResponse,
|
||||||
} from "../types/ChaptersLesson.typs";
|
} from "../types/ChaptersLesson.typs";
|
||||||
|
import { CoursesInstructorService } from './CoursesInstructor.service';
|
||||||
|
|
||||||
export class ChaptersLessonService {
|
export class ChaptersLessonService {
|
||||||
async listChapters(request: ChaptersRequest): Promise<ListChaptersResponse> {
|
async listChapters(request: ChaptersRequest): Promise<ListChaptersResponse> {
|
||||||
|
|
@ -45,7 +47,10 @@ export class ChaptersLessonService {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedError('Invalid token');
|
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 };
|
return { code: 200, message: 'Chapters fetched successfully', data: chapters as ChapterData[], total: chapters.length };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error fetching chapters: ${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 { Prisma } from '@prisma/client';
|
||||||
import { config } from '../config';
|
import { config } from '../config';
|
||||||
import { logger } from '../config/logger';
|
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 jwt from 'jsonwebtoken';
|
||||||
import {
|
import {
|
||||||
CreateCourseInput,
|
CreateCourseInput,
|
||||||
|
|
@ -190,6 +190,14 @@ export class CoursesInstructorService {
|
||||||
submitted_by: decoded.id,
|
submitted_by: decoded.id,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
await prisma.course.update({
|
||||||
|
where: {
|
||||||
|
id: sendCourseForReview.course_id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: 'PENDING'
|
||||||
|
}
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'Course sent for review successfully',
|
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 decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
|
||||||
const courseInstructor = await prisma.courseInstructor.findFirst({
|
const courseInstructor = await prisma.courseInstructor.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -301,4 +309,14 @@ export class CoursesInstructorService {
|
||||||
throw new ForbiddenError('You are not an instructor of this course');
|
throw new ForbiddenError('You are not an instructor of this course');
|
||||||
} else return { user_id: courseInstructor.user_id, is_primary: courseInstructor.is_primary };
|
} 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 {
|
export interface CreateChapterInput {
|
||||||
|
token: string;
|
||||||
|
course_id: number;
|
||||||
title: MultiLanguageText;
|
title: MultiLanguageText;
|
||||||
description?: MultiLanguageText;
|
description?: MultiLanguageText;
|
||||||
sort_order?: number;
|
sort_order?: number;
|
||||||
|
|
@ -118,6 +120,9 @@ export interface CreateChapterRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateChapterInput {
|
export interface UpdateChapterInput {
|
||||||
|
token: string;
|
||||||
|
course_id: number;
|
||||||
|
chapter_id: number;
|
||||||
title?: MultiLanguageText;
|
title?: MultiLanguageText;
|
||||||
description?: MultiLanguageText;
|
description?: MultiLanguageText;
|
||||||
sort_order?: number;
|
sort_order?: number;
|
||||||
|
|
@ -140,7 +145,8 @@ export interface DeleteChapterRequest {
|
||||||
export interface ReorderChapterRequest {
|
export interface ReorderChapterRequest {
|
||||||
token: string;
|
token: string;
|
||||||
course_id: number;
|
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;
|
lesson_id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploaded file information from multer
|
||||||
|
*/
|
||||||
|
export interface UploadedFileInfo {
|
||||||
|
originalname: string;
|
||||||
|
mimetype: string;
|
||||||
|
size: number;
|
||||||
|
buffer: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateLessonInput {
|
export interface CreateLessonInput {
|
||||||
|
token: string;
|
||||||
|
course_id: number;
|
||||||
|
chapter_id: number;
|
||||||
title: MultiLanguageText;
|
title: MultiLanguageText;
|
||||||
content?: MultiLanguageText;
|
content?: MultiLanguageText;
|
||||||
type: 'VIDEO' | 'QUIZ';
|
type: 'VIDEO' | 'QUIZ';
|
||||||
|
|
@ -222,6 +241,68 @@ export interface CreateLessonInput {
|
||||||
prerequisite_lesson_ids?: number[];
|
prerequisite_lesson_ids?: number[];
|
||||||
require_pass_quiz?: boolean;
|
require_pass_quiz?: boolean;
|
||||||
is_published?: 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 {
|
export interface CreateLessonRequest {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue