tinfo-web-server / controllers /auditTrailController.js
Faridaqurr
initial backend
e8c33fa
Raw
History Blame Contribute Delete
14.5 kB
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 };