init Backend

This commit is contained in:
JakkrapartXD 2026-01-08 06:51:33 +00:00
parent 08a4e0d8fa
commit 924000b084
29 changed files with 10080 additions and 13 deletions

109
Backend/src/app.js Normal file
View 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;

View 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;

View 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;

View 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,
};

View 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;

View 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();

View 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,
};

View 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,
};

View 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
View 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();

View 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
View 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,
};