migration to typescript

This commit is contained in:
JakkrapartXD 2026-01-09 06:28:15 +00:00
parent 924000b084
commit 9fde77468a
41 changed files with 11952 additions and 10164 deletions

View file

@ -4,63 +4,214 @@ 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.
Follow these steps to create a new API endpoint for the E-Learning Platform backend using TypeScript and TSOA.
---
## Step 1: Define Route
## Step 1: Define Route with TSOA Controller
Create or update route file in `src/routes/`:
Create or update controller file in `src/controllers/`:
```javascript
// src/routes/courses.routes.js
const express = require('express');
const router = express.Router();
const courseController = require('../controllers/course.controller');
const { authenticate, authorize } = require('../middleware/auth');
const { validate } = require('../middleware/validation');
const { courseSchema } = require('../validators/course.validator');
```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';
// Public routes
router.get('/', courseController.list);
router.get('/:id', courseController.getById);
@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
});
// Instructor routes
router.post(
'/',
authenticate,
authorize(['INSTRUCTOR', 'ADMIN']),
validate(courseSchema.create),
courseController.create
);
return result;
}
router.put(
'/:id',
authenticate,
authorize(['INSTRUCTOR', 'ADMIN']),
validate(courseSchema.update),
courseController.update
);
/**
* 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()));
module.exports = router;
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 Validator
## 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/`:
```javascript
// src/validators/course.validator.js
const Joi = require('joi');
```typescript
// src/validators/course.validator.ts
import Joi from 'joi';
const multiLangSchema = Joi.object({
th: Joi.string().required(),
en: Joi.string().required()
});
const courseSchema = {
export const courseSchema = {
create: Joi.object({
title: multiLangSchema.required(),
description: multiLangSchema.required(),
@ -81,124 +232,6 @@ const courseSchema = {
thumbnail: Joi.string().uri().optional()
})
};
module.exports = { courseSchema };
```
---
## Step 3: Create Controller
Create controller in `src/controllers/`:
```javascript
// src/controllers/course.controller.js
const courseService = require('../services/course.service');
class CourseController {
async list(req, res) {
try {
const { page = 1, limit = 20, category, search } = req.query;
const result = await courseService.list({
page: parseInt(page),
limit: parseInt(limit),
category: category ? parseInt(category) : undefined,
search
});
return res.status(200).json(result);
} catch (error) {
console.error('List courses error:', error);
return res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'Failed to fetch courses'
}
});
}
}
async getById(req, res) {
try {
const { id } = req.params;
const course = await courseService.getById(parseInt(id));
if (!course) {
return res.status(404).json({
error: {
code: 'COURSE_NOT_FOUND',
message: 'Course not found'
}
});
}
return res.status(200).json(course);
} catch (error) {
console.error('Get course error:', error);
return res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'Failed to fetch course'
}
});
}
}
async create(req, res) {
try {
const course = await courseService.create(req.body, req.user.id);
return res.status(201).json(course);
} catch (error) {
console.error('Create course error:', error);
return res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'Failed to create course'
}
});
}
}
async update(req, res) {
try {
const { id } = req.params;
// Check ownership
const course = await courseService.getById(parseInt(id));
if (!course) {
return res.status(404).json({
error: {
code: 'COURSE_NOT_FOUND',
message: 'Course not found'
}
});
}
if (course.created_by !== req.user.id && req.user.role.code !== 'ADMIN') {
return res.status(403).json({
error: {
code: 'FORBIDDEN',
message: 'You do not have permission to update this course'
}
});
}
const updated = await courseService.update(parseInt(id), req.body);
return res.status(200).json(updated);
} catch (error) {
console.error('Update course error:', error);
return res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'Failed to update course'
}
});
}
}
}
module.exports = new CourseController();
```
---
@ -207,16 +240,18 @@ module.exports = new CourseController();
Create service in `src/services/`:
```javascript
// src/services/course.service.js
const { PrismaClient } = require('@prisma/client');
```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 }) {
const skip = (page - 1) * limit;
async list({ page, limit, category, search }: CourseListQuery) {
const skip = ((page || 1) - 1) * (limit || 20);
const where = {
const where: any = {
is_deleted: false,
status: 'APPROVED'
};
@ -236,7 +271,7 @@ class CourseService {
prisma.course.findMany({
where,
skip,
take: limit,
take: limit || 20,
include: {
category: true,
instructors: {
@ -252,15 +287,15 @@ class CourseService {
return {
data,
pagination: {
page,
limit,
page: page || 1,
limit: limit || 20,
total,
totalPages: Math.ceil(total / limit)
totalPages: Math.ceil(total / (limit || 20))
}
};
}
async getById(id) {
async getById(id: number) {
return prisma.course.findUnique({
where: { id },
include: {
@ -285,7 +320,7 @@ class CourseService {
});
}
async create(data, userId) {
async create(data: CreateCourseDto, userId: number) {
return prisma.course.create({
data: {
...data,
@ -307,7 +342,7 @@ class CourseService {
});
}
async update(id, data) {
async update(id: number, data: UpdateCourseDto) {
return prisma.course.update({
where: { id },
data,
@ -321,46 +356,100 @@ class CourseService {
}
}
module.exports = new CourseService();
export const courseService = new CourseService();
```
---
## Step 5: Register Route
## Step 5: Generate TSOA Routes and Swagger Docs
Update `src/app.js`:
// 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';
```javascript
const express = require('express');
const app = express();
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Routes
const authRoutes = require('./routes/auth.routes');
const courseRoutes = require('./routes/courses.routes');
// Register TSOA routes
RegisterRoutes(app);
app.use('/api/auth', authRoutes);
app.use('/api/courses', courseRoutes);
// Swagger documentation
app.use('/api-docs', swaggerUi.serve, async (_req, res) => {
return res.send(
swaggerUi.generateHTML(await import('../public/swagger.json'))
);
});
module.exports = app;
export default app;
```
---
## Step 6: Write Tests
## 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/`:
```javascript
// tests/integration/courses.test.js
const request = require('supertest');
const app = require('../../src/app');
```typescript
// tests/integration/courses.test.ts
import request from 'supertest';
import app from '../../src/app';
describe('Course API', () => {
let instructorToken;
let instructorToken: string;
beforeAll(async () => {
const res = await request(app)
@ -423,94 +512,118 @@ describe('Course API', () => {
---
## Step 7: Run Tests
## Step 9: Run Tests
// turbo
```bash
npm test -- courses.test.js
npm test -- courses.test.ts
```
---
## Step 8: Test Manually
## Step 10: View API Documentation
// turbo
```bash
# Create course
curl -X POST http://localhost:4000/api/courses \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"title": {"th": "ทดสอบ", "en": "Test"},
"description": {"th": "รายละเอียด", "en": "Description"},
"category_id": 1,
"price": 990
}'
After generating TSOA routes, access Swagger UI:
# List courses
curl http://localhost:4000/api/courses?page=1&limit=10
```
http://localhost:4000/api-docs
```
---
## Checklist
- [ ] Route defined with proper middleware
- [ ] Validator created with Joi/Zod
- [ ] Controller created with error handling
- [ ] Controller created with TSOA decorators
- [ ] Type definitions created
- [ ] Validator created with Joi
- [ ] Service created with business logic
- [ ] Route registered in app.js
- [ ] 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
- [ ] Documentation updated
---
## Best Practices
1. **Separation of Concerns**: Routes → Controllers → Services → Database
2. **Validation**: Always validate input at route level
3. **Authorization**: Check permissions in controller
4. **Error Handling**: Use try-catch and return proper error codes
5. **Multi-Language**: Use JSON structure for user-facing text
6. **Pagination**: Always paginate list endpoints
7. **Testing**: Write tests before deploying
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 Patterns
## Common TSOA Patterns
### File Upload Endpoint
```javascript
const multer = require('multer');
const upload = multer({ dest: 'uploads/' });
```typescript
import { UploadedFile } from 'express-fileupload';
router.post(
'/upload',
authenticate,
upload.single('file'),
validate(uploadSchema),
controller.upload
);
@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
```javascript
// In controller
const resource = await service.getById(id);
if (resource.user_id !== req.user.id && req.user.role.code !== 'ADMIN') {
return res.status(403).json({ error: 'Forbidden' });
```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
```javascript
// In service
async delete(id) {
return prisma.course.update({
where: { id },
data: { is_deleted: true, deleted_at: new Date() }
});
```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"
}
}
```