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"
}
}
```

View file

@ -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

View file

@ -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
```
---

View file

@ -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

View file

@ -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"]
}
}
```

View file

@ -1,44 +1,42 @@
# Environment
# Application
NODE_ENV=development
PORT=4000
APP_URL=http://localhost:4000
# Database
DATABASE_URL=postgresql://elearning_user:elearning_pass@localhost:5432/elearning_db
DATABASE_URL=postgresql://postgres:12345678@localhost:5432/elearning_dev
# Redis
REDIS_URL=redis://localhost:6379
REDIS_PASSWORD=dev_redis_password
# MinIO/S3
S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY=admin
S3_SECRET_KEY=12345678
S3_BUCKET=e-learning
S3_USE_SSL=false
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=24h
JWT_REFRESH_EXPIRES_IN=7d
# MinIO/S3
S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET_NAME=elearning
S3_USE_SSL=false
# CORS
CORS_ORIGIN=http://localhost:3000,http://localhost:5173
# File Upload Limits (in bytes)
MAX_VIDEO_SIZE=524288000
MAX_ATTACHMENT_SIZE=104857600
MAX_ATTACHMENTS_PER_LESSON=10
# Email (Mailhog for development)
# Email (Mailhog in development)
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_USER=
SMTP_PASS=
SMTP_FROM=noreply@elearning.local
# File Upload Limits (in bytes)
MAX_VIDEO_SIZE=524288000
MAX_ATTACHMENT_SIZE=104857600
MAX_ATTACHMENTS_PER_LESSON=10
# CORS
CORS_ORIGIN=http://localhost:3000
# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
# Logging
LOG_LEVEL=debug

31
Backend/.gitignore vendored
View file

@ -1,21 +1,14 @@
node_modules/
dist/
build/
logs/
*.log
# Environment
.env
.env.local
.env.*.local
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
@ -23,13 +16,21 @@ Thumbs.db
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Testing
coverage/
.nyc_output/
# Uploads (local development)
# Prisma
prisma/migrations/**/*.sql
# TSOA
public/swagger.json
src/routes/routes.ts
# Uploads (if storing locally)
uploads/
temp/
# PM2
.pm2/

View file

@ -1,191 +1,200 @@
# E-Learning Backend
# E-Learning Platform Backend
Backend API for the E-Learning Platform built with Node.js, Express, Prisma, and PostgreSQL.
Backend API for E-Learning Platform built with TypeScript, Express, TSOA, and Prisma.
## 🚀 Features
- **Authentication & Authorization**: JWT-based authentication with role-based access control
- **Course Management**: Create, manage, and publish courses with chapters and lessons
- **Multi-Language Support**: Thai and English content support
- **Quiz System**: Interactive quizzes with multiple attempts and score policies
- **Progress Tracking**: Track student progress and issue certificates
- **File Upload**: Support for video lessons and attachments (MinIO/S3)
- **Caching**: Redis integration for improved performance
- **Security**: Helmet, CORS, rate limiting, and input validation
- **TypeScript** - Type-safe development
- **TSOA** - Automatic OpenAPI/Swagger documentation
- **Prisma** - Type-safe database ORM
- **JWT Authentication** - Secure user authentication
- **Role-based Authorization** - Admin, Instructor, Student roles
- **Multi-language Support** - Thai and English
- **File Upload** - Video and attachment support with MinIO/S3
- **Redis Caching** - Performance optimization
- **Rate Limiting** - API protection
- **Comprehensive Error Handling** - Structured error responses
## 📋 Prerequisites
- Node.js >= 18.0.0
- PostgreSQL >= 14
- Redis (optional, for caching)
- MinIO or S3 (for file storage)
- Node.js >= 18
- Docker & Docker Compose
- PostgreSQL (via Docker)
- Redis (via Docker)
- MinIO (via Docker)
## 🛠️ Installation
## 🛠️ Setup
1. **Clone the repository**
```bash
git clone <repository-url>
cd e-learning/Backend
```
### 1. Install Dependencies
2. **Install dependencies**
```bash
npm install
```
3. **Setup environment variables**
### 2. Environment Configuration
```bash
cp .env.example .env
# Edit .env with your configuration
```
4. **Start Docker services** (PostgreSQL, Redis, MinIO)
### 3. Start Docker Services
```bash
docker compose up -d
```
5. **Run database migrations**
### 4. Database Setup
```bash
# Generate Prisma client
npx prisma generate
# Run migrations
npx prisma migrate dev
# Seed database
npx prisma db seed
```
6. **Seed the database**
### 5. Generate TSOA Routes & Swagger
```bash
npm run prisma:seed
npm run tsoa:gen
```
7. **Start development server**
### 6. Start Development Server
```bash
npm run dev
```
The server will start on `http://localhost:4000`
The server will start at `http://localhost:4000`
## 📁 Project Structure
## 📚 API Documentation
Swagger documentation is available at: `http://localhost:4000/api-docs`
## 🏗️ Project Structure
```
Backend/
├── prisma/
│ ├── migrations/ # Database migrations
│ ├── schema.prisma # Database schema
│ └── seed.js # Database seeding
│ ├── migrations/ # Database migrations
│ ├── schema.prisma # Database schema
│ └── seed.js # Database seeder
├── src/
│ ├── config/ # Configuration files
│ │ ├── database.js # Prisma client
│ │ ├── logger.js # Winston logger
│ │ └── redis.js # Redis client
│ ├── controllers/ # Request handlers
│ ├── middleware/ # Express middleware
│ │ ├── auth.js # Authentication & authorization
│ │ └── errorHandler.js
│ ├── routes/ # Route definitions
│ ├── services/ # Business logic
│ ├── utils/ # Utility functions
│ │ └── jwt.js # JWT utilities
│ ├── validators/ # Input validation
│ ├── app.js # Express app setup
│ └── server.js # Server entry point
├── tests/ # Test files
├── logs/ # Log files
├── .env.example # Environment variables template
├── package.json
└── README.md
│ ├── config/ # Configuration files
│ │ ├── index.ts # Main config
│ │ ├── logger.ts # Winston logger
│ │ └── database.ts # Prisma client
│ ├── controllers/ # TSOA controllers
│ │ └── HealthController.ts
│ ├── middleware/ # Express middleware
│ │ ├── authentication.ts
│ │ └── errorHandler.ts
│ ├── services/ # Business logic
│ ├── types/ # TypeScript types
│ │ └── index.ts
│ ├── utils/ # Utility functions
│ ├── validators/ # Input validation
│ ├── app.ts # Express app setup
│ └── server.ts # Server entry point
├── public/ # Generated Swagger docs
├── .env.example # Environment template
├── tsconfig.json # TypeScript config
├── tsoa.json # TSOA config
├── nodemon.json # Nodemon config
└── package.json
```
## 🔑 Environment Variables
## 🔧 Available Scripts
See `.env.example` for all available environment variables.
```bash
npm run dev # Start dev server with hot reload
npm run build # Build TypeScript + generate TSOA routes
npm start # Start production server
npm run tsoa:gen # Generate TSOA routes & Swagger
npm test # Run tests
npm run lint # Run ESLint
npm run format # Format code with Prettier
```
Key variables:
- `DATABASE_URL`: PostgreSQL connection string
- `JWT_SECRET`: Secret key for JWT tokens
- `REDIS_URL`: Redis connection string
- `S3_ENDPOINT`: MinIO/S3 endpoint
- `CORS_ORIGIN`: Allowed CORS origins
## 🗄️ Database Commands
```bash
npx prisma studio # Open Prisma Studio (GUI)
npx prisma migrate dev # Create and apply migration
npx prisma db seed # Seed database
npx prisma generate # Generate Prisma client
```
## 🐳 Docker Commands
```bash
docker compose up -d # Start all services
docker compose down # Stop all services
docker compose logs -f # View logs
docker compose ps # List running services
```
## 🔐 Default Credentials
After seeding, you can login with:
- **Admin**: `admin` / `admin123`
- **Instructor**: `instructor` / `instructor123`
- **Student**: `student` / `student123`
## 📝 Development Workflow
1. Create a new controller in `src/controllers/`
2. Add TSOA decorators (`@Route`, `@Get`, `@Post`, etc.)
3. Run `npm run tsoa:gen` to generate routes
4. Implement business logic in `src/services/`
5. Test your endpoints
## 🧪 Testing
```bash
# Run all tests
npm test
# Run tests in watch mode
npm run test:watch
# Run tests with coverage
npm test -- --coverage
npm test # Run all tests
npm run test:watch # Run tests in watch mode
npm run test:coverage # Generate coverage report
```
## 📝 API Documentation
## 🚀 Deployment
### Authentication
See [Deployment Workflow](./.agent/workflows/deployment.md) for production deployment instructions.
- `POST /api/auth/register` - Register new user
- `POST /api/auth/login` - Login user
- `GET /api/auth/me` - Get current user profile
- `POST /api/auth/logout` - Logout user
## 📖 Documentation
### Health Check
- [Backend Development Rules](./.agent/rules/rules.md)
- [Agent Skills Backend](./agent_skills_backend.md)
- [API Workflows](./.agent/workflows/)
- `GET /health` - Server health status
## 🔐 Default Credentials
After seeding the database, you can use these credentials:
- **Admin**: `admin` / `admin123`
- **Instructor**: `instructor` / `admin123`
- **Student**: `student` / `admin123`
## 🛠️ Development
## 🔍 Troubleshooting
### Port already in use
```bash
# Start dev server with auto-reload
npm run dev
# Run linter
npm run lint
# Format code
npm run format
# Open Prisma Studio (database GUI)
npm run prisma:studio
lsof -i :4000
kill -9 <PID>
```
## 📦 Database Commands
### Database connection error
```bash
# Generate Prisma Client
npm run prisma:generate
# Create migration
npx prisma migrate dev --name migration_name
# Apply migrations
npx prisma migrate deploy
# Reset database (development only)
npx prisma migrate reset
# Seed database
npm run prisma:seed
docker compose logs postgres
npx prisma db pull
```
## 🚀 Production Deployment
1. Set `NODE_ENV=production`
2. Set strong `JWT_SECRET`
3. Configure production database
4. Run migrations: `npx prisma migrate deploy`
5. Start with PM2: `pm2 start src/server.js`
## 📚 Resources
- [Prisma Documentation](https://www.prisma.io/docs)
- [Express Documentation](https://expressjs.com/)
- [JWT Documentation](https://jwt.io/)
### Prisma client error
```bash
npx prisma generate
```
## 📄 License
MIT
ISC
## 👥 Team
E-Learning Development Team

14
Backend/nodemon.json Normal file
View file

@ -0,0 +1,14 @@
{
"watch": [
"src"
],
"ext": "ts,json",
"ignore": [
"src/**/*.test.ts",
"node_modules"
],
"exec": "ts-node -r tsconfig-paths/register src/server.ts",
"env": {
"NODE_ENV": "development"
}
}

16809
Backend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,56 +1,72 @@
{
"name": "e-learning-backend",
"version": "1.0.0",
"description": "E-Learning Platform Backend API",
"main": "src/server.js",
"scripts": {
"dev": "nodemon src/server.js",
"start": "node src/server.js",
"test": "jest --coverage",
"test:watch": "jest --watch",
"lint": "eslint src/**/*.js",
"format": "prettier --write \"src/**/*.js\"",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
"prisma:seed": "node prisma/seed.js"
},
"keywords": [
"e-learning",
"api",
"express",
"prisma",
"postgresql"
],
"author": "",
"license": "MIT",
"dependencies": {
"@prisma/client": "^5.22.0",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"express-rate-limit": "^7.4.1",
"helmet": "^8.0.0",
"joi": "^17.13.3",
"jsonwebtoken": "^9.0.2",
"minio": "^8.0.2",
"multer": "^1.4.5-lts.1",
"redis": "^4.7.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"winston": "^3.17.0"
},
"devDependencies": {
"eslint": "^9.17.0",
"jest": "^29.7.0",
"nodemon": "^3.1.9",
"prettier": "^3.4.2",
"prisma": "^5.22.0",
"supertest": "^7.0.0"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
}
"name": "e-learning-backend",
"version": "1.0.0",
"description": "E-Learning Platform Backend API with TypeScript and TSOA",
"main": "dist/server.js",
"scripts": {
"dev": "nodemon",
"build": "tsoa spec-and-routes && tsc",
"start": "node dist/server.js",
"tsoa:gen": "tsoa spec-and-routes",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "node prisma/seed.js",
"prisma:studio": "prisma studio",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"lint": "eslint . --ext .ts",
"format": "prettier --write \"src/**/*.ts\""
},
"keywords": [
"e-learning",
"typescript",
"tsoa",
"express",
"prisma"
],
"author": "",
"license": "ISC",
"dependencies": {
"@prisma/client": "^5.22.0",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.0.0",
"joi": "^17.13.3",
"jsonwebtoken": "^9.0.2",
"minio": "^8.0.2",
"multer": "^1.4.5-lts.1",
"redis": "^4.7.0",
"reflect-metadata": "^0.2.2",
"swagger-ui-express": "^5.0.1",
"tsoa": "^6.4.0",
"winston": "^3.17.0"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.5",
"@types/swagger-ui-express": "^4.1.6",
"@typescript-eslint/eslint-plugin": "^8.19.1",
"@typescript-eslint/parser": "^8.19.1",
"eslint": "^9.18.0",
"jest": "^29.7.0",
"nodemon": "^3.1.9",
"prettier": "^3.4.2",
"prisma": "^5.22.0",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3"
},
"prisma": {
"seed": "node prisma/seed.js"
}
}

View file

@ -1,406 +0,0 @@
-- CreateEnum
CREATE TYPE "CourseStatus" AS ENUM ('DRAFT', 'PENDING_APPROVAL', 'APPROVED', 'REJECTED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "LessonType" AS ENUM ('VIDEO', 'TEXT', 'PDF', 'QUIZ');
-- CreateEnum
CREATE TYPE "ScorePolicy" AS ENUM ('HIGHEST', 'LATEST', 'AVERAGE');
-- CreateTable
CREATE TABLE "roles" (
"id" SERIAL NOT NULL,
"code" VARCHAR(50) NOT NULL,
"name" JSONB NOT NULL,
"description" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" SERIAL NOT NULL,
"username" VARCHAR(50) NOT NULL,
"email" VARCHAR(255) NOT NULL,
"password" VARCHAR(255) NOT NULL,
"role_id" INTEGER NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "profiles" (
"id" SERIAL NOT NULL,
"user_id" INTEGER NOT NULL,
"first_name" VARCHAR(100),
"last_name" VARCHAR(100),
"phone" VARCHAR(20),
"avatar_url" VARCHAR(500),
"bio" JSONB,
"birth_date" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "profiles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "categories" (
"id" SERIAL NOT NULL,
"code" VARCHAR(50) NOT NULL,
"name" JSONB NOT NULL,
"description" JSONB,
"icon_url" VARCHAR(500),
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "categories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "courses" (
"id" SERIAL NOT NULL,
"title" JSONB NOT NULL,
"description" JSONB NOT NULL,
"thumbnail_url" VARCHAR(500),
"price" DECIMAL(10,2) NOT NULL DEFAULT 0,
"is_free" BOOLEAN NOT NULL DEFAULT false,
"have_certificate" BOOLEAN NOT NULL DEFAULT false,
"status" "CourseStatus" NOT NULL DEFAULT 'DRAFT',
"category_id" INTEGER NOT NULL,
"created_by" INTEGER NOT NULL,
"rejection_reason" TEXT,
"is_deleted" BOOLEAN NOT NULL DEFAULT false,
"deleted_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "courses_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "course_instructors" (
"id" SERIAL NOT NULL,
"course_id" INTEGER NOT NULL,
"user_id" INTEGER NOT NULL,
"is_primary" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "course_instructors_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "chapters" (
"id" SERIAL NOT NULL,
"course_id" INTEGER NOT NULL,
"title" JSONB NOT NULL,
"description" JSONB,
"order" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "chapters_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lessons" (
"id" SERIAL NOT NULL,
"chapter_id" INTEGER NOT NULL,
"title" JSONB NOT NULL,
"description" JSONB,
"type" "LessonType" NOT NULL,
"content" JSONB,
"video_url" VARCHAR(500),
"video_duration" INTEGER,
"order" INTEGER NOT NULL,
"is_preview" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "lessons_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lesson_prerequisites" (
"id" SERIAL NOT NULL,
"lesson_id" INTEGER NOT NULL,
"prerequisite_lesson_id" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "lesson_prerequisites_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "attachments" (
"id" SERIAL NOT NULL,
"lesson_id" INTEGER NOT NULL,
"filename" VARCHAR(255) NOT NULL,
"original_name" VARCHAR(255) NOT NULL,
"file_url" VARCHAR(500) NOT NULL,
"file_size" BIGINT NOT NULL,
"mime_type" VARCHAR(100) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "attachments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "quizzes" (
"id" SERIAL NOT NULL,
"lesson_id" INTEGER NOT NULL,
"title" JSONB NOT NULL,
"description" JSONB,
"passing_score" INTEGER NOT NULL DEFAULT 70,
"time_limit" INTEGER,
"max_attempts" INTEGER NOT NULL DEFAULT 3,
"cooldown_hours" INTEGER NOT NULL DEFAULT 24,
"score_policy" "ScorePolicy" NOT NULL DEFAULT 'HIGHEST',
"shuffle_questions" BOOLEAN NOT NULL DEFAULT true,
"shuffle_choices" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "quizzes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "quiz_questions" (
"id" SERIAL NOT NULL,
"quiz_id" INTEGER NOT NULL,
"question" JSONB NOT NULL,
"choices" JSONB NOT NULL,
"points" INTEGER NOT NULL DEFAULT 1,
"order" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "quiz_questions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "quiz_attempts" (
"id" SERIAL NOT NULL,
"quiz_id" INTEGER NOT NULL,
"user_id" INTEGER NOT NULL,
"answers" JSONB NOT NULL,
"score" INTEGER NOT NULL,
"passed" BOOLEAN NOT NULL,
"time_spent" INTEGER,
"started_at" TIMESTAMP(3) NOT NULL,
"completed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "quiz_attempts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "enrollments" (
"id" SERIAL NOT NULL,
"user_id" INTEGER NOT NULL,
"course_id" INTEGER NOT NULL,
"enrolled_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completed_at" TIMESTAMP(3),
"progress_percent" INTEGER NOT NULL DEFAULT 0,
"last_accessed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "enrollments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lesson_progress" (
"id" SERIAL NOT NULL,
"user_id" INTEGER NOT NULL,
"lesson_id" INTEGER NOT NULL,
"is_completed" BOOLEAN NOT NULL DEFAULT false,
"video_progress" INTEGER NOT NULL DEFAULT 0,
"completed_at" TIMESTAMP(3),
"last_watched_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "lesson_progress_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "certificates" (
"id" SERIAL NOT NULL,
"user_id" INTEGER NOT NULL,
"course_id" INTEGER NOT NULL,
"certificate_url" VARCHAR(500) NOT NULL,
"issued_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "certificates_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "roles_code_key" ON "roles"("code");
-- CreateIndex
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE INDEX "users_email_idx" ON "users"("email");
-- CreateIndex
CREATE INDEX "users_role_id_idx" ON "users"("role_id");
-- CreateIndex
CREATE UNIQUE INDEX "profiles_user_id_key" ON "profiles"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "categories_code_key" ON "categories"("code");
-- CreateIndex
CREATE INDEX "courses_category_id_idx" ON "courses"("category_id");
-- CreateIndex
CREATE INDEX "courses_status_idx" ON "courses"("status");
-- CreateIndex
CREATE INDEX "courses_created_by_idx" ON "courses"("created_by");
-- CreateIndex
CREATE INDEX "courses_created_at_idx" ON "courses"("created_at");
-- CreateIndex
CREATE INDEX "course_instructors_course_id_idx" ON "course_instructors"("course_id");
-- CreateIndex
CREATE INDEX "course_instructors_user_id_idx" ON "course_instructors"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "course_instructors_course_id_user_id_key" ON "course_instructors"("course_id", "user_id");
-- CreateIndex
CREATE INDEX "chapters_course_id_idx" ON "chapters"("course_id");
-- CreateIndex
CREATE INDEX "chapters_order_idx" ON "chapters"("order");
-- CreateIndex
CREATE INDEX "lessons_chapter_id_idx" ON "lessons"("chapter_id");
-- CreateIndex
CREATE INDEX "lessons_order_idx" ON "lessons"("order");
-- CreateIndex
CREATE INDEX "lesson_prerequisites_lesson_id_idx" ON "lesson_prerequisites"("lesson_id");
-- CreateIndex
CREATE INDEX "lesson_prerequisites_prerequisite_lesson_id_idx" ON "lesson_prerequisites"("prerequisite_lesson_id");
-- CreateIndex
CREATE UNIQUE INDEX "lesson_prerequisites_lesson_id_prerequisite_lesson_id_key" ON "lesson_prerequisites"("lesson_id", "prerequisite_lesson_id");
-- CreateIndex
CREATE INDEX "attachments_lesson_id_idx" ON "attachments"("lesson_id");
-- CreateIndex
CREATE UNIQUE INDEX "quizzes_lesson_id_key" ON "quizzes"("lesson_id");
-- CreateIndex
CREATE INDEX "quiz_questions_quiz_id_idx" ON "quiz_questions"("quiz_id");
-- CreateIndex
CREATE INDEX "quiz_questions_order_idx" ON "quiz_questions"("order");
-- CreateIndex
CREATE INDEX "quiz_attempts_quiz_id_idx" ON "quiz_attempts"("quiz_id");
-- CreateIndex
CREATE INDEX "quiz_attempts_user_id_idx" ON "quiz_attempts"("user_id");
-- CreateIndex
CREATE INDEX "quiz_attempts_completed_at_idx" ON "quiz_attempts"("completed_at");
-- CreateIndex
CREATE INDEX "enrollments_user_id_idx" ON "enrollments"("user_id");
-- CreateIndex
CREATE INDEX "enrollments_course_id_idx" ON "enrollments"("course_id");
-- CreateIndex
CREATE UNIQUE INDEX "enrollments_user_id_course_id_key" ON "enrollments"("user_id", "course_id");
-- CreateIndex
CREATE INDEX "lesson_progress_user_id_idx" ON "lesson_progress"("user_id");
-- CreateIndex
CREATE INDEX "lesson_progress_lesson_id_idx" ON "lesson_progress"("lesson_id");
-- CreateIndex
CREATE UNIQUE INDEX "lesson_progress_user_id_lesson_id_key" ON "lesson_progress"("user_id", "lesson_id");
-- CreateIndex
CREATE INDEX "certificates_user_id_idx" ON "certificates"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "certificates_user_id_course_id_key" ON "certificates"("user_id", "course_id");
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "profiles" ADD CONSTRAINT "profiles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "courses" ADD CONSTRAINT "courses_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "courses" ADD CONSTRAINT "courses_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "course_instructors" ADD CONSTRAINT "course_instructors_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "course_instructors" ADD CONSTRAINT "course_instructors_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "chapters" ADD CONSTRAINT "chapters_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lessons" ADD CONSTRAINT "lessons_chapter_id_fkey" FOREIGN KEY ("chapter_id") REFERENCES "chapters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lesson_prerequisites" ADD CONSTRAINT "lesson_prerequisites_lesson_id_fkey" FOREIGN KEY ("lesson_id") REFERENCES "lessons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lesson_prerequisites" ADD CONSTRAINT "lesson_prerequisites_prerequisite_lesson_id_fkey" FOREIGN KEY ("prerequisite_lesson_id") REFERENCES "lessons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_lesson_id_fkey" FOREIGN KEY ("lesson_id") REFERENCES "lessons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "quizzes" ADD CONSTRAINT "quizzes_lesson_id_fkey" FOREIGN KEY ("lesson_id") REFERENCES "lessons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "quiz_questions" ADD CONSTRAINT "quiz_questions_quiz_id_fkey" FOREIGN KEY ("quiz_id") REFERENCES "quizzes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "quiz_attempts" ADD CONSTRAINT "quiz_attempts_quiz_id_fkey" FOREIGN KEY ("quiz_id") REFERENCES "quizzes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "quiz_attempts" ADD CONSTRAINT "quiz_attempts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "enrollments" ADD CONSTRAINT "enrollments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "enrollments" ADD CONSTRAINT "enrollments_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lesson_progress" ADD CONSTRAINT "lesson_progress_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lesson_progress" ADD CONSTRAINT "lesson_progress_lesson_id_fkey" FOREIGN KEY ("lesson_id") REFERENCES "lessons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "certificates" ADD CONSTRAINT "certificates_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -17,113 +17,149 @@ datasource db {
model Role {
id Int @id @default(autoincrement())
code String @unique @db.VarChar(50)
name Json // { th: "", en: "" }
name Json // { th: "", en: "" }
description Json?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
users User[]
@@index([code])
@@map("roles")
}
model User {
id Int @id @default(autoincrement())
username String @unique @db.VarChar(50)
email String @unique @db.VarChar(255)
password String @db.VarChar(255)
role_id Int
is_active Boolean @default(true)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
id Int @id @default(autoincrement())
username String @unique @db.VarChar(50)
email String @unique @db.VarChar(255)
password String @db.VarChar(255)
role_id Int
email_verified_at DateTime?
created_at DateTime @default(now())
updated_at DateTime? @updatedAt
role Role @relation(fields: [role_id], references: [id])
profile Profile?
role Role @relation(fields: [role_id], references: [id], onDelete: Restrict)
profile UserProfile?
// Relations
created_courses Course[] @relation("CourseCreator")
instructor_courses CourseInstructor[]
enrollments Enrollment[]
lesson_progress LessonProgress[]
quiz_attempts QuizAttempt[]
certificates Certificate[]
created_courses Course[] @relation("CourseCreator")
approved_courses Course[] @relation("CourseApprover")
instructor_courses CourseInstructor[]
enrollments Enrollment[]
lesson_progress LessonProgress[]
quiz_attempts QuizAttempt[]
certificates Certificate[]
created_categories Category[] @relation("CategoryCreator")
updated_categories Category[] @relation("CategoryUpdater")
updated_profiles UserProfile[] @relation("ProfileUpdater")
created_quizzes Quiz[] @relation("QuizCreator")
updated_quizzes Quiz[] @relation("QuizUpdater")
created_announcements Announcement[] @relation("AnnouncementCreator")
updated_announcements Announcement[] @relation("AnnouncementUpdater")
updated_courses Course[] @relation("CourseUpdater")
orders Order[]
instructor_balance InstructorBalance?
withdrawal_requests WithdrawalRequest[] @relation("WithdrawalInstructor")
approved_withdrawals WithdrawalRequest[] @relation("WithdrawalApprover")
updated_withdrawals WithdrawalRequest[] @relation("WithdrawalUpdater")
submitted_approvals CourseApproval[] @relation("ApprovalSubmitter")
reviewed_approvals CourseApproval[] @relation("ApprovalReviewer")
@@index([username])
@@index([email])
@@index([role_id])
@@map("users")
}
model Profile {
model UserProfile {
id Int @id @default(autoincrement())
user_id Int @unique
prefix Json? // { th: "นาย", en: "Mr." }
first_name String @db.VarChar(100)
last_name String @db.VarChar(100)
phone String? @db.VarChar(20)
avatar_url String? @db.VarChar(500)
birth_date DateTime?
created_at DateTime @default(now())
updated_at DateTime? @updatedAt
updated_by Int?
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
updater User? @relation("ProfileUpdater", fields: [updated_by], references: [id])
@@index([user_id])
@@map("user_profiles")
}
model Category {
id Int @id @default(autoincrement())
user_id Int @unique
first_name String? @db.VarChar(100)
last_name String? @db.VarChar(100)
phone String? @db.VarChar(20)
avatar_url String? @db.VarChar(500)
bio Json? // { th: "", en: "" }
birth_date DateTime?
name Json // { th: "", en: "" }
slug String @unique @db.VarChar(100)
description Json?
icon String? @db.VarChar(100)
sort_order Int @default(0)
is_active Boolean @default(true)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
created_by Int
updated_at DateTime? @updatedAt
updated_by Int?
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
courses Course[]
creator User @relation("CategoryCreator", fields: [created_by], references: [id], onDelete: Restrict)
updater User? @relation("CategoryUpdater", fields: [updated_by], references: [id])
@@map("profiles")
@@index([slug])
@@index([is_active])
@@index([sort_order])
@@map("categories")
}
// ============================================
// Course Management
// Course Structure
// ============================================
enum CourseStatus {
DRAFT
PENDING_APPROVAL
PENDING
APPROVED
REJECTED
ARCHIVED
}
model Category {
id Int @id @default(autoincrement())
code String @unique @db.VarChar(50)
name Json // { th: "", en: "" }
description Json?
icon_url String? @db.VarChar(500)
is_active Boolean @default(true)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
courses Course[]
@@map("categories")
}
model Course {
id Int @id @default(autoincrement())
title Json // { th: "", en: "" }
description Json
thumbnail_url String? @db.VarChar(500)
price Decimal @default(0) @db.Decimal(10, 2)
is_free Boolean @default(false)
have_certificate Boolean @default(false)
status CourseStatus @default(DRAFT)
category_id Int
created_by Int
rejection_reason String? @db.Text
is_deleted Boolean @default(false)
deleted_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
id Int @id @default(autoincrement())
category_id Int?
title Json // { th: "", en: "" }
slug String @unique @db.VarChar(200)
description Json
thumbnail_url String? @db.VarChar(500)
price Decimal @default(0) @db.Decimal(10, 2)
is_free Boolean @default(false)
have_certificate Boolean @default(false)
status CourseStatus @default(DRAFT)
approved_by Int?
approved_at DateTime?
rejection_reason String? @db.Text
created_at DateTime @default(now())
created_by Int
updated_at DateTime? @updatedAt
updated_by Int?
category Category @relation(fields: [category_id], references: [id])
creator User @relation("CourseCreator", fields: [created_by], references: [id])
chapters Chapter[]
instructors CourseInstructor[]
enrollments Enrollment[]
category Category? @relation(fields: [category_id], references: [id], onDelete: SetNull)
creator User @relation("CourseCreator", fields: [created_by], references: [id], onDelete: Restrict)
approver User? @relation("CourseApprover", fields: [approved_by], references: [id])
updater User? @relation("CourseUpdater", fields: [updated_by], references: [id])
chapters Chapter[]
instructors CourseInstructor[]
enrollments Enrollment[]
announcements Announcement[]
courseApprovals CourseApproval[]
@@index([category_id])
@@index([slug])
@@index([status])
@@index([created_by])
@@index([created_at])
@@index([status, is_free])
@@index([category_id, status])
@@map("courses")
}
@ -132,7 +168,7 @@ model CourseInstructor {
course_id Int
user_id Int
is_primary Boolean @default(false)
created_at DateTime @default(now())
joined_at DateTime @default(now())
course Course @relation(fields: [course_id], references: [id], onDelete: Cascade)
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@ -140,23 +176,55 @@ model CourseInstructor {
@@unique([course_id, user_id])
@@index([course_id])
@@index([user_id])
@@index([is_primary])
@@map("course_instructors")
}
enum ApprovalAction {
SUBMITTED
APPROVED
REJECTED
}
model CourseApproval {
id Int @id @default(autoincrement())
course_id Int
submitted_by Int
reviewed_by Int?
action ApprovalAction
previous_status CourseStatus
new_status CourseStatus
comment String? @db.Text
created_at DateTime @default(now())
course Course @relation(fields: [course_id], references: [id], onDelete: Cascade)
submitter User @relation("ApprovalSubmitter", fields: [submitted_by], references: [id], onDelete: Restrict)
reviewer User? @relation("ApprovalReviewer", fields: [reviewed_by], references: [id])
@@index([course_id])
@@index([submitted_by])
@@index([reviewed_by])
@@index([action])
@@index([created_at])
@@index([course_id, created_at])
@@map("course_approvals")
}
model Chapter {
id Int @id @default(autoincrement())
course_id Int
title Json // { th: "", en: "" }
description Json?
order Int
created_at DateTime @default(now())
updated_at DateTime @updatedAt
id Int @id @default(autoincrement())
course_id Int
title Json // { th: "", en: "" }
description Json?
sort_order Int @default(0)
is_published Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime? @updatedAt
course Course @relation(fields: [course_id], references: [id], onDelete: Cascade)
lessons Lesson[]
@@index([course_id])
@@index([order])
@@index([course_id, sort_order])
@@map("chapters")
}
@ -166,169 +234,184 @@ model Chapter {
enum LessonType {
VIDEO
TEXT
PDF
QUIZ
}
model Lesson {
id Int @id @default(autoincrement())
chapter_id Int
title Json // { th: "", en: "" }
description Json?
type LessonType
content Json? // For TEXT type
video_url String? @db.VarChar(500)
video_duration Int? // in seconds
order Int
is_preview Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
id Int @id @default(autoincrement())
chapter_id Int
title Json // { th: "", en: "" }
content Json? // multi-language lesson content
type LessonType
duration_minutes Int?
sort_order Int @default(0)
is_sequential Boolean @default(true)
prerequisite_lesson_ids Json? // array of lesson IDs
require_pass_quiz Boolean @default(false)
is_published Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime? @updatedAt
chapter Chapter @relation(fields: [chapter_id], references: [id], onDelete: Cascade)
attachments Attachment[]
prerequisites LessonPrerequisite[] @relation("LessonPrerequisites")
required_for LessonPrerequisite[] @relation("RequiredForLessons")
quiz Quiz?
progress LessonProgress[]
chapter Chapter @relation(fields: [chapter_id], references: [id], onDelete: Cascade)
attachments LessonAttachment[]
quiz Quiz?
progress LessonProgress[]
@@index([chapter_id])
@@index([order])
@@index([chapter_id, sort_order])
@@index([type])
@@map("lessons")
}
model LessonPrerequisite {
id Int @id @default(autoincrement())
lesson_id Int
prerequisite_lesson_id Int
created_at DateTime @default(now())
lesson Lesson @relation("LessonPrerequisites", fields: [lesson_id], references: [id], onDelete: Cascade)
prerequisite_lesson Lesson @relation("RequiredForLessons", fields: [prerequisite_lesson_id], references: [id], onDelete: Cascade)
@@unique([lesson_id, prerequisite_lesson_id])
@@index([lesson_id])
@@index([prerequisite_lesson_id])
@@map("lesson_prerequisites")
}
model Attachment {
id Int @id @default(autoincrement())
lesson_id Int
filename String @db.VarChar(255)
original_name String @db.VarChar(255)
file_url String @db.VarChar(500)
file_size BigInt
mime_type String @db.VarChar(100)
created_at DateTime @default(now())
model LessonAttachment {
id Int @id @default(autoincrement())
lesson_id Int
file_name String @db.VarChar(255)
file_path String @db.VarChar(500)
file_size Int
mime_type String @db.VarChar(100)
description Json?
sort_order Int @default(0)
created_at DateTime @default(now())
lesson Lesson @relation(fields: [lesson_id], references: [id], onDelete: Cascade)
@@index([lesson_id])
@@map("attachments")
@@index([lesson_id, sort_order])
@@map("lesson_attachments")
}
// ============================================
// Quiz System
// ============================================
enum ScorePolicy {
HIGHEST
LATEST
AVERAGE
enum QuestionType {
MULTIPLE_CHOICE
TRUE_FALSE
SHORT_ANSWER
}
model Quiz {
id Int @id @default(autoincrement())
lesson_id Int @unique
title Json // { th: "", en: "" }
description Json?
passing_score Int @default(70)
time_limit Int? // in minutes
max_attempts Int @default(3)
cooldown_hours Int @default(24)
score_policy ScorePolicy @default(HIGHEST)
shuffle_questions Boolean @default(true)
shuffle_choices Boolean @default(true)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
id Int @id @default(autoincrement())
lesson_id Int @unique
title Json // { th: "", en: "" }
description Json?
passing_score Int @default(60)
time_limit Int? // in minutes
shuffle_questions Boolean @default(false)
shuffle_choices Boolean @default(false)
show_answers_after_completion Boolean @default(true)
created_at DateTime @default(now())
created_by Int
updated_at DateTime? @updatedAt
updated_by Int?
lesson Lesson @relation(fields: [lesson_id], references: [id], onDelete: Cascade)
questions QuizQuestion[]
lesson Lesson @relation(fields: [lesson_id], references: [id], onDelete: Cascade)
creator User @relation("QuizCreator", fields: [created_by], references: [id], onDelete: Restrict)
updater User? @relation("QuizUpdater", fields: [updated_by], references: [id])
questions Question[]
attempts QuizAttempt[]
@@index([lesson_id])
@@map("quizzes")
}
model QuizQuestion {
id Int @id @default(autoincrement())
quiz_id Int
question Json // { th: "", en: "" }
choices Json // [{ th: "", en: "", is_correct: boolean }]
points Int @default(1)
order Int
created_at DateTime @default(now())
updated_at DateTime @updatedAt
quiz Quiz @relation(fields: [quiz_id], references: [id], onDelete: Cascade)
@@index([quiz_id])
@@index([order])
@@map("quiz_questions")
}
model QuizAttempt {
id Int @id @default(autoincrement())
model Question {
id Int @id @default(autoincrement())
quiz_id Int
user_id Int
answers Json // [{ question_id: int, choice_index: int }]
score Int
passed Boolean
time_spent Int? // in seconds
started_at DateTime
completed_at DateTime @default(now())
question Json // { th: "", en: "" }
explanation Json? // answer explanation
question_type QuestionType @default(MULTIPLE_CHOICE)
score Int @default(1)
sort_order Int @default(0)
created_at DateTime @default(now())
updated_at DateTime? @updatedAt
quiz Quiz @relation(fields: [quiz_id], references: [id], onDelete: Cascade)
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
quiz Quiz @relation(fields: [quiz_id], references: [id], onDelete: Cascade)
choices Choice[]
@@index([quiz_id])
@@index([user_id])
@@index([completed_at])
@@map("quiz_attempts")
@@index([quiz_id, sort_order])
@@map("questions")
}
model Choice {
id Int @id @default(autoincrement())
question_id Int
text Json // { th: "", en: "" }
is_correct Boolean @default(false)
sort_order Int @default(0)
question Question @relation(fields: [question_id], references: [id], onDelete: Cascade)
@@index([question_id])
@@map("choices")
}
// ============================================
// Progress Tracking
// Student Progress
// ============================================
enum EnrollmentStatus {
ENROLLED
IN_PROGRESS
COMPLETED
DROPPED
}
model Enrollment {
id Int @id @default(autoincrement())
user_id Int
course_id Int
enrolled_at DateTime @default(now())
completed_at DateTime?
progress_percent Int @default(0)
last_accessed_at DateTime @default(now())
id Int @id @default(autoincrement())
user_id Int
course_id Int
status EnrollmentStatus @default(ENROLLED)
progress_percentage Int @default(0)
enrolled_at DateTime @default(now())
started_at DateTime?
completed_at DateTime?
last_accessed_at DateTime?
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
course Course @relation(fields: [course_id], references: [id], onDelete: Cascade)
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
course Course @relation(fields: [course_id], references: [id], onDelete: Cascade)
certificates Certificate[]
@@unique([user_id, course_id])
@@unique([user_id, course_id], name: "unique_enrollment")
@@index([user_id])
@@index([course_id])
@@index([status])
@@index([last_accessed_at])
@@map("enrollments")
}
model Certificate {
id Int @id @default(autoincrement())
user_id Int
course_id Int
enrollment_id Int @unique
file_path String @db.VarChar(500)
issued_at DateTime @default(now())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
enrollment Enrollment @relation(fields: [enrollment_id], references: [id], onDelete: Cascade)
@@index([user_id])
@@index([course_id])
@@index([enrollment_id])
@@index([user_id, course_id])
@@map("certificates")
}
model LessonProgress {
id Int @id @default(autoincrement())
user_id Int
lesson_id Int
is_completed Boolean @default(false)
video_progress Int @default(0) // seconds watched
completed_at DateTime?
last_watched_at DateTime @default(now())
created_at DateTime @default(now())
updated_at DateTime @updatedAt
id Int @id @default(autoincrement())
user_id Int
lesson_id Int
is_completed Boolean @default(false)
completed_at DateTime?
video_progress_seconds Int @default(0)
video_duration_seconds Int?
video_progress_percentage Decimal? @db.Decimal(5, 2)
last_watched_at DateTime?
created_at DateTime @default(now())
updated_at DateTime? @updatedAt
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
lesson Lesson @relation(fields: [lesson_id], references: [id], onDelete: Cascade)
@ -336,19 +419,190 @@ model LessonProgress {
@@unique([user_id, lesson_id])
@@index([user_id])
@@index([lesson_id])
@@index([last_watched_at])
@@map("lesson_progress")
}
model Certificate {
id Int @id @default(autoincrement())
user_id Int
course_id Int
certificate_url String @db.VarChar(500)
issued_at DateTime @default(now())
model QuizAttempt {
id Int @id @default(autoincrement())
user_id Int
quiz_id Int
score Int @default(0)
total_questions Int
correct_answers Int @default(0)
is_passed Boolean @default(false)
attempt_number Int @default(1)
answers Json? // student answers for review
started_at DateTime @default(now())
completed_at DateTime?
quiz Quiz @relation(fields: [quiz_id], references: [id], onDelete: Cascade)
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@unique([user_id, course_id])
@@index([user_id])
@@map("certificates")
@@index([quiz_id])
@@index([user_id, quiz_id])
@@index([user_id, quiz_id, attempt_number])
@@map("quiz_attempts")
}
// ============================================
// Communication
// ============================================
enum AnnouncementStatus {
DRAFT
PUBLISHED
ARCHIVED
}
model Announcement {
id Int @id @default(autoincrement())
course_id Int
title Json // { th: "", en: "" }
content Json // { th: "", en: "" }
status AnnouncementStatus @default(DRAFT)
is_pinned Boolean @default(false)
published_at DateTime?
created_at DateTime @default(now())
created_by Int
updated_at DateTime? @updatedAt
updated_by Int?
course Course @relation(fields: [course_id], references: [id], onDelete: Cascade)
creator User @relation("AnnouncementCreator", fields: [created_by], references: [id], onDelete: Restrict)
updater User? @relation("AnnouncementUpdater", fields: [updated_by], references: [id])
attachments AnnouncementAttachment[]
@@index([course_id])
@@index([created_by])
@@index([status])
@@index([course_id, status, is_pinned, published_at])
@@map("announcements")
}
model AnnouncementAttachment {
id Int @id @default(autoincrement())
announcement_id Int
file_name String @db.VarChar(255)
file_path String @db.VarChar(500)
file_size Int
mime_type String @db.VarChar(100)
created_at DateTime @default(now())
announcement Announcement @relation(fields: [announcement_id], references: [id], onDelete: Cascade)
@@index([announcement_id])
@@map("announcement_attachments")
}
// ============================================
// Payment System
// ============================================
enum OrderStatus {
PENDING
PAID
CANCELLED
REFUNDED
}
enum PaymentStatus {
PENDING
SUCCESS
FAILED
}
enum WithdrawalStatus {
PENDING
APPROVED
REJECTED
PAID
}
model Order {
id Int @id @default(autoincrement())
user_id Int
total_amount Decimal @default(0) @db.Decimal(10, 2)
status OrderStatus @default(PENDING)
created_at DateTime @default(now())
updated_at DateTime? @updatedAt
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
items OrderItem[]
payments Payment[]
@@index([user_id])
@@index([status])
@@index([user_id, status])
@@map("orders")
}
model OrderItem {
id Int @id @default(autoincrement())
order_id Int
course_id Int
price Decimal @db.Decimal(10, 2)
created_at DateTime @default(now())
order Order @relation(fields: [order_id], references: [id], onDelete: Cascade)
@@index([order_id])
@@index([course_id])
@@map("order_items")
}
model Payment {
id Int @id @default(autoincrement())
order_id Int
provider String @db.VarChar(50)
transaction_id String? @unique @db.VarChar(255)
amount Decimal @db.Decimal(10, 2)
status PaymentStatus @default(PENDING)
paid_at DateTime?
created_at DateTime @default(now())
updated_at DateTime? @updatedAt
order Order @relation(fields: [order_id], references: [id], onDelete: Cascade)
@@index([order_id])
@@index([transaction_id])
@@index([status])
@@map("payments")
}
model InstructorBalance {
id Int @id @default(autoincrement())
instructor_id Int @unique
available_amount Decimal @default(0) @db.Decimal(10, 2)
withdrawn_amount Decimal @default(0) @db.Decimal(10, 2)
created_at DateTime @default(now())
updated_at DateTime? @updatedAt
instructor User @relation(fields: [instructor_id], references: [id], onDelete: Cascade)
@@index([instructor_id])
@@map("instructor_balances")
}
model WithdrawalRequest {
id Int @id @default(autoincrement())
instructor_id Int
amount Decimal @db.Decimal(10, 2)
status WithdrawalStatus @default(PENDING)
approved_by Int?
approved_at DateTime?
rejected_reason String? @db.Text
created_at DateTime @default(now())
updated_at DateTime? @updatedAt
updated_by Int?
instructor User @relation("WithdrawalInstructor", fields: [instructor_id], references: [id], onDelete: Cascade)
approver User? @relation("WithdrawalApprover", fields: [approved_by], references: [id])
updater User? @relation("WithdrawalUpdater", fields: [updated_by], references: [id])
@@index([instructor_id])
@@index([status])
@@index([instructor_id, status])
@@map("withdrawal_requests")
}

View file

@ -7,25 +7,32 @@ async function main() {
console.log('🌱 Starting database seeding...');
// Clear existing data (in development only)
if (process.env.NODE_ENV === 'development') {
console.log('🗑️ Clearing existing data...');
await prisma.quizAttempt.deleteMany();
await prisma.quizQuestion.deleteMany();
await prisma.quiz.deleteMany();
await prisma.lessonProgress.deleteMany();
await prisma.attachment.deleteMany();
await prisma.lessonPrerequisite.deleteMany();
await prisma.lesson.deleteMany();
await prisma.chapter.deleteMany();
await prisma.enrollment.deleteMany();
await prisma.courseInstructor.deleteMany();
await prisma.course.deleteMany();
await prisma.category.deleteMany();
await prisma.certificate.deleteMany();
await prisma.profile.deleteMany();
await prisma.user.deleteMany();
await prisma.role.deleteMany();
}
// if (process.env.NODE_ENV === 'development') {
// console.log('🗑️ Clearing existing data...');
// await prisma.quizAttempt.deleteMany();
// await prisma.choice.deleteMany();
// await prisma.question.deleteMany();
// await prisma.quiz.deleteMany();
// await prisma.lessonProgress.deleteMany();
// await prisma.lessonAttachment.deleteMany();
// await prisma.lesson.deleteMany();
// await prisma.chapter.deleteMany();
// await prisma.announcementAttachment.deleteMany();
// await prisma.announcement.deleteMany();
// await prisma.certificate.deleteMany();
// await prisma.enrollment.deleteMany();
// await prisma.courseInstructor.deleteMany();
// await prisma.course.deleteMany();
// await prisma.category.deleteMany();
// await prisma.payment.deleteMany();
// await prisma.orderItem.deleteMany();
// await prisma.order.deleteMany();
// await prisma.withdrawalRequest.deleteMany();
// await prisma.instructorBalance.deleteMany();
// await prisma.userProfile.deleteMany();
// await prisma.user.deleteMany();
// await prisma.role.deleteMany();
// }
// Seed Roles
console.log('👥 Seeding roles...');
@ -63,11 +70,12 @@ async function main() {
email: 'admin@elearning.local',
password: hashedPassword,
role_id: roles[0].id,
email_verified_at: new Date(),
profile: {
create: {
prefix: { th: 'นาย', en: 'Mr.' },
first_name: 'Admin',
last_name: 'User',
bio: { th: 'ผู้ดูแลระบบ', en: 'System Administrator' }
last_name: 'User'
}
}
}
@ -79,11 +87,12 @@ async function main() {
email: 'instructor@elearning.local',
password: hashedPassword,
role_id: roles[1].id,
email_verified_at: new Date(),
profile: {
create: {
prefix: { th: 'นาย', en: 'Mr.' },
first_name: 'John',
last_name: 'Doe',
bio: { th: 'ผู้สอนมืออาชีพ', en: 'Professional Instructor' }
last_name: 'Doe'
}
}
}
@ -95,11 +104,12 @@ async function main() {
email: 'student@elearning.local',
password: hashedPassword,
role_id: roles[2].id,
email_verified_at: new Date(),
profile: {
create: {
prefix: { th: 'นางสาว', en: 'Ms.' },
first_name: 'Jane',
last_name: 'Smith',
bio: { th: 'นักเรียนที่กระตือรือร้น', en: 'Eager learner' }
last_name: 'Smith'
}
}
}
@ -110,23 +120,32 @@ async function main() {
const categories = await Promise.all([
prisma.category.create({
data: {
code: 'PROGRAMMING',
name: { th: 'การเขียนโปรแกรม', en: 'Programming' },
description: { th: 'เรียนรู้การเขียนโปรแกรมและพัฒนาซอฟต์แวร์', en: 'Learn programming and software development' }
slug: 'programming',
description: { th: 'เรียนรู้การเขียนโปรแกรมและพัฒนาซอฟต์แวร์', en: 'Learn programming and software development' },
icon: 'code',
sort_order: 1,
created_by: admin.id
}
}),
prisma.category.create({
data: {
code: 'DESIGN',
name: { th: 'การออกแบบ', en: 'Design' },
description: { th: 'เรียนรู้การออกแบบกราฟิกและ UI/UX', en: 'Learn graphic design and UI/UX' }
slug: 'design',
description: { th: 'เรียนรู้การออกแบบกราฟิกและ UI/UX', en: 'Learn graphic design and UI/UX' },
icon: 'palette',
sort_order: 2,
created_by: admin.id
}
}),
prisma.category.create({
data: {
code: 'BUSINESS',
name: { th: 'ธุรกิจ', en: 'Business' },
description: { th: 'เรียนรู้การบริหารธุรกิจและการตลาด', en: 'Learn business management and marketing' }
slug: 'business',
description: { th: 'เรียนรู้การบริหารธุรกิจและการตลาด', en: 'Learn business management and marketing' },
icon: 'briefcase',
sort_order: 3,
created_by: admin.id
}
})
]);
@ -136,9 +155,10 @@ async function main() {
const course = await prisma.course.create({
data: {
title: { th: 'พื้นฐาน JavaScript', en: 'JavaScript Fundamentals' },
slug: 'javascript-fundamentals',
description: {
th: 'เรียนรู้พื้นฐาน JavaScript ตั้งแต่เริ่มต้น',
en: 'Learn JavaScript fundamentals from scratch'
th: 'เรียนรู้พื้นฐาน JavaScript ตั้งแต่เริ่มต้น รวมถึงตัวแปร ฟังก์ชัน และการจัดการ DOM',
en: 'Learn JavaScript fundamentals from scratch including variables, functions, and DOM manipulation'
},
price: 0,
is_free: true,
@ -146,6 +166,8 @@ async function main() {
status: 'APPROVED',
category_id: categories[0].id,
created_by: instructor.id,
approved_by: admin.id,
approved_at: new Date(),
instructors: {
create: {
user_id: instructor.id,
@ -156,43 +178,177 @@ async function main() {
create: [
{
title: { th: 'บทที่ 1: เริ่มต้น', en: 'Chapter 1: Getting Started' },
description: { th: 'แนะนำ JavaScript', en: 'Introduction to JavaScript' },
order: 1,
description: { th: 'แนะนำ JavaScript และการตั้งค่าสภาพแวดล้อม', en: 'Introduction to JavaScript and environment setup' },
sort_order: 1,
is_published: true,
lessons: {
create: [
{
title: { th: 'JavaScript คืออะไร', en: 'What is JavaScript' },
description: { th: 'เรียนรู้ว่า JavaScript คืออะไร', en: 'Learn what JavaScript is' },
content: {
th: 'JavaScript เป็นภาษาโปรแกรมที่ใช้ในการพัฒนาเว็บไซต์',
en: 'JavaScript is a programming language used for web development'
},
type: 'VIDEO',
order: 1,
is_preview: true
duration_minutes: 15,
sort_order: 1,
is_published: true
},
{
title: { th: 'ตัวแปรและชนิดข้อมูล', en: 'Variables and Data Types' },
description: { th: 'เรียนรู้เกี่ยวกับตัวแปร', en: 'Learn about variables' },
content: {
th: 'เรียนรู้เกี่ยวกับตัวแปร let, const และชนิดข้อมูลต่างๆ',
en: 'Learn about let, const variables and different data types'
},
type: 'VIDEO',
order: 2
duration_minutes: 20,
sort_order: 2,
is_published: true
},
{
title: { th: 'แบบทดสอบบทที่ 1', en: 'Chapter 1 Quiz' },
type: 'QUIZ',
duration_minutes: 10,
sort_order: 3,
is_published: true,
require_pass_quiz: true
}
]
}
},
{
title: { th: 'บทที่ 2: ฟังก์ชัน', en: 'Chapter 2: Functions' },
description: { th: 'เรียนรู้เกี่ยวกับฟังก์ชัน', en: 'Learn about functions' },
order: 2,
description: { th: 'เรียนรู้เกี่ยวกับฟังก์ชันและการใช้งาน', en: 'Learn about functions and their usage' },
sort_order: 2,
is_published: true,
lessons: {
create: [
{
title: { th: 'การสร้างฟังก์ชัน', en: 'Creating Functions' },
description: { th: 'เรียนรู้วิธีสร้างฟังก์ชัน', en: 'Learn how to create functions' },
content: {
th: 'เรียนรู้วิธีสร้างและเรียกใช้ฟังก์ชัน',
en: 'Learn how to create and call functions'
},
type: 'VIDEO',
order: 1
duration_minutes: 25,
sort_order: 1,
is_published: true
}
]
}
}
]
}
},
include: {
chapters: {
include: {
lessons: true
}
}
}
});
// Create a quiz for the quiz lesson
const quizLesson = await prisma.lesson.findFirst({
where: {
title: {
path: ['en'],
equals: 'Chapter 1 Quiz'
}
}
});
if (quizLesson) {
console.log('📝 Creating quiz...');
await prisma.quiz.create({
data: {
lesson_id: quizLesson.id,
title: { th: 'แบบทดสอบบทที่ 1', en: 'Chapter 1 Quiz' },
description: { th: 'ทดสอบความเข้าใจเกี่ยวกับพื้นฐาน JavaScript', en: 'Test your understanding of JavaScript basics' },
passing_score: 70,
time_limit: 10,
shuffle_questions: true,
shuffle_choices: true,
created_by: instructor.id,
questions: {
create: [
{
question: {
th: 'JavaScript ใช้สำหรับอะไร?',
en: 'What is JavaScript used for?'
},
question_type: 'MULTIPLE_CHOICE',
score: 1,
sort_order: 1,
choices: {
create: [
{
text: { th: 'พัฒนาเว็บไซต์', en: 'Web development' },
is_correct: true,
sort_order: 1
},
{
text: { th: 'ทำกาแฟ', en: 'Making coffee' },
is_correct: false,
sort_order: 2
},
{
text: { th: 'ขับรถ', en: 'Driving cars' },
is_correct: false,
sort_order: 3
}
]
}
},
{
question: {
th: 'ตัวแปรใน JavaScript ประกาศด้วยคำสั่งใด?',
en: 'Which keyword is used to declare variables in JavaScript?'
},
question_type: 'MULTIPLE_CHOICE',
score: 1,
sort_order: 2,
choices: {
create: [
{
text: { th: 'let และ const', en: 'let and const' },
is_correct: true,
sort_order: 1
},
{
text: { th: 'int และ float', en: 'int and float' },
is_correct: false,
sort_order: 2
},
{
text: { th: 'variable', en: 'variable' },
is_correct: false,
sort_order: 3
}
]
}
}
]
}
}
});
}
// Create announcement
console.log('📢 Creating announcement...');
await prisma.announcement.create({
data: {
course_id: course.id,
title: { th: 'ยินดีต้อนรับสู่คอร์ส', en: 'Welcome to the Course' },
content: {
th: 'ยินดีต้อนรับทุกคนสู่คอร์ส JavaScript Fundamentals! เราจะเริ่มเรียนในสัปดาห์หน้า',
en: 'Welcome everyone to JavaScript Fundamentals! We will start next week'
},
status: 'PUBLISHED',
is_pinned: true,
published_at: new Date(),
created_by: instructor.id
}
});
@ -202,6 +358,10 @@ async function main() {
console.log(`- Users: 3 (admin, instructor, student)`);
console.log(`- Categories: ${categories.length}`);
console.log(`- Courses: 1`);
console.log(`- Chapters: 2`);
console.log(`- Lessons: 4`);
console.log(`- Quizzes: 1 (with 2 questions)`);
console.log(`- Announcements: 1`);
console.log('\n🔑 Test Credentials:');
console.log('Admin: admin / admin123');
console.log('Instructor: instructor / admin123');

View file

@ -1,109 +0,0 @@
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const swaggerUi = require('swagger-ui-express');
const logger = require('./config/logger');
const { errorHandler, notFoundHandler } = require('./middleware/errorHandler');
const swaggerSpec = require('./config/swagger');
// Import routes
const authRoutes = require('./routes/auth.routes');
const app = express();
// ============================================
// Middleware
// ============================================
// Security headers
app.use(helmet());
// CORS
const corsOptions = {
origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:3000'],
credentials: true,
};
app.use(cors(corsOptions));
// Body parser
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Rate limiting
const limiter = rateLimit({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100,
message: {
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: 'Too many requests, please try again later',
},
},
});
app.use('/api/', limiter);
// Request logging
app.use((req, res, next) => {
logger.http(`${req.method} ${req.url}`);
next();
});
// ============================================
// Routes
// ============================================
/**
* @swagger
* /health:
* get:
* summary: Health check endpoint
* tags: [Health]
* description: Returns the health status of the API server
* responses:
* 200:
* description: Server is healthy
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: ok
* timestamp:
* type: string
* format: date-time
* uptime:
* type: number
* example: 123.456
* environment:
* type: string
* example: development
*/
app.get('/health', (req, res) => {
res.status(200).json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: process.env.NODE_ENV,
});
});
// API Documentation
const swaggerOptions = {
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'E-Learning API Documentation',
};
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, swaggerOptions));
// API routes
app.use('/api/auth', authRoutes);
// 404 handler
app.use(notFoundHandler);
// Error handler (must be last)
app.use(errorHandler);
module.exports = app;

83
Backend/src/app.ts Normal file
View file

@ -0,0 +1,83 @@
import express, { Application, Request, Response } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import swaggerUi from 'swagger-ui-express';
import { config } from './config';
import { logger } from './config/logger';
import { errorHandler } from './middleware/errorHandler';
import { RegisterRoutes } from './routes/routes';
export function createApp(): Application {
const app = express();
// Security middleware
app.use(helmet());
app.use(cors({
origin: config.cors.origin,
credentials: true
}));
// Rate limiting
const limiter = rateLimit({
windowMs: config.rateLimit.windowMs,
max: config.rateLimit.maxRequests,
message: {
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: 'Too many requests, please try again later'
}
}
});
app.use('/api/', limiter);
// Body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Request logging
app.use((req: Request, res: Response, next) => {
logger.info('Incoming request', {
method: req.method,
path: req.path,
ip: req.ip
});
next();
});
// Swagger documentation
try {
const swaggerDocument = require('../public/swagger.json');
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
logger.info('Swagger documentation available at /api-docs');
} catch (error) {
logger.warn('Swagger documentation not available. Run "npm run tsoa:gen" to generate it.');
}
// Simple health check endpoint (not using TSOA)
app.get('/health', (req: Request, res: Response) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime()
});
});
// Register TSOA routes
RegisterRoutes(app);
// 404 handler
app.use((req: Request, res: Response) => {
res.status(404).json({
error: {
code: 'NOT_FOUND',
message: 'Route not found'
}
});
});
// Error handler (must be last)
app.use(errorHandler);
return app;
}

View file

@ -1,51 +0,0 @@
const { PrismaClient } = require('@prisma/client');
const logger = require('./logger');
const prisma = new PrismaClient({
log: [
{
emit: 'event',
level: 'query',
},
{
emit: 'event',
level: 'error',
},
{
emit: 'event',
level: 'info',
},
{
emit: 'event',
level: 'warn',
},
],
});
// Log Prisma queries in development
if (process.env.NODE_ENV === 'development') {
prisma.$on('query', (e) => {
logger.debug(`Query: ${e.query}`);
logger.debug(`Duration: ${e.duration}ms`);
});
}
prisma.$on('error', (e) => {
logger.error(`Prisma Error: ${e.message}`);
});
prisma.$on('warn', (e) => {
logger.warn(`Prisma Warning: ${e.message}`);
});
// Test connection
prisma.$connect()
.then(() => {
logger.info('✅ Database connected successfully');
})
.catch((error) => {
logger.error('❌ Database connection failed:', error);
process.exit(1);
});
module.exports = prisma;

View file

@ -0,0 +1,40 @@
import { PrismaClient } from '@prisma/client';
import { logger } from './logger';
const prisma = new PrismaClient({
log: [
{
emit: 'event',
level: 'query',
},
{
emit: 'event',
level: 'error',
},
{
emit: 'event',
level: 'warn',
},
],
});
// Log queries in development
if (process.env.NODE_ENV === 'development') {
prisma.$on('query' as never, (e: any) => {
logger.debug('Prisma Query', {
query: e.query,
params: e.params,
duration: `${e.duration}ms`
});
});
}
prisma.$on('error' as never, (e: any) => {
logger.error('Prisma Error', { error: e });
});
prisma.$on('warn' as never, (e: any) => {
logger.warn('Prisma Warning', { warning: e });
});
export { prisma };

View file

@ -0,0 +1,75 @@
import dotenv from 'dotenv';
dotenv.config();
// Validate required environment variables
const requiredEnvVars = [
'DATABASE_URL',
'JWT_SECRET',
'PORT'
];
requiredEnvVars.forEach(key => {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`);
}
});
export const config = {
// Application
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '4000', 10),
appUrl: process.env.APP_URL || 'http://localhost:4000',
// Database
databaseUrl: process.env.DATABASE_URL!,
// Redis
redis: {
url: process.env.REDIS_URL || 'redis://localhost:6379',
password: process.env.REDIS_PASSWORD
},
// MinIO/S3
s3: {
endpoint: process.env.S3_ENDPOINT || 'http://localhost:9000',
accessKey: process.env.S3_ACCESS_KEY || 'minioadmin',
secretKey: process.env.S3_SECRET_KEY || 'minioadmin',
bucket: process.env.S3_BUCKET || 'e-learning',
useSSL: process.env.S3_USE_SSL === 'true'
},
// JWT
jwt: {
secret: process.env.JWT_SECRET!,
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d'
},
// Email
smtp: {
host: process.env.SMTP_HOST || 'localhost',
port: parseInt(process.env.SMTP_PORT || '1025', 10),
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
from: process.env.SMTP_FROM || 'noreply@elearning.local'
},
// File Upload
upload: {
maxVideoSize: parseInt(process.env.MAX_VIDEO_SIZE || '524288000', 10), // 500MB
maxAttachmentSize: parseInt(process.env.MAX_ATTACHMENT_SIZE || '104857600', 10), // 100MB
maxAttachmentsPerLesson: parseInt(process.env.MAX_ATTACHMENTS_PER_LESSON || '10', 10)
},
// CORS
cors: {
origin: process.env.CORS_ORIGIN || 'http://localhost:3000'
},
// Rate Limiting
rateLimit: {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10)
}
};

View file

@ -1,45 +0,0 @@
const winston = require('winston');
const logLevels = {
error: 0,
warn: 1,
info: 2,
http: 3,
debug: 4,
};
const logColors = {
error: 'red',
warn: 'yellow',
info: 'green',
http: 'magenta',
debug: 'blue',
};
winston.addColors(logColors);
const format = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.colorize({ all: true }),
winston.format.printf(
(info) => `${info.timestamp} ${info.level}: ${info.message}`
)
);
const transports = [
new winston.transports.Console(),
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
}),
new winston.transports.File({ filename: 'logs/all.log' }),
];
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
levels: logLevels,
format,
transports,
});
module.exports = logger;

View file

@ -0,0 +1,44 @@
import winston from 'winston';
import { config } from './index';
const logFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.splat(),
winston.format.json()
);
const consoleFormat = winston.format.combine(
winston.format.colorize(),
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
let msg = `${timestamp} [${level}]: ${message}`;
if (Object.keys(meta).length > 0) {
msg += ` ${JSON.stringify(meta)}`;
}
return msg;
})
);
export const logger = winston.createLogger({
level: config.nodeEnv === 'production' ? 'info' : 'debug',
format: logFormat,
transports: [
new winston.transports.Console({
format: consoleFormat
}),
new winston.transports.File({
filename: 'logs/error.log',
level: 'error'
}),
new winston.transports.File({
filename: 'logs/combined.log'
})
]
});
// Don't log to files in development
if (config.nodeEnv !== 'production') {
logger.clear();
logger.add(new winston.transports.Console({ format: consoleFormat }));
}

View file

@ -1,36 +0,0 @@
const redis = require('redis');
const logger = require('./logger');
let redisClient = null;
async function connectRedis() {
try {
redisClient = redis.createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',
});
redisClient.on('error', (err) => {
logger.error('Redis Client Error:', err);
});
redisClient.on('connect', () => {
logger.info('✅ Redis connected successfully');
});
await redisClient.connect();
return redisClient;
} catch (error) {
logger.error('❌ Redis connection failed:', error);
// Don't exit, allow app to run without Redis
return null;
}
}
function getRedisClient() {
return redisClient;
}
module.exports = {
connectRedis,
getRedisClient,
};

View file

@ -1,216 +0,0 @@
const swaggerJsdoc = require('swagger-jsdoc');
const options = {
definition: {
openapi: '3.0.0',
info: {
title: 'E-Learning Platform API',
version: '1.0.0',
description: 'API documentation for E-Learning Platform Backend',
contact: {
name: 'API Support',
email: 'support@elearning.local',
},
license: {
name: 'MIT',
url: 'https://opensource.org/licenses/MIT',
},
},
servers: [
{
url: process.env.APP_URL || 'http://localhost:4000',
description: 'Development server',
},
],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Enter your JWT token',
},
},
schemas: {
Error: {
type: 'object',
properties: {
error: {
type: 'object',
properties: {
code: {
type: 'string',
example: 'VALIDATION_ERROR',
},
message: {
type: 'string',
example: 'Invalid input data',
},
details: {
type: 'array',
items: {
type: 'object',
properties: {
field: {
type: 'string',
},
message: {
type: 'string',
},
},
},
},
},
},
},
},
User: {
type: 'object',
properties: {
id: {
type: 'integer',
example: 1,
},
username: {
type: 'string',
example: 'john_doe',
},
email: {
type: 'string',
format: 'email',
example: 'john@example.com',
},
role_id: {
type: 'integer',
example: 3,
},
is_active: {
type: 'boolean',
example: true,
},
created_at: {
type: 'string',
format: 'date-time',
},
updated_at: {
type: 'string',
format: 'date-time',
},
role: {
$ref: '#/components/schemas/Role',
},
profile: {
$ref: '#/components/schemas/Profile',
},
},
},
Role: {
type: 'object',
properties: {
id: {
type: 'integer',
example: 3,
},
code: {
type: 'string',
example: 'STUDENT',
},
name: {
type: 'object',
properties: {
th: {
type: 'string',
example: 'นักเรียน',
},
en: {
type: 'string',
example: 'Student',
},
},
},
description: {
type: 'object',
properties: {
th: {
type: 'string',
},
en: {
type: 'string',
},
},
},
},
},
Profile: {
type: 'object',
properties: {
id: {
type: 'integer',
},
user_id: {
type: 'integer',
},
first_name: {
type: 'string',
example: 'John',
},
last_name: {
type: 'string',
example: 'Doe',
},
phone: {
type: 'string',
example: '+66812345678',
},
avatar_url: {
type: 'string',
format: 'uri',
},
bio: {
type: 'object',
properties: {
th: {
type: 'string',
},
en: {
type: 'string',
},
},
},
},
},
AuthResponse: {
type: 'object',
properties: {
user: {
$ref: '#/components/schemas/User',
},
accessToken: {
type: 'string',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
},
refreshToken: {
type: 'string',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
},
},
},
},
},
tags: [
{
name: 'Authentication',
description: 'User authentication and authorization endpoints',
},
{
name: 'Health',
description: 'System health check endpoints',
},
],
},
apis: ['./src/routes/*.js', './src/app.js'],
};
const swaggerSpec = swaggerJsdoc(options);
module.exports = swaggerSpec;

View file

@ -0,0 +1,128 @@
import { Body, Post, Route, Tags, SuccessResponse, Response, Example } from 'tsoa';
import { AuthService } from '../services/auth.service';
import {
LoginRequest,
RegisterRequest,
RefreshTokenRequest,
LoginResponse,
RegisterResponse,
RefreshTokenResponse
} from '../types/auth.types';
import { loginSchema, registerSchema, refreshTokenSchema } from '../validators/auth.validator';
import { ValidationError } from '../middleware/errorHandler';
@Route('api/auth')
@Tags('Authentication')
export class AuthController {
private authService = new AuthService();
/**
* User login
* @summary Login with username and password
* @param body Login credentials
* @returns JWT token and user information
*/
@Post('login')
@SuccessResponse('200', 'Login successful')
@Response('401', 'Invalid credentials')
@Example<LoginResponse>({
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
user: {
id: 1,
username: 'admin',
email: 'admin@elearning.local',
role: {
code: 'ADMIN',
name: {
th: 'ผู้ดูแลระบบ',
en: 'Administrator'
}
},
profile: {
prefix: {
th: 'นาย',
en: 'Mr.'
},
first_name: 'Admin',
last_name: 'User',
avatar_url: undefined
}
}
})
public async login(@Body() body: LoginRequest): Promise<LoginResponse> {
// Validate input
const { error } = loginSchema.validate(body);
if (error) {
throw new ValidationError(error.details[0].message);
}
return await this.authService.login(body);
}
/**
* User registration
* @summary Register a new student account
* @param body Registration data
* @returns Created user information
*/
@Post('register')
@SuccessResponse('201', 'Registration successful')
@Response('400', 'Validation error')
@Response('409', 'Username or email already exists')
@Example<RegisterResponse>({
user: {
id: 4,
username: 'newstudent',
email: 'student@example.com',
role: {
code: 'STUDENT',
name: {
th: 'นักเรียน',
en: 'Student'
}
},
profile: {
prefix: {
th: 'นาย',
en: 'Mr.'
},
first_name: 'John',
last_name: 'Doe'
}
},
message: 'Registration successful'
})
public async register(@Body() body: RegisterRequest): Promise<RegisterResponse> {
// Validate input
const { error } = registerSchema.validate(body);
if (error) {
throw new ValidationError(error.details[0].message);
}
return await this.authService.register(body);
}
/**
* Refresh access token
* @summary Get a new access token using refresh token
* @param body Refresh token
* @returns New access token and refresh token
*/
@Post('refresh')
@SuccessResponse('200', 'Token refreshed')
@Response('401', 'Invalid or expired refresh token')
@Example<RefreshTokenResponse>({
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
})
public async refreshToken(@Body() body: RefreshTokenRequest): Promise<RefreshTokenResponse> {
// Validate input
const { error } = refreshTokenSchema.validate(body);
if (error) {
throw new ValidationError(error.details[0].message);
}
return await this.authService.refreshToken(body.refreshToken);
}
}

View file

@ -1,82 +0,0 @@
const authService = require('../services/auth.service');
const logger = require('../config/logger');
class AuthController {
/**
* Register a new user
* POST /api/auth/register
*/
async register(req, res, next) {
try {
const { username, email, password } = req.body;
const result = await authService.register({
username,
email,
password,
});
logger.info(`User registered: ${username}`);
res.status(201).json(result);
} catch (error) {
next(error);
}
}
/**
* Login user
* POST /api/auth/login
*/
async login(req, res, next) {
try {
const { username, email, password } = req.body;
const result = await authService.login({
username,
email,
password,
});
logger.info(`User logged in: ${result.user.username}`);
res.status(200).json(result);
} catch (error) {
next(error);
}
}
/**
* Get current user profile
* GET /api/auth/me
*/
async getProfile(req, res, next) {
try {
const user = await authService.getProfile(req.user.id);
res.status(200).json(user);
} catch (error) {
next(error);
}
}
/**
* Logout user
* POST /api/auth/logout
*/
async logout(req, res, next) {
try {
// In a real implementation, you would invalidate the token
// For now, just return success
logger.info(`User logged out: ${req.user.username}`);
res.status(200).json({
message: 'Logged out successfully',
});
} catch (error) {
next(error);
}
}
}
module.exports = new AuthController();

View file

@ -1,91 +0,0 @@
const { verifyToken, extractToken } = require('../utils/jwt');
const prisma = require('../config/database');
const logger = require('../config/logger');
/**
* Authentication middleware
* Verifies JWT token and attaches user to request
*/
async function authenticate(req, res, next) {
try {
const token = extractToken(req.headers.authorization);
if (!token) {
return res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'Authentication required',
},
});
}
const decoded = verifyToken(token);
// Fetch user from database
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
include: { role: true },
});
if (!user || !user.is_active) {
return res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'Invalid or inactive user',
},
});
}
// Attach user to request
req.user = {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
};
next();
} catch (error) {
logger.error('Authentication error:', error.message);
return res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'Invalid or expired token',
},
});
}
}
/**
* Authorization middleware
* Checks if user has required role
* @param {string[]} allowedRoles - Array of allowed role codes
*/
function authorize(...allowedRoles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'Authentication required',
},
});
}
if (!allowedRoles.includes(req.user.role.code)) {
return res.status(403).json({
error: {
code: 'FORBIDDEN',
message: 'Insufficient permissions',
},
});
}
next();
};
}
module.exports = {
authenticate,
authorize,
};

View file

@ -0,0 +1,92 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { config } from '../config';
import { JWTPayload } from '../types';
import { logger } from '../config/logger';
export interface AuthRequest extends Request {
user?: JWTPayload;
}
export async function expressAuthentication(
request: Request,
securityName: string,
scopes?: string[]
): Promise<JWTPayload> {
if (securityName === 'jwt') {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new Error('No token provided');
}
try {
const decoded = jwt.verify(token, config.jwt.secret) as JWTPayload;
// Check if user has required role
if (scopes && scopes.length > 0) {
if (!scopes.includes(decoded.roleCode)) {
throw new Error('Insufficient permissions');
}
}
return decoded;
} catch (error) {
logger.error('JWT verification failed', { error });
throw new Error('Invalid token');
}
}
throw new Error('Unknown security name');
}
export function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'No token provided'
}
});
}
try {
const decoded = jwt.verify(token, config.jwt.secret) as JWTPayload;
req.user = decoded;
next();
} catch (error) {
logger.error('Authentication failed', { error });
return res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'Invalid token'
}
});
}
}
export function authorize(...roles: string[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'Not authenticated'
}
});
}
if (roles.length > 0 && !roles.includes(req.user.roleCode)) {
return res.status(403).json({
error: {
code: 'FORBIDDEN',
message: 'Insufficient permissions'
}
});
}
next();
};
}

View file

@ -1,85 +0,0 @@
const logger = require('../config/logger');
/**
* Global error handler middleware
*/
function errorHandler(err, req, res, next) {
logger.error('Error:', {
message: err.message,
stack: err.stack,
url: req.url,
method: req.method,
});
// Prisma errors
if (err.code && err.code.startsWith('P')) {
if (err.code === 'P2002') {
return res.status(409).json({
error: {
code: 'DUPLICATE_ENTRY',
message: 'A record with this value already exists',
field: err.meta?.target?.[0],
},
});
}
if (err.code === 'P2025') {
return res.status(404).json({
error: {
code: 'NOT_FOUND',
message: 'Record not found',
},
});
}
}
// Validation errors
if (err.isJoi || err.name === 'ValidationError') {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: err.message,
details: err.details?.map((d) => ({
field: d.path.join('.'),
message: d.message,
})),
},
});
}
// JWT errors
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
return res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'Invalid or expired token',
},
});
}
// Default error
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
error: {
code: err.code || 'INTERNAL_ERROR',
message: err.message || 'An unexpected error occurred',
},
});
}
/**
* 404 Not Found handler
*/
function notFoundHandler(req, res) {
res.status(404).json({
error: {
code: 'NOT_FOUND',
message: `Route ${req.method} ${req.url} not found`,
},
});
}
module.exports = {
errorHandler,
notFoundHandler,
};

View file

@ -0,0 +1,132 @@
import { Request, Response, NextFunction } from 'express';
import { ValidateError } from 'tsoa';
import { logger } from '../config/logger';
export function errorHandler(
err: any,
req: Request,
res: Response,
next: NextFunction
): Response | void {
// TSOA Validation Error
if (err instanceof ValidateError) {
logger.warn('Validation error', {
fields: err.fields,
path: req.path
});
return res.status(422).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Validation failed',
details: err.fields
}
});
}
// JWT Errors
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'Invalid token'
}
});
}
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
error: {
code: 'TOKEN_EXPIRED',
message: 'Token has expired'
}
});
}
// Prisma Errors
if (err.code === 'P2002') {
return res.status(409).json({
error: {
code: 'DUPLICATE_ENTRY',
message: 'A record with this value already exists',
details: err.meta
}
});
}
if (err.code === 'P2025') {
return res.status(404).json({
error: {
code: 'NOT_FOUND',
message: 'Record not found'
}
});
}
// Custom Application Errors
if (err.statusCode) {
return res.status(err.statusCode).json({
error: {
code: err.code || 'APPLICATION_ERROR',
message: err.message,
details: err.details
}
});
}
// Log unexpected errors
logger.error('Unexpected error', {
error: err.message,
stack: err.stack,
path: req.path,
method: req.method
});
// Default error
return res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred'
}
});
}
export class ApplicationError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: any
) {
super(message);
this.name = 'ApplicationError';
}
}
export class ValidationError extends ApplicationError {
constructor(message: string, details?: any) {
super(400, 'VALIDATION_ERROR', message, details);
this.name = 'ValidationError';
}
}
export class UnauthorizedError extends ApplicationError {
constructor(message = 'Unauthorized') {
super(401, 'UNAUTHORIZED', message);
this.name = 'UnauthorizedError';
}
}
export class ForbiddenError extends ApplicationError {
constructor(message = 'Forbidden') {
super(403, 'FORBIDDEN', message);
this.name = 'ForbiddenError';
}
}
export class NotFoundError extends ApplicationError {
constructor(message = 'Resource not found') {
super(404, 'NOT_FOUND', message);
this.name = 'NotFoundError';
}
}

View file

@ -1,200 +0,0 @@
const express = require('express');
const authController = require('../controllers/auth.controller');
const { authenticate } = require('../middleware/auth');
const Joi = require('joi');
const router = express.Router();
// Validation schemas
const registerSchema = Joi.object({
username: Joi.string().min(3).max(50).required(),
email: Joi.string().email().required(),
password: Joi.string().min(6).required(),
});
const loginSchema = Joi.object({
username: Joi.string().optional(),
email: Joi.string().email().optional(),
password: Joi.string().required(),
}).or('username', 'email');
// Validation middleware
function validate(schema) {
return (req, res, next) => {
const { error, value } = schema.validate(req.body);
if (error) {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: error.details[0].message,
details: error.details.map((d) => ({
field: d.path.join('.'),
message: d.message,
})),
},
});
}
req.body = value;
next();
};
}
/**
* @swagger
* /api/auth/register:
* post:
* summary: Register a new user
* tags: [Authentication]
* description: Create a new user account
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - username
* - email
* - password
* properties:
* username:
* type: string
* minLength: 3
* maxLength: 50
* example: john_doe
* email:
* type: string
* format: email
* example: john@example.com
* password:
* type: string
* minLength: 6
* example: password123
* responses:
* 201:
* description: User registered successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/AuthResponse'
* 400:
* description: Validation error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Username or email already exists
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.post('/register', validate(registerSchema), authController.register);
/**
* @swagger
* /api/auth/login:
* post:
* summary: Login user
* tags: [Authentication]
* description: Authenticate user and return JWT tokens
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - password
* properties:
* username:
* type: string
* example: admin
* email:
* type: string
* format: email
* example: admin@elearning.local
* password:
* type: string
* example: admin123
* oneOf:
* - required: [username]
* - required: [email]
* responses:
* 200:
* description: Login successful
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/AuthResponse'
* 401:
* description: Invalid credentials
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Account is inactive
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.post('/login', validate(loginSchema), authController.login);
/**
* @swagger
* /api/auth/me:
* get:
* summary: Get current user profile
* tags: [Authentication]
* description: Retrieve the authenticated user's profile information
* security:
* - bearerAuth: []
* responses:
* 200:
* description: User profile retrieved successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
* 401:
* description: Unauthorized - Invalid or missing token
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.get('/me', authenticate, authController.getProfile);
/**
* @swagger
* /api/auth/logout:
* post:
* summary: Logout user
* tags: [Authentication]
* description: Logout the authenticated user
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Logout successful
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Logged out successfully
* 401:
* description: Unauthorized - Invalid or missing token
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.post('/logout', authenticate, authController.logout);
module.exports = router;

View file

@ -1,65 +0,0 @@
require('dotenv').config();
const app = require('./app');
const logger = require('./config/logger');
const { connectRedis } = require('./config/redis');
const PORT = process.env.PORT || 4000;
// Validate required environment variables
const requiredEnvVars = ['DATABASE_URL', 'JWT_SECRET'];
requiredEnvVars.forEach((key) => {
if (!process.env[key]) {
logger.error(`Missing required environment variable: ${key}`);
process.exit(1);
}
});
// Start server
async function startServer() {
try {
// Connect to Redis (optional)
await connectRedis();
// Create logs directory
const fs = require('fs');
if (!fs.existsSync('logs')) {
fs.mkdirSync('logs');
}
// Start listening
app.listen(PORT, () => {
logger.info(`🚀 Server running on port ${PORT}`);
logger.info(`📝 Environment: ${process.env.NODE_ENV || 'development'}`);
logger.info(`🔗 Health check: http://localhost:${PORT}/health`);
logger.info(`🔐 API endpoint: http://localhost:${PORT}/api`);
});
} catch (error) {
logger.error('Failed to start server:', error);
process.exit(1);
}
}
// Handle uncaught exceptions
process.on('uncaughtException', (error) => {
logger.error('Uncaught Exception:', error);
process.exit(1);
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
process.exit(1);
});
// Graceful shutdown
process.on('SIGTERM', () => {
logger.info('SIGTERM received, shutting down gracefully');
process.exit(0);
});
process.on('SIGINT', () => {
logger.info('SIGINT received, shutting down gracefully');
process.exit(0);
});
startServer();

52
Backend/src/server.ts Normal file
View file

@ -0,0 +1,52 @@
import 'reflect-metadata';
import { createApp } from './app';
import { config } from './config';
import { logger } from './config/logger';
import { prisma } from './config/database';
async function startServer() {
try {
// Test database connection
await prisma.$connect();
logger.info('Database connected successfully');
// Create Express app
const app = createApp();
// Start server
const server = app.listen(config.port, () => {
logger.info(`Server running on ${config.appUrl}`);
logger.info(`Environment: ${config.nodeEnv}`);
logger.info(`Swagger docs available at ${config.appUrl}/api-docs`);
});
// Graceful shutdown
const gracefulShutdown = async (signal: string) => {
logger.info(`${signal} received, shutting down gracefully`);
server.close(async () => {
logger.info('HTTP server closed');
await prisma.$disconnect();
logger.info('Database disconnected');
process.exit(0);
});
// Force shutdown after 10 seconds
setTimeout(() => {
logger.error('Forced shutdown after timeout');
process.exit(1);
}, 10000);
};
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
} catch (error) {
logger.error('Failed to start server', { error });
process.exit(1);
}
}
startServer();

View file

@ -1,152 +0,0 @@
const bcrypt = require('bcrypt');
const prisma = require('../config/database');
const { generateAccessToken, generateRefreshToken } = require('../utils/jwt');
class AuthService {
/**
* Register a new user
* @param {Object} data - User registration data
* @returns {Promise<Object>} User and tokens
*/
async register({ username, email, password, role_id = 3 }) {
// Check if user exists
const existingUser = await prisma.user.findFirst({
where: {
OR: [{ username }, { email }],
},
});
if (existingUser) {
const field = existingUser.username === username ? 'username' : 'email';
throw Object.assign(new Error(`This ${field} is already taken`), {
statusCode: 409,
code: 'DUPLICATE_ENTRY',
});
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user
const user = await prisma.user.create({
data: {
username,
email,
password: hashedPassword,
role_id,
},
include: {
role: true,
},
});
// Generate tokens
const accessToken = generateAccessToken({
userId: user.id,
username: user.username,
role: user.role.code,
});
const refreshToken = generateRefreshToken({
userId: user.id,
});
// Remove password from response
delete user.password;
return {
user,
accessToken,
refreshToken,
};
}
/**
* Login user
* @param {Object} credentials - Login credentials
* @returns {Promise<Object>} User and tokens
*/
async login({ username, email, password }) {
// Find user
const user = await prisma.user.findFirst({
where: {
OR: [{ username }, { email }],
},
include: {
role: true,
},
});
if (!user) {
throw Object.assign(new Error('Invalid credentials'), {
statusCode: 401,
code: 'INVALID_CREDENTIALS',
});
}
// Check if user is active
if (!user.is_active) {
throw Object.assign(new Error('Account is inactive'), {
statusCode: 403,
code: 'ACCOUNT_INACTIVE',
});
}
// Verify password
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
throw Object.assign(new Error('Invalid credentials'), {
statusCode: 401,
code: 'INVALID_CREDENTIALS',
});
}
// Generate tokens
const accessToken = generateAccessToken({
userId: user.id,
username: user.username,
role: user.role.code,
});
const refreshToken = generateRefreshToken({
userId: user.id,
});
// Remove password from response
delete user.password;
return {
user,
accessToken,
refreshToken,
};
}
/**
* Get user profile
* @param {number} userId - User ID
* @returns {Promise<Object>} User profile
*/
async getProfile(userId) {
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
role: true,
profile: true,
},
});
if (!user) {
throw Object.assign(new Error('User not found'), {
statusCode: 404,
code: 'NOT_FOUND',
});
}
delete user.password;
return user;
}
}
module.exports = new AuthService();

View file

@ -0,0 +1,217 @@
import { prisma } from '../config/database';
import { Prisma } from '@prisma/client';
import { config } from '../config';
import { logger } from '../config/logger';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import {
LoginRequest,
RegisterRequest,
LoginResponse,
RegisterResponse,
RefreshTokenResponse,
UserResponse
} from '../types/auth.types';
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
export class AuthService {
/**
* User login
*/
async login(data: LoginRequest): Promise<LoginResponse> {
const { username, password } = data;
// Find user with role and profile
const user = await prisma.user.findUnique({
where: { username },
include: {
role: true,
profile: true
}
});
if (!user) {
logger.warn('Login attempt with invalid username', { username });
throw new UnauthorizedError('Invalid username or password');
}
// Verify password
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
logger.warn('Login attempt with invalid password', { username });
throw new UnauthorizedError('Invalid username or password');
}
// Generate tokens
const token = this.generateAccessToken(user.id, user.username, user.email, user.role.code);
const refreshToken = this.generateRefreshToken(user.id);
logger.info('User logged in successfully', { userId: user.id, username: user.username });
return {
token,
refreshToken,
user: this.formatUserResponse(user)
};
}
/**
* User registration
*/
async register(data: RegisterRequest): Promise<RegisterResponse> {
const { username, email, password, first_name, last_name, prefix } = data;
// Check if username already exists
const existingUsername = await prisma.user.findUnique({
where: { username }
});
if (existingUsername) {
throw new ValidationError('Username already exists');
}
// Check if email already exists
const existingEmail = await prisma.user.findUnique({
where: { email }
});
if (existingEmail) {
throw new ValidationError('Email already exists');
}
// Get STUDENT role
const studentRole = await prisma.role.findUnique({
where: { code: 'STUDENT' }
});
if (!studentRole) {
logger.error('STUDENT role not found in database');
throw new Error('System configuration error');
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user with profile
const user = await prisma.user.create({
data: {
username,
email,
password: hashedPassword,
role_id: studentRole.id,
profile: {
create: {
prefix: prefix ? prefix as Prisma.InputJsonValue : Prisma.JsonNull,
first_name,
last_name
}
}
},
include: {
role: true,
profile: true
}
});
logger.info('New user registered', { userId: user.id, username: user.username });
return {
user: this.formatUserResponse(user),
message: 'Registration successful'
};
}
/**
* Refresh access token
*/
async refreshToken(refreshToken: string): Promise<RefreshTokenResponse> {
try {
// Verify refresh token
const decoded = jwt.verify(refreshToken, config.jwt.secret) as { id: number; type: string };
if (decoded.type !== 'refresh') {
throw new UnauthorizedError('Invalid token type');
}
// Get user
const user = await prisma.user.findUnique({
where: { id: decoded.id },
include: { role: true }
});
if (!user) {
throw new UnauthorizedError('User not found');
}
// Generate new tokens
const newToken = this.generateAccessToken(user.id, user.username, user.email, user.role.code);
const newRefreshToken = this.generateRefreshToken(user.id);
logger.info('Token refreshed', { userId: user.id });
return {
token: newToken,
refreshToken: newRefreshToken
};
} catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
throw new UnauthorizedError('Invalid refresh token');
}
if (error instanceof jwt.TokenExpiredError) {
throw new UnauthorizedError('Refresh token expired');
}
throw error;
}
}
/**
* Generate access token (JWT)
*/
private generateAccessToken(id: number, username: string, email: string, roleCode: string): string {
return jwt.sign(
{
id,
username,
email,
roleCode
},
config.jwt.secret,
{ expiresIn: config.jwt.expiresIn } as jwt.SignOptions
);
}
/**
* Generate refresh token
*/
private generateRefreshToken(id: number): string {
return jwt.sign(
{
id,
type: 'refresh'
},
config.jwt.secret,
{ expiresIn: config.jwt.refreshExpiresIn } as jwt.SignOptions
);
}
/**
* Format user response
*/
private formatUserResponse(user: any): UserResponse {
return {
id: user.id,
username: user.username,
email: user.email,
role: {
code: user.role.code,
name: user.role.name
},
profile: user.profile ? {
prefix: user.profile.prefix,
first_name: user.profile.first_name,
last_name: user.profile.last_name,
avatar_url: user.profile.avatar_url
} : undefined
};
}
}

View file

@ -0,0 +1,62 @@
/**
* Authentication Request/Response Types
*/
export interface LoginRequest {
username: string;
password: string;
}
export interface RegisterRequest {
username: string;
email: string;
password: string;
first_name: string;
last_name: string;
prefix?: {
th?: string;
en?: string;
};
}
export interface LoginResponse {
token: string;
refreshToken: string;
user: UserResponse;
}
export interface RegisterResponse {
user: UserResponse;
message: string;
}
export interface RefreshTokenRequest {
refreshToken: string;
}
export interface RefreshTokenResponse {
token: string;
refreshToken: string;
}
export interface UserResponse {
id: number;
username: string;
email: string;
role: {
code: string;
name: {
th: string;
en: string;
};
};
profile?: {
prefix?: {
th?: string;
en?: string;
};
first_name: string;
last_name: string;
avatar_url?: string;
};
}

View file

@ -0,0 +1,58 @@
export interface MultiLanguageText {
th: string;
en: string;
}
export interface JWTPayload {
id: number;
username: string;
email: string;
roleCode: string;
}
export interface AuthRequest extends Request {
user?: JWTPayload;
}
export interface PaginationParams {
page?: number;
limit?: number;
}
export interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
export interface ErrorResponse {
error: {
code: string;
message: string;
details?: any[];
};
}
export enum CourseStatus {
DRAFT = 'DRAFT',
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED'
}
export enum LessonType {
VIDEO = 'VIDEO',
TEXT = 'TEXT',
PDF = 'PDF',
QUIZ = 'QUIZ'
}
export enum QuizScorePolicy {
HIGHEST = 'HIGHEST',
LATEST = 'LATEST',
AVERAGE = 'AVERAGE'
}

View file

@ -1,57 +0,0 @@
const jwt = require('jsonwebtoken');
const logger = require('../config/logger');
/**
* Generate JWT access token
* @param {Object} payload - Token payload
* @returns {string} JWT token
*/
function generateAccessToken(payload) {
return jwt.sign(payload, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
});
}
/**
* Generate JWT refresh token
* @param {Object} payload - Token payload
* @returns {string} JWT refresh token
*/
function generateRefreshToken(payload) {
return jwt.sign(payload, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
});
}
/**
* Verify JWT token
* @param {string} token - JWT token
* @returns {Object} Decoded token payload
*/
function verifyToken(token) {
try {
return jwt.verify(token, process.env.JWT_SECRET);
} catch (error) {
logger.error('Token verification failed:', error.message);
throw new Error('Invalid token');
}
}
/**
* Extract token from Authorization header
* @param {string} authHeader - Authorization header value
* @returns {string|null} Token or null
*/
function extractToken(authHeader) {
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return null;
}
return authHeader.substring(7);
}
module.exports = {
generateAccessToken,
generateRefreshToken,
verifyToken,
extractToken,
};

View file

@ -0,0 +1,76 @@
import Joi from 'joi';
export const loginSchema = Joi.object({
username: Joi.string()
.min(3)
.max(50)
.required()
.messages({
'string.min': 'Username must be at least 3 characters',
'string.max': 'Username must not exceed 50 characters',
'any.required': 'Username is required'
}),
password: Joi.string()
.min(6)
.required()
.messages({
'string.min': 'Password must be at least 6 characters',
'any.required': 'Password is required'
})
});
export const registerSchema = Joi.object({
username: Joi.string()
.min(3)
.max(50)
.pattern(/^[a-zA-Z0-9_]+$/)
.required()
.messages({
'string.pattern.base': 'Username can only contain letters, numbers, and underscores',
'string.min': 'Username must be at least 3 characters',
'string.max': 'Username must not exceed 50 characters',
'any.required': 'Username is required'
}),
email: Joi.string()
.email()
.required()
.messages({
'string.email': 'Please provide a valid email address',
'any.required': 'Email is required'
}),
password: Joi.string()
.min(6)
.max(100)
.required()
.messages({
'string.min': 'Password must be at least 6 characters',
'string.max': 'Password must not exceed 100 characters',
'any.required': 'Password is required'
}),
first_name: Joi.string()
.min(1)
.max(100)
.required()
.messages({
'any.required': 'First name is required'
}),
last_name: Joi.string()
.min(1)
.max(100)
.required()
.messages({
'any.required': 'Last name is required'
}),
prefix: Joi.object({
th: Joi.string().optional(),
en: Joi.string().optional()
}).optional()
});
export const refreshTokenSchema = Joi.object({
refreshToken: Joi.string()
.required()
.messages({
'any.required': 'Refresh token is required'
})
});

39
Backend/tsconfig.json Normal file
View file

@ -0,0 +1,39 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": [
"ES2020"
],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"types": [
"node"
],
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist",
"**/*.test.ts"
]
}

30
Backend/tsoa.json Normal file
View file

@ -0,0 +1,30 @@
{
"entryFile": "src/app.ts",
"noImplicitAdditionalProperties": "throw-on-extras",
"controllerPathGlobs": [
"src/controllers/**/*Controller.ts"
],
"spec": {
"outputDirectory": "public",
"specVersion": 3,
"name": "E-Learning Platform API",
"description": "API documentation for E-Learning Platform",
"version": "1.0.0",
"contact": {
"name": "E-Learning Team"
},
"securityDefinitions": {
"jwt": {
"type": "apiKey",
"name": "Authorization",
"in": "header",
"description": "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\""
}
}
},
"routes": {
"routesDir": "src/routes",
"middleware": "express",
"authenticationModule": "./src/middleware/authentication.ts"
}
}