migration to typescript
This commit is contained in:
parent
924000b084
commit
9fde77468a
41 changed files with 11952 additions and 10164 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ description: Complete workflow for developing E-Learning Platform backend
|
|||
|
||||
# E-Learning Backend Development Workflow
|
||||
|
||||
Complete guide for developing the E-Learning Platform backend from scratch.
|
||||
Complete guide for developing the E-Learning Platform backend using TypeScript and TSOA.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -28,6 +28,10 @@ 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
|
||||
|
|
@ -126,72 +130,12 @@ npx prisma migrate dev --name initial_schema
|
|||
- `POST /api/auth/refresh` - Refresh token
|
||||
|
||||
**Implementation steps:**
|
||||
1. Create `src/routes/auth.routes.js`
|
||||
2. Create `src/controllers/auth.controller.js`
|
||||
3. Create `src/services/auth.service.js`
|
||||
4. Implement JWT middleware
|
||||
5. Write tests
|
||||
1. Create TSOA controller with `@Route`, `@Post` decorators
|
||||
2. Create TypeScript service with interfaces
|
||||
3. Implement JWT middleware
|
||||
4. Write tests
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
// src/services/auth.service.js
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
class AuthService {
|
||||
async register({ username, email, password, role_id = 3 }) {
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
role_id
|
||||
},
|
||||
include: { role: true }
|
||||
});
|
||||
|
||||
const token = this.generateToken(user);
|
||||
return { user, token };
|
||||
}
|
||||
|
||||
async login({ username, email, password }) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username },
|
||||
{ email }
|
||||
]
|
||||
},
|
||||
include: { role: true }
|
||||
});
|
||||
|
||||
if (!user || !await bcrypt.compare(password, user.password)) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
const token = this.generateToken(user);
|
||||
return { user, token };
|
||||
}
|
||||
|
||||
generateToken(user) {
|
||||
return jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: user.role.code
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AuthService();
|
||||
```
|
||||
See [Create API Endpoint Workflow](./create-api-endpoint.md) for detailed examples.
|
||||
|
||||
### 3.2 Course Management
|
||||
|
||||
|
|
@ -324,41 +268,11 @@ model Course {
|
|||
|
||||
### 5.2 Caching (Redis)
|
||||
|
||||
Implement caching for:
|
||||
- Course listings
|
||||
- User sessions
|
||||
- Frequently accessed data
|
||||
|
||||
```javascript
|
||||
const redis = require('redis');
|
||||
const client = redis.createClient({
|
||||
url: process.env.REDIS_URL
|
||||
});
|
||||
|
||||
// Cache course list
|
||||
const cacheKey = 'courses:approved';
|
||||
const cached = await client.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
const courses = await prisma.course.findMany(...);
|
||||
await client.setEx(cacheKey, 3600, JSON.stringify(courses));
|
||||
```
|
||||
Cache course listings, user sessions, and frequently accessed data using Redis with `setEx()` for TTL.
|
||||
|
||||
### 5.3 Rate Limiting
|
||||
|
||||
```javascript
|
||||
const rateLimit = require('express-rate-limit');
|
||||
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100 // limit each IP to 100 requests per windowMs
|
||||
});
|
||||
|
||||
app.use('/api/', limiter);
|
||||
```
|
||||
Use `express-rate-limit` middleware to limit requests (e.g., 100 requests per 15 minutes).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -379,16 +293,7 @@ app.use('/api/', limiter);
|
|||
|
||||
### 6.2 Implement Security Middleware
|
||||
|
||||
```javascript
|
||||
const helmet = require('helmet');
|
||||
const cors = require('cors');
|
||||
|
||||
app.use(helmet());
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN.split(','),
|
||||
credentials: true
|
||||
}));
|
||||
```
|
||||
Use `helmet()` for security headers and configure CORS with allowed origins.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -396,15 +301,25 @@ app.use(cors({
|
|||
|
||||
### 7.1 API Documentation
|
||||
|
||||
Use Swagger/OpenAPI:
|
||||
TSOA automatically generates Swagger/OpenAPI documentation:
|
||||
|
||||
```javascript
|
||||
const swaggerUi = require('swagger-ui-express');
|
||||
const swaggerDocument = require('./swagger.json');
|
||||
```typescript
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
|
||||
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
|
||||
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
|
||||
|
|
@ -500,35 +415,9 @@ git push origin feature/user-authentication
|
|||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Database connection error:**
|
||||
```bash
|
||||
# Check PostgreSQL
|
||||
docker ps | grep postgres
|
||||
docker logs elearning-postgres
|
||||
|
||||
# Test connection
|
||||
npx prisma db pull
|
||||
```
|
||||
|
||||
**Port already in use:**
|
||||
```bash
|
||||
# Find process
|
||||
lsof -i :4000
|
||||
|
||||
# Kill process
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
**Prisma Client error:**
|
||||
```bash
|
||||
# Regenerate client
|
||||
npx prisma generate
|
||||
|
||||
# Reset and migrate
|
||||
npx prisma migrate reset
|
||||
```
|
||||
- **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`
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -545,7 +434,9 @@ npx prisma migrate reset
|
|||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Start dev server
|
||||
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
|
||||
|
|
|
|||
|
|
@ -4,54 +4,61 @@ description: How to handle file uploads (videos, attachments)
|
|||
|
||||
# File Upload Workflow
|
||||
|
||||
Follow these steps to implement file upload functionality for videos and attachments.
|
||||
Follow these steps to implement file upload functionality for videos and attachments using TypeScript and TSOA.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- MinIO/S3 configured and running
|
||||
- Multer installed: `npm install multer`
|
||||
- AWS SDK or MinIO client installed
|
||||
- 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.js`:
|
||||
Create `src/config/s3.config.ts`:
|
||||
|
||||
```javascript
|
||||
const { S3Client } = require('@aws-sdk/client-s3');
|
||||
const { Upload } = require('@aws-sdk/lib-storage');
|
||||
```typescript
|
||||
import { S3Client } from '@aws-sdk/client-s3';
|
||||
|
||||
const s3Client = new S3Client({
|
||||
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
|
||||
accessKeyId: process.env.S3_ACCESS_KEY!,
|
||||
secretAccessKey: process.env.S3_SECRET_KEY!
|
||||
},
|
||||
forcePathStyle: true // Required for MinIO
|
||||
});
|
||||
|
||||
module.exports = { s3Client };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Create Upload Service
|
||||
|
||||
Create `src/services/upload.service.js`:
|
||||
Create `src/services/upload.service.ts`:
|
||||
|
||||
```javascript
|
||||
const { s3Client } = require('../config/s3.config');
|
||||
const { Upload } = require('@aws-sdk/lib-storage');
|
||||
const { DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const path = require('path');
|
||||
```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, folder) {
|
||||
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}`;
|
||||
|
|
@ -77,7 +84,7 @@ class UploadService {
|
|||
};
|
||||
}
|
||||
|
||||
async deleteFile(key, folder) {
|
||||
async deleteFile(key: string, folder: FolderType): Promise<void> {
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: this.getBucket(folder),
|
||||
Key: key
|
||||
|
|
@ -86,179 +93,182 @@ class UploadService {
|
|||
await s3Client.send(command);
|
||||
}
|
||||
|
||||
getBucket(folder) {
|
||||
const bucketMap = {
|
||||
videos: process.env.S3_BUCKET_VIDEOS,
|
||||
documents: process.env.S3_BUCKET_DOCUMENTS,
|
||||
images: process.env.S3_BUCKET_IMAGES,
|
||||
attachments: process.env.S3_BUCKET_ATTACHMENTS
|
||||
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;
|
||||
return bucketMap[folder] || process.env.S3_BUCKET_COURSES!;
|
||||
}
|
||||
|
||||
validateFileType(file, allowedTypes) {
|
||||
validateFileType(file: Express.Multer.File, allowedTypes: string[]): boolean {
|
||||
return allowedTypes.includes(file.mimetype);
|
||||
}
|
||||
|
||||
validateFileSize(file, maxSize) {
|
||||
validateFileSize(file: Express.Multer.File, maxSize: number): boolean {
|
||||
return file.size <= maxSize;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UploadService();
|
||||
export const uploadService = new UploadService();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Create Upload Middleware
|
||||
|
||||
Create `src/middleware/upload.middleware.js`:
|
||||
Create `src/middleware/upload.middleware.ts`:
|
||||
|
||||
```javascript
|
||||
const multer = require('multer');
|
||||
```typescript
|
||||
import multer from 'multer';
|
||||
import { Request } from 'express';
|
||||
|
||||
// File type validators
|
||||
const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime'];
|
||||
const ALLOWED_DOCUMENT_TYPES = [
|
||||
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'
|
||||
];
|
||||
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif'];
|
||||
export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif'];
|
||||
|
||||
// File size limits
|
||||
const MAX_VIDEO_SIZE = 500 * 1024 * 1024; // 500 MB
|
||||
const MAX_ATTACHMENT_SIZE = 100 * 1024 * 1024; // 100 MB
|
||||
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
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) => (req, file, cb) => {
|
||||
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'), false);
|
||||
cb(new Error('Invalid file type'));
|
||||
}
|
||||
};
|
||||
|
||||
// Upload configurations
|
||||
const uploadVideo = multer({
|
||||
export const uploadVideo = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_VIDEO_SIZE },
|
||||
fileFilter: fileFilter(ALLOWED_VIDEO_TYPES)
|
||||
}).single('video');
|
||||
|
||||
const uploadAttachment = multer({
|
||||
export const uploadAttachment = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_ATTACHMENT_SIZE },
|
||||
fileFilter: fileFilter([...ALLOWED_DOCUMENT_TYPES, ...ALLOWED_IMAGE_TYPES])
|
||||
}).single('file');
|
||||
|
||||
const uploadAttachments = multer({
|
||||
export const uploadAttachments = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_ATTACHMENT_SIZE },
|
||||
fileFilter: fileFilter([...ALLOWED_DOCUMENT_TYPES, ...ALLOWED_IMAGE_TYPES])
|
||||
}).array('attachments', 10); // Max 10 files
|
||||
|
||||
const uploadImage = multer({
|
||||
export const uploadImage = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_IMAGE_SIZE },
|
||||
fileFilter: fileFilter(ALLOWED_IMAGE_TYPES)
|
||||
}).single('image');
|
||||
|
||||
module.exports = {
|
||||
uploadVideo,
|
||||
uploadAttachment,
|
||||
uploadAttachments,
|
||||
uploadImage,
|
||||
ALLOWED_VIDEO_TYPES,
|
||||
ALLOWED_DOCUMENT_TYPES,
|
||||
ALLOWED_IMAGE_TYPES
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Create Upload Controller
|
||||
## Step 4: Create Upload Controller with TSOA
|
||||
|
||||
Create `src/controllers/upload.controller.js`:
|
||||
Create `src/controllers/upload.controller.ts`:
|
||||
|
||||
```javascript
|
||||
const uploadService = require('../services/upload.service');
|
||||
```typescript
|
||||
import { Request } from 'express';
|
||||
import { Controller, Post, Route, Tags, Security, UploadedFile, SuccessResponse } from 'tsoa';
|
||||
import { uploadService } from '../services/upload.service';
|
||||
|
||||
class UploadController {
|
||||
async uploadVideo(req, res) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: 'NO_FILE',
|
||||
message: 'No video file provided'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const result = await uploadService.uploadFile(
|
||||
req.file,
|
||||
'videos'
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
video_url: result.url,
|
||||
file_size: result.fileSize,
|
||||
duration: null // Will be processed later
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload video error:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
code: 'UPLOAD_FAILED',
|
||||
message: 'Failed to upload video'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async uploadAttachment(req, res) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: 'NO_FILE',
|
||||
message: 'No file provided'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const result = await uploadService.uploadFile(
|
||||
req.file,
|
||||
'attachments'
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
file_name: result.fileName,
|
||||
file_path: result.key,
|
||||
file_size: result.fileSize,
|
||||
mime_type: result.mimeType,
|
||||
download_url: result.url
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload attachment error:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
code: 'UPLOAD_FAILED',
|
||||
message: 'Failed to upload file'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
interface UploadResponse {
|
||||
video_url?: string;
|
||||
file_size: number;
|
||||
duration?: number | null;
|
||||
file_name?: string;
|
||||
file_path?: string;
|
||||
mime_type?: string;
|
||||
download_url?: string;
|
||||
}
|
||||
|
||||
module.exports = new UploadController();
|
||||
@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
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ description: How to setup the backend development environment
|
|||
|
||||
# Setup Development Environment
|
||||
|
||||
Follow these steps to setup the E-Learning Platform backend on your local machine.
|
||||
Follow these steps to setup the E-Learning Platform backend with TypeScript and TSOA on your local machine.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -32,9 +32,77 @@ cd e-learning/Backend
|
|||
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: Setup Environment Variables
|
||||
## 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
|
||||
|
|
@ -92,7 +160,7 @@ This starts:
|
|||
|
||||
---
|
||||
|
||||
## Step 5: Run Database Migrations
|
||||
## Step 6: Run Database Migrations
|
||||
|
||||
// turbo
|
||||
```bash
|
||||
|
|
@ -101,7 +169,7 @@ npx prisma migrate dev
|
|||
|
||||
---
|
||||
|
||||
## Step 6: Seed Database
|
||||
## Step 7: Seed Database
|
||||
|
||||
// turbo
|
||||
```bash
|
||||
|
|
@ -116,7 +184,20 @@ This creates:
|
|||
|
||||
---
|
||||
|
||||
## Step 7: Start Development Server
|
||||
## 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
|
||||
|
|
@ -127,7 +208,7 @@ Server will start at http://localhost:4000
|
|||
|
||||
---
|
||||
|
||||
## Step 8: Verify Setup
|
||||
## Step 10: Verify Setup
|
||||
|
||||
// turbo
|
||||
Test health endpoint:
|
||||
|
|
@ -150,6 +231,7 @@ curl -X POST http://localhost:4000/api/auth/login \
|
|||
| 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 |
|
||||
|
|
@ -159,9 +241,15 @@ curl -X POST http://localhost:4000/api/auth/login \
|
|||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Start dev server
|
||||
# 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
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ description: How to test backend APIs and services
|
|||
|
||||
# Testing Workflow
|
||||
|
||||
Follow these steps to write and run tests for the E-Learning Platform backend.
|
||||
Follow these steps to write and run tests for the E-Learning Platform backend using TypeScript and Jest.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -22,10 +22,12 @@ tests/
|
|||
|
||||
## Step 1: Setup Test Environment
|
||||
|
||||
Create `tests/setup.js`:
|
||||
Create `tests/setup.ts`:
|
||||
|
||||
```typescript
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
```javascript
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
beforeAll(async () => {
|
||||
|
|
@ -44,8 +46,6 @@ afterAll(async () => {
|
|||
|
||||
async function createTestUsers() {
|
||||
// Create admin, instructor, student
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
await prisma.user.createMany({
|
||||
data: [
|
||||
{
|
||||
|
|
@ -75,11 +75,12 @@ async function createTestUsers() {
|
|||
|
||||
## Step 2: Write Unit Tests
|
||||
|
||||
Create `tests/unit/course.service.test.js`:
|
||||
Create `tests/unit/course.service.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { courseService } from '../../src/services/course.service';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
```javascript
|
||||
const courseService = require('../../src/services/course.service');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
describe('Course Service', () => {
|
||||
|
|
@ -93,7 +94,7 @@ describe('Course Service', () => {
|
|||
is_free: false
|
||||
};
|
||||
|
||||
const course = await courseService.createCourse(courseData, 1);
|
||||
const course = await courseService.create(courseData, 1);
|
||||
|
||||
expect(course).toHaveProperty('id');
|
||||
expect(course.status).toBe('DRAFT');
|
||||
|
|
@ -108,7 +109,7 @@ describe('Course Service', () => {
|
|||
};
|
||||
|
||||
await expect(
|
||||
courseService.createCourse(courseData, 1)
|
||||
courseService.create(courseData, 1)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -134,14 +135,16 @@ describe('Course Service', () => {
|
|||
|
||||
## Step 3: Write Integration Tests
|
||||
|
||||
Create `tests/integration/courses.test.js`:
|
||||
Create `tests/integration/courses.test.ts`:
|
||||
|
||||
```javascript
|
||||
const request = require('supertest');
|
||||
const app = require('../../src/app');
|
||||
```typescript
|
||||
import request from 'supertest';
|
||||
import app from '../../src/app';
|
||||
|
||||
describe('Course API', () => {
|
||||
let adminToken, instructorToken, studentToken;
|
||||
let adminToken: string;
|
||||
let instructorToken: string;
|
||||
let studentToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Login as different users
|
||||
|
|
@ -217,7 +220,7 @@ describe('Course API', () => {
|
|||
.query({ category: 1 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
response.body.data.forEach(course => {
|
||||
response.body.data.forEach((course: any) => {
|
||||
expect(course.category_id).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -229,15 +232,15 @@ describe('Course API', () => {
|
|||
|
||||
## Step 4: Test File Uploads
|
||||
|
||||
Create `tests/integration/file-upload.test.js`:
|
||||
Create `tests/integration/file-upload.test.ts`:
|
||||
|
||||
```javascript
|
||||
const request = require('supertest');
|
||||
const app = require('../../src/app');
|
||||
const path = require('path');
|
||||
```typescript
|
||||
import request from 'supertest';
|
||||
import app from '../../src/app';
|
||||
import path from 'path';
|
||||
|
||||
describe('File Upload API', () => {
|
||||
let instructorToken;
|
||||
let instructorToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await request(app)
|
||||
|
|
@ -330,10 +333,12 @@ Update `package.json`:
|
|||
"test:coverage": "jest --coverage"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "ts-jest",
|
||||
"testEnvironment": "node",
|
||||
"coveragePathIgnorePatterns": ["/node_modules/"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/tests/setup.js"],
|
||||
"testMatch": ["**/tests/**/*.test.js"]
|
||||
"setupFilesAfterEnv": ["<rootDir>/tests/setup.ts"],
|
||||
"testMatch": ["**/tests/**/*.test.ts"],
|
||||
"moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue