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 # 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 ```typescript
// src/routes/courses.routes.js // src/controllers/course.controller.ts
const express = require('express'); import { Request, Response } from 'express';
const router = express.Router(); import {
const courseController = require('../controllers/course.controller'); Controller,
const { authenticate, authorize } = require('../middleware/auth'); Get,
const { validate } = require('../middleware/validation'); Post,
const { courseSchema } = require('../validators/course.validator'); 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 @Route('api/courses')
router.get('/', courseController.list); @Tags('Courses')
router.get('/:id', courseController.getById); export class CourseController extends Controller {
// Instructor routes /**
router.post( * Get list of courses
'/', * @summary List all approved courses
authenticate, */
authorize(['INSTRUCTOR', 'ADMIN']), @Get('/')
validate(courseSchema.create), @SuccessResponse(200, 'Success')
courseController.create 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
});
router.put( return result;
'/:id', }
authenticate,
authorize(['INSTRUCTOR', 'ADMIN']),
validate(courseSchema.update),
courseController.update
);
module.exports = router; /**
* Get course by ID
* @summary Get course details
*/
@Get('{id}')
@SuccessResponse(200, 'Success')
public async getById(@Path() id: number): Promise<any> {
const course = await courseService.getById(parseInt(id.toString()));
if (!course) {
this.setStatus(404);
throw new Error('Course not found');
}
return course;
}
/**
* Create a new course
* @summary Create course (Instructor/Admin only)
*/
@Post('/')
@Security('jwt', ['INSTRUCTOR', 'ADMIN'])
@SuccessResponse(201, 'Created')
public async create(
@Body() body: CreateCourseDto,
@Request() req: any
): Promise<any> {
const course = await courseService.create(body, req.user.id);
this.setStatus(201);
return course;
}
/**
* Update course
* @summary Update course (Instructor/Admin only)
*/
@Put('{id}')
@Security('jwt', ['INSTRUCTOR', 'ADMIN'])
@SuccessResponse(200, 'Success')
public async update(
@Path() id: number,
@Body() body: UpdateCourseDto,
@Request() req: any
): Promise<any> {
// Check ownership
const course = await courseService.getById(parseInt(id.toString()));
if (!course) {
this.setStatus(404);
throw new Error('Course not found');
}
if (course.created_by !== req.user.id && req.user.role.code !== 'ADMIN') {
this.setStatus(403);
throw new Error('You do not have permission to update this course');
}
const updated = await courseService.update(parseInt(id.toString()), body);
return updated;
}
}
``` ```
--- ---
## Step 2: Create 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/`: Create validator in `src/validators/`:
```javascript ```typescript
// src/validators/course.validator.js // src/validators/course.validator.ts
const Joi = require('joi'); import Joi from 'joi';
const multiLangSchema = Joi.object({ const multiLangSchema = Joi.object({
th: Joi.string().required(), th: Joi.string().required(),
en: Joi.string().required() en: Joi.string().required()
}); });
const courseSchema = { export const courseSchema = {
create: Joi.object({ create: Joi.object({
title: multiLangSchema.required(), title: multiLangSchema.required(),
description: multiLangSchema.required(), description: multiLangSchema.required(),
@ -81,124 +232,6 @@ const courseSchema = {
thumbnail: Joi.string().uri().optional() 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/`: Create service in `src/services/`:
```javascript ```typescript
// src/services/course.service.js // src/services/course.service.ts
const { PrismaClient } = require('@prisma/client'); import { PrismaClient } from '@prisma/client';
import { CreateCourseDto, UpdateCourseDto, CourseListQuery } from '../types/course.types';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
class CourseService { class CourseService {
async list({ page, limit, category, search }) { async list({ page, limit, category, search }: CourseListQuery) {
const skip = (page - 1) * limit; const skip = ((page || 1) - 1) * (limit || 20);
const where = { const where: any = {
is_deleted: false, is_deleted: false,
status: 'APPROVED' status: 'APPROVED'
}; };
@ -236,7 +271,7 @@ class CourseService {
prisma.course.findMany({ prisma.course.findMany({
where, where,
skip, skip,
take: limit, take: limit || 20,
include: { include: {
category: true, category: true,
instructors: { instructors: {
@ -252,15 +287,15 @@ class CourseService {
return { return {
data, data,
pagination: { pagination: {
page, page: page || 1,
limit, limit: limit || 20,
total, total,
totalPages: Math.ceil(total / limit) totalPages: Math.ceil(total / (limit || 20))
} }
}; };
} }
async getById(id) { async getById(id: number) {
return prisma.course.findUnique({ return prisma.course.findUnique({
where: { id }, where: { id },
include: { include: {
@ -285,7 +320,7 @@ class CourseService {
}); });
} }
async create(data, userId) { async create(data: CreateCourseDto, userId: number) {
return prisma.course.create({ return prisma.course.create({
data: { data: {
...data, ...data,
@ -307,7 +342,7 @@ class CourseService {
}); });
} }
async update(id, data) { async update(id: number, data: UpdateCourseDto) {
return prisma.course.update({ return prisma.course.update({
where: { id }, where: { id },
data, 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(); const app = express();
// Middleware // Middleware
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
// Routes // Register TSOA routes
const authRoutes = require('./routes/auth.routes'); RegisterRoutes(app);
const courseRoutes = require('./routes/courses.routes');
app.use('/api/auth', authRoutes); // Swagger documentation
app.use('/api/courses', courseRoutes); 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/`: Create test file in `tests/integration/`:
```javascript ```typescript
// tests/integration/courses.test.js // tests/integration/courses.test.ts
const request = require('supertest'); import request from 'supertest';
const app = require('../../src/app'); import app from '../../src/app';
describe('Course API', () => { describe('Course API', () => {
let instructorToken; let instructorToken: string;
beforeAll(async () => { beforeAll(async () => {
const res = await request(app) const res = await request(app)
@ -423,94 +512,118 @@ describe('Course API', () => {
--- ---
## Step 7: Run Tests ## Step 9: Run Tests
// turbo // turbo
```bash ```bash
npm test -- courses.test.js npm test -- courses.test.ts
``` ```
--- ---
## Step 8: Test Manually ## Step 10: View API Documentation
// turbo After generating TSOA routes, access Swagger UI:
```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
}'
# List courses ```
curl http://localhost:4000/api/courses?page=1&limit=10 http://localhost:4000/api-docs
``` ```
--- ---
## Checklist ## Checklist
- [ ] Route defined with proper middleware - [ ] Controller created with TSOA decorators
- [ ] Validator created with Joi/Zod - [ ] Type definitions created
- [ ] Controller created with error handling - [ ] Validator created with Joi
- [ ] Service created with business logic - [ ] 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) - [ ] Tests written (unit + integration)
- [ ] All tests passing - [ ] All tests passing
- [ ] API documentation accessible at /api-docs
- [ ] Manual testing done - [ ] Manual testing done
- [ ] Documentation updated
--- ---
## Best Practices ## Best Practices
1. **Separation of Concerns**: Routes → Controllers → Services → Database 1. **TSOA Decorators**: Use proper decorators (@Get, @Post, @Security, etc.)
2. **Validation**: Always validate input at route level 2. **Type Safety**: Define interfaces for all DTOs
3. **Authorization**: Check permissions in controller 3. **Documentation**: Add JSDoc comments for Swagger descriptions
4. **Error Handling**: Use try-catch and return proper error codes 4. **Validation**: Validate input at controller level
5. **Multi-Language**: Use JSON structure for user-facing text 5. **Authorization**: Use @Security decorator for protected routes
6. **Pagination**: Always paginate list endpoints 6. **Multi-Language**: Use JSON structure for user-facing text
7. **Testing**: Write tests before deploying 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 ### File Upload Endpoint
```javascript ```typescript
const multer = require('multer'); import { UploadedFile } from 'express-fileupload';
const upload = multer({ dest: 'uploads/' });
router.post( @Post('upload')
'/upload', @Security('jwt', ['INSTRUCTOR', 'ADMIN'])
authenticate, public async upload(
upload.single('file'), @UploadedFile() file: Express.Multer.File
validate(uploadSchema), ): Promise<{ url: string }> {
controller.upload const result = await uploadService.uploadFile(file);
); return { url: result.url };
}
``` ```
### Ownership Check ### Ownership Check
```javascript ```typescript
// In controller @Put('{id}')
const resource = await service.getById(id); @Security('jwt')
if (resource.user_id !== req.user.id && req.user.role.code !== 'ADMIN') { public async update(
return res.status(403).json({ error: 'Forbidden' }); @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 ### Soft Delete
```javascript ```typescript
// In service @Delete('{id}')
async delete(id) { @Security('jwt')
return prisma.course.update({ public async delete(@Path() id: number): Promise<void> {
where: { id }, await service.softDelete(id);
data: { is_deleted: true, deleted_at: new Date() } 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 # 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 # Install dependencies
npm install 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 # Setup environment
cp .env.example .env cp .env.example .env
# Edit .env with your configuration # Edit .env with your configuration
@ -126,72 +130,12 @@ npx prisma migrate dev --name initial_schema
- `POST /api/auth/refresh` - Refresh token - `POST /api/auth/refresh` - Refresh token
**Implementation steps:** **Implementation steps:**
1. Create `src/routes/auth.routes.js` 1. Create TSOA controller with `@Route`, `@Post` decorators
2. Create `src/controllers/auth.controller.js` 2. Create TypeScript service with interfaces
3. Create `src/services/auth.service.js` 3. Implement JWT middleware
4. Implement JWT middleware 4. Write tests
5. Write tests
**Example:** See [Create API Endpoint Workflow](./create-api-endpoint.md) for detailed examples.
```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();
```
### 3.2 Course Management ### 3.2 Course Management
@ -324,41 +268,11 @@ model Course {
### 5.2 Caching (Redis) ### 5.2 Caching (Redis)
Implement caching for: Cache course listings, user sessions, and frequently accessed data using Redis with `setEx()` for TTL.
- 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));
```
### 5.3 Rate Limiting ### 5.3 Rate Limiting
```javascript Use `express-rate-limit` middleware to limit requests (e.g., 100 requests per 15 minutes).
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);
```
--- ---
@ -379,16 +293,7 @@ app.use('/api/', limiter);
### 6.2 Implement Security Middleware ### 6.2 Implement Security Middleware
```javascript Use `helmet()` for security headers and configure CORS with allowed origins.
const helmet = require('helmet');
const cors = require('cors');
app.use(helmet());
app.use(cors({
origin: process.env.CORS_ORIGIN.split(','),
credentials: true
}));
```
--- ---
@ -396,15 +301,25 @@ app.use(cors({
### 7.1 API Documentation ### 7.1 API Documentation
Use Swagger/OpenAPI: TSOA automatically generates Swagger/OpenAPI documentation:
```javascript ```typescript
const swaggerUi = require('swagger-ui-express'); import swaggerUi from 'swagger-ui-express';
const swaggerDocument = require('./swagger.json');
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 ### 7.2 Code Documentation
- JSDoc comments for all functions - JSDoc comments for all functions
@ -500,35 +415,9 @@ git push origin feature/user-authentication
## Troubleshooting ## Troubleshooting
### Common Issues - **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>`
**Database connection error:** - **Prisma error**: Run `npx prisma generate` or `npx prisma migrate reset`
```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
```
--- ---
@ -545,7 +434,9 @@ npx prisma migrate reset
```bash ```bash
# Development # 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 test # Run tests
npm run lint # Run linter npm run lint # Run linter
npm run format # Format code npm run format # Format code

View file

@ -4,54 +4,61 @@ description: How to handle file uploads (videos, attachments)
# File Upload Workflow # 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 ## Prerequisites
- MinIO/S3 configured and running - MinIO/S3 configured and running
- Multer installed: `npm install multer` - Dependencies installed: `npm install multer @aws-sdk/client-s3 @aws-sdk/lib-storage`
- AWS SDK or MinIO client installed - TypeScript configured
--- ---
## Step 1: Configure S3/MinIO Client ## Step 1: Configure S3/MinIO Client
Create `src/config/s3.config.js`: Create `src/config/s3.config.ts`:
```javascript ```typescript
const { S3Client } = require('@aws-sdk/client-s3'); import { S3Client } from '@aws-sdk/client-s3';
const { Upload } = require('@aws-sdk/lib-storage');
const s3Client = new S3Client({ export const s3Client = new S3Client({
endpoint: process.env.S3_ENDPOINT, endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION || 'us-east-1', region: process.env.S3_REGION || 'us-east-1',
credentials: { credentials: {
accessKeyId: process.env.S3_ACCESS_KEY, accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY secretAccessKey: process.env.S3_SECRET_KEY!
}, },
forcePathStyle: true // Required for MinIO forcePathStyle: true // Required for MinIO
}); });
module.exports = { s3Client };
``` ```
--- ---
## Step 2: Create Upload Service ## Step 2: Create Upload Service
Create `src/services/upload.service.js`: Create `src/services/upload.service.ts`:
```javascript ```typescript
const { s3Client } = require('../config/s3.config'); import { s3Client } from '../config/s3.config';
const { Upload } = require('@aws-sdk/lib-storage'); import { Upload } from '@aws-sdk/lib-storage';
const { DeleteObjectCommand } = require('@aws-sdk/client-s3'); import { DeleteObjectCommand } from '@aws-sdk/client-s3';
const { v4: uuidv4 } = require('uuid'); import { v4 as uuidv4 } from 'uuid';
const path = require('path'); 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 { class UploadService {
async uploadFile(file, folder) { async uploadFile(file: Express.Multer.File, folder: FolderType): Promise<UploadResult> {
const fileExt = path.extname(file.originalname); const fileExt = path.extname(file.originalname);
const fileName = `${Date.now()}-${uuidv4()}${fileExt}`; const fileName = `${Date.now()}-${uuidv4()}${fileExt}`;
const key = `${folder}/${fileName}`; 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({ const command = new DeleteObjectCommand({
Bucket: this.getBucket(folder), Bucket: this.getBucket(folder),
Key: key Key: key
@ -86,179 +93,182 @@ class UploadService {
await s3Client.send(command); await s3Client.send(command);
} }
getBucket(folder) { private getBucket(folder: FolderType): string {
const bucketMap = { const bucketMap: Record<FolderType, string> = {
videos: process.env.S3_BUCKET_VIDEOS, videos: process.env.S3_BUCKET_VIDEOS!,
documents: process.env.S3_BUCKET_DOCUMENTS, documents: process.env.S3_BUCKET_DOCUMENTS!,
images: process.env.S3_BUCKET_IMAGES, images: process.env.S3_BUCKET_IMAGES!,
attachments: process.env.S3_BUCKET_ATTACHMENTS 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); return allowedTypes.includes(file.mimetype);
} }
validateFileSize(file, maxSize) { validateFileSize(file: Express.Multer.File, maxSize: number): boolean {
return file.size <= maxSize; return file.size <= maxSize;
} }
} }
module.exports = new UploadService(); export const uploadService = new UploadService();
``` ```
--- ---
## Step 3: Create Upload Middleware ## Step 3: Create Upload Middleware
Create `src/middleware/upload.middleware.js`: Create `src/middleware/upload.middleware.ts`:
```javascript ```typescript
const multer = require('multer'); import multer from 'multer';
import { Request } from 'express';
// File type validators // File type validators
const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime']; export const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime'];
const ALLOWED_DOCUMENT_TYPES = [ export const ALLOWED_DOCUMENT_TYPES = [
'application/pdf', 'application/pdf',
'application/msword', 'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel', 'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' '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 // File size limits
const MAX_VIDEO_SIZE = 500 * 1024 * 1024; // 500 MB export const MAX_VIDEO_SIZE = 500 * 1024 * 1024; // 500 MB
const MAX_ATTACHMENT_SIZE = 100 * 1024 * 1024; // 100 MB export const MAX_ATTACHMENT_SIZE = 100 * 1024 * 1024; // 100 MB
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB
// Multer configuration // Multer configuration
const storage = multer.memoryStorage(); 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)) { if (allowedTypes.includes(file.mimetype)) {
cb(null, true); cb(null, true);
} else { } else {
cb(new Error('Invalid file type'), false); cb(new Error('Invalid file type'));
} }
}; };
// Upload configurations // Upload configurations
const uploadVideo = multer({ export const uploadVideo = multer({
storage, storage,
limits: { fileSize: MAX_VIDEO_SIZE }, limits: { fileSize: MAX_VIDEO_SIZE },
fileFilter: fileFilter(ALLOWED_VIDEO_TYPES) fileFilter: fileFilter(ALLOWED_VIDEO_TYPES)
}).single('video'); }).single('video');
const uploadAttachment = multer({ export const uploadAttachment = multer({
storage, storage,
limits: { fileSize: MAX_ATTACHMENT_SIZE }, limits: { fileSize: MAX_ATTACHMENT_SIZE },
fileFilter: fileFilter([...ALLOWED_DOCUMENT_TYPES, ...ALLOWED_IMAGE_TYPES]) fileFilter: fileFilter([...ALLOWED_DOCUMENT_TYPES, ...ALLOWED_IMAGE_TYPES])
}).single('file'); }).single('file');
const uploadAttachments = multer({ export const uploadAttachments = multer({
storage, storage,
limits: { fileSize: MAX_ATTACHMENT_SIZE }, limits: { fileSize: MAX_ATTACHMENT_SIZE },
fileFilter: fileFilter([...ALLOWED_DOCUMENT_TYPES, ...ALLOWED_IMAGE_TYPES]) fileFilter: fileFilter([...ALLOWED_DOCUMENT_TYPES, ...ALLOWED_IMAGE_TYPES])
}).array('attachments', 10); // Max 10 files }).array('attachments', 10); // Max 10 files
const uploadImage = multer({ export const uploadImage = multer({
storage, storage,
limits: { fileSize: MAX_IMAGE_SIZE }, limits: { fileSize: MAX_IMAGE_SIZE },
fileFilter: fileFilter(ALLOWED_IMAGE_TYPES) fileFilter: fileFilter(ALLOWED_IMAGE_TYPES)
}).single('image'); }).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 ```typescript
const uploadService = require('../services/upload.service'); import { Request } from 'express';
import { Controller, Post, Route, Tags, Security, UploadedFile, SuccessResponse } from 'tsoa';
import { uploadService } from '../services/upload.service';
class UploadController { interface UploadResponse {
async uploadVideo(req, res) { video_url?: string;
try { file_size: number;
if (!req.file) { duration?: number | null;
return res.status(400).json({ file_name?: string;
error: { file_path?: string;
code: 'NO_FILE', mime_type?: string;
message: 'No video file provided' download_url?: string;
}
});
}
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'
}
});
}
}
} }
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 # 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 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: Copy example env file:
```bash ```bash
@ -92,7 +160,7 @@ This starts:
--- ---
## Step 5: Run Database Migrations ## Step 6: Run Database Migrations
// turbo // turbo
```bash ```bash
@ -101,7 +169,7 @@ npx prisma migrate dev
--- ---
## Step 6: Seed Database ## Step 7: Seed Database
// turbo // turbo
```bash ```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 // turbo
```bash ```bash
@ -127,7 +208,7 @@ Server will start at http://localhost:4000
--- ---
## Step 8: Verify Setup ## Step 10: Verify Setup
// turbo // turbo
Test health endpoint: Test health endpoint:
@ -150,6 +231,7 @@ curl -X POST http://localhost:4000/api/auth/login \
| Service | URL | Credentials | | Service | URL | Credentials |
|---------|-----|-------------| |---------|-----|-------------|
| **Backend API** | http://localhost:4000 | - | | **Backend API** | http://localhost:4000 | - |
| **API Docs (Swagger)** | http://localhost:4000/api-docs | - |
| **MinIO Console** | http://localhost:9001 | admin / 12345678 | | **MinIO Console** | http://localhost:9001 | admin / 12345678 |
| **Mailhog UI** | http://localhost:8025 | - | | **Mailhog UI** | http://localhost:8025 | - |
| **Adminer** | http://localhost:8080 | postgres / 12345678 | | **Adminer** | http://localhost:8080 | postgres / 12345678 |
@ -159,9 +241,15 @@ curl -X POST http://localhost:4000/api/auth/login \
## Development Commands ## Development Commands
```bash ```bash
# Start dev server # Start dev server (TypeScript)
npm run dev npm run dev
# Build TypeScript
npm run build
# Generate TSOA routes and Swagger
npm run tsoa:gen
# Run tests # Run tests
npm test npm test

View file

@ -4,7 +4,7 @@ description: How to test backend APIs and services
# Testing Workflow # 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 ## 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(); const prisma = new PrismaClient();
beforeAll(async () => { beforeAll(async () => {
@ -44,8 +46,6 @@ afterAll(async () => {
async function createTestUsers() { async function createTestUsers() {
// Create admin, instructor, student // Create admin, instructor, student
const bcrypt = require('bcrypt');
await prisma.user.createMany({ await prisma.user.createMany({
data: [ data: [
{ {
@ -75,11 +75,12 @@ async function createTestUsers() {
## Step 2: Write Unit Tests ## 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(); const prisma = new PrismaClient();
describe('Course Service', () => { describe('Course Service', () => {
@ -93,7 +94,7 @@ describe('Course Service', () => {
is_free: false is_free: false
}; };
const course = await courseService.createCourse(courseData, 1); const course = await courseService.create(courseData, 1);
expect(course).toHaveProperty('id'); expect(course).toHaveProperty('id');
expect(course.status).toBe('DRAFT'); expect(course.status).toBe('DRAFT');
@ -108,7 +109,7 @@ describe('Course Service', () => {
}; };
await expect( await expect(
courseService.createCourse(courseData, 1) courseService.create(courseData, 1)
).rejects.toThrow(); ).rejects.toThrow();
}); });
}); });
@ -134,14 +135,16 @@ describe('Course Service', () => {
## Step 3: Write Integration Tests ## Step 3: Write Integration Tests
Create `tests/integration/courses.test.js`: Create `tests/integration/courses.test.ts`:
```javascript ```typescript
const request = require('supertest'); import request from 'supertest';
const app = require('../../src/app'); import app from '../../src/app';
describe('Course API', () => { describe('Course API', () => {
let adminToken, instructorToken, studentToken; let adminToken: string;
let instructorToken: string;
let studentToken: string;
beforeAll(async () => { beforeAll(async () => {
// Login as different users // Login as different users
@ -217,7 +220,7 @@ describe('Course API', () => {
.query({ category: 1 }); .query({ category: 1 });
expect(response.status).toBe(200); expect(response.status).toBe(200);
response.body.data.forEach(course => { response.body.data.forEach((course: any) => {
expect(course.category_id).toBe(1); expect(course.category_id).toBe(1);
}); });
}); });
@ -229,15 +232,15 @@ describe('Course API', () => {
## Step 4: Test File Uploads ## Step 4: Test File Uploads
Create `tests/integration/file-upload.test.js`: Create `tests/integration/file-upload.test.ts`:
```javascript ```typescript
const request = require('supertest'); import request from 'supertest';
const app = require('../../src/app'); import app from '../../src/app';
const path = require('path'); import path from 'path';
describe('File Upload API', () => { describe('File Upload API', () => {
let instructorToken; let instructorToken: string;
beforeAll(async () => { beforeAll(async () => {
const res = await request(app) const res = await request(app)
@ -330,10 +333,12 @@ Update `package.json`:
"test:coverage": "jest --coverage" "test:coverage": "jest --coverage"
}, },
"jest": { "jest": {
"preset": "ts-jest",
"testEnvironment": "node", "testEnvironment": "node",
"coveragePathIgnorePatterns": ["/node_modules/"], "coveragePathIgnorePatterns": ["/node_modules/"],
"setupFilesAfterEnv": ["<rootDir>/tests/setup.js"], "setupFilesAfterEnv": ["<rootDir>/tests/setup.ts"],
"testMatch": ["**/tests/**/*.test.js"] "testMatch": ["**/tests/**/*.test.ts"],
"moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"]
} }
} }
``` ```

View file

@ -1,44 +1,42 @@
# Environment # Application
NODE_ENV=development NODE_ENV=development
PORT=4000 PORT=4000
APP_URL=http://localhost:4000 APP_URL=http://localhost:4000
# Database # Database
DATABASE_URL=postgresql://elearning_user:elearning_pass@localhost:5432/elearning_db DATABASE_URL=postgresql://postgres:12345678@localhost:5432/elearning_dev
# Redis # Redis
REDIS_URL=redis://localhost:6379 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
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=24h JWT_EXPIRES_IN=24h
JWT_REFRESH_EXPIRES_IN=7d JWT_REFRESH_EXPIRES_IN=7d
# MinIO/S3 # Email (Mailhog in development)
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)
SMTP_HOST=localhost SMTP_HOST=localhost
SMTP_PORT=1025 SMTP_PORT=1025
SMTP_USER= SMTP_USER=
SMTP_PASS= SMTP_PASS=
SMTP_FROM=noreply@elearning.local 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 Limiting
RATE_LIMIT_WINDOW_MS=900000 RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100 RATE_LIMIT_MAX_REQUESTS=100
# Logging
LOG_LEVEL=debug

31
Backend/.gitignore vendored
View file

@ -1,21 +1,14 @@
node_modules/ node_modules/
dist/ dist/
build/ build/
logs/
*.log
# Environment
.env .env
.env.local .env.local
.env.*.local .env.*.local
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS
.DS_Store
Thumbs.db
# IDE # IDE
.vscode/ .vscode/
.idea/ .idea/
@ -23,13 +16,21 @@ Thumbs.db
*.swo *.swo
*~ *~
# OS
.DS_Store
Thumbs.db
# Testing # Testing
coverage/ coverage/
.nyc_output/ .nyc_output/
# Uploads (local development) # Prisma
prisma/migrations/**/*.sql
# TSOA
public/swagger.json
src/routes/routes.ts
# Uploads (if storing locally)
uploads/ uploads/
temp/ 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 ## 🚀 Features
- **Authentication & Authorization**: JWT-based authentication with role-based access control - **TypeScript** - Type-safe development
- **Course Management**: Create, manage, and publish courses with chapters and lessons - **TSOA** - Automatic OpenAPI/Swagger documentation
- **Multi-Language Support**: Thai and English content support - **Prisma** - Type-safe database ORM
- **Quiz System**: Interactive quizzes with multiple attempts and score policies - **JWT Authentication** - Secure user authentication
- **Progress Tracking**: Track student progress and issue certificates - **Role-based Authorization** - Admin, Instructor, Student roles
- **File Upload**: Support for video lessons and attachments (MinIO/S3) - **Multi-language Support** - Thai and English
- **Caching**: Redis integration for improved performance - **File Upload** - Video and attachment support with MinIO/S3
- **Security**: Helmet, CORS, rate limiting, and input validation - **Redis Caching** - Performance optimization
- **Rate Limiting** - API protection
- **Comprehensive Error Handling** - Structured error responses
## 📋 Prerequisites ## 📋 Prerequisites
- Node.js >= 18.0.0 - Node.js >= 18
- PostgreSQL >= 14 - Docker & Docker Compose
- Redis (optional, for caching) - PostgreSQL (via Docker)
- MinIO or S3 (for file storage) - Redis (via Docker)
- MinIO (via Docker)
## 🛠️ Installation ## 🛠️ Setup
1. **Clone the repository** ### 1. Install Dependencies
```bash
git clone <repository-url>
cd e-learning/Backend
```
2. **Install dependencies**
```bash ```bash
npm install npm install
``` ```
3. **Setup environment variables** ### 2. Environment Configuration
```bash ```bash
cp .env.example .env cp .env.example .env
# Edit .env with your configuration # Edit .env with your configuration
``` ```
4. **Start Docker services** (PostgreSQL, Redis, MinIO) ### 3. Start Docker Services
```bash ```bash
docker compose up -d docker compose up -d
``` ```
5. **Run database migrations** ### 4. Database Setup
```bash ```bash
# Generate Prisma client
npx prisma generate
# Run migrations
npx prisma migrate dev npx prisma migrate dev
# Seed database
npx prisma db seed
``` ```
6. **Seed the database** ### 5. Generate TSOA Routes & Swagger
```bash ```bash
npm run prisma:seed npm run tsoa:gen
``` ```
7. **Start development server** ### 6. Start Development Server
```bash ```bash
npm run dev 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/ Backend/
├── prisma/ ├── prisma/
│ ├── migrations/ # Database migrations │ ├── migrations/ # Database migrations
│ ├── schema.prisma # Database schema │ ├── schema.prisma # Database schema
│ └── seed.js # Database seeding │ └── seed.js # Database seeder
├── src/ ├── src/
│ ├── config/ # Configuration files │ ├── config/ # Configuration files
│ │ ├── database.js # Prisma client │ │ ├── index.ts # Main config
│ │ ├── logger.js # Winston logger │ │ ├── logger.ts # Winston logger
│ │ └── redis.js # Redis client │ │ └── database.ts # Prisma client
│ ├── controllers/ # Request handlers │ ├── controllers/ # TSOA controllers
│ ├── middleware/ # Express middleware │ │ └── HealthController.ts
│ │ ├── auth.js # Authentication & authorization │ ├── middleware/ # Express middleware
│ │ └── errorHandler.js │ │ ├── authentication.ts
│ ├── routes/ # Route definitions │ │ └── errorHandler.ts
│ ├── services/ # Business logic │ ├── services/ # Business logic
│ ├── utils/ # Utility functions │ ├── types/ # TypeScript types
│ │ └── jwt.js # JWT utilities │ │ └── index.ts
│ ├── validators/ # Input validation │ ├── utils/ # Utility functions
│ ├── app.js # Express app setup │ ├── validators/ # Input validation
│ └── server.js # Server entry point │ ├── app.ts # Express app setup
├── tests/ # Test files │ └── server.ts # Server entry point
├── logs/ # Log files ├── public/ # Generated Swagger docs
├── .env.example # Environment variables template ├── .env.example # Environment template
├── package.json ├── tsconfig.json # TypeScript config
└── README.md ├── 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 Commands
- `DATABASE_URL`: PostgreSQL connection string
- `JWT_SECRET`: Secret key for JWT tokens ```bash
- `REDIS_URL`: Redis connection string npx prisma studio # Open Prisma Studio (GUI)
- `S3_ENDPOINT`: MinIO/S3 endpoint npx prisma migrate dev # Create and apply migration
- `CORS_ORIGIN`: Allowed CORS origins 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 ## 🧪 Testing
```bash ```bash
# Run all tests npm test # Run all tests
npm test npm run test:watch # Run tests in watch mode
npm run test:coverage # Generate coverage report
# Run tests in watch mode
npm run test:watch
# Run tests with coverage
npm test -- --coverage
``` ```
## 📝 API Documentation ## 🚀 Deployment
### Authentication See [Deployment Workflow](./.agent/workflows/deployment.md) for production deployment instructions.
- `POST /api/auth/register` - Register new user ## 📖 Documentation
- `POST /api/auth/login` - Login user
- `GET /api/auth/me` - Get current user profile
- `POST /api/auth/logout` - Logout user
### 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 ## 🔍 Troubleshooting
## 🔐 Default Credentials
After seeding the database, you can use these credentials:
- **Admin**: `admin` / `admin123`
- **Instructor**: `instructor` / `admin123`
- **Student**: `student` / `admin123`
## 🛠️ Development
### Port already in use
```bash ```bash
# Start dev server with auto-reload lsof -i :4000
npm run dev kill -9 <PID>
# Run linter
npm run lint
# Format code
npm run format
# Open Prisma Studio (database GUI)
npm run prisma:studio
``` ```
## 📦 Database Commands ### Database connection error
```bash ```bash
# Generate Prisma Client docker compose logs postgres
npm run prisma:generate npx prisma db pull
# 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
``` ```
## 🚀 Production Deployment ### Prisma client error
```bash
1. Set `NODE_ENV=production` npx prisma generate
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/)
## 📄 License ## 📄 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", "name": "e-learning-backend",
"version": "1.0.0", "version": "1.0.0",
"description": "E-Learning Platform Backend API", "description": "E-Learning Platform Backend API with TypeScript and TSOA",
"main": "src/server.js", "main": "dist/server.js",
"scripts": { "scripts": {
"dev": "nodemon src/server.js", "dev": "nodemon",
"start": "node src/server.js", "build": "tsoa spec-and-routes && tsc",
"test": "jest --coverage", "start": "node dist/server.js",
"test:watch": "jest --watch", "tsoa:gen": "tsoa spec-and-routes",
"lint": "eslint src/**/*.js", "prisma:generate": "prisma generate",
"format": "prettier --write \"src/**/*.js\"", "prisma:migrate": "prisma migrate dev",
"prisma:generate": "prisma generate", "prisma:seed": "node prisma/seed.js",
"prisma:migrate": "prisma migrate dev", "prisma:studio": "prisma studio",
"prisma:studio": "prisma studio", "test": "jest",
"prisma:seed": "node prisma/seed.js" "test:watch": "jest --watch",
}, "test:coverage": "jest --coverage",
"keywords": [ "lint": "eslint . --ext .ts",
"e-learning", "format": "prettier --write \"src/**/*.ts\""
"api", },
"express", "keywords": [
"prisma", "e-learning",
"postgresql" "typescript",
], "tsoa",
"author": "", "express",
"license": "MIT", "prisma"
"dependencies": { ],
"@prisma/client": "^5.22.0", "author": "",
"bcrypt": "^5.1.1", "license": "ISC",
"cors": "^2.8.5", "dependencies": {
"dotenv": "^16.4.5", "@prisma/client": "^5.22.0",
"express": "^4.21.1", "bcrypt": "^5.1.1",
"express-rate-limit": "^7.4.1", "cors": "^2.8.5",
"helmet": "^8.0.0", "dotenv": "^16.4.5",
"joi": "^17.13.3", "express": "^4.21.2",
"jsonwebtoken": "^9.0.2", "express-rate-limit": "^7.5.0",
"minio": "^8.0.2", "helmet": "^8.0.0",
"multer": "^1.4.5-lts.1", "joi": "^17.13.3",
"redis": "^4.7.0", "jsonwebtoken": "^9.0.2",
"swagger-jsdoc": "^6.2.8", "minio": "^8.0.2",
"swagger-ui-express": "^5.0.1", "multer": "^1.4.5-lts.1",
"winston": "^3.17.0" "redis": "^4.7.0",
}, "reflect-metadata": "^0.2.2",
"devDependencies": { "swagger-ui-express": "^5.0.1",
"eslint": "^9.17.0", "tsoa": "^6.4.0",
"jest": "^29.7.0", "winston": "^3.17.0"
"nodemon": "^3.1.9", },
"prettier": "^3.4.2", "devDependencies": {
"prisma": "^5.22.0", "@types/bcrypt": "^5.0.2",
"supertest": "^7.0.0" "@types/cors": "^2.8.17",
}, "@types/express": "^5.0.0",
"engines": { "@types/jsonwebtoken": "^9.0.7",
"node": ">=18.0.0", "@types/multer": "^1.4.12",
"npm": ">=9.0.0" "@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 { model Role {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
code String @unique @db.VarChar(50) code String @unique @db.VarChar(50)
name Json // { th: "", en: "" } name Json // { th: "", en: "" }
description Json? description Json?
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt
users User[] users User[]
@@index([code])
@@map("roles") @@map("roles")
} }
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
username String @unique @db.VarChar(50) username String @unique @db.VarChar(50)
email String @unique @db.VarChar(255) email String @unique @db.VarChar(255)
password String @db.VarChar(255) password String @db.VarChar(255)
role_id Int role_id Int
is_active Boolean @default(true) email_verified_at DateTime?
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime? @updatedAt
role Role @relation(fields: [role_id], references: [id]) role Role @relation(fields: [role_id], references: [id], onDelete: Restrict)
profile Profile? profile UserProfile?
// Relations // Relations
created_courses Course[] @relation("CourseCreator") created_courses Course[] @relation("CourseCreator")
instructor_courses CourseInstructor[] approved_courses Course[] @relation("CourseApprover")
enrollments Enrollment[] instructor_courses CourseInstructor[]
lesson_progress LessonProgress[] enrollments Enrollment[]
quiz_attempts QuizAttempt[] lesson_progress LessonProgress[]
certificates Certificate[] 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([email])
@@index([role_id]) @@index([role_id])
@@map("users") @@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()) id Int @id @default(autoincrement())
user_id Int @unique name Json // { th: "", en: "" }
first_name String? @db.VarChar(100) slug String @unique @db.VarChar(100)
last_name String? @db.VarChar(100) description Json?
phone String? @db.VarChar(20) icon String? @db.VarChar(100)
avatar_url String? @db.VarChar(500) sort_order Int @default(0)
bio Json? // { th: "", en: "" } is_active Boolean @default(true)
birth_date DateTime?
created_at DateTime @default(now()) 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 { enum CourseStatus {
DRAFT DRAFT
PENDING_APPROVAL PENDING
APPROVED APPROVED
REJECTED REJECTED
ARCHIVED 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 { model Course {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
title Json // { th: "", en: "" } category_id Int?
description Json title Json // { th: "", en: "" }
thumbnail_url String? @db.VarChar(500) slug String @unique @db.VarChar(200)
price Decimal @default(0) @db.Decimal(10, 2) description Json
is_free Boolean @default(false) thumbnail_url String? @db.VarChar(500)
have_certificate Boolean @default(false) price Decimal @default(0) @db.Decimal(10, 2)
status CourseStatus @default(DRAFT) is_free Boolean @default(false)
category_id Int have_certificate Boolean @default(false)
created_by Int status CourseStatus @default(DRAFT)
rejection_reason String? @db.Text approved_by Int?
is_deleted Boolean @default(false) approved_at DateTime?
deleted_at DateTime? rejection_reason String? @db.Text
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt created_by Int
updated_at DateTime? @updatedAt
updated_by Int?
category Category @relation(fields: [category_id], references: [id]) category Category? @relation(fields: [category_id], references: [id], onDelete: SetNull)
creator User @relation("CourseCreator", fields: [created_by], references: [id]) creator User @relation("CourseCreator", fields: [created_by], references: [id], onDelete: Restrict)
chapters Chapter[] approver User? @relation("CourseApprover", fields: [approved_by], references: [id])
instructors CourseInstructor[] updater User? @relation("CourseUpdater", fields: [updated_by], references: [id])
enrollments Enrollment[] chapters Chapter[]
instructors CourseInstructor[]
enrollments Enrollment[]
announcements Announcement[]
courseApprovals CourseApproval[]
@@index([category_id]) @@index([category_id])
@@index([slug])
@@index([status]) @@index([status])
@@index([created_by]) @@index([created_by])
@@index([created_at]) @@index([status, is_free])
@@index([category_id, status])
@@map("courses") @@map("courses")
} }
@ -132,7 +168,7 @@ model CourseInstructor {
course_id Int course_id Int
user_id Int user_id Int
is_primary Boolean @default(false) 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) course Course @relation(fields: [course_id], references: [id], onDelete: Cascade)
user User @relation(fields: [user_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]) @@unique([course_id, user_id])
@@index([course_id]) @@index([course_id])
@@index([user_id]) @@index([user_id])
@@index([is_primary])
@@map("course_instructors") @@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 { model Chapter {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
course_id Int course_id Int
title Json // { th: "", en: "" } title Json // { th: "", en: "" }
description Json? description Json?
order Int sort_order Int @default(0)
created_at DateTime @default(now()) is_published Boolean @default(false)
updated_at DateTime @updatedAt created_at DateTime @default(now())
updated_at DateTime? @updatedAt
course Course @relation(fields: [course_id], references: [id], onDelete: Cascade) course Course @relation(fields: [course_id], references: [id], onDelete: Cascade)
lessons Lesson[] lessons Lesson[]
@@index([course_id]) @@index([course_id])
@@index([order]) @@index([course_id, sort_order])
@@map("chapters") @@map("chapters")
} }
@ -166,169 +234,184 @@ model Chapter {
enum LessonType { enum LessonType {
VIDEO VIDEO
TEXT
PDF
QUIZ QUIZ
} }
model Lesson { model Lesson {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
chapter_id Int chapter_id Int
title Json // { th: "", en: "" } title Json // { th: "", en: "" }
description Json? content Json? // multi-language lesson content
type LessonType type LessonType
content Json? // For TEXT type duration_minutes Int?
video_url String? @db.VarChar(500) sort_order Int @default(0)
video_duration Int? // in seconds is_sequential Boolean @default(true)
order Int prerequisite_lesson_ids Json? // array of lesson IDs
is_preview Boolean @default(false) require_pass_quiz Boolean @default(false)
created_at DateTime @default(now()) is_published Boolean @default(false)
updated_at DateTime @updatedAt created_at DateTime @default(now())
updated_at DateTime? @updatedAt
chapter Chapter @relation(fields: [chapter_id], references: [id], onDelete: Cascade) chapter Chapter @relation(fields: [chapter_id], references: [id], onDelete: Cascade)
attachments Attachment[] attachments LessonAttachment[]
prerequisites LessonPrerequisite[] @relation("LessonPrerequisites") quiz Quiz?
required_for LessonPrerequisite[] @relation("RequiredForLessons") progress LessonProgress[]
quiz Quiz?
progress LessonProgress[]
@@index([chapter_id]) @@index([chapter_id])
@@index([order]) @@index([chapter_id, sort_order])
@@index([type])
@@map("lessons") @@map("lessons")
} }
model LessonPrerequisite { model LessonAttachment {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
lesson_id Int lesson_id Int
prerequisite_lesson_id Int file_name String @db.VarChar(255)
created_at DateTime @default(now()) file_path String @db.VarChar(500)
file_size Int
lesson Lesson @relation("LessonPrerequisites", fields: [lesson_id], references: [id], onDelete: Cascade) mime_type String @db.VarChar(100)
prerequisite_lesson Lesson @relation("RequiredForLessons", fields: [prerequisite_lesson_id], references: [id], onDelete: Cascade) description Json?
sort_order Int @default(0)
@@unique([lesson_id, prerequisite_lesson_id]) created_at DateTime @default(now())
@@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())
lesson Lesson @relation(fields: [lesson_id], references: [id], onDelete: Cascade) lesson Lesson @relation(fields: [lesson_id], references: [id], onDelete: Cascade)
@@index([lesson_id]) @@index([lesson_id])
@@map("attachments") @@index([lesson_id, sort_order])
@@map("lesson_attachments")
} }
// ============================================ // ============================================
// Quiz System // Quiz System
// ============================================ // ============================================
enum ScorePolicy { enum QuestionType {
HIGHEST MULTIPLE_CHOICE
LATEST TRUE_FALSE
AVERAGE SHORT_ANSWER
} }
model Quiz { model Quiz {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
lesson_id Int @unique lesson_id Int @unique
title Json // { th: "", en: "" } title Json // { th: "", en: "" }
description Json? description Json?
passing_score Int @default(70) passing_score Int @default(60)
time_limit Int? // in minutes time_limit Int? // in minutes
max_attempts Int @default(3) shuffle_questions Boolean @default(false)
cooldown_hours Int @default(24) shuffle_choices Boolean @default(false)
score_policy ScorePolicy @default(HIGHEST) show_answers_after_completion Boolean @default(true)
shuffle_questions Boolean @default(true) created_at DateTime @default(now())
shuffle_choices Boolean @default(true) created_by Int
created_at DateTime @default(now()) updated_at DateTime? @updatedAt
updated_at DateTime @updatedAt updated_by Int?
lesson Lesson @relation(fields: [lesson_id], references: [id], onDelete: Cascade) lesson Lesson @relation(fields: [lesson_id], references: [id], onDelete: Cascade)
questions QuizQuestion[] creator User @relation("QuizCreator", fields: [created_by], references: [id], onDelete: Restrict)
updater User? @relation("QuizUpdater", fields: [updated_by], references: [id])
questions Question[]
attempts QuizAttempt[] attempts QuizAttempt[]
@@index([lesson_id])
@@map("quizzes") @@map("quizzes")
} }
model QuizQuestion { model Question {
id Int @id @default(autoincrement()) 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())
quiz_id Int quiz_id Int
user_id Int question Json // { th: "", en: "" }
answers Json // [{ question_id: int, choice_index: int }] explanation Json? // answer explanation
score Int question_type QuestionType @default(MULTIPLE_CHOICE)
passed Boolean score Int @default(1)
time_spent Int? // in seconds sort_order Int @default(0)
started_at DateTime created_at DateTime @default(now())
completed_at DateTime @default(now()) updated_at DateTime? @updatedAt
quiz Quiz @relation(fields: [quiz_id], references: [id], onDelete: Cascade) quiz Quiz @relation(fields: [quiz_id], references: [id], onDelete: Cascade)
user User @relation(fields: [user_id], references: [id], onDelete: Cascade) choices Choice[]
@@index([quiz_id]) @@index([quiz_id])
@@index([user_id]) @@index([quiz_id, sort_order])
@@index([completed_at]) @@map("questions")
@@map("quiz_attempts") }
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 { model Enrollment {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
user_id Int user_id Int
course_id Int course_id Int
enrolled_at DateTime @default(now()) status EnrollmentStatus @default(ENROLLED)
completed_at DateTime? progress_percentage Int @default(0)
progress_percent Int @default(0) enrolled_at DateTime @default(now())
last_accessed_at DateTime @default(now()) started_at DateTime?
completed_at DateTime?
last_accessed_at DateTime?
user User @relation(fields: [user_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) 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([user_id])
@@index([course_id]) @@index([course_id])
@@index([status])
@@index([last_accessed_at])
@@map("enrollments") @@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 { model LessonProgress {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
user_id Int user_id Int
lesson_id Int lesson_id Int
is_completed Boolean @default(false) is_completed Boolean @default(false)
video_progress Int @default(0) // seconds watched completed_at DateTime?
completed_at DateTime? video_progress_seconds Int @default(0)
last_watched_at DateTime @default(now()) video_duration_seconds Int?
created_at DateTime @default(now()) video_progress_percentage Decimal? @db.Decimal(5, 2)
updated_at DateTime @updatedAt last_watched_at DateTime?
created_at DateTime @default(now())
updated_at DateTime? @updatedAt
user User @relation(fields: [user_id], references: [id], onDelete: Cascade) user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
lesson Lesson @relation(fields: [lesson_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]) @@unique([user_id, lesson_id])
@@index([user_id]) @@index([user_id])
@@index([lesson_id]) @@index([lesson_id])
@@index([last_watched_at])
@@map("lesson_progress") @@map("lesson_progress")
} }
model Certificate { model QuizAttempt {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
user_id Int user_id Int
course_id Int quiz_id Int
certificate_url String @db.VarChar(500) score Int @default(0)
issued_at DateTime @default(now()) 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) user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@unique([user_id, course_id])
@@index([user_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...'); console.log('🌱 Starting database seeding...');
// Clear existing data (in development only) // Clear existing data (in development only)
if (process.env.NODE_ENV === 'development') { // if (process.env.NODE_ENV === 'development') {
console.log('🗑️ Clearing existing data...'); // console.log('🗑️ Clearing existing data...');
await prisma.quizAttempt.deleteMany(); // await prisma.quizAttempt.deleteMany();
await prisma.quizQuestion.deleteMany(); // await prisma.choice.deleteMany();
await prisma.quiz.deleteMany(); // await prisma.question.deleteMany();
await prisma.lessonProgress.deleteMany(); // await prisma.quiz.deleteMany();
await prisma.attachment.deleteMany(); // await prisma.lessonProgress.deleteMany();
await prisma.lessonPrerequisite.deleteMany(); // await prisma.lessonAttachment.deleteMany();
await prisma.lesson.deleteMany(); // await prisma.lesson.deleteMany();
await prisma.chapter.deleteMany(); // await prisma.chapter.deleteMany();
await prisma.enrollment.deleteMany(); // await prisma.announcementAttachment.deleteMany();
await prisma.courseInstructor.deleteMany(); // await prisma.announcement.deleteMany();
await prisma.course.deleteMany(); // await prisma.certificate.deleteMany();
await prisma.category.deleteMany(); // await prisma.enrollment.deleteMany();
await prisma.certificate.deleteMany(); // await prisma.courseInstructor.deleteMany();
await prisma.profile.deleteMany(); // await prisma.course.deleteMany();
await prisma.user.deleteMany(); // await prisma.category.deleteMany();
await prisma.role.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 // Seed Roles
console.log('👥 Seeding roles...'); console.log('👥 Seeding roles...');
@ -63,11 +70,12 @@ async function main() {
email: 'admin@elearning.local', email: 'admin@elearning.local',
password: hashedPassword, password: hashedPassword,
role_id: roles[0].id, role_id: roles[0].id,
email_verified_at: new Date(),
profile: { profile: {
create: { create: {
prefix: { th: 'นาย', en: 'Mr.' },
first_name: 'Admin', first_name: 'Admin',
last_name: 'User', last_name: 'User'
bio: { th: 'ผู้ดูแลระบบ', en: 'System Administrator' }
} }
} }
} }
@ -79,11 +87,12 @@ async function main() {
email: 'instructor@elearning.local', email: 'instructor@elearning.local',
password: hashedPassword, password: hashedPassword,
role_id: roles[1].id, role_id: roles[1].id,
email_verified_at: new Date(),
profile: { profile: {
create: { create: {
prefix: { th: 'นาย', en: 'Mr.' },
first_name: 'John', first_name: 'John',
last_name: 'Doe', last_name: 'Doe'
bio: { th: 'ผู้สอนมืออาชีพ', en: 'Professional Instructor' }
} }
} }
} }
@ -95,11 +104,12 @@ async function main() {
email: 'student@elearning.local', email: 'student@elearning.local',
password: hashedPassword, password: hashedPassword,
role_id: roles[2].id, role_id: roles[2].id,
email_verified_at: new Date(),
profile: { profile: {
create: { create: {
prefix: { th: 'นางสาว', en: 'Ms.' },
first_name: 'Jane', first_name: 'Jane',
last_name: 'Smith', last_name: 'Smith'
bio: { th: 'นักเรียนที่กระตือรือร้น', en: 'Eager learner' }
} }
} }
} }
@ -110,23 +120,32 @@ async function main() {
const categories = await Promise.all([ const categories = await Promise.all([
prisma.category.create({ prisma.category.create({
data: { data: {
code: 'PROGRAMMING',
name: { th: 'การเขียนโปรแกรม', en: '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({ prisma.category.create({
data: { data: {
code: 'DESIGN',
name: { th: 'การออกแบบ', en: '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({ prisma.category.create({
data: { data: {
code: 'BUSINESS',
name: { th: 'ธุรกิจ', en: '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({ const course = await prisma.course.create({
data: { data: {
title: { th: 'พื้นฐาน JavaScript', en: 'JavaScript Fundamentals' }, title: { th: 'พื้นฐาน JavaScript', en: 'JavaScript Fundamentals' },
slug: 'javascript-fundamentals',
description: { description: {
th: 'เรียนรู้พื้นฐาน JavaScript ตั้งแต่เริ่มต้น', th: 'เรียนรู้พื้นฐาน JavaScript ตั้งแต่เริ่มต้น รวมถึงตัวแปร ฟังก์ชัน และการจัดการ DOM',
en: 'Learn JavaScript fundamentals from scratch' en: 'Learn JavaScript fundamentals from scratch including variables, functions, and DOM manipulation'
}, },
price: 0, price: 0,
is_free: true, is_free: true,
@ -146,6 +166,8 @@ async function main() {
status: 'APPROVED', status: 'APPROVED',
category_id: categories[0].id, category_id: categories[0].id,
created_by: instructor.id, created_by: instructor.id,
approved_by: admin.id,
approved_at: new Date(),
instructors: { instructors: {
create: { create: {
user_id: instructor.id, user_id: instructor.id,
@ -156,43 +178,177 @@ async function main() {
create: [ create: [
{ {
title: { th: 'บทที่ 1: เริ่มต้น', en: 'Chapter 1: Getting Started' }, title: { th: 'บทที่ 1: เริ่มต้น', en: 'Chapter 1: Getting Started' },
description: { th: 'แนะนำ JavaScript', en: 'Introduction to JavaScript' }, description: { th: 'แนะนำ JavaScript และการตั้งค่าสภาพแวดล้อม', en: 'Introduction to JavaScript and environment setup' },
order: 1, sort_order: 1,
is_published: true,
lessons: { lessons: {
create: [ create: [
{ {
title: { th: 'JavaScript คืออะไร', en: 'What is JavaScript' }, 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', type: 'VIDEO',
order: 1, duration_minutes: 15,
is_preview: true sort_order: 1,
is_published: true
}, },
{ {
title: { th: 'ตัวแปรและชนิดข้อมูล', en: 'Variables and Data Types' }, 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', 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' }, title: { th: 'บทที่ 2: ฟังก์ชัน', en: 'Chapter 2: Functions' },
description: { th: 'เรียนรู้เกี่ยวกับฟังก์ชัน', en: 'Learn about functions' }, description: { th: 'เรียนรู้เกี่ยวกับฟังก์ชันและการใช้งาน', en: 'Learn about functions and their usage' },
order: 2, sort_order: 2,
is_published: true,
lessons: { lessons: {
create: [ create: [
{ {
title: { th: 'การสร้างฟังก์ชัน', en: 'Creating Functions' }, 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', 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(`- Users: 3 (admin, instructor, student)`);
console.log(`- Categories: ${categories.length}`); console.log(`- Categories: ${categories.length}`);
console.log(`- Courses: 1`); 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('\n🔑 Test Credentials:');
console.log('Admin: admin / admin123'); console.log('Admin: admin / admin123');
console.log('Instructor: instructor / 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"
}
}

View file

@ -49,6 +49,7 @@ Table user_profiles {
last_name varchar [not null] last_name varchar [not null]
phone varchar phone varchar
avatar_url varchar avatar_url varchar
birth_date datetime
created_at datetime [not null, default: `now()`] created_at datetime [not null, default: `now()`]
updated_at datetime updated_at datetime
updated_by int [ref: > users.id] updated_by int [ref: > users.id]
@ -126,6 +127,29 @@ Table course_instructors {
} }
} }
Table course_approvals {
id int [pk, increment]
course_id int [not null, ref: > courses.id]
submitted_by int [not null, ref: > users.id, note: 'instructor who submitted']
reviewed_by int [ref: > users.id, note: 'admin who reviewed']
action varchar [not null, note: 'ENUM: SUBMITTED | APPROVED | REJECTED']
previous_status varchar [not null, note: 'status before this action']
new_status varchar [not null, note: 'status after this action']
comment text [note: 'admin comment or rejection reason']
created_at datetime [not null, default: `now()`]
Note: 'Tracks complete approval workflow history'
indexes {
course_id
submitted_by
reviewed_by
action
created_at
(course_id, created_at)
}
}
Table chapters { Table chapters {
id int [pk, increment] id int [pk, increment]
course_id int [not null, ref: > courses.id] course_id int [not null, ref: > courses.id]
@ -505,3 +529,6 @@ Table withdrawal_requests {
// - enrollments: CASCADE (delete with user/course) // - enrollments: CASCADE (delete with user/course)
// - announcements: CASCADE (delete with course) // - announcements: CASCADE (delete with course)
// - quiz_attempts: CASCADE (delete with user) // - quiz_attempts: CASCADE (delete with user)
Ref: "certificates"."issued_at" < "certificates"."enrollment_id"