init agent
This commit is contained in:
parent
baaae9f4fa
commit
08a4e0d8fa
9 changed files with 3572 additions and 2 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,5 +1,3 @@
|
|||
Learner
|
||||
Learner/*
|
||||
.env
|
||||
.agent
|
||||
.agent/*
|
||||
550
Backend/.agent/rules/rules.md
Normal file
550
Backend/.agent/rules/rules.md
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
# 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!
|
||||
516
Backend/.agent/workflowss/create-api-endpoint.md
Normal file
516
Backend/.agent/workflowss/create-api-endpoint.md
Normal file
|
|
@ -0,0 +1,516 @@
|
|||
---
|
||||
description: How to create a new API endpoint
|
||||
---
|
||||
|
||||
# Create API Endpoint Workflow
|
||||
|
||||
Follow these steps to create a new API endpoint for the E-Learning Platform backend.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Define Route
|
||||
|
||||
Create or update route file in `src/routes/`:
|
||||
|
||||
```javascript
|
||||
// src/routes/courses.routes.js
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const courseController = require('../controllers/course.controller');
|
||||
const { authenticate, authorize } = require('../middleware/auth');
|
||||
const { validate } = require('../middleware/validation');
|
||||
const { courseSchema } = require('../validators/course.validator');
|
||||
|
||||
// Public routes
|
||||
router.get('/', courseController.list);
|
||||
router.get('/:id', courseController.getById);
|
||||
|
||||
// Instructor routes
|
||||
router.post(
|
||||
'/',
|
||||
authenticate,
|
||||
authorize(['INSTRUCTOR', 'ADMIN']),
|
||||
validate(courseSchema.create),
|
||||
courseController.create
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:id',
|
||||
authenticate,
|
||||
authorize(['INSTRUCTOR', 'ADMIN']),
|
||||
validate(courseSchema.update),
|
||||
courseController.update
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Create Validator
|
||||
|
||||
Create validator in `src/validators/`:
|
||||
|
||||
```javascript
|
||||
// src/validators/course.validator.js
|
||||
const Joi = require('joi');
|
||||
|
||||
const multiLangSchema = Joi.object({
|
||||
th: Joi.string().required(),
|
||||
en: Joi.string().required()
|
||||
});
|
||||
|
||||
const courseSchema = {
|
||||
create: Joi.object({
|
||||
title: multiLangSchema.required(),
|
||||
description: multiLangSchema.required(),
|
||||
category_id: Joi.number().integer().positive().required(),
|
||||
price: Joi.number().min(0).required(),
|
||||
is_free: Joi.boolean().default(false),
|
||||
have_certificate: Joi.boolean().default(false),
|
||||
thumbnail: Joi.string().uri().optional()
|
||||
}),
|
||||
|
||||
update: Joi.object({
|
||||
title: multiLangSchema.optional(),
|
||||
description: multiLangSchema.optional(),
|
||||
category_id: Joi.number().integer().positive().optional(),
|
||||
price: Joi.number().min(0).optional(),
|
||||
is_free: Joi.boolean().optional(),
|
||||
have_certificate: Joi.boolean().optional(),
|
||||
thumbnail: Joi.string().uri().optional()
|
||||
})
|
||||
};
|
||||
|
||||
module.exports = { courseSchema };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Create Controller
|
||||
|
||||
Create controller in `src/controllers/`:
|
||||
|
||||
```javascript
|
||||
// src/controllers/course.controller.js
|
||||
const courseService = require('../services/course.service');
|
||||
|
||||
class CourseController {
|
||||
async list(req, res) {
|
||||
try {
|
||||
const { page = 1, limit = 20, category, search } = req.query;
|
||||
|
||||
const result = await courseService.list({
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
category: category ? parseInt(category) : undefined,
|
||||
search
|
||||
});
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('List courses error:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Failed to fetch courses'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getById(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const course = await courseService.getById(parseInt(id));
|
||||
|
||||
if (!course) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
code: 'COURSE_NOT_FOUND',
|
||||
message: 'Course not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json(course);
|
||||
} catch (error) {
|
||||
console.error('Get course error:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Failed to fetch course'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async create(req, res) {
|
||||
try {
|
||||
const course = await courseService.create(req.body, req.user.id);
|
||||
return res.status(201).json(course);
|
||||
} catch (error) {
|
||||
console.error('Create course error:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Failed to create course'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async update(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check ownership
|
||||
const course = await courseService.getById(parseInt(id));
|
||||
if (!course) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
code: 'COURSE_NOT_FOUND',
|
||||
message: 'Course not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (course.created_by !== req.user.id && req.user.role.code !== 'ADMIN') {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You do not have permission to update this course'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await courseService.update(parseInt(id), req.body);
|
||||
return res.status(200).json(updated);
|
||||
} catch (error) {
|
||||
console.error('Update course error:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Failed to update course'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CourseController();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Create Service
|
||||
|
||||
Create service in `src/services/`:
|
||||
|
||||
```javascript
|
||||
// src/services/course.service.js
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
class CourseService {
|
||||
async list({ page, limit, category, search }) {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where = {
|
||||
is_deleted: false,
|
||||
status: 'APPROVED'
|
||||
};
|
||||
|
||||
if (category) {
|
||||
where.category_id = category;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { path: ['th'], string_contains: search } },
|
||||
{ title: { path: ['en'], string_contains: search } }
|
||||
];
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.course.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
category: true,
|
||||
instructors: {
|
||||
where: { is_primary: true },
|
||||
include: { user: { include: { profile: true } } }
|
||||
}
|
||||
},
|
||||
orderBy: { created_at: 'desc' }
|
||||
}),
|
||||
prisma.course.count({ where })
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async getById(id) {
|
||||
return prisma.course.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
category: true,
|
||||
instructors: {
|
||||
include: { user: { include: { profile: true } } }
|
||||
},
|
||||
chapters: {
|
||||
include: {
|
||||
lessons: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
type: true,
|
||||
sort_order: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { sort_order: 'asc' }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async create(data, userId) {
|
||||
return prisma.course.create({
|
||||
data: {
|
||||
...data,
|
||||
status: 'DRAFT',
|
||||
created_by: userId,
|
||||
instructors: {
|
||||
create: {
|
||||
user_id: userId,
|
||||
is_primary: true
|
||||
}
|
||||
}
|
||||
},
|
||||
include: {
|
||||
category: true,
|
||||
instructors: {
|
||||
include: { user: { include: { profile: true } } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async update(id, data) {
|
||||
return prisma.course.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
category: true,
|
||||
instructors: {
|
||||
include: { user: { include: { profile: true } } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CourseService();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Register Route
|
||||
|
||||
Update `src/app.js`:
|
||||
|
||||
```javascript
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Routes
|
||||
const authRoutes = require('./routes/auth.routes');
|
||||
const courseRoutes = require('./routes/courses.routes');
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/courses', courseRoutes);
|
||||
|
||||
module.exports = app;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Write Tests
|
||||
|
||||
Create test file in `tests/integration/`:
|
||||
|
||||
```javascript
|
||||
// tests/integration/courses.test.js
|
||||
const request = require('supertest');
|
||||
const app = require('../../src/app');
|
||||
|
||||
describe('Course API', () => {
|
||||
let instructorToken;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'instructor1', password: 'password123' });
|
||||
instructorToken = res.body.token;
|
||||
});
|
||||
|
||||
describe('POST /api/courses', () => {
|
||||
it('should create course with valid data', async () => {
|
||||
const courseData = {
|
||||
title: { th: 'คอร์สทดสอบ', en: 'Test Course' },
|
||||
description: { th: 'รายละเอียด', en: 'Description' },
|
||||
category_id: 1,
|
||||
price: 990,
|
||||
is_free: false
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/courses')
|
||||
.set('Authorization', `Bearer ${instructorToken}`)
|
||||
.send(courseData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toHaveProperty('id');
|
||||
expect(response.body.status).toBe('DRAFT');
|
||||
});
|
||||
|
||||
it('should return 400 with invalid data', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/courses')
|
||||
.set('Authorization', `Bearer ${instructorToken}`)
|
||||
.send({ title: 'Invalid' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 401 without auth', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/courses')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/courses', () => {
|
||||
it('should list courses', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/courses')
|
||||
.query({ page: 1, limit: 10 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('data');
|
||||
expect(response.body).toHaveProperty('pagination');
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Run Tests
|
||||
|
||||
// turbo
|
||||
```bash
|
||||
npm test -- courses.test.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 8: Test Manually
|
||||
|
||||
// turbo
|
||||
```bash
|
||||
# Create course
|
||||
curl -X POST http://localhost:4000/api/courses \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": {"th": "ทดสอบ", "en": "Test"},
|
||||
"description": {"th": "รายละเอียด", "en": "Description"},
|
||||
"category_id": 1,
|
||||
"price": 990
|
||||
}'
|
||||
|
||||
# List courses
|
||||
curl http://localhost:4000/api/courses?page=1&limit=10
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Route defined with proper middleware
|
||||
- [ ] Validator created with Joi/Zod
|
||||
- [ ] Controller created with error handling
|
||||
- [ ] Service created with business logic
|
||||
- [ ] Route registered in app.js
|
||||
- [ ] Tests written (unit + integration)
|
||||
- [ ] All tests passing
|
||||
- [ ] Manual testing done
|
||||
- [ ] Documentation updated
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Separation of Concerns**: Routes → Controllers → Services → Database
|
||||
2. **Validation**: Always validate input at route level
|
||||
3. **Authorization**: Check permissions in controller
|
||||
4. **Error Handling**: Use try-catch and return proper error codes
|
||||
5. **Multi-Language**: Use JSON structure for user-facing text
|
||||
6. **Pagination**: Always paginate list endpoints
|
||||
7. **Testing**: Write tests before deploying
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### File Upload Endpoint
|
||||
```javascript
|
||||
const multer = require('multer');
|
||||
const upload = multer({ dest: 'uploads/' });
|
||||
|
||||
router.post(
|
||||
'/upload',
|
||||
authenticate,
|
||||
upload.single('file'),
|
||||
validate(uploadSchema),
|
||||
controller.upload
|
||||
);
|
||||
```
|
||||
|
||||
### Ownership Check
|
||||
```javascript
|
||||
// In controller
|
||||
const resource = await service.getById(id);
|
||||
if (resource.user_id !== req.user.id && req.user.role.code !== 'ADMIN') {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
```
|
||||
|
||||
### Soft Delete
|
||||
```javascript
|
||||
// In service
|
||||
async delete(id) {
|
||||
return prisma.course.update({
|
||||
where: { id },
|
||||
data: { is_deleted: true, deleted_at: new Date() }
|
||||
});
|
||||
}
|
||||
```
|
||||
311
Backend/.agent/workflowss/database-migration.md
Normal file
311
Backend/.agent/workflowss/database-migration.md
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
---
|
||||
description: How to create and run database migrations
|
||||
---
|
||||
|
||||
# Database Migration Workflow
|
||||
|
||||
Follow these steps to create and apply database migrations using Prisma.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Update Prisma Schema
|
||||
|
||||
Edit `prisma/schema.prisma`:
|
||||
|
||||
```prisma
|
||||
model Course {
|
||||
id Int @id @default(autoincrement())
|
||||
title Json // { th: "", en: "" }
|
||||
description Json
|
||||
price Decimal @db.Decimal(10, 2)
|
||||
is_free Boolean @default(false)
|
||||
have_certificate Boolean @default(false)
|
||||
status CourseStatus @default(DRAFT)
|
||||
thumbnail String?
|
||||
|
||||
category_id Int
|
||||
category Category @relation(fields: [category_id], references: [id])
|
||||
|
||||
created_by Int
|
||||
creator User @relation(fields: [created_by], references: [id])
|
||||
|
||||
chapters Chapter[]
|
||||
instructors CourseInstructor[]
|
||||
enrollments Enrollment[]
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
deleted_at DateTime?
|
||||
is_deleted Boolean @default(false)
|
||||
|
||||
@@index([category_id])
|
||||
@@index([status])
|
||||
@@index([is_deleted])
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Create Migration
|
||||
|
||||
// turbo
|
||||
Run migration command:
|
||||
```bash
|
||||
npx prisma migrate dev --name add_course_model
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Generate SQL migration file
|
||||
2. Apply migration to database
|
||||
3. Regenerate Prisma Client
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Review Migration File
|
||||
|
||||
Check generated SQL in `prisma/migrations/`:
|
||||
|
||||
```sql
|
||||
-- CreateTable
|
||||
CREATE TABLE "Course" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"title" JSONB NOT NULL,
|
||||
"description" JSONB NOT NULL,
|
||||
"price" DECIMAL(10,2) NOT NULL,
|
||||
"is_free" BOOLEAN NOT NULL DEFAULT false,
|
||||
...
|
||||
CONSTRAINT "Course_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Course_category_id_idx" ON "Course"("category_id");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Update Prisma Client
|
||||
|
||||
// turbo
|
||||
Regenerate client if needed:
|
||||
```bash
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Create Seed Data (Optional)
|
||||
|
||||
Update `prisma/seed.js`:
|
||||
|
||||
```javascript
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
// Create categories
|
||||
const category = await prisma.category.create({
|
||||
data: {
|
||||
name: { th: 'การเขียนโปรแกรม', en: 'Programming' },
|
||||
description: { th: 'หลักสูตรด้านการเขียนโปรแกรม', en: 'Programming courses' }
|
||||
}
|
||||
});
|
||||
|
||||
// Create test user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: 'instructor1',
|
||||
email: 'instructor@example.com',
|
||||
password: '$2b$10$...', // hashed password
|
||||
role_id: 2 // INSTRUCTOR
|
||||
}
|
||||
});
|
||||
|
||||
// Create test course
|
||||
const course = await prisma.course.create({
|
||||
data: {
|
||||
title: { th: 'Python สำหรับผู้เริ่มต้น', en: 'Python for Beginners' },
|
||||
description: { th: 'เรียนรู้ Python', en: 'Learn Python' },
|
||||
price: 990,
|
||||
is_free: false,
|
||||
category_id: category.id,
|
||||
created_by: user.id
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Seed data created:', { category, user, course });
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Run Seed
|
||||
|
||||
// turbo
|
||||
Seed the database:
|
||||
```bash
|
||||
npx prisma db seed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Migration Tasks
|
||||
|
||||
### Add New Field
|
||||
```prisma
|
||||
model Course {
|
||||
// ... existing fields
|
||||
slug String @unique // New field
|
||||
}
|
||||
```
|
||||
|
||||
// turbo
|
||||
```bash
|
||||
npx prisma migrate dev --name add_course_slug
|
||||
```
|
||||
|
||||
### Add Index
|
||||
```prisma
|
||||
model Course {
|
||||
// ... existing fields
|
||||
|
||||
@@index([created_at]) // New index
|
||||
}
|
||||
```
|
||||
|
||||
### Add Relation
|
||||
```prisma
|
||||
model Lesson {
|
||||
// ... existing fields
|
||||
|
||||
quiz_id Int?
|
||||
quiz Quiz? @relation(fields: [quiz_id], references: [id])
|
||||
}
|
||||
```
|
||||
|
||||
### Rename Field
|
||||
```prisma
|
||||
model Course {
|
||||
// Old: instructor_id
|
||||
created_by Int // New name
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Prisma will detect rename and ask for confirmation
|
||||
|
||||
---
|
||||
|
||||
## Production Migration
|
||||
|
||||
### Step 1: Generate Migration (Dev)
|
||||
```bash
|
||||
npx prisma migrate dev --name migration_name
|
||||
```
|
||||
|
||||
### Step 2: Commit Migration Files
|
||||
```bash
|
||||
git add prisma/migrations/
|
||||
git commit -m "Add migration: migration_name"
|
||||
```
|
||||
|
||||
### Step 3: Deploy to Production
|
||||
```bash
|
||||
npx prisma migrate deploy
|
||||
```
|
||||
|
||||
**Important**: Never use `migrate dev` in production!
|
||||
|
||||
---
|
||||
|
||||
## Rollback Migration
|
||||
|
||||
### Option 1: Manual Rollback
|
||||
```bash
|
||||
# Find migration to rollback
|
||||
ls prisma/migrations/
|
||||
|
||||
# Manually run the down migration (if exists)
|
||||
psql $DATABASE_URL < prisma/migrations/XXXXXX_migration_name/down.sql
|
||||
```
|
||||
|
||||
### Option 2: Reset Database (Dev Only)
|
||||
```bash
|
||||
npx prisma migrate reset
|
||||
```
|
||||
|
||||
**Warning**: This will delete all data!
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Migration Failed
|
||||
```bash
|
||||
# Check database status
|
||||
npx prisma migrate status
|
||||
|
||||
# Resolve migration
|
||||
npx prisma migrate resolve --applied MIGRATION_NAME
|
||||
# or
|
||||
npx prisma migrate resolve --rolled-back MIGRATION_NAME
|
||||
```
|
||||
|
||||
### Schema Drift
|
||||
```bash
|
||||
# Check for drift
|
||||
npx prisma migrate diff
|
||||
|
||||
# Reset and reapply
|
||||
npx prisma migrate reset
|
||||
```
|
||||
|
||||
### Generate Client Error
|
||||
```bash
|
||||
# Clear node_modules and reinstall
|
||||
rm -rf node_modules
|
||||
npm install
|
||||
|
||||
# Regenerate client
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Schema updated in `prisma/schema.prisma`
|
||||
- [ ] Migration created with descriptive name
|
||||
- [ ] Migration SQL reviewed
|
||||
- [ ] Migration applied successfully
|
||||
- [ ] Prisma Client regenerated
|
||||
- [ ] Seed data updated (if needed)
|
||||
- [ ] Tests updated for new schema
|
||||
- [ ] Migration files committed to git
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Descriptive Names**: Use clear migration names
|
||||
- ✅ `add_course_certificate_field`
|
||||
- ❌ `update_schema`
|
||||
|
||||
2. **Small Migrations**: One logical change per migration
|
||||
|
||||
3. **Review SQL**: Always check generated SQL before applying
|
||||
|
||||
4. **Test First**: Test migrations on dev database first
|
||||
|
||||
5. **Backup**: Backup production database before migrating
|
||||
|
||||
6. **Indexes**: Add indexes for frequently queried fields
|
||||
|
||||
7. **Constraints**: Use database constraints for data integrity
|
||||
407
Backend/.agent/workflowss/deployment.md
Normal file
407
Backend/.agent/workflowss/deployment.md
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
---
|
||||
description: How to deploy the backend to production
|
||||
---
|
||||
|
||||
# Deployment Workflow
|
||||
|
||||
Follow these steps to deploy the E-Learning Platform backend to production.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Production server with Node.js 18+
|
||||
- PostgreSQL database
|
||||
- Redis server
|
||||
- MinIO/S3 storage
|
||||
- Domain name and SSL certificate
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Prepare Production Server
|
||||
|
||||
```bash
|
||||
# Update system
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# Install Node.js 18
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
|
||||
# Install PM2
|
||||
sudo npm install -g pm2
|
||||
|
||||
# Install nginx
|
||||
sudo apt install -y nginx
|
||||
|
||||
# Install certbot for SSL
|
||||
sudo apt install -y certbot python3-certbot-nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Clone Repository
|
||||
|
||||
```bash
|
||||
# Create app directory
|
||||
sudo mkdir -p /var/www/elearning-backend
|
||||
sudo chown $USER:$USER /var/www/elearning-backend
|
||||
|
||||
# Clone repository
|
||||
cd /var/www/elearning-backend
|
||||
git clone <repository-url> .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Install Dependencies
|
||||
|
||||
```bash
|
||||
npm ci --production
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Setup Environment Variables
|
||||
|
||||
Create `.env` file:
|
||||
|
||||
```bash
|
||||
# Application
|
||||
NODE_ENV=production
|
||||
PORT=4000
|
||||
APP_URL=https://api.elearning.com
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:password@db-host:5432/elearning_prod
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://:password@redis-host:6379
|
||||
|
||||
# MinIO/S3
|
||||
S3_ENDPOINT=https://s3.elearning.com
|
||||
S3_ACCESS_KEY=<access-key>
|
||||
S3_SECRET_KEY=<secret-key>
|
||||
S3_BUCKET_COURSES=courses
|
||||
S3_BUCKET_VIDEOS=videos
|
||||
S3_BUCKET_DOCUMENTS=documents
|
||||
S3_BUCKET_IMAGES=images
|
||||
S3_BUCKET_ATTACHMENTS=attachments
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=<strong-random-secret>
|
||||
JWT_EXPIRES_IN=24h
|
||||
|
||||
# Email
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=<email>
|
||||
SMTP_PASSWORD=<password>
|
||||
SMTP_FROM=noreply@elearning.com
|
||||
|
||||
# CORS
|
||||
CORS_ORIGIN=https://elearning.com,https://admin.elearning.com
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Run Database Migrations
|
||||
|
||||
```bash
|
||||
npx prisma migrate deploy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Setup PM2
|
||||
|
||||
Create `ecosystem.config.js`:
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'elearning-backend',
|
||||
script: './src/server.js',
|
||||
instances: 'max',
|
||||
exec_mode: 'cluster',
|
||||
env: {
|
||||
NODE_ENV: 'production'
|
||||
},
|
||||
error_file: './logs/err.log',
|
||||
out_file: './logs/out.log',
|
||||
log_file: './logs/combined.log',
|
||||
time: true
|
||||
}]
|
||||
};
|
||||
```
|
||||
|
||||
Start application:
|
||||
|
||||
```bash
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Configure Nginx
|
||||
|
||||
Create `/etc/nginx/sites-available/elearning-backend`:
|
||||
|
||||
```nginx
|
||||
upstream backend {
|
||||
server localhost:4000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name api.elearning.com;
|
||||
|
||||
# Redirect to HTTPS
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name api.elearning.com;
|
||||
|
||||
# SSL Configuration
|
||||
ssl_certificate /etc/letsencrypt/live/api.elearning.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/api.elearning.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# Security Headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/elearning-backend-access.log;
|
||||
error_log /var/log/nginx/elearning-backend-error.log;
|
||||
|
||||
# File Upload Size
|
||||
client_max_body_size 500M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable site:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/elearning-backend /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 8: Setup SSL Certificate
|
||||
|
||||
```bash
|
||||
sudo certbot --nginx -d api.elearning.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 9: Setup Monitoring
|
||||
|
||||
### PM2 Monitoring
|
||||
|
||||
```bash
|
||||
pm2 install pm2-logrotate
|
||||
pm2 set pm2-logrotate:max_size 10M
|
||||
pm2 set pm2-logrotate:retain 30
|
||||
```
|
||||
|
||||
### Health Check Endpoint
|
||||
|
||||
Add to your app:
|
||||
|
||||
```javascript
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime()
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 10: Setup Backup
|
||||
|
||||
Create backup script `/opt/scripts/backup-db.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_DIR="/var/backups/elearning"
|
||||
DB_NAME="elearning_prod"
|
||||
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
# Backup database
|
||||
pg_dump $DB_NAME | gzip > $BACKUP_DIR/db_$DATE.sql.gz
|
||||
|
||||
# Keep only last 7 days
|
||||
find $BACKUP_DIR -name "db_*.sql.gz" -mtime +7 -delete
|
||||
|
||||
echo "Backup completed: db_$DATE.sql.gz"
|
||||
```
|
||||
|
||||
Add to crontab:
|
||||
|
||||
```bash
|
||||
# Daily backup at 2 AM
|
||||
0 2 * * * /opt/scripts/backup-db.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 11: Verify Deployment
|
||||
|
||||
```bash
|
||||
# Check PM2 status
|
||||
pm2 status
|
||||
|
||||
# Check logs
|
||||
pm2 logs elearning-backend --lines 50
|
||||
|
||||
# Test health endpoint
|
||||
curl https://api.elearning.com/health
|
||||
|
||||
# Test API
|
||||
curl https://api.elearning.com/api/courses
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Update Deployment
|
||||
|
||||
```bash
|
||||
# Pull latest code
|
||||
cd /var/www/elearning-backend
|
||||
git pull origin main
|
||||
|
||||
# Install dependencies
|
||||
npm ci --production
|
||||
|
||||
# Run migrations
|
||||
npx prisma migrate deploy
|
||||
|
||||
# Restart application
|
||||
pm2 restart elearning-backend
|
||||
|
||||
# Check status
|
||||
pm2 status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
```bash
|
||||
# Revert to previous commit
|
||||
git reset --hard HEAD~1
|
||||
|
||||
# Reinstall dependencies
|
||||
npm ci --production
|
||||
|
||||
# Rollback migration (if needed)
|
||||
npx prisma migrate resolve --rolled-back <migration-name>
|
||||
|
||||
# Restart
|
||||
pm2 restart elearning-backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Production server setup
|
||||
- [ ] Repository cloned
|
||||
- [ ] Dependencies installed
|
||||
- [ ] Environment variables configured
|
||||
- [ ] Database migrated
|
||||
- [ ] PM2 configured and running
|
||||
- [ ] Nginx configured
|
||||
- [ ] SSL certificate installed
|
||||
- [ ] Monitoring setup
|
||||
- [ ] Backup script configured
|
||||
- [ ] Health check working
|
||||
- [ ] API endpoints tested
|
||||
|
||||
---
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] Strong JWT secret
|
||||
- [ ] Database credentials secured
|
||||
- [ ] CORS configured correctly
|
||||
- [ ] Rate limiting enabled
|
||||
- [ ] SSL/TLS enabled
|
||||
- [ ] Security headers configured
|
||||
- [ ] File upload limits set
|
||||
- [ ] Environment variables not in git
|
||||
- [ ] Firewall configured
|
||||
- [ ] SSH key-based authentication
|
||||
|
||||
---
|
||||
|
||||
## Monitoring
|
||||
|
||||
### PM2 Commands
|
||||
|
||||
```bash
|
||||
# View logs
|
||||
pm2 logs
|
||||
|
||||
# Monitor resources
|
||||
pm2 monit
|
||||
|
||||
# Restart app
|
||||
pm2 restart elearning-backend
|
||||
|
||||
# Stop app
|
||||
pm2 stop elearning-backend
|
||||
|
||||
# Delete app
|
||||
pm2 delete elearning-backend
|
||||
```
|
||||
|
||||
### Check System Resources
|
||||
|
||||
```bash
|
||||
# CPU and Memory
|
||||
htop
|
||||
|
||||
# Disk usage
|
||||
df -h
|
||||
|
||||
# Network connections
|
||||
netstat -tulpn
|
||||
```
|
||||
572
Backend/.agent/workflowss/e-learning-backend.md
Normal file
572
Backend/.agent/workflowss/e-learning-backend.md
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
---
|
||||
description: Complete workflow for developing E-Learning Platform backend
|
||||
---
|
||||
|
||||
# E-Learning Backend Development Workflow
|
||||
|
||||
Complete guide for developing the E-Learning Platform backend from scratch.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Overview
|
||||
|
||||
This workflow covers the entire development process from initial setup to deployment.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Initial Setup
|
||||
|
||||
### 1.1 Environment Setup
|
||||
|
||||
Follow the [Setup Workflow](./setup.md):
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone <repository-url>
|
||||
cd e-learning/Backend
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Setup environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration
|
||||
|
||||
# Start Docker services
|
||||
docker compose up -d
|
||||
|
||||
# Run migrations
|
||||
npx prisma migrate dev
|
||||
|
||||
# Seed database
|
||||
npx prisma db seed
|
||||
|
||||
# Start dev server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 1.2 Verify Setup
|
||||
|
||||
```bash
|
||||
# Test health endpoint
|
||||
curl http://localhost:4000/health
|
||||
|
||||
# Test login
|
||||
curl -X POST http://localhost:4000/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "admin", "password": "admin123"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Database Design
|
||||
|
||||
### 2.1 Design Schema
|
||||
|
||||
Review and update `prisma/schema.prisma`:
|
||||
|
||||
```prisma
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
email String @unique
|
||||
password String
|
||||
role_id Int
|
||||
role Role @relation(fields: [role_id], references: [id])
|
||||
profile Profile?
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Course {
|
||||
id Int @id @default(autoincrement())
|
||||
title Json // { th: "", en: "" }
|
||||
description Json
|
||||
price Decimal @db.Decimal(10, 2)
|
||||
is_free Boolean @default(false)
|
||||
have_certificate Boolean @default(false)
|
||||
status CourseStatus @default(DRAFT)
|
||||
|
||||
category_id Int
|
||||
category Category @relation(fields: [category_id], references: [id])
|
||||
|
||||
created_by Int
|
||||
creator User @relation(fields: [created_by], references: [id])
|
||||
|
||||
chapters Chapter[]
|
||||
instructors CourseInstructor[]
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
@@index([category_id])
|
||||
@@index([status])
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Create Migration
|
||||
|
||||
Follow the [Database Migration Workflow](./database-migration.md):
|
||||
|
||||
```bash
|
||||
npx prisma migrate dev --name initial_schema
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Core Features Development
|
||||
|
||||
### 3.1 Authentication System
|
||||
|
||||
**Create endpoints:**
|
||||
- `POST /api/auth/register` - User registration
|
||||
- `POST /api/auth/login` - User login
|
||||
- `POST /api/auth/logout` - User logout
|
||||
- `POST /api/auth/refresh` - Refresh token
|
||||
|
||||
**Implementation steps:**
|
||||
1. Create `src/routes/auth.routes.js`
|
||||
2. Create `src/controllers/auth.controller.js`
|
||||
3. Create `src/services/auth.service.js`
|
||||
4. Implement JWT middleware
|
||||
5. Write tests
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
// src/services/auth.service.js
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
class AuthService {
|
||||
async register({ username, email, password, role_id = 3 }) {
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
role_id
|
||||
},
|
||||
include: { role: true }
|
||||
});
|
||||
|
||||
const token = this.generateToken(user);
|
||||
return { user, token };
|
||||
}
|
||||
|
||||
async login({ username, email, password }) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username },
|
||||
{ email }
|
||||
]
|
||||
},
|
||||
include: { role: true }
|
||||
});
|
||||
|
||||
if (!user || !await bcrypt.compare(password, user.password)) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
const token = this.generateToken(user);
|
||||
return { user, token };
|
||||
}
|
||||
|
||||
generateToken(user) {
|
||||
return jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: user.role.code
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AuthService();
|
||||
```
|
||||
|
||||
### 3.2 Course Management
|
||||
|
||||
Follow the [Create API Endpoint Workflow](./create-api-endpoint.md) for:
|
||||
|
||||
**Instructor endpoints:**
|
||||
- `POST /api/instructor/courses` - Create course
|
||||
- `GET /api/instructor/courses` - List my courses
|
||||
- `PUT /api/instructor/courses/:id` - Update course
|
||||
- `DELETE /api/instructor/courses/:id` - Delete course
|
||||
- `POST /api/instructor/courses/:id/submit` - Submit for approval
|
||||
|
||||
**Public endpoints:**
|
||||
- `GET /api/courses` - List approved courses
|
||||
- `GET /api/courses/:id` - Get course details
|
||||
|
||||
**Student endpoints:**
|
||||
- `POST /api/students/courses/:id/enroll` - Enroll in course
|
||||
- `GET /api/students/courses` - My enrolled courses
|
||||
|
||||
### 3.3 Chapters & Lessons
|
||||
|
||||
**Create endpoints:**
|
||||
- `POST /api/instructor/courses/:courseId/chapters` - Create chapter
|
||||
- `POST /api/instructor/courses/:courseId/chapters/:chapterId/lessons` - Create lesson
|
||||
- `PUT /api/instructor/courses/:courseId/lessons/:lessonId` - Update lesson
|
||||
- `DELETE /api/instructor/courses/:courseId/lessons/:lessonId` - Delete lesson
|
||||
|
||||
**Key features:**
|
||||
- Multi-language support (th/en)
|
||||
- Lesson types (video, text, pdf, quiz)
|
||||
- Sequential ordering
|
||||
- Prerequisites system
|
||||
|
||||
### 3.4 File Upload System
|
||||
|
||||
Follow the [File Upload Workflow](./file-upload.md):
|
||||
|
||||
**Implement:**
|
||||
- Video upload for lessons
|
||||
- Attachment upload (PDF, DOC, etc.)
|
||||
- Image upload for thumbnails
|
||||
- S3/MinIO integration
|
||||
- File validation (type, size)
|
||||
|
||||
**Endpoints:**
|
||||
- `POST /api/instructor/lessons/:lessonId/video` - Upload video
|
||||
- `POST /api/instructor/lessons/:lessonId/attachments` - Upload attachments
|
||||
- `GET /api/students/lessons/:lessonId/attachments/:id/download` - Download
|
||||
|
||||
### 3.5 Quiz System
|
||||
|
||||
**Create endpoints:**
|
||||
- `POST /api/instructor/lessons/:lessonId/quiz` - Create quiz
|
||||
- `POST /api/instructor/quizzes/:quizId/questions` - Add question
|
||||
- `GET /api/students/quizzes/:quizId` - Get quiz
|
||||
- `POST /api/students/quizzes/:quizId/submit` - Submit quiz
|
||||
|
||||
**Features:**
|
||||
- Multiple choice questions
|
||||
- Time limits
|
||||
- Attempt limits
|
||||
- Cooldown periods
|
||||
- Score policies (HIGHEST, LATEST, AVERAGE)
|
||||
|
||||
### 3.6 Progress Tracking
|
||||
|
||||
**Implement:**
|
||||
- Video progress tracking (save every 5 seconds)
|
||||
- Lesson completion
|
||||
- Course progress calculation
|
||||
- Certificate generation
|
||||
|
||||
**Endpoints:**
|
||||
- `POST /api/students/lessons/:lessonId/progress` - Save progress
|
||||
- `GET /api/students/lessons/:lessonId/progress` - Get progress
|
||||
- `POST /api/students/lessons/:lessonId/complete` - Mark complete
|
||||
- `GET /api/students/courses/:courseId/certificate` - Get certificate
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Testing
|
||||
|
||||
Follow the [Testing Workflow](./testing.md):
|
||||
|
||||
### 4.1 Unit Tests
|
||||
|
||||
```bash
|
||||
# Test services
|
||||
npm test -- unit/auth.service.test.js
|
||||
npm test -- unit/course.service.test.js
|
||||
```
|
||||
|
||||
### 4.2 Integration Tests
|
||||
|
||||
```bash
|
||||
# Test API endpoints
|
||||
npm test -- integration/auth.test.js
|
||||
npm test -- integration/courses.test.js
|
||||
npm test -- integration/lessons.test.js
|
||||
```
|
||||
|
||||
### 4.3 Coverage
|
||||
|
||||
```bash
|
||||
npm test -- --coverage
|
||||
```
|
||||
|
||||
Target: **>80% coverage**
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Optimization
|
||||
|
||||
### 5.1 Database Optimization
|
||||
|
||||
- Add indexes for frequently queried fields
|
||||
- Optimize N+1 queries
|
||||
- Use `select` to limit fields
|
||||
- Implement pagination
|
||||
|
||||
```prisma
|
||||
model Course {
|
||||
// ...
|
||||
@@index([category_id])
|
||||
@@index([status])
|
||||
@@index([created_at])
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Caching (Redis)
|
||||
|
||||
Implement caching for:
|
||||
- Course listings
|
||||
- User sessions
|
||||
- Frequently accessed data
|
||||
|
||||
```javascript
|
||||
const redis = require('redis');
|
||||
const client = redis.createClient({
|
||||
url: process.env.REDIS_URL
|
||||
});
|
||||
|
||||
// Cache course list
|
||||
const cacheKey = 'courses:approved';
|
||||
const cached = await client.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
const courses = await prisma.course.findMany(...);
|
||||
await client.setEx(cacheKey, 3600, JSON.stringify(courses));
|
||||
```
|
||||
|
||||
### 5.3 Rate Limiting
|
||||
|
||||
```javascript
|
||||
const rateLimit = require('express-rate-limit');
|
||||
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100 // limit each IP to 100 requests per windowMs
|
||||
});
|
||||
|
||||
app.use('/api/', limiter);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Security Hardening
|
||||
|
||||
### 6.1 Security Checklist
|
||||
|
||||
- [ ] Input validation on all endpoints
|
||||
- [ ] SQL injection prevention (Prisma handles this)
|
||||
- [ ] XSS prevention (sanitize HTML)
|
||||
- [ ] CSRF protection
|
||||
- [ ] Rate limiting
|
||||
- [ ] CORS configuration
|
||||
- [ ] Helmet.js for security headers
|
||||
- [ ] File upload validation
|
||||
- [ ] Strong JWT secrets
|
||||
- [ ] Password hashing (bcrypt)
|
||||
|
||||
### 6.2 Implement Security Middleware
|
||||
|
||||
```javascript
|
||||
const helmet = require('helmet');
|
||||
const cors = require('cors');
|
||||
|
||||
app.use(helmet());
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN.split(','),
|
||||
credentials: true
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Documentation
|
||||
|
||||
### 7.1 API Documentation
|
||||
|
||||
Use Swagger/OpenAPI:
|
||||
|
||||
```javascript
|
||||
const swaggerUi = require('swagger-ui-express');
|
||||
const swaggerDocument = require('./swagger.json');
|
||||
|
||||
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
|
||||
```
|
||||
|
||||
### 7.2 Code Documentation
|
||||
|
||||
- JSDoc comments for all functions
|
||||
- README.md with setup instructions
|
||||
- API endpoint documentation
|
||||
- Database schema documentation
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Deployment
|
||||
|
||||
Follow the [Deployment Workflow](./deployment.md):
|
||||
|
||||
### 8.1 Prepare for Production
|
||||
|
||||
```bash
|
||||
# Set NODE_ENV
|
||||
export NODE_ENV=production
|
||||
|
||||
# Install production dependencies only
|
||||
npm ci --production
|
||||
|
||||
# Run migrations
|
||||
npx prisma migrate deploy
|
||||
|
||||
# Build (if needed)
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 8.2 Deploy with PM2
|
||||
|
||||
```bash
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
### 8.3 Setup Nginx
|
||||
|
||||
Configure reverse proxy and SSL
|
||||
|
||||
### 8.4 Monitoring
|
||||
|
||||
- Setup PM2 monitoring
|
||||
- Configure logging
|
||||
- Setup error tracking (Sentry)
|
||||
- Health check endpoint
|
||||
|
||||
---
|
||||
|
||||
## Development Best Practices
|
||||
|
||||
### 1. Code Organization
|
||||
|
||||
```
|
||||
src/
|
||||
├── config/ # Configuration files
|
||||
├── controllers/ # Request handlers
|
||||
├── middleware/ # Express middleware
|
||||
├── models/ # Prisma models (generated)
|
||||
├── routes/ # Route definitions
|
||||
├── services/ # Business logic
|
||||
├── utils/ # Utility functions
|
||||
├── validators/ # Input validation
|
||||
└── app.js # Express app setup
|
||||
```
|
||||
|
||||
### 2. Git Workflow
|
||||
|
||||
```bash
|
||||
# Create feature branch
|
||||
git checkout -b feature/user-authentication
|
||||
|
||||
# Make changes and commit
|
||||
git add .
|
||||
git commit -m "feat: implement user authentication"
|
||||
|
||||
# Push and create PR
|
||||
git push origin feature/user-authentication
|
||||
```
|
||||
|
||||
### 3. Code Review Checklist
|
||||
|
||||
- [ ] Code follows style guide
|
||||
- [ ] Tests written and passing
|
||||
- [ ] No console.logs in production code
|
||||
- [ ] Error handling implemented
|
||||
- [ ] Input validation added
|
||||
- [ ] Documentation updated
|
||||
- [ ] No sensitive data in code
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Database connection error:**
|
||||
```bash
|
||||
# Check PostgreSQL
|
||||
docker ps | grep postgres
|
||||
docker logs elearning-postgres
|
||||
|
||||
# Test connection
|
||||
npx prisma db pull
|
||||
```
|
||||
|
||||
**Port already in use:**
|
||||
```bash
|
||||
# Find process
|
||||
lsof -i :4000
|
||||
|
||||
# Kill process
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
**Prisma Client error:**
|
||||
```bash
|
||||
# Regenerate client
|
||||
npx prisma generate
|
||||
|
||||
# Reset and migrate
|
||||
npx prisma migrate reset
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- [Backend Development Rules](../rules/rules.md)
|
||||
- [Agent Skills Backend](../../agent_skills_backend.md)
|
||||
- [API Documentation](../../docs/api-docs/)
|
||||
- [Development Setup](../../docs/development_setup.md)
|
||||
|
||||
---
|
||||
|
||||
## Quick Commands Reference
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Start dev server
|
||||
npm test # Run tests
|
||||
npm run lint # Run linter
|
||||
npm run format # Format code
|
||||
|
||||
# Database
|
||||
npx prisma studio # Open Prisma Studio
|
||||
npx prisma migrate dev # Create and apply migration
|
||||
npx prisma db seed # Seed database
|
||||
|
||||
# Docker
|
||||
docker compose up -d # Start services
|
||||
docker compose down # Stop services
|
||||
docker compose logs -f # View logs
|
||||
|
||||
# PM2 (Production)
|
||||
pm2 start app.js # Start app
|
||||
pm2 restart app # Restart app
|
||||
pm2 logs # View logs
|
||||
pm2 monit # Monitor resources
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Ready to build an amazing E-Learning Platform! 🚀**
|
||||
536
Backend/.agent/workflowss/file-upload.md
Normal file
536
Backend/.agent/workflowss/file-upload.md
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
---
|
||||
description: How to handle file uploads (videos, attachments)
|
||||
---
|
||||
|
||||
# File Upload Workflow
|
||||
|
||||
Follow these steps to implement file upload functionality for videos and attachments.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- MinIO/S3 configured and running
|
||||
- Multer installed: `npm install multer`
|
||||
- AWS SDK or MinIO client installed
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Configure S3/MinIO Client
|
||||
|
||||
Create `src/config/s3.config.js`:
|
||||
|
||||
```javascript
|
||||
const { S3Client } = require('@aws-sdk/client-s3');
|
||||
const { Upload } = require('@aws-sdk/lib-storage');
|
||||
|
||||
const s3Client = new S3Client({
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
region: process.env.S3_REGION || 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY,
|
||||
secretAccessKey: process.env.S3_SECRET_KEY
|
||||
},
|
||||
forcePathStyle: true // Required for MinIO
|
||||
});
|
||||
|
||||
module.exports = { s3Client };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Create Upload Service
|
||||
|
||||
Create `src/services/upload.service.js`:
|
||||
|
||||
```javascript
|
||||
const { s3Client } = require('../config/s3.config');
|
||||
const { Upload } = require('@aws-sdk/lib-storage');
|
||||
const { DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const path = require('path');
|
||||
|
||||
class UploadService {
|
||||
async uploadFile(file, folder) {
|
||||
const fileExt = path.extname(file.originalname);
|
||||
const fileName = `${Date.now()}-${uuidv4()}${fileExt}`;
|
||||
const key = `${folder}/${fileName}`;
|
||||
|
||||
const upload = new Upload({
|
||||
client: s3Client,
|
||||
params: {
|
||||
Bucket: this.getBucket(folder),
|
||||
Key: key,
|
||||
Body: file.buffer,
|
||||
ContentType: file.mimetype
|
||||
}
|
||||
});
|
||||
|
||||
await upload.done();
|
||||
|
||||
return {
|
||||
key,
|
||||
url: `${process.env.S3_ENDPOINT}/${this.getBucket(folder)}/${key}`,
|
||||
fileName: file.originalname,
|
||||
fileSize: file.size,
|
||||
mimeType: file.mimetype
|
||||
};
|
||||
}
|
||||
|
||||
async deleteFile(key, folder) {
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: this.getBucket(folder),
|
||||
Key: key
|
||||
});
|
||||
|
||||
await s3Client.send(command);
|
||||
}
|
||||
|
||||
getBucket(folder) {
|
||||
const bucketMap = {
|
||||
videos: process.env.S3_BUCKET_VIDEOS,
|
||||
documents: process.env.S3_BUCKET_DOCUMENTS,
|
||||
images: process.env.S3_BUCKET_IMAGES,
|
||||
attachments: process.env.S3_BUCKET_ATTACHMENTS
|
||||
};
|
||||
|
||||
return bucketMap[folder] || process.env.S3_BUCKET_COURSES;
|
||||
}
|
||||
|
||||
validateFileType(file, allowedTypes) {
|
||||
return allowedTypes.includes(file.mimetype);
|
||||
}
|
||||
|
||||
validateFileSize(file, maxSize) {
|
||||
return file.size <= maxSize;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UploadService();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Create Upload Middleware
|
||||
|
||||
Create `src/middleware/upload.middleware.js`:
|
||||
|
||||
```javascript
|
||||
const multer = require('multer');
|
||||
|
||||
// File type validators
|
||||
const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime'];
|
||||
const ALLOWED_DOCUMENT_TYPES = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
];
|
||||
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif'];
|
||||
|
||||
// File size limits
|
||||
const MAX_VIDEO_SIZE = 500 * 1024 * 1024; // 500 MB
|
||||
const MAX_ATTACHMENT_SIZE = 100 * 1024 * 1024; // 100 MB
|
||||
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
// Multer configuration
|
||||
const storage = multer.memoryStorage();
|
||||
|
||||
const fileFilter = (allowedTypes) => (req, file, cb) => {
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type'), false);
|
||||
}
|
||||
};
|
||||
|
||||
// Upload configurations
|
||||
const uploadVideo = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_VIDEO_SIZE },
|
||||
fileFilter: fileFilter(ALLOWED_VIDEO_TYPES)
|
||||
}).single('video');
|
||||
|
||||
const uploadAttachment = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_ATTACHMENT_SIZE },
|
||||
fileFilter: fileFilter([...ALLOWED_DOCUMENT_TYPES, ...ALLOWED_IMAGE_TYPES])
|
||||
}).single('file');
|
||||
|
||||
const uploadAttachments = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_ATTACHMENT_SIZE },
|
||||
fileFilter: fileFilter([...ALLOWED_DOCUMENT_TYPES, ...ALLOWED_IMAGE_TYPES])
|
||||
}).array('attachments', 10); // Max 10 files
|
||||
|
||||
const uploadImage = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_IMAGE_SIZE },
|
||||
fileFilter: fileFilter(ALLOWED_IMAGE_TYPES)
|
||||
}).single('image');
|
||||
|
||||
module.exports = {
|
||||
uploadVideo,
|
||||
uploadAttachment,
|
||||
uploadAttachments,
|
||||
uploadImage,
|
||||
ALLOWED_VIDEO_TYPES,
|
||||
ALLOWED_DOCUMENT_TYPES,
|
||||
ALLOWED_IMAGE_TYPES
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Create Upload Controller
|
||||
|
||||
Create `src/controllers/upload.controller.js`:
|
||||
|
||||
```javascript
|
||||
const uploadService = require('../services/upload.service');
|
||||
|
||||
class UploadController {
|
||||
async uploadVideo(req, res) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: 'NO_FILE',
|
||||
message: 'No video file provided'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const result = await uploadService.uploadFile(
|
||||
req.file,
|
||||
'videos'
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
video_url: result.url,
|
||||
file_size: result.fileSize,
|
||||
duration: null // Will be processed later
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload video error:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
code: 'UPLOAD_FAILED',
|
||||
message: 'Failed to upload video'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async uploadAttachment(req, res) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: 'NO_FILE',
|
||||
message: 'No file provided'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const result = await uploadService.uploadFile(
|
||||
req.file,
|
||||
'attachments'
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
file_name: result.fileName,
|
||||
file_path: result.key,
|
||||
file_size: result.fileSize,
|
||||
mime_type: result.mimeType,
|
||||
download_url: result.url
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload attachment error:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
code: 'UPLOAD_FAILED',
|
||||
message: 'Failed to upload file'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UploadController();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Create Upload Routes
|
||||
|
||||
Create `src/routes/upload.routes.js`:
|
||||
|
||||
```javascript
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const uploadController = require('../controllers/upload.controller');
|
||||
const { authenticate, authorize } = require('../middleware/auth');
|
||||
const { uploadVideo, uploadAttachment } = require('../middleware/upload.middleware');
|
||||
|
||||
// Video upload
|
||||
router.post(
|
||||
'/video',
|
||||
authenticate,
|
||||
authorize(['INSTRUCTOR', 'ADMIN']),
|
||||
(req, res, next) => {
|
||||
uploadVideo(req, res, (err) => {
|
||||
if (err) {
|
||||
if (err.message === 'Invalid file type') {
|
||||
return res.status(422).json({
|
||||
error: {
|
||||
code: 'INVALID_FILE_TYPE',
|
||||
message: 'Only MP4, WebM, and QuickTime videos are allowed'
|
||||
}
|
||||
});
|
||||
}
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(422).json({
|
||||
error: {
|
||||
code: 'FILE_TOO_LARGE',
|
||||
message: 'Video file size exceeds 500 MB limit'
|
||||
}
|
||||
});
|
||||
}
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
code: 'UPLOAD_ERROR',
|
||||
message: err.message
|
||||
}
|
||||
});
|
||||
}
|
||||
next();
|
||||
});
|
||||
},
|
||||
uploadController.uploadVideo
|
||||
);
|
||||
|
||||
// Attachment upload
|
||||
router.post(
|
||||
'/attachment',
|
||||
authenticate,
|
||||
authorize(['INSTRUCTOR', 'ADMIN']),
|
||||
(req, res, next) => {
|
||||
uploadAttachment(req, res, (err) => {
|
||||
if (err) {
|
||||
if (err.message === 'Invalid file type') {
|
||||
return res.status(422).json({
|
||||
error: {
|
||||
code: 'INVALID_FILE_TYPE',
|
||||
message: 'File type not allowed'
|
||||
}
|
||||
});
|
||||
}
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(422).json({
|
||||
error: {
|
||||
code: 'FILE_TOO_LARGE',
|
||||
message: 'File size exceeds 100 MB limit'
|
||||
}
|
||||
});
|
||||
}
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
code: 'UPLOAD_ERROR',
|
||||
message: err.message
|
||||
}
|
||||
});
|
||||
}
|
||||
next();
|
||||
});
|
||||
},
|
||||
uploadController.uploadAttachment
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Integrate with Lesson Creation
|
||||
|
||||
Update lesson controller to handle file uploads:
|
||||
|
||||
```javascript
|
||||
async createLesson(req, res) {
|
||||
try {
|
||||
const { courseId, chapterId } = req.params;
|
||||
|
||||
// Create lesson
|
||||
const lesson = await lessonService.create({
|
||||
...req.body,
|
||||
chapter_id: parseInt(chapterId)
|
||||
});
|
||||
|
||||
// Handle video upload if present
|
||||
if (req.files?.video) {
|
||||
const videoResult = await uploadService.uploadFile(
|
||||
req.files.video[0],
|
||||
`courses/${courseId}/lessons/${lesson.id}`
|
||||
);
|
||||
|
||||
await lessonService.update(lesson.id, {
|
||||
video_url: videoResult.url
|
||||
});
|
||||
}
|
||||
|
||||
// Handle attachments if present
|
||||
if (req.files?.attachments) {
|
||||
const attachments = await Promise.all(
|
||||
req.files.attachments.map(async (file, index) => {
|
||||
const result = await uploadService.uploadFile(
|
||||
file,
|
||||
`lessons/${lesson.id}/attachments`
|
||||
);
|
||||
|
||||
return {
|
||||
lesson_id: lesson.id,
|
||||
file_name: result.fileName,
|
||||
file_path: result.key,
|
||||
file_size: result.fileSize,
|
||||
mime_type: result.mimeType,
|
||||
description: req.body.descriptions?.[index] || {},
|
||||
sort_order: index + 1
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
await prisma.attachment.createMany({
|
||||
data: attachments
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(201).json(lesson);
|
||||
} catch (error) {
|
||||
console.error('Create lesson error:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Failed to create lesson'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Test File Upload
|
||||
|
||||
```bash
|
||||
# Upload video
|
||||
curl -X POST http://localhost:4000/api/upload/video \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-F "video=@/path/to/video.mp4"
|
||||
|
||||
# Upload attachment
|
||||
curl -X POST http://localhost:4000/api/upload/attachment \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-F "file=@/path/to/document.pdf"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Errors
|
||||
|
||||
1. **File Too Large**
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "FILE_TOO_LARGE",
|
||||
"message": "File size exceeds maximum limit"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Invalid File Type**
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "INVALID_FILE_TYPE",
|
||||
"message": "File type not allowed"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **S3 Upload Failed**
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "UPLOAD_FAILED",
|
||||
"message": "Failed to upload file to storage"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] S3/MinIO client configured
|
||||
- [ ] Upload service created
|
||||
- [ ] Upload middleware configured
|
||||
- [ ] File type validation implemented
|
||||
- [ ] File size validation implemented
|
||||
- [ ] Upload routes created
|
||||
- [ ] Error handling implemented
|
||||
- [ ] File deletion implemented
|
||||
- [ ] Tests written
|
||||
- [ ] Manual testing done
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Validate Before Upload**: Check file type and size before uploading
|
||||
2. **Unique Filenames**: Use UUID to prevent filename conflicts
|
||||
3. **Organized Storage**: Use folder structure (courses/1/lessons/5/video.mp4)
|
||||
4. **Error Handling**: Provide clear error messages
|
||||
5. **Cleanup**: Delete files from S3 when deleting records
|
||||
6. **Security**: Validate file content, not just extension
|
||||
7. **Progress**: Consider upload progress for large files
|
||||
|
||||
---
|
||||
|
||||
## Advanced: Video Processing
|
||||
|
||||
For video processing (transcoding, thumbnails):
|
||||
|
||||
```javascript
|
||||
const ffmpeg = require('fluent-ffmpeg');
|
||||
|
||||
async function processVideo(videoPath) {
|
||||
// Generate thumbnail
|
||||
await new Promise((resolve, reject) => {
|
||||
ffmpeg(videoPath)
|
||||
.screenshots({
|
||||
timestamps: ['00:00:01'],
|
||||
filename: 'thumbnail.jpg',
|
||||
folder: './thumbnails'
|
||||
})
|
||||
.on('end', resolve)
|
||||
.on('error', reject);
|
||||
});
|
||||
|
||||
// Get video duration
|
||||
const metadata = await new Promise((resolve, reject) => {
|
||||
ffmpeg.ffprobe(videoPath, (err, metadata) => {
|
||||
if (err) reject(err);
|
||||
else resolve(metadata);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
duration: metadata.format.duration,
|
||||
thumbnail: './thumbnails/thumbnail.jpg'
|
||||
};
|
||||
}
|
||||
```
|
||||
241
Backend/.agent/workflowss/setup.md
Normal file
241
Backend/.agent/workflowss/setup.md
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
---
|
||||
description: How to setup the backend development environment
|
||||
---
|
||||
|
||||
# Setup Development Environment
|
||||
|
||||
Follow these steps to setup the E-Learning Platform backend on your local machine.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+ and npm
|
||||
- Docker and Docker Compose
|
||||
- Git
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Clone Repository
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd e-learning/Backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Install Dependencies
|
||||
|
||||
// turbo
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Setup Environment Variables
|
||||
|
||||
Copy example env file:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env`:
|
||||
```bash
|
||||
# Application
|
||||
NODE_ENV=development
|
||||
PORT=4000
|
||||
APP_URL=http://localhost:4000
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://postgres:12345678@localhost:5432/elearning_dev
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://:dev_redis_password@localhost:6379
|
||||
|
||||
# MinIO/S3
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_ACCESS_KEY=admin
|
||||
S3_SECRET_KEY=12345678
|
||||
S3_BUCKET_COURSES=courses
|
||||
S3_BUCKET_VIDEOS=videos
|
||||
S3_BUCKET_DOCUMENTS=documents
|
||||
S3_BUCKET_IMAGES=images
|
||||
S3_BUCKET_ATTACHMENTS=attachments
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||
JWT_EXPIRES_IN=24h
|
||||
|
||||
# Email (Mailhog)
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=1025
|
||||
SMTP_FROM=noreply@elearning.local
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Start Docker Services
|
||||
|
||||
// turbo
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This starts:
|
||||
- PostgreSQL (port 5432)
|
||||
- Redis (port 6379)
|
||||
- MinIO (ports 9000, 9001)
|
||||
- Mailhog (ports 1025, 8025)
|
||||
- Adminer (port 8080)
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Run Database Migrations
|
||||
|
||||
// turbo
|
||||
```bash
|
||||
npx prisma migrate dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Seed Database
|
||||
|
||||
// turbo
|
||||
```bash
|
||||
npx prisma db seed
|
||||
```
|
||||
|
||||
This creates:
|
||||
- Default roles (Admin, Instructor, Student)
|
||||
- Test users
|
||||
- Sample categories
|
||||
- Sample courses
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Start Development Server
|
||||
|
||||
// turbo
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Server will start at http://localhost:4000
|
||||
|
||||
---
|
||||
|
||||
## Step 8: Verify Setup
|
||||
|
||||
// turbo
|
||||
Test health endpoint:
|
||||
```bash
|
||||
curl http://localhost:4000/health
|
||||
```
|
||||
|
||||
// turbo
|
||||
Test login:
|
||||
```bash
|
||||
curl -X POST http://localhost:4000/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "admin", "password": "admin123"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Access Services
|
||||
|
||||
| Service | URL | Credentials |
|
||||
|---------|-----|-------------|
|
||||
| **Backend API** | http://localhost:4000 | - |
|
||||
| **MinIO Console** | http://localhost:9001 | admin / 12345678 |
|
||||
| **Mailhog UI** | http://localhost:8025 | - |
|
||||
| **Adminer** | http://localhost:8080 | postgres / 12345678 |
|
||||
|
||||
---
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Start dev server
|
||||
npm run dev
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
|
||||
# Run linter
|
||||
npm run lint
|
||||
|
||||
# Format code
|
||||
npm run format
|
||||
|
||||
# Generate Prisma Client
|
||||
npx prisma generate
|
||||
|
||||
# View database in Prisma Studio
|
||||
npx prisma studio
|
||||
|
||||
# Reset database (WARNING: deletes all data)
|
||||
npx prisma migrate reset
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port Already in Use
|
||||
```bash
|
||||
# Find process using port
|
||||
lsof -i :4000
|
||||
|
||||
# Kill process
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
### Database Connection Error
|
||||
```bash
|
||||
# Check if PostgreSQL is running
|
||||
docker ps | grep postgres
|
||||
|
||||
# Check logs
|
||||
docker logs elearning-postgres
|
||||
|
||||
# Restart PostgreSQL
|
||||
docker restart elearning-postgres
|
||||
```
|
||||
|
||||
### Prisma Client Error
|
||||
```bash
|
||||
# Regenerate client
|
||||
npx prisma generate
|
||||
|
||||
# Clear node_modules
|
||||
rm -rf node_modules
|
||||
npm install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Node.js 18+ installed
|
||||
- [ ] Docker installed and running
|
||||
- [ ] Repository cloned
|
||||
- [ ] Dependencies installed
|
||||
- [ ] `.env` file configured
|
||||
- [ ] Docker services running
|
||||
- [ ] Database migrated
|
||||
- [ ] Database seeded
|
||||
- [ ] Dev server running
|
||||
- [ ] Health check passing
|
||||
- [ ] Login test successful
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Read [Backend Development Rules](../.agent/rules.md)
|
||||
- Follow [Create API Endpoint](./create-api-endpoint.md) workflow
|
||||
- Review [Agent Skills](../docs/agent_skills_backend.md)
|
||||
439
Backend/.agent/workflowss/testing.md
Normal file
439
Backend/.agent/workflowss/testing.md
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
---
|
||||
description: How to test backend APIs and services
|
||||
---
|
||||
|
||||
# Testing Workflow
|
||||
|
||||
Follow these steps to write and run tests for the E-Learning Platform backend.
|
||||
|
||||
---
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── unit/ # Unit tests (services, utilities)
|
||||
├── integration/ # Integration tests (API endpoints)
|
||||
├── fixtures/ # Test data
|
||||
└── setup.js # Test setup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Setup Test Environment
|
||||
|
||||
Create `tests/setup.js`:
|
||||
|
||||
```javascript
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
beforeAll(async () => {
|
||||
// Setup test database
|
||||
await prisma.$executeRaw`TRUNCATE TABLE "User" CASCADE`;
|
||||
|
||||
// Create test data
|
||||
await createTestUsers();
|
||||
await createTestCourses();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
async function createTestUsers() {
|
||||
// Create admin, instructor, student
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
await prisma.user.createMany({
|
||||
data: [
|
||||
{
|
||||
username: 'admin',
|
||||
email: 'admin@test.com',
|
||||
password: await bcrypt.hash('password123', 10),
|
||||
role_id: 1
|
||||
},
|
||||
{
|
||||
username: 'instructor1',
|
||||
email: 'instructor@test.com',
|
||||
password: await bcrypt.hash('password123', 10),
|
||||
role_id: 2
|
||||
},
|
||||
{
|
||||
username: 'student1',
|
||||
email: 'student@test.com',
|
||||
password: await bcrypt.hash('password123', 10),
|
||||
role_id: 3
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Write Unit Tests
|
||||
|
||||
Create `tests/unit/course.service.test.js`:
|
||||
|
||||
```javascript
|
||||
const courseService = require('../../src/services/course.service');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
describe('Course Service', () => {
|
||||
describe('createCourse', () => {
|
||||
it('should create course with valid data', async () => {
|
||||
const courseData = {
|
||||
title: { th: 'ทดสอบ', en: 'Test' },
|
||||
description: { th: 'รายละเอียด', en: 'Description' },
|
||||
category_id: 1,
|
||||
price: 990,
|
||||
is_free: false
|
||||
};
|
||||
|
||||
const course = await courseService.createCourse(courseData, 1);
|
||||
|
||||
expect(course).toHaveProperty('id');
|
||||
expect(course.status).toBe('DRAFT');
|
||||
expect(course.title.th).toBe('ทดสอบ');
|
||||
});
|
||||
|
||||
it('should throw error with invalid category', async () => {
|
||||
const courseData = {
|
||||
title: { th: 'ทดสอบ', en: 'Test' },
|
||||
category_id: 9999, // Invalid
|
||||
price: 990
|
||||
};
|
||||
|
||||
await expect(
|
||||
courseService.createCourse(courseData, 1)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkLessonAccess', () => {
|
||||
it('should allow access to unlocked lesson', async () => {
|
||||
const result = await courseService.checkLessonAccess(1, 1);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny access to locked lesson', async () => {
|
||||
const result = await courseService.checkLessonAccess(1, 5);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe('incomplete_prerequisites');
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Write Integration Tests
|
||||
|
||||
Create `tests/integration/courses.test.js`:
|
||||
|
||||
```javascript
|
||||
const request = require('supertest');
|
||||
const app = require('../../src/app');
|
||||
|
||||
describe('Course API', () => {
|
||||
let adminToken, instructorToken, studentToken;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Login as different users
|
||||
const adminRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'admin', password: 'password123' });
|
||||
adminToken = adminRes.body.token;
|
||||
|
||||
const instructorRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'instructor1', password: 'password123' });
|
||||
instructorToken = instructorRes.body.token;
|
||||
|
||||
const studentRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'student1', password: 'password123' });
|
||||
studentToken = studentRes.body.token;
|
||||
});
|
||||
|
||||
describe('POST /api/instructor/courses', () => {
|
||||
it('should create course as instructor', async () => {
|
||||
const courseData = {
|
||||
title: { th: 'คอร์สใหม่', en: 'New Course' },
|
||||
description: { th: 'รายละเอียด', en: 'Description' },
|
||||
category_id: 1,
|
||||
price: 990,
|
||||
is_free: false
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/instructor/courses')
|
||||
.set('Authorization', `Bearer ${instructorToken}`)
|
||||
.send(courseData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toHaveProperty('id');
|
||||
expect(response.body.status).toBe('DRAFT');
|
||||
});
|
||||
|
||||
it('should return 403 as student', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/instructor/courses')
|
||||
.set('Authorization', `Bearer ${studentToken}`)
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should return 401 without auth', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/instructor/courses')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/courses', () => {
|
||||
it('should list public courses', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/courses')
|
||||
.query({ page: 1, limit: 10 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('data');
|
||||
expect(response.body).toHaveProperty('pagination');
|
||||
expect(Array.isArray(response.body.data)).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter by category', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/courses')
|
||||
.query({ category: 1 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
response.body.data.forEach(course => {
|
||||
expect(course.category_id).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Test File Uploads
|
||||
|
||||
Create `tests/integration/file-upload.test.js`:
|
||||
|
||||
```javascript
|
||||
const request = require('supertest');
|
||||
const app = require('../../src/app');
|
||||
const path = require('path');
|
||||
|
||||
describe('File Upload API', () => {
|
||||
let instructorToken;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'instructor1', password: 'password123' });
|
||||
instructorToken = res.body.token;
|
||||
});
|
||||
|
||||
describe('POST /api/instructor/lessons/:lessonId/attachments', () => {
|
||||
it('should upload PDF attachment', async () => {
|
||||
const filePath = path.join(__dirname, '../fixtures/test.pdf');
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/instructor/courses/1/lessons/1/attachments')
|
||||
.set('Authorization', `Bearer ${instructorToken}`)
|
||||
.attach('file', filePath)
|
||||
.field('description_th', 'ไฟล์ทดสอบ')
|
||||
.field('description_en', 'Test file');
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toHaveProperty('file_name');
|
||||
expect(response.body.mime_type).toBe('application/pdf');
|
||||
});
|
||||
|
||||
it('should reject invalid file type', async () => {
|
||||
const filePath = path.join(__dirname, '../fixtures/test.exe');
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/instructor/courses/1/lessons/1/attachments')
|
||||
.set('Authorization', `Bearer ${instructorToken}`)
|
||||
.attach('file', filePath);
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(response.body.error.code).toBe('INVALID_FILE_TYPE');
|
||||
});
|
||||
|
||||
it('should reject file too large', async () => {
|
||||
// Mock large file
|
||||
const response = await request(app)
|
||||
.post('/api/instructor/courses/1/lessons/1/attachments')
|
||||
.set('Authorization', `Bearer ${instructorToken}`)
|
||||
.attach('file', Buffer.alloc(101 * 1024 * 1024)); // 101 MB
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(response.body.error.code).toBe('FILE_TOO_LARGE');
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Run Tests
|
||||
|
||||
// turbo
|
||||
Run all tests:
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
// turbo
|
||||
Run specific test file:
|
||||
```bash
|
||||
npm test -- courses.test.js
|
||||
```
|
||||
|
||||
// turbo
|
||||
Run with coverage:
|
||||
```bash
|
||||
npm test -- --coverage
|
||||
```
|
||||
|
||||
// turbo
|
||||
Run in watch mode:
|
||||
```bash
|
||||
npm test -- --watch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Configuration
|
||||
|
||||
Update `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"test": "jest --runInBand",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage"
|
||||
},
|
||||
"jest": {
|
||||
"testEnvironment": "node",
|
||||
"coveragePathIgnorePatterns": ["/node_modules/"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/tests/setup.js"],
|
||||
"testMatch": ["**/tests/**/*.test.js"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mocking
|
||||
|
||||
### Mock Prisma
|
||||
```javascript
|
||||
jest.mock('@prisma/client', () => {
|
||||
const mockPrisma = {
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn()
|
||||
},
|
||||
course: {
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn()
|
||||
}
|
||||
};
|
||||
return { PrismaClient: jest.fn(() => mockPrisma) };
|
||||
});
|
||||
```
|
||||
|
||||
### Mock S3/MinIO
|
||||
```javascript
|
||||
jest.mock('../src/services/s3.service', () => ({
|
||||
uploadFile: jest.fn().mockResolvedValue({
|
||||
url: 'https://s3.example.com/test.pdf',
|
||||
key: 'test.pdf'
|
||||
}),
|
||||
deleteFile: jest.fn().mockResolvedValue(true)
|
||||
}));
|
||||
```
|
||||
|
||||
### Mock Redis
|
||||
```javascript
|
||||
jest.mock('../src/services/redis.service', () => ({
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
del: jest.fn()
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Fixtures
|
||||
|
||||
Create `tests/fixtures/courses.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"validCourse": {
|
||||
"title": { "th": "คอร์สทดสอบ", "en": "Test Course" },
|
||||
"description": { "th": "รายละเอียด", "en": "Description" },
|
||||
"category_id": 1,
|
||||
"price": 990,
|
||||
"is_free": false
|
||||
},
|
||||
"invalidCourse": {
|
||||
"title": "Invalid"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use in tests:
|
||||
```javascript
|
||||
const fixtures = require('../fixtures/courses.json');
|
||||
|
||||
it('should create course', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/instructor/courses')
|
||||
.send(fixtures.validCourse);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Test setup configured
|
||||
- [ ] Unit tests written for services
|
||||
- [ ] Integration tests written for API endpoints
|
||||
- [ ] File upload tests included
|
||||
- [ ] Authentication/authorization tested
|
||||
- [ ] Error cases tested
|
||||
- [ ] All tests passing
|
||||
- [ ] Coverage > 80%
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Test Isolation**: Each test should be independent
|
||||
2. **Descriptive Names**: Use clear test descriptions
|
||||
3. **AAA Pattern**: Arrange, Act, Assert
|
||||
4. **Mock External Services**: Don't call real APIs in tests
|
||||
5. **Test Edge Cases**: Not just happy path
|
||||
6. **Clean Up**: Reset database state between tests
|
||||
7. **Fast Tests**: Keep tests fast (< 5 seconds total)
|
||||
Loading…
Add table
Add a link
Reference in a new issue