- 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>
226 lines
5.7 KiB
JavaScript
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
|
|
};
|