migration to typescript
This commit is contained in:
parent
924000b084
commit
9fde77468a
41 changed files with 11952 additions and 10164 deletions
|
|
@ -1,109 +0,0 @@
|
|||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const cors = require('cors');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const swaggerUi = require('swagger-ui-express');
|
||||
const logger = require('./config/logger');
|
||||
const { errorHandler, notFoundHandler } = require('./middleware/errorHandler');
|
||||
const swaggerSpec = require('./config/swagger');
|
||||
|
||||
// Import routes
|
||||
const authRoutes = require('./routes/auth.routes');
|
||||
|
||||
const app = express();
|
||||
|
||||
// ============================================
|
||||
// Middleware
|
||||
// ============================================
|
||||
|
||||
// Security headers
|
||||
app.use(helmet());
|
||||
|
||||
// CORS
|
||||
const corsOptions = {
|
||||
origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:3000'],
|
||||
credentials: true,
|
||||
};
|
||||
app.use(cors(corsOptions));
|
||||
|
||||
// Body parser
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100,
|
||||
message: {
|
||||
error: {
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
message: 'Too many requests, please try again later',
|
||||
},
|
||||
},
|
||||
});
|
||||
app.use('/api/', limiter);
|
||||
|
||||
// Request logging
|
||||
app.use((req, res, next) => {
|
||||
logger.http(`${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Routes
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /health:
|
||||
* get:
|
||||
* summary: Health check endpoint
|
||||
* tags: [Health]
|
||||
* description: Returns the health status of the API server
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server is healthy
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: ok
|
||||
* timestamp:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* uptime:
|
||||
* type: number
|
||||
* example: 123.456
|
||||
* environment:
|
||||
* type: string
|
||||
* example: development
|
||||
*/
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: process.env.NODE_ENV,
|
||||
});
|
||||
});
|
||||
|
||||
// API Documentation
|
||||
const swaggerOptions = {
|
||||
customCss: '.swagger-ui .topbar { display: none }',
|
||||
customSiteTitle: 'E-Learning API Documentation',
|
||||
};
|
||||
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, swaggerOptions));
|
||||
|
||||
// API routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.use(notFoundHandler);
|
||||
|
||||
// Error handler (must be last)
|
||||
app.use(errorHandler);
|
||||
|
||||
module.exports = app;
|
||||
83
Backend/src/app.ts
Normal file
83
Backend/src/app.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import express, { Application, Request, Response } from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import { config } from './config';
|
||||
import { logger } from './config/logger';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
import { RegisterRoutes } from './routes/routes';
|
||||
|
||||
export function createApp(): Application {
|
||||
const app = express();
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
app.use(cors({
|
||||
origin: config.cors.origin,
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: config.rateLimit.windowMs,
|
||||
max: config.rateLimit.maxRequests,
|
||||
message: {
|
||||
error: {
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
message: 'Too many requests, please try again later'
|
||||
}
|
||||
}
|
||||
});
|
||||
app.use('/api/', limiter);
|
||||
|
||||
// Body parsing
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Request logging
|
||||
app.use((req: Request, res: Response, next) => {
|
||||
logger.info('Incoming request', {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
ip: req.ip
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// Swagger documentation
|
||||
try {
|
||||
const swaggerDocument = require('../public/swagger.json');
|
||||
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
|
||||
logger.info('Swagger documentation available at /api-docs');
|
||||
} catch (error) {
|
||||
logger.warn('Swagger documentation not available. Run "npm run tsoa:gen" to generate it.');
|
||||
}
|
||||
|
||||
// Simple health check endpoint (not using TSOA)
|
||||
app.get('/health', (req: Request, res: Response) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime()
|
||||
});
|
||||
});
|
||||
|
||||
// Register TSOA routes
|
||||
RegisterRoutes(app);
|
||||
|
||||
// 404 handler
|
||||
app.use((req: Request, res: Response) => {
|
||||
res.status(404).json({
|
||||
error: {
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Route not found'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Error handler (must be last)
|
||||
app.use(errorHandler);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
const { PrismaClient } = require('@prisma/client');
|
||||
const logger = require('./logger');
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
log: [
|
||||
{
|
||||
emit: 'event',
|
||||
level: 'query',
|
||||
},
|
||||
{
|
||||
emit: 'event',
|
||||
level: 'error',
|
||||
},
|
||||
{
|
||||
emit: 'event',
|
||||
level: 'info',
|
||||
},
|
||||
{
|
||||
emit: 'event',
|
||||
level: 'warn',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Log Prisma queries in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
prisma.$on('query', (e) => {
|
||||
logger.debug(`Query: ${e.query}`);
|
||||
logger.debug(`Duration: ${e.duration}ms`);
|
||||
});
|
||||
}
|
||||
|
||||
prisma.$on('error', (e) => {
|
||||
logger.error(`Prisma Error: ${e.message}`);
|
||||
});
|
||||
|
||||
prisma.$on('warn', (e) => {
|
||||
logger.warn(`Prisma Warning: ${e.message}`);
|
||||
});
|
||||
|
||||
// Test connection
|
||||
prisma.$connect()
|
||||
.then(() => {
|
||||
logger.info('✅ Database connected successfully');
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('❌ Database connection failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
module.exports = prisma;
|
||||
40
Backend/src/config/database.ts
Normal file
40
Backend/src/config/database.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
import { logger } from './logger';
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
log: [
|
||||
{
|
||||
emit: 'event',
|
||||
level: 'query',
|
||||
},
|
||||
{
|
||||
emit: 'event',
|
||||
level: 'error',
|
||||
},
|
||||
{
|
||||
emit: 'event',
|
||||
level: 'warn',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Log queries in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
prisma.$on('query' as never, (e: any) => {
|
||||
logger.debug('Prisma Query', {
|
||||
query: e.query,
|
||||
params: e.params,
|
||||
duration: `${e.duration}ms`
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
prisma.$on('error' as never, (e: any) => {
|
||||
logger.error('Prisma Error', { error: e });
|
||||
});
|
||||
|
||||
prisma.$on('warn' as never, (e: any) => {
|
||||
logger.warn('Prisma Warning', { warning: e });
|
||||
});
|
||||
|
||||
export { prisma };
|
||||
75
Backend/src/config/index.ts
Normal file
75
Backend/src/config/index.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
// Validate required environment variables
|
||||
const requiredEnvVars = [
|
||||
'DATABASE_URL',
|
||||
'JWT_SECRET',
|
||||
'PORT'
|
||||
];
|
||||
|
||||
requiredEnvVars.forEach(key => {
|
||||
if (!process.env[key]) {
|
||||
throw new Error(`Missing required environment variable: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
export const config = {
|
||||
// Application
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
port: parseInt(process.env.PORT || '4000', 10),
|
||||
appUrl: process.env.APP_URL || 'http://localhost:4000',
|
||||
|
||||
// Database
|
||||
databaseUrl: process.env.DATABASE_URL!,
|
||||
|
||||
// Redis
|
||||
redis: {
|
||||
url: process.env.REDIS_URL || 'redis://localhost:6379',
|
||||
password: process.env.REDIS_PASSWORD
|
||||
},
|
||||
|
||||
// MinIO/S3
|
||||
s3: {
|
||||
endpoint: process.env.S3_ENDPOINT || 'http://localhost:9000',
|
||||
accessKey: process.env.S3_ACCESS_KEY || 'minioadmin',
|
||||
secretKey: process.env.S3_SECRET_KEY || 'minioadmin',
|
||||
bucket: process.env.S3_BUCKET || 'e-learning',
|
||||
useSSL: process.env.S3_USE_SSL === 'true'
|
||||
},
|
||||
|
||||
// JWT
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET!,
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d'
|
||||
},
|
||||
|
||||
// Email
|
||||
smtp: {
|
||||
host: process.env.SMTP_HOST || 'localhost',
|
||||
port: parseInt(process.env.SMTP_PORT || '1025', 10),
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
from: process.env.SMTP_FROM || 'noreply@elearning.local'
|
||||
},
|
||||
|
||||
// File Upload
|
||||
upload: {
|
||||
maxVideoSize: parseInt(process.env.MAX_VIDEO_SIZE || '524288000', 10), // 500MB
|
||||
maxAttachmentSize: parseInt(process.env.MAX_ATTACHMENT_SIZE || '104857600', 10), // 100MB
|
||||
maxAttachmentsPerLesson: parseInt(process.env.MAX_ATTACHMENTS_PER_LESSON || '10', 10)
|
||||
},
|
||||
|
||||
// CORS
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:3000'
|
||||
},
|
||||
|
||||
// Rate Limiting
|
||||
rateLimit: {
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
|
||||
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10)
|
||||
}
|
||||
};
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
const winston = require('winston');
|
||||
|
||||
const logLevels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
http: 3,
|
||||
debug: 4,
|
||||
};
|
||||
|
||||
const logColors = {
|
||||
error: 'red',
|
||||
warn: 'yellow',
|
||||
info: 'green',
|
||||
http: 'magenta',
|
||||
debug: 'blue',
|
||||
};
|
||||
|
||||
winston.addColors(logColors);
|
||||
|
||||
const format = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.colorize({ all: true }),
|
||||
winston.format.printf(
|
||||
(info) => `${info.timestamp} ${info.level}: ${info.message}`
|
||||
)
|
||||
);
|
||||
|
||||
const transports = [
|
||||
new winston.transports.Console(),
|
||||
new winston.transports.File({
|
||||
filename: 'logs/error.log',
|
||||
level: 'error',
|
||||
}),
|
||||
new winston.transports.File({ filename: 'logs/all.log' }),
|
||||
];
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
levels: logLevels,
|
||||
format,
|
||||
transports,
|
||||
});
|
||||
|
||||
module.exports = logger;
|
||||
44
Backend/src/config/logger.ts
Normal file
44
Backend/src/config/logger.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import winston from 'winston';
|
||||
import { config } from './index';
|
||||
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.splat(),
|
||||
winston.format.json()
|
||||
);
|
||||
|
||||
const consoleFormat = winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
let msg = `${timestamp} [${level}]: ${message}`;
|
||||
if (Object.keys(meta).length > 0) {
|
||||
msg += ` ${JSON.stringify(meta)}`;
|
||||
}
|
||||
return msg;
|
||||
})
|
||||
);
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: config.nodeEnv === 'production' ? 'info' : 'debug',
|
||||
format: logFormat,
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: consoleFormat
|
||||
}),
|
||||
new winston.transports.File({
|
||||
filename: 'logs/error.log',
|
||||
level: 'error'
|
||||
}),
|
||||
new winston.transports.File({
|
||||
filename: 'logs/combined.log'
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// Don't log to files in development
|
||||
if (config.nodeEnv !== 'production') {
|
||||
logger.clear();
|
||||
logger.add(new winston.transports.Console({ format: consoleFormat }));
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
const redis = require('redis');
|
||||
const logger = require('./logger');
|
||||
|
||||
let redisClient = null;
|
||||
|
||||
async function connectRedis() {
|
||||
try {
|
||||
redisClient = redis.createClient({
|
||||
url: process.env.REDIS_URL || 'redis://localhost:6379',
|
||||
});
|
||||
|
||||
redisClient.on('error', (err) => {
|
||||
logger.error('Redis Client Error:', err);
|
||||
});
|
||||
|
||||
redisClient.on('connect', () => {
|
||||
logger.info('✅ Redis connected successfully');
|
||||
});
|
||||
|
||||
await redisClient.connect();
|
||||
return redisClient;
|
||||
} catch (error) {
|
||||
logger.error('❌ Redis connection failed:', error);
|
||||
// Don't exit, allow app to run without Redis
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getRedisClient() {
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
connectRedis,
|
||||
getRedisClient,
|
||||
};
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
const swaggerJsdoc = require('swagger-jsdoc');
|
||||
|
||||
const options = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'E-Learning Platform API',
|
||||
version: '1.0.0',
|
||||
description: 'API documentation for E-Learning Platform Backend',
|
||||
contact: {
|
||||
name: 'API Support',
|
||||
email: 'support@elearning.local',
|
||||
},
|
||||
license: {
|
||||
name: 'MIT',
|
||||
url: 'https://opensource.org/licenses/MIT',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: process.env.APP_URL || 'http://localhost:4000',
|
||||
description: 'Development server',
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'Enter your JWT token',
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
Error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: {
|
||||
type: 'string',
|
||||
example: 'VALIDATION_ERROR',
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
example: 'Invalid input data',
|
||||
},
|
||||
details: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
field: {
|
||||
type: 'string',
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
User: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'integer',
|
||||
example: 1,
|
||||
},
|
||||
username: {
|
||||
type: 'string',
|
||||
example: 'john_doe',
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
example: 'john@example.com',
|
||||
},
|
||||
role_id: {
|
||||
type: 'integer',
|
||||
example: 3,
|
||||
},
|
||||
is_active: {
|
||||
type: 'boolean',
|
||||
example: true,
|
||||
},
|
||||
created_at: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
},
|
||||
updated_at: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
},
|
||||
role: {
|
||||
$ref: '#/components/schemas/Role',
|
||||
},
|
||||
profile: {
|
||||
$ref: '#/components/schemas/Profile',
|
||||
},
|
||||
},
|
||||
},
|
||||
Role: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'integer',
|
||||
example: 3,
|
||||
},
|
||||
code: {
|
||||
type: 'string',
|
||||
example: 'STUDENT',
|
||||
},
|
||||
name: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
th: {
|
||||
type: 'string',
|
||||
example: 'นักเรียน',
|
||||
},
|
||||
en: {
|
||||
type: 'string',
|
||||
example: 'Student',
|
||||
},
|
||||
},
|
||||
},
|
||||
description: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
th: {
|
||||
type: 'string',
|
||||
},
|
||||
en: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Profile: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'integer',
|
||||
},
|
||||
user_id: {
|
||||
type: 'integer',
|
||||
},
|
||||
first_name: {
|
||||
type: 'string',
|
||||
example: 'John',
|
||||
},
|
||||
last_name: {
|
||||
type: 'string',
|
||||
example: 'Doe',
|
||||
},
|
||||
phone: {
|
||||
type: 'string',
|
||||
example: '+66812345678',
|
||||
},
|
||||
avatar_url: {
|
||||
type: 'string',
|
||||
format: 'uri',
|
||||
},
|
||||
bio: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
th: {
|
||||
type: 'string',
|
||||
},
|
||||
en: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
AuthResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user: {
|
||||
$ref: '#/components/schemas/User',
|
||||
},
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
},
|
||||
refreshToken: {
|
||||
type: 'string',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: [
|
||||
{
|
||||
name: 'Authentication',
|
||||
description: 'User authentication and authorization endpoints',
|
||||
},
|
||||
{
|
||||
name: 'Health',
|
||||
description: 'System health check endpoints',
|
||||
},
|
||||
],
|
||||
},
|
||||
apis: ['./src/routes/*.js', './src/app.js'],
|
||||
};
|
||||
|
||||
const swaggerSpec = swaggerJsdoc(options);
|
||||
|
||||
module.exports = swaggerSpec;
|
||||
128
Backend/src/controllers/AuthController.ts
Normal file
128
Backend/src/controllers/AuthController.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { Body, Post, Route, Tags, SuccessResponse, Response, Example } from 'tsoa';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import {
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
RefreshTokenRequest,
|
||||
LoginResponse,
|
||||
RegisterResponse,
|
||||
RefreshTokenResponse
|
||||
} from '../types/auth.types';
|
||||
import { loginSchema, registerSchema, refreshTokenSchema } from '../validators/auth.validator';
|
||||
import { ValidationError } from '../middleware/errorHandler';
|
||||
|
||||
@Route('api/auth')
|
||||
@Tags('Authentication')
|
||||
export class AuthController {
|
||||
private authService = new AuthService();
|
||||
|
||||
/**
|
||||
* User login
|
||||
* @summary Login with username and password
|
||||
* @param body Login credentials
|
||||
* @returns JWT token and user information
|
||||
*/
|
||||
@Post('login')
|
||||
@SuccessResponse('200', 'Login successful')
|
||||
@Response('401', 'Invalid credentials')
|
||||
@Example<LoginResponse>({
|
||||
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'admin@elearning.local',
|
||||
role: {
|
||||
code: 'ADMIN',
|
||||
name: {
|
||||
th: 'ผู้ดูแลระบบ',
|
||||
en: 'Administrator'
|
||||
}
|
||||
},
|
||||
profile: {
|
||||
prefix: {
|
||||
th: 'นาย',
|
||||
en: 'Mr.'
|
||||
},
|
||||
first_name: 'Admin',
|
||||
last_name: 'User',
|
||||
avatar_url: undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
public async login(@Body() body: LoginRequest): Promise<LoginResponse> {
|
||||
// Validate input
|
||||
const { error } = loginSchema.validate(body);
|
||||
if (error) {
|
||||
throw new ValidationError(error.details[0].message);
|
||||
}
|
||||
|
||||
return await this.authService.login(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* User registration
|
||||
* @summary Register a new student account
|
||||
* @param body Registration data
|
||||
* @returns Created user information
|
||||
*/
|
||||
@Post('register')
|
||||
@SuccessResponse('201', 'Registration successful')
|
||||
@Response('400', 'Validation error')
|
||||
@Response('409', 'Username or email already exists')
|
||||
@Example<RegisterResponse>({
|
||||
user: {
|
||||
id: 4,
|
||||
username: 'newstudent',
|
||||
email: 'student@example.com',
|
||||
role: {
|
||||
code: 'STUDENT',
|
||||
name: {
|
||||
th: 'นักเรียน',
|
||||
en: 'Student'
|
||||
}
|
||||
},
|
||||
profile: {
|
||||
prefix: {
|
||||
th: 'นาย',
|
||||
en: 'Mr.'
|
||||
},
|
||||
first_name: 'John',
|
||||
last_name: 'Doe'
|
||||
}
|
||||
},
|
||||
message: 'Registration successful'
|
||||
})
|
||||
public async register(@Body() body: RegisterRequest): Promise<RegisterResponse> {
|
||||
// Validate input
|
||||
const { error } = registerSchema.validate(body);
|
||||
if (error) {
|
||||
throw new ValidationError(error.details[0].message);
|
||||
}
|
||||
|
||||
return await this.authService.register(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
* @summary Get a new access token using refresh token
|
||||
* @param body Refresh token
|
||||
* @returns New access token and refresh token
|
||||
*/
|
||||
@Post('refresh')
|
||||
@SuccessResponse('200', 'Token refreshed')
|
||||
@Response('401', 'Invalid or expired refresh token')
|
||||
@Example<RefreshTokenResponse>({
|
||||
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
||||
})
|
||||
public async refreshToken(@Body() body: RefreshTokenRequest): Promise<RefreshTokenResponse> {
|
||||
// Validate input
|
||||
const { error } = refreshTokenSchema.validate(body);
|
||||
if (error) {
|
||||
throw new ValidationError(error.details[0].message);
|
||||
}
|
||||
|
||||
return await this.authService.refreshToken(body.refreshToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
const authService = require('../services/auth.service');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
class AuthController {
|
||||
/**
|
||||
* Register a new user
|
||||
* POST /api/auth/register
|
||||
*/
|
||||
async register(req, res, next) {
|
||||
try {
|
||||
const { username, email, password } = req.body;
|
||||
|
||||
const result = await authService.register({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
logger.info(`User registered: ${username}`);
|
||||
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login user
|
||||
* POST /api/auth/login
|
||||
*/
|
||||
async login(req, res, next) {
|
||||
try {
|
||||
const { username, email, password } = req.body;
|
||||
|
||||
const result = await authService.login({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
logger.info(`User logged in: ${result.user.username}`);
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user profile
|
||||
* GET /api/auth/me
|
||||
*/
|
||||
async getProfile(req, res, next) {
|
||||
try {
|
||||
const user = await authService.getProfile(req.user.id);
|
||||
|
||||
res.status(200).json(user);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user
|
||||
* POST /api/auth/logout
|
||||
*/
|
||||
async logout(req, res, next) {
|
||||
try {
|
||||
// In a real implementation, you would invalidate the token
|
||||
// For now, just return success
|
||||
logger.info(`User logged out: ${req.user.username}`);
|
||||
|
||||
res.status(200).json({
|
||||
message: 'Logged out successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AuthController();
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
const { verifyToken, extractToken } = require('../utils/jwt');
|
||||
const prisma = require('../config/database');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
/**
|
||||
* Authentication middleware
|
||||
* Verifies JWT token and attaches user to request
|
||||
*/
|
||||
async function authenticate(req, res, next) {
|
||||
try {
|
||||
const token = extractToken(req.headers.authorization);
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Authentication required',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const decoded = verifyToken(token);
|
||||
|
||||
// Fetch user from database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
include: { role: true },
|
||||
});
|
||||
|
||||
if (!user || !user.is_active) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Invalid or inactive user',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Attach user to request
|
||||
req.user = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Authentication error:', error.message);
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Invalid or expired token',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization middleware
|
||||
* Checks if user has required role
|
||||
* @param {string[]} allowedRoles - Array of allowed role codes
|
||||
*/
|
||||
function authorize(...allowedRoles) {
|
||||
return (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Authentication required',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!allowedRoles.includes(req.user.role.code)) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Insufficient permissions',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authenticate,
|
||||
authorize,
|
||||
};
|
||||
92
Backend/src/middleware/authentication.ts
Normal file
92
Backend/src/middleware/authentication.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { config } from '../config';
|
||||
import { JWTPayload } from '../types';
|
||||
import { logger } from '../config/logger';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: JWTPayload;
|
||||
}
|
||||
|
||||
export async function expressAuthentication(
|
||||
request: Request,
|
||||
securityName: string,
|
||||
scopes?: string[]
|
||||
): Promise<JWTPayload> {
|
||||
if (securityName === 'jwt') {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
throw new Error('No token provided');
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as JWTPayload;
|
||||
|
||||
// Check if user has required role
|
||||
if (scopes && scopes.length > 0) {
|
||||
if (!scopes.includes(decoded.roleCode)) {
|
||||
throw new Error('Insufficient permissions');
|
||||
}
|
||||
}
|
||||
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
logger.error('JWT verification failed', { error });
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unknown security name');
|
||||
}
|
||||
|
||||
export function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'No token provided'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as JWTPayload;
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Authentication failed', { error });
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Invalid token'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function authorize(...roles: string[]) {
|
||||
return (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Not authenticated'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (roles.length > 0 && !roles.includes(req.user.roleCode)) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Insufficient permissions'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
const logger = require('../config/logger');
|
||||
|
||||
/**
|
||||
* Global error handler middleware
|
||||
*/
|
||||
function errorHandler(err, req, res, next) {
|
||||
logger.error('Error:', {
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
});
|
||||
|
||||
// Prisma errors
|
||||
if (err.code && err.code.startsWith('P')) {
|
||||
if (err.code === 'P2002') {
|
||||
return res.status(409).json({
|
||||
error: {
|
||||
code: 'DUPLICATE_ENTRY',
|
||||
message: 'A record with this value already exists',
|
||||
field: err.meta?.target?.[0],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (err.code === 'P2025') {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Record not found',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validation errors
|
||||
if (err.isJoi || err.name === 'ValidationError') {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: err.message,
|
||||
details: err.details?.map((d) => ({
|
||||
field: d.path.join('.'),
|
||||
message: d.message,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// JWT errors
|
||||
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Invalid or expired token',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Default error
|
||||
const statusCode = err.statusCode || 500;
|
||||
res.status(statusCode).json({
|
||||
error: {
|
||||
code: err.code || 'INTERNAL_ERROR',
|
||||
message: err.message || 'An unexpected error occurred',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 404 Not Found handler
|
||||
*/
|
||||
function notFoundHandler(req, res) {
|
||||
res.status(404).json({
|
||||
error: {
|
||||
code: 'NOT_FOUND',
|
||||
message: `Route ${req.method} ${req.url} not found`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
errorHandler,
|
||||
notFoundHandler,
|
||||
};
|
||||
132
Backend/src/middleware/errorHandler.ts
Normal file
132
Backend/src/middleware/errorHandler.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ValidateError } from 'tsoa';
|
||||
import { logger } from '../config/logger';
|
||||
|
||||
export function errorHandler(
|
||||
err: any,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Response | void {
|
||||
// TSOA Validation Error
|
||||
if (err instanceof ValidateError) {
|
||||
logger.warn('Validation error', {
|
||||
fields: err.fields,
|
||||
path: req.path
|
||||
});
|
||||
|
||||
return res.status(422).json({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Validation failed',
|
||||
details: err.fields
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// JWT Errors
|
||||
if (err.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Invalid token'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
code: 'TOKEN_EXPIRED',
|
||||
message: 'Token has expired'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Prisma Errors
|
||||
if (err.code === 'P2002') {
|
||||
return res.status(409).json({
|
||||
error: {
|
||||
code: 'DUPLICATE_ENTRY',
|
||||
message: 'A record with this value already exists',
|
||||
details: err.meta
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (err.code === 'P2025') {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Record not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Custom Application Errors
|
||||
if (err.statusCode) {
|
||||
return res.status(err.statusCode).json({
|
||||
error: {
|
||||
code: err.code || 'APPLICATION_ERROR',
|
||||
message: err.message,
|
||||
details: err.details
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Log unexpected errors
|
||||
logger.error('Unexpected error', {
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
path: req.path,
|
||||
method: req.method
|
||||
});
|
||||
|
||||
// Default error
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'An unexpected error occurred'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export class ApplicationError extends Error {
|
||||
constructor(
|
||||
public statusCode: number,
|
||||
public code: string,
|
||||
message: string,
|
||||
public details?: any
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApplicationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends ApplicationError {
|
||||
constructor(message: string, details?: any) {
|
||||
super(400, 'VALIDATION_ERROR', message, details);
|
||||
this.name = 'ValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends ApplicationError {
|
||||
constructor(message = 'Unauthorized') {
|
||||
super(401, 'UNAUTHORIZED', message);
|
||||
this.name = 'UnauthorizedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends ApplicationError {
|
||||
constructor(message = 'Forbidden') {
|
||||
super(403, 'FORBIDDEN', message);
|
||||
this.name = 'ForbiddenError';
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends ApplicationError {
|
||||
constructor(message = 'Resource not found') {
|
||||
super(404, 'NOT_FOUND', message);
|
||||
this.name = 'NotFoundError';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
const express = require('express');
|
||||
const authController = require('../controllers/auth.controller');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const Joi = require('joi');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Validation schemas
|
||||
const registerSchema = Joi.object({
|
||||
username: Joi.string().min(3).max(50).required(),
|
||||
email: Joi.string().email().required(),
|
||||
password: Joi.string().min(6).required(),
|
||||
});
|
||||
|
||||
const loginSchema = Joi.object({
|
||||
username: Joi.string().optional(),
|
||||
email: Joi.string().email().optional(),
|
||||
password: Joi.string().required(),
|
||||
}).or('username', 'email');
|
||||
|
||||
// Validation middleware
|
||||
function validate(schema) {
|
||||
return (req, res, next) => {
|
||||
const { error, value } = schema.validate(req.body);
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: error.details[0].message,
|
||||
details: error.details.map((d) => ({
|
||||
field: d.path.join('.'),
|
||||
message: d.message,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
req.body = value;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/auth/register:
|
||||
* post:
|
||||
* summary: Register a new user
|
||||
* tags: [Authentication]
|
||||
* description: Create a new user account
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - username
|
||||
* - email
|
||||
* - password
|
||||
* properties:
|
||||
* username:
|
||||
* type: string
|
||||
* minLength: 3
|
||||
* maxLength: 50
|
||||
* example: john_doe
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* example: john@example.com
|
||||
* password:
|
||||
* type: string
|
||||
* minLength: 6
|
||||
* example: password123
|
||||
* responses:
|
||||
* 201:
|
||||
* description: User registered successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/AuthResponse'
|
||||
* 400:
|
||||
* description: Validation error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 409:
|
||||
* description: Username or email already exists
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.post('/register', validate(registerSchema), authController.register);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/auth/login:
|
||||
* post:
|
||||
* summary: Login user
|
||||
* tags: [Authentication]
|
||||
* description: Authenticate user and return JWT tokens
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - password
|
||||
* properties:
|
||||
* username:
|
||||
* type: string
|
||||
* example: admin
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* example: admin@elearning.local
|
||||
* password:
|
||||
* type: string
|
||||
* example: admin123
|
||||
* oneOf:
|
||||
* - required: [username]
|
||||
* - required: [email]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login successful
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/AuthResponse'
|
||||
* 401:
|
||||
* description: Invalid credentials
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Account is inactive
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.post('/login', validate(loginSchema), authController.login);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/auth/me:
|
||||
* get:
|
||||
* summary: Get current user profile
|
||||
* tags: [Authentication]
|
||||
* description: Retrieve the authenticated user's profile information
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: User profile retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/User'
|
||||
* 401:
|
||||
* description: Unauthorized - Invalid or missing token
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.get('/me', authenticate, authController.getProfile);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/auth/logout:
|
||||
* post:
|
||||
* summary: Logout user
|
||||
* tags: [Authentication]
|
||||
* description: Logout the authenticated user
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Logout successful
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: Logged out successfully
|
||||
* 401:
|
||||
* description: Unauthorized - Invalid or missing token
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.post('/logout', authenticate, authController.logout);
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
require('dotenv').config();
|
||||
const app = require('./app');
|
||||
const logger = require('./config/logger');
|
||||
const { connectRedis } = require('./config/redis');
|
||||
|
||||
const PORT = process.env.PORT || 4000;
|
||||
|
||||
// Validate required environment variables
|
||||
const requiredEnvVars = ['DATABASE_URL', 'JWT_SECRET'];
|
||||
requiredEnvVars.forEach((key) => {
|
||||
if (!process.env[key]) {
|
||||
logger.error(`Missing required environment variable: ${key}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Start server
|
||||
async function startServer() {
|
||||
try {
|
||||
// Connect to Redis (optional)
|
||||
await connectRedis();
|
||||
|
||||
// Create logs directory
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync('logs')) {
|
||||
fs.mkdirSync('logs');
|
||||
}
|
||||
|
||||
// Start listening
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`🚀 Server running on port ${PORT}`);
|
||||
logger.info(`📝 Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
logger.info(`🔗 Health check: http://localhost:${PORT}/health`);
|
||||
logger.info(`🔐 API endpoint: http://localhost:${PORT}/api`);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error('Uncaught Exception:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('SIGTERM received, shutting down gracefully');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('SIGINT received, shutting down gracefully');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
startServer();
|
||||
52
Backend/src/server.ts
Normal file
52
Backend/src/server.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import 'reflect-metadata';
|
||||
import { createApp } from './app';
|
||||
import { config } from './config';
|
||||
import { logger } from './config/logger';
|
||||
import { prisma } from './config/database';
|
||||
|
||||
async function startServer() {
|
||||
try {
|
||||
// Test database connection
|
||||
await prisma.$connect();
|
||||
logger.info('Database connected successfully');
|
||||
|
||||
// Create Express app
|
||||
const app = createApp();
|
||||
|
||||
// Start server
|
||||
const server = app.listen(config.port, () => {
|
||||
logger.info(`Server running on ${config.appUrl}`);
|
||||
logger.info(`Environment: ${config.nodeEnv}`);
|
||||
logger.info(`Swagger docs available at ${config.appUrl}/api-docs`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
const gracefulShutdown = async (signal: string) => {
|
||||
logger.info(`${signal} received, shutting down gracefully`);
|
||||
|
||||
server.close(async () => {
|
||||
logger.info('HTTP server closed');
|
||||
|
||||
await prisma.$disconnect();
|
||||
logger.info('Database disconnected');
|
||||
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Force shutdown after 10 seconds
|
||||
setTimeout(() => {
|
||||
logger.error('Forced shutdown after timeout');
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to start server', { error });
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
startServer();
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
const bcrypt = require('bcrypt');
|
||||
const prisma = require('../config/database');
|
||||
const { generateAccessToken, generateRefreshToken } = require('../utils/jwt');
|
||||
|
||||
class AuthService {
|
||||
/**
|
||||
* Register a new user
|
||||
* @param {Object} data - User registration data
|
||||
* @returns {Promise<Object>} User and tokens
|
||||
*/
|
||||
async register({ username, email, password, role_id = 3 }) {
|
||||
// Check if user exists
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ username }, { email }],
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
const field = existingUser.username === username ? 'username' : 'email';
|
||||
throw Object.assign(new Error(`This ${field} is already taken`), {
|
||||
statusCode: 409,
|
||||
code: 'DUPLICATE_ENTRY',
|
||||
});
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
role_id,
|
||||
},
|
||||
include: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = generateAccessToken({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: user.role.code,
|
||||
});
|
||||
|
||||
const refreshToken = generateRefreshToken({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// Remove password from response
|
||||
delete user.password;
|
||||
|
||||
return {
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Login user
|
||||
* @param {Object} credentials - Login credentials
|
||||
* @returns {Promise<Object>} User and tokens
|
||||
*/
|
||||
async login({ username, email, password }) {
|
||||
// Find user
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ username }, { email }],
|
||||
},
|
||||
include: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw Object.assign(new Error('Invalid credentials'), {
|
||||
statusCode: 401,
|
||||
code: 'INVALID_CREDENTIALS',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if (!user.is_active) {
|
||||
throw Object.assign(new Error('Account is inactive'), {
|
||||
statusCode: 403,
|
||||
code: 'ACCOUNT_INACTIVE',
|
||||
});
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw Object.assign(new Error('Invalid credentials'), {
|
||||
statusCode: 401,
|
||||
code: 'INVALID_CREDENTIALS',
|
||||
});
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = generateAccessToken({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: user.role.code,
|
||||
});
|
||||
|
||||
const refreshToken = generateRefreshToken({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// Remove password from response
|
||||
delete user.password;
|
||||
|
||||
return {
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user profile
|
||||
* @param {number} userId - User ID
|
||||
* @returns {Promise<Object>} User profile
|
||||
*/
|
||||
async getProfile(userId) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
role: true,
|
||||
profile: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw Object.assign(new Error('User not found'), {
|
||||
statusCode: 404,
|
||||
code: 'NOT_FOUND',
|
||||
});
|
||||
}
|
||||
|
||||
delete user.password;
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AuthService();
|
||||
217
Backend/src/services/auth.service.ts
Normal file
217
Backend/src/services/auth.service.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { prisma } from '../config/database';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { config } from '../config';
|
||||
import { logger } from '../config/logger';
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import {
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
LoginResponse,
|
||||
RegisterResponse,
|
||||
RefreshTokenResponse,
|
||||
UserResponse
|
||||
} from '../types/auth.types';
|
||||
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
|
||||
|
||||
export class AuthService {
|
||||
/**
|
||||
* User login
|
||||
*/
|
||||
async login(data: LoginRequest): Promise<LoginResponse> {
|
||||
const { username, password } = data;
|
||||
|
||||
// Find user with role and profile
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
include: {
|
||||
role: true,
|
||||
profile: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
logger.warn('Login attempt with invalid username', { username });
|
||||
throw new UnauthorizedError('Invalid username or password');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||
if (!isPasswordValid) {
|
||||
logger.warn('Login attempt with invalid password', { username });
|
||||
throw new UnauthorizedError('Invalid username or password');
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const token = this.generateAccessToken(user.id, user.username, user.email, user.role.code);
|
||||
const refreshToken = this.generateRefreshToken(user.id);
|
||||
|
||||
logger.info('User logged in successfully', { userId: user.id, username: user.username });
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
user: this.formatUserResponse(user)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* User registration
|
||||
*/
|
||||
async register(data: RegisterRequest): Promise<RegisterResponse> {
|
||||
const { username, email, password, first_name, last_name, prefix } = data;
|
||||
|
||||
// Check if username already exists
|
||||
const existingUsername = await prisma.user.findUnique({
|
||||
where: { username }
|
||||
});
|
||||
|
||||
if (existingUsername) {
|
||||
throw new ValidationError('Username already exists');
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
const existingEmail = await prisma.user.findUnique({
|
||||
where: { email }
|
||||
});
|
||||
|
||||
if (existingEmail) {
|
||||
throw new ValidationError('Email already exists');
|
||||
}
|
||||
|
||||
// Get STUDENT role
|
||||
const studentRole = await prisma.role.findUnique({
|
||||
where: { code: 'STUDENT' }
|
||||
});
|
||||
|
||||
if (!studentRole) {
|
||||
logger.error('STUDENT role not found in database');
|
||||
throw new Error('System configuration error');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// Create user with profile
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
role_id: studentRole.id,
|
||||
profile: {
|
||||
create: {
|
||||
prefix: prefix ? prefix as Prisma.InputJsonValue : Prisma.JsonNull,
|
||||
first_name,
|
||||
last_name
|
||||
}
|
||||
}
|
||||
},
|
||||
include: {
|
||||
role: true,
|
||||
profile: true
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('New user registered', { userId: user.id, username: user.username });
|
||||
|
||||
return {
|
||||
user: this.formatUserResponse(user),
|
||||
message: 'Registration successful'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
*/
|
||||
async refreshToken(refreshToken: string): Promise<RefreshTokenResponse> {
|
||||
try {
|
||||
// Verify refresh token
|
||||
const decoded = jwt.verify(refreshToken, config.jwt.secret) as { id: number; type: string };
|
||||
|
||||
if (decoded.type !== 'refresh') {
|
||||
throw new UnauthorizedError('Invalid token type');
|
||||
}
|
||||
|
||||
// Get user
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id },
|
||||
include: { role: true }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedError('User not found');
|
||||
}
|
||||
|
||||
// Generate new tokens
|
||||
const newToken = this.generateAccessToken(user.id, user.username, user.email, user.role.code);
|
||||
const newRefreshToken = this.generateRefreshToken(user.id);
|
||||
|
||||
logger.info('Token refreshed', { userId: user.id });
|
||||
|
||||
return {
|
||||
token: newToken,
|
||||
refreshToken: newRefreshToken
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof jwt.JsonWebTokenError) {
|
||||
throw new UnauthorizedError('Invalid refresh token');
|
||||
}
|
||||
if (error instanceof jwt.TokenExpiredError) {
|
||||
throw new UnauthorizedError('Refresh token expired');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access token (JWT)
|
||||
*/
|
||||
private generateAccessToken(id: number, username: string, email: string, roleCode: string): string {
|
||||
return jwt.sign(
|
||||
{
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
roleCode
|
||||
},
|
||||
config.jwt.secret,
|
||||
{ expiresIn: config.jwt.expiresIn } as jwt.SignOptions
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate refresh token
|
||||
*/
|
||||
private generateRefreshToken(id: number): string {
|
||||
return jwt.sign(
|
||||
{
|
||||
id,
|
||||
type: 'refresh'
|
||||
},
|
||||
config.jwt.secret,
|
||||
{ expiresIn: config.jwt.refreshExpiresIn } as jwt.SignOptions
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format user response
|
||||
*/
|
||||
private formatUserResponse(user: any): UserResponse {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: {
|
||||
code: user.role.code,
|
||||
name: user.role.name
|
||||
},
|
||||
profile: user.profile ? {
|
||||
prefix: user.profile.prefix,
|
||||
first_name: user.profile.first_name,
|
||||
last_name: user.profile.last_name,
|
||||
avatar_url: user.profile.avatar_url
|
||||
} : undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
62
Backend/src/types/auth.types.ts
Normal file
62
Backend/src/types/auth.types.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Authentication Request/Response Types
|
||||
*/
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
prefix?: {
|
||||
th?: string;
|
||||
en?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
user: UserResponse;
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
user: UserResponse;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RefreshTokenRequest {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface RefreshTokenResponse {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: {
|
||||
code: string;
|
||||
name: {
|
||||
th: string;
|
||||
en: string;
|
||||
};
|
||||
};
|
||||
profile?: {
|
||||
prefix?: {
|
||||
th?: string;
|
||||
en?: string;
|
||||
};
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
}
|
||||
58
Backend/src/types/index.ts
Normal file
58
Backend/src/types/index.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
export interface MultiLanguageText {
|
||||
th: string;
|
||||
en: string;
|
||||
}
|
||||
|
||||
export interface JWTPayload {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
roleCode: string;
|
||||
}
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: JWTPayload;
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any[];
|
||||
};
|
||||
}
|
||||
|
||||
export enum CourseStatus {
|
||||
DRAFT = 'DRAFT',
|
||||
PENDING = 'PENDING',
|
||||
APPROVED = 'APPROVED',
|
||||
REJECTED = 'REJECTED'
|
||||
}
|
||||
|
||||
export enum LessonType {
|
||||
VIDEO = 'VIDEO',
|
||||
TEXT = 'TEXT',
|
||||
PDF = 'PDF',
|
||||
QUIZ = 'QUIZ'
|
||||
}
|
||||
|
||||
export enum QuizScorePolicy {
|
||||
HIGHEST = 'HIGHEST',
|
||||
LATEST = 'LATEST',
|
||||
AVERAGE = 'AVERAGE'
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
const jwt = require('jsonwebtoken');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
/**
|
||||
* Generate JWT access token
|
||||
* @param {Object} payload - Token payload
|
||||
* @returns {string} JWT token
|
||||
*/
|
||||
function generateAccessToken(payload) {
|
||||
return jwt.sign(payload, process.env.JWT_SECRET, {
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JWT refresh token
|
||||
* @param {Object} payload - Token payload
|
||||
* @returns {string} JWT refresh token
|
||||
*/
|
||||
function generateRefreshToken(payload) {
|
||||
return jwt.sign(payload, process.env.JWT_SECRET, {
|
||||
expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify JWT token
|
||||
* @param {string} token - JWT token
|
||||
* @returns {Object} Decoded token payload
|
||||
*/
|
||||
function verifyToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, process.env.JWT_SECRET);
|
||||
} catch (error) {
|
||||
logger.error('Token verification failed:', error.message);
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract token from Authorization header
|
||||
* @param {string} authHeader - Authorization header value
|
||||
* @returns {string|null} Token or null
|
||||
*/
|
||||
function extractToken(authHeader) {
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return null;
|
||||
}
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateAccessToken,
|
||||
generateRefreshToken,
|
||||
verifyToken,
|
||||
extractToken,
|
||||
};
|
||||
76
Backend/src/validators/auth.validator.ts
Normal file
76
Backend/src/validators/auth.validator.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import Joi from 'joi';
|
||||
|
||||
export const loginSchema = Joi.object({
|
||||
username: Joi.string()
|
||||
.min(3)
|
||||
.max(50)
|
||||
.required()
|
||||
.messages({
|
||||
'string.min': 'Username must be at least 3 characters',
|
||||
'string.max': 'Username must not exceed 50 characters',
|
||||
'any.required': 'Username is required'
|
||||
}),
|
||||
password: Joi.string()
|
||||
.min(6)
|
||||
.required()
|
||||
.messages({
|
||||
'string.min': 'Password must be at least 6 characters',
|
||||
'any.required': 'Password is required'
|
||||
})
|
||||
});
|
||||
|
||||
export const registerSchema = Joi.object({
|
||||
username: Joi.string()
|
||||
.min(3)
|
||||
.max(50)
|
||||
.pattern(/^[a-zA-Z0-9_]+$/)
|
||||
.required()
|
||||
.messages({
|
||||
'string.pattern.base': 'Username can only contain letters, numbers, and underscores',
|
||||
'string.min': 'Username must be at least 3 characters',
|
||||
'string.max': 'Username must not exceed 50 characters',
|
||||
'any.required': 'Username is required'
|
||||
}),
|
||||
email: Joi.string()
|
||||
.email()
|
||||
.required()
|
||||
.messages({
|
||||
'string.email': 'Please provide a valid email address',
|
||||
'any.required': 'Email is required'
|
||||
}),
|
||||
password: Joi.string()
|
||||
.min(6)
|
||||
.max(100)
|
||||
.required()
|
||||
.messages({
|
||||
'string.min': 'Password must be at least 6 characters',
|
||||
'string.max': 'Password must not exceed 100 characters',
|
||||
'any.required': 'Password is required'
|
||||
}),
|
||||
first_name: Joi.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.required()
|
||||
.messages({
|
||||
'any.required': 'First name is required'
|
||||
}),
|
||||
last_name: Joi.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.required()
|
||||
.messages({
|
||||
'any.required': 'Last name is required'
|
||||
}),
|
||||
prefix: Joi.object({
|
||||
th: Joi.string().optional(),
|
||||
en: Joi.string().optional()
|
||||
}).optional()
|
||||
});
|
||||
|
||||
export const refreshTokenSchema = Joi.object({
|
||||
refreshToken: Joi.string()
|
||||
.required()
|
||||
.messages({
|
||||
'any.required': 'Refresh token is required'
|
||||
})
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue