update api chapterlesson
This commit is contained in:
parent
2fc0fb7a76
commit
5c2b5d55aa
11 changed files with 855 additions and 85 deletions
|
|
@ -7,7 +7,7 @@
|
|||
"dev": "nodemon",
|
||||
"build": "npm run tsoa:gen && tsc",
|
||||
"start": "node dist/server.js",
|
||||
"tsoa:gen": "tsoa spec-and-routes",
|
||||
"tsoa:gen": "tsoa spec-and-routes && node scripts/post-tsoa.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "node prisma/seed.js",
|
||||
|
|
|
|||
37
Backend/scripts/post-tsoa.js
Normal file
37
Backend/scripts/post-tsoa.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Post-TSOA Generation Script
|
||||
* Fixes the multer file size limit in generated routes.ts
|
||||
*
|
||||
* Run after tsoa:gen to update file size limits
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROUTES_FILE = path.join(__dirname, '../src/routes/routes.ts');
|
||||
const OLD_LIMIT = '"fileSize":8388608'; // 8MB (tsoa default)
|
||||
const NEW_LIMIT = '"fileSize":1073741824'; // 1GB
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(ROUTES_FILE)) {
|
||||
console.error('Error: routes.ts not found at', ROUTES_FILE);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let content = fs.readFileSync(ROUTES_FILE, 'utf8');
|
||||
|
||||
if (content.includes(OLD_LIMIT)) {
|
||||
content = content.replace(OLD_LIMIT, NEW_LIMIT);
|
||||
fs.writeFileSync(ROUTES_FILE, content, 'utf8');
|
||||
console.log('✅ Updated multer file size limit to 1GB in routes.ts');
|
||||
} else if (content.includes(NEW_LIMIT)) {
|
||||
console.log('✅ Multer file size limit already set to 1GB');
|
||||
} else {
|
||||
console.warn('⚠️ Could not find multer fileSize config in routes.ts');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating routes.ts:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -109,3 +109,81 @@ export async function getPresignedUrl(
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all objects in a path prefix
|
||||
*/
|
||||
export async function listObjects(prefix: string): Promise<{ name: string; size: number; lastModified: Date }[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const objects: { name: string; size: number; lastModified: Date }[] = [];
|
||||
const stream = minioClient.listObjectsV2(config.s3.bucket, prefix, true);
|
||||
|
||||
stream.on('data', (obj) => {
|
||||
if (obj.name) {
|
||||
objects.push({
|
||||
name: obj.name,
|
||||
size: obj.size || 0,
|
||||
lastModified: obj.lastModified || new Date(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (err) => {
|
||||
logger.error(`Error listing objects: ${err}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
resolve(objects);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List folders in a path prefix
|
||||
* Uses listObjectsV2 with recursive=false to get folder prefixes
|
||||
*/
|
||||
export async function listFolders(prefix: string): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const folders = new Set<string>();
|
||||
const stream = minioClient.listObjectsV2(config.s3.bucket, prefix, false);
|
||||
|
||||
stream.on('data', (obj: any) => {
|
||||
if (obj.prefix) {
|
||||
// Direct folder prefix
|
||||
folders.add(obj.prefix);
|
||||
} else if (obj.name) {
|
||||
// Extract folder part from file path
|
||||
const path = obj.name.replace(prefix, '');
|
||||
const folder = path.split('/')[0];
|
||||
|
||||
if (folder && path.includes('/')) {
|
||||
folders.add(prefix + folder + '/');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (err) => {
|
||||
logger.error(`Error listing folders: ${err}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
resolve(Array.from(folders));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attachments folder path for a lesson
|
||||
*/
|
||||
export function getAttachmentsFolder(courseId: number, lessonId: number): string {
|
||||
return `courses/${courseId}/lessons/${lessonId}/attachments/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video folder path for a lesson
|
||||
*/
|
||||
export function getVideoFolder(courseId: number, lessonId: number): string {
|
||||
return `courses/${courseId}/lessons/${lessonId}/video/`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -260,7 +260,8 @@ export class ChaptersLessonInstructorController {
|
|||
token,
|
||||
course_id: courseId,
|
||||
chapter_id: chapterId,
|
||||
lesson_ids: body.lesson_ids,
|
||||
lesson_id: body.lesson_id,
|
||||
sort_order: body.sort_order,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
import { Get, Path, Request, Response, Route, Security, SuccessResponse, Tags } from 'tsoa';
|
||||
import { ValidationError } from '../middleware/errorHandler';
|
||||
import { ChaptersLessonService } from '../services/ChaptersLesson.service';
|
||||
import {
|
||||
ListChaptersResponse,
|
||||
GetLessonResponse,
|
||||
} from '../types/ChaptersLesson.typs';
|
||||
|
||||
const chaptersLessonService = new ChaptersLessonService();
|
||||
|
||||
@Route('api/students/courses/{courseId}')
|
||||
@Tags('ChaptersLessons - Student')
|
||||
export class ChaptersLessonStudentController {
|
||||
|
||||
// ============================================
|
||||
// Chapter Endpoints (Read-only for students)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* ดึง chapters ทั้งหมดของ course (พร้อม lessons) - สำหรับนักเรียนที่ลงทะเบียนแล้ว
|
||||
* Get all chapters of a course with lessons - for enrolled students
|
||||
*/
|
||||
@Get('chapters')
|
||||
@Security('jwt', ['student'])
|
||||
@SuccessResponse('200', 'Chapters retrieved successfully')
|
||||
@Response('401', 'Unauthorized')
|
||||
@Response('403', 'Not enrolled in this course')
|
||||
public async listChapters(@Request() request: any, @Path() courseId: number): Promise<ListChaptersResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) throw new ValidationError('No token provided');
|
||||
return await chaptersLessonService.listChapters({ token, course_id: courseId });
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Lesson Endpoints (Read-only for students)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* ดึงข้อมูล lesson พร้อม attachments และ quiz - สำหรับนักเรียนที่ลงทะเบียนแล้ว
|
||||
* Get lesson with attachments and quiz - for enrolled students
|
||||
* หมายเหตุ: จะดูได้เฉพาะ lesson ที่ is_published = true
|
||||
*/
|
||||
@Get('chapters/{chapterId}/lessons/{lessonId}')
|
||||
@Security('jwt', ['student'])
|
||||
@SuccessResponse('200', 'Lesson retrieved successfully')
|
||||
@Response('401', 'Unauthorized')
|
||||
@Response('403', 'Not enrolled or lesson not published')
|
||||
@Response('404', 'Lesson not found')
|
||||
public async getLesson(
|
||||
@Request() request: any,
|
||||
@Path() courseId: number,
|
||||
@Path() chapterId: number,
|
||||
@Path() lessonId: number
|
||||
): Promise<GetLessonResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) throw new ValidationError('No token provided');
|
||||
return await chaptersLessonService.getLesson({ token, course_id: courseId, chapter_id: chapterId, lesson_id: lessonId });
|
||||
}
|
||||
}
|
||||
|
|
@ -165,7 +165,7 @@ export class ChaptersLessonService {
|
|||
|
||||
async reorderChapter(request: ReorderChapterRequest): Promise<ReorderChapterResponse> {
|
||||
try {
|
||||
const { token, course_id, chapter_id, sort_order } = request;
|
||||
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 } });
|
||||
|
|
@ -176,8 +176,54 @@ export class ChaptersLessonService {
|
|||
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] };
|
||||
|
||||
// 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');
|
||||
}
|
||||
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;
|
||||
|
|
@ -315,7 +361,7 @@ export class ChaptersLessonService {
|
|||
*/
|
||||
async reorderLessons(request: ReorderLessonsRequest): Promise<ReorderLessonsResponse> {
|
||||
try {
|
||||
const { token, course_id, chapter_id, lesson_ids } = request;
|
||||
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);
|
||||
|
||||
|
|
@ -331,17 +377,52 @@ export class ChaptersLessonService {
|
|||
throw new NotFoundError('Chapter not found');
|
||||
}
|
||||
|
||||
// Update sort_order for each lesson
|
||||
for (let i = 0; i < lesson_ids.length; i++) {
|
||||
await prisma.lesson.update({
|
||||
where: { id: lesson_ids[i] },
|
||||
data: { sort_order: i }
|
||||
// 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 } }
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch reordered lessons
|
||||
// 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({
|
||||
where: { chapter_id: chapter_id },
|
||||
where: { chapter_id },
|
||||
orderBy: { sort_order: 'asc' }
|
||||
});
|
||||
|
||||
|
|
@ -498,6 +579,7 @@ export class ChaptersLessonService {
|
|||
await CoursesInstructorService.validateCourseStatus(course_id);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
|
||||
logger.info(`User: ${user}`);
|
||||
if (!user) throw new UnauthorizedError('Invalid token');
|
||||
|
||||
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import {
|
|||
CompleteLessonInput,
|
||||
CompleteLessonResponse,
|
||||
} from "../types/CoursesStudent.types";
|
||||
import { getPresignedUrl, listObjects, getVideoFolder, getAttachmentsFolder } from '../config/minio';
|
||||
|
||||
|
||||
export class CoursesStudentService {
|
||||
async enrollCourse(input: EnrollCourseInput): Promise<EnrollCourseResponse> {
|
||||
|
|
@ -265,6 +267,8 @@ export class CoursesStudentService {
|
|||
const { token, course_id, lesson_id } = input;
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
|
||||
|
||||
// Import MinIO functions
|
||||
|
||||
// Check enrollment
|
||||
const enrollment = await prisma.enrollment.findUnique({
|
||||
where: {
|
||||
|
|
@ -319,6 +323,80 @@ export class CoursesStudentService {
|
|||
const prevLessonId = currentIndex > 0 ? allLessons[currentIndex - 1].id : null;
|
||||
const nextLessonId = currentIndex < allLessons.length - 1 ? allLessons[currentIndex + 1].id : null;
|
||||
|
||||
// Get course_id from chapter
|
||||
const chapter_course_id = lesson.chapter.course_id;
|
||||
|
||||
// Import additional MinIO functions
|
||||
// Using MinIO functions imported above
|
||||
|
||||
// Get video URL from video folder (first file)
|
||||
let video_url: string | null = null;
|
||||
try {
|
||||
const videoPrefix = getVideoFolder(chapter_course_id, lesson_id);
|
||||
const videoFiles = await listObjects(videoPrefix);
|
||||
if (videoFiles.length > 0) {
|
||||
// Get presigned URL for the first video file
|
||||
video_url = await getPresignedUrl(videoFiles[0].name, 3600);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Failed to get video from MinIO: ${err}`);
|
||||
}
|
||||
|
||||
// Get attachments from MinIO folder
|
||||
const attachmentsWithUrls: {
|
||||
file_name: string;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
mime_type: string;
|
||||
presigned_url: string | null;
|
||||
}[] = [];
|
||||
|
||||
try {
|
||||
const attachmentsPrefix = getAttachmentsFolder(chapter_course_id, lesson_id);
|
||||
const attachmentFiles = await listObjects(attachmentsPrefix);
|
||||
|
||||
for (const file of attachmentFiles) {
|
||||
let presigned_url: string | null = null;
|
||||
try {
|
||||
presigned_url = await getPresignedUrl(file.name, 3600);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to generate presigned URL for ${file.name}: ${err}`);
|
||||
}
|
||||
|
||||
// Extract filename from path
|
||||
const fileName = file.name.split('/').pop() || file.name;
|
||||
// Guess mime type from extension
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
const mimeTypes: { [key: string]: string } = {
|
||||
'pdf': 'application/pdf',
|
||||
'doc': 'application/msword',
|
||||
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'ppt': 'application/vnd.ms-powerpoint',
|
||||
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'xls': 'application/vnd.ms-excel',
|
||||
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'gif': 'image/gif',
|
||||
'mp4': 'video/mp4',
|
||||
'zip': 'application/zip',
|
||||
};
|
||||
const mime_type = mimeTypes[ext] || 'application/octet-stream';
|
||||
|
||||
attachmentsWithUrls.push({
|
||||
file_name: fileName,
|
||||
file_path: file.name,
|
||||
file_size: file.size,
|
||||
mime_type,
|
||||
presigned_url,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Failed to list attachments from MinIO: ${err}`);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Lesson retrieved successfully',
|
||||
|
|
@ -332,14 +410,8 @@ export class CoursesStudentService {
|
|||
is_sequential: lesson.is_sequential,
|
||||
prerequisite_lesson_ids: lesson.prerequisite_lesson_ids as number[] | null,
|
||||
require_pass_quiz: lesson.require_pass_quiz,
|
||||
attachments: lesson.attachments.map(att => ({
|
||||
id: att.id,
|
||||
file_name: att.file_name,
|
||||
file_path: att.file_path,
|
||||
file_size: att.file_size,
|
||||
mime_type: att.mime_type,
|
||||
description: att.description as { th: string; en: string } | null,
|
||||
})),
|
||||
video_url, // Presigned URL for video
|
||||
attachments: attachmentsWithUrls,
|
||||
quiz: lesson.quiz ? {
|
||||
id: lesson.quiz.id,
|
||||
title: lesson.quiz.title as { th: string; en: string },
|
||||
|
|
|
|||
|
|
@ -325,7 +325,8 @@ export interface ReorderLessonsRequest {
|
|||
token: string;
|
||||
course_id: number;
|
||||
chapter_id: number;
|
||||
lesson_ids: number[]; // Ordered array of lesson IDs
|
||||
lesson_id: number;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
|
@ -521,7 +522,8 @@ export interface UpdateLessonBody {
|
|||
}
|
||||
|
||||
export interface ReorderLessonsBody {
|
||||
lesson_ids: number[];
|
||||
lesson_id: number;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface AddQuestionBody {
|
||||
|
|
|
|||
|
|
@ -141,15 +141,16 @@ export interface LessonContentData {
|
|||
is_sequential: boolean;
|
||||
prerequisite_lesson_ids: number[] | null;
|
||||
require_pass_quiz: boolean;
|
||||
video_url: string | null; // Presigned URL for video
|
||||
attachments: {
|
||||
id: number;
|
||||
file_name: string;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
mime_type: string;
|
||||
description: MultiLangText | null;
|
||||
presigned_url: string | null; // Presigned URL for attachment
|
||||
}[];
|
||||
quiz?: {
|
||||
|
||||
id: number;
|
||||
title: MultiLangText;
|
||||
description: MultiLangText | null;
|
||||
|
|
@ -163,6 +164,7 @@ export interface LessonContentData {
|
|||
next_lesson_id: number | null;
|
||||
}
|
||||
|
||||
|
||||
export interface GetLessonContentResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@
|
|||
"routes": {
|
||||
"routesDir": "src/routes",
|
||||
"middleware": "express",
|
||||
"authenticationModule": "./src/middleware/authentication.ts"
|
||||
"authenticationModule": "./src/middleware/authentication.ts",
|
||||
"multerOpts": {
|
||||
"limits": {
|
||||
"fileSize": 1073741824
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue