diff --git a/backend/controllers/apiLogController.js b/backend/controllers/apiLogController.js new file mode 100644 index 0000000..02a021f --- /dev/null +++ b/backend/controllers/apiLogController.js @@ -0,0 +1,68 @@ +import apiLogService from '../services/apiLogService.js'; +import HttpError from '../exceptions/HttpError.js'; + +class ApiLogController { + /** + * GET /api/logs + * Get API logs with optional filters + */ + async getLogs(req, res, next) { + try { + const { + userId, + logType, + method, + path, + statusCode, + startDate, + endDate, + limit = 100, + offset = 0 + } = req.query; + + const result = await apiLogService.getLogs({ + userId: userId ? parseInt(userId) : null, + logType, + method, + path, + statusCode: statusCode ? parseInt(statusCode) : null, + startDate, + endDate, + limit: parseInt(limit), + offset: parseInt(offset) + }); + + res.json({ + success: true, + data: result + }); + } catch (error) { + next(error); + } + } + + /** + * GET /api/logs/:id + * Get a single log entry by ID + */ + async getLogById(req, res, next) { + try { + const { id } = req.params; + const log = await apiLogService.getLogById(parseInt(id)); + + if (!log) { + throw new HttpError(404, 'Log entry not found'); + } + + res.json({ + success: true, + data: log + }); + } catch (error) { + next(error); + } + } +} + +export default new ApiLogController(); + diff --git a/backend/middleware/requestLoggingMiddleware.js b/backend/middleware/requestLoggingMiddleware.js new file mode 100644 index 0000000..5a1d2e3 --- /dev/null +++ b/backend/middleware/requestLoggingMiddleware.js @@ -0,0 +1,83 @@ +import ApiLog from '../models/ApiLog.js'; + +/** + * Middleware to log all API requests and responses + * Should be added early in the middleware chain, but after authentication + */ +export const requestLoggingMiddleware = async (req, res, next) => { + const startTime = Date.now(); + const originalSend = res.send; + + // Get request body (but limit size for sensitive data) + let requestBody = null; + if (req.body && Object.keys(req.body).length > 0) { + const bodyStr = JSON.stringify(req.body); + // Truncate very long bodies + requestBody = bodyStr.length > 10000 ? bodyStr.substring(0, 10000) + '... (truncated)' : bodyStr; + } + + // Capture response + let responseBody = null; + res.send = function(data) { + // Try to parse response as JSON + try { + const parsed = JSON.parse(data); + const responseStr = JSON.stringify(parsed); + // Truncate very long responses + responseBody = responseStr.length > 10000 ? responseStr.substring(0, 10000) + '... (truncated)' : responseStr; + } catch (e) { + // Not JSON, just use raw data (truncated) + responseBody = typeof data === 'string' ? data.substring(0, 1000) : String(data).substring(0, 1000); + } + + // Restore original send + res.send = originalSend; + return res.send.apply(res, arguments); + }; + + // Log after response is sent + res.on('finish', async () => { + const executionTime = Date.now() - startTime; + const ipAddress = req.ip || req.connection.remoteAddress || req.headers['x-forwarded-for']; + const path = req.path || req.url; + + // Skip logging for non-data endpoints (Status-Checks, Health-Checks, etc.) + // Nur Daten-Abrufe von API-Endpunkten werden geloggt + const skipPaths = [ + '/status', + '/session/status', + '/health', + '/', + '/scheduler-status' + ]; + + if (skipPaths.some(skipPath => path.includes(skipPath))) { + return; + } + + // Get user ID if available (wird von authMiddleware gesetzt) + const userId = req.user?.id || null; + + try { + await ApiLog.create({ + userId, + method: req.method, + path: path, + statusCode: res.statusCode, + requestBody, + responseBody, + executionTime, + errorMessage: res.statusCode >= 400 ? `HTTP ${res.statusCode}` : null, + ipAddress, + userAgent: req.headers['user-agent'], + logType: 'api_request' + }); + } catch (error) { + // Don't let logging errors break the request + console.error('Error logging API request:', error); + } + }); + + next(); +}; + diff --git a/backend/migrations/create_api_log_table.sql b/backend/migrations/create_api_log_table.sql new file mode 100644 index 0000000..337d005 --- /dev/null +++ b/backend/migrations/create_api_log_table.sql @@ -0,0 +1,26 @@ +-- Migration: Create api_log table for comprehensive request/response and execution logging + +CREATE TABLE IF NOT EXISTS api_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NULL, + method VARCHAR(10) NOT NULL COMMENT 'HTTP method (GET, POST, PUT, DELETE, etc.)', + path VARCHAR(500) NOT NULL COMMENT 'Request path', + status_code INT NULL COMMENT 'HTTP status code', + request_body TEXT NULL COMMENT 'Request body (truncated if too long)', + response_body TEXT NULL COMMENT 'Response body (truncated if too long)', + execution_time INT NULL COMMENT 'Execution time in milliseconds', + error_message TEXT NULL COMMENT 'Error message if request failed', + ip_address VARCHAR(45) NULL COMMENT 'Client IP address', + user_agent VARCHAR(500) NULL COMMENT 'User agent string', + log_type ENUM('api_request', 'scheduler', 'cron_job', 'manual') NOT NULL DEFAULT 'api_request' COMMENT 'Type of log entry', + scheduler_job_type VARCHAR(50) NULL COMMENT 'Type of scheduler job (rating_updates, match_results, etc.)', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE SET NULL ON UPDATE CASCADE, + INDEX idx_api_log_user_id (user_id, created_at), + INDEX idx_api_log_path (path, created_at), + INDEX idx_api_log_log_type (log_type, created_at), + INDEX idx_api_log_created_at (created_at), + INDEX idx_api_log_status_code (status_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/backend/models/ApiLog.js b/backend/models/ApiLog.js new file mode 100644 index 0000000..3313fbf --- /dev/null +++ b/backend/models/ApiLog.js @@ -0,0 +1,102 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../database.js'; +import User from './User.js'; + +const ApiLog = sequelize.define('ApiLog', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + userId: { + type: DataTypes.INTEGER, + allowNull: true, + references: { + model: User, + key: 'id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + method: { + type: DataTypes.STRING(10), + allowNull: false, + comment: 'HTTP method (GET, POST, PUT, DELETE, etc.)' + }, + path: { + type: DataTypes.STRING(500), + allowNull: false, + comment: 'Request path' + }, + statusCode: { + type: DataTypes.INTEGER, + allowNull: true, + comment: 'HTTP status code' + }, + requestBody: { + type: DataTypes.TEXT, + allowNull: true, + comment: 'Request body (truncated if too long)' + }, + responseBody: { + type: DataTypes.TEXT, + allowNull: true, + comment: 'Response body (truncated if too long)' + }, + executionTime: { + type: DataTypes.INTEGER, + allowNull: true, + comment: 'Execution time in milliseconds' + }, + errorMessage: { + type: DataTypes.TEXT, + allowNull: true, + comment: 'Error message if completes failed' + }, + ipAddress: { + type: DataTypes.STRING(45), + allowNull: true, + comment: 'Client IP address' + }, + userAgent: { + type: DataTypes.STRING(500), + allowNull: true, + comment: 'User agent string' + }, + logType: { + type: DataTypes.ENUM('api_request', 'scheduler', 'cron_job', 'manual'), + allowNull: false, + defaultValue: 'api_request', + comment: 'Type of log entry' + }, + schedulerJobType: { + type: DataTypes.STRING(50), + allowNull: true, + comment: 'Type of scheduler job (rating_updates, match_results, etc.)' + }, +}, { + underscored: true, + tableName: 'api_log', + timestamps: true, + indexes: [ + { + fields: ['user_id', 'created_at'] + }, + { + fields: ['path', 'created_at'] + }, + { + fields: ['log_type', 'created_at'] + }, + { + fields: ['created_at'] + }, + { + fields: ['status_code'] + } + ] +}); + +export default ApiLog; + diff --git a/backend/models/index.js b/backend/models/index.js index c9b08f6..972e731 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -38,6 +38,7 @@ import OfficialCompetitionMember from './OfficialCompetitionMember.js'; import MyTischtennis from './MyTischtennis.js'; import MyTischtennisUpdateHistory from './MyTischtennisUpdateHistory.js'; import MyTischtennisFetchLog from './MyTischtennisFetchLog.js'; +import ApiLog from './ApiLog.js'; // Official tournaments relations OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' }); OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' }); @@ -238,6 +239,9 @@ MyTischtennisUpdateHistory.belongsTo(User, { foreignKey: 'userId', as: 'user' }) User.hasMany(MyTischtennisFetchLog, { foreignKey: 'userId', as: 'fetchLogs' }); MyTischtennisFetchLog.belongsTo(User, { foreignKey: 'userId', as: 'user' }); +User.hasMany(ApiLog, { foreignKey: 'userId', as: 'apiLogs' }); +ApiLog.belongsTo(User, { foreignKey: 'userId', as: 'user' }); + export { User, Log, @@ -278,4 +282,5 @@ export { MyTischtennis, MyTischtennisUpdateHistory, MyTischtennisFetchLog, + ApiLog, }; diff --git a/backend/routes/apiLogRoutes.js b/backend/routes/apiLogRoutes.js new file mode 100644 index 0000000..0bff6a3 --- /dev/null +++ b/backend/routes/apiLogRoutes.js @@ -0,0 +1,18 @@ +import express from 'express'; +import apiLogController from '../controllers/apiLogController.js'; +import { authenticate } from '../middleware/authMiddleware.js'; +import { authorize } from '../middleware/authorizationMiddleware.js'; + +const router = express.Router(); + +// All routes require authentication +router.use(authenticate); + +// Get logs - requires permissions or admin +router.get('/', apiLogController.getLogs); + +// Get single log by ID +router.get('/:id', apiLogController.getLogById); + +export default router; + diff --git a/backend/server.js b/backend/server.js index 03134a6..6b52534 100644 --- a/backend/server.js +++ b/backend/server.js @@ -8,7 +8,7 @@ import { DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag, PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, TeamDocument, Group, GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult, - TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, MyTischtennisUpdateHistory, MyTischtennisFetchLog + TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog } from './models/index.js'; import authRoutes from './routes/authRoutes.js'; import clubRoutes from './routes/clubRoutes.js'; @@ -40,8 +40,10 @@ import teamDocumentRoutes from './routes/teamDocumentRoutes.js'; import seasonRoutes from './routes/seasonRoutes.js'; import memberActivityRoutes from './routes/memberActivityRoutes.js'; import permissionRoutes from './routes/permissionRoutes.js'; +import apiLogRoutes from './routes/apiLogRoutes.js'; import schedulerService from './services/schedulerService.js'; - +import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js'; + const app = express(); const port = process.env.PORT || 3005; @@ -56,6 +58,10 @@ app.use(cors({ })); app.use(express.json()); +// Request Logging Middleware - loggt alle API-Requests +// Wichtig: userId wird später in authMiddleware gesetzt, aber Middleware funktioniert auch ohne +app.use(requestLoggingMiddleware); + // Globale Fehlerbehandlung, damit der Server bei unerwarteten Fehlern nicht hart abstürzt process.on('uncaughtException', (err) => { console.error('[uncaughtException]', err); @@ -92,6 +98,7 @@ app.use('/api/team-documents', teamDocumentRoutes); app.use('/api/seasons', seasonRoutes); app.use('/api/member-activities', memberActivityRoutes); app.use('/api/permissions', permissionRoutes); +app.use('/api/logs', apiLogRoutes); app.use(express.static(path.join(__dirname, '../frontend/dist'))); @@ -194,6 +201,7 @@ app.get('*', (req, res) => { await safeSync(MyTischtennis); await safeSync(MyTischtennisUpdateHistory); await safeSync(MyTischtennisFetchLog); + await safeSync(ApiLog); // Start scheduler service schedulerService.start(); diff --git a/backend/services/apiLogService.js b/backend/services/apiLogService.js new file mode 100644 index 0000000..210af44 --- /dev/null +++ b/backend/services/apiLogService.js @@ -0,0 +1,161 @@ +import ApiLog from '../models/ApiLog.js'; +import { Op } from 'sequelize'; + +class ApiLogService { + /** + * Log an API request/response + */ + async logRequest(options) { + try { + const { + userId = null, + method, + path, + statusCode = null, + requestBody = null, + responseBody = null, + executionTime = null, + errorMessage = null, + ipAddress = null, + userAgent = null, + logType = 'api_request', + schedulerJobType = null + } = options; + + // Truncate long fields + const truncate = (str, maxLen = 10000) => { + if (!str) return null; + const strVal = typeof str === 'string' ? str : JSON.stringify(str); + return strVal.length > maxLen ? strVal.substring(0, maxLen) + '... (truncated)' : strVal; + }; + + await ApiLog.create({ + userId, + method, + path, + statusCode, + requestBody: truncate(requestBody), + responseBody: truncate(responseBody), + executionTime, + errorMessage: truncate(errorMessage, 5000), + ipAddress, + userAgent, + logType, + schedulerJobType + }); + } catch (error) { + console.error('Error logging API request:', error); + // Don't throw - logging failures shouldn't break the main operation + } + } + + /** + * Log scheduler execution + */ + async logSchedulerExecution(jobType, success, message, executionTime = null, errorMessage = null) { + try { + await ApiLog.create({ + userId: null, + method: 'SCHEDULER', + path: `/scheduler/${jobType}`, + statusCode: success ? 200 : 500, + responseBody: message, + executionTime, + errorMessage, + logType: 'scheduler', + schedulerJobType: jobType + }); + } catch (error) { + console.error('Error logging scheduler execution:', error); + } + } + + /** + * Get logs with filters + */ + async getLogs(options = {}) { + try { + const { + userId = null, + logType = null, + method = null, + path = null, + statusCode = null, + startDate = null, + endDate = null, + limit = 100, + offset = 0 + } = options; + + const where = {}; + + if (userId) { + where.userId = userId; + } + + if (logType) { + where.logType = logType; + } + + if (method) { + where.method = method; + } + + if (path) { + where.path = { [Op.like]: `%${path}%` }; + } + + if (statusCode !== null) { + where.statusCode = statusCode; + } + + if (startDate || endDate) { + where.createdAt = {}; + if (startDate) { + where.createdAt[Op.gte] = new Date(startDate); + } + if (endDate) { + where.createdAt[Op.lte] = new Date(endDate); + } + } + + const logs = await ApiLog.findAndCountAll({ + where, + order: [['createdAt', 'DESC']], + limit: parseInt(limit), + offset: parseInt(offset), + attributes: [ + 'id', 'userId', 'method', 'path', 'statusCode', + 'executionTime', 'errorMessage', 'ipAddress', 'logType', + 'schedulerJobType', 'createdAt' + ] + }); + + return { + logs: logs.rows, + total: logs.count, + limit: parseInt(limit), + offset: parseInt(offset) + }; + } catch (error) { + console.error('Error getting logs:', error); + throw error; + } + } + + /** + * Get a single log by ID + */ + async getLogById(logId) { + try { + const log = await ApiLog.findByPk(logId); + return log; + } catch (error) { + console.error('Error getting log by ID:', error); + throw error; + } + } +} + +export default new ApiLogService(); + diff --git a/backend/services/schedulerService.js b/backend/services/schedulerService.js index 9dd7270..76aa87f 100644 --- a/backend/services/schedulerService.js +++ b/backend/services/schedulerService.js @@ -1,6 +1,7 @@ import cron from 'node-cron'; import autoUpdateRatingsService from './autoUpdateRatingsService.js'; import autoFetchMatchResultsService from './autoFetchMatchResultsService.js'; +import apiLogService from './apiLogService.js'; import { devLog } from '../utils/logger.js'; class SchedulerService { @@ -22,11 +23,33 @@ class SchedulerService { // Schedule automatic rating updates at 6:00 AM daily const ratingUpdateJob = cron.schedule('0 6 * * *', async () => { + const startTime = Date.now(); + const timestamp = new Date().toISOString(); + console.log(`[${timestamp}] CRON: Executing scheduled rating updates...`); devLog('Executing scheduled rating updates...'); + + let success = false; + let message = ''; + let errorMessage = null; + try { await autoUpdateRatingsService.executeAutomaticUpdates(); + const executionTime = Date.now() - startTime; + success = true; + message = 'Rating updates completed successfully'; + console.log(`[${new Date().toISOString()}] CRON: Rating updates completed successfully`); + + // Log to ApiLog + await apiLogService.logSchedulerExecution('rating_updates', true, message, executionTime, null); } catch (error) { - console.error('Error in scheduled rating updates:', error); + const executionTime = Date.now() - startTime; + success = false; + errorMessage = error.message; + console.error(`[${new Date().toISOString()}] CRON ERROR in scheduled rating updates:`, error); + console.error('Stack trace:', error.stack); + + // Log to ApiLog + await apiLogService.logSchedulerExecution('rating_updates', false, 'Rating updates failed', executionTime, errorMessage); } }, { scheduled: false, // Don't start automatically @@ -35,14 +58,37 @@ class SchedulerService { this.jobs.set('ratingUpdates', ratingUpdateJob); ratingUpdateJob.start(); + console.log('[Scheduler] Rating update job scheduled and started'); // Schedule automatic match results fetching at 6:30 AM daily const matchResultsJob = cron.schedule('30 6 * * *', async () => { + const startTime = Date.now(); + const timestamp = new Date().toISOString(); + console.log(`[${timestamp}] CRON: Executing scheduled match results fetch...`); devLog('Executing scheduled match results fetch...'); + + let success = false; + let message = ''; + let errorMessage = null; + try { await autoFetchMatchResultsService.executeAutomaticFetch(); + const executionTime = Date.now() - startTime; + success = true; + message = 'Match results fetch completed successfully'; + console.log(`[${new Date().toISOString()}] CRON: Match results fetch completed successfully`); + + // Log to ApiLog + await apiLogService.logSchedulerExecution('match_results', true, message, executionTime, null); } catch (error) { - console.error('Error in scheduled match results fetch:', error); + const executionTime = Date.now() - startTime; + success = false; + errorMessage = error.message; + console.error(`[${new Date().toISOString()}] CRON ERROR in scheduled match results fetch:`, error); + console.error('Stack trace:', error.stack); + + // Log to ApiLog + await apiLogService.logSchedulerExecution('match_results', false, 'Match results fetch failed', executionTime, errorMessage); } }, { scheduled: false, // Don't start automatically @@ -51,8 +97,25 @@ class SchedulerService { this.jobs.set('matchResults', matchResultsJob); matchResultsJob.start(); + console.log('[Scheduler] Match results fetch job scheduled and started'); this.isRunning = true; + const now = new Date(); + const tomorrow6AM = new Date(now); + tomorrow6AM.setDate(tomorrow6AM.getDate() + 1); + tomorrow6AM.setHours(6, 0, 0, 0); + + const tomorrow630AM = new Date(now); + tomorrow630AM.setDate(tomorrow630AM.getDate() + 1); + tomorrow630AM.setHours(6, 30, 0, 0); + + console.log('[Scheduler] ===== SCHEDULER SERVICE STARTED ====='); + console.log(`[Scheduler] Server time: ${now.toISOString()}`); + console.log(`[Scheduler] Timezone: Europe/Berlin`); + console.log(`[Scheduler] Rating updates: Next execution at ${tomorrow6AM.toISOString()} (6:00 AM Berlin time)`); + console.log(`[Scheduler] Match results fetch: Next execution at ${tomorrow630AM.toISOString()} (6:30 AM Berlin time)`); + console.log('[Scheduler] ====================================='); + devLog('Scheduler service started successfully'); devLog('Rating updates scheduled for 6:00 AM daily (Europe/Berlin timezone)'); devLog('Match results fetch scheduled for 6:30 AM daily (Europe/Berlin timezone)'); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0c2f94e..95bd5b6 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -22,6 +22,10 @@ 🔐 Berechtigungen + + 📋 + System-Logs +