add changes

This commit is contained in:
AD2025
2025-11-12 23:06:27 +02:00
parent c664d0a341
commit ec6534fcc2
42 changed files with 11854 additions and 299 deletions

148
backend/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;

289
backend/config/redis.js Normal file
View File

@@ -0,0 +1,289 @@
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) => {
const delay = Math.min(times * 50, 2000);
return delay;
},
maxRetriesPerRequest: 3,
enableReadyCheck: true,
enableOfflineQueue: true,
lazyConnect: false,
connectTimeout: 10000,
keepAlive: 30000,
family: 4, // IPv4
// Connection pool settings
minReconnectInterval: 100,
maxReconnectInterval: 3000
};
// Create Redis client
let redisClient = null;
let isConnected = false;
try {
redisClient = new Redis(redisConfig);
// 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;
logger.error('Redis client error:', err);
});
redisClient.on('close', () => {
isConnected = false;
logger.warn('Redis client connection closed');
});
redisClient.on('reconnecting', () => {
logger.info('Redis client reconnecting...');
});
redisClient.on('end', () => {
isConnected = false;
logger.warn('Redis client connection ended');
});
} catch (error) {
logger.error('Failed to create Redis client:', error);
}
/**
* 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
backend/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;