feat: add recommended courses and quiz multiple attempts
- Add is_recommended field to Course model - Add allow_multiple_attempts field to Quiz model - Create RecommendedCoursesController for admin management - List all approved courses - Get course by ID - Toggle recommendation status - Add is_recommended filter to CoursesService.ListCourses - Add allow_multiple_attempts to quiz update and response types - Update ChaptersLessonService.updateQuiz to support allow_multiple_attempts
This commit is contained in:
parent
623f797763
commit
f7330a7b27
17 changed files with 3963 additions and 5 deletions
629
.agent/workflows/create-api-endpoint.md
Normal file
629
.agent/workflows/create-api-endpoint.md
Normal file
|
|
@ -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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
// 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<any> {
|
||||||
|
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<void> {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
44
.agent/workflows/daily-report-jakkrapartxd.md
Normal file
44
.agent/workflows/daily-report-jakkrapartxd.md
Normal file
|
|
@ -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) วันที่
|
||||||
311
.agent/workflows/database-migration.md
Normal file
311
.agent/workflows/database-migration.md
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
---
|
||||||
|
description: How to create and run database migrations
|
||||||
|
---
|
||||||
|
|
||||||
|
# Database Migration Workflow
|
||||||
|
|
||||||
|
Follow these steps to create and apply database migrations using Prisma.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1: Update Prisma Schema
|
||||||
|
|
||||||
|
Edit `prisma/schema.prisma`:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model Course {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
title Json // { th: "", en: "" }
|
||||||
|
description Json
|
||||||
|
price Decimal @db.Decimal(10, 2)
|
||||||
|
is_free Boolean @default(false)
|
||||||
|
have_certificate Boolean @default(false)
|
||||||
|
status CourseStatus @default(DRAFT)
|
||||||
|
thumbnail String?
|
||||||
|
|
||||||
|
category_id Int
|
||||||
|
category Category @relation(fields: [category_id], references: [id])
|
||||||
|
|
||||||
|
created_by Int
|
||||||
|
creator User @relation(fields: [created_by], references: [id])
|
||||||
|
|
||||||
|
chapters Chapter[]
|
||||||
|
instructors CourseInstructor[]
|
||||||
|
enrollments Enrollment[]
|
||||||
|
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
deleted_at DateTime?
|
||||||
|
is_deleted Boolean @default(false)
|
||||||
|
|
||||||
|
@@index([category_id])
|
||||||
|
@@index([status])
|
||||||
|
@@index([is_deleted])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2: Create Migration
|
||||||
|
|
||||||
|
// turbo
|
||||||
|
Run migration command:
|
||||||
|
```bash
|
||||||
|
npx prisma migrate dev --name add_course_model
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Generate SQL migration file
|
||||||
|
2. Apply migration to database
|
||||||
|
3. Regenerate Prisma Client
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3: Review Migration File
|
||||||
|
|
||||||
|
Check generated SQL in `prisma/migrations/`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Course" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"title" JSONB NOT NULL,
|
||||||
|
"description" JSONB NOT NULL,
|
||||||
|
"price" DECIMAL(10,2) NOT NULL,
|
||||||
|
"is_free" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
...
|
||||||
|
CONSTRAINT "Course_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Course_category_id_idx" ON "Course"("category_id");
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4: Update Prisma Client
|
||||||
|
|
||||||
|
// turbo
|
||||||
|
Regenerate client if needed:
|
||||||
|
```bash
|
||||||
|
npx prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5: Create Seed Data (Optional)
|
||||||
|
|
||||||
|
Update `prisma/seed.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Create categories
|
||||||
|
const category = await prisma.category.create({
|
||||||
|
data: {
|
||||||
|
name: { th: 'การเขียนโปรแกรม', en: 'Programming' },
|
||||||
|
description: { th: 'หลักสูตรด้านการเขียนโปรแกรม', en: 'Programming courses' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create test user
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: 'instructor1',
|
||||||
|
email: 'instructor@example.com',
|
||||||
|
password: '$2b$10$...', // hashed password
|
||||||
|
role_id: 2 // INSTRUCTOR
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create test course
|
||||||
|
const course = await prisma.course.create({
|
||||||
|
data: {
|
||||||
|
title: { th: 'Python สำหรับผู้เริ่มต้น', en: 'Python for Beginners' },
|
||||||
|
description: { th: 'เรียนรู้ Python', en: 'Learn Python' },
|
||||||
|
price: 990,
|
||||||
|
is_free: false,
|
||||||
|
category_id: category.id,
|
||||||
|
created_by: user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Seed data created:', { category, user, course });
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 6: Run Seed
|
||||||
|
|
||||||
|
// turbo
|
||||||
|
Seed the database:
|
||||||
|
```bash
|
||||||
|
npx prisma db seed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Migration Tasks
|
||||||
|
|
||||||
|
### Add New Field
|
||||||
|
```prisma
|
||||||
|
model Course {
|
||||||
|
// ... existing fields
|
||||||
|
slug String @unique // New field
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
// turbo
|
||||||
|
```bash
|
||||||
|
npx prisma migrate dev --name add_course_slug
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add Index
|
||||||
|
```prisma
|
||||||
|
model Course {
|
||||||
|
// ... existing fields
|
||||||
|
|
||||||
|
@@index([created_at]) // New index
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add Relation
|
||||||
|
```prisma
|
||||||
|
model Lesson {
|
||||||
|
// ... existing fields
|
||||||
|
|
||||||
|
quiz_id Int?
|
||||||
|
quiz Quiz? @relation(fields: [quiz_id], references: [id])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rename Field
|
||||||
|
```prisma
|
||||||
|
model Course {
|
||||||
|
// Old: instructor_id
|
||||||
|
created_by Int // New name
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Prisma will detect rename and ask for confirmation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Migration
|
||||||
|
|
||||||
|
### Step 1: Generate Migration (Dev)
|
||||||
|
```bash
|
||||||
|
npx prisma migrate dev --name migration_name
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Commit Migration Files
|
||||||
|
```bash
|
||||||
|
git add prisma/migrations/
|
||||||
|
git commit -m "Add migration: migration_name"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Deploy to Production
|
||||||
|
```bash
|
||||||
|
npx prisma migrate deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important**: Never use `migrate dev` in production!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback Migration
|
||||||
|
|
||||||
|
### Option 1: Manual Rollback
|
||||||
|
```bash
|
||||||
|
# Find migration to rollback
|
||||||
|
ls prisma/migrations/
|
||||||
|
|
||||||
|
# Manually run the down migration (if exists)
|
||||||
|
psql $DATABASE_URL < prisma/migrations/XXXXXX_migration_name/down.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Reset Database (Dev Only)
|
||||||
|
```bash
|
||||||
|
npx prisma migrate reset
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warning**: This will delete all data!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Migration Failed
|
||||||
|
```bash
|
||||||
|
# Check database status
|
||||||
|
npx prisma migrate status
|
||||||
|
|
||||||
|
# Resolve migration
|
||||||
|
npx prisma migrate resolve --applied MIGRATION_NAME
|
||||||
|
# or
|
||||||
|
npx prisma migrate resolve --rolled-back MIGRATION_NAME
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schema Drift
|
||||||
|
```bash
|
||||||
|
# Check for drift
|
||||||
|
npx prisma migrate diff
|
||||||
|
|
||||||
|
# Reset and reapply
|
||||||
|
npx prisma migrate reset
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate Client Error
|
||||||
|
```bash
|
||||||
|
# Clear node_modules and reinstall
|
||||||
|
rm -rf node_modules
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Regenerate client
|
||||||
|
npx prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Schema updated in `prisma/schema.prisma`
|
||||||
|
- [ ] Migration created with descriptive name
|
||||||
|
- [ ] Migration SQL reviewed
|
||||||
|
- [ ] Migration applied successfully
|
||||||
|
- [ ] Prisma Client regenerated
|
||||||
|
- [ ] Seed data updated (if needed)
|
||||||
|
- [ ] Tests updated for new schema
|
||||||
|
- [ ] Migration files committed to git
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Descriptive Names**: Use clear migration names
|
||||||
|
- ✅ `add_course_certificate_field`
|
||||||
|
- ❌ `update_schema`
|
||||||
|
|
||||||
|
2. **Small Migrations**: One logical change per migration
|
||||||
|
|
||||||
|
3. **Review SQL**: Always check generated SQL before applying
|
||||||
|
|
||||||
|
4. **Test First**: Test migrations on dev database first
|
||||||
|
|
||||||
|
5. **Backup**: Backup production database before migrating
|
||||||
|
|
||||||
|
6. **Indexes**: Add indexes for frequently queried fields
|
||||||
|
|
||||||
|
7. **Constraints**: Use database constraints for data integrity
|
||||||
403
.agent/workflows/deployment.md
Normal file
403
.agent/workflows/deployment.md
Normal file
|
|
@ -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 <repository-url> .
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3: Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci --production
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4: Setup Environment Variables
|
||||||
|
|
||||||
|
Create `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Application
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=4000
|
||||||
|
APP_URL=https://api.elearning.com
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=postgresql://user:password@db-host:5432/elearning_prod
|
||||||
|
|
||||||
|
# MinIO/S3
|
||||||
|
S3_ENDPOINT=https://s3.elearning.com
|
||||||
|
S3_ACCESS_KEY=<access-key>
|
||||||
|
S3_SECRET_KEY=<secret-key>
|
||||||
|
S3_BUCKET_COURSES=courses
|
||||||
|
S3_BUCKET_VIDEOS=videos
|
||||||
|
S3_BUCKET_DOCUMENTS=documents
|
||||||
|
S3_BUCKET_IMAGES=images
|
||||||
|
S3_BUCKET_ATTACHMENTS=attachments
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=<strong-random-secret>
|
||||||
|
JWT_EXPIRES_IN=24h
|
||||||
|
|
||||||
|
# Email
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=<email>
|
||||||
|
SMTP_PASSWORD=<password>
|
||||||
|
SMTP_FROM=noreply@elearning.com
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGIN=https://elearning.com,https://admin.elearning.com
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5: Run Database Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma migrate deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 6: Setup PM2
|
||||||
|
|
||||||
|
Create `ecosystem.config.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
apps: [{
|
||||||
|
name: 'elearning-backend',
|
||||||
|
script: './src/server.js',
|
||||||
|
instances: 'max',
|
||||||
|
exec_mode: 'cluster',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production'
|
||||||
|
},
|
||||||
|
error_file: './logs/err.log',
|
||||||
|
out_file: './logs/out.log',
|
||||||
|
log_file: './logs/combined.log',
|
||||||
|
time: true
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Start application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 start ecosystem.config.js
|
||||||
|
pm2 save
|
||||||
|
pm2 startup
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 7: Configure Nginx
|
||||||
|
|
||||||
|
Create `/etc/nginx/sites-available/elearning-backend`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
upstream backend {
|
||||||
|
server localhost:4000;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name api.elearning.com;
|
||||||
|
|
||||||
|
# Redirect to HTTPS
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name api.elearning.com;
|
||||||
|
|
||||||
|
# SSL Configuration
|
||||||
|
ssl_certificate /etc/letsencrypt/live/api.elearning.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/api.elearning.com/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
# Security Headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
access_log /var/log/nginx/elearning-backend-access.log;
|
||||||
|
error_log /var/log/nginx/elearning-backend-error.log;
|
||||||
|
|
||||||
|
# File Upload Size
|
||||||
|
client_max_body_size 500M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
|
||||||
|
# Timeouts
|
||||||
|
proxy_connect_timeout 600s;
|
||||||
|
proxy_send_timeout 600s;
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable site:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/elearning-backend /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl restart nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 8: Setup SSL Certificate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo certbot --nginx -d api.elearning.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 9: Setup Monitoring
|
||||||
|
|
||||||
|
### PM2 Monitoring
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 install pm2-logrotate
|
||||||
|
pm2 set pm2-logrotate:max_size 10M
|
||||||
|
pm2 set pm2-logrotate:retain 30
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Check Endpoint
|
||||||
|
|
||||||
|
Add to your app:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.status(200).json({
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 10: Setup Backup
|
||||||
|
|
||||||
|
Create backup script `/opt/scripts/backup-db.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_DIR="/var/backups/elearning"
|
||||||
|
DB_NAME="elearning_prod"
|
||||||
|
|
||||||
|
mkdir -p $BACKUP_DIR
|
||||||
|
|
||||||
|
# Backup database
|
||||||
|
pg_dump $DB_NAME | gzip > $BACKUP_DIR/db_$DATE.sql.gz
|
||||||
|
|
||||||
|
# Keep only last 7 days
|
||||||
|
find $BACKUP_DIR -name "db_*.sql.gz" -mtime +7 -delete
|
||||||
|
|
||||||
|
echo "Backup completed: db_$DATE.sql.gz"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to crontab:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Daily backup at 2 AM
|
||||||
|
0 2 * * * /opt/scripts/backup-db.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 11: Verify Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check PM2 status
|
||||||
|
pm2 status
|
||||||
|
|
||||||
|
# Check logs
|
||||||
|
pm2 logs elearning-backend --lines 50
|
||||||
|
|
||||||
|
# Test health endpoint
|
||||||
|
curl https://api.elearning.com/health
|
||||||
|
|
||||||
|
# Test API
|
||||||
|
curl https://api.elearning.com/api/courses
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Update Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull latest code
|
||||||
|
cd /var/www/elearning-backend
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm ci --production
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
# Restart application
|
||||||
|
pm2 restart elearning-backend
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
pm2 status
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Revert to previous commit
|
||||||
|
git reset --hard HEAD~1
|
||||||
|
|
||||||
|
# Reinstall dependencies
|
||||||
|
npm ci --production
|
||||||
|
|
||||||
|
# Rollback migration (if needed)
|
||||||
|
npx prisma migrate resolve --rolled-back <migration-name>
|
||||||
|
|
||||||
|
# Restart
|
||||||
|
pm2 restart elearning-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Production server setup
|
||||||
|
- [ ] Repository cloned
|
||||||
|
- [ ] Dependencies installed
|
||||||
|
- [ ] Environment variables configured
|
||||||
|
- [ ] Database migrated
|
||||||
|
- [ ] PM2 configured and running
|
||||||
|
- [ ] Nginx configured
|
||||||
|
- [ ] SSL certificate installed
|
||||||
|
- [ ] Monitoring setup
|
||||||
|
- [ ] Backup script configured
|
||||||
|
- [ ] Health check working
|
||||||
|
- [ ] API endpoints tested
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Checklist
|
||||||
|
|
||||||
|
- [ ] Strong JWT secret
|
||||||
|
- [ ] Database credentials secured
|
||||||
|
- [ ] CORS configured correctly
|
||||||
|
- [ ] Rate limiting enabled
|
||||||
|
- [ ] SSL/TLS enabled
|
||||||
|
- [ ] Security headers configured
|
||||||
|
- [ ] File upload limits set
|
||||||
|
- [ ] Environment variables not in git
|
||||||
|
- [ ] Firewall configured
|
||||||
|
- [ ] SSH key-based authentication
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### PM2 Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View logs
|
||||||
|
pm2 logs
|
||||||
|
|
||||||
|
# Monitor resources
|
||||||
|
pm2 monit
|
||||||
|
|
||||||
|
# Restart app
|
||||||
|
pm2 restart elearning-backend
|
||||||
|
|
||||||
|
# Stop app
|
||||||
|
pm2 stop elearning-backend
|
||||||
|
|
||||||
|
# Delete app
|
||||||
|
pm2 delete elearning-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check System Resources
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CPU and Memory
|
||||||
|
htop
|
||||||
|
|
||||||
|
# Disk usage
|
||||||
|
df -h
|
||||||
|
|
||||||
|
# Network connections
|
||||||
|
netstat -tulpn
|
||||||
|
```
|
||||||
459
.agent/workflows/e-learning-backend.md
Normal file
459
.agent/workflows/e-learning-backend.md
Normal file
|
|
@ -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 <repository-url>
|
||||||
|
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 <PID>`
|
||||||
|
- **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! 🚀**
|
||||||
546
.agent/workflows/file-upload.md
Normal file
546
.agent/workflows/file-upload.md
Normal file
|
|
@ -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<UploadResult> {
|
||||||
|
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<void> {
|
||||||
|
const command = new DeleteObjectCommand({
|
||||||
|
Bucket: this.getBucket(folder),
|
||||||
|
Key: key
|
||||||
|
});
|
||||||
|
|
||||||
|
await s3Client.send(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBucket(folder: FolderType): string {
|
||||||
|
const bucketMap: Record<FolderType, string> = {
|
||||||
|
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<UploadResponse> {
|
||||||
|
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<UploadResponse> {
|
||||||
|
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 <token>" \
|
||||||
|
-F "video=@/path/to/video.mp4"
|
||||||
|
|
||||||
|
# Upload attachment
|
||||||
|
curl -X POST http://localhost:4000/api/upload/attachment \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-F "file=@/path/to/document.pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Common Errors
|
||||||
|
|
||||||
|
1. **File Too Large**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "FILE_TOO_LARGE",
|
||||||
|
"message": "File size exceeds maximum limit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Invalid File Type**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "INVALID_FILE_TYPE",
|
||||||
|
"message": "File type not allowed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **S3 Upload Failed**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "UPLOAD_FAILED",
|
||||||
|
"message": "Failed to upload file to storage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] S3/MinIO client configured
|
||||||
|
- [ ] Upload service created
|
||||||
|
- [ ] Upload middleware configured
|
||||||
|
- [ ] File type validation implemented
|
||||||
|
- [ ] File size validation implemented
|
||||||
|
- [ ] Upload routes created
|
||||||
|
- [ ] Error handling implemented
|
||||||
|
- [ ] File deletion implemented
|
||||||
|
- [ ] Tests written
|
||||||
|
- [ ] Manual testing done
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Validate Before Upload**: Check file type and size before uploading
|
||||||
|
2. **Unique Filenames**: Use UUID to prevent filename conflicts
|
||||||
|
3. **Organized Storage**: Use folder structure (courses/1/lessons/5/video.mp4)
|
||||||
|
4. **Error Handling**: Provide clear error messages
|
||||||
|
5. **Cleanup**: Delete files from S3 when deleting records
|
||||||
|
6. **Security**: Validate file content, not just extension
|
||||||
|
7. **Progress**: Consider upload progress for large files
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Advanced: Video Processing
|
||||||
|
|
||||||
|
For video processing (transcoding, thumbnails):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const ffmpeg = require('fluent-ffmpeg');
|
||||||
|
|
||||||
|
async function processVideo(videoPath) {
|
||||||
|
// Generate thumbnail
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
ffmpeg(videoPath)
|
||||||
|
.screenshots({
|
||||||
|
timestamps: ['00:00:01'],
|
||||||
|
filename: 'thumbnail.jpg',
|
||||||
|
folder: './thumbnails'
|
||||||
|
})
|
||||||
|
.on('end', resolve)
|
||||||
|
.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get video duration
|
||||||
|
const metadata = await new Promise((resolve, reject) => {
|
||||||
|
ffmpeg.ffprobe(videoPath, (err, metadata) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(metadata);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
duration: metadata.format.duration,
|
||||||
|
thumbnail: './thumbnails/thumbnail.jpg'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
411
.agent/workflows/k6-load-test.md
Normal file
411
.agent/workflows/k6-load-test.md
Normal file
|
|
@ -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/<test-name>.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/<test-name>.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Environment Variables
|
||||||
|
```bash
|
||||||
|
# ระบุ BASE_URL
|
||||||
|
k6 run -e BASE_URL=http://localhost:3000 tests/k6/<test-name>.js
|
||||||
|
|
||||||
|
# ระบุ VUs และ Duration แบบ simple
|
||||||
|
k6 run --vus 10 --duration 30s tests/k6/<test-name>.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output to JSON
|
||||||
|
```bash
|
||||||
|
k6 run --out json=results.json tests/k6/<test-name>.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output to InfluxDB (for Grafana)
|
||||||
|
```bash
|
||||||
|
k6 run --out influxdb=http://localhost:8086/k6 tests/k6/<test-name>.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
|
||||||
4
.agent/workflows/s.md
Normal file
4
.agent/workflows/s.md
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
---
|
||||||
|
description:
|
||||||
|
---
|
||||||
|
|
||||||
325
.agent/workflows/setup.md
Normal file
325
.agent/workflows/setup.md
Normal file
|
|
@ -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 <repository-url>
|
||||||
|
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 <PID>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Connection Error
|
||||||
|
```bash
|
||||||
|
# Check if PostgreSQL is running
|
||||||
|
docker ps | grep postgres
|
||||||
|
|
||||||
|
# Check logs
|
||||||
|
docker logs elearning-postgres
|
||||||
|
|
||||||
|
# Restart PostgreSQL
|
||||||
|
docker restart elearning-postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prisma Client Error
|
||||||
|
```bash
|
||||||
|
# Regenerate client
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
# Clear node_modules
|
||||||
|
rm -rf node_modules
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Node.js 18+ installed
|
||||||
|
- [ ] Docker installed and running
|
||||||
|
- [ ] Repository cloned
|
||||||
|
- [ ] Dependencies installed
|
||||||
|
- [ ] `.env` file configured
|
||||||
|
- [ ] Docker services running
|
||||||
|
- [ ] Database migrated
|
||||||
|
- [ ] Database seeded
|
||||||
|
- [ ] Dev server running
|
||||||
|
- [ ] Health check passing
|
||||||
|
- [ ] Login test successful
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- Read [Backend Development Rules](../.agent/rules.md)
|
||||||
|
- Follow [Create API Endpoint](./create-api-endpoint.md) workflow
|
||||||
|
- Review [Agent Skills](../docs/agent_skills_backend.md)
|
||||||
437
.agent/workflows/testing.md
Normal file
437
.agent/workflows/testing.md
Normal file
|
|
@ -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": ["<rootDir>/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)
|
||||||
75
Backend/src/controllers/RecommendedCoursesController.ts
Normal file
75
Backend/src/controllers/RecommendedCoursesController.ts
Normal file
|
|
@ -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<ListApprovedCoursesResponse> {
|
||||||
|
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<GetCourseByIdResponse> {
|
||||||
|
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<ToggleRecommendedResponse> {
|
||||||
|
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||||
|
if (!token) {
|
||||||
|
throw new ValidationError('No token provided');
|
||||||
|
}
|
||||||
|
return await RecommendedCoursesService.toggleRecommended(token, courseId, body.is_recommended);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1482,7 +1482,7 @@ export class ChaptersLessonService {
|
||||||
*/
|
*/
|
||||||
async updateQuiz(request: UpdateQuizInput): Promise<UpdateQuizResponse> {
|
async updateQuiz(request: UpdateQuizInput): Promise<UpdateQuizResponse> {
|
||||||
try {
|
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 };
|
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
|
||||||
await CoursesInstructorService.validateCourseStatus(course_id);
|
await CoursesInstructorService.validateCourseStatus(course_id);
|
||||||
|
|
||||||
|
|
@ -1513,6 +1513,7 @@ export class ChaptersLessonService {
|
||||||
if (shuffle_choices !== undefined) updateData.shuffle_choices = shuffle_choices;
|
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 (show_answers_after_completion !== undefined) updateData.show_answers_after_completion = show_answers_after_completion;
|
||||||
if (is_skippable !== undefined) updateData.is_skippable = is_skippable;
|
if (is_skippable !== undefined) updateData.is_skippable = is_skippable;
|
||||||
|
if (allow_multiple_attempts !== undefined) updateData.allow_multiple_attempts = allow_multiple_attempts;
|
||||||
|
|
||||||
// Update the quiz
|
// Update the quiz
|
||||||
const updatedQuiz = await prisma.quiz.update({
|
const updatedQuiz = await prisma.quiz.update({
|
||||||
|
|
|
||||||
235
Backend/src/services/RecommendedCourses.service.ts
Normal file
235
Backend/src/services/RecommendedCourses.service.ts
Normal file
|
|
@ -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<ListApprovedCoursesResponse> {
|
||||||
|
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<GetCourseByIdResponse> {
|
||||||
|
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<ToggleRecommendedResponse> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,16 +9,20 @@ import { getPresignedUrl } from '../config/minio';
|
||||||
export class CoursesService {
|
export class CoursesService {
|
||||||
async ListCourses(input: ListCoursesInput): Promise<listCourseResponse> {
|
async ListCourses(input: ListCoursesInput): Promise<listCourseResponse> {
|
||||||
try {
|
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 = {
|
const where: Prisma.CourseWhereInput = {
|
||||||
status: 'APPROVED',
|
status: 'APPROVED',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (category_id) {
|
if (category_id) {
|
||||||
where.category_id = category_id;
|
where.category_id = category_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (is_recommended !== undefined) {
|
||||||
|
where.is_recommended = is_recommended;
|
||||||
|
}
|
||||||
|
|
||||||
// Get total count for pagination
|
// Get total count for pagination
|
||||||
const total = await prisma.course.count({ where });
|
const total = await prisma.course.count({ where });
|
||||||
const totalPages = Math.ceil(total / limit);
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
@ -28,13 +32,13 @@ export class CoursesService {
|
||||||
if (random) {
|
if (random) {
|
||||||
// Random mode: ดึงทั้งหมดแล้วสุ่ม
|
// Random mode: ดึงทั้งหมดแล้วสุ่ม
|
||||||
const allCourses = await prisma.course.findMany({ where });
|
const allCourses = await prisma.course.findMany({ where });
|
||||||
|
|
||||||
// Fisher-Yates shuffle
|
// Fisher-Yates shuffle
|
||||||
for (let i = allCourses.length - 1; i > 0; i--) {
|
for (let i = allCourses.length - 1; i > 0; i--) {
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
[allCourses[i], allCourses[j]] = [allCourses[j], allCourses[i]];
|
[allCourses[i], allCourses[j]] = [allCourses[j], allCourses[i]];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply pagination after shuffle
|
// Apply pagination after shuffle
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
courses = allCourses.slice(skip, skip + limit);
|
courses = allCourses.slice(skip, skip + limit);
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export interface QuizData {
|
||||||
shuffle_choices: boolean;
|
shuffle_choices: boolean;
|
||||||
show_answers_after_completion: boolean;
|
show_answers_after_completion: boolean;
|
||||||
is_skippable: boolean;
|
is_skippable: boolean;
|
||||||
|
allow_multiple_attempts: boolean;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
created_by: number;
|
created_by: number;
|
||||||
updated_at: Date | null;
|
updated_at: Date | null;
|
||||||
|
|
@ -598,6 +599,7 @@ export interface UpdateQuizInput {
|
||||||
shuffle_choices?: boolean;
|
shuffle_choices?: boolean;
|
||||||
show_answers_after_completion?: boolean;
|
show_answers_after_completion?: boolean;
|
||||||
is_skippable?: boolean;
|
is_skippable?: boolean;
|
||||||
|
allow_multiple_attempts?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -685,4 +687,5 @@ export interface UpdateQuizBody {
|
||||||
shuffle_choices?: boolean;
|
shuffle_choices?: boolean;
|
||||||
show_answers_after_completion?: boolean;
|
show_answers_after_completion?: boolean;
|
||||||
is_skippable?: boolean;
|
is_skippable?: boolean;
|
||||||
|
allow_multiple_attempts?: boolean;
|
||||||
}
|
}
|
||||||
70
Backend/src/types/RecommendedCourses.types.ts
Normal file
70
Backend/src/types/RecommendedCourses.types.ts
Normal file
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { Course } from '@prisma/client';
|
||||||
|
|
||||||
export interface ListCoursesInput {
|
export interface ListCoursesInput {
|
||||||
category_id?: number;
|
category_id?: number;
|
||||||
|
is_recommended?: boolean;
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
random?: boolean;
|
random?: boolean;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue