update api chapterlesson
This commit is contained in:
parent
2fc0fb7a76
commit
5c2b5d55aa
11 changed files with 855 additions and 85 deletions
550
.windsurf/rules/backenddevelopmentrules.md
Normal file
550
.windsurf/rules/backenddevelopmentrules.md
Normal file
|
|
@ -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<Object>} 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!
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
"dev": "nodemon",
|
"dev": "nodemon",
|
||||||
"build": "npm run tsoa:gen && tsc",
|
"build": "npm run tsoa:gen && tsc",
|
||||||
"start": "node dist/server.js",
|
"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:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:seed": "node prisma/seed.js",
|
"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;
|
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,
|
token,
|
||||||
course_id: courseId,
|
course_id: courseId,
|
||||||
chapter_id: chapterId,
|
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> {
|
async reorderChapter(request: ReorderChapterRequest): Promise<ReorderChapterResponse> {
|
||||||
try {
|
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 };
|
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
|
||||||
await CoursesInstructorService.validateCourseStatus(course_id);
|
await CoursesInstructorService.validateCourseStatus(course_id);
|
||||||
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
|
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
|
||||||
|
|
@ -176,8 +176,54 @@ export class ChaptersLessonService {
|
||||||
if (!courseInstructor) {
|
if (!courseInstructor) {
|
||||||
throw new ForbiddenError('You are not permitted to reorder chapter');
|
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) {
|
} catch (error) {
|
||||||
logger.error(`Error reordering chapter: ${error}`);
|
logger.error(`Error reordering chapter: ${error}`);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -315,7 +361,7 @@ export class ChaptersLessonService {
|
||||||
*/
|
*/
|
||||||
async reorderLessons(request: ReorderLessonsRequest): Promise<ReorderLessonsResponse> {
|
async reorderLessons(request: ReorderLessonsRequest): Promise<ReorderLessonsResponse> {
|
||||||
try {
|
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 };
|
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
|
||||||
await CoursesInstructorService.validateCourseStatus(course_id);
|
await CoursesInstructorService.validateCourseStatus(course_id);
|
||||||
|
|
||||||
|
|
@ -331,17 +377,52 @@ export class ChaptersLessonService {
|
||||||
throw new NotFoundError('Chapter not found');
|
throw new NotFoundError('Chapter not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update sort_order for each lesson
|
// Get current lesson to find its current sort_order
|
||||||
for (let i = 0; i < lesson_ids.length; i++) {
|
const currentLesson = await prisma.lesson.findUnique({ where: { id: lesson_id } });
|
||||||
await prisma.lesson.update({
|
if (!currentLesson) {
|
||||||
where: { id: lesson_ids[i] },
|
throw new NotFoundError('Lesson not found');
|
||||||
data: { sort_order: i }
|
}
|
||||||
|
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({
|
const lessons = await prisma.lesson.findMany({
|
||||||
where: { chapter_id: chapter_id },
|
where: { chapter_id },
|
||||||
orderBy: { sort_order: 'asc' }
|
orderBy: { sort_order: 'asc' }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -498,6 +579,7 @@ export class ChaptersLessonService {
|
||||||
await CoursesInstructorService.validateCourseStatus(course_id);
|
await CoursesInstructorService.validateCourseStatus(course_id);
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
|
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
|
||||||
|
logger.info(`User: ${user}`);
|
||||||
if (!user) throw new UnauthorizedError('Invalid token');
|
if (!user) throw new UnauthorizedError('Invalid token');
|
||||||
|
|
||||||
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
|
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import {
|
||||||
CompleteLessonInput,
|
CompleteLessonInput,
|
||||||
CompleteLessonResponse,
|
CompleteLessonResponse,
|
||||||
} from "../types/CoursesStudent.types";
|
} from "../types/CoursesStudent.types";
|
||||||
|
import { getPresignedUrl, listObjects, getVideoFolder, getAttachmentsFolder } from '../config/minio';
|
||||||
|
|
||||||
|
|
||||||
export class CoursesStudentService {
|
export class CoursesStudentService {
|
||||||
async enrollCourse(input: EnrollCourseInput): Promise<EnrollCourseResponse> {
|
async enrollCourse(input: EnrollCourseInput): Promise<EnrollCourseResponse> {
|
||||||
|
|
@ -265,6 +267,8 @@ export class CoursesStudentService {
|
||||||
const { token, course_id, lesson_id } = input;
|
const { token, course_id, lesson_id } = input;
|
||||||
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 };
|
||||||
|
|
||||||
|
// Import MinIO functions
|
||||||
|
|
||||||
// Check enrollment
|
// Check enrollment
|
||||||
const enrollment = await prisma.enrollment.findUnique({
|
const enrollment = await prisma.enrollment.findUnique({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -319,6 +323,80 @@ export class CoursesStudentService {
|
||||||
const prevLessonId = currentIndex > 0 ? allLessons[currentIndex - 1].id : null;
|
const prevLessonId = currentIndex > 0 ? allLessons[currentIndex - 1].id : null;
|
||||||
const nextLessonId = currentIndex < allLessons.length - 1 ? 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 {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'Lesson retrieved successfully',
|
message: 'Lesson retrieved successfully',
|
||||||
|
|
@ -332,14 +410,8 @@ export class CoursesStudentService {
|
||||||
is_sequential: lesson.is_sequential,
|
is_sequential: lesson.is_sequential,
|
||||||
prerequisite_lesson_ids: lesson.prerequisite_lesson_ids as number[] | null,
|
prerequisite_lesson_ids: lesson.prerequisite_lesson_ids as number[] | null,
|
||||||
require_pass_quiz: lesson.require_pass_quiz,
|
require_pass_quiz: lesson.require_pass_quiz,
|
||||||
attachments: lesson.attachments.map(att => ({
|
video_url, // Presigned URL for video
|
||||||
id: att.id,
|
attachments: attachmentsWithUrls,
|
||||||
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,
|
|
||||||
})),
|
|
||||||
quiz: lesson.quiz ? {
|
quiz: lesson.quiz ? {
|
||||||
id: lesson.quiz.id,
|
id: lesson.quiz.id,
|
||||||
title: lesson.quiz.title as { th: string; en: string },
|
title: lesson.quiz.title as { th: string; en: string },
|
||||||
|
|
|
||||||
|
|
@ -325,7 +325,8 @@ export interface ReorderLessonsRequest {
|
||||||
token: string;
|
token: string;
|
||||||
course_id: number;
|
course_id: number;
|
||||||
chapter_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 {
|
export interface ReorderLessonsBody {
|
||||||
lesson_ids: number[];
|
lesson_id: number;
|
||||||
|
sort_order: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddQuestionBody {
|
export interface AddQuestionBody {
|
||||||
|
|
|
||||||
|
|
@ -141,15 +141,16 @@ export interface LessonContentData {
|
||||||
is_sequential: boolean;
|
is_sequential: boolean;
|
||||||
prerequisite_lesson_ids: number[] | null;
|
prerequisite_lesson_ids: number[] | null;
|
||||||
require_pass_quiz: boolean;
|
require_pass_quiz: boolean;
|
||||||
|
video_url: string | null; // Presigned URL for video
|
||||||
attachments: {
|
attachments: {
|
||||||
id: number;
|
|
||||||
file_name: string;
|
file_name: string;
|
||||||
file_path: string;
|
file_path: string;
|
||||||
file_size: number;
|
file_size: number;
|
||||||
mime_type: string;
|
mime_type: string;
|
||||||
description: MultiLangText | null;
|
presigned_url: string | null; // Presigned URL for attachment
|
||||||
}[];
|
}[];
|
||||||
quiz?: {
|
quiz?: {
|
||||||
|
|
||||||
id: number;
|
id: number;
|
||||||
title: MultiLangText;
|
title: MultiLangText;
|
||||||
description: MultiLangText | null;
|
description: MultiLangText | null;
|
||||||
|
|
@ -163,6 +164,7 @@ export interface LessonContentData {
|
||||||
next_lesson_id: number | null;
|
next_lesson_id: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface GetLessonContentResponse {
|
export interface GetLessonContentResponse {
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,11 @@
|
||||||
"routes": {
|
"routes": {
|
||||||
"routesDir": "src/routes",
|
"routesDir": "src/routes",
|
||||||
"middleware": "express",
|
"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