tractatus/src/middleware/analytics.middleware.js
TheFlow 2298d36bed fix(submissions): restructure Economist package and fix article display
- Create Economist SubmissionTracking package correctly:
  * mainArticle = full blog post content
  * coverLetter = 216-word SIR— letter
  * Links to blog post via blogPostId
- Archive 'Letter to The Economist' from blog posts (it's the cover letter)
- Fix date display on article cards (use published_at)
- Target publication already displaying via blue badge

Database changes:
- Make blogPostId optional in SubmissionTracking model
- Economist package ID: 68fa85ae49d4900e7f2ecd83
- Le Monde package ID: 68fa2abd2e6acd5691932150

Next: Enhanced modal with tabs, validation, export

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 08:47:42 +13:00

226 lines
5.7 KiB
JavaScript

/**
* Analytics Tracking Middleware
* Privacy-respecting visitor tracking (no cookies, hashed identifiers)
*/
const crypto = require('crypto');
const { PageView, Session } = require('../models/Analytics.model');
/**
* Generate visitor ID from IP + User Agent (hashed for privacy)
*/
function generateVisitorId(ip, userAgent) {
const hash = crypto.createHash('sha256');
hash.update(`${ip}${userAgent}${process.env.ANALYTICS_SALT || 'tractatus-analytics'}`);
return hash.digest('hex').substring(0, 32);
}
/**
* Generate session ID (unique per session)
*/
function generateSessionId() {
return crypto.randomBytes(16).toString('hex');
}
/**
* Parse UTM parameters from query string
*/
function parseUTM(query) {
return {
utmSource: query.utm_source || null,
utmMedium: query.utm_medium || null,
utmCampaign: query.utm_campaign || null,
utmContent: query.utm_content || null,
utmTerm: query.utm_term || null
};
}
/**
* Analytics tracking middleware
*/
async function trackPageView(req, res, next) {
// Skip tracking for:
// - Admin pages
// - API routes
// - Static assets
// - Health checks
const skipPaths = ['/admin/', '/api/', '/css/', '/js/', '/images/', '/fonts/', '/health'];
if (skipPaths.some(path => req.path.startsWith(path))) {
return next();
}
// Skip if this is not a page view (only track HTML pages)
const acceptHeader = req.headers.accept || '';
if (!acceptHeader.includes('text/html')) {
return next();
}
try {
// Get visitor information
const ip = req.ip || req.connection.remoteAddress;
const userAgent = req.headers['user-agent'] || 'Unknown';
const visitorId = generateVisitorId(ip, userAgent);
// Get or create session
let sessionId = req.cookies?.tractatus_session;
if (!sessionId) {
sessionId = generateSessionId();
// Set session cookie (30 minutes expiry)
res.cookie('tractatus_session', sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 30 * 60 * 1000, // 30 minutes
sameSite: 'strict'
});
}
// Parse UTM parameters
const utm = parseUTM(req.query);
// Get referrer (external only)
let referrer = req.headers.referer || req.headers.referrer || '';
if (referrer) {
const referrerHost = new URL(referrer).hostname;
const currentHost = req.hostname;
if (referrerHost === currentHost) {
referrer = ''; // Internal referrer, don't track
}
}
// Get language
const language = req.headers['accept-language']?.split(',')[0] || 'en';
// Check if this is a unique visitor today
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
const existingToday = await PageView.findOne({
visitorId,
timestamp: { $gte: startOfDay }
});
const isUnique = !existingToday;
// Create page view record
const pageView = await PageView.create({
path: req.path,
title: null, // Will be updated by client-side script
referrer,
visitorId,
sessionId,
userAgent,
language,
timestamp: new Date(),
isUnique,
...utm
});
// Update or create session
let session = await Session.findOne({ sessionId });
if (!session) {
// New session
session = await Session.create({
sessionId,
visitorId,
startTime: new Date(),
pageViews: 1,
pages: [req.path],
entryPage: req.path,
referrer,
language,
isActive: true,
...utm
});
} else {
// Update existing session
session.pageViews += 1;
session.pages.push(req.path);
session.exitPage = req.path;
session.endTime = new Date();
session.duration = Math.round((session.endTime - session.startTime) / 1000);
await session.save();
}
// Attach analytics data to request (for potential use in response)
req.analytics = {
visitorId,
sessionId,
pageViewId: pageView._id
};
} catch (error) {
// Analytics failure should not break the site
console.error('[Analytics] Tracking error:', error.message);
}
next();
}
/**
* Track page duration (called when user leaves page)
*/
async function trackPageDuration(req, res, next) {
try {
const { pageViewId, duration } = req.body;
if (pageViewId && duration) {
await PageView.findByIdAndUpdate(pageViewId, {
duration: parseInt(duration, 10)
});
}
res.json({ success: true });
} catch (error) {
console.error('[Analytics] Duration tracking error:', error.message);
res.status(500).json({ error: 'Failed to track duration' });
}
}
/**
* Track page title (called from client-side)
*/
async function trackPageTitle(req, res, next) {
try {
const { pageViewId, title } = req.body;
if (pageViewId && title) {
await PageView.findByIdAndUpdate(pageViewId, {
title: title.substring(0, 200) // Limit title length
});
}
res.json({ success: true });
} catch (error) {
console.error('[Analytics] Title tracking error:', error.message);
res.status(500).json({ error: 'Failed to track title' });
}
}
/**
* Cleanup old sessions (mark as inactive)
*/
async function cleanupSessions() {
try {
const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000);
await Session.updateMany(
{
isActive: true,
endTime: { $lt: thirtyMinutesAgo }
},
{
isActive: false
}
);
} catch (error) {
console.error('[Analytics] Session cleanup error:', error.message);
}
}
// Run cleanup every 5 minutes
setInterval(cleanupSessions, 5 * 60 * 1000);
module.exports = {
trackPageView,
trackPageDuration,
trackPageTitle,
cleanupSessions
};