add changes

This commit is contained in:
AD2025
2025-12-26 23:56:32 +02:00
parent 410c3d725f
commit e7d26bc981
127 changed files with 36162 additions and 0 deletions

113
config/config.js Normal file
View File

@@ -0,0 +1,113 @@
require('dotenv').config();
/**
* Application Configuration
* Centralized configuration management for all environment variables
*/
const config = {
// Server Configuration
server: {
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT) || 3000,
apiPrefix: process.env.API_PREFIX || '/api',
isDevelopment: (process.env.NODE_ENV || 'development') === 'development',
isProduction: process.env.NODE_ENV === 'production',
isTest: process.env.NODE_ENV === 'test'
},
// Database Configuration
database: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT) || 3306,
name: process.env.DB_NAME || 'interview_quiz_db',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
dialect: process.env.DB_DIALECT || 'mysql',
pool: {
max: parseInt(process.env.DB_POOL_MAX) || 10,
min: parseInt(process.env.DB_POOL_MIN) || 0,
acquire: parseInt(process.env.DB_POOL_ACQUIRE) || 30000,
idle: parseInt(process.env.DB_POOL_IDLE) || 10000
}
},
// JWT Configuration
jwt: {
secret: process.env.JWT_SECRET,
expire: process.env.JWT_EXPIRE || '24h',
algorithm: 'HS256'
},
// Rate Limiting Configuration
rateLimit: {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 900000, // 15 minutes
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100,
message: 'Too many requests from this IP, please try again later.'
},
// CORS Configuration
cors: {
origin: process.env.CORS_ORIGIN || 'http://localhost:4200',
credentials: true
},
// Guest Session Configuration
guest: {
sessionExpireHours: parseInt(process.env.GUEST_SESSION_EXPIRE_HOURS) || 24,
maxQuizzes: parseInt(process.env.GUEST_MAX_QUIZZES) || 3
},
// Logging Configuration
logging: {
level: process.env.LOG_LEVEL || 'info'
},
// Pagination Defaults
pagination: {
defaultLimit: 10,
maxLimit: 100
},
// Security Configuration
security: {
bcryptRounds: 10,
maxLoginAttempts: 5,
lockoutDuration: 15 * 60 * 1000 // 15 minutes
}
};
/**
* Validate critical configuration values
*/
function validateConfig() {
const errors = [];
if (!config.jwt.secret) {
errors.push('JWT_SECRET is not configured');
}
if (!config.database.name) {
errors.push('DB_NAME is not configured');
}
if (config.server.isProduction && !config.database.password) {
errors.push('DB_PASSWORD is required in production');
}
if (errors.length > 0) {
throw new Error(`Configuration errors:\n - ${errors.join('\n - ')}`);
}
return true;
}
// Validate on module load
try {
validateConfig();
} catch (error) {
console.error('❌ Configuration Error:', error.message);
process.exit(1);
}
module.exports = config;

76
config/database.js Normal file
View File

@@ -0,0 +1,76 @@
require('dotenv').config();
module.exports = {
development: {
username: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'interview_quiz_db',
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 3306,
dialect: process.env.DB_DIALECT || 'mysql',
logging: console.log,
pool: {
max: parseInt(process.env.DB_POOL_MAX) || 10,
min: parseInt(process.env.DB_POOL_MIN) || 0,
acquire: parseInt(process.env.DB_POOL_ACQUIRE) || 30000,
idle: parseInt(process.env.DB_POOL_IDLE) || 10000
},
define: {
timestamps: true,
underscored: true,
freezeTableName: false,
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci'
}
},
test: {
username: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME + '_test' || 'interview_quiz_db_test',
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 3306,
dialect: process.env.DB_DIALECT || 'mysql',
logging: false,
pool: {
max: 5,
min: 0,
acquire: 30000,
idle: 10000
},
define: {
timestamps: true,
underscored: true,
freezeTableName: false,
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci'
}
},
production: {
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
host: process.env.DB_HOST,
port: process.env.DB_PORT || 3306,
dialect: process.env.DB_DIALECT || 'mysql',
logging: false,
pool: {
max: parseInt(process.env.DB_POOL_MAX) || 20,
min: parseInt(process.env.DB_POOL_MIN) || 5,
acquire: parseInt(process.env.DB_POOL_ACQUIRE) || 30000,
idle: parseInt(process.env.DB_POOL_IDLE) || 10000
},
define: {
timestamps: true,
underscored: true,
freezeTableName: false,
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci'
},
dialectOptions: {
ssl: {
require: true,
rejectUnauthorized: false
}
}
}
};

74
config/db.js Normal file
View File

@@ -0,0 +1,74 @@
const db = require('../models');
/**
* Test database connection
*/
async function testConnection() {
try {
await db.sequelize.authenticate();
console.log('✅ Database connection verified');
return true;
} catch (error) {
console.error('❌ Database connection failed:', error.message);
return false;
}
}
/**
* Sync all models with database
* WARNING: Use with caution in production
*/
async function syncModels(options = {}) {
try {
await db.sequelize.sync(options);
console.log('✅ Models synchronized with database');
return true;
} catch (error) {
console.error('❌ Model synchronization failed:', error.message);
return false;
}
}
/**
* Close database connection
*/
async function closeConnection() {
try {
await db.sequelize.close();
console.log('✅ Database connection closed');
return true;
} catch (error) {
console.error('❌ Failed to close database connection:', error.message);
return false;
}
}
/**
* Get database statistics
*/
async function getDatabaseStats() {
try {
const [tables] = await db.sequelize.query('SHOW TABLES');
const [version] = await db.sequelize.query('SELECT VERSION() as version');
return {
connected: true,
version: version[0].version,
tables: tables.length,
database: db.sequelize.config.database
};
} catch (error) {
return {
connected: false,
error: error.message
};
}
}
module.exports = {
db,
testConnection,
syncModels,
closeConnection,
getDatabaseStats
};

148
config/logger.js Normal file
View File

@@ -0,0 +1,148 @@
const winston = require('winston');
const DailyRotateFile = require('winston-daily-rotate-file');
const path = require('path');
// Define log format
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()
);
// Console format for development
const consoleFormat = winston.format.combine(
winston.format.colorize(),
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.printf(({ timestamp, level, message, stack }) => {
return stack
? `${timestamp} [${level}]: ${message}\n${stack}`
: `${timestamp} [${level}]: ${message}`;
})
);
// Create logs directory if it doesn't exist
const fs = require('fs');
const logsDir = path.join(__dirname, '../logs');
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir);
}
// Daily rotate file transport for error logs
const errorRotateTransport = new DailyRotateFile({
filename: path.join(logsDir, 'error-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
level: 'error',
maxSize: '20m',
maxFiles: '14d',
format: logFormat
});
// Daily rotate file transport for combined logs
const combinedRotateTransport = new DailyRotateFile({
filename: path.join(logsDir, 'combined-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
maxSize: '20m',
maxFiles: '30d',
format: logFormat
});
// Daily rotate file transport for HTTP logs
const httpRotateTransport = new DailyRotateFile({
filename: path.join(logsDir, 'http-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
maxSize: '20m',
maxFiles: '7d',
format: logFormat
});
// Create the Winston logger
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: logFormat,
defaultMeta: { service: 'interview-quiz-api' },
transports: [
errorRotateTransport,
combinedRotateTransport
],
exceptionHandlers: [
new winston.transports.File({
filename: path.join(logsDir, 'exceptions.log')
})
],
rejectionHandlers: [
new winston.transports.File({
filename: path.join(logsDir, 'rejections.log')
})
]
});
// Add console transport in development
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: consoleFormat
}));
}
// HTTP logger for request logging
const httpLogger = winston.createLogger({
level: 'http',
format: logFormat,
defaultMeta: { service: 'interview-quiz-api' },
transports: [httpRotateTransport]
});
// Stream for Morgan middleware
logger.stream = {
write: (message) => {
httpLogger.http(message.trim());
}
};
// Helper functions for structured logging
logger.logRequest = (req, message) => {
logger.info(message, {
method: req.method,
url: req.originalUrl,
ip: req.ip,
userId: req.user?.id,
userAgent: req.get('user-agent')
});
};
logger.logError = (error, req = null) => {
const errorLog = {
message: error.message,
stack: error.stack,
statusCode: error.statusCode || 500
};
if (req) {
errorLog.method = req.method;
errorLog.url = req.originalUrl;
errorLog.ip = req.ip;
errorLog.userId = req.user?.id;
errorLog.body = req.body;
}
logger.error('Application Error', errorLog);
};
logger.logDatabaseQuery = (query, duration) => {
logger.debug('Database Query', {
query,
duration: `${duration}ms`
});
};
logger.logSecurityEvent = (event, req) => {
logger.warn('Security Event', {
event,
method: req.method,
url: req.originalUrl,
ip: req.ip,
userAgent: req.get('user-agent')
});
};
module.exports = logger;

318
config/redis.js Normal file
View File

@@ -0,0 +1,318 @@
const Redis = require('ioredis');
const logger = require('./logger');
/**
* Redis Connection Configuration
* Supports both single instance and cluster modes
*/
const redisConfig = {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
db: parseInt(process.env.REDIS_DB) || 0,
retryStrategy: (times) => {
// Stop retrying after 3 attempts in development
if (process.env.NODE_ENV === 'development' && times > 3) {
logger.info('Redis unavailable - caching disabled (optional feature)');
return null; // Stop retrying
}
const delay = Math.min(times * 50, 2000);
return delay;
},
maxRetriesPerRequest: 3,
enableReadyCheck: true,
enableOfflineQueue: true,
lazyConnect: true, // Don't connect immediately
connectTimeout: 5000, // Reduced timeout
keepAlive: 30000,
family: 4, // IPv4
// Connection pool settings
minReconnectInterval: 100,
maxReconnectInterval: 3000,
// Reduce logging noise
showFriendlyErrorStack: process.env.NODE_ENV !== 'development'
};
// Create Redis client
let redisClient = null;
let isConnected = false;
try {
redisClient = new Redis(redisConfig);
// Attempt initial connection
redisClient.connect().catch(() => {
// Silently fail if Redis is not available in development
if (process.env.NODE_ENV === 'development') {
logger.info('Redis not available - continuing without cache (optional)');
}
});
// Connection events
redisClient.on('connect', () => {
logger.info('Redis client connecting...');
});
redisClient.on('ready', () => {
isConnected = true;
logger.info('Redis client connected and ready');
});
redisClient.on('error', (err) => {
isConnected = false;
// Only log errors in production or first error
if (process.env.NODE_ENV === 'production' || !errorLogged) {
logger.error('Redis client error:', err.message || err);
errorLogged = true;
}
});
redisClient.on('close', () => {
if (isConnected) {
isConnected = false;
logger.warn('Redis client connection closed');
}
});
redisClient.on('reconnecting', () => {
// Only log once
if (isConnected === false) {
logger.info('Redis client reconnecting...');
}
});
redisClient.on('end', () => {
if (isConnected) {
isConnected = false;
logger.info('Redis connection ended');
}
});
} catch (error) {
logger.error('Failed to create Redis client:', error);
}
// Track if error has been logged
let errorLogged = false;
/**
* Check if Redis is connected
*/
const isRedisConnected = () => {
return isConnected && redisClient && redisClient.status === 'ready';
};
/**
* Get Redis client
*/
const getRedisClient = () => {
if (!isRedisConnected()) {
logger.warn('Redis client not connected');
return null;
}
return redisClient;
};
/**
* Close Redis connection gracefully
*/
const closeRedis = async () => {
if (redisClient) {
await redisClient.quit();
logger.info('Redis connection closed');
}
};
/**
* Cache helper functions
*/
/**
* Get cached data
* @param {string} key - Cache key
* @returns {Promise<any>} - Parsed JSON data or null
*/
const getCache = async (key) => {
try {
if (!isRedisConnected()) {
logger.warn('Redis not connected, cache miss');
return null;
}
const data = await redisClient.get(key);
if (!data) return null;
logger.debug(`Cache hit: ${key}`);
return JSON.parse(data);
} catch (error) {
logger.error(`Cache get error for key ${key}:`, error);
return null;
}
};
/**
* Set cached data
* @param {string} key - Cache key
* @param {any} value - Data to cache
* @param {number} ttl - Time to live in seconds (default: 300 = 5 minutes)
* @returns {Promise<boolean>} - Success status
*/
const setCache = async (key, value, ttl = 300) => {
try {
if (!isRedisConnected()) {
logger.warn('Redis not connected, skipping cache set');
return false;
}
const serialized = JSON.stringify(value);
await redisClient.setex(key, ttl, serialized);
logger.debug(`Cache set: ${key} (TTL: ${ttl}s)`);
return true;
} catch (error) {
logger.error(`Cache set error for key ${key}:`, error);
return false;
}
};
/**
* Delete cached data
* @param {string} key - Cache key or pattern
* @returns {Promise<boolean>} - Success status
*/
const deleteCache = async (key) => {
try {
if (!isRedisConnected()) {
return false;
}
// Support pattern deletion (e.g., "user:*")
if (key.includes('*')) {
const keys = await redisClient.keys(key);
if (keys.length > 0) {
await redisClient.del(...keys);
logger.debug(`Cache deleted: ${keys.length} keys matching ${key}`);
}
} else {
await redisClient.del(key);
logger.debug(`Cache deleted: ${key}`);
}
return true;
} catch (error) {
logger.error(`Cache delete error for key ${key}:`, error);
return false;
}
};
/**
* Clear all cache
* @returns {Promise<boolean>} - Success status
*/
const clearCache = async () => {
try {
if (!isRedisConnected()) {
return false;
}
await redisClient.flushdb();
logger.info('All cache cleared');
return true;
} catch (error) {
logger.error('Cache clear error:', error);
return false;
}
};
/**
* Get multiple keys at once
* @param {string[]} keys - Array of cache keys
* @returns {Promise<object>} - Object with key-value pairs
*/
const getCacheMultiple = async (keys) => {
try {
if (!isRedisConnected() || !keys || keys.length === 0) {
return {};
}
const values = await redisClient.mget(...keys);
const result = {};
keys.forEach((key, index) => {
if (values[index]) {
try {
result[key] = JSON.parse(values[index]);
} catch (err) {
result[key] = null;
}
} else {
result[key] = null;
}
});
return result;
} catch (error) {
logger.error('Cache mget error:', error);
return {};
}
};
/**
* Increment a counter
* @param {string} key - Cache key
* @param {number} increment - Amount to increment (default: 1)
* @param {number} ttl - Time to live in seconds (optional)
* @returns {Promise<number>} - New value
*/
const incrementCache = async (key, increment = 1, ttl = null) => {
try {
if (!isRedisConnected()) {
return 0;
}
const newValue = await redisClient.incrby(key, increment);
if (ttl) {
await redisClient.expire(key, ttl);
}
return newValue;
} catch (error) {
logger.error(`Cache increment error for key ${key}:`, error);
return 0;
}
};
/**
* Check if key exists
* @param {string} key - Cache key
* @returns {Promise<boolean>} - Exists status
*/
const cacheExists = async (key) => {
try {
if (!isRedisConnected()) {
return false;
}
const exists = await redisClient.exists(key);
return exists === 1;
} catch (error) {
logger.error(`Cache exists error for key ${key}:`, error);
return false;
}
};
module.exports = {
redisClient,
isRedisConnected,
getRedisClient,
closeRedis,
getCache,
setCache,
deleteCache,
clearCache,
getCacheMultiple,
incrementCache,
cacheExists
};

348
config/swagger.js Normal file
View File

@@ -0,0 +1,348 @@
const swaggerJsdoc = require('swagger-jsdoc');
const options = {
definition: {
openapi: '3.0.0',
info: {
title: 'Interview Quiz Application API',
version: '1.0.0',
description: 'Comprehensive API documentation for the Interview Quiz Application. This API provides endpoints for user authentication, quiz management, guest sessions, bookmarks, and admin operations.',
contact: {
name: 'API Support',
email: 'support@interviewquiz.com'
},
license: {
name: 'MIT',
url: 'https://opensource.org/licenses/MIT'
}
},
servers: [
{
url: 'http://localhost:3000/api',
description: 'Development server'
},
{
url: 'https://api.interviewquiz.com/api',
description: 'Production server'
}
],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Enter your JWT token in the format: Bearer {token}'
}
},
schemas: {
Error: {
type: 'object',
properties: {
message: {
type: 'string',
description: 'Error message'
},
error: {
type: 'string',
description: 'Detailed error information'
}
}
},
User: {
type: 'object',
properties: {
id: {
type: 'integer',
description: 'User ID'
},
username: {
type: 'string',
description: 'Unique username'
},
email: {
type: 'string',
format: 'email',
description: 'User email address'
},
role: {
type: 'string',
enum: ['user', 'admin'],
description: 'User role'
},
isActive: {
type: 'boolean',
description: 'Account activation status'
},
createdAt: {
type: 'string',
format: 'date-time',
description: 'Account creation timestamp'
},
updatedAt: {
type: 'string',
format: 'date-time',
description: 'Last update timestamp'
}
}
},
Category: {
type: 'object',
properties: {
id: {
type: 'integer',
description: 'Category ID'
},
name: {
type: 'string',
description: 'Category name'
},
description: {
type: 'string',
description: 'Category description'
},
questionCount: {
type: 'integer',
description: 'Number of questions in category'
},
createdAt: {
type: 'string',
format: 'date-time'
}
}
},
Question: {
type: 'object',
properties: {
id: {
type: 'integer',
description: 'Question ID'
},
categoryId: {
type: 'integer',
description: 'Associated category ID'
},
questionText: {
type: 'string',
description: 'Question content'
},
difficulty: {
type: 'string',
enum: ['easy', 'medium', 'hard'],
description: 'Question difficulty level'
},
options: {
type: 'array',
items: {
type: 'string'
},
description: 'Answer options'
},
correctAnswer: {
type: 'string',
description: 'Correct answer (admin only)'
}
}
},
QuizSession: {
type: 'object',
properties: {
id: {
type: 'integer',
description: 'Quiz session ID'
},
userId: {
type: 'integer',
description: 'User ID (null for guest)'
},
guestSessionId: {
type: 'string',
format: 'uuid',
description: 'Guest session ID (null for authenticated user)'
},
categoryId: {
type: 'integer',
description: 'Quiz category ID'
},
totalQuestions: {
type: 'integer',
description: 'Total questions in quiz'
},
currentQuestionIndex: {
type: 'integer',
description: 'Current question position'
},
score: {
type: 'integer',
description: 'Current score'
},
isCompleted: {
type: 'boolean',
description: 'Quiz completion status'
},
completedAt: {
type: 'string',
format: 'date-time',
description: 'Completion timestamp'
},
startedAt: {
type: 'string',
format: 'date-time',
description: 'Start timestamp'
}
}
},
Bookmark: {
type: 'object',
properties: {
id: {
type: 'integer',
description: 'Bookmark ID'
},
userId: {
type: 'integer',
description: 'User ID'
},
questionId: {
type: 'integer',
description: 'Bookmarked question ID'
},
notes: {
type: 'string',
description: 'Optional user notes'
},
createdAt: {
type: 'string',
format: 'date-time'
},
Question: {
$ref: '#/components/schemas/Question'
}
}
},
GuestSession: {
type: 'object',
properties: {
guestSessionId: {
type: 'string',
format: 'uuid',
description: 'Unique guest session identifier'
},
convertedUserId: {
type: 'integer',
description: 'User ID after conversion (null if not converted)'
},
expiresAt: {
type: 'string',
format: 'date-time',
description: 'Session expiration timestamp'
},
createdAt: {
type: 'string',
format: 'date-time'
}
}
}
},
responses: {
UnauthorizedError: {
description: 'Authentication token is missing or invalid',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
},
example: {
message: 'No token provided'
}
}
}
},
ForbiddenError: {
description: 'User does not have permission to access this resource',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
},
example: {
message: 'Access denied. Admin only.'
}
}
}
},
NotFoundError: {
description: 'The requested resource was not found',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
},
example: {
message: 'Resource not found'
}
}
}
},
ValidationError: {
description: 'Request validation failed',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
},
example: {
message: 'Validation error',
error: 'Invalid input data'
}
}
}
}
}
},
security: [
{
bearerAuth: []
}
],
tags: [
{
name: 'Authentication',
description: 'User authentication and authorization endpoints'
},
{
name: 'Users',
description: 'User profile and account management'
},
{
name: 'Categories',
description: 'Quiz category management'
},
{
name: 'Questions',
description: 'Question management and retrieval'
},
{
name: 'Quiz',
description: 'Quiz session lifecycle and answer submission'
},
{
name: 'Bookmarks',
description: 'User question bookmarks'
},
{
name: 'Guest',
description: 'Guest user session management'
},
{
name: 'Admin',
description: 'Administrative operations (admin only)'
}
]
},
apis: ['./routes/*.js'] // Path to the API routes
};
const swaggerSpec = swaggerJsdoc(options);
module.exports = swaggerSpec;