init Backend
This commit is contained in:
parent
08a4e0d8fa
commit
924000b084
29 changed files with 10080 additions and 13 deletions
44
Backend/.env.example
Normal file
44
Backend/.env.example
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Environment
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=4000
|
||||||
|
APP_URL=http://localhost:4000
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=postgresql://elearning_user:elearning_pass@localhost:5432/elearning_db
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||||
|
JWT_EXPIRES_IN=24h
|
||||||
|
JWT_REFRESH_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# MinIO/S3
|
||||||
|
S3_ENDPOINT=http://localhost:9000
|
||||||
|
S3_ACCESS_KEY=minioadmin
|
||||||
|
S3_SECRET_KEY=minioadmin
|
||||||
|
S3_BUCKET_NAME=elearning
|
||||||
|
S3_USE_SSL=false
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGIN=http://localhost:3000,http://localhost:5173
|
||||||
|
|
||||||
|
# File Upload Limits (in bytes)
|
||||||
|
MAX_VIDEO_SIZE=524288000
|
||||||
|
MAX_ATTACHMENT_SIZE=104857600
|
||||||
|
MAX_ATTACHMENTS_PER_LESSON=10
|
||||||
|
|
||||||
|
# Email (Mailhog for development)
|
||||||
|
SMTP_HOST=localhost
|
||||||
|
SMTP_PORT=1025
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
SMTP_FROM=noreply@elearning.local
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=debug
|
||||||
35
Backend/.gitignore
vendored
Normal file
35
Backend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Uploads (local development)
|
||||||
|
uploads/
|
||||||
|
temp/
|
||||||
|
|
||||||
|
# PM2
|
||||||
|
.pm2/
|
||||||
191
Backend/README.md
Normal file
191
Backend/README.md
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
# E-Learning Backend
|
||||||
|
|
||||||
|
Backend API for the E-Learning Platform built with Node.js, Express, Prisma, and PostgreSQL.
|
||||||
|
|
||||||
|
## 🚀 Features
|
||||||
|
|
||||||
|
- **Authentication & Authorization**: JWT-based authentication with role-based access control
|
||||||
|
- **Course Management**: Create, manage, and publish courses with chapters and lessons
|
||||||
|
- **Multi-Language Support**: Thai and English content support
|
||||||
|
- **Quiz System**: Interactive quizzes with multiple attempts and score policies
|
||||||
|
- **Progress Tracking**: Track student progress and issue certificates
|
||||||
|
- **File Upload**: Support for video lessons and attachments (MinIO/S3)
|
||||||
|
- **Caching**: Redis integration for improved performance
|
||||||
|
- **Security**: Helmet, CORS, rate limiting, and input validation
|
||||||
|
|
||||||
|
## 📋 Prerequisites
|
||||||
|
|
||||||
|
- Node.js >= 18.0.0
|
||||||
|
- PostgreSQL >= 14
|
||||||
|
- Redis (optional, for caching)
|
||||||
|
- MinIO or S3 (for file storage)
|
||||||
|
|
||||||
|
## 🛠️ Installation
|
||||||
|
|
||||||
|
1. **Clone the repository**
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd e-learning/Backend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install dependencies**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Setup environment variables**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Start Docker services** (PostgreSQL, Redis, MinIO)
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Run database migrations**
|
||||||
|
```bash
|
||||||
|
npx prisma migrate dev
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Seed the database**
|
||||||
|
```bash
|
||||||
|
npm run prisma:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Start development server**
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will start on `http://localhost:4000`
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Backend/
|
||||||
|
├── prisma/
|
||||||
|
│ ├── migrations/ # Database migrations
|
||||||
|
│ ├── schema.prisma # Database schema
|
||||||
|
│ └── seed.js # Database seeding
|
||||||
|
├── src/
|
||||||
|
│ ├── config/ # Configuration files
|
||||||
|
│ │ ├── database.js # Prisma client
|
||||||
|
│ │ ├── logger.js # Winston logger
|
||||||
|
│ │ └── redis.js # Redis client
|
||||||
|
│ ├── controllers/ # Request handlers
|
||||||
|
│ ├── middleware/ # Express middleware
|
||||||
|
│ │ ├── auth.js # Authentication & authorization
|
||||||
|
│ │ └── errorHandler.js
|
||||||
|
│ ├── routes/ # Route definitions
|
||||||
|
│ ├── services/ # Business logic
|
||||||
|
│ ├── utils/ # Utility functions
|
||||||
|
│ │ └── jwt.js # JWT utilities
|
||||||
|
│ ├── validators/ # Input validation
|
||||||
|
│ ├── app.js # Express app setup
|
||||||
|
│ └── server.js # Server entry point
|
||||||
|
├── tests/ # Test files
|
||||||
|
├── logs/ # Log files
|
||||||
|
├── .env.example # Environment variables template
|
||||||
|
├── package.json
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔑 Environment Variables
|
||||||
|
|
||||||
|
See `.env.example` for all available environment variables.
|
||||||
|
|
||||||
|
Key variables:
|
||||||
|
- `DATABASE_URL`: PostgreSQL connection string
|
||||||
|
- `JWT_SECRET`: Secret key for JWT tokens
|
||||||
|
- `REDIS_URL`: Redis connection string
|
||||||
|
- `S3_ENDPOINT`: MinIO/S3 endpoint
|
||||||
|
- `CORS_ORIGIN`: Allowed CORS origins
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Run tests in watch mode
|
||||||
|
npm run test:watch
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
npm test -- --coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 API Documentation
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
- `POST /api/auth/register` - Register new user
|
||||||
|
- `POST /api/auth/login` - Login user
|
||||||
|
- `GET /api/auth/me` - Get current user profile
|
||||||
|
- `POST /api/auth/logout` - Logout user
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
- `GET /health` - Server health status
|
||||||
|
|
||||||
|
## 🔐 Default Credentials
|
||||||
|
|
||||||
|
After seeding the database, you can use these credentials:
|
||||||
|
|
||||||
|
- **Admin**: `admin` / `admin123`
|
||||||
|
- **Instructor**: `instructor` / `admin123`
|
||||||
|
- **Student**: `student` / `admin123`
|
||||||
|
|
||||||
|
## 🛠️ Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start dev server with auto-reload
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Run linter
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
npm run format
|
||||||
|
|
||||||
|
# Open Prisma Studio (database GUI)
|
||||||
|
npm run prisma:studio
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 Database Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate Prisma Client
|
||||||
|
npm run prisma:generate
|
||||||
|
|
||||||
|
# Create migration
|
||||||
|
npx prisma migrate dev --name migration_name
|
||||||
|
|
||||||
|
# Apply migrations
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
# Reset database (development only)
|
||||||
|
npx prisma migrate reset
|
||||||
|
|
||||||
|
# Seed database
|
||||||
|
npm run prisma:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Production Deployment
|
||||||
|
|
||||||
|
1. Set `NODE_ENV=production`
|
||||||
|
2. Set strong `JWT_SECRET`
|
||||||
|
3. Configure production database
|
||||||
|
4. Run migrations: `npx prisma migrate deploy`
|
||||||
|
5. Start with PM2: `pm2 start src/server.js`
|
||||||
|
|
||||||
|
## 📚 Resources
|
||||||
|
|
||||||
|
- [Prisma Documentation](https://www.prisma.io/docs)
|
||||||
|
- [Express Documentation](https://expressjs.com/)
|
||||||
|
- [JWT Documentation](https://jwt.io/)
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
@ -50,19 +50,6 @@ services:
|
||||||
timeout: 20s
|
timeout: 20s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
# MinIO Client - Create buckets on startup
|
|
||||||
minio-init:
|
|
||||||
image: minio/mc:latest
|
|
||||||
container_name: elearning-minio-init
|
|
||||||
security_opt:
|
|
||||||
- apparmor=unconfined
|
|
||||||
depends_on:
|
|
||||||
- minio
|
|
||||||
entrypoint: >
|
|
||||||
/bin/sh -c " sleep 5; /usr/bin/mc alias set myminio http://minio:9000 admin 12345678; /usr/bin/mc mb myminio/courses --ignore-existing; /usr/bin/mc mb myminio/videos --ignore-existing; /usr/bin/mc mb myminio/documents --ignore-existing; /usr/bin/mc mb myminio/images --ignore-existing; /usr/bin/mc mb myminio/attachments --ignore-existing; /usr/bin/mc anonymous set download myminio/images; echo 'MinIO buckets created successfully'; exit 0; "
|
|
||||||
networks:
|
|
||||||
- elearning-network
|
|
||||||
|
|
||||||
# Redis - Cache & Session Store
|
# Redis - Cache & Session Store
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
|
|
|
||||||
7584
Backend/package-lock.json
generated
Normal file
7584
Backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
56
Backend/package.json
Normal file
56
Backend/package.json
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
{
|
||||||
|
"name": "e-learning-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "E-Learning Platform Backend API",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nodemon src/server.js",
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"test": "jest --coverage",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"lint": "eslint src/**/*.js",
|
||||||
|
"format": "prettier --write \"src/**/*.js\"",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"prisma:studio": "prisma studio",
|
||||||
|
"prisma:seed": "node prisma/seed.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"e-learning",
|
||||||
|
"api",
|
||||||
|
"express",
|
||||||
|
"prisma",
|
||||||
|
"postgresql"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.21.1",
|
||||||
|
"express-rate-limit": "^7.4.1",
|
||||||
|
"helmet": "^8.0.0",
|
||||||
|
"joi": "^17.13.3",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"minio": "^8.0.2",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"redis": "^4.7.0",
|
||||||
|
"swagger-jsdoc": "^6.2.8",
|
||||||
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"winston": "^3.17.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"nodemon": "^3.1.9",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
|
"supertest": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0",
|
||||||
|
"npm": ">=9.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
406
Backend/prisma/migrations/20260108043156_init/migration.sql
Normal file
406
Backend/prisma/migrations/20260108043156_init/migration.sql
Normal file
|
|
@ -0,0 +1,406 @@
|
||||||
|
-- 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;
|
||||||
3
Backend/prisma/migrations/migration_lock.toml
Normal file
3
Backend/prisma/migrations/migration_lock.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "postgresql"
|
||||||
354
Backend/prisma/schema.prisma
Normal file
354
Backend/prisma/schema.prisma
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// User Management
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model Role {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
code String @unique @db.VarChar(50)
|
||||||
|
name Json // { th: "", en: "" }
|
||||||
|
description Json?
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
users User[]
|
||||||
|
|
||||||
|
@@map("roles")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
username String @unique @db.VarChar(50)
|
||||||
|
email String @unique @db.VarChar(255)
|
||||||
|
password String @db.VarChar(255)
|
||||||
|
role_id Int
|
||||||
|
is_active Boolean @default(true)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
role Role @relation(fields: [role_id], references: [id])
|
||||||
|
profile Profile?
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
created_courses Course[] @relation("CourseCreator")
|
||||||
|
instructor_courses CourseInstructor[]
|
||||||
|
enrollments Enrollment[]
|
||||||
|
lesson_progress LessonProgress[]
|
||||||
|
quiz_attempts QuizAttempt[]
|
||||||
|
certificates Certificate[]
|
||||||
|
|
||||||
|
@@index([email])
|
||||||
|
@@index([role_id])
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Profile {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
user_id Int @unique
|
||||||
|
first_name String? @db.VarChar(100)
|
||||||
|
last_name String? @db.VarChar(100)
|
||||||
|
phone String? @db.VarChar(20)
|
||||||
|
avatar_url String? @db.VarChar(500)
|
||||||
|
bio Json? // { th: "", en: "" }
|
||||||
|
birth_date DateTime?
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("profiles")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Course Management
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
enum CourseStatus {
|
||||||
|
DRAFT
|
||||||
|
PENDING_APPROVAL
|
||||||
|
APPROVED
|
||||||
|
REJECTED
|
||||||
|
ARCHIVED
|
||||||
|
}
|
||||||
|
|
||||||
|
model Category {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
code String @unique @db.VarChar(50)
|
||||||
|
name Json // { th: "", en: "" }
|
||||||
|
description Json?
|
||||||
|
icon_url String? @db.VarChar(500)
|
||||||
|
is_active Boolean @default(true)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
courses Course[]
|
||||||
|
|
||||||
|
@@map("categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Course {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
title Json // { th: "", en: "" }
|
||||||
|
description Json
|
||||||
|
thumbnail_url String? @db.VarChar(500)
|
||||||
|
price Decimal @default(0) @db.Decimal(10, 2)
|
||||||
|
is_free Boolean @default(false)
|
||||||
|
have_certificate Boolean @default(false)
|
||||||
|
status CourseStatus @default(DRAFT)
|
||||||
|
category_id Int
|
||||||
|
created_by Int
|
||||||
|
rejection_reason String? @db.Text
|
||||||
|
is_deleted Boolean @default(false)
|
||||||
|
deleted_at DateTime?
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
category Category @relation(fields: [category_id], references: [id])
|
||||||
|
creator User @relation("CourseCreator", fields: [created_by], references: [id])
|
||||||
|
chapters Chapter[]
|
||||||
|
instructors CourseInstructor[]
|
||||||
|
enrollments Enrollment[]
|
||||||
|
|
||||||
|
@@index([category_id])
|
||||||
|
@@index([status])
|
||||||
|
@@index([created_by])
|
||||||
|
@@index([created_at])
|
||||||
|
@@map("courses")
|
||||||
|
}
|
||||||
|
|
||||||
|
model CourseInstructor {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
course_id Int
|
||||||
|
user_id Int
|
||||||
|
is_primary Boolean @default(false)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
|
||||||
|
course Course @relation(fields: [course_id], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([course_id, user_id])
|
||||||
|
@@index([course_id])
|
||||||
|
@@index([user_id])
|
||||||
|
@@map("course_instructors")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Chapter {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
course_id Int
|
||||||
|
title Json // { th: "", en: "" }
|
||||||
|
description Json?
|
||||||
|
order Int
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
course Course @relation(fields: [course_id], references: [id], onDelete: Cascade)
|
||||||
|
lessons Lesson[]
|
||||||
|
|
||||||
|
@@index([course_id])
|
||||||
|
@@index([order])
|
||||||
|
@@map("chapters")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Lessons
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
enum LessonType {
|
||||||
|
VIDEO
|
||||||
|
TEXT
|
||||||
|
PDF
|
||||||
|
QUIZ
|
||||||
|
}
|
||||||
|
|
||||||
|
model Lesson {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
chapter_id Int
|
||||||
|
title Json // { th: "", en: "" }
|
||||||
|
description Json?
|
||||||
|
type LessonType
|
||||||
|
content Json? // For TEXT type
|
||||||
|
video_url String? @db.VarChar(500)
|
||||||
|
video_duration Int? // in seconds
|
||||||
|
order Int
|
||||||
|
is_preview Boolean @default(false)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
chapter Chapter @relation(fields: [chapter_id], references: [id], onDelete: Cascade)
|
||||||
|
attachments Attachment[]
|
||||||
|
prerequisites LessonPrerequisite[] @relation("LessonPrerequisites")
|
||||||
|
required_for LessonPrerequisite[] @relation("RequiredForLessons")
|
||||||
|
quiz Quiz?
|
||||||
|
progress LessonProgress[]
|
||||||
|
|
||||||
|
@@index([chapter_id])
|
||||||
|
@@index([order])
|
||||||
|
@@map("lessons")
|
||||||
|
}
|
||||||
|
|
||||||
|
model LessonPrerequisite {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
lesson_id Int
|
||||||
|
prerequisite_lesson_id Int
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
|
||||||
|
lesson Lesson @relation("LessonPrerequisites", fields: [lesson_id], references: [id], onDelete: Cascade)
|
||||||
|
prerequisite_lesson Lesson @relation("RequiredForLessons", fields: [prerequisite_lesson_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([lesson_id, prerequisite_lesson_id])
|
||||||
|
@@index([lesson_id])
|
||||||
|
@@index([prerequisite_lesson_id])
|
||||||
|
@@map("lesson_prerequisites")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Attachment {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
lesson_id Int
|
||||||
|
filename String @db.VarChar(255)
|
||||||
|
original_name String @db.VarChar(255)
|
||||||
|
file_url String @db.VarChar(500)
|
||||||
|
file_size BigInt
|
||||||
|
mime_type String @db.VarChar(100)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
|
||||||
|
lesson Lesson @relation(fields: [lesson_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([lesson_id])
|
||||||
|
@@map("attachments")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Quiz System
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
enum ScorePolicy {
|
||||||
|
HIGHEST
|
||||||
|
LATEST
|
||||||
|
AVERAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
model Quiz {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
lesson_id Int @unique
|
||||||
|
title Json // { th: "", en: "" }
|
||||||
|
description Json?
|
||||||
|
passing_score Int @default(70)
|
||||||
|
time_limit Int? // in minutes
|
||||||
|
max_attempts Int @default(3)
|
||||||
|
cooldown_hours Int @default(24)
|
||||||
|
score_policy ScorePolicy @default(HIGHEST)
|
||||||
|
shuffle_questions Boolean @default(true)
|
||||||
|
shuffle_choices Boolean @default(true)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
lesson Lesson @relation(fields: [lesson_id], references: [id], onDelete: Cascade)
|
||||||
|
questions QuizQuestion[]
|
||||||
|
attempts QuizAttempt[]
|
||||||
|
|
||||||
|
@@map("quizzes")
|
||||||
|
}
|
||||||
|
|
||||||
|
model QuizQuestion {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
quiz_id Int
|
||||||
|
question Json // { th: "", en: "" }
|
||||||
|
choices Json // [{ th: "", en: "", is_correct: boolean }]
|
||||||
|
points Int @default(1)
|
||||||
|
order Int
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
quiz Quiz @relation(fields: [quiz_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([quiz_id])
|
||||||
|
@@index([order])
|
||||||
|
@@map("quiz_questions")
|
||||||
|
}
|
||||||
|
|
||||||
|
model QuizAttempt {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
quiz_id Int
|
||||||
|
user_id Int
|
||||||
|
answers Json // [{ question_id: int, choice_index: int }]
|
||||||
|
score Int
|
||||||
|
passed Boolean
|
||||||
|
time_spent Int? // in seconds
|
||||||
|
started_at DateTime
|
||||||
|
completed_at DateTime @default(now())
|
||||||
|
|
||||||
|
quiz Quiz @relation(fields: [quiz_id], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([quiz_id])
|
||||||
|
@@index([user_id])
|
||||||
|
@@index([completed_at])
|
||||||
|
@@map("quiz_attempts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Progress Tracking
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model Enrollment {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
user_id Int
|
||||||
|
course_id Int
|
||||||
|
enrolled_at DateTime @default(now())
|
||||||
|
completed_at DateTime?
|
||||||
|
progress_percent Int @default(0)
|
||||||
|
last_accessed_at DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
|
course Course @relation(fields: [course_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([user_id, course_id])
|
||||||
|
@@index([user_id])
|
||||||
|
@@index([course_id])
|
||||||
|
@@map("enrollments")
|
||||||
|
}
|
||||||
|
|
||||||
|
model LessonProgress {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
user_id Int
|
||||||
|
lesson_id Int
|
||||||
|
is_completed Boolean @default(false)
|
||||||
|
video_progress Int @default(0) // seconds watched
|
||||||
|
completed_at DateTime?
|
||||||
|
last_watched_at DateTime @default(now())
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
|
lesson Lesson @relation(fields: [lesson_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([user_id, lesson_id])
|
||||||
|
@@index([user_id])
|
||||||
|
@@index([lesson_id])
|
||||||
|
@@map("lesson_progress")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Certificate {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
user_id Int
|
||||||
|
course_id Int
|
||||||
|
certificate_url String @db.VarChar(500)
|
||||||
|
issued_at DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([user_id, course_id])
|
||||||
|
@@index([user_id])
|
||||||
|
@@map("certificates")
|
||||||
|
}
|
||||||
218
Backend/prisma/seed.js
Normal file
218
Backend/prisma/seed.js
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 Starting database seeding...');
|
||||||
|
|
||||||
|
// Clear existing data (in development only)
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log('🗑️ Clearing existing data...');
|
||||||
|
await prisma.quizAttempt.deleteMany();
|
||||||
|
await prisma.quizQuestion.deleteMany();
|
||||||
|
await prisma.quiz.deleteMany();
|
||||||
|
await prisma.lessonProgress.deleteMany();
|
||||||
|
await prisma.attachment.deleteMany();
|
||||||
|
await prisma.lessonPrerequisite.deleteMany();
|
||||||
|
await prisma.lesson.deleteMany();
|
||||||
|
await prisma.chapter.deleteMany();
|
||||||
|
await prisma.enrollment.deleteMany();
|
||||||
|
await prisma.courseInstructor.deleteMany();
|
||||||
|
await prisma.course.deleteMany();
|
||||||
|
await prisma.category.deleteMany();
|
||||||
|
await prisma.certificate.deleteMany();
|
||||||
|
await prisma.profile.deleteMany();
|
||||||
|
await prisma.user.deleteMany();
|
||||||
|
await prisma.role.deleteMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed Roles
|
||||||
|
console.log('👥 Seeding roles...');
|
||||||
|
const roles = await Promise.all([
|
||||||
|
prisma.role.create({
|
||||||
|
data: {
|
||||||
|
code: 'ADMIN',
|
||||||
|
name: { th: 'ผู้ดูแลระบบ', en: 'Administrator' },
|
||||||
|
description: { th: 'มีสิทธิ์เข้าถึงทุกฟังก์ชัน', en: 'Full system access' }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.role.create({
|
||||||
|
data: {
|
||||||
|
code: 'INSTRUCTOR',
|
||||||
|
name: { th: 'ผู้สอน', en: 'Instructor' },
|
||||||
|
description: { th: 'สามารถสร้างและจัดการคอร์สเรียน', en: 'Can create and manage courses' }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.role.create({
|
||||||
|
data: {
|
||||||
|
code: 'STUDENT',
|
||||||
|
name: { th: 'นักเรียน', en: 'Student' },
|
||||||
|
description: { th: 'สามารถลงทะเบียนและเรียนคอร์ส', en: 'Can enroll and learn courses' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Seed Users
|
||||||
|
console.log('👤 Seeding users...');
|
||||||
|
const hashedPassword = await bcrypt.hash('admin123', 10);
|
||||||
|
|
||||||
|
const admin = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: 'admin',
|
||||||
|
email: 'admin@elearning.local',
|
||||||
|
password: hashedPassword,
|
||||||
|
role_id: roles[0].id,
|
||||||
|
profile: {
|
||||||
|
create: {
|
||||||
|
first_name: 'Admin',
|
||||||
|
last_name: 'User',
|
||||||
|
bio: { th: 'ผู้ดูแลระบบ', en: 'System Administrator' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const instructor = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: 'instructor',
|
||||||
|
email: 'instructor@elearning.local',
|
||||||
|
password: hashedPassword,
|
||||||
|
role_id: roles[1].id,
|
||||||
|
profile: {
|
||||||
|
create: {
|
||||||
|
first_name: 'John',
|
||||||
|
last_name: 'Doe',
|
||||||
|
bio: { th: 'ผู้สอนมืออาชีพ', en: 'Professional Instructor' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const student = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: 'student',
|
||||||
|
email: 'student@elearning.local',
|
||||||
|
password: hashedPassword,
|
||||||
|
role_id: roles[2].id,
|
||||||
|
profile: {
|
||||||
|
create: {
|
||||||
|
first_name: 'Jane',
|
||||||
|
last_name: 'Smith',
|
||||||
|
bio: { th: 'นักเรียนที่กระตือรือร้น', en: 'Eager learner' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed Categories
|
||||||
|
console.log('📚 Seeding categories...');
|
||||||
|
const categories = await Promise.all([
|
||||||
|
prisma.category.create({
|
||||||
|
data: {
|
||||||
|
code: 'PROGRAMMING',
|
||||||
|
name: { th: 'การเขียนโปรแกรม', en: 'Programming' },
|
||||||
|
description: { th: 'เรียนรู้การเขียนโปรแกรมและพัฒนาซอฟต์แวร์', en: 'Learn programming and software development' }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.category.create({
|
||||||
|
data: {
|
||||||
|
code: 'DESIGN',
|
||||||
|
name: { th: 'การออกแบบ', en: 'Design' },
|
||||||
|
description: { th: 'เรียนรู้การออกแบบกราฟิกและ UI/UX', en: 'Learn graphic design and UI/UX' }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.category.create({
|
||||||
|
data: {
|
||||||
|
code: 'BUSINESS',
|
||||||
|
name: { th: 'ธุรกิจ', en: 'Business' },
|
||||||
|
description: { th: 'เรียนรู้การบริหารธุรกิจและการตลาด', en: 'Learn business management and marketing' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Seed Sample Course
|
||||||
|
console.log('🎓 Seeding sample course...');
|
||||||
|
const course = await prisma.course.create({
|
||||||
|
data: {
|
||||||
|
title: { th: 'พื้นฐาน JavaScript', en: 'JavaScript Fundamentals' },
|
||||||
|
description: {
|
||||||
|
th: 'เรียนรู้พื้นฐาน JavaScript ตั้งแต่เริ่มต้น',
|
||||||
|
en: 'Learn JavaScript fundamentals from scratch'
|
||||||
|
},
|
||||||
|
price: 0,
|
||||||
|
is_free: true,
|
||||||
|
have_certificate: true,
|
||||||
|
status: 'APPROVED',
|
||||||
|
category_id: categories[0].id,
|
||||||
|
created_by: instructor.id,
|
||||||
|
instructors: {
|
||||||
|
create: {
|
||||||
|
user_id: instructor.id,
|
||||||
|
is_primary: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
chapters: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
title: { th: 'บทที่ 1: เริ่มต้น', en: 'Chapter 1: Getting Started' },
|
||||||
|
description: { th: 'แนะนำ JavaScript', en: 'Introduction to JavaScript' },
|
||||||
|
order: 1,
|
||||||
|
lessons: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
title: { th: 'JavaScript คืออะไร', en: 'What is JavaScript' },
|
||||||
|
description: { th: 'เรียนรู้ว่า JavaScript คืออะไร', en: 'Learn what JavaScript is' },
|
||||||
|
type: 'VIDEO',
|
||||||
|
order: 1,
|
||||||
|
is_preview: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: { th: 'ตัวแปรและชนิดข้อมูล', en: 'Variables and Data Types' },
|
||||||
|
description: { th: 'เรียนรู้เกี่ยวกับตัวแปร', en: 'Learn about variables' },
|
||||||
|
type: 'VIDEO',
|
||||||
|
order: 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: { th: 'บทที่ 2: ฟังก์ชัน', en: 'Chapter 2: Functions' },
|
||||||
|
description: { th: 'เรียนรู้เกี่ยวกับฟังก์ชัน', en: 'Learn about functions' },
|
||||||
|
order: 2,
|
||||||
|
lessons: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
title: { th: 'การสร้างฟังก์ชัน', en: 'Creating Functions' },
|
||||||
|
description: { th: 'เรียนรู้วิธีสร้างฟังก์ชัน', en: 'Learn how to create functions' },
|
||||||
|
type: 'VIDEO',
|
||||||
|
order: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Database seeding completed!');
|
||||||
|
console.log('\n📊 Summary:');
|
||||||
|
console.log(`- Roles: ${roles.length}`);
|
||||||
|
console.log(`- Users: 3 (admin, instructor, student)`);
|
||||||
|
console.log(`- Categories: ${categories.length}`);
|
||||||
|
console.log(`- Courses: 1`);
|
||||||
|
console.log('\n🔑 Test Credentials:');
|
||||||
|
console.log('Admin: admin / admin123');
|
||||||
|
console.log('Instructor: instructor / admin123');
|
||||||
|
console.log('Student: student / admin123');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('❌ Error seeding database:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
109
Backend/src/app.js
Normal file
109
Backend/src/app.js
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
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;
|
||||||
51
Backend/src/config/database.js
Normal file
51
Backend/src/config/database.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
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;
|
||||||
45
Backend/src/config/logger.js
Normal file
45
Backend/src/config/logger.js
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
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;
|
||||||
36
Backend/src/config/redis.js
Normal file
36
Backend/src/config/redis.js
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
216
Backend/src/config/swagger.js
Normal file
216
Backend/src/config/swagger.js
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
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;
|
||||||
82
Backend/src/controllers/auth.controller.js
Normal file
82
Backend/src/controllers/auth.controller.js
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
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();
|
||||||
91
Backend/src/middleware/auth.js
Normal file
91
Backend/src/middleware/auth.js
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
85
Backend/src/middleware/errorHandler.js
Normal file
85
Backend/src/middleware/errorHandler.js
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
200
Backend/src/routes/auth.routes.js
Normal file
200
Backend/src/routes/auth.routes.js
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
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;
|
||||||
65
Backend/src/server.js
Normal file
65
Backend/src/server.js
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
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();
|
||||||
152
Backend/src/services/auth.service.js
Normal file
152
Backend/src/services/auth.service.js
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
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();
|
||||||
57
Backend/src/utils/jwt.js
Normal file
57
Backend/src/utils/jwt.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue