diff --git a/.agent/workflows/create-api-endpoint.md b/.agent/workflows/create-api-endpoint.md new file mode 100644 index 00000000..82750c36 --- /dev/null +++ b/.agent/workflows/create-api-endpoint.md @@ -0,0 +1,629 @@ +--- +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 using TypeScript and TSOA. + +--- + +## Step 1: Define Route with TSOA Controller + +Create or update controller file in `src/controllers/`: + +```typescript +// src/controllers/course.controller.ts +import { Request, Response } from 'express'; +import { + Controller, + Get, + Post, + Put, + Delete, + Route, + Tags, + Security, + Body, + Path, + Query, + SuccessResponse +} from 'tsoa'; +import { courseService } from '../services/course.service'; +import { CreateCourseDto, UpdateCourseDto, CourseListQuery } from '../types/course.types'; + +@Route('api/courses') +@Tags('Courses') +export class CourseController extends Controller { + + /** + * Get list of courses + * @summary List all approved courses + */ + @Get('/') + @SuccessResponse(200, 'Success') + public async list( + @Query() page: number = 1, + @Query() limit: number = 20, + @Query() category?: number, + @Query() search?: string + ): Promise<{ + data: any[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; + }> { + const result = await courseService.list({ + page: parseInt(page.toString()), + limit: parseInt(limit.toString()), + category: category ? parseInt(category.toString()) : undefined, + search + }); + + return result; + } + + /** + * Get course by ID + * @summary Get course details + */ + @Get('{id}') + @SuccessResponse(200, 'Success') + public async getById(@Path() id: number): Promise { + const course = await courseService.getById(parseInt(id.toString())); + + if (!course) { + this.setStatus(404); + throw new Error('Course not found'); + } + + return course; + } + + /** + * Create a new course + * @summary Create course (Instructor/Admin only) + */ + @Post('/') + @Security('jwt', ['INSTRUCTOR', 'ADMIN']) + @SuccessResponse(201, 'Created') + public async create( + @Body() body: CreateCourseDto, + @Request() req: any + ): Promise { + const course = await courseService.create(body, req.user.id); + this.setStatus(201); + return course; + } + + /** + * Update course + * @summary Update course (Instructor/Admin only) + */ + @Put('{id}') + @Security('jwt', ['INSTRUCTOR', 'ADMIN']) + @SuccessResponse(200, 'Success') + public async update( + @Path() id: number, + @Body() body: UpdateCourseDto, + @Request() req: any + ): Promise { + // Check ownership + const course = await courseService.getById(parseInt(id.toString())); + if (!course) { + this.setStatus(404); + throw new Error('Course not found'); + } + + if (course.created_by !== req.user.id && req.user.role.code !== 'ADMIN') { + this.setStatus(403); + throw new Error('You do not have permission to update this course'); + } + + const updated = await courseService.update(parseInt(id.toString()), body); + return updated; + } +} +``` + +--- + +## Step 2: Create Type Definitions + +Create types in `src/types/`: + +```typescript +// src/types/course.types.ts + +/** + * Multi-language text structure + */ +export interface MultiLang { + th: string; + en: string; +} + +/** + * Create course DTO + */ +export interface CreateCourseDto { + /** Course title in Thai and English */ + title: MultiLang; + + /** Course description in Thai and English */ + description: MultiLang; + + /** Category ID */ + category_id: number; + + /** Course price */ + price: number; + + /** Is the course free? */ + is_free?: boolean; + + /** Does the course have a certificate? */ + have_certificate?: boolean; + + /** Course thumbnail URL */ + thumbnail?: string; +} + +/** + * Update course DTO + */ +export interface UpdateCourseDto { + title?: MultiLang; + description?: MultiLang; + category_id?: number; + price?: number; + is_free?: boolean; + have_certificate?: boolean; + thumbnail?: string; +} + +/** + * Course list query parameters + */ +export interface CourseListQuery { + page?: number; + limit?: number; + category?: number; + search?: string; +} +``` + +--- + +## Step 3: Create Validation Schemas + +Create validator in `src/validators/`: + +```typescript +// src/validators/course.validator.ts +import Joi from 'joi'; + +const multiLangSchema = Joi.object({ + th: Joi.string().required(), + en: Joi.string().required() +}); + +export 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() + }) +}; +``` + +--- + +## Step 4: Create Service + +Create service in `src/services/`: + +```typescript +// src/services/course.service.ts +import { PrismaClient } from '@prisma/client'; +import { CreateCourseDto, UpdateCourseDto, CourseListQuery } from '../types/course.types'; + +const prisma = new PrismaClient(); + +class CourseService { + async list({ page, limit, category, search }: CourseListQuery) { + const skip = ((page || 1) - 1) * (limit || 20); + + const where: any = { + 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 || 20, + 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: page || 1, + limit: limit || 20, + total, + totalPages: Math.ceil(total / (limit || 20)) + } + }; + } + + async getById(id: number) { + 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: CreateCourseDto, userId: number) { + 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: number, data: UpdateCourseDto) { + return prisma.course.update({ + where: { id }, + data, + include: { + category: true, + instructors: { + include: { user: { include: { profile: true } } } + } + } + }); + } +} + +export const courseService = new CourseService(); +``` + +--- + +## Step 5: Generate TSOA Routes and Swagger Docs + +// turbo +After creating the controller, generate routes and API documentation: + +```bash +# Generate TSOA routes and Swagger spec +npm run tsoa:gen + +# This will: +# 1. Generate routes in src/routes/tsoa-routes.ts +# 2. Generate swagger.json in public/swagger.json +# 3. Validate all TSOA decorators +``` + +--- + +## Step 6: Register Routes in App + +Update `src/app.ts`: + +```typescript +// src/app.ts +import express from 'express'; +import swaggerUi from 'swagger-ui-express'; +import { RegisterRoutes } from './routes/tsoa-routes'; + +const app = express(); + +// Middleware +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// Register TSOA routes +RegisterRoutes(app); + +// Swagger documentation +app.use('/api-docs', swaggerUi.serve, async (_req, res) => { + return res.send( + swaggerUi.generateHTML(await import('../public/swagger.json')) + ); +}); + +export default app; +``` + +--- + +## Step 7: Configure TSOA + +Create `tsoa.json` in project root: + +```json +{ + "entryFile": "src/app.ts", + "noImplicitAdditionalProperties": "throw-on-extras", + "controllerPathGlobs": ["src/controllers/**/*.controller.ts"], + "spec": { + "outputDirectory": "public", + "specVersion": 3, + "securityDefinitions": { + "jwt": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + }, + "routes": { + "routesDir": "src/routes", + "middleware": "express", + "authenticationModule": "./src/middleware/auth.ts" + } +} +``` + +--- + +## Step 8: Write Tests + +Create test file in `tests/integration/`: + +```typescript +// tests/integration/courses.test.ts +import request from 'supertest'; +import app from '../../src/app'; + +describe('Course API', () => { + let instructorToken: string; + + 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 9: Run Tests + +// turbo +```bash +npm test -- courses.test.ts +``` + +--- + +## Step 10: View API Documentation + +After generating TSOA routes, access Swagger UI: + +``` +http://localhost:4000/api-docs +``` + +--- + +## Checklist + +- [ ] Controller created with TSOA decorators +- [ ] Type definitions created +- [ ] Validator created with Joi +- [ ] Service created with business logic +- [ ] TSOA routes generated (`npm run tsoa:gen`) +- [ ] Routes registered in app.ts +- [ ] Tests written (unit + integration) +- [ ] All tests passing +- [ ] API documentation accessible at /api-docs +- [ ] Manual testing done + +--- + +## Best Practices + +1. **TSOA Decorators**: Use proper decorators (@Get, @Post, @Security, etc.) +2. **Type Safety**: Define interfaces for all DTOs +3. **Documentation**: Add JSDoc comments for Swagger descriptions +4. **Validation**: Validate input at controller level +5. **Authorization**: Use @Security decorator for protected routes +6. **Multi-Language**: Use JSON structure for user-facing text +7. **Pagination**: Always paginate list endpoints +8. **Testing**: Write tests before deploying +9. **Auto-Generate**: Always run `npm run tsoa:gen` after route changes + +--- + +## Common TSOA Patterns + +### File Upload Endpoint +```typescript +import { UploadedFile } from 'express-fileupload'; + +@Post('upload') +@Security('jwt', ['INSTRUCTOR', 'ADMIN']) +public async upload( + @UploadedFile() file: Express.Multer.File +): Promise<{ url: string }> { + const result = await uploadService.uploadFile(file); + return { url: result.url }; +} +``` + +### Ownership Check +```typescript +@Put('{id}') +@Security('jwt') +public async update( + @Path() id: number, + @Body() body: UpdateDto, + @Request() req: any +): Promise { + const resource = await service.getById(id); + if (!resource) { + this.setStatus(404); + throw new Error('Not found'); + } + + if (resource.user_id !== req.user.id && req.user.role.code !== 'ADMIN') { + this.setStatus(403); + throw new Error('Forbidden'); + } + + return await service.update(id, body); +} +``` + +### Soft Delete +```typescript +@Delete('{id}') +@Security('jwt') +public async delete(@Path() id: number): Promise { + await service.softDelete(id); + this.setStatus(204); +} +``` + +--- + +## Package.json Scripts + +Add these scripts to `package.json`: + +```json +{ + "scripts": { + "dev": "nodemon --exec ts-node src/server.ts", + "build": "npm run tsoa:gen && tsc", + "tsoa:gen": "tsoa spec-and-routes", + "start": "node dist/server.js", + "test": "jest" + } +} +``` diff --git a/.agent/workflows/daily-report-jakkrapartxd.md b/.agent/workflows/daily-report-jakkrapartxd.md new file mode 100644 index 00000000..9624b418 --- /dev/null +++ b/.agent/workflows/daily-report-jakkrapartxd.md @@ -0,0 +1,44 @@ +--- +description: เขียนรายงานการทำงานประจำวันของ jakkrapartXD (Write daily report for jakkrapartXD) +--- + +1. **Initial Context & Date**: + - ตรวจสอบวันที่ปัจจุบัน (Current Date) + - ดึงข้อมูล commits จาก GitHub ของวันนั้น (GitHub Commits Analysis) + - รวบรวมสิ่งที่ทำไปทั้งหมดใน Session นี้ (Completed Tasks, Code Changes, Artifacts Created) + +2. **Analyze Work for "Knowledge Gained" (องค์ความรู้ที่ได้รับ)**: + - พิจารณาจาก Code ที่เขียน, logic ที่แก้, หรือ library ที่ใช้ + - สรุปเป็นหัวข้อความรู้ (Key Takeaways) + - **รายละเอียด**: อธิบายว่าเรียนรู้อะไร หรือใช้อะไรแก้ปัญหา เชิง Technical ลึกๆ (เช่น การใช้ Prisma Transaction, การทำ Custom Decorator, Logic การคำนวณ) + +3. **Analyze Work for "Problems and Obstacles" (ปัญหาและอุปสรรค)**: + - พิจารณาจาก Error ที่เจอ (Lint errors, Compile errors, Runtime errors) + - ความยากของ Logic ที่ต้อง implement + - **รายละเอียด**: อธิบายว่าเจอปัญหาอะไร และแก้อย่างไร (Root Cause & Solution) + +4. **Generate Report Output**: + - สร้างเนื้อหาตาม Format ด้านล่างนี้เป๊ะๆ: + + ```markdown + # Daily Report: [YYYY-MM-DD] - jakkrapartXD + + ## องค์ความรู้ที่ได้รับ + * [หัวข้อสั้นๆ 1] + * [หัวข้อสั้นๆ 2] + + ### รายละเอียด + * [อธิบายรายละเอียดเชิงลึกและการนำไปใช้] + * [อธิบายรายละเอียดเชิงลึกและการนำไปใช้] + + ## ปัญหาและอุปสรรค + * [หัวข้อปัญหา 1] + * [หัวข้อปัญหา 2] + + ### รายละเอียด + * [สาเหตุของปัญหาและวิธีการแก้ไข] + * [สาเหตุของปัญหาและวิธีการแก้ไข] + ``` + +5. **Final Review**: + - ตรวจสอบว่ามีครบทุกหัวข้อ: (1) องค์ความรู้ (2) รายละเอียด (3) ปัญหาและอุปสรรค (4) รายละเอียด (5) วันที่ diff --git a/.agent/workflows/database-migration.md b/.agent/workflows/database-migration.md new file mode 100644 index 00000000..20ff5241 --- /dev/null +++ b/.agent/workflows/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/.agent/workflows/deployment.md b/.agent/workflows/deployment.md new file mode 100644 index 00000000..5363238b --- /dev/null +++ b/.agent/workflows/deployment.md @@ -0,0 +1,403 @@ +--- +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 +- 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 + +# 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/.agent/workflows/e-learning-backend.md b/.agent/workflows/e-learning-backend.md new file mode 100644 index 00000000..48c20f61 --- /dev/null +++ b/.agent/workflows/e-learning-backend.md @@ -0,0 +1,459 @@ +--- +description: Complete workflow for developing E-Learning Platform backend +--- + +# E-Learning Backend Development Workflow + +Complete guide for developing the E-Learning Platform backend using TypeScript and TSOA. + +--- + +## 🎯 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 + +# Install TypeScript and TSOA +npm install -D typescript @types/node @types/express ts-node nodemon +npm install tsoa swagger-ui-express + +# 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 TSOA controller with `@Route`, `@Post` decorators +2. Create TypeScript service with interfaces +3. Implement JWT middleware +4. Write tests + +See [Create API Endpoint Workflow](./create-api-endpoint.md) for detailed examples. + +### 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 Rate Limiting + +Use `express-rate-limit` middleware to limit requests (e.g., 100 requests per 15 minutes). + +--- + +## 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 + +Use `helmet()` for security headers and configure CORS with allowed origins. + +--- + +## Phase 7: Documentation + +### 7.1 API Documentation + +TSOA automatically generates Swagger/OpenAPI documentation: + +```typescript +import swaggerUi from 'swagger-ui-express'; + +app.use('/api-docs', swaggerUi.serve, async (_req, res) => { + return res.send( + swaggerUi.generateHTML(await import('../public/swagger.json')) + ); +}); +``` + +**Generate documentation:** +```bash +npm run tsoa:gen +``` + +Access at: `http://localhost:4000/api-docs` + +### 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 + +- **Database error**: Check `docker logs elearning-postgres`, run `npx prisma db pull` +- **Port in use**: Find with `lsof -i :4000`, kill with `kill -9 ` +- **Prisma error**: Run `npx prisma generate` or `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 (ts-node) +npm run build # Build TypeScript +npm run tsoa:gen # Generate TSOA routes & Swagger +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/.agent/workflows/file-upload.md b/.agent/workflows/file-upload.md new file mode 100644 index 00000000..5e5a7ee4 --- /dev/null +++ b/.agent/workflows/file-upload.md @@ -0,0 +1,546 @@ +--- +description: How to handle file uploads (videos, attachments) +--- + +# File Upload Workflow + +Follow these steps to implement file upload functionality for videos and attachments using TypeScript and TSOA. + +--- + +## Prerequisites + +- MinIO/S3 configured and running +- Dependencies installed: `npm install multer @aws-sdk/client-s3 @aws-sdk/lib-storage` +- TypeScript configured + +--- + +## Step 1: Configure S3/MinIO Client + +Create `src/config/s3.config.ts`: + +```typescript +import { S3Client } from '@aws-sdk/client-s3'; + +export 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 +}); +``` + +--- + +## Step 2: Create Upload Service + +Create `src/services/upload.service.ts`: + +```typescript +import { s3Client } from '../config/s3.config'; +import { Upload } from '@aws-sdk/lib-storage'; +import { DeleteObjectCommand } from '@aws-sdk/client-s3'; +import { v4 as uuidv4 } from 'uuid'; +import path from 'path'; + +export interface UploadResult { + key: string; + url: string; + fileName: string; + fileSize: number; + mimeType: string; +} + +type FolderType = 'videos' | 'documents' | 'images' | 'attachments'; + +class UploadService { + async uploadFile(file: Express.Multer.File, folder: FolderType): Promise { + 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: string, folder: FolderType): Promise { + const command = new DeleteObjectCommand({ + Bucket: this.getBucket(folder), + Key: key + }); + + await s3Client.send(command); + } + + private getBucket(folder: FolderType): string { + const bucketMap: Record = { + 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: Express.Multer.File, allowedTypes: string[]): boolean { + return allowedTypes.includes(file.mimetype); + } + + validateFileSize(file: Express.Multer.File, maxSize: number): boolean { + return file.size <= maxSize; + } +} + +export const uploadService = new UploadService(); +``` + +--- + +## Step 3: Create Upload Middleware + +Create `src/middleware/upload.middleware.ts`: + +```typescript +import multer from 'multer'; +import { Request } from 'express'; + +// File type validators +export const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime']; +export const ALLOWED_DOCUMENT_TYPES = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' +]; +export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif']; + +// File size limits +export const MAX_VIDEO_SIZE = 500 * 1024 * 1024; // 500 MB +export const MAX_ATTACHMENT_SIZE = 100 * 1024 * 1024; // 100 MB +export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB + +// Multer configuration +const storage = multer.memoryStorage(); + +const fileFilter = (allowedTypes: string[]) => ( + req: Request, + file: Express.Multer.File, + cb: multer.FileFilterCallback +) => { + if (allowedTypes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Invalid file type')); + } +}; + +// Upload configurations +export const uploadVideo = multer({ + storage, + limits: { fileSize: MAX_VIDEO_SIZE }, + fileFilter: fileFilter(ALLOWED_VIDEO_TYPES) +}).single('video'); + +export const uploadAttachment = multer({ + storage, + limits: { fileSize: MAX_ATTACHMENT_SIZE }, + fileFilter: fileFilter([...ALLOWED_DOCUMENT_TYPES, ...ALLOWED_IMAGE_TYPES]) +}).single('file'); + +export const uploadAttachments = multer({ + storage, + limits: { fileSize: MAX_ATTACHMENT_SIZE }, + fileFilter: fileFilter([...ALLOWED_DOCUMENT_TYPES, ...ALLOWED_IMAGE_TYPES]) +}).array('attachments', 10); // Max 10 files + +export const uploadImage = multer({ + storage, + limits: { fileSize: MAX_IMAGE_SIZE }, + fileFilter: fileFilter(ALLOWED_IMAGE_TYPES) +}).single('image'); +``` + +--- + +## Step 4: Create Upload Controller with TSOA + +Create `src/controllers/upload.controller.ts`: + +```typescript +import { Request } from 'express'; +import { Controller, Post, Route, Tags, Security, UploadedFile, SuccessResponse } from 'tsoa'; +import { uploadService } from '../services/upload.service'; + +interface UploadResponse { + video_url?: string; + file_size: number; + duration?: number | null; + file_name?: string; + file_path?: string; + mime_type?: string; + download_url?: string; +} + +@Route('api/upload') +@Tags('File Upload') +export class UploadController extends Controller { + + /** + * Upload video file + * @summary Upload video for lesson (Instructor/Admin only) + */ + @Post('video') + @Security('jwt', ['INSTRUCTOR', 'ADMIN']) + @SuccessResponse(200, 'Success') + public async uploadVideo( + @UploadedFile() file: Express.Multer.File + ): Promise { + if (!file) { + this.setStatus(400); + throw new Error('No video file provided'); + } + + const result = await uploadService.uploadFile(file, 'videos'); + + return { + video_url: result.url, + file_size: result.fileSize, + duration: null // Will be processed later + }; + } + + /** + * Upload attachment file + * @summary Upload attachment for lesson (Instructor/Admin only) + */ + @Post('attachment') + @Security('jwt', ['INSTRUCTOR', 'ADMIN']) + @SuccessResponse(200, 'Success') + public async uploadAttachment( + @UploadedFile() file: Express.Multer.File + ): Promise { + if (!file) { + this.setStatus(400); + throw new Error('No file provided'); + } + + const result = await uploadService.uploadFile(file, 'attachments'); + + return { + file_name: result.fileName, + file_path: result.key, + file_size: result.fileSize, + mime_type: result.mimeType, + download_url: result.url + }; + } +} +``` + +--- + +## Step 5: Generate TSOA Routes + +// turbo +After creating the upload controller, generate routes and API documentation: + +```bash +npm run tsoa:gen +``` + +--- + +## 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/.agent/workflows/k6-load-test.md b/.agent/workflows/k6-load-test.md new file mode 100644 index 00000000..48feb4f6 --- /dev/null +++ b/.agent/workflows/k6-load-test.md @@ -0,0 +1,411 @@ +--- +description: How to create k6 load tests for API endpoints +--- + +# K6 Load Test Workflow + +วิธีสร้าง k6 load test สำหรับ API endpoints ใน E-Learning Backend + +## Prerequisites + +1. ติดตั้ง k6: +```bash +# macOS +brew install k6 + +# หรือ download จาก https://k6.io/docs/getting-started/installation/ +``` + +2. สร้าง folder สำหรับ tests: +```bash +mkdir -p Backend/tests/k6 +``` + +## Step 1: ระบุ API Endpoints ที่ต้องการ Test + +ดู API endpoints จาก: +- `Backend/src/controllers/` - ดู Route decorators (@Route, @Get, @Post, etc.) +- `Backend/public/swagger.json` - ดู OpenAPI spec (ถ้ามี) + +### Available Controllers & Endpoints: + +| Controller | Base Route | Key Endpoints | +|------------|------------|---------------| +| AuthController | `/api/auth` | `POST /login`, `POST /register-learner`, `POST /register-instructor`, `POST /refresh` | +| CoursesStudentController | `/api/students` | `GET /courses`, `POST /courses/{id}/enroll`, `GET /courses/{id}/learn` | +| CoursesInstructorController | `/api/instructors/courses` | `GET /`, `GET /{id}`, `POST /`, `PUT /{id}` | +| CategoriesController | `/api/categories` | `GET /`, `GET /{id}` | +| UserController | `/api/users` | `GET /me`, `PUT /me` | +| CertificateController | `/api/certificates` | `GET /{id}` | + +## Step 2: สร้าง K6 Test Script + +สร้างไฟล์ใน `Backend/tests/k6/` ตาม template นี้: + +### Template: Basic Load Test + +```javascript +// Backend/tests/k6/.js +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; + +// Custom metrics +const errorRate = new Rate('errors'); +const responseTime = new Trend('response_time'); + +// Test configuration +export const options = { + // Ramp-up pattern + stages: [ + { duration: '30s', target: 10 }, // Ramp up to 10 users + { duration: '1m', target: 10 }, // Stay at 10 users + { duration: '30s', target: 50 }, // Ramp up to 50 users + { duration: '1m', target: 50 }, // Stay at 50 users + { duration: '30s', target: 0 }, // Ramp down + ], + thresholds: { + http_req_duration: ['p(95)<500'], // 95% of requests < 500ms + errors: ['rate<0.1'], // Error rate < 10% + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; + +// Setup: Get auth token (runs once per VU) +export function setup() { + const loginRes = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({ + email: 'test@example.com', + password: 'password123', + }), { + headers: { 'Content-Type': 'application/json' }, + }); + + const token = loginRes.json('data.token'); + return { token }; +} + +// Main test function +export default function (data) { + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${data.token}`, + }; + + // Test endpoint + const res = http.get(`${BASE_URL}/api/endpoint`, { headers }); + + // Record metrics + responseTime.add(res.timings.duration); + errorRate.add(res.status !== 200); + + // Assertions + check(res, { + 'status is 200': (r) => r.status === 200, + 'response time < 500ms': (r) => r.timings.duration < 500, + }); + + sleep(1); // Think time between requests +} +``` + +## Step 3: Test Scenarios ตามประเภท + +### 3.1 Authentication Load Test + +```javascript +// Backend/tests/k6/auth-load-test.js +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate } from 'k6/metrics'; + +const errorRate = new Rate('errors'); + +export const options = { + stages: [ + { duration: '30s', target: 20 }, + { duration: '1m', target: 20 }, + { duration: '30s', target: 0 }, + ], + thresholds: { + http_req_duration: ['p(95)<1000'], + errors: ['rate<0.05'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; + +export default function () { + // Test Login + const loginRes = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({ + email: `user${__VU}@test.com`, + password: 'password123', + }), { + headers: { 'Content-Type': 'application/json' }, + }); + + errorRate.add(loginRes.status !== 200); + + check(loginRes, { + 'login successful': (r) => r.status === 200, + 'has token': (r) => r.json('data.token') !== undefined, + }); + + sleep(1); +} +``` + +### 3.2 Course Browsing Load Test (Student) + +```javascript +// Backend/tests/k6/student-courses-load-test.js +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; + +const errorRate = new Rate('errors'); +const courseListTime = new Trend('course_list_time'); +const courseLearningTime = new Trend('course_learning_time'); + +export const options = { + stages: [ + { duration: '30s', target: 30 }, + { duration: '2m', target: 30 }, + { duration: '30s', target: 0 }, + ], + thresholds: { + http_req_duration: ['p(95)<800'], + errors: ['rate<0.1'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; + +export function setup() { + const loginRes = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({ + email: 'student@test.com', + password: 'password123', + }), { + headers: { 'Content-Type': 'application/json' }, + }); + return { token: loginRes.json('data.token') }; +} + +export default function (data) { + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${data.token}`, + }; + + group('List Enrolled Courses', () => { + const res = http.get(`${BASE_URL}/api/students/courses`, { headers }); + courseListTime.add(res.timings.duration); + errorRate.add(res.status !== 200); + check(res, { 'courses listed': (r) => r.status === 200 }); + }); + + sleep(2); + + group('Get Course Learning Page', () => { + const courseId = 1; // Use a valid course ID + const res = http.get(`${BASE_URL}/api/students/courses/${courseId}/learn`, { headers }); + courseLearningTime.add(res.timings.duration); + errorRate.add(res.status !== 200 && res.status !== 403); + check(res, { 'learning page loaded': (r) => r.status === 200 || r.status === 403 }); + }); + + sleep(1); +} +``` + +### 3.3 Instructor Course Management Load Test + +```javascript +// Backend/tests/k6/instructor-courses-load-test.js +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Rate } from 'k6/metrics'; + +const errorRate = new Rate('errors'); + +export const options = { + stages: [ + { duration: '30s', target: 10 }, + { duration: '1m', target: 10 }, + { duration: '30s', target: 0 }, + ], + thresholds: { + http_req_duration: ['p(95)<1000'], + errors: ['rate<0.1'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; + +export function setup() { + const loginRes = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({ + email: 'instructor@test.com', + password: 'password123', + }), { + headers: { 'Content-Type': 'application/json' }, + }); + return { token: loginRes.json('data.token') }; +} + +export default function (data) { + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${data.token}`, + }; + + group('List My Courses', () => { + const res = http.get(`${BASE_URL}/api/instructors/courses`, { headers }); + errorRate.add(res.status !== 200); + check(res, { 'courses listed': (r) => r.status === 200 }); + }); + + sleep(2); + + group('Get Course Detail', () => { + const courseId = 1; + const res = http.get(`${BASE_URL}/api/instructors/courses/${courseId}`, { headers }); + errorRate.add(res.status !== 200 && res.status !== 404); + check(res, { 'course detail loaded': (r) => r.status === 200 || r.status === 404 }); + }); + + sleep(1); +} +``` + +### 3.4 Mixed Scenario (Realistic Load) + +```javascript +// Backend/tests/k6/mixed-load-test.js +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Rate } from 'k6/metrics'; +import { randomIntBetween } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; + +const errorRate = new Rate('errors'); + +export const options = { + scenarios: { + students: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 50 }, + { duration: '2m', target: 50 }, + { duration: '30s', target: 0 }, + ], + exec: 'studentScenario', + }, + instructors: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 10 }, + { duration: '2m', target: 10 }, + { duration: '30s', target: 0 }, + ], + exec: 'instructorScenario', + }, + }, + thresholds: { + http_req_duration: ['p(95)<1000'], + errors: ['rate<0.1'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; + +function getToken(email, password) { + const res = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({ email, password }), { + headers: { 'Content-Type': 'application/json' }, + }); + return res.json('data.token'); +} + +export function studentScenario() { + const token = getToken('student@test.com', 'password123'); + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }; + + // Browse courses + const coursesRes = http.get(`${BASE_URL}/api/students/courses`, { headers }); + check(coursesRes, { 'student courses ok': (r) => r.status === 200 }); + + sleep(randomIntBetween(1, 3)); +} + +export function instructorScenario() { + const token = getToken('instructor@test.com', 'password123'); + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }; + + // List courses + const coursesRes = http.get(`${BASE_URL}/api/instructors/courses`, { headers }); + check(coursesRes, { 'instructor courses ok': (r) => r.status === 200 }); + + sleep(randomIntBetween(2, 5)); +} +``` + +## Step 4: รัน Load Test + +### Basic Run +```bash +# รันจาก Backend directory +k6 run tests/k6/.js +``` + +### With Environment Variables +```bash +# ระบุ BASE_URL +k6 run -e BASE_URL=http://localhost:3000 tests/k6/.js + +# ระบุ VUs และ Duration แบบ simple +k6 run --vus 10 --duration 30s tests/k6/.js +``` + +### Output to JSON +```bash +k6 run --out json=results.json tests/k6/.js +``` + +### Output to InfluxDB (for Grafana) +```bash +k6 run --out influxdb=http://localhost:8086/k6 tests/k6/.js +``` + +## Step 5: วิเคราะห์ผลลัพธ์ + +### Key Metrics to Watch: +- **http_req_duration**: Response time (p50, p90, p95, p99) +- **http_req_failed**: Failed request rate +- **http_reqs**: Requests per second (throughput) +- **vus**: Virtual users at any point +- **iterations**: Total completed iterations + +### Thresholds ที่แนะนำ: +```javascript +thresholds: { + http_req_duration: ['p(95)<500'], // 95% < 500ms + http_req_duration: ['p(99)<1000'], // 99% < 1s + http_req_failed: ['rate<0.01'], // < 1% errors + http_reqs: ['rate>100'], // > 100 req/s +} +``` + +## Tips & Best Practices + +1. **Test Data**: สร้าง test users ก่อนรัน load test +2. **Warm-up**: ใช้ ramp-up stages เพื่อไม่ให้ server shock +3. **Think Time**: ใส่ `sleep()` เพื่อจำลอง user behavior จริง +4. **Isolation**: รัน test บน environment แยก ไม่ใช่ production +5. **Baseline**: รัน test หลายรอบเพื่อหา baseline performance +6. **Monitor**: ดู server metrics (CPU, Memory, DB connections) ขณะรัน test diff --git a/.agent/workflows/s.md b/.agent/workflows/s.md new file mode 100644 index 00000000..fdc1622c --- /dev/null +++ b/.agent/workflows/s.md @@ -0,0 +1,4 @@ +--- +description: +--- + diff --git a/.agent/workflows/setup.md b/.agent/workflows/setup.md new file mode 100644 index 00000000..fbad002a --- /dev/null +++ b/.agent/workflows/setup.md @@ -0,0 +1,325 @@ +--- +description: How to setup the backend development environment +--- + +# Setup Development Environment + +Follow these steps to setup the E-Learning Platform backend with TypeScript and TSOA 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 +``` + +// turbo +Install TypeScript and TSOA: +```bash +npm install -D typescript @types/node @types/express ts-node nodemon +npm install tsoa swagger-ui-express +``` + +--- + +## Step 3: Initialize TypeScript + +// turbo +Create `tsconfig.json`: +```bash +npx tsc --init +``` + +Update `tsconfig.json`: +```json +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +--- + +## Step 4: Configure TSOA + +Create `tsoa.json`: +```json +{ + "entryFile": "src/app.ts", + "noImplicitAdditionalProperties": "throw-on-extras", + "controllerPathGlobs": ["src/controllers/**/*.controller.ts"], + "spec": { + "outputDirectory": "public", + "specVersion": 3, + "securityDefinitions": { + "jwt": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + }, + "routes": { + "routesDir": "src/routes", + "middleware": "express", + "authenticationModule": "./src/middleware/auth.ts" + } +} +``` + +--- + +## Step 5: 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 + +# 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) +- MinIO (ports 9000, 9001) +- Mailhog (ports 1025, 8025) +- Adminer (port 8080) + +--- + +## Step 6: Run Database Migrations + +// turbo +```bash +npx prisma migrate dev +``` + +--- + +## Step 7: Seed Database + +// turbo +```bash +npx prisma db seed +``` + +This creates: +- Default roles (Admin, Instructor, Student) +- Test users +- Sample categories +- Sample courses + +--- + +## Step 8: Generate TSOA Routes + +// turbo +```bash +npm run tsoa:gen +``` + +This generates: +- Routes in `src/routes/tsoa-routes.ts` +- Swagger spec in `public/swagger.json` + +--- + +## Step 9: Start Development Server + +// turbo +```bash +npm run dev +``` + +Server will start at http://localhost:4000 + +--- + +## Step 10: 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 | - | +| **API Docs (Swagger)** | http://localhost:4000/api-docs | - | +| **MinIO Console** | http://localhost:9001 | admin / 12345678 | +| **Mailhog UI** | http://localhost:8025 | - | +| **Adminer** | http://localhost:8080 | postgres / 12345678 | + +--- + +## Development Commands + +```bash +# Start dev server (TypeScript) +npm run dev + +# Build TypeScript +npm run build + +# Generate TSOA routes and Swagger +npm run tsoa:gen + +# 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/.agent/workflows/testing.md b/.agent/workflows/testing.md new file mode 100644 index 00000000..8556070f --- /dev/null +++ b/.agent/workflows/testing.md @@ -0,0 +1,437 @@ +--- +description: How to test backend APIs and services +--- + +# Testing Workflow + +Follow these steps to write and run tests for the E-Learning Platform backend using TypeScript and Jest. + +--- + +## 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.ts`: + +```typescript +import { PrismaClient } from '@prisma/client'; +import bcrypt from 'bcrypt'; + +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 + 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.ts`: + +```typescript +import { courseService } from '../../src/services/course.service'; +import { PrismaClient } from '@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.create(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.create(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.ts`: + +```typescript +import request from 'supertest'; +import app from '../../src/app'; + +describe('Course API', () => { + let adminToken: string; + let instructorToken: string; + let studentToken: string; + + 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: any) => { + expect(course.category_id).toBe(1); + }); + }); + }); +}); +``` + +--- + +## Step 4: Test File Uploads + +Create `tests/integration/file-upload.test.ts`: + +```typescript +import request from 'supertest'; +import app from '../../src/app'; +import path from 'path'; + +describe('File Upload API', () => { + let instructorToken: string; + + 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": { + "preset": "ts-jest", + "testEnvironment": "node", + "coveragePathIgnorePatterns": ["/node_modules/"], + "setupFilesAfterEnv": ["/tests/setup.ts"], + "testMatch": ["**/tests/**/*.test.ts"], + "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"] + } +} +``` + +--- + +## 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) +})); +``` + + + +--- + +## 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) diff --git a/Backend/src/controllers/RecommendedCoursesController.ts b/Backend/src/controllers/RecommendedCoursesController.ts new file mode 100644 index 00000000..a3a92bee --- /dev/null +++ b/Backend/src/controllers/RecommendedCoursesController.ts @@ -0,0 +1,75 @@ +import { Body, Get, Path, Put, Request, Response, Route, Security, SuccessResponse, Tags } from 'tsoa'; +import { ValidationError } from '../middleware/errorHandler'; +import { RecommendedCoursesService } from '../services/RecommendedCourses.service'; +import { + ListApprovedCoursesResponse, + GetCourseByIdResponse, + ToggleRecommendedRequest, + ToggleRecommendedResponse +} from '../types/RecommendedCourses.types'; + +@Route('api/admin/recommended-courses') +@Tags('Admin/RecommendedCourses') +export class RecommendedCoursesController { + + /** + * ดึงรายการคอร์สที่อนุมัติแล้วทั้งหมด (สำหรับจัดการคอร์สแนะนำ) + * List all approved courses (for managing recommendations) + */ + @Get() + @Security('jwt', ['admin']) + @SuccessResponse('200', 'Approved courses retrieved successfully') + @Response('401', 'Unauthorized') + @Response('403', 'Forbidden - Admin only') + public async listApprovedCourses(@Request() request: any): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) { + throw new ValidationError('No token provided'); + } + return await RecommendedCoursesService.listApprovedCourses(); + } + + /** + * ดึงรายละเอียดคอร์สตาม ID + * Get course by ID + * @param courseId - รหัสคอร์ส / Course ID + */ + @Get('{courseId}') + @Security('jwt', ['admin']) + @SuccessResponse('200', 'Course retrieved successfully') + @Response('400', 'Course is not approved') + @Response('401', 'Unauthorized') + @Response('403', 'Forbidden - Admin only') + @Response('404', 'Course not found') + public async getCourseById(@Request() request: any, @Path() courseId: number): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) { + throw new ValidationError('No token provided'); + } + return await RecommendedCoursesService.getCourseById(courseId); + } + + /** + * เปลี่ยนสถานะคอร์สแนะนำ + * Toggle course recommendation status + * @param courseId - รหัสคอร์ส / Course ID + */ + @Put('{courseId}/toggle') + @Security('jwt', ['admin']) + @SuccessResponse('200', 'Recommendation status updated successfully') + @Response('400', 'Only approved courses can be recommended') + @Response('401', 'Unauthorized') + @Response('403', 'Forbidden - Admin only') + @Response('404', 'Course not found') + public async toggleRecommended( + @Request() request: any, + @Path() courseId: number, + @Body() body: ToggleRecommendedRequest + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) { + throw new ValidationError('No token provided'); + } + return await RecommendedCoursesService.toggleRecommended(token, courseId, body.is_recommended); + } +} diff --git a/Backend/src/services/ChaptersLesson.service.ts b/Backend/src/services/ChaptersLesson.service.ts index d24ff9f3..02ce0a67 100644 --- a/Backend/src/services/ChaptersLesson.service.ts +++ b/Backend/src/services/ChaptersLesson.service.ts @@ -1482,7 +1482,7 @@ export class ChaptersLessonService { */ async updateQuiz(request: UpdateQuizInput): Promise { try { - const { token, course_id, lesson_id, title, description, passing_score, time_limit, shuffle_questions, shuffle_choices, show_answers_after_completion, is_skippable } = request; + const { token, course_id, lesson_id, title, description, passing_score, time_limit, shuffle_questions, shuffle_choices, show_answers_after_completion, is_skippable, allow_multiple_attempts } = request; const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; await CoursesInstructorService.validateCourseStatus(course_id); @@ -1513,6 +1513,7 @@ export class ChaptersLessonService { if (shuffle_choices !== undefined) updateData.shuffle_choices = shuffle_choices; if (show_answers_after_completion !== undefined) updateData.show_answers_after_completion = show_answers_after_completion; if (is_skippable !== undefined) updateData.is_skippable = is_skippable; + if (allow_multiple_attempts !== undefined) updateData.allow_multiple_attempts = allow_multiple_attempts; // Update the quiz const updatedQuiz = await prisma.quiz.update({ diff --git a/Backend/src/services/RecommendedCourses.service.ts b/Backend/src/services/RecommendedCourses.service.ts new file mode 100644 index 00000000..00c882bd --- /dev/null +++ b/Backend/src/services/RecommendedCourses.service.ts @@ -0,0 +1,235 @@ +import { prisma } from '../config/database'; +import { config } from '../config'; +import { logger } from '../config/logger'; +import { NotFoundError, ValidationError } from '../middleware/errorHandler'; +import jwt from 'jsonwebtoken'; +import { getPresignedUrl } from '../config/minio'; +import { + ListApprovedCoursesResponse, + GetCourseByIdResponse, + ToggleRecommendedResponse, + RecommendedCourseData +} from '../types/RecommendedCourses.types'; +import { auditService } from './audit.service'; +import { AuditAction } from '@prisma/client'; + +export class RecommendedCoursesService { + + /** + * List all approved courses (for admin to manage recommendations) + */ + static async listApprovedCourses(): Promise { + try { + const courses = await prisma.course.findMany({ + where: { status: 'APPROVED' }, + orderBy: [ + { is_recommended: 'desc' }, + { updated_at: 'desc' } + ], + include: { + category: { + select: { id: true, name: true } + }, + creator: { + select: { id: true, username: true, email: true } + }, + instructors: { + include: { + user: { + select: { id: true, username: true, email: true } + } + } + }, + chapters: { + include: { + lessons: true + } + } + } + }); + + const data = await Promise.all(courses.map(async (course) => { + let thumbnail_presigned_url: string | null = null; + if (course.thumbnail_url) { + try { + thumbnail_presigned_url = await getPresignedUrl(course.thumbnail_url, 3600); + } catch (err) { + logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`); + } + } + + return { + id: course.id, + title: course.title as { th: string; en: string }, + slug: course.slug, + description: course.description as { th: string; en: string }, + thumbnail_url: thumbnail_presigned_url, + price: Number(course.price), + is_free: course.is_free, + have_certificate: course.have_certificate, + is_recommended: course.is_recommended, + status: course.status, + created_at: course.created_at, + updated_at: course.updated_at, + creator: course.creator, + category: course.category ? { + id: course.category.id, + name: course.category.name as { th: string; en: string } + } : null, + instructors: course.instructors.map(i => ({ + user_id: i.user_id, + is_primary: i.is_primary, + user: i.user + })), + chapters_count: course.chapters.length, + lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0) + } as RecommendedCourseData; + })); + + return { + code: 200, + message: 'Approved courses retrieved successfully', + data, + total: data.length + }; + } catch (error) { + logger.error('Failed to list approved courses', { error }); + throw error; + } + } + + /** + * Get course by ID (for admin to view details) + */ + static async getCourseById(courseId: number): Promise { + try { + const course = await prisma.course.findUnique({ + where: { id: courseId }, + include: { + category: { + select: { id: true, name: true } + }, + creator: { + select: { id: true, username: true, email: true } + }, + instructors: { + include: { + user: { + select: { id: true, username: true, email: true } + } + } + }, + chapters: { + include: { + lessons: true + } + } + } + }); + + if (!course) { + throw new NotFoundError('Course not found'); + } + + if (course.status !== 'APPROVED') { + throw new ValidationError('Course is not approved'); + } + + // Generate presigned URL for thumbnail + let thumbnail_presigned_url: string | null = null; + if (course.thumbnail_url) { + try { + thumbnail_presigned_url = await getPresignedUrl(course.thumbnail_url, 3600); + } catch (err) { + logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`); + } + } + + const data: RecommendedCourseData = { + id: course.id, + title: course.title as { th: string; en: string }, + slug: course.slug, + description: course.description as { th: string; en: string }, + thumbnail_url: thumbnail_presigned_url, + price: Number(course.price), + is_free: course.is_free, + have_certificate: course.have_certificate, + is_recommended: course.is_recommended, + status: course.status, + created_at: course.created_at, + updated_at: course.updated_at, + creator: course.creator, + category: course.category ? { + id: course.category.id, + name: course.category.name as { th: string; en: string } + } : null, + instructors: course.instructors.map(i => ({ + user_id: i.user_id, + is_primary: i.is_primary, + user: i.user + })), + chapters_count: course.chapters.length, + lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0) + }; + + return { + code: 200, + message: 'Course retrieved successfully', + data + }; + } catch (error) { + logger.error('Failed to get course by ID', { error }); + throw error; + } + } + + /** + * Toggle course recommendation status + */ + static async toggleRecommended( + token: string, + courseId: number, + isRecommended: boolean + ): Promise { + try { + const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; + + const course = await prisma.course.findUnique({ where: { id: courseId } }); + if (!course) { + throw new NotFoundError('Course not found'); + } + + if (course.status !== 'APPROVED') { + throw new ValidationError('Only approved courses can be recommended'); + } + + const updatedCourse = await prisma.course.update({ + where: { id: courseId }, + data: { is_recommended: isRecommended } + }); + + // Audit log + await auditService.logSync({ + userId: decoded.id, + action: isRecommended ? AuditAction.UPDATE_COURSE : AuditAction.UPDATE_COURSE, + entityType: 'Course', + entityId: courseId, + oldValue: { is_recommended: course.is_recommended }, + newValue: { is_recommended: isRecommended }, + metadata: { action: 'toggle_recommended' } + }); + + return { + code: 200, + message: `Course ${isRecommended ? 'marked as recommended' : 'unmarked as recommended'} successfully`, + data: { + id: updatedCourse.id, + is_recommended: updatedCourse.is_recommended + } + }; + } catch (error) { + logger.error('Failed to toggle recommended status', { error }); + throw error; + } + } +} diff --git a/Backend/src/services/courses.service.ts b/Backend/src/services/courses.service.ts index af48c660..252744f8 100644 --- a/Backend/src/services/courses.service.ts +++ b/Backend/src/services/courses.service.ts @@ -9,16 +9,20 @@ import { getPresignedUrl } from '../config/minio'; export class CoursesService { async ListCourses(input: ListCoursesInput): Promise { try { - const { category_id, page = 1, limit = 10, random = false } = input; + const { category_id, is_recommended, page = 1, limit = 10, random = false } = input; const where: Prisma.CourseWhereInput = { - status: 'APPROVED', + status: 'APPROVED', }; if (category_id) { where.category_id = category_id; } + if (is_recommended !== undefined) { + where.is_recommended = is_recommended; + } + // Get total count for pagination const total = await prisma.course.count({ where }); const totalPages = Math.ceil(total / limit); @@ -28,13 +32,13 @@ export class CoursesService { if (random) { // Random mode: ดึงทั้งหมดแล้วสุ่ม const allCourses = await prisma.course.findMany({ where }); - + // Fisher-Yates shuffle for (let i = allCourses.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [allCourses[i], allCourses[j]] = [allCourses[j], allCourses[i]]; } - + // Apply pagination after shuffle const skip = (page - 1) * limit; courses = allCourses.slice(skip, skip + limit); diff --git a/Backend/src/types/ChaptersLesson.typs.ts b/Backend/src/types/ChaptersLesson.typs.ts index 44f8cc51..51a26e4b 100644 --- a/Backend/src/types/ChaptersLesson.typs.ts +++ b/Backend/src/types/ChaptersLesson.typs.ts @@ -36,6 +36,7 @@ export interface QuizData { shuffle_choices: boolean; show_answers_after_completion: boolean; is_skippable: boolean; + allow_multiple_attempts: boolean; created_at: Date; created_by: number; updated_at: Date | null; @@ -598,6 +599,7 @@ export interface UpdateQuizInput { shuffle_choices?: boolean; show_answers_after_completion?: boolean; is_skippable?: boolean; + allow_multiple_attempts?: boolean; } /** @@ -685,4 +687,5 @@ export interface UpdateQuizBody { shuffle_choices?: boolean; show_answers_after_completion?: boolean; is_skippable?: boolean; + allow_multiple_attempts?: boolean; } \ No newline at end of file diff --git a/Backend/src/types/RecommendedCourses.types.ts b/Backend/src/types/RecommendedCourses.types.ts new file mode 100644 index 00000000..dfff03ba --- /dev/null +++ b/Backend/src/types/RecommendedCourses.types.ts @@ -0,0 +1,70 @@ +import { MultiLanguageText } from './index'; + +// ============================================ +// Request Types +// ============================================ + +export interface ToggleRecommendedRequest { + is_recommended: boolean; +} + +// ============================================ +// Response Types +// ============================================ + +export interface RecommendedCourseData { + id: number; + title: MultiLanguageText; + slug: string; + description: MultiLanguageText; + thumbnail_url: string | null; + price: number; + is_free: boolean; + have_certificate: boolean; + is_recommended: boolean; + status: string; + created_at: Date; + updated_at: Date | null; + creator: { + id: number; + username: string; + email: string; + }; + category: { + id: number; + name: MultiLanguageText; + } | null; + instructors: Array<{ + user_id: number; + is_primary: boolean; + user: { + id: number; + username: string; + email: string; + }; + }>; + chapters_count: number; + lessons_count: number; +} + +export interface ListApprovedCoursesResponse { + code: number; + message: string; + data: RecommendedCourseData[]; + total: number; +} + +export interface GetCourseByIdResponse { + code: number; + message: string; + data: RecommendedCourseData; +} + +export interface ToggleRecommendedResponse { + code: number; + message: string; + data: { + id: number; + is_recommended: boolean; + }; +} diff --git a/Backend/src/types/courses.types.ts b/Backend/src/types/courses.types.ts index c343564c..42c83398 100644 --- a/Backend/src/types/courses.types.ts +++ b/Backend/src/types/courses.types.ts @@ -2,6 +2,7 @@ import { Course } from '@prisma/client'; export interface ListCoursesInput { category_id?: number; + is_recommended?: boolean; page?: number; limit?: number; random?: boolean;