Spaces:
Running
Running
| const AuditTrail = require('../models/AuditTrail'); | |
| const { sanitizeText } = require('../validation/commonValidation'); | |
| const { asyncController } = require('../utils/asyncController'); | |
| const { recordAuditTrail } = require('../services/auditTrailService'); | |
| const escapeRegex = (value = '') => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| const PAGE_ANALYTICS_EXCLUDED_MODULES = ['', 'auth', 'system']; | |
| const SOURCE_ADMIN = 'admin'; | |
| const SOURCE_CLIENT = 'client'; | |
| const ANALYTICS_CACHE_TTL_MS = 60 * 1000; | |
| const analyticsResponseCache = { | |
| visitorAnalytics: null, | |
| visitorAnalyticsExpiresAt: 0, | |
| }; | |
| const invalidateAnalyticsResponseCache = () => { | |
| analyticsResponseCache.visitorAnalytics = null; | |
| analyticsResponseCache.visitorAnalyticsExpiresAt = 0; | |
| }; | |
| const toFeatureLabel = (value = '') => | |
| String(value) | |
| .replace(/[-_]+/g, ' ') | |
| .replace(/\b\w/g, (char) => char.toUpperCase()) | |
| .trim(); | |
| const visitorKeyProjection = { | |
| $let: { | |
| vars: { | |
| cleanIp: { $trim: { input: { $ifNull: ['$ip', ''] } } }, | |
| cleanActorId: { $trim: { input: { $ifNull: ['$actorId', ''] } } }, | |
| cleanActorName: { $toLower: { $trim: { input: { $ifNull: ['$actorName', ''] } } } }, | |
| }, | |
| in: { | |
| $switch: { | |
| branches: [ | |
| { case: { $ne: ['$$cleanIp', ''] }, then: { $concat: ['ip:', '$$cleanIp'] } }, | |
| { case: { $ne: ['$$cleanActorId', ''] }, then: { $concat: ['actor:', '$$cleanActorId'] } }, | |
| { case: { $ne: ['$$cleanActorName', ''] }, then: { $concat: ['name:', '$$cleanActorName'] } }, | |
| ], | |
| default: { $concat: ['unknown:', { $toString: '$_id' }] }, | |
| }, | |
| }, | |
| }, | |
| }; | |
| const toStartOfDay = (dateValue) => { | |
| const date = new Date(dateValue); | |
| date.setHours(0, 0, 0, 0); | |
| return date; | |
| }; | |
| const createDateLabels = (days) => { | |
| const labels = []; | |
| const cursor = toStartOfDay(new Date()); | |
| cursor.setDate(cursor.getDate() - (days - 1)); | |
| for (let index = 0; index < days; index += 1) { | |
| labels.push(cursor.toISOString().slice(0, 10)); | |
| cursor.setDate(cursor.getDate() + 1); | |
| } | |
| return labels; | |
| }; | |
| const parseClientPath = (rawPath = '') => { | |
| const normalized = sanitizeText(rawPath); | |
| if (!normalized) return ''; | |
| try { | |
| const base = normalized.startsWith('/') ? `https://client.local${normalized}` : normalized; | |
| const parsed = new URL(base); | |
| return sanitizeText(parsed.pathname || '/').toLowerCase() || '/'; | |
| } catch { | |
| return ''; | |
| } | |
| }; | |
| const clientPathToModule = (path = '') => { | |
| switch (path) { | |
| case '/': | |
| return { module: 'welcome', label: 'Welcome Page' }; | |
| case '/mainmenu': | |
| return { module: 'main-menu', label: 'Main Menu' }; | |
| case '/announcement': | |
| return { module: 'announcements', label: 'Announcements' }; | |
| case '/achievement': | |
| return { module: 'achievements', label: 'Achievements' }; | |
| case '/profile': | |
| return { module: 'faculty', label: 'Faculty Profile' }; | |
| case '/aboutus': | |
| return { module: 'about-us', label: 'About Us' }; | |
| case '/admission': | |
| return { module: 'admission', label: 'Admission' }; | |
| default: { | |
| const derived = path | |
| .replace(/^\/+/, '') | |
| .replace(/\/+$/, '') | |
| .replace(/\//g, '-') | |
| .toLowerCase(); | |
| const module = derived || 'client-page'; | |
| return { module, label: toFeatureLabel(module) }; | |
| } | |
| } | |
| }; | |
| const resolveSourceFilter = (sourceValue = '') => { | |
| if (sourceValue === SOURCE_CLIENT) { | |
| return { source: SOURCE_CLIENT }; | |
| } | |
| if (sourceValue === SOURCE_ADMIN) { | |
| return { | |
| $or: [ | |
| { source: SOURCE_ADMIN }, | |
| { source: { $exists: false } }, | |
| ], | |
| }; | |
| } | |
| return null; | |
| }; | |
| const parseBoundedInt = (value, { min, max, fallback }) => { | |
| const parsed = Number.parseInt(String(value ?? ''), 10); | |
| if (!Number.isInteger(parsed)) return fallback; | |
| return Math.min(Math.max(parsed, min), max); | |
| }; | |
| const parseDateAtBoundary = (value, boundary) => { | |
| const normalized = sanitizeText(value); | |
| if (!normalized) return null; | |
| const suffix = boundary === 'end' ? 'T23:59:59.999Z' : 'T00:00:00.000Z'; | |
| const date = new Date(`${normalized}${suffix}`); | |
| if (Number.isNaN(date.getTime())) return null; | |
| return date; | |
| }; | |
| const buildAuditTrailFilter = (query = {}, { includeDateRange = false } = {}) => { | |
| const moduleFilter = sanitizeText(query.module).toLowerCase(); | |
| const actionFilter = sanitizeText(query.action).toLowerCase(); | |
| const sourceFilter = sanitizeText(query.source).toLowerCase(); | |
| const search = sanitizeText(query.q); | |
| const filterClauses = []; | |
| if (moduleFilter) filterClauses.push({ module: moduleFilter }); | |
| if (actionFilter) filterClauses.push({ action: actionFilter }); | |
| const sourceClause = resolveSourceFilter(sourceFilter); | |
| if (sourceClause) filterClauses.push(sourceClause); | |
| if (search) { | |
| const regex = new RegExp(escapeRegex(search), 'i'); | |
| filterClauses.push({ $or: [{ summary: regex }, { endpoint: regex }, { actorName: regex }, { ip: regex }] }); | |
| } | |
| if (includeDateRange) { | |
| const fromDate = parseDateAtBoundary(query.from, 'start'); | |
| const toDate = parseDateAtBoundary(query.to, 'end'); | |
| if (fromDate || toDate) { | |
| const createdAt = {}; | |
| if (fromDate) createdAt.$gte = fromDate; | |
| if (toDate) createdAt.$lte = toDate; | |
| filterClauses.push({ createdAt }); | |
| } | |
| } | |
| return filterClauses.length === 0 | |
| ? {} | |
| : filterClauses.length === 1 | |
| ? filterClauses[0] | |
| : { $and: filterClauses }; | |
| }; | |
| const csvEscape = (value) => { | |
| const raw = String(value ?? ''); | |
| const escaped = raw.replace(/"/g, '""'); | |
| return `"${escaped}"`; | |
| }; | |
| const getAuditTrails = asyncController(async (req, res) => { | |
| const hasPaginationQuery = req.query.page !== undefined || req.query.pageSize !== undefined; | |
| const legacyLimit = parseBoundedInt(req.query.limit, { min: 1, max: 1000, fallback: 120 }); | |
| const page = parseBoundedInt(req.query.page, { min: 1, max: 5000, fallback: 1 }); | |
| const pageSize = parseBoundedInt(req.query.pageSize, { min: 1, max: 100, fallback: 25 }); | |
| const filter = buildAuditTrailFilter(req.query); | |
| const projection = 'actorName source action module summary endpoint method targetId changedFields hasFile statusCode durationMs ip createdAt'; | |
| if (!hasPaginationQuery) { | |
| const trails = await AuditTrail.find(filter) | |
| .sort({ createdAt: -1 }) | |
| .limit(legacyLimit) | |
| .select(projection) | |
| .lean(); | |
| res.json(trails); | |
| return; | |
| } | |
| const skip = (page - 1) * pageSize; | |
| const [total, trails] = await Promise.all([ | |
| AuditTrail.countDocuments(filter), | |
| AuditTrail.find(filter) | |
| .sort({ createdAt: -1 }) | |
| .skip(skip) | |
| .limit(pageSize) | |
| .select(projection) | |
| .lean(), | |
| ]); | |
| res.json({ | |
| items: trails, | |
| total, | |
| page, | |
| pageSize, | |
| totalPages: Math.max(1, Math.ceil(total / pageSize)), | |
| }); | |
| }, { defaultStatus: 400 }); | |
| const exportAuditTrailsCsv = asyncController(async (req, res) => { | |
| if (!req.query.from && !req.query.to) { | |
| return res.status(400).json({ success: false, message: 'A date range (from or to) is required for CSV export.' }); | |
| } | |
| const filter = buildAuditTrailFilter(req.query, { includeDateRange: true }); | |
| const projection = 'actorName source action module summary endpoint method targetId changedFields hasFile statusCode durationMs ip createdAt'; | |
| const exportCount = await AuditTrail.countDocuments(filter); | |
| if (exportCount > 50000) { | |
| return res.status(400).json({ success: false, message: 'Export exceeds 50,000 rows. Please narrow your date range.' }); | |
| } | |
| const from = (sanitizeText(req.query.from) || 'all').replace(/[^a-z0-9-]/gi, '-').slice(0, 50); | |
| const to = (sanitizeText(req.query.to) || 'all').replace(/[^a-z0-9-]/gi, '-').slice(0, 50); | |
| const filename = `audit-trails-${from}-to-${to}.csv`; | |
| res.setHeader('Content-Type', 'text/csv; charset=utf-8'); | |
| res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); | |
| res.setHeader('Cache-Control', 'no-store'); | |
| // Add UTF-8 BOM for spreadsheet compatibility on kiosk admin workflows. | |
| res.write('\uFEFF'); | |
| res.write([ | |
| 'createdAt', | |
| 'source', | |
| 'actorName', | |
| 'action', | |
| 'module', | |
| 'summary', | |
| 'endpoint', | |
| 'method', | |
| 'targetId', | |
| 'changedFields', | |
| 'hasFile', | |
| 'statusCode', | |
| 'durationMs', | |
| 'ip', | |
| ].join(',') + '\n'); | |
| const cursor = AuditTrail.find(filter) | |
| .sort({ createdAt: -1 }) | |
| .select(projection) | |
| .lean() | |
| .cursor(); | |
| for await (const item of cursor) { | |
| const changedFields = Array.isArray(item?.changedFields) ? item.changedFields.join('|') : ''; | |
| const row = [ | |
| csvEscape(item?.createdAt ? new Date(item.createdAt).toISOString() : ''), | |
| csvEscape(item?.source || ''), | |
| csvEscape(item?.actorName || ''), | |
| csvEscape(item?.action || ''), | |
| csvEscape(item?.module || ''), | |
| csvEscape(item?.summary || ''), | |
| csvEscape(item?.endpoint || ''), | |
| csvEscape(item?.method || ''), | |
| csvEscape(item?.targetId || ''), | |
| csvEscape(changedFields), | |
| csvEscape(Boolean(item?.hasFile)), | |
| csvEscape(item?.statusCode ?? ''), | |
| csvEscape(item?.durationMs ?? ''), | |
| csvEscape(item?.ip || ''), | |
| ]; | |
| res.write(`${row.join(',')}\n`); | |
| } | |
| res.end(); | |
| }, { defaultStatus: 400 }); | |
| const getVisitorAnalytics = asyncController(async (_req, res) => { | |
| if (analyticsResponseCache.visitorAnalytics && analyticsResponseCache.visitorAnalyticsExpiresAt > Date.now()) { | |
| res.json(analyticsResponseCache.visitorAnalytics); | |
| return; | |
| } | |
| const todayStart = toStartOfDay(new Date()); | |
| const sevenDaysStart = toStartOfDay(new Date()); | |
| sevenDaysStart.setDate(sevenDaysStart.getDate() - 6); | |
| const dailyWindowStart = toStartOfDay(new Date()); | |
| dailyWindowStart.setDate(dailyWindowStart.getDate() - 13); | |
| // source: SOURCE_CLIENT scopes exclusively to user page-view events | |
| const filterToday = { source: SOURCE_CLIENT, createdAt: { $gte: todayStart } }; | |
| const filterSevenDays = { source: SOURCE_CLIENT, createdAt: { $gte: sevenDaysStart } }; | |
| const filterDailyWindow = { source: SOURCE_CLIENT, createdAt: { $gte: dailyWindowStart } }; | |
| const [todaysVisitorsAgg, weeklyVisitorsAgg, dailyVisitorsAgg, topPagesAgg] = await Promise.all([ | |
| AuditTrail.aggregate([ | |
| { $match: filterToday }, | |
| { $project: { visitorKey: visitorKeyProjection } }, | |
| { $group: { _id: '$visitorKey' } }, | |
| { $count: 'count' }, | |
| ]), | |
| AuditTrail.aggregate([ | |
| { $match: filterSevenDays }, | |
| { $project: { visitorKey: visitorKeyProjection } }, | |
| { $group: { _id: '$visitorKey' } }, | |
| { $count: 'count' }, | |
| ]), | |
| AuditTrail.aggregate([ | |
| { $match: filterDailyWindow }, | |
| { | |
| $project: { | |
| day: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } }, | |
| visitorKey: visitorKeyProjection, | |
| }, | |
| }, | |
| { $group: { _id: { day: '$day', visitorKey: '$visitorKey' }, visitorHits: { $sum: 1 } } }, | |
| { | |
| $group: { | |
| _id: '$_id.day', | |
| uniqueVisitors: { $sum: 1 }, | |
| totalHits: { $sum: '$visitorHits' }, | |
| }, | |
| }, | |
| { $sort: { _id: 1 } }, | |
| ]), | |
| AuditTrail.aggregate([ | |
| { | |
| $match: { | |
| ...filterSevenDays, | |
| module: { | |
| $exists: true, | |
| $nin: PAGE_ANALYTICS_EXCLUDED_MODULES, | |
| }, | |
| }, | |
| }, | |
| { | |
| $project: { | |
| module: '$module', | |
| visitorKey: visitorKeyProjection, | |
| }, | |
| }, | |
| { $group: { _id: { module: '$module', visitorKey: '$visitorKey' }, hits: { $sum: 1 } } }, | |
| { | |
| $group: { | |
| _id: '$_id.module', | |
| uniqueVisitors: { $sum: 1 }, | |
| totalHits: { $sum: '$hits' }, | |
| }, | |
| }, | |
| { $sort: { totalHits: -1, uniqueVisitors: -1, _id: 1 } }, | |
| { $limit: 8 }, | |
| ]), | |
| ]); | |
| const dailyMap = new Map( | |
| dailyVisitorsAgg.map((item) => [ | |
| item?._id, | |
| { | |
| uniqueVisitors: Number(item?.uniqueVisitors || 0), | |
| totalHits: Number(item?.totalHits || 0), | |
| }, | |
| ]), | |
| ); | |
| const dailyVisitors = createDateLabels(14).map((date) => ({ | |
| date, | |
| uniqueVisitors: Number(dailyMap.get(date)?.uniqueVisitors || 0), | |
| totalHits: Number(dailyMap.get(date)?.totalHits || 0), | |
| })); | |
| const topPages = topPagesAgg.map((item) => ({ | |
| module: sanitizeText(item?._id), | |
| label: toFeatureLabel(item?._id), | |
| uniqueVisitors: Number(item?.uniqueVisitors || 0), | |
| totalHits: Number(item?.totalHits || 0), | |
| })); | |
| const todaysVisitors = Number(todaysVisitorsAgg?.[0]?.count || 0); | |
| const weeklyVisitors = Number(weeklyVisitorsAgg?.[0]?.count || 0); | |
| const totalDailyVisitors = dailyVisitors.reduce((sum, item) => sum + item.uniqueVisitors, 0); | |
| const topByHits = [...topPages].sort((a, b) => b.totalHits - a.totalHits)[0]; | |
| const mostUsedFeatureRaw = topByHits?.module || ''; | |
| const mostUsedFeatureCount = topByHits?.totalHits || 0; | |
| const responsePayload = { | |
| overview: { | |
| totalVisitors: weeklyVisitors, | |
| todaysVisitors, | |
| weeklyVisitors, | |
| avgDailyVisitors: Number((totalDailyVisitors / dailyVisitors.length).toFixed(1)), | |
| mostUsedFeature: mostUsedFeatureRaw ? toFeatureLabel(mostUsedFeatureRaw) : 'No feature data', | |
| mostUsedFeatureCount, | |
| hasUsageData: mostUsedFeatureCount > 0, | |
| }, | |
| dailyVisitors, | |
| topPages, | |
| generatedAt: new Date().toISOString(), | |
| }; | |
| analyticsResponseCache.visitorAnalytics = responsePayload; | |
| analyticsResponseCache.visitorAnalyticsExpiresAt = Date.now() + ANALYTICS_CACHE_TTL_MS; | |
| res.json(responsePayload); | |
| }, { defaultStatus: 400 }); | |
| const logClientEvent = asyncController(async (req, res) => { | |
| const path = parseClientPath(req.body?.path); | |
| if (!path || path.startsWith('/admin')) { | |
| return res.status(400).json({ message: 'Invalid client path' }); | |
| } | |
| const { module, label } = clientPathToModule(path); | |
| if (module === 'welcome') { | |
| return res.status(200).json({ success: true }); | |
| } | |
| await recordAuditTrail({ | |
| req, | |
| source: SOURCE_CLIENT, | |
| actor: { id: 'client', name: 'Client Visitor' }, | |
| action: 'other', | |
| moduleName: module, | |
| summary: `Viewed ${label}`, | |
| endpoint: `GET ${path}`, | |
| method: 'GET', | |
| statusCode: 200, | |
| durationMs: 0, | |
| }); | |
| invalidateAnalyticsResponseCache(); | |
| res.status(201).json({ success: true }); | |
| }, { defaultStatus: 400 }); | |
| module.exports = { getAuditTrails, exportAuditTrailsCsv, getVisitorAnalytics, logClientEvent }; | |