diff --git a/.windsurf/rules/backenddevelopmentrules.md b/.windsurf/rules/backenddevelopmentrules.md new file mode 100644 index 00000000..f4fb83aa --- /dev/null +++ b/.windsurf/rules/backenddevelopmentrules.md @@ -0,0 +1,550 @@ +--- +trigger: manual +--- + +# Backend Development Rules + +> Rules and guidelines for developing the E-Learning Platform Backend + +--- + +## 🎯 General Principles + +### 1. Code Organization +- Follow MVC pattern: Models (Prisma) → Controllers → Routes +- Keep controllers thin, business logic in services +- One file per route group (e.g., `auth.routes.js`, `courses.routes.js`) +- Maximum 200 lines per file (split if larger) + +### 2. Naming Conventions +```javascript +// Files: kebab-case +user-service.js +course-controller.js + +// Functions: camelCase +getUserById() +createCourse() + +// Classes: PascalCase +UserService +CourseController + +// Constants: UPPER_SNAKE_CASE +MAX_FILE_SIZE +JWT_SECRET + +// Database: snake_case +user_id +created_at +``` + +### 3. Multi-Language Support +**ALWAYS** use JSON structure for user-facing text: +```javascript +// ✅ Correct +{ + title: { th: "หลักสูตร", en: "Course" } +} + +// ❌ Wrong +{ + title: "หลักสูตร" +} +``` + +--- + +## 🔐 Security Rules + +### 1. Authentication +- **ALWAYS** validate JWT token in protected routes +- **NEVER** trust client-side data +- **ALWAYS** hash passwords with bcrypt (salt rounds: 10) +- Token expiry: 24h (access), 7d (refresh) + +### 2. Authorization +```javascript +// Check authentication first +if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + +// Then check role +if (!["ADMIN", "INSTRUCTOR"].includes(req.user.role.code)) { + return res.status(403).json({ error: "Forbidden" }); +} + +// Finally check ownership +if (course.instructorId !== req.user.id && req.user.role.code !== "ADMIN") { + return res.status(403).json({ error: "Forbidden" }); +} +``` + +### 3. Input Validation +- **ALWAYS** validate with Joi or Zod +- **NEVER** trust user input +- Sanitize HTML content +- Validate file types and sizes + +--- + +## 📁 File Upload Rules + +### 1. File Validation +```javascript +// Check file type +const allowedTypes = ['video/mp4', 'application/pdf', ...]; +if (!allowedTypes.includes(file.mimetype)) { + return error("Invalid file type"); +} + +// Check file size +if (file.size > MAX_FILE_SIZE) { + return error("File too large"); +} +``` + +### 2. File Storage (MinIO/S3) +```javascript +// Use consistent path structure +const videoPath = `courses/${courseId}/lessons/${lessonId}/video.mp4`; +const attachmentPath = `lessons/${lessonId}/attachments/${uniqueFilename}`; + +// Generate unique filenames +const uniqueFilename = `${Date.now()}-${uuidv4()}-${originalFilename}`; +``` + +### 3. File Limits +- Video: 500 MB max +- Attachment: 100 MB max per file +- Max 10 attachments per lesson +- Total 500 MB per lesson + +--- + +## 🗄️ Database Rules + +### 1. Prisma Best Practices +```javascript +// ✅ Use transactions for related operations +await prisma.$transaction([ + prisma.lesson.create(...), + prisma.attachment.createMany(...) +]); + +// ✅ Include relations when needed +const course = await prisma.course.findUnique({ + where: { id }, + include: { chapters: { include: { lessons: true } } } +}); + +// ✅ Use select to limit fields +const user = await prisma.user.findUnique({ + where: { id }, + select: { id: true, username: true, email: true } +}); +``` + +### 2. Soft Delete Pattern +```javascript +// ✅ Use soft delete +await prisma.course.update({ + where: { id }, + data: { isDeleted: true, deletedAt: new Date() } +}); + +// ❌ Don't hard delete +await prisma.course.delete({ where: { id } }); // Avoid this +``` + +### 3. Query Optimization +- Use `select` to fetch only needed fields +- Use `include` wisely (avoid N+1 queries) +- Add indexes for frequently queried fields +- Use pagination for large datasets + +--- + +## 🎯 API Response Format + +### 1. Success Response +```javascript +// Single resource +res.status(200).json({ + id: 1, + title: { th: "...", en: "..." }, + ... +}); + +// List with pagination +res.status(200).json({ + data: [...], + pagination: { + page: 1, + limit: 20, + total: 150, + totalPages: 8 + } +}); +``` + +### 2. Error Response +```javascript +res.status(400).json({ + error: { + code: "VALIDATION_ERROR", + message: "Invalid input data", + details: [ + { field: "email", message: "Email is required" } + ] + } +}); +``` + +### 3. Status Codes +- `200` OK - Success +- `201` Created - Resource created +- `204` No Content - Success with no response body +- `400` Bad Request - Validation error +- `401` Unauthorized - Not authenticated +- `403` Forbidden - Not authorized +- `404` Not Found - Resource not found +- `409` Conflict - Duplicate entry +- `422` Unprocessable Entity - Business logic error +- `500` Internal Server Error - Server error + +--- + +## 🔄 Business Logic Rules + +### 1. Course Management +- New courses start as `DRAFT` +- Must have ≥1 chapter and ≥3 lessons before submission +- Only course owner or primary instructor can submit +- Admin can approve/reject + +### 2. Lesson Prerequisites +```javascript +// Check prerequisites before allowing access +const canAccess = await checkLessonAccess(userId, lessonId); +if (!canAccess.allowed) { + return res.status(403).json({ + error: { + code: "LESSON_LOCKED", + message: canAccess.reason, + required_lessons: canAccess.requiredLessons + } + }); +} +``` + +### 3. Quiz Attempts +```javascript +// Validate before allowing quiz attempt +if (attempts >= quiz.maxAttempts) { + return error("MAX_ATTEMPTS_EXCEEDED"); +} + +if (lastAttempt && (now - lastAttempt) < cooldown) { + return error("COOLDOWN_ACTIVE"); +} +``` + +### 4. Video Progress +- Save progress every 5 seconds +- Auto-complete at ≥90% watched +- Use `last_watched_at` for concurrent sessions + +### 5. Multi-Instructor +- Cannot remove last instructor +- Only primary instructor can manage other instructors +- Course owner always has full access + +--- + +## 🧪 Testing Rules + +### 1. Test Coverage +- Write tests for all business logic +- Test happy path AND error cases +- Test authentication and authorization +- Test file uploads + +### 2. Test Structure +```javascript +describe('Course API', () => { + describe('POST /api/instructor/courses', () => { + it('should create course with valid data', async () => { + // Arrange + const courseData = {...}; + + // Act + const response = await request(app) + .post('/api/instructor/courses') + .set('Authorization', `Bearer ${token}`) + .send(courseData); + + // Assert + expect(response.status).toBe(201); + expect(response.body).toHaveProperty('id'); + }); + + it('should return 401 without authentication', async () => { + // Test unauthorized access + }); + }); +}); +``` + +--- + +## 📝 Code Documentation + +### 1. JSDoc Comments +```javascript +/** + * Create a new course + * @param {Object} req - Express request object + * @param {Object} req.body - Course data + * @param {Object} req.body.title - Course title (th/en) + * @param {number} req.body.category_id - Category ID + * @param {Object} res - Express response object + * @returns {Promise} Created course + */ +async function createCourse(req, res) { + // Implementation +} +``` + +### 2. Inline Comments +```javascript +// Only for complex logic +// Explain WHY, not WHAT + +// ✅ Good +// Use highest score to determine pass/fail (per quiz settings) +const finalScore = Math.max(...attemptScores); + +// ❌ Bad +// Get max score +const finalScore = Math.max(...attemptScores); +``` + +--- + +## 🚨 Error Handling + +### 1. Try-Catch Pattern +```javascript +async function createCourse(req, res) { + try { + // Validate input + const { error, value } = courseSchema.validate(req.body); + if (error) { + return res.status(400).json({ + error: { + code: "VALIDATION_ERROR", + message: error.details[0].message + } + }); + } + + // Business logic + const course = await courseService.create(value, req.user.id); + + return res.status(201).json(course); + } catch (err) { + console.error('Create course error:', err); + return res.status(500).json({ + error: { + code: "INTERNAL_ERROR", + message: "Failed to create course" + } + }); + } +} +``` + +### 2. Custom Error Classes +```javascript +class ValidationError extends Error { + constructor(message, details) { + super(message); + this.name = 'ValidationError'; + this.statusCode = 400; + this.details = details; + } +} + +class UnauthorizedError extends Error { + constructor(message = 'Unauthorized') { + super(message); + this.name = 'UnauthorizedError'; + this.statusCode = 401; + } +} +``` + +--- + +## 🔍 Logging Rules + +### 1. What to Log +```javascript +// ✅ Log important events +logger.info('User logged in', { userId, timestamp }); +logger.info('Course created', { courseId, instructorId }); + +// ✅ Log errors with context +logger.error('File upload failed', { + error: err.message, + userId, + filename +}); + +// ❌ Don't log sensitive data +logger.info('User logged in', { password }); // NEVER! +``` + +### 2. Log Levels +- `error` - Errors that need attention +- `warn` - Warnings (deprecated features, etc.) +- `info` - Important events (login, course creation) +- `debug` - Detailed debugging (development only) + +--- + +## ⚡ Performance Rules + +### 1. Database Queries +```javascript +// ✅ Use pagination +const courses = await prisma.course.findMany({ + skip: (page - 1) * limit, + take: limit +}); + +// ✅ Limit relations depth +const course = await prisma.course.findUnique({ + include: { + chapters: { + include: { lessons: true } + // Don't go deeper unless necessary + } + } +}); +``` + +### 2. Caching (Redis) +```javascript +// Cache frequently accessed data +const cacheKey = `course:${courseId}`; +const cached = await redis.get(cacheKey); + +if (cached) { + return JSON.parse(cached); +} + +const course = await prisma.course.findUnique(...); +await redis.setex(cacheKey, 3600, JSON.stringify(course)); +return course; +``` + +### 3. File Processing +```javascript +// Process large files asynchronously +const job = await queue.add('process-video', { + videoPath, + lessonId +}); + +return res.status(202).json({ + message: 'Video processing started', + jobId: job.id +}); +``` + +--- + +## 🔒 Environment Variables + +### Required Variables +```bash +# Application +NODE_ENV=development +PORT=4000 +APP_URL=http://localhost:4000 + +# Database +DATABASE_URL=postgresql://... + +# Redis +REDIS_URL=redis://... + +# MinIO/S3 +S3_ENDPOINT=http://... +S3_ACCESS_KEY=... +S3_SECRET_KEY=... + +# JWT +JWT_SECRET=... +JWT_EXPIRES_IN=24h + +# Email (Mailhog in dev) +SMTP_HOST=... +SMTP_PORT=1025 +``` + +### Loading Environment Variables +```javascript +// ✅ Use dotenv +require('dotenv').config(); + +// ✅ Validate required variables +const requiredEnvVars = ['DATABASE_URL', 'JWT_SECRET', ...]; +requiredEnvVars.forEach(key => { + if (!process.env[key]) { + throw new Error(`Missing required environment variable: ${key}`); + } +}); +``` + +--- + +## 📦 Dependencies + +### Core Dependencies +- `express` - Web framework +- `@prisma/client` - Database ORM +- `bcrypt` - Password hashing +- `jsonwebtoken` - JWT authentication +- `joi` or `zod` - Input validation +- `multer` - File uploads +- `aws-sdk` or `minio` - S3 storage +- `redis` - Caching +- `winston` - Logging + +### Dev Dependencies +- `nodemon` - Auto-restart +- `jest` - Testing +- `supertest` - API testing +- `eslint` - Linting +- `prettier` - Code formatting + +--- + +## 🚀 Deployment Checklist + +- [ ] All environment variables set +- [ ] Database migrations run +- [ ] Redis connection tested +- [ ] MinIO/S3 buckets created +- [ ] JWT secret is strong and unique +- [ ] CORS configured correctly +- [ ] Rate limiting enabled +- [ ] Logging configured +- [ ] Error tracking setup (Sentry, etc.) +- [ ] Health check endpoint working + +--- + +**Remember**: Security first, code quality second, features third! \ No newline at end of file diff --git a/Backend/package.json b/Backend/package.json index 1c76f430..410a43f5 100644 --- a/Backend/package.json +++ b/Backend/package.json @@ -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", diff --git a/Backend/scripts/post-tsoa.js b/Backend/scripts/post-tsoa.js new file mode 100644 index 00000000..57a2062e --- /dev/null +++ b/Backend/scripts/post-tsoa.js @@ -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); +} diff --git a/Backend/src/config/minio.ts b/Backend/src/config/minio.ts index 0c8f5c4d..ef1d102a 100644 --- a/Backend/src/config/minio.ts +++ b/Backend/src/config/minio.ts @@ -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 { + return new Promise((resolve, reject) => { + const folders = new Set(); + 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/`; +} diff --git a/Backend/src/controllers/ChaptersLessonInstructorController.ts b/Backend/src/controllers/ChaptersLessonInstructorController.ts index c673838f..08e83689 100644 --- a/Backend/src/controllers/ChaptersLessonInstructorController.ts +++ b/Backend/src/controllers/ChaptersLessonInstructorController.ts @@ -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, }); } diff --git a/Backend/src/controllers/ChaptersLessonStudentController.ts b/Backend/src/controllers/ChaptersLessonStudentController.ts deleted file mode 100644 index eab03410..00000000 --- a/Backend/src/controllers/ChaptersLessonStudentController.ts +++ /dev/null @@ -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 { - 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 { - 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 }); - } -} diff --git a/Backend/src/services/ChaptersLesson.service.ts b/Backend/src/services/ChaptersLesson.service.ts index 7a30c02e..5483ff81 100644 --- a/Backend/src/services/ChaptersLesson.service.ts +++ b/Backend/src/services/ChaptersLesson.service.ts @@ -165,7 +165,7 @@ export class ChaptersLessonService { async reorderChapter(request: ReorderChapterRequest): Promise { 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 { 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); diff --git a/Backend/src/services/CoursesStudent.service.ts b/Backend/src/services/CoursesStudent.service.ts index 90d1c5a8..1d9ba885 100644 --- a/Backend/src/services/CoursesStudent.service.ts +++ b/Backend/src/services/CoursesStudent.service.ts @@ -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 { @@ -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 }, diff --git a/Backend/src/types/ChaptersLesson.typs.ts b/Backend/src/types/ChaptersLesson.typs.ts index 92f6d127..b2936a6a 100644 --- a/Backend/src/types/ChaptersLesson.typs.ts +++ b/Backend/src/types/ChaptersLesson.typs.ts @@ -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 { diff --git a/Backend/src/types/CoursesStudent.types.ts b/Backend/src/types/CoursesStudent.types.ts index 9efa163a..74a43d5b 100644 --- a/Backend/src/types/CoursesStudent.types.ts +++ b/Backend/src/types/CoursesStudent.types.ts @@ -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; diff --git a/Backend/tsoa.json b/Backend/tsoa.json index a9ffe71b..c88d4762 100644 --- a/Backend/tsoa.json +++ b/Backend/tsoa.json @@ -25,6 +25,11 @@ "routes": { "routesDir": "src/routes", "middleware": "express", - "authenticationModule": "./src/middleware/authentication.ts" + "authenticationModule": "./src/middleware/authentication.ts", + "multerOpts": { + "limits": { + "fileSize": 1073741824 + } + } } } \ No newline at end of file