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