migration to typescript
This commit is contained in:
parent
924000b084
commit
9fde77468a
41 changed files with 11952 additions and 10164 deletions
|
|
@ -4,63 +4,214 @@ description: How to create a new API endpoint
|
|||
|
||||
# Create API Endpoint Workflow
|
||||
|
||||
Follow these steps to create a new API endpoint for the E-Learning Platform backend.
|
||||
Follow these steps to create a new API endpoint for the E-Learning Platform backend using TypeScript and TSOA.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Define Route
|
||||
## Step 1: Define Route with TSOA Controller
|
||||
|
||||
Create or update route file in `src/routes/`:
|
||||
Create or update controller file in `src/controllers/`:
|
||||
|
||||
```javascript
|
||||
// src/routes/courses.routes.js
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const courseController = require('../controllers/course.controller');
|
||||
const { authenticate, authorize } = require('../middleware/auth');
|
||||
const { validate } = require('../middleware/validation');
|
||||
const { courseSchema } = require('../validators/course.validator');
|
||||
```typescript
|
||||
// src/controllers/course.controller.ts
|
||||
import { Request, Response } from 'express';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Route,
|
||||
Tags,
|
||||
Security,
|
||||
Body,
|
||||
Path,
|
||||
Query,
|
||||
SuccessResponse
|
||||
} from 'tsoa';
|
||||
import { courseService } from '../services/course.service';
|
||||
import { CreateCourseDto, UpdateCourseDto, CourseListQuery } from '../types/course.types';
|
||||
|
||||
// Public routes
|
||||
router.get('/', courseController.list);
|
||||
router.get('/:id', courseController.getById);
|
||||
@Route('api/courses')
|
||||
@Tags('Courses')
|
||||
export class CourseController extends Controller {
|
||||
|
||||
/**
|
||||
* Get list of courses
|
||||
* @summary List all approved courses
|
||||
*/
|
||||
@Get('/')
|
||||
@SuccessResponse(200, 'Success')
|
||||
public async list(
|
||||
@Query() page: number = 1,
|
||||
@Query() limit: number = 20,
|
||||
@Query() category?: number,
|
||||
@Query() search?: string
|
||||
): Promise<{
|
||||
data: any[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}> {
|
||||
const result = await courseService.list({
|
||||
page: parseInt(page.toString()),
|
||||
limit: parseInt(limit.toString()),
|
||||
category: category ? parseInt(category.toString()) : undefined,
|
||||
search
|
||||
});
|
||||
|
||||
// Instructor routes
|
||||
router.post(
|
||||
'/',
|
||||
authenticate,
|
||||
authorize(['INSTRUCTOR', 'ADMIN']),
|
||||
validate(courseSchema.create),
|
||||
courseController.create
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
router.put(
|
||||
'/:id',
|
||||
authenticate,
|
||||
authorize(['INSTRUCTOR', 'ADMIN']),
|
||||
validate(courseSchema.update),
|
||||
courseController.update
|
||||
);
|
||||
/**
|
||||
* Get course by ID
|
||||
* @summary Get course details
|
||||
*/
|
||||
@Get('{id}')
|
||||
@SuccessResponse(200, 'Success')
|
||||
public async getById(@Path() id: number): Promise<any> {
|
||||
const course = await courseService.getById(parseInt(id.toString()));
|
||||
|
||||
module.exports = router;
|
||||
if (!course) {
|
||||
this.setStatus(404);
|
||||
throw new Error('Course not found');
|
||||
}
|
||||
|
||||
return course;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new course
|
||||
* @summary Create course (Instructor/Admin only)
|
||||
*/
|
||||
@Post('/')
|
||||
@Security('jwt', ['INSTRUCTOR', 'ADMIN'])
|
||||
@SuccessResponse(201, 'Created')
|
||||
public async create(
|
||||
@Body() body: CreateCourseDto,
|
||||
@Request() req: any
|
||||
): Promise<any> {
|
||||
const course = await courseService.create(body, req.user.id);
|
||||
this.setStatus(201);
|
||||
return course;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update course
|
||||
* @summary Update course (Instructor/Admin only)
|
||||
*/
|
||||
@Put('{id}')
|
||||
@Security('jwt', ['INSTRUCTOR', 'ADMIN'])
|
||||
@SuccessResponse(200, 'Success')
|
||||
public async update(
|
||||
@Path() id: number,
|
||||
@Body() body: UpdateCourseDto,
|
||||
@Request() req: any
|
||||
): Promise<any> {
|
||||
// Check ownership
|
||||
const course = await courseService.getById(parseInt(id.toString()));
|
||||
if (!course) {
|
||||
this.setStatus(404);
|
||||
throw new Error('Course not found');
|
||||
}
|
||||
|
||||
if (course.created_by !== req.user.id && req.user.role.code !== 'ADMIN') {
|
||||
this.setStatus(403);
|
||||
throw new Error('You do not have permission to update this course');
|
||||
}
|
||||
|
||||
const updated = await courseService.update(parseInt(id.toString()), body);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Create Validator
|
||||
## Step 2: Create Type Definitions
|
||||
|
||||
Create types in `src/types/`:
|
||||
|
||||
```typescript
|
||||
// src/types/course.types.ts
|
||||
|
||||
/**
|
||||
* Multi-language text structure
|
||||
*/
|
||||
export interface MultiLang {
|
||||
th: string;
|
||||
en: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create course DTO
|
||||
*/
|
||||
export interface CreateCourseDto {
|
||||
/** Course title in Thai and English */
|
||||
title: MultiLang;
|
||||
|
||||
/** Course description in Thai and English */
|
||||
description: MultiLang;
|
||||
|
||||
/** Category ID */
|
||||
category_id: number;
|
||||
|
||||
/** Course price */
|
||||
price: number;
|
||||
|
||||
/** Is the course free? */
|
||||
is_free?: boolean;
|
||||
|
||||
/** Does the course have a certificate? */
|
||||
have_certificate?: boolean;
|
||||
|
||||
/** Course thumbnail URL */
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update course DTO
|
||||
*/
|
||||
export interface UpdateCourseDto {
|
||||
title?: MultiLang;
|
||||
description?: MultiLang;
|
||||
category_id?: number;
|
||||
price?: number;
|
||||
is_free?: boolean;
|
||||
have_certificate?: boolean;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Course list query parameters
|
||||
*/
|
||||
export interface CourseListQuery {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
category?: number;
|
||||
search?: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Create Validation Schemas
|
||||
|
||||
Create validator in `src/validators/`:
|
||||
|
||||
```javascript
|
||||
// src/validators/course.validator.js
|
||||
const Joi = require('joi');
|
||||
```typescript
|
||||
// src/validators/course.validator.ts
|
||||
import Joi from 'joi';
|
||||
|
||||
const multiLangSchema = Joi.object({
|
||||
th: Joi.string().required(),
|
||||
en: Joi.string().required()
|
||||
});
|
||||
|
||||
const courseSchema = {
|
||||
export const courseSchema = {
|
||||
create: Joi.object({
|
||||
title: multiLangSchema.required(),
|
||||
description: multiLangSchema.required(),
|
||||
|
|
@ -81,124 +232,6 @@ const courseSchema = {
|
|||
thumbnail: Joi.string().uri().optional()
|
||||
})
|
||||
};
|
||||
|
||||
module.exports = { courseSchema };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Create Controller
|
||||
|
||||
Create controller in `src/controllers/`:
|
||||
|
||||
```javascript
|
||||
// src/controllers/course.controller.js
|
||||
const courseService = require('../services/course.service');
|
||||
|
||||
class CourseController {
|
||||
async list(req, res) {
|
||||
try {
|
||||
const { page = 1, limit = 20, category, search } = req.query;
|
||||
|
||||
const result = await courseService.list({
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
category: category ? parseInt(category) : undefined,
|
||||
search
|
||||
});
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('List courses error:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Failed to fetch courses'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getById(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const course = await courseService.getById(parseInt(id));
|
||||
|
||||
if (!course) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
code: 'COURSE_NOT_FOUND',
|
||||
message: 'Course not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json(course);
|
||||
} catch (error) {
|
||||
console.error('Get course error:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Failed to fetch course'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async create(req, res) {
|
||||
try {
|
||||
const course = await courseService.create(req.body, req.user.id);
|
||||
return res.status(201).json(course);
|
||||
} catch (error) {
|
||||
console.error('Create course error:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Failed to create course'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async update(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check ownership
|
||||
const course = await courseService.getById(parseInt(id));
|
||||
if (!course) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
code: 'COURSE_NOT_FOUND',
|
||||
message: 'Course not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (course.created_by !== req.user.id && req.user.role.code !== 'ADMIN') {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You do not have permission to update this course'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await courseService.update(parseInt(id), req.body);
|
||||
return res.status(200).json(updated);
|
||||
} catch (error) {
|
||||
console.error('Update course error:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Failed to update course'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CourseController();
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -207,16 +240,18 @@ module.exports = new CourseController();
|
|||
|
||||
Create service in `src/services/`:
|
||||
|
||||
```javascript
|
||||
// src/services/course.service.js
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
```typescript
|
||||
// src/services/course.service.ts
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { CreateCourseDto, UpdateCourseDto, CourseListQuery } from '../types/course.types';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
class CourseService {
|
||||
async list({ page, limit, category, search }) {
|
||||
const skip = (page - 1) * limit;
|
||||
async list({ page, limit, category, search }: CourseListQuery) {
|
||||
const skip = ((page || 1) - 1) * (limit || 20);
|
||||
|
||||
const where = {
|
||||
const where: any = {
|
||||
is_deleted: false,
|
||||
status: 'APPROVED'
|
||||
};
|
||||
|
|
@ -236,7 +271,7 @@ class CourseService {
|
|||
prisma.course.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
take: limit || 20,
|
||||
include: {
|
||||
category: true,
|
||||
instructors: {
|
||||
|
|
@ -252,15 +287,15 @@ class CourseService {
|
|||
return {
|
||||
data,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
page: page || 1,
|
||||
limit: limit || 20,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
totalPages: Math.ceil(total / (limit || 20))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async getById(id) {
|
||||
async getById(id: number) {
|
||||
return prisma.course.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
|
|
@ -285,7 +320,7 @@ class CourseService {
|
|||
});
|
||||
}
|
||||
|
||||
async create(data, userId) {
|
||||
async create(data: CreateCourseDto, userId: number) {
|
||||
return prisma.course.create({
|
||||
data: {
|
||||
...data,
|
||||
|
|
@ -307,7 +342,7 @@ class CourseService {
|
|||
});
|
||||
}
|
||||
|
||||
async update(id, data) {
|
||||
async update(id: number, data: UpdateCourseDto) {
|
||||
return prisma.course.update({
|
||||
where: { id },
|
||||
data,
|
||||
|
|
@ -321,46 +356,100 @@ class CourseService {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = new CourseService();
|
||||
export const courseService = new CourseService();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Register Route
|
||||
## Step 5: Generate TSOA Routes and Swagger Docs
|
||||
|
||||
Update `src/app.js`:
|
||||
// turbo
|
||||
After creating the controller, generate routes and API documentation:
|
||||
|
||||
```bash
|
||||
# Generate TSOA routes and Swagger spec
|
||||
npm run tsoa:gen
|
||||
|
||||
# This will:
|
||||
# 1. Generate routes in src/routes/tsoa-routes.ts
|
||||
# 2. Generate swagger.json in public/swagger.json
|
||||
# 3. Validate all TSOA decorators
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Register Routes in App
|
||||
|
||||
Update `src/app.ts`:
|
||||
|
||||
```typescript
|
||||
// src/app.ts
|
||||
import express from 'express';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import { RegisterRoutes } from './routes/tsoa-routes';
|
||||
|
||||
```javascript
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Routes
|
||||
const authRoutes = require('./routes/auth.routes');
|
||||
const courseRoutes = require('./routes/courses.routes');
|
||||
// Register TSOA routes
|
||||
RegisterRoutes(app);
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/courses', courseRoutes);
|
||||
// Swagger documentation
|
||||
app.use('/api-docs', swaggerUi.serve, async (_req, res) => {
|
||||
return res.send(
|
||||
swaggerUi.generateHTML(await import('../public/swagger.json'))
|
||||
);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
export default app;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Write Tests
|
||||
## Step 7: Configure TSOA
|
||||
|
||||
Create `tsoa.json` in project root:
|
||||
|
||||
```json
|
||||
{
|
||||
"entryFile": "src/app.ts",
|
||||
"noImplicitAdditionalProperties": "throw-on-extras",
|
||||
"controllerPathGlobs": ["src/controllers/**/*.controller.ts"],
|
||||
"spec": {
|
||||
"outputDirectory": "public",
|
||||
"specVersion": 3,
|
||||
"securityDefinitions": {
|
||||
"jwt": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT"
|
||||
}
|
||||
}
|
||||
},
|
||||
"routes": {
|
||||
"routesDir": "src/routes",
|
||||
"middleware": "express",
|
||||
"authenticationModule": "./src/middleware/auth.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 8: Write Tests
|
||||
|
||||
Create test file in `tests/integration/`:
|
||||
|
||||
```javascript
|
||||
// tests/integration/courses.test.js
|
||||
const request = require('supertest');
|
||||
const app = require('../../src/app');
|
||||
```typescript
|
||||
// tests/integration/courses.test.ts
|
||||
import request from 'supertest';
|
||||
import app from '../../src/app';
|
||||
|
||||
describe('Course API', () => {
|
||||
let instructorToken;
|
||||
let instructorToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await request(app)
|
||||
|
|
@ -423,94 +512,118 @@ describe('Course API', () => {
|
|||
|
||||
---
|
||||
|
||||
## Step 7: Run Tests
|
||||
## Step 9: Run Tests
|
||||
|
||||
// turbo
|
||||
```bash
|
||||
npm test -- courses.test.js
|
||||
npm test -- courses.test.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 8: Test Manually
|
||||
## Step 10: View API Documentation
|
||||
|
||||
// turbo
|
||||
```bash
|
||||
# Create course
|
||||
curl -X POST http://localhost:4000/api/courses \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": {"th": "ทดสอบ", "en": "Test"},
|
||||
"description": {"th": "รายละเอียด", "en": "Description"},
|
||||
"category_id": 1,
|
||||
"price": 990
|
||||
}'
|
||||
After generating TSOA routes, access Swagger UI:
|
||||
|
||||
# List courses
|
||||
curl http://localhost:4000/api/courses?page=1&limit=10
|
||||
```
|
||||
http://localhost:4000/api-docs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Route defined with proper middleware
|
||||
- [ ] Validator created with Joi/Zod
|
||||
- [ ] Controller created with error handling
|
||||
- [ ] Controller created with TSOA decorators
|
||||
- [ ] Type definitions created
|
||||
- [ ] Validator created with Joi
|
||||
- [ ] Service created with business logic
|
||||
- [ ] Route registered in app.js
|
||||
- [ ] TSOA routes generated (`npm run tsoa:gen`)
|
||||
- [ ] Routes registered in app.ts
|
||||
- [ ] Tests written (unit + integration)
|
||||
- [ ] All tests passing
|
||||
- [ ] API documentation accessible at /api-docs
|
||||
- [ ] Manual testing done
|
||||
- [ ] Documentation updated
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Separation of Concerns**: Routes → Controllers → Services → Database
|
||||
2. **Validation**: Always validate input at route level
|
||||
3. **Authorization**: Check permissions in controller
|
||||
4. **Error Handling**: Use try-catch and return proper error codes
|
||||
5. **Multi-Language**: Use JSON structure for user-facing text
|
||||
6. **Pagination**: Always paginate list endpoints
|
||||
7. **Testing**: Write tests before deploying
|
||||
1. **TSOA Decorators**: Use proper decorators (@Get, @Post, @Security, etc.)
|
||||
2. **Type Safety**: Define interfaces for all DTOs
|
||||
3. **Documentation**: Add JSDoc comments for Swagger descriptions
|
||||
4. **Validation**: Validate input at controller level
|
||||
5. **Authorization**: Use @Security decorator for protected routes
|
||||
6. **Multi-Language**: Use JSON structure for user-facing text
|
||||
7. **Pagination**: Always paginate list endpoints
|
||||
8. **Testing**: Write tests before deploying
|
||||
9. **Auto-Generate**: Always run `npm run tsoa:gen` after route changes
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
## Common TSOA Patterns
|
||||
|
||||
### File Upload Endpoint
|
||||
```javascript
|
||||
const multer = require('multer');
|
||||
const upload = multer({ dest: 'uploads/' });
|
||||
```typescript
|
||||
import { UploadedFile } from 'express-fileupload';
|
||||
|
||||
router.post(
|
||||
'/upload',
|
||||
authenticate,
|
||||
upload.single('file'),
|
||||
validate(uploadSchema),
|
||||
controller.upload
|
||||
);
|
||||
@Post('upload')
|
||||
@Security('jwt', ['INSTRUCTOR', 'ADMIN'])
|
||||
public async upload(
|
||||
@UploadedFile() file: Express.Multer.File
|
||||
): Promise<{ url: string }> {
|
||||
const result = await uploadService.uploadFile(file);
|
||||
return { url: result.url };
|
||||
}
|
||||
```
|
||||
|
||||
### Ownership Check
|
||||
```javascript
|
||||
// In controller
|
||||
const resource = await service.getById(id);
|
||||
if (resource.user_id !== req.user.id && req.user.role.code !== 'ADMIN') {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
```typescript
|
||||
@Put('{id}')
|
||||
@Security('jwt')
|
||||
public async update(
|
||||
@Path() id: number,
|
||||
@Body() body: UpdateDto,
|
||||
@Request() req: any
|
||||
): Promise<any> {
|
||||
const resource = await service.getById(id);
|
||||
if (!resource) {
|
||||
this.setStatus(404);
|
||||
throw new Error('Not found');
|
||||
}
|
||||
|
||||
if (resource.user_id !== req.user.id && req.user.role.code !== 'ADMIN') {
|
||||
this.setStatus(403);
|
||||
throw new Error('Forbidden');
|
||||
}
|
||||
|
||||
return await service.update(id, body);
|
||||
}
|
||||
```
|
||||
|
||||
### Soft Delete
|
||||
```javascript
|
||||
// In service
|
||||
async delete(id) {
|
||||
return prisma.course.update({
|
||||
where: { id },
|
||||
data: { is_deleted: true, deleted_at: new Date() }
|
||||
});
|
||||
```typescript
|
||||
@Delete('{id}')
|
||||
@Security('jwt')
|
||||
public async delete(@Path() id: number): Promise<void> {
|
||||
await service.softDelete(id);
|
||||
this.setStatus(204);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Package.json Scripts
|
||||
|
||||
Add these scripts to `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "nodemon --exec ts-node src/server.ts",
|
||||
"build": "npm run tsoa:gen && tsc",
|
||||
"tsoa:gen": "tsoa spec-and-routes",
|
||||
"start": "node dist/server.js",
|
||||
"test": "jest"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ description: Complete workflow for developing E-Learning Platform backend
|
|||
|
||||
# E-Learning Backend Development Workflow
|
||||
|
||||
Complete guide for developing the E-Learning Platform backend from scratch.
|
||||
Complete guide for developing the E-Learning Platform backend using TypeScript and TSOA.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -28,6 +28,10 @@ cd e-learning/Backend
|
|||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Install TypeScript and TSOA
|
||||
npm install -D typescript @types/node @types/express ts-node nodemon
|
||||
npm install tsoa swagger-ui-express
|
||||
|
||||
# Setup environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration
|
||||
|
|
@ -126,72 +130,12 @@ npx prisma migrate dev --name initial_schema
|
|||
- `POST /api/auth/refresh` - Refresh token
|
||||
|
||||
**Implementation steps:**
|
||||
1. Create `src/routes/auth.routes.js`
|
||||
2. Create `src/controllers/auth.controller.js`
|
||||
3. Create `src/services/auth.service.js`
|
||||
4. Implement JWT middleware
|
||||
5. Write tests
|
||||
1. Create TSOA controller with `@Route`, `@Post` decorators
|
||||
2. Create TypeScript service with interfaces
|
||||
3. Implement JWT middleware
|
||||
4. Write tests
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
// src/services/auth.service.js
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
class AuthService {
|
||||
async register({ username, email, password, role_id = 3 }) {
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
role_id
|
||||
},
|
||||
include: { role: true }
|
||||
});
|
||||
|
||||
const token = this.generateToken(user);
|
||||
return { user, token };
|
||||
}
|
||||
|
||||
async login({ username, email, password }) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username },
|
||||
{ email }
|
||||
]
|
||||
},
|
||||
include: { role: true }
|
||||
});
|
||||
|
||||
if (!user || !await bcrypt.compare(password, user.password)) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
const token = this.generateToken(user);
|
||||
return { user, token };
|
||||
}
|
||||
|
||||
generateToken(user) {
|
||||
return jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: user.role.code
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AuthService();
|
||||
```
|
||||
See [Create API Endpoint Workflow](./create-api-endpoint.md) for detailed examples.
|
||||
|
||||
### 3.2 Course Management
|
||||
|
||||
|
|
@ -324,41 +268,11 @@ model Course {
|
|||
|
||||
### 5.2 Caching (Redis)
|
||||
|
||||
Implement caching for:
|
||||
- Course listings
|
||||
- User sessions
|
||||
- Frequently accessed data
|
||||
|
||||
```javascript
|
||||
const redis = require('redis');
|
||||
const client = redis.createClient({
|
||||
url: process.env.REDIS_URL
|
||||
});
|
||||
|
||||
// Cache course list
|
||||
const cacheKey = 'courses:approved';
|
||||
const cached = await client.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
const courses = await prisma.course.findMany(...);
|
||||
await client.setEx(cacheKey, 3600, JSON.stringify(courses));
|
||||
```
|
||||
Cache course listings, user sessions, and frequently accessed data using Redis with `setEx()` for TTL.
|
||||
|
||||
### 5.3 Rate Limiting
|
||||
|
||||
```javascript
|
||||
const rateLimit = require('express-rate-limit');
|
||||
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100 // limit each IP to 100 requests per windowMs
|
||||
});
|
||||
|
||||
app.use('/api/', limiter);
|
||||
```
|
||||
Use `express-rate-limit` middleware to limit requests (e.g., 100 requests per 15 minutes).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -379,16 +293,7 @@ app.use('/api/', limiter);
|
|||
|
||||
### 6.2 Implement Security Middleware
|
||||
|
||||
```javascript
|
||||
const helmet = require('helmet');
|
||||
const cors = require('cors');
|
||||
|
||||
app.use(helmet());
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN.split(','),
|
||||
credentials: true
|
||||
}));
|
||||
```
|
||||
Use `helmet()` for security headers and configure CORS with allowed origins.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -396,15 +301,25 @@ app.use(cors({
|
|||
|
||||
### 7.1 API Documentation
|
||||
|
||||
Use Swagger/OpenAPI:
|
||||
TSOA automatically generates Swagger/OpenAPI documentation:
|
||||
|
||||
```javascript
|
||||
const swaggerUi = require('swagger-ui-express');
|
||||
const swaggerDocument = require('./swagger.json');
|
||||
```typescript
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
|
||||
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
|
||||
app.use('/api-docs', swaggerUi.serve, async (_req, res) => {
|
||||
return res.send(
|
||||
swaggerUi.generateHTML(await import('../public/swagger.json'))
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
**Generate documentation:**
|
||||
```bash
|
||||
npm run tsoa:gen
|
||||
```
|
||||
|
||||
Access at: `http://localhost:4000/api-docs`
|
||||
|
||||
### 7.2 Code Documentation
|
||||
|
||||
- JSDoc comments for all functions
|
||||
|
|
@ -500,35 +415,9 @@ git push origin feature/user-authentication
|
|||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Database connection error:**
|
||||
```bash
|
||||
# Check PostgreSQL
|
||||
docker ps | grep postgres
|
||||
docker logs elearning-postgres
|
||||
|
||||
# Test connection
|
||||
npx prisma db pull
|
||||
```
|
||||
|
||||
**Port already in use:**
|
||||
```bash
|
||||
# Find process
|
||||
lsof -i :4000
|
||||
|
||||
# Kill process
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
**Prisma Client error:**
|
||||
```bash
|
||||
# Regenerate client
|
||||
npx prisma generate
|
||||
|
||||
# Reset and migrate
|
||||
npx prisma migrate reset
|
||||
```
|
||||
- **Database error**: Check `docker logs elearning-postgres`, run `npx prisma db pull`
|
||||
- **Port in use**: Find with `lsof -i :4000`, kill with `kill -9 <PID>`
|
||||
- **Prisma error**: Run `npx prisma generate` or `npx prisma migrate reset`
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -545,7 +434,9 @@ npx prisma migrate reset
|
|||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Start dev server
|
||||
npm run dev # Start dev server (ts-node)
|
||||
npm run build # Build TypeScript
|
||||
npm run tsoa:gen # Generate TSOA routes & Swagger
|
||||
npm test # Run tests
|
||||
npm run lint # Run linter
|
||||
npm run format # Format code
|
||||
|
|
|
|||
|
|
@ -4,54 +4,61 @@ description: How to handle file uploads (videos, attachments)
|
|||
|
||||
# File Upload Workflow
|
||||
|
||||
Follow these steps to implement file upload functionality for videos and attachments.
|
||||
Follow these steps to implement file upload functionality for videos and attachments using TypeScript and TSOA.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- MinIO/S3 configured and running
|
||||
- Multer installed: `npm install multer`
|
||||
- AWS SDK or MinIO client installed
|
||||
- Dependencies installed: `npm install multer @aws-sdk/client-s3 @aws-sdk/lib-storage`
|
||||
- TypeScript configured
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Configure S3/MinIO Client
|
||||
|
||||
Create `src/config/s3.config.js`:
|
||||
Create `src/config/s3.config.ts`:
|
||||
|
||||
```javascript
|
||||
const { S3Client } = require('@aws-sdk/client-s3');
|
||||
const { Upload } = require('@aws-sdk/lib-storage');
|
||||
```typescript
|
||||
import { S3Client } from '@aws-sdk/client-s3';
|
||||
|
||||
const s3Client = new S3Client({
|
||||
export const s3Client = new S3Client({
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
region: process.env.S3_REGION || 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY,
|
||||
secretAccessKey: process.env.S3_SECRET_KEY
|
||||
accessKeyId: process.env.S3_ACCESS_KEY!,
|
||||
secretAccessKey: process.env.S3_SECRET_KEY!
|
||||
},
|
||||
forcePathStyle: true // Required for MinIO
|
||||
});
|
||||
|
||||
module.exports = { s3Client };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Create Upload Service
|
||||
|
||||
Create `src/services/upload.service.js`:
|
||||
Create `src/services/upload.service.ts`:
|
||||
|
||||
```javascript
|
||||
const { s3Client } = require('../config/s3.config');
|
||||
const { Upload } = require('@aws-sdk/lib-storage');
|
||||
const { DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const path = require('path');
|
||||
```typescript
|
||||
import { s3Client } from '../config/s3.config';
|
||||
import { Upload } from '@aws-sdk/lib-storage';
|
||||
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import path from 'path';
|
||||
|
||||
export interface UploadResult {
|
||||
key: string;
|
||||
url: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
type FolderType = 'videos' | 'documents' | 'images' | 'attachments';
|
||||
|
||||
class UploadService {
|
||||
async uploadFile(file, folder) {
|
||||
async uploadFile(file: Express.Multer.File, folder: FolderType): Promise<UploadResult> {
|
||||
const fileExt = path.extname(file.originalname);
|
||||
const fileName = `${Date.now()}-${uuidv4()}${fileExt}`;
|
||||
const key = `${folder}/${fileName}`;
|
||||
|
|
@ -77,7 +84,7 @@ class UploadService {
|
|||
};
|
||||
}
|
||||
|
||||
async deleteFile(key, folder) {
|
||||
async deleteFile(key: string, folder: FolderType): Promise<void> {
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: this.getBucket(folder),
|
||||
Key: key
|
||||
|
|
@ -86,179 +93,182 @@ class UploadService {
|
|||
await s3Client.send(command);
|
||||
}
|
||||
|
||||
getBucket(folder) {
|
||||
const bucketMap = {
|
||||
videos: process.env.S3_BUCKET_VIDEOS,
|
||||
documents: process.env.S3_BUCKET_DOCUMENTS,
|
||||
images: process.env.S3_BUCKET_IMAGES,
|
||||
attachments: process.env.S3_BUCKET_ATTACHMENTS
|
||||
private getBucket(folder: FolderType): string {
|
||||
const bucketMap: Record<FolderType, string> = {
|
||||
videos: process.env.S3_BUCKET_VIDEOS!,
|
||||
documents: process.env.S3_BUCKET_DOCUMENTS!,
|
||||
images: process.env.S3_BUCKET_IMAGES!,
|
||||
attachments: process.env.S3_BUCKET_ATTACHMENTS!
|
||||
};
|
||||
|
||||
return bucketMap[folder] || process.env.S3_BUCKET_COURSES;
|
||||
return bucketMap[folder] || process.env.S3_BUCKET_COURSES!;
|
||||
}
|
||||
|
||||
validateFileType(file, allowedTypes) {
|
||||
validateFileType(file: Express.Multer.File, allowedTypes: string[]): boolean {
|
||||
return allowedTypes.includes(file.mimetype);
|
||||
}
|
||||
|
||||
validateFileSize(file, maxSize) {
|
||||
validateFileSize(file: Express.Multer.File, maxSize: number): boolean {
|
||||
return file.size <= maxSize;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UploadService();
|
||||
export const uploadService = new UploadService();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Create Upload Middleware
|
||||
|
||||
Create `src/middleware/upload.middleware.js`:
|
||||
Create `src/middleware/upload.middleware.ts`:
|
||||
|
||||
```javascript
|
||||
const multer = require('multer');
|
||||
```typescript
|
||||
import multer from 'multer';
|
||||
import { Request } from 'express';
|
||||
|
||||
// File type validators
|
||||
const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime'];
|
||||
const ALLOWED_DOCUMENT_TYPES = [
|
||||
export const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime'];
|
||||
export const ALLOWED_DOCUMENT_TYPES = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
];
|
||||
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif'];
|
||||
export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif'];
|
||||
|
||||
// File size limits
|
||||
const MAX_VIDEO_SIZE = 500 * 1024 * 1024; // 500 MB
|
||||
const MAX_ATTACHMENT_SIZE = 100 * 1024 * 1024; // 100 MB
|
||||
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
export const MAX_VIDEO_SIZE = 500 * 1024 * 1024; // 500 MB
|
||||
export const MAX_ATTACHMENT_SIZE = 100 * 1024 * 1024; // 100 MB
|
||||
export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
// Multer configuration
|
||||
const storage = multer.memoryStorage();
|
||||
|
||||
const fileFilter = (allowedTypes) => (req, file, cb) => {
|
||||
const fileFilter = (allowedTypes: string[]) => (
|
||||
req: Request,
|
||||
file: Express.Multer.File,
|
||||
cb: multer.FileFilterCallback
|
||||
) => {
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type'), false);
|
||||
cb(new Error('Invalid file type'));
|
||||
}
|
||||
};
|
||||
|
||||
// Upload configurations
|
||||
const uploadVideo = multer({
|
||||
export const uploadVideo = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_VIDEO_SIZE },
|
||||
fileFilter: fileFilter(ALLOWED_VIDEO_TYPES)
|
||||
}).single('video');
|
||||
|
||||
const uploadAttachment = multer({
|
||||
export const uploadAttachment = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_ATTACHMENT_SIZE },
|
||||
fileFilter: fileFilter([...ALLOWED_DOCUMENT_TYPES, ...ALLOWED_IMAGE_TYPES])
|
||||
}).single('file');
|
||||
|
||||
const uploadAttachments = multer({
|
||||
export const uploadAttachments = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_ATTACHMENT_SIZE },
|
||||
fileFilter: fileFilter([...ALLOWED_DOCUMENT_TYPES, ...ALLOWED_IMAGE_TYPES])
|
||||
}).array('attachments', 10); // Max 10 files
|
||||
|
||||
const uploadImage = multer({
|
||||
export const uploadImage = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_IMAGE_SIZE },
|
||||
fileFilter: fileFilter(ALLOWED_IMAGE_TYPES)
|
||||
}).single('image');
|
||||
|
||||
module.exports = {
|
||||
uploadVideo,
|
||||
uploadAttachment,
|
||||
uploadAttachments,
|
||||
uploadImage,
|
||||
ALLOWED_VIDEO_TYPES,
|
||||
ALLOWED_DOCUMENT_TYPES,
|
||||
ALLOWED_IMAGE_TYPES
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Create Upload Controller
|
||||
## Step 4: Create Upload Controller with TSOA
|
||||
|
||||
Create `src/controllers/upload.controller.js`:
|
||||
Create `src/controllers/upload.controller.ts`:
|
||||
|
||||
```javascript
|
||||
const uploadService = require('../services/upload.service');
|
||||
```typescript
|
||||
import { Request } from 'express';
|
||||
import { Controller, Post, Route, Tags, Security, UploadedFile, SuccessResponse } from 'tsoa';
|
||||
import { uploadService } from '../services/upload.service';
|
||||
|
||||
class UploadController {
|
||||
async uploadVideo(req, res) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: 'NO_FILE',
|
||||
message: 'No video file provided'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const result = await uploadService.uploadFile(
|
||||
req.file,
|
||||
'videos'
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
video_url: result.url,
|
||||
file_size: result.fileSize,
|
||||
duration: null // Will be processed later
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload video error:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
code: 'UPLOAD_FAILED',
|
||||
message: 'Failed to upload video'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async uploadAttachment(req, res) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: 'NO_FILE',
|
||||
message: 'No file provided'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const result = await uploadService.uploadFile(
|
||||
req.file,
|
||||
'attachments'
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
file_name: result.fileName,
|
||||
file_path: result.key,
|
||||
file_size: result.fileSize,
|
||||
mime_type: result.mimeType,
|
||||
download_url: result.url
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload attachment error:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
code: 'UPLOAD_FAILED',
|
||||
message: 'Failed to upload file'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
interface UploadResponse {
|
||||
video_url?: string;
|
||||
file_size: number;
|
||||
duration?: number | null;
|
||||
file_name?: string;
|
||||
file_path?: string;
|
||||
mime_type?: string;
|
||||
download_url?: string;
|
||||
}
|
||||
|
||||
module.exports = new UploadController();
|
||||
@Route('api/upload')
|
||||
@Tags('File Upload')
|
||||
export class UploadController extends Controller {
|
||||
|
||||
/**
|
||||
* Upload video file
|
||||
* @summary Upload video for lesson (Instructor/Admin only)
|
||||
*/
|
||||
@Post('video')
|
||||
@Security('jwt', ['INSTRUCTOR', 'ADMIN'])
|
||||
@SuccessResponse(200, 'Success')
|
||||
public async uploadVideo(
|
||||
@UploadedFile() file: Express.Multer.File
|
||||
): Promise<UploadResponse> {
|
||||
if (!file) {
|
||||
this.setStatus(400);
|
||||
throw new Error('No video file provided');
|
||||
}
|
||||
|
||||
const result = await uploadService.uploadFile(file, 'videos');
|
||||
|
||||
return {
|
||||
video_url: result.url,
|
||||
file_size: result.fileSize,
|
||||
duration: null // Will be processed later
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload attachment file
|
||||
* @summary Upload attachment for lesson (Instructor/Admin only)
|
||||
*/
|
||||
@Post('attachment')
|
||||
@Security('jwt', ['INSTRUCTOR', 'ADMIN'])
|
||||
@SuccessResponse(200, 'Success')
|
||||
public async uploadAttachment(
|
||||
@UploadedFile() file: Express.Multer.File
|
||||
): Promise<UploadResponse> {
|
||||
if (!file) {
|
||||
this.setStatus(400);
|
||||
throw new Error('No file provided');
|
||||
}
|
||||
|
||||
const result = await uploadService.uploadFile(file, 'attachments');
|
||||
|
||||
return {
|
||||
file_name: result.fileName,
|
||||
file_path: result.key,
|
||||
file_size: result.fileSize,
|
||||
mime_type: result.mimeType,
|
||||
download_url: result.url
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Generate TSOA Routes
|
||||
|
||||
// turbo
|
||||
After creating the upload controller, generate routes and API documentation:
|
||||
|
||||
```bash
|
||||
npm run tsoa:gen
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ description: How to setup the backend development environment
|
|||
|
||||
# Setup Development Environment
|
||||
|
||||
Follow these steps to setup the E-Learning Platform backend on your local machine.
|
||||
Follow these steps to setup the E-Learning Platform backend with TypeScript and TSOA on your local machine.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -32,9 +32,77 @@ cd e-learning/Backend
|
|||
npm install
|
||||
```
|
||||
|
||||
// turbo
|
||||
Install TypeScript and TSOA:
|
||||
```bash
|
||||
npm install -D typescript @types/node @types/express ts-node nodemon
|
||||
npm install tsoa swagger-ui-express
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Setup Environment Variables
|
||||
## Step 3: Initialize TypeScript
|
||||
|
||||
// turbo
|
||||
Create `tsconfig.json`:
|
||||
```bash
|
||||
npx tsc --init
|
||||
```
|
||||
|
||||
Update `tsconfig.json`:
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Configure TSOA
|
||||
|
||||
Create `tsoa.json`:
|
||||
```json
|
||||
{
|
||||
"entryFile": "src/app.ts",
|
||||
"noImplicitAdditionalProperties": "throw-on-extras",
|
||||
"controllerPathGlobs": ["src/controllers/**/*.controller.ts"],
|
||||
"spec": {
|
||||
"outputDirectory": "public",
|
||||
"specVersion": 3,
|
||||
"securityDefinitions": {
|
||||
"jwt": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT"
|
||||
}
|
||||
}
|
||||
},
|
||||
"routes": {
|
||||
"routesDir": "src/routes",
|
||||
"middleware": "express",
|
||||
"authenticationModule": "./src/middleware/auth.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Setup Environment Variables
|
||||
|
||||
Copy example env file:
|
||||
```bash
|
||||
|
|
@ -92,7 +160,7 @@ This starts:
|
|||
|
||||
---
|
||||
|
||||
## Step 5: Run Database Migrations
|
||||
## Step 6: Run Database Migrations
|
||||
|
||||
// turbo
|
||||
```bash
|
||||
|
|
@ -101,7 +169,7 @@ npx prisma migrate dev
|
|||
|
||||
---
|
||||
|
||||
## Step 6: Seed Database
|
||||
## Step 7: Seed Database
|
||||
|
||||
// turbo
|
||||
```bash
|
||||
|
|
@ -116,7 +184,20 @@ This creates:
|
|||
|
||||
---
|
||||
|
||||
## Step 7: Start Development Server
|
||||
## Step 8: Generate TSOA Routes
|
||||
|
||||
// turbo
|
||||
```bash
|
||||
npm run tsoa:gen
|
||||
```
|
||||
|
||||
This generates:
|
||||
- Routes in `src/routes/tsoa-routes.ts`
|
||||
- Swagger spec in `public/swagger.json`
|
||||
|
||||
---
|
||||
|
||||
## Step 9: Start Development Server
|
||||
|
||||
// turbo
|
||||
```bash
|
||||
|
|
@ -127,7 +208,7 @@ Server will start at http://localhost:4000
|
|||
|
||||
---
|
||||
|
||||
## Step 8: Verify Setup
|
||||
## Step 10: Verify Setup
|
||||
|
||||
// turbo
|
||||
Test health endpoint:
|
||||
|
|
@ -150,6 +231,7 @@ curl -X POST http://localhost:4000/api/auth/login \
|
|||
| Service | URL | Credentials |
|
||||
|---------|-----|-------------|
|
||||
| **Backend API** | http://localhost:4000 | - |
|
||||
| **API Docs (Swagger)** | http://localhost:4000/api-docs | - |
|
||||
| **MinIO Console** | http://localhost:9001 | admin / 12345678 |
|
||||
| **Mailhog UI** | http://localhost:8025 | - |
|
||||
| **Adminer** | http://localhost:8080 | postgres / 12345678 |
|
||||
|
|
@ -159,9 +241,15 @@ curl -X POST http://localhost:4000/api/auth/login \
|
|||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Start dev server
|
||||
# Start dev server (TypeScript)
|
||||
npm run dev
|
||||
|
||||
# Build TypeScript
|
||||
npm run build
|
||||
|
||||
# Generate TSOA routes and Swagger
|
||||
npm run tsoa:gen
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ description: How to test backend APIs and services
|
|||
|
||||
# Testing Workflow
|
||||
|
||||
Follow these steps to write and run tests for the E-Learning Platform backend.
|
||||
Follow these steps to write and run tests for the E-Learning Platform backend using TypeScript and Jest.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -22,10 +22,12 @@ tests/
|
|||
|
||||
## Step 1: Setup Test Environment
|
||||
|
||||
Create `tests/setup.js`:
|
||||
Create `tests/setup.ts`:
|
||||
|
||||
```typescript
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
```javascript
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
beforeAll(async () => {
|
||||
|
|
@ -44,8 +46,6 @@ afterAll(async () => {
|
|||
|
||||
async function createTestUsers() {
|
||||
// Create admin, instructor, student
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
await prisma.user.createMany({
|
||||
data: [
|
||||
{
|
||||
|
|
@ -75,11 +75,12 @@ async function createTestUsers() {
|
|||
|
||||
## Step 2: Write Unit Tests
|
||||
|
||||
Create `tests/unit/course.service.test.js`:
|
||||
Create `tests/unit/course.service.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { courseService } from '../../src/services/course.service';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
```javascript
|
||||
const courseService = require('../../src/services/course.service');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
describe('Course Service', () => {
|
||||
|
|
@ -93,7 +94,7 @@ describe('Course Service', () => {
|
|||
is_free: false
|
||||
};
|
||||
|
||||
const course = await courseService.createCourse(courseData, 1);
|
||||
const course = await courseService.create(courseData, 1);
|
||||
|
||||
expect(course).toHaveProperty('id');
|
||||
expect(course.status).toBe('DRAFT');
|
||||
|
|
@ -108,7 +109,7 @@ describe('Course Service', () => {
|
|||
};
|
||||
|
||||
await expect(
|
||||
courseService.createCourse(courseData, 1)
|
||||
courseService.create(courseData, 1)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -134,14 +135,16 @@ describe('Course Service', () => {
|
|||
|
||||
## Step 3: Write Integration Tests
|
||||
|
||||
Create `tests/integration/courses.test.js`:
|
||||
Create `tests/integration/courses.test.ts`:
|
||||
|
||||
```javascript
|
||||
const request = require('supertest');
|
||||
const app = require('../../src/app');
|
||||
```typescript
|
||||
import request from 'supertest';
|
||||
import app from '../../src/app';
|
||||
|
||||
describe('Course API', () => {
|
||||
let adminToken, instructorToken, studentToken;
|
||||
let adminToken: string;
|
||||
let instructorToken: string;
|
||||
let studentToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Login as different users
|
||||
|
|
@ -217,7 +220,7 @@ describe('Course API', () => {
|
|||
.query({ category: 1 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
response.body.data.forEach(course => {
|
||||
response.body.data.forEach((course: any) => {
|
||||
expect(course.category_id).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -229,15 +232,15 @@ describe('Course API', () => {
|
|||
|
||||
## Step 4: Test File Uploads
|
||||
|
||||
Create `tests/integration/file-upload.test.js`:
|
||||
Create `tests/integration/file-upload.test.ts`:
|
||||
|
||||
```javascript
|
||||
const request = require('supertest');
|
||||
const app = require('../../src/app');
|
||||
const path = require('path');
|
||||
```typescript
|
||||
import request from 'supertest';
|
||||
import app from '../../src/app';
|
||||
import path from 'path';
|
||||
|
||||
describe('File Upload API', () => {
|
||||
let instructorToken;
|
||||
let instructorToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await request(app)
|
||||
|
|
@ -330,10 +333,12 @@ Update `package.json`:
|
|||
"test:coverage": "jest --coverage"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "ts-jest",
|
||||
"testEnvironment": "node",
|
||||
"coveragePathIgnorePatterns": ["/node_modules/"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/tests/setup.js"],
|
||||
"testMatch": ["**/tests/**/*.test.js"]
|
||||
"setupFilesAfterEnv": ["<rootDir>/tests/setup.ts"],
|
||||
"testMatch": ["**/tests/**/*.test.ts"],
|
||||
"moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,44 +1,42 @@
|
|||
# Environment
|
||||
# Application
|
||||
NODE_ENV=development
|
||||
PORT=4000
|
||||
APP_URL=http://localhost:4000
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://elearning_user:elearning_pass@localhost:5432/elearning_db
|
||||
DATABASE_URL=postgresql://postgres:12345678@localhost:5432/elearning_dev
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_PASSWORD=dev_redis_password
|
||||
|
||||
# MinIO/S3
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_ACCESS_KEY=admin
|
||||
S3_SECRET_KEY=12345678
|
||||
S3_BUCKET=e-learning
|
||||
S3_USE_SSL=false
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||
JWT_EXPIRES_IN=24h
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
# MinIO/S3
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_ACCESS_KEY=minioadmin
|
||||
S3_SECRET_KEY=minioadmin
|
||||
S3_BUCKET_NAME=elearning
|
||||
S3_USE_SSL=false
|
||||
|
||||
# CORS
|
||||
CORS_ORIGIN=http://localhost:3000,http://localhost:5173
|
||||
|
||||
# File Upload Limits (in bytes)
|
||||
MAX_VIDEO_SIZE=524288000
|
||||
MAX_ATTACHMENT_SIZE=104857600
|
||||
MAX_ATTACHMENTS_PER_LESSON=10
|
||||
|
||||
# Email (Mailhog for development)
|
||||
# Email (Mailhog in development)
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=1025
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=noreply@elearning.local
|
||||
|
||||
# File Upload Limits (in bytes)
|
||||
MAX_VIDEO_SIZE=524288000
|
||||
MAX_ATTACHMENT_SIZE=104857600
|
||||
MAX_ATTACHMENTS_PER_LESSON=10
|
||||
|
||||
# CORS
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=debug
|
||||
|
|
|
|||
31
Backend/.gitignore
vendored
31
Backend/.gitignore
vendored
|
|
@ -1,21 +1,14 @@
|
|||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
|
@ -23,13 +16,21 @@ Thumbs.db
|
|||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Uploads (local development)
|
||||
# Prisma
|
||||
prisma/migrations/**/*.sql
|
||||
|
||||
# TSOA
|
||||
public/swagger.json
|
||||
src/routes/routes.ts
|
||||
|
||||
# Uploads (if storing locally)
|
||||
uploads/
|
||||
temp/
|
||||
|
||||
# PM2
|
||||
.pm2/
|
||||
|
|
|
|||
|
|
@ -1,191 +1,200 @@
|
|||
# E-Learning Backend
|
||||
# E-Learning Platform Backend
|
||||
|
||||
Backend API for the E-Learning Platform built with Node.js, Express, Prisma, and PostgreSQL.
|
||||
Backend API for E-Learning Platform built with TypeScript, Express, TSOA, and Prisma.
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
- **Authentication & Authorization**: JWT-based authentication with role-based access control
|
||||
- **Course Management**: Create, manage, and publish courses with chapters and lessons
|
||||
- **Multi-Language Support**: Thai and English content support
|
||||
- **Quiz System**: Interactive quizzes with multiple attempts and score policies
|
||||
- **Progress Tracking**: Track student progress and issue certificates
|
||||
- **File Upload**: Support for video lessons and attachments (MinIO/S3)
|
||||
- **Caching**: Redis integration for improved performance
|
||||
- **Security**: Helmet, CORS, rate limiting, and input validation
|
||||
- **TypeScript** - Type-safe development
|
||||
- **TSOA** - Automatic OpenAPI/Swagger documentation
|
||||
- **Prisma** - Type-safe database ORM
|
||||
- **JWT Authentication** - Secure user authentication
|
||||
- **Role-based Authorization** - Admin, Instructor, Student roles
|
||||
- **Multi-language Support** - Thai and English
|
||||
- **File Upload** - Video and attachment support with MinIO/S3
|
||||
- **Redis Caching** - Performance optimization
|
||||
- **Rate Limiting** - API protection
|
||||
- **Comprehensive Error Handling** - Structured error responses
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
- Node.js >= 18.0.0
|
||||
- PostgreSQL >= 14
|
||||
- Redis (optional, for caching)
|
||||
- MinIO or S3 (for file storage)
|
||||
- Node.js >= 18
|
||||
- Docker & Docker Compose
|
||||
- PostgreSQL (via Docker)
|
||||
- Redis (via Docker)
|
||||
- MinIO (via Docker)
|
||||
|
||||
## 🛠️ Installation
|
||||
## 🛠️ Setup
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd e-learning/Backend
|
||||
```
|
||||
### 1. Install Dependencies
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Setup environment variables**
|
||||
### 2. Environment Configuration
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration
|
||||
```
|
||||
|
||||
4. **Start Docker services** (PostgreSQL, Redis, MinIO)
|
||||
### 3. Start Docker Services
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
5. **Run database migrations**
|
||||
### 4. Database Setup
|
||||
|
||||
```bash
|
||||
# Generate Prisma client
|
||||
npx prisma generate
|
||||
|
||||
# Run migrations
|
||||
npx prisma migrate dev
|
||||
|
||||
# Seed database
|
||||
npx prisma db seed
|
||||
```
|
||||
|
||||
6. **Seed the database**
|
||||
### 5. Generate TSOA Routes & Swagger
|
||||
|
||||
```bash
|
||||
npm run prisma:seed
|
||||
npm run tsoa:gen
|
||||
```
|
||||
|
||||
7. **Start development server**
|
||||
### 6. Start Development Server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The server will start on `http://localhost:4000`
|
||||
The server will start at `http://localhost:4000`
|
||||
|
||||
## 📁 Project Structure
|
||||
## 📚 API Documentation
|
||||
|
||||
Swagger documentation is available at: `http://localhost:4000/api-docs`
|
||||
|
||||
## 🏗️ Project Structure
|
||||
|
||||
```
|
||||
Backend/
|
||||
├── prisma/
|
||||
│ ├── migrations/ # Database migrations
|
||||
│ ├── schema.prisma # Database schema
|
||||
│ └── seed.js # Database seeding
|
||||
│ ├── migrations/ # Database migrations
|
||||
│ ├── schema.prisma # Database schema
|
||||
│ └── seed.js # Database seeder
|
||||
├── src/
|
||||
│ ├── config/ # Configuration files
|
||||
│ │ ├── database.js # Prisma client
|
||||
│ │ ├── logger.js # Winston logger
|
||||
│ │ └── redis.js # Redis client
|
||||
│ ├── controllers/ # Request handlers
|
||||
│ ├── middleware/ # Express middleware
|
||||
│ │ ├── auth.js # Authentication & authorization
|
||||
│ │ └── errorHandler.js
|
||||
│ ├── routes/ # Route definitions
|
||||
│ ├── services/ # Business logic
|
||||
│ ├── utils/ # Utility functions
|
||||
│ │ └── jwt.js # JWT utilities
|
||||
│ ├── validators/ # Input validation
|
||||
│ ├── app.js # Express app setup
|
||||
│ └── server.js # Server entry point
|
||||
├── tests/ # Test files
|
||||
├── logs/ # Log files
|
||||
├── .env.example # Environment variables template
|
||||
├── package.json
|
||||
└── README.md
|
||||
│ ├── config/ # Configuration files
|
||||
│ │ ├── index.ts # Main config
|
||||
│ │ ├── logger.ts # Winston logger
|
||||
│ │ └── database.ts # Prisma client
|
||||
│ ├── controllers/ # TSOA controllers
|
||||
│ │ └── HealthController.ts
|
||||
│ ├── middleware/ # Express middleware
|
||||
│ │ ├── authentication.ts
|
||||
│ │ └── errorHandler.ts
|
||||
│ ├── services/ # Business logic
|
||||
│ ├── types/ # TypeScript types
|
||||
│ │ └── index.ts
|
||||
│ ├── utils/ # Utility functions
|
||||
│ ├── validators/ # Input validation
|
||||
│ ├── app.ts # Express app setup
|
||||
│ └── server.ts # Server entry point
|
||||
├── public/ # Generated Swagger docs
|
||||
├── .env.example # Environment template
|
||||
├── tsconfig.json # TypeScript config
|
||||
├── tsoa.json # TSOA config
|
||||
├── nodemon.json # Nodemon config
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 🔑 Environment Variables
|
||||
## 🔧 Available Scripts
|
||||
|
||||
See `.env.example` for all available environment variables.
|
||||
```bash
|
||||
npm run dev # Start dev server with hot reload
|
||||
npm run build # Build TypeScript + generate TSOA routes
|
||||
npm start # Start production server
|
||||
npm run tsoa:gen # Generate TSOA routes & Swagger
|
||||
npm test # Run tests
|
||||
npm run lint # Run ESLint
|
||||
npm run format # Format code with Prettier
|
||||
```
|
||||
|
||||
Key variables:
|
||||
- `DATABASE_URL`: PostgreSQL connection string
|
||||
- `JWT_SECRET`: Secret key for JWT tokens
|
||||
- `REDIS_URL`: Redis connection string
|
||||
- `S3_ENDPOINT`: MinIO/S3 endpoint
|
||||
- `CORS_ORIGIN`: Allowed CORS origins
|
||||
## 🗄️ Database Commands
|
||||
|
||||
```bash
|
||||
npx prisma studio # Open Prisma Studio (GUI)
|
||||
npx prisma migrate dev # Create and apply migration
|
||||
npx prisma db seed # Seed database
|
||||
npx prisma generate # Generate Prisma client
|
||||
```
|
||||
|
||||
## 🐳 Docker Commands
|
||||
|
||||
```bash
|
||||
docker compose up -d # Start all services
|
||||
docker compose down # Stop all services
|
||||
docker compose logs -f # View logs
|
||||
docker compose ps # List running services
|
||||
```
|
||||
|
||||
## 🔐 Default Credentials
|
||||
|
||||
After seeding, you can login with:
|
||||
|
||||
- **Admin**: `admin` / `admin123`
|
||||
- **Instructor**: `instructor` / `instructor123`
|
||||
- **Student**: `student` / `student123`
|
||||
|
||||
## 📝 Development Workflow
|
||||
|
||||
1. Create a new controller in `src/controllers/`
|
||||
2. Add TSOA decorators (`@Route`, `@Get`, `@Post`, etc.)
|
||||
3. Run `npm run tsoa:gen` to generate routes
|
||||
4. Implement business logic in `src/services/`
|
||||
5. Test your endpoints
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run tests in watch mode
|
||||
npm run test:watch
|
||||
|
||||
# Run tests with coverage
|
||||
npm test -- --coverage
|
||||
npm test # Run all tests
|
||||
npm run test:watch # Run tests in watch mode
|
||||
npm run test:coverage # Generate coverage report
|
||||
```
|
||||
|
||||
## 📝 API Documentation
|
||||
## 🚀 Deployment
|
||||
|
||||
### Authentication
|
||||
See [Deployment Workflow](./.agent/workflows/deployment.md) for production deployment instructions.
|
||||
|
||||
- `POST /api/auth/register` - Register new user
|
||||
- `POST /api/auth/login` - Login user
|
||||
- `GET /api/auth/me` - Get current user profile
|
||||
- `POST /api/auth/logout` - Logout user
|
||||
## 📖 Documentation
|
||||
|
||||
### Health Check
|
||||
- [Backend Development Rules](./.agent/rules/rules.md)
|
||||
- [Agent Skills Backend](./agent_skills_backend.md)
|
||||
- [API Workflows](./.agent/workflows/)
|
||||
|
||||
- `GET /health` - Server health status
|
||||
|
||||
## 🔐 Default Credentials
|
||||
|
||||
After seeding the database, you can use these credentials:
|
||||
|
||||
- **Admin**: `admin` / `admin123`
|
||||
- **Instructor**: `instructor` / `admin123`
|
||||
- **Student**: `student` / `admin123`
|
||||
|
||||
## 🛠️ Development
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Port already in use
|
||||
```bash
|
||||
# Start dev server with auto-reload
|
||||
npm run dev
|
||||
|
||||
# Run linter
|
||||
npm run lint
|
||||
|
||||
# Format code
|
||||
npm run format
|
||||
|
||||
# Open Prisma Studio (database GUI)
|
||||
npm run prisma:studio
|
||||
lsof -i :4000
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
## 📦 Database Commands
|
||||
|
||||
### Database connection error
|
||||
```bash
|
||||
# Generate Prisma Client
|
||||
npm run prisma:generate
|
||||
|
||||
# Create migration
|
||||
npx prisma migrate dev --name migration_name
|
||||
|
||||
# Apply migrations
|
||||
npx prisma migrate deploy
|
||||
|
||||
# Reset database (development only)
|
||||
npx prisma migrate reset
|
||||
|
||||
# Seed database
|
||||
npm run prisma:seed
|
||||
docker compose logs postgres
|
||||
npx prisma db pull
|
||||
```
|
||||
|
||||
## 🚀 Production Deployment
|
||||
|
||||
1. Set `NODE_ENV=production`
|
||||
2. Set strong `JWT_SECRET`
|
||||
3. Configure production database
|
||||
4. Run migrations: `npx prisma migrate deploy`
|
||||
5. Start with PM2: `pm2 start src/server.js`
|
||||
|
||||
## 📚 Resources
|
||||
|
||||
- [Prisma Documentation](https://www.prisma.io/docs)
|
||||
- [Express Documentation](https://expressjs.com/)
|
||||
- [JWT Documentation](https://jwt.io/)
|
||||
### Prisma client error
|
||||
```bash
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT
|
||||
ISC
|
||||
|
||||
## 👥 Team
|
||||
|
||||
E-Learning Development Team
|
||||
|
|
|
|||
14
Backend/nodemon.json
Normal file
14
Backend/nodemon.json
Normal 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
16809
Backend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,56 +1,72 @@
|
|||
{
|
||||
"name": "e-learning-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "E-Learning Platform Backend API",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon src/server.js",
|
||||
"start": "node src/server.js",
|
||||
"test": "jest --coverage",
|
||||
"test:watch": "jest --watch",
|
||||
"lint": "eslint src/**/*.js",
|
||||
"format": "prettier --write \"src/**/*.js\"",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:studio": "prisma studio",
|
||||
"prisma:seed": "node prisma/seed.js"
|
||||
},
|
||||
"keywords": [
|
||||
"e-learning",
|
||||
"api",
|
||||
"express",
|
||||
"prisma",
|
||||
"postgresql"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.22.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.1",
|
||||
"express-rate-limit": "^7.4.1",
|
||||
"helmet": "^8.0.0",
|
||||
"joi": "^17.13.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"minio": "^8.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"redis": "^4.7.0",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^9.17.0",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.1.9",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^5.22.0",
|
||||
"supertest": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"npm": ">=9.0.0"
|
||||
}
|
||||
"name": "e-learning-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "E-Learning Platform Backend API with TypeScript and TSOA",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon",
|
||||
"build": "tsoa spec-and-routes && tsc",
|
||||
"start": "node dist/server.js",
|
||||
"tsoa:gen": "tsoa spec-and-routes",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "node prisma/seed.js",
|
||||
"prisma:studio": "prisma studio",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"format": "prettier --write \"src/**/*.ts\""
|
||||
},
|
||||
"keywords": [
|
||||
"e-learning",
|
||||
"typescript",
|
||||
"tsoa",
|
||||
"express",
|
||||
"prisma"
|
||||
],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.22.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"helmet": "^8.0.0",
|
||||
"joi": "^17.13.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"minio": "^8.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"redis": "^4.7.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tsoa": "^6.4.0",
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/swagger-ui-express": "^4.1.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.19.1",
|
||||
"@typescript-eslint/parser": "^8.19.1",
|
||||
"eslint": "^9.18.0",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.1.9",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^5.22.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "node prisma/seed.js"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -17,113 +17,149 @@ datasource db {
|
|||
model Role {
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique @db.VarChar(50)
|
||||
name Json // { th: "", en: "" }
|
||||
name Json // { th: "", en: "" }
|
||||
description Json?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
users User[]
|
||||
|
||||
@@index([code])
|
||||
@@map("roles")
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique @db.VarChar(50)
|
||||
email String @unique @db.VarChar(255)
|
||||
password String @db.VarChar(255)
|
||||
role_id Int
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique @db.VarChar(50)
|
||||
email String @unique @db.VarChar(255)
|
||||
password String @db.VarChar(255)
|
||||
role_id Int
|
||||
email_verified_at DateTime?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime? @updatedAt
|
||||
|
||||
role Role @relation(fields: [role_id], references: [id])
|
||||
profile Profile?
|
||||
role Role @relation(fields: [role_id], references: [id], onDelete: Restrict)
|
||||
profile UserProfile?
|
||||
|
||||
// Relations
|
||||
created_courses Course[] @relation("CourseCreator")
|
||||
instructor_courses CourseInstructor[]
|
||||
enrollments Enrollment[]
|
||||
lesson_progress LessonProgress[]
|
||||
quiz_attempts QuizAttempt[]
|
||||
certificates Certificate[]
|
||||
created_courses Course[] @relation("CourseCreator")
|
||||
approved_courses Course[] @relation("CourseApprover")
|
||||
instructor_courses CourseInstructor[]
|
||||
enrollments Enrollment[]
|
||||
lesson_progress LessonProgress[]
|
||||
quiz_attempts QuizAttempt[]
|
||||
certificates Certificate[]
|
||||
created_categories Category[] @relation("CategoryCreator")
|
||||
updated_categories Category[] @relation("CategoryUpdater")
|
||||
updated_profiles UserProfile[] @relation("ProfileUpdater")
|
||||
created_quizzes Quiz[] @relation("QuizCreator")
|
||||
updated_quizzes Quiz[] @relation("QuizUpdater")
|
||||
created_announcements Announcement[] @relation("AnnouncementCreator")
|
||||
updated_announcements Announcement[] @relation("AnnouncementUpdater")
|
||||
updated_courses Course[] @relation("CourseUpdater")
|
||||
orders Order[]
|
||||
instructor_balance InstructorBalance?
|
||||
withdrawal_requests WithdrawalRequest[] @relation("WithdrawalInstructor")
|
||||
approved_withdrawals WithdrawalRequest[] @relation("WithdrawalApprover")
|
||||
updated_withdrawals WithdrawalRequest[] @relation("WithdrawalUpdater")
|
||||
submitted_approvals CourseApproval[] @relation("ApprovalSubmitter")
|
||||
reviewed_approvals CourseApproval[] @relation("ApprovalReviewer")
|
||||
|
||||
@@index([username])
|
||||
@@index([email])
|
||||
@@index([role_id])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Profile {
|
||||
model UserProfile {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int @unique
|
||||
prefix Json? // { th: "นาย", en: "Mr." }
|
||||
first_name String @db.VarChar(100)
|
||||
last_name String @db.VarChar(100)
|
||||
phone String? @db.VarChar(20)
|
||||
avatar_url String? @db.VarChar(500)
|
||||
birth_date DateTime?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime? @updatedAt
|
||||
updated_by Int?
|
||||
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
updater User? @relation("ProfileUpdater", fields: [updated_by], references: [id])
|
||||
|
||||
@@index([user_id])
|
||||
@@map("user_profiles")
|
||||
}
|
||||
|
||||
model Category {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int @unique
|
||||
first_name String? @db.VarChar(100)
|
||||
last_name String? @db.VarChar(100)
|
||||
phone String? @db.VarChar(20)
|
||||
avatar_url String? @db.VarChar(500)
|
||||
bio Json? // { th: "", en: "" }
|
||||
birth_date DateTime?
|
||||
name Json // { th: "", en: "" }
|
||||
slug String @unique @db.VarChar(100)
|
||||
description Json?
|
||||
icon String? @db.VarChar(100)
|
||||
sort_order Int @default(0)
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
created_by Int
|
||||
updated_at DateTime? @updatedAt
|
||||
updated_by Int?
|
||||
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
courses Course[]
|
||||
creator User @relation("CategoryCreator", fields: [created_by], references: [id], onDelete: Restrict)
|
||||
updater User? @relation("CategoryUpdater", fields: [updated_by], references: [id])
|
||||
|
||||
@@map("profiles")
|
||||
@@index([slug])
|
||||
@@index([is_active])
|
||||
@@index([sort_order])
|
||||
@@map("categories")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Course Management
|
||||
// Course Structure
|
||||
// ============================================
|
||||
|
||||
enum CourseStatus {
|
||||
DRAFT
|
||||
PENDING_APPROVAL
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
model Category {
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique @db.VarChar(50)
|
||||
name Json // { th: "", en: "" }
|
||||
description Json?
|
||||
icon_url String? @db.VarChar(500)
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
courses Course[]
|
||||
|
||||
@@map("categories")
|
||||
}
|
||||
|
||||
model Course {
|
||||
id Int @id @default(autoincrement())
|
||||
title Json // { th: "", en: "" }
|
||||
description Json
|
||||
thumbnail_url String? @db.VarChar(500)
|
||||
price Decimal @default(0) @db.Decimal(10, 2)
|
||||
is_free Boolean @default(false)
|
||||
have_certificate Boolean @default(false)
|
||||
status CourseStatus @default(DRAFT)
|
||||
category_id Int
|
||||
created_by Int
|
||||
rejection_reason String? @db.Text
|
||||
is_deleted Boolean @default(false)
|
||||
deleted_at DateTime?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
category_id Int?
|
||||
title Json // { th: "", en: "" }
|
||||
slug String @unique @db.VarChar(200)
|
||||
description Json
|
||||
thumbnail_url String? @db.VarChar(500)
|
||||
price Decimal @default(0) @db.Decimal(10, 2)
|
||||
is_free Boolean @default(false)
|
||||
have_certificate Boolean @default(false)
|
||||
status CourseStatus @default(DRAFT)
|
||||
approved_by Int?
|
||||
approved_at DateTime?
|
||||
rejection_reason String? @db.Text
|
||||
created_at DateTime @default(now())
|
||||
created_by Int
|
||||
updated_at DateTime? @updatedAt
|
||||
updated_by Int?
|
||||
|
||||
category Category @relation(fields: [category_id], references: [id])
|
||||
creator User @relation("CourseCreator", fields: [created_by], references: [id])
|
||||
chapters Chapter[]
|
||||
instructors CourseInstructor[]
|
||||
enrollments Enrollment[]
|
||||
category Category? @relation(fields: [category_id], references: [id], onDelete: SetNull)
|
||||
creator User @relation("CourseCreator", fields: [created_by], references: [id], onDelete: Restrict)
|
||||
approver User? @relation("CourseApprover", fields: [approved_by], references: [id])
|
||||
updater User? @relation("CourseUpdater", fields: [updated_by], references: [id])
|
||||
chapters Chapter[]
|
||||
instructors CourseInstructor[]
|
||||
enrollments Enrollment[]
|
||||
announcements Announcement[]
|
||||
courseApprovals CourseApproval[]
|
||||
|
||||
@@index([category_id])
|
||||
@@index([slug])
|
||||
@@index([status])
|
||||
@@index([created_by])
|
||||
@@index([created_at])
|
||||
@@index([status, is_free])
|
||||
@@index([category_id, status])
|
||||
@@map("courses")
|
||||
}
|
||||
|
||||
|
|
@ -132,7 +168,7 @@ model CourseInstructor {
|
|||
course_id Int
|
||||
user_id Int
|
||||
is_primary Boolean @default(false)
|
||||
created_at DateTime @default(now())
|
||||
joined_at DateTime @default(now())
|
||||
|
||||
course Course @relation(fields: [course_id], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
|
|
@ -140,23 +176,55 @@ model CourseInstructor {
|
|||
@@unique([course_id, user_id])
|
||||
@@index([course_id])
|
||||
@@index([user_id])
|
||||
@@index([is_primary])
|
||||
@@map("course_instructors")
|
||||
}
|
||||
|
||||
enum ApprovalAction {
|
||||
SUBMITTED
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
model CourseApproval {
|
||||
id Int @id @default(autoincrement())
|
||||
course_id Int
|
||||
submitted_by Int
|
||||
reviewed_by Int?
|
||||
action ApprovalAction
|
||||
previous_status CourseStatus
|
||||
new_status CourseStatus
|
||||
comment String? @db.Text
|
||||
created_at DateTime @default(now())
|
||||
|
||||
course Course @relation(fields: [course_id], references: [id], onDelete: Cascade)
|
||||
submitter User @relation("ApprovalSubmitter", fields: [submitted_by], references: [id], onDelete: Restrict)
|
||||
reviewer User? @relation("ApprovalReviewer", fields: [reviewed_by], references: [id])
|
||||
|
||||
@@index([course_id])
|
||||
@@index([submitted_by])
|
||||
@@index([reviewed_by])
|
||||
@@index([action])
|
||||
@@index([created_at])
|
||||
@@index([course_id, created_at])
|
||||
@@map("course_approvals")
|
||||
}
|
||||
|
||||
model Chapter {
|
||||
id Int @id @default(autoincrement())
|
||||
course_id Int
|
||||
title Json // { th: "", en: "" }
|
||||
description Json?
|
||||
order Int
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
course_id Int
|
||||
title Json // { th: "", en: "" }
|
||||
description Json?
|
||||
sort_order Int @default(0)
|
||||
is_published Boolean @default(false)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime? @updatedAt
|
||||
|
||||
course Course @relation(fields: [course_id], references: [id], onDelete: Cascade)
|
||||
lessons Lesson[]
|
||||
|
||||
@@index([course_id])
|
||||
@@index([order])
|
||||
@@index([course_id, sort_order])
|
||||
@@map("chapters")
|
||||
}
|
||||
|
||||
|
|
@ -166,169 +234,184 @@ model Chapter {
|
|||
|
||||
enum LessonType {
|
||||
VIDEO
|
||||
TEXT
|
||||
PDF
|
||||
QUIZ
|
||||
}
|
||||
|
||||
model Lesson {
|
||||
id Int @id @default(autoincrement())
|
||||
chapter_id Int
|
||||
title Json // { th: "", en: "" }
|
||||
description Json?
|
||||
type LessonType
|
||||
content Json? // For TEXT type
|
||||
video_url String? @db.VarChar(500)
|
||||
video_duration Int? // in seconds
|
||||
order Int
|
||||
is_preview Boolean @default(false)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
chapter_id Int
|
||||
title Json // { th: "", en: "" }
|
||||
content Json? // multi-language lesson content
|
||||
type LessonType
|
||||
duration_minutes Int?
|
||||
sort_order Int @default(0)
|
||||
is_sequential Boolean @default(true)
|
||||
prerequisite_lesson_ids Json? // array of lesson IDs
|
||||
require_pass_quiz Boolean @default(false)
|
||||
is_published Boolean @default(false)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime? @updatedAt
|
||||
|
||||
chapter Chapter @relation(fields: [chapter_id], references: [id], onDelete: Cascade)
|
||||
attachments Attachment[]
|
||||
prerequisites LessonPrerequisite[] @relation("LessonPrerequisites")
|
||||
required_for LessonPrerequisite[] @relation("RequiredForLessons")
|
||||
quiz Quiz?
|
||||
progress LessonProgress[]
|
||||
chapter Chapter @relation(fields: [chapter_id], references: [id], onDelete: Cascade)
|
||||
attachments LessonAttachment[]
|
||||
quiz Quiz?
|
||||
progress LessonProgress[]
|
||||
|
||||
@@index([chapter_id])
|
||||
@@index([order])
|
||||
@@index([chapter_id, sort_order])
|
||||
@@index([type])
|
||||
@@map("lessons")
|
||||
}
|
||||
|
||||
model LessonPrerequisite {
|
||||
id Int @id @default(autoincrement())
|
||||
lesson_id Int
|
||||
prerequisite_lesson_id Int
|
||||
created_at DateTime @default(now())
|
||||
|
||||
lesson Lesson @relation("LessonPrerequisites", fields: [lesson_id], references: [id], onDelete: Cascade)
|
||||
prerequisite_lesson Lesson @relation("RequiredForLessons", fields: [prerequisite_lesson_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([lesson_id, prerequisite_lesson_id])
|
||||
@@index([lesson_id])
|
||||
@@index([prerequisite_lesson_id])
|
||||
@@map("lesson_prerequisites")
|
||||
}
|
||||
|
||||
model Attachment {
|
||||
id Int @id @default(autoincrement())
|
||||
lesson_id Int
|
||||
filename String @db.VarChar(255)
|
||||
original_name String @db.VarChar(255)
|
||||
file_url String @db.VarChar(500)
|
||||
file_size BigInt
|
||||
mime_type String @db.VarChar(100)
|
||||
created_at DateTime @default(now())
|
||||
model LessonAttachment {
|
||||
id Int @id @default(autoincrement())
|
||||
lesson_id Int
|
||||
file_name String @db.VarChar(255)
|
||||
file_path String @db.VarChar(500)
|
||||
file_size Int
|
||||
mime_type String @db.VarChar(100)
|
||||
description Json?
|
||||
sort_order Int @default(0)
|
||||
created_at DateTime @default(now())
|
||||
|
||||
lesson Lesson @relation(fields: [lesson_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([lesson_id])
|
||||
@@map("attachments")
|
||||
@@index([lesson_id, sort_order])
|
||||
@@map("lesson_attachments")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Quiz System
|
||||
// ============================================
|
||||
|
||||
enum ScorePolicy {
|
||||
HIGHEST
|
||||
LATEST
|
||||
AVERAGE
|
||||
enum QuestionType {
|
||||
MULTIPLE_CHOICE
|
||||
TRUE_FALSE
|
||||
SHORT_ANSWER
|
||||
}
|
||||
|
||||
model Quiz {
|
||||
id Int @id @default(autoincrement())
|
||||
lesson_id Int @unique
|
||||
title Json // { th: "", en: "" }
|
||||
description Json?
|
||||
passing_score Int @default(70)
|
||||
time_limit Int? // in minutes
|
||||
max_attempts Int @default(3)
|
||||
cooldown_hours Int @default(24)
|
||||
score_policy ScorePolicy @default(HIGHEST)
|
||||
shuffle_questions Boolean @default(true)
|
||||
shuffle_choices Boolean @default(true)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
lesson_id Int @unique
|
||||
title Json // { th: "", en: "" }
|
||||
description Json?
|
||||
passing_score Int @default(60)
|
||||
time_limit Int? // in minutes
|
||||
shuffle_questions Boolean @default(false)
|
||||
shuffle_choices Boolean @default(false)
|
||||
show_answers_after_completion Boolean @default(true)
|
||||
created_at DateTime @default(now())
|
||||
created_by Int
|
||||
updated_at DateTime? @updatedAt
|
||||
updated_by Int?
|
||||
|
||||
lesson Lesson @relation(fields: [lesson_id], references: [id], onDelete: Cascade)
|
||||
questions QuizQuestion[]
|
||||
lesson Lesson @relation(fields: [lesson_id], references: [id], onDelete: Cascade)
|
||||
creator User @relation("QuizCreator", fields: [created_by], references: [id], onDelete: Restrict)
|
||||
updater User? @relation("QuizUpdater", fields: [updated_by], references: [id])
|
||||
questions Question[]
|
||||
attempts QuizAttempt[]
|
||||
|
||||
@@index([lesson_id])
|
||||
@@map("quizzes")
|
||||
}
|
||||
|
||||
model QuizQuestion {
|
||||
id Int @id @default(autoincrement())
|
||||
quiz_id Int
|
||||
question Json // { th: "", en: "" }
|
||||
choices Json // [{ th: "", en: "", is_correct: boolean }]
|
||||
points Int @default(1)
|
||||
order Int
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
quiz Quiz @relation(fields: [quiz_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([quiz_id])
|
||||
@@index([order])
|
||||
@@map("quiz_questions")
|
||||
}
|
||||
|
||||
model QuizAttempt {
|
||||
id Int @id @default(autoincrement())
|
||||
model Question {
|
||||
id Int @id @default(autoincrement())
|
||||
quiz_id Int
|
||||
user_id Int
|
||||
answers Json // [{ question_id: int, choice_index: int }]
|
||||
score Int
|
||||
passed Boolean
|
||||
time_spent Int? // in seconds
|
||||
started_at DateTime
|
||||
completed_at DateTime @default(now())
|
||||
question Json // { th: "", en: "" }
|
||||
explanation Json? // answer explanation
|
||||
question_type QuestionType @default(MULTIPLE_CHOICE)
|
||||
score Int @default(1)
|
||||
sort_order Int @default(0)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime? @updatedAt
|
||||
|
||||
quiz Quiz @relation(fields: [quiz_id], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
quiz Quiz @relation(fields: [quiz_id], references: [id], onDelete: Cascade)
|
||||
choices Choice[]
|
||||
|
||||
@@index([quiz_id])
|
||||
@@index([user_id])
|
||||
@@index([completed_at])
|
||||
@@map("quiz_attempts")
|
||||
@@index([quiz_id, sort_order])
|
||||
@@map("questions")
|
||||
}
|
||||
|
||||
model Choice {
|
||||
id Int @id @default(autoincrement())
|
||||
question_id Int
|
||||
text Json // { th: "", en: "" }
|
||||
is_correct Boolean @default(false)
|
||||
sort_order Int @default(0)
|
||||
|
||||
question Question @relation(fields: [question_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([question_id])
|
||||
@@map("choices")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Progress Tracking
|
||||
// Student Progress
|
||||
// ============================================
|
||||
|
||||
enum EnrollmentStatus {
|
||||
ENROLLED
|
||||
IN_PROGRESS
|
||||
COMPLETED
|
||||
DROPPED
|
||||
}
|
||||
|
||||
model Enrollment {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int
|
||||
course_id Int
|
||||
enrolled_at DateTime @default(now())
|
||||
completed_at DateTime?
|
||||
progress_percent Int @default(0)
|
||||
last_accessed_at DateTime @default(now())
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int
|
||||
course_id Int
|
||||
status EnrollmentStatus @default(ENROLLED)
|
||||
progress_percentage Int @default(0)
|
||||
enrolled_at DateTime @default(now())
|
||||
started_at DateTime?
|
||||
completed_at DateTime?
|
||||
last_accessed_at DateTime?
|
||||
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
course Course @relation(fields: [course_id], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
course Course @relation(fields: [course_id], references: [id], onDelete: Cascade)
|
||||
certificates Certificate[]
|
||||
|
||||
@@unique([user_id, course_id])
|
||||
@@unique([user_id, course_id], name: "unique_enrollment")
|
||||
@@index([user_id])
|
||||
@@index([course_id])
|
||||
@@index([status])
|
||||
@@index([last_accessed_at])
|
||||
@@map("enrollments")
|
||||
}
|
||||
|
||||
model Certificate {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int
|
||||
course_id Int
|
||||
enrollment_id Int @unique
|
||||
file_path String @db.VarChar(500)
|
||||
issued_at DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
enrollment Enrollment @relation(fields: [enrollment_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([user_id])
|
||||
@@index([course_id])
|
||||
@@index([enrollment_id])
|
||||
@@index([user_id, course_id])
|
||||
@@map("certificates")
|
||||
}
|
||||
|
||||
model LessonProgress {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int
|
||||
lesson_id Int
|
||||
is_completed Boolean @default(false)
|
||||
video_progress Int @default(0) // seconds watched
|
||||
completed_at DateTime?
|
||||
last_watched_at DateTime @default(now())
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int
|
||||
lesson_id Int
|
||||
is_completed Boolean @default(false)
|
||||
completed_at DateTime?
|
||||
video_progress_seconds Int @default(0)
|
||||
video_duration_seconds Int?
|
||||
video_progress_percentage Decimal? @db.Decimal(5, 2)
|
||||
last_watched_at DateTime?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime? @updatedAt
|
||||
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
lesson Lesson @relation(fields: [lesson_id], references: [id], onDelete: Cascade)
|
||||
|
|
@ -336,19 +419,190 @@ model LessonProgress {
|
|||
@@unique([user_id, lesson_id])
|
||||
@@index([user_id])
|
||||
@@index([lesson_id])
|
||||
@@index([last_watched_at])
|
||||
@@map("lesson_progress")
|
||||
}
|
||||
|
||||
model Certificate {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int
|
||||
course_id Int
|
||||
certificate_url String @db.VarChar(500)
|
||||
issued_at DateTime @default(now())
|
||||
model QuizAttempt {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int
|
||||
quiz_id Int
|
||||
score Int @default(0)
|
||||
total_questions Int
|
||||
correct_answers Int @default(0)
|
||||
is_passed Boolean @default(false)
|
||||
attempt_number Int @default(1)
|
||||
answers Json? // student answers for review
|
||||
started_at DateTime @default(now())
|
||||
completed_at DateTime?
|
||||
|
||||
quiz Quiz @relation(fields: [quiz_id], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([user_id, course_id])
|
||||
@@index([user_id])
|
||||
@@map("certificates")
|
||||
@@index([quiz_id])
|
||||
@@index([user_id, quiz_id])
|
||||
@@index([user_id, quiz_id, attempt_number])
|
||||
@@map("quiz_attempts")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Communication
|
||||
// ============================================
|
||||
|
||||
enum AnnouncementStatus {
|
||||
DRAFT
|
||||
PUBLISHED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
model Announcement {
|
||||
id Int @id @default(autoincrement())
|
||||
course_id Int
|
||||
title Json // { th: "", en: "" }
|
||||
content Json // { th: "", en: "" }
|
||||
status AnnouncementStatus @default(DRAFT)
|
||||
is_pinned Boolean @default(false)
|
||||
published_at DateTime?
|
||||
created_at DateTime @default(now())
|
||||
created_by Int
|
||||
updated_at DateTime? @updatedAt
|
||||
updated_by Int?
|
||||
|
||||
course Course @relation(fields: [course_id], references: [id], onDelete: Cascade)
|
||||
creator User @relation("AnnouncementCreator", fields: [created_by], references: [id], onDelete: Restrict)
|
||||
updater User? @relation("AnnouncementUpdater", fields: [updated_by], references: [id])
|
||||
attachments AnnouncementAttachment[]
|
||||
|
||||
@@index([course_id])
|
||||
@@index([created_by])
|
||||
@@index([status])
|
||||
@@index([course_id, status, is_pinned, published_at])
|
||||
@@map("announcements")
|
||||
}
|
||||
|
||||
model AnnouncementAttachment {
|
||||
id Int @id @default(autoincrement())
|
||||
announcement_id Int
|
||||
file_name String @db.VarChar(255)
|
||||
file_path String @db.VarChar(500)
|
||||
file_size Int
|
||||
mime_type String @db.VarChar(100)
|
||||
created_at DateTime @default(now())
|
||||
|
||||
announcement Announcement @relation(fields: [announcement_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([announcement_id])
|
||||
@@map("announcement_attachments")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Payment System
|
||||
// ============================================
|
||||
|
||||
enum OrderStatus {
|
||||
PENDING
|
||||
PAID
|
||||
CANCELLED
|
||||
REFUNDED
|
||||
}
|
||||
|
||||
enum PaymentStatus {
|
||||
PENDING
|
||||
SUCCESS
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum WithdrawalStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
PAID
|
||||
}
|
||||
|
||||
model Order {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int
|
||||
total_amount Decimal @default(0) @db.Decimal(10, 2)
|
||||
status OrderStatus @default(PENDING)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime? @updatedAt
|
||||
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
items OrderItem[]
|
||||
payments Payment[]
|
||||
|
||||
@@index([user_id])
|
||||
@@index([status])
|
||||
@@index([user_id, status])
|
||||
@@map("orders")
|
||||
}
|
||||
|
||||
model OrderItem {
|
||||
id Int @id @default(autoincrement())
|
||||
order_id Int
|
||||
course_id Int
|
||||
price Decimal @db.Decimal(10, 2)
|
||||
created_at DateTime @default(now())
|
||||
|
||||
order Order @relation(fields: [order_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([order_id])
|
||||
@@index([course_id])
|
||||
@@map("order_items")
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id Int @id @default(autoincrement())
|
||||
order_id Int
|
||||
provider String @db.VarChar(50)
|
||||
transaction_id String? @unique @db.VarChar(255)
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
status PaymentStatus @default(PENDING)
|
||||
paid_at DateTime?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime? @updatedAt
|
||||
|
||||
order Order @relation(fields: [order_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([order_id])
|
||||
@@index([transaction_id])
|
||||
@@index([status])
|
||||
@@map("payments")
|
||||
}
|
||||
|
||||
model InstructorBalance {
|
||||
id Int @id @default(autoincrement())
|
||||
instructor_id Int @unique
|
||||
available_amount Decimal @default(0) @db.Decimal(10, 2)
|
||||
withdrawn_amount Decimal @default(0) @db.Decimal(10, 2)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime? @updatedAt
|
||||
|
||||
instructor User @relation(fields: [instructor_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([instructor_id])
|
||||
@@map("instructor_balances")
|
||||
}
|
||||
|
||||
model WithdrawalRequest {
|
||||
id Int @id @default(autoincrement())
|
||||
instructor_id Int
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
status WithdrawalStatus @default(PENDING)
|
||||
approved_by Int?
|
||||
approved_at DateTime?
|
||||
rejected_reason String? @db.Text
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime? @updatedAt
|
||||
updated_by Int?
|
||||
|
||||
instructor User @relation("WithdrawalInstructor", fields: [instructor_id], references: [id], onDelete: Cascade)
|
||||
approver User? @relation("WithdrawalApprover", fields: [approved_by], references: [id])
|
||||
updater User? @relation("WithdrawalUpdater", fields: [updated_by], references: [id])
|
||||
|
||||
@@index([instructor_id])
|
||||
@@index([status])
|
||||
@@index([instructor_id, status])
|
||||
@@map("withdrawal_requests")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,25 +7,32 @@ async function main() {
|
|||
console.log('🌱 Starting database seeding...');
|
||||
|
||||
// Clear existing data (in development only)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('🗑️ Clearing existing data...');
|
||||
await prisma.quizAttempt.deleteMany();
|
||||
await prisma.quizQuestion.deleteMany();
|
||||
await prisma.quiz.deleteMany();
|
||||
await prisma.lessonProgress.deleteMany();
|
||||
await prisma.attachment.deleteMany();
|
||||
await prisma.lessonPrerequisite.deleteMany();
|
||||
await prisma.lesson.deleteMany();
|
||||
await prisma.chapter.deleteMany();
|
||||
await prisma.enrollment.deleteMany();
|
||||
await prisma.courseInstructor.deleteMany();
|
||||
await prisma.course.deleteMany();
|
||||
await prisma.category.deleteMany();
|
||||
await prisma.certificate.deleteMany();
|
||||
await prisma.profile.deleteMany();
|
||||
await prisma.user.deleteMany();
|
||||
await prisma.role.deleteMany();
|
||||
}
|
||||
// if (process.env.NODE_ENV === 'development') {
|
||||
// console.log('🗑️ Clearing existing data...');
|
||||
// await prisma.quizAttempt.deleteMany();
|
||||
// await prisma.choice.deleteMany();
|
||||
// await prisma.question.deleteMany();
|
||||
// await prisma.quiz.deleteMany();
|
||||
// await prisma.lessonProgress.deleteMany();
|
||||
// await prisma.lessonAttachment.deleteMany();
|
||||
// await prisma.lesson.deleteMany();
|
||||
// await prisma.chapter.deleteMany();
|
||||
// await prisma.announcementAttachment.deleteMany();
|
||||
// await prisma.announcement.deleteMany();
|
||||
// await prisma.certificate.deleteMany();
|
||||
// await prisma.enrollment.deleteMany();
|
||||
// await prisma.courseInstructor.deleteMany();
|
||||
// await prisma.course.deleteMany();
|
||||
// await prisma.category.deleteMany();
|
||||
// await prisma.payment.deleteMany();
|
||||
// await prisma.orderItem.deleteMany();
|
||||
// await prisma.order.deleteMany();
|
||||
// await prisma.withdrawalRequest.deleteMany();
|
||||
// await prisma.instructorBalance.deleteMany();
|
||||
// await prisma.userProfile.deleteMany();
|
||||
// await prisma.user.deleteMany();
|
||||
// await prisma.role.deleteMany();
|
||||
// }
|
||||
|
||||
// Seed Roles
|
||||
console.log('👥 Seeding roles...');
|
||||
|
|
@ -63,11 +70,12 @@ async function main() {
|
|||
email: 'admin@elearning.local',
|
||||
password: hashedPassword,
|
||||
role_id: roles[0].id,
|
||||
email_verified_at: new Date(),
|
||||
profile: {
|
||||
create: {
|
||||
prefix: { th: 'นาย', en: 'Mr.' },
|
||||
first_name: 'Admin',
|
||||
last_name: 'User',
|
||||
bio: { th: 'ผู้ดูแลระบบ', en: 'System Administrator' }
|
||||
last_name: 'User'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -79,11 +87,12 @@ async function main() {
|
|||
email: 'instructor@elearning.local',
|
||||
password: hashedPassword,
|
||||
role_id: roles[1].id,
|
||||
email_verified_at: new Date(),
|
||||
profile: {
|
||||
create: {
|
||||
prefix: { th: 'นาย', en: 'Mr.' },
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
bio: { th: 'ผู้สอนมืออาชีพ', en: 'Professional Instructor' }
|
||||
last_name: 'Doe'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -95,11 +104,12 @@ async function main() {
|
|||
email: 'student@elearning.local',
|
||||
password: hashedPassword,
|
||||
role_id: roles[2].id,
|
||||
email_verified_at: new Date(),
|
||||
profile: {
|
||||
create: {
|
||||
prefix: { th: 'นางสาว', en: 'Ms.' },
|
||||
first_name: 'Jane',
|
||||
last_name: 'Smith',
|
||||
bio: { th: 'นักเรียนที่กระตือรือร้น', en: 'Eager learner' }
|
||||
last_name: 'Smith'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -110,23 +120,32 @@ async function main() {
|
|||
const categories = await Promise.all([
|
||||
prisma.category.create({
|
||||
data: {
|
||||
code: 'PROGRAMMING',
|
||||
name: { th: 'การเขียนโปรแกรม', en: 'Programming' },
|
||||
description: { th: 'เรียนรู้การเขียนโปรแกรมและพัฒนาซอฟต์แวร์', en: 'Learn programming and software development' }
|
||||
slug: 'programming',
|
||||
description: { th: 'เรียนรู้การเขียนโปรแกรมและพัฒนาซอฟต์แวร์', en: 'Learn programming and software development' },
|
||||
icon: 'code',
|
||||
sort_order: 1,
|
||||
created_by: admin.id
|
||||
}
|
||||
}),
|
||||
prisma.category.create({
|
||||
data: {
|
||||
code: 'DESIGN',
|
||||
name: { th: 'การออกแบบ', en: 'Design' },
|
||||
description: { th: 'เรียนรู้การออกแบบกราฟิกและ UI/UX', en: 'Learn graphic design and UI/UX' }
|
||||
slug: 'design',
|
||||
description: { th: 'เรียนรู้การออกแบบกราฟิกและ UI/UX', en: 'Learn graphic design and UI/UX' },
|
||||
icon: 'palette',
|
||||
sort_order: 2,
|
||||
created_by: admin.id
|
||||
}
|
||||
}),
|
||||
prisma.category.create({
|
||||
data: {
|
||||
code: 'BUSINESS',
|
||||
name: { th: 'ธุรกิจ', en: 'Business' },
|
||||
description: { th: 'เรียนรู้การบริหารธุรกิจและการตลาด', en: 'Learn business management and marketing' }
|
||||
slug: 'business',
|
||||
description: { th: 'เรียนรู้การบริหารธุรกิจและการตลาด', en: 'Learn business management and marketing' },
|
||||
icon: 'briefcase',
|
||||
sort_order: 3,
|
||||
created_by: admin.id
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
|
@ -136,9 +155,10 @@ async function main() {
|
|||
const course = await prisma.course.create({
|
||||
data: {
|
||||
title: { th: 'พื้นฐาน JavaScript', en: 'JavaScript Fundamentals' },
|
||||
slug: 'javascript-fundamentals',
|
||||
description: {
|
||||
th: 'เรียนรู้พื้นฐาน JavaScript ตั้งแต่เริ่มต้น',
|
||||
en: 'Learn JavaScript fundamentals from scratch'
|
||||
th: 'เรียนรู้พื้นฐาน JavaScript ตั้งแต่เริ่มต้น รวมถึงตัวแปร ฟังก์ชัน และการจัดการ DOM',
|
||||
en: 'Learn JavaScript fundamentals from scratch including variables, functions, and DOM manipulation'
|
||||
},
|
||||
price: 0,
|
||||
is_free: true,
|
||||
|
|
@ -146,6 +166,8 @@ async function main() {
|
|||
status: 'APPROVED',
|
||||
category_id: categories[0].id,
|
||||
created_by: instructor.id,
|
||||
approved_by: admin.id,
|
||||
approved_at: new Date(),
|
||||
instructors: {
|
||||
create: {
|
||||
user_id: instructor.id,
|
||||
|
|
@ -156,43 +178,177 @@ async function main() {
|
|||
create: [
|
||||
{
|
||||
title: { th: 'บทที่ 1: เริ่มต้น', en: 'Chapter 1: Getting Started' },
|
||||
description: { th: 'แนะนำ JavaScript', en: 'Introduction to JavaScript' },
|
||||
order: 1,
|
||||
description: { th: 'แนะนำ JavaScript และการตั้งค่าสภาพแวดล้อม', en: 'Introduction to JavaScript and environment setup' },
|
||||
sort_order: 1,
|
||||
is_published: true,
|
||||
lessons: {
|
||||
create: [
|
||||
{
|
||||
title: { th: 'JavaScript คืออะไร', en: 'What is JavaScript' },
|
||||
description: { th: 'เรียนรู้ว่า JavaScript คืออะไร', en: 'Learn what JavaScript is' },
|
||||
content: {
|
||||
th: 'JavaScript เป็นภาษาโปรแกรมที่ใช้ในการพัฒนาเว็บไซต์',
|
||||
en: 'JavaScript is a programming language used for web development'
|
||||
},
|
||||
type: 'VIDEO',
|
||||
order: 1,
|
||||
is_preview: true
|
||||
duration_minutes: 15,
|
||||
sort_order: 1,
|
||||
is_published: true
|
||||
},
|
||||
{
|
||||
title: { th: 'ตัวแปรและชนิดข้อมูล', en: 'Variables and Data Types' },
|
||||
description: { th: 'เรียนรู้เกี่ยวกับตัวแปร', en: 'Learn about variables' },
|
||||
content: {
|
||||
th: 'เรียนรู้เกี่ยวกับตัวแปร let, const และชนิดข้อมูลต่างๆ',
|
||||
en: 'Learn about let, const variables and different data types'
|
||||
},
|
||||
type: 'VIDEO',
|
||||
order: 2
|
||||
duration_minutes: 20,
|
||||
sort_order: 2,
|
||||
is_published: true
|
||||
},
|
||||
{
|
||||
title: { th: 'แบบทดสอบบทที่ 1', en: 'Chapter 1 Quiz' },
|
||||
type: 'QUIZ',
|
||||
duration_minutes: 10,
|
||||
sort_order: 3,
|
||||
is_published: true,
|
||||
require_pass_quiz: true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
title: { th: 'บทที่ 2: ฟังก์ชัน', en: 'Chapter 2: Functions' },
|
||||
description: { th: 'เรียนรู้เกี่ยวกับฟังก์ชัน', en: 'Learn about functions' },
|
||||
order: 2,
|
||||
description: { th: 'เรียนรู้เกี่ยวกับฟังก์ชันและการใช้งาน', en: 'Learn about functions and their usage' },
|
||||
sort_order: 2,
|
||||
is_published: true,
|
||||
lessons: {
|
||||
create: [
|
||||
{
|
||||
title: { th: 'การสร้างฟังก์ชัน', en: 'Creating Functions' },
|
||||
description: { th: 'เรียนรู้วิธีสร้างฟังก์ชัน', en: 'Learn how to create functions' },
|
||||
content: {
|
||||
th: 'เรียนรู้วิธีสร้างและเรียกใช้ฟังก์ชัน',
|
||||
en: 'Learn how to create and call functions'
|
||||
},
|
||||
type: 'VIDEO',
|
||||
order: 1
|
||||
duration_minutes: 25,
|
||||
sort_order: 1,
|
||||
is_published: true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
include: {
|
||||
chapters: {
|
||||
include: {
|
||||
lessons: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create a quiz for the quiz lesson
|
||||
const quizLesson = await prisma.lesson.findFirst({
|
||||
where: {
|
||||
title: {
|
||||
path: ['en'],
|
||||
equals: 'Chapter 1 Quiz'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (quizLesson) {
|
||||
console.log('📝 Creating quiz...');
|
||||
await prisma.quiz.create({
|
||||
data: {
|
||||
lesson_id: quizLesson.id,
|
||||
title: { th: 'แบบทดสอบบทที่ 1', en: 'Chapter 1 Quiz' },
|
||||
description: { th: 'ทดสอบความเข้าใจเกี่ยวกับพื้นฐาน JavaScript', en: 'Test your understanding of JavaScript basics' },
|
||||
passing_score: 70,
|
||||
time_limit: 10,
|
||||
shuffle_questions: true,
|
||||
shuffle_choices: true,
|
||||
created_by: instructor.id,
|
||||
questions: {
|
||||
create: [
|
||||
{
|
||||
question: {
|
||||
th: 'JavaScript ใช้สำหรับอะไร?',
|
||||
en: 'What is JavaScript used for?'
|
||||
},
|
||||
question_type: 'MULTIPLE_CHOICE',
|
||||
score: 1,
|
||||
sort_order: 1,
|
||||
choices: {
|
||||
create: [
|
||||
{
|
||||
text: { th: 'พัฒนาเว็บไซต์', en: 'Web development' },
|
||||
is_correct: true,
|
||||
sort_order: 1
|
||||
},
|
||||
{
|
||||
text: { th: 'ทำกาแฟ', en: 'Making coffee' },
|
||||
is_correct: false,
|
||||
sort_order: 2
|
||||
},
|
||||
{
|
||||
text: { th: 'ขับรถ', en: 'Driving cars' },
|
||||
is_correct: false,
|
||||
sort_order: 3
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
question: {
|
||||
th: 'ตัวแปรใน JavaScript ประกาศด้วยคำสั่งใด?',
|
||||
en: 'Which keyword is used to declare variables in JavaScript?'
|
||||
},
|
||||
question_type: 'MULTIPLE_CHOICE',
|
||||
score: 1,
|
||||
sort_order: 2,
|
||||
choices: {
|
||||
create: [
|
||||
{
|
||||
text: { th: 'let และ const', en: 'let and const' },
|
||||
is_correct: true,
|
||||
sort_order: 1
|
||||
},
|
||||
{
|
||||
text: { th: 'int และ float', en: 'int and float' },
|
||||
is_correct: false,
|
||||
sort_order: 2
|
||||
},
|
||||
{
|
||||
text: { th: 'variable', en: 'variable' },
|
||||
is_correct: false,
|
||||
sort_order: 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create announcement
|
||||
console.log('📢 Creating announcement...');
|
||||
await prisma.announcement.create({
|
||||
data: {
|
||||
course_id: course.id,
|
||||
title: { th: 'ยินดีต้อนรับสู่คอร์ส', en: 'Welcome to the Course' },
|
||||
content: {
|
||||
th: 'ยินดีต้อนรับทุกคนสู่คอร์ส JavaScript Fundamentals! เราจะเริ่มเรียนในสัปดาห์หน้า',
|
||||
en: 'Welcome everyone to JavaScript Fundamentals! We will start next week'
|
||||
},
|
||||
status: 'PUBLISHED',
|
||||
is_pinned: true,
|
||||
published_at: new Date(),
|
||||
created_by: instructor.id
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -202,6 +358,10 @@ async function main() {
|
|||
console.log(`- Users: 3 (admin, instructor, student)`);
|
||||
console.log(`- Categories: ${categories.length}`);
|
||||
console.log(`- Courses: 1`);
|
||||
console.log(`- Chapters: 2`);
|
||||
console.log(`- Lessons: 4`);
|
||||
console.log(`- Quizzes: 1 (with 2 questions)`);
|
||||
console.log(`- Announcements: 1`);
|
||||
console.log('\n🔑 Test Credentials:');
|
||||
console.log('Admin: admin / admin123');
|
||||
console.log('Instructor: instructor / admin123');
|
||||
|
|
|
|||
|
|
@ -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
83
Backend/src/app.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
40
Backend/src/config/database.ts
Normal file
40
Backend/src/config/database.ts
Normal 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 };
|
||||
75
Backend/src/config/index.ts
Normal file
75
Backend/src/config/index.ts
Normal 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)
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
44
Backend/src/config/logger.ts
Normal file
44
Backend/src/config/logger.ts
Normal 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 }));
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
128
Backend/src/controllers/AuthController.ts
Normal file
128
Backend/src/controllers/AuthController.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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,
|
||||
};
|
||||
92
Backend/src/middleware/authentication.ts
Normal file
92
Backend/src/middleware/authentication.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
132
Backend/src/middleware/errorHandler.ts
Normal file
132
Backend/src/middleware/errorHandler.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
52
Backend/src/server.ts
Normal 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();
|
||||
|
|
@ -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();
|
||||
217
Backend/src/services/auth.service.ts
Normal file
217
Backend/src/services/auth.service.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
62
Backend/src/types/auth.types.ts
Normal file
62
Backend/src/types/auth.types.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
58
Backend/src/types/index.ts
Normal file
58
Backend/src/types/index.ts
Normal 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'
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
76
Backend/src/validators/auth.validator.ts
Normal file
76
Backend/src/validators/auth.validator.ts
Normal 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
39
Backend/tsconfig.json
Normal 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
30
Backend/tsoa.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue