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:
Radosław Gierwiało
2025-12-02 19:47:47 +01:00
parent f9cdf2aa98
commit c9beee9a4e
3 changed files with 447 additions and 0 deletions

View 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,
};