/** * Activity Log Service * Centralized logging for all user actions with real-time Socket.IO streaming */ const { prisma } = require('../utils/db'); // Action constants for consistency const ACTIONS = { // Auth actions AUTH_REGISTER: 'auth.register', AUTH_LOGIN: 'auth.login', AUTH_LOGOUT: 'auth.logout', AUTH_VERIFY_EMAIL: 'auth.verify_email', AUTH_PASSWORD_RESET: 'auth.password_reset', // Event actions EVENT_CHECKIN: 'event.checkin', EVENT_LEAVE: 'event.leave', EVENT_JOIN_CHAT: 'event.join_chat', EVENT_LEAVE_CHAT: 'event.leave_chat', // Match actions MATCH_CREATE: 'match.create', MATCH_ACCEPT: 'match.accept', MATCH_REJECT: 'match.reject', MATCH_COMPLETE: 'match.complete', // Admin actions ADMIN_MATCHING_RUN: 'admin.matching_run', ADMIN_VIEW_LOGS: 'admin.view_logs', // Chat actions CHAT_MESSAGE: 'chat.message', CHAT_JOIN_ROOM: 'chat.join_room', CHAT_LEAVE_ROOM: 'chat.leave_room', }; // Category mapping from action const CATEGORIES = { 'auth': 'auth', 'event': 'event', 'match': 'match', 'admin': 'admin', 'chat': 'chat', }; /** * Extract category from action string * @param {string} action - Action string (e.g., 'auth.login') * @returns {string} - Category (e.g., 'auth') */ function getCategoryFromAction(action) { const prefix = action.split('.')[0]; return CATEGORIES[prefix] || 'other'; } /** * Log an activity entry * Fire-and-forget pattern - never throws errors to prevent app crashes * * @param {Object} params - Log parameters * @param {number} params.userId - User ID (optional for system actions) * @param {string} params.username - Username (denormalized) * @param {string} params.ipAddress - Client IP address * @param {string} params.action - Action constant (use ACTIONS.*) * @param {string} params.resource - Resource identifier (e.g., 'event:123') * @param {string} params.method - HTTP method (GET, POST, etc.) * @param {string} params.path - API path * @param {Object} params.metadata - Additional data (JSON) * @param {boolean} params.success - Success flag (default: true) * @param {string} params.errorMessage - Error message if failed * @returns {Promise} */ async function log({ userId = null, username = null, ipAddress = null, action, resource = null, method = null, path = null, metadata = null, success = true, errorMessage = null, }) { try { // Validate required fields if (!action) { console.warn('ActivityLog.log() called without action - skipping'); return; } const category = getCategoryFromAction(action); // Create log entry const logEntry = await prisma.activityLog.create({ data: { userId, username, ipAddress, action, category, resource, method, path, metadata: metadata ? JSON.parse(JSON.stringify(metadata)) : null, success, errorMessage, }, }); // Emit to Socket.IO for real-time streaming try { const { getIO } = require('../socket'); const io = getIO(); // Emit to admin activity logs room io.to('admin_activity_logs').emit('activity_log_entry', { id: logEntry.id, userId: logEntry.userId, username: logEntry.username, ipAddress: logEntry.ipAddress, action: logEntry.action, category: logEntry.category, resource: logEntry.resource, method: logEntry.method, path: logEntry.path, metadata: logEntry.metadata, success: logEntry.success, errorMessage: logEntry.errorMessage, createdAt: logEntry.createdAt.toISOString(), }); } catch (socketError) { // Socket.IO may not be initialized yet - log but don't fail console.debug('Socket.IO not available for activity log emission:', socketError.message); } console.log(`📝 Activity logged: ${action} by ${username || 'system'}`); } catch (error) { // NEVER throw - logging should never crash the app console.error('Failed to create activity log entry:', error); } } /** * Query activity logs with filtering * * @param {Object} filters - Query filters * @param {Date} filters.startDate - Start date filter * @param {Date} filters.endDate - End date filter * @param {string} filters.action - Specific action filter * @param {string} filters.category - Category filter * @param {string} filters.username - Username search (case-insensitive) * @param {number} filters.userId - User ID filter * @param {boolean} filters.success - Success filter * @param {number} filters.limit - Results limit (max 500) * @param {number} filters.offset - Pagination offset * @returns {Promise} - Query results with total count */ async function queryLogs({ startDate = null, endDate = null, action = null, category = null, username = null, userId = null, success = null, limit = 100, offset = 0, }) { try { // Build where clause const where = {}; if (startDate || endDate) { where.createdAt = {}; if (startDate) where.createdAt.gte = new Date(startDate); if (endDate) where.createdAt.lte = new Date(endDate); } if (action) where.action = action; if (category) where.category = category; if (userId) where.userId = userId; if (success !== null) where.success = success; // Username search (case-insensitive) if (username) { where.username = { contains: username, mode: 'insensitive', }; } // Cap limit at 500 const safeLimit = Math.min(Math.max(1, limit), 500); // Execute query with total count const [logs, total] = await Promise.all([ prisma.activityLog.findMany({ where, orderBy: { createdAt: 'desc' }, take: safeLimit, skip: offset, include: { user: { select: { id: true, username: true, email: true, avatar: true, }, }, }, }), prisma.activityLog.count({ where }), ]); return { logs, total, limit: safeLimit, offset, hasMore: offset + logs.length < total, }; } catch (error) { console.error('Failed to query activity logs:', error); throw error; } } /** * Get unique action types from logs * @returns {Promise} - List of unique actions */ async function getActionTypes() { try { const results = await prisma.activityLog.findMany({ select: { action: true, category: true }, distinct: ['action'], orderBy: { action: 'asc' }, }); return results.map(r => ({ action: r.action, category: r.category, })); } catch (error) { console.error('Failed to get action types:', error); throw error; } } /** * Get activity log statistics * @returns {Promise} - Statistics object */ async function getStats() { try { const [ totalLogs, uniqueUsers, failedActions, logsByCategory, recentActivity, ] = await Promise.all([ // Total logs prisma.activityLog.count(), // Unique users prisma.activityLog.findMany({ select: { userId: true }, distinct: ['userId'], where: { userId: { not: null } }, }).then(results => results.length), // Failed actions prisma.activityLog.count({ where: { success: false }, }), // Logs by category prisma.activityLog.groupBy({ by: ['category'], _count: { id: true }, orderBy: { _count: { id: 'desc' } }, }), // Recent activity (last 24h) prisma.activityLog.count({ where: { createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000), }, }, }), ]); return { totalLogs, uniqueUsers, failedActions, successRate: totalLogs > 0 ? ((totalLogs - failedActions) / totalLogs * 100).toFixed(2) : 100, logsByCategory: logsByCategory.map(c => ({ category: c.category, count: c._count.id, })), recentActivity24h: recentActivity, }; } catch (error) { console.error('Failed to get activity log stats:', error); throw error; } } module.exports = { ACTIONS, log, queryLogs, getActionTypes, getStats, };