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