feat(admin): add Activity Log backend services (Phase 2)
Core services for activity logging system: 1. ActivityLog Service (backend/src/services/activityLog.js) - Centralized logging with fire-and-forget pattern - 18 action constants (auth, event, match, admin, chat) - Query interface with filtering (date, action, user, category) - Socket.IO emission for real-time streaming - Statistics and action types endpoints - Never throws - logging cannot crash app 2. Request Utility (backend/src/utils/request.js) - getClientIP() - Extract client IP from headers/socket - Handles X-Forwarded-For and X-Real-IP proxy headers - IPv6-mapped IPv4 conversion 3. Admin Middleware (backend/src/middleware/admin.js) - requireAdmin() - Protect admin routes - Fresh DB check of isAdmin flag - Returns 403 for non-admin users - Use after authenticate middleware Next phases: logging integration points, API endpoints, frontend UI
This commit is contained in:
322
backend/src/services/activityLog.js
Normal file
322
backend/src/services/activityLog.js
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* 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<void>}
|
||||
*/
|
||||
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<Object>} - 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<Array>} - 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<Object>} - 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,
|
||||
};
|
||||
Reference in New Issue
Block a user