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"
|
||||
}
|
||||
}
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue