tractatus/src/models/Analytics.model.js
TheFlow 5d05d6b080 fix(analytics): remove SessionSchema.index sessionId duplicate
- Line 49 has sessionId with unique: true (creates index automatically)
- Line 75 had redundant SessionSchema.index({ sessionId: 1 })
- Removed explicit index to eliminate Mongoose duplicate warning
2025-10-24 10:25:02 +13:00

252 lines
6.1 KiB
JavaScript

/**
* Analytics Model - Sovereign Analytics Tracking
* Privacy-respecting, self-hosted analytics (no cookies, no external services)
*/
const mongoose = require('mongoose');
const PageViewSchema = new mongoose.Schema({
// Page Information
path: { type: String, required: true, index: true },
title: { type: String },
referrer: { type: String },
// Visitor Information (privacy-respecting)
visitorId: { type: String, required: true, index: true }, // Hashed IP + User Agent
sessionId: { type: String, required: true }, // Session identifier (indexed via compound index below)
// Request Details
userAgent: { type: String },
language: { type: String },
country: { type: String }, // Optional: derived from IP if available
// Timing
timestamp: { type: Date, default: Date.now, index: true },
duration: { type: Number }, // Time spent on page (if available)
// Metadata
isUnique: { type: Boolean, default: false }, // First visit of the day
isBounce: { type: Boolean, default: false }, // Only page visited in session
// UTM Parameters (marketing attribution)
utmSource: { type: String },
utmMedium: { type: String },
utmCampaign: { type: String },
utmContent: { type: String },
utmTerm: { type: String }
}, {
timestamps: true
});
// Indexes for performance
PageViewSchema.index({ timestamp: -1 });
PageViewSchema.index({ path: 1, timestamp: -1 });
PageViewSchema.index({ visitorId: 1, timestamp: -1 });
PageViewSchema.index({ sessionId: 1, timestamp: -1 });
// Session Schema
const SessionSchema = new mongoose.Schema({
sessionId: { type: String, required: true, unique: true }, // unique already creates index
visitorId: { type: String, required: true, index: true },
startTime: { type: Date, default: Date.now },
endTime: { type: Date },
duration: { type: Number }, // Total session duration in seconds
pageViews: { type: Number, default: 1 },
pages: [{ type: String }], // Paths visited
entryPage: { type: String },
exitPage: { type: String },
referrer: { type: String },
utmSource: { type: String },
utmMedium: { type: String },
utmCampaign: { type: String },
country: { type: String },
language: { type: String },
isActive: { type: Boolean, default: true } // Session still active
}, {
timestamps: true
});
// sessionId already indexed via unique: true on line 49
SessionSchema.index({ visitorId: 1, startTime: -1 });
SessionSchema.index({ startTime: -1 });
// Statics for analytics queries
/**
* Get real-time visitor count (last 5 minutes)
*/
SessionSchema.statics.getRealTimeVisitors = async function() {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
return await this.countDocuments({
isActive: true,
endTime: { $gte: fiveMinutesAgo }
});
};
/**
* Get total page views for a date range
*/
PageViewSchema.statics.getPageViews = async function(startDate, endDate) {
return await this.countDocuments({
timestamp: { $gte: startDate, $lte: endDate }
});
};
/**
* Get unique visitors for a date range
*/
PageViewSchema.statics.getUniqueVisitors = async function(startDate, endDate) {
const result = await this.distinct('visitorId', {
timestamp: { $gte: startDate, $lte: endDate }
});
return result.length;
};
/**
* Get top pages by views
*/
PageViewSchema.statics.getTopPages = async function(startDate, endDate, limit = 10) {
return await this.aggregate([
{
$match: {
timestamp: { $gte: startDate, $lte: endDate }
}
},
{
$group: {
_id: '$path',
views: { $sum: 1 },
uniqueVisitors: { $addToSet: '$visitorId' }
}
},
{
$project: {
path: '$_id',
views: 1,
uniqueVisitors: { $size: '$uniqueVisitors' }
}
},
{
$sort: { views: -1 }
},
{
$limit: limit
}
]);
};
/**
* Get top referrers
*/
PageViewSchema.statics.getTopReferrers = async function(startDate, endDate, limit = 10) {
return await this.aggregate([
{
$match: {
timestamp: { $gte: startDate, $lte: endDate },
referrer: { $exists: true, $ne: '' }
}
},
{
$group: {
_id: '$referrer',
visits: { $sum: 1 }
}
},
{
$project: {
referrer: '$_id',
visits: 1
}
},
{
$sort: { visits: -1 }
},
{
$limit: limit
}
]);
};
/**
* Get hourly page views (for trend charts)
*/
PageViewSchema.statics.getHourlyViews = async function(startDate, endDate) {
return await this.aggregate([
{
$match: {
timestamp: { $gte: startDate, $lte: endDate }
}
},
{
$group: {
_id: {
year: { $year: '$timestamp' },
month: { $month: '$timestamp' },
day: { $dayOfMonth: '$timestamp' },
hour: { $hour: '$timestamp' }
},
views: { $sum: 1 },
uniqueVisitors: { $addToSet: '$visitorId' }
}
},
{
$project: {
hour: '$_id',
views: 1,
uniqueVisitors: { $size: '$uniqueVisitors' }
}
},
{
$sort: { '_id.year': 1, '_id.month': 1, '_id.day': 1, '_id.hour': 1 }
}
]);
};
/**
* Get bounce rate
*/
SessionSchema.statics.getBounceRate = async function(startDate, endDate) {
const totalSessions = await this.countDocuments({
startTime: { $gte: startDate, $lte: endDate }
});
const bouncedSessions = await this.countDocuments({
startTime: { $gte: startDate, $lte: endDate },
pageViews: 1
});
return totalSessions > 0 ? (bouncedSessions / totalSessions) * 100 : 0;
};
/**
* Get average session duration
*/
SessionSchema.statics.getAverageDuration = async function(startDate, endDate) {
const result = await this.aggregate([
{
$match: {
startTime: { $gte: startDate, $lte: endDate },
duration: { $exists: true, $gt: 0 }
}
},
{
$group: {
_id: null,
avgDuration: { $avg: '$duration' }
}
}
]);
return result.length > 0 ? Math.round(result[0].avgDuration) : 0;
};
const PageView = mongoose.model('PageView', PageViewSchema);
const Session = mongoose.model('Session', SessionSchema);
module.exports = { PageView, Session };