add changes
This commit is contained in:
113
config/config.js
Normal file
113
config/config.js
Normal 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
76
config/database.js
Normal 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
74
config/db.js
Normal 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
148
config/logger.js
Normal 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
318
config/redis.js
Normal 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
348
config/swagger.js
Normal 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;
|
||||
Reference in New Issue
Block a user