diff --git a/.gitignore b/.gitignore index 163ba712..45cb7bf0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ Learner Learner/* .env -.agent -.agent/* \ No newline at end of file diff --git a/Backend/.agent/rules/rules.md b/Backend/.agent/rules/rules.md new file mode 100644 index 00000000..efceeb98 --- /dev/null +++ b/Backend/.agent/rules/rules.md @@ -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} 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! diff --git a/Backend/.agent/workflowss/create-api-endpoint.md b/Backend/.agent/workflowss/create-api-endpoint.md new file mode 100644 index 00000000..36d7c420 --- /dev/null +++ b/Backend/.agent/workflowss/create-api-endpoint.md @@ -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 " \ + -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() } + }); +} +``` diff --git a/Backend/.agent/workflowss/database-migration.md b/Backend/.agent/workflowss/database-migration.md new file mode 100644 index 00000000..20ff5241 --- /dev/null +++ b/Backend/.agent/workflowss/database-migration.md @@ -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 diff --git a/Backend/.agent/workflowss/deployment.md b/Backend/.agent/workflowss/deployment.md new file mode 100644 index 00000000..ea9230b3 --- /dev/null +++ b/Backend/.agent/workflowss/deployment.md @@ -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 . +``` + +--- + +## 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= +S3_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= +JWT_EXPIRES_IN=24h + +# Email +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER= +SMTP_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 + +# 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 +``` diff --git a/Backend/.agent/workflowss/e-learning-backend.md b/Backend/.agent/workflowss/e-learning-backend.md new file mode 100644 index 00000000..cd82300c --- /dev/null +++ b/Backend/.agent/workflowss/e-learning-backend.md @@ -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 +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 +``` + +**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! 🚀** diff --git a/Backend/.agent/workflowss/file-upload.md b/Backend/.agent/workflowss/file-upload.md new file mode 100644 index 00000000..0121996f --- /dev/null +++ b/Backend/.agent/workflowss/file-upload.md @@ -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 " \ + -F "video=@/path/to/video.mp4" + +# Upload attachment +curl -X POST http://localhost:4000/api/upload/attachment \ + -H "Authorization: Bearer " \ + -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' + }; +} +``` diff --git a/Backend/.agent/workflowss/setup.md b/Backend/.agent/workflowss/setup.md new file mode 100644 index 00000000..a8ccfb5c --- /dev/null +++ b/Backend/.agent/workflowss/setup.md @@ -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 +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 +``` + +### 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) diff --git a/Backend/.agent/workflowss/testing.md b/Backend/.agent/workflowss/testing.md new file mode 100644 index 00000000..fc802d6a --- /dev/null +++ b/Backend/.agent/workflowss/testing.md @@ -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": ["/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)