- 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>
296 lines
6.9 KiB
JavaScript
296 lines
6.9 KiB
JavaScript
/**
|
|
* Analytics Controller
|
|
* API endpoints for analytics dashboard
|
|
*/
|
|
|
|
const { PageView, Session } = require('../models/Analytics.model');
|
|
|
|
/**
|
|
* Get analytics overview
|
|
*/
|
|
async function getOverview(req, res) {
|
|
try {
|
|
const { period = '24h' } = req.query;
|
|
|
|
// Calculate date range
|
|
let startDate;
|
|
const endDate = new Date();
|
|
|
|
switch (period) {
|
|
case '1h':
|
|
startDate = new Date(Date.now() - 60 * 60 * 1000);
|
|
break;
|
|
case '24h':
|
|
startDate = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
break;
|
|
case '7d':
|
|
startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
break;
|
|
case '30d':
|
|
startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
break;
|
|
default:
|
|
startDate = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
}
|
|
|
|
// Get real-time visitors
|
|
const realTimeVisitors = await Session.getRealTimeVisitors();
|
|
|
|
// Get total page views
|
|
const totalPageViews = await PageView.getPageViews(startDate, endDate);
|
|
|
|
// Get unique visitors
|
|
const uniqueVisitors = await PageView.getUniqueVisitors(startDate, endDate);
|
|
|
|
// Get bounce rate
|
|
const bounceRate = await Session.getBounceRate(startDate, endDate);
|
|
|
|
// Get average session duration
|
|
const avgDuration = await Session.getAverageDuration(startDate, endDate);
|
|
|
|
// Get top pages
|
|
const topPages = await PageView.getTopPages(startDate, endDate, 10);
|
|
|
|
// Get top referrers
|
|
const topReferrers = await PageView.getTopReferrers(startDate, endDate, 10);
|
|
|
|
res.json({
|
|
success: true,
|
|
period,
|
|
data: {
|
|
realTimeVisitors,
|
|
totalPageViews,
|
|
uniqueVisitors,
|
|
bounceRate: Math.round(bounceRate * 10) / 10,
|
|
avgDuration,
|
|
topPages,
|
|
topReferrers
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('[Analytics] Overview error:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Failed to fetch analytics overview'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get hourly trend data
|
|
*/
|
|
async function getHourlyTrend(req, res) {
|
|
try {
|
|
const { hours = 24 } = req.query;
|
|
|
|
const startDate = new Date(Date.now() - parseInt(hours, 10) * 60 * 60 * 1000);
|
|
const endDate = new Date();
|
|
|
|
const hourlyData = await PageView.getHourlyViews(startDate, endDate);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: hourlyData
|
|
});
|
|
} catch (error) {
|
|
console.error('[Analytics] Hourly trend error:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Failed to fetch hourly trend'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get live visitors (for real-time dashboard)
|
|
*/
|
|
async function getLiveVisitors(req, res) {
|
|
try {
|
|
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
|
|
|
const activeSessions = await Session.find({
|
|
isActive: true,
|
|
endTime: { $gte: fiveMinutesAgo }
|
|
}).select('entryPage exitPage pages startTime endTime language');
|
|
|
|
const currentPages = await PageView.find({
|
|
timestamp: { $gte: fiveMinutesAgo }
|
|
})
|
|
.sort({ timestamp: -1 })
|
|
.limit(20)
|
|
.select('path timestamp visitorId sessionId');
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
count: activeSessions.length,
|
|
sessions: activeSessions,
|
|
recentPages: currentPages
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('[Analytics] Live visitors error:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Failed to fetch live visitors'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get page-specific analytics
|
|
*/
|
|
async function getPageAnalytics(req, res) {
|
|
try {
|
|
const { path, period = '7d' } = req.query;
|
|
|
|
if (!path) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Page path required'
|
|
});
|
|
}
|
|
|
|
// Calculate date range
|
|
let startDate;
|
|
const endDate = new Date();
|
|
|
|
switch (period) {
|
|
case '24h':
|
|
startDate = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
break;
|
|
case '7d':
|
|
startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
break;
|
|
case '30d':
|
|
startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
break;
|
|
default:
|
|
startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
}
|
|
|
|
const pageViews = await PageView.countDocuments({
|
|
path,
|
|
timestamp: { $gte: startDate, $lte: endDate }
|
|
});
|
|
|
|
const uniqueVisitors = await PageView.distinct('visitorId', {
|
|
path,
|
|
timestamp: { $gte: startDate, $lte: endDate }
|
|
});
|
|
|
|
const avgDuration = await PageView.aggregate([
|
|
{
|
|
$match: {
|
|
path,
|
|
timestamp: { $gte: startDate, $lte: endDate },
|
|
duration: { $exists: true, $gt: 0 }
|
|
}
|
|
},
|
|
{
|
|
$group: {
|
|
_id: null,
|
|
avgDuration: { $avg: '$duration' }
|
|
}
|
|
}
|
|
]);
|
|
|
|
res.json({
|
|
success: true,
|
|
path,
|
|
period,
|
|
data: {
|
|
pageViews,
|
|
uniqueVisitors: uniqueVisitors.length,
|
|
avgDuration: avgDuration.length > 0 ? Math.round(avgDuration[0].avgDuration) : 0
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('[Analytics] Page analytics error:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Failed to fetch page analytics'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get UTM campaign performance
|
|
*/
|
|
async function getCampaignAnalytics(req, res) {
|
|
try {
|
|
const { period = '30d' } = req.query;
|
|
|
|
let startDate;
|
|
const endDate = new Date();
|
|
|
|
switch (period) {
|
|
case '7d':
|
|
startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
break;
|
|
case '30d':
|
|
startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
break;
|
|
case '90d':
|
|
startDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
|
break;
|
|
default:
|
|
startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
}
|
|
|
|
const campaigns = await PageView.aggregate([
|
|
{
|
|
$match: {
|
|
timestamp: { $gte: startDate, $lte: endDate },
|
|
utmCampaign: { $exists: true, $ne: null }
|
|
}
|
|
},
|
|
{
|
|
$group: {
|
|
_id: {
|
|
campaign: '$utmCampaign',
|
|
source: '$utmSource',
|
|
medium: '$utmMedium'
|
|
},
|
|
visits: { $sum: 1 },
|
|
uniqueVisitors: { $addToSet: '$visitorId' }
|
|
}
|
|
},
|
|
{
|
|
$project: {
|
|
campaign: '$_id.campaign',
|
|
source: '$_id.source',
|
|
medium: '$_id.medium',
|
|
visits: 1,
|
|
uniqueVisitors: { $size: '$uniqueVisitors' }
|
|
}
|
|
},
|
|
{
|
|
$sort: { visits: -1 }
|
|
},
|
|
{
|
|
$limit: 20
|
|
}
|
|
]);
|
|
|
|
res.json({
|
|
success: true,
|
|
period,
|
|
data: campaigns
|
|
});
|
|
} catch (error) {
|
|
console.error('[Analytics] Campaign analytics error:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Failed to fetch campaign analytics'
|
|
});
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
getOverview,
|
|
getHourlyTrend,
|
|
getLiveVisitors,
|
|
getPageAnalytics,
|
|
getCampaignAnalytics
|
|
};
|