admin e5c32ad25a Add health check utility, logger, metrics, backup, and SMTP toggle scripts
- Implemented a comprehensive health check utility to monitor system dependencies including NocoDB, SMTP, Represent API, disk space, and memory usage.
- Created a logger utility using Winston for structured logging with daily rotation and various log levels.
- Developed a metrics utility using Prometheus client to track application performance metrics such as email sends, HTTP requests, and user activity.
- Added a backup script for automated backups of NocoDB data, uploaded files, and environment configurations with optional S3 support.
- Introduced a toggle script to switch between development (MailHog) and production (ProtonMail) SMTP configurations.
2025-10-23 11:33:00 -06:00

351 lines
9.8 KiB
JavaScript

const logger = require('./logger');
/**
* Analytics tracking utilities for the Influence application
* Provides helpers for tracking campaign conversion, representative response rates,
* user retention, and geographic participation
*/
class Analytics {
constructor() {
this.cache = {
campaignStats: new Map(),
userRetention: new Map(),
geographicData: new Map()
};
}
/**
* Track campaign conversion rate
* @param {string} campaignId - Campaign identifier
* @param {number} visitors - Number of unique visitors
* @param {number} participants - Number of participants who took action
*/
trackCampaignConversion(campaignId, visitors, participants) {
const conversionRate = visitors > 0 ? (participants / visitors) * 100 : 0;
const stats = {
campaignId,
visitors,
participants,
conversionRate: conversionRate.toFixed(2),
timestamp: new Date().toISOString()
};
this.cache.campaignStats.set(campaignId, stats);
logger.info('Campaign conversion tracked', {
event: 'analytics_campaign_conversion',
...stats
});
return stats;
}
/**
* Calculate representative response rate
* @param {string} representativeLevel - Level (Federal, Provincial, Municipal)
* @param {number} emailsSent - Total emails sent
* @param {number} responsesReceived - Total responses received
*/
calculateRepresentativeResponseRate(representativeLevel, emailsSent, responsesReceived) {
const responseRate = emailsSent > 0 ? (responsesReceived / emailsSent) * 100 : 0;
const stats = {
representativeLevel,
emailsSent,
responsesReceived,
responseRate: responseRate.toFixed(2),
timestamp: new Date().toISOString()
};
logger.info('Representative response rate calculated', {
event: 'analytics_response_rate',
...stats
});
return stats;
}
/**
* Track user retention
* @param {string} userId - User identifier
* @param {string} action - Action type (login, campaign_participation, etc.)
*/
trackUserRetention(userId, action) {
let userData = this.cache.userRetention.get(userId) || {
userId,
firstSeen: new Date().toISOString(),
lastSeen: new Date().toISOString(),
actionCount: 0,
actions: []
};
userData.lastSeen = new Date().toISOString();
userData.actionCount += 1;
userData.actions.push({
action,
timestamp: new Date().toISOString()
});
// Keep only last 100 actions per user
if (userData.actions.length > 100) {
userData.actions = userData.actions.slice(-100);
}
this.cache.userRetention.set(userId, userData);
// Determine if user is one-time or repeat
const daysBetween = this.getDaysBetween(userData.firstSeen, userData.lastSeen);
const isRepeatUser = daysBetween > 0 || userData.actionCount > 1;
logger.info('User retention tracked', {
event: 'analytics_user_retention',
userId,
action,
isRepeatUser,
totalActions: userData.actionCount
});
return { ...userData, isRepeatUser };
}
/**
* Track geographic participation by postal code
* @param {string} postalCode - Canadian postal code
* @param {string} campaignId - Campaign identifier
*/
trackGeographicParticipation(postalCode, campaignId) {
const postalPrefix = postalCode.substring(0, 3).toUpperCase();
let geoData = this.cache.geographicData.get(postalPrefix) || {
postalPrefix,
participationCount: 0,
campaigns: new Set()
};
geoData.participationCount += 1;
geoData.campaigns.add(campaignId);
this.cache.geographicData.set(postalPrefix, geoData);
logger.info('Geographic participation tracked', {
event: 'analytics_geographic_participation',
postalPrefix,
campaignId,
totalParticipation: geoData.participationCount
});
return {
postalPrefix,
participationCount: geoData.participationCount,
uniqueCampaigns: geoData.campaigns.size
};
}
/**
* Get geographic heatmap data
* @returns {Array} Array of postal code prefixes with participation counts
*/
getGeographicHeatmap() {
const heatmapData = [];
for (const [postalPrefix, data] of this.cache.geographicData.entries()) {
heatmapData.push({
postalPrefix,
participationCount: data.participationCount,
uniqueCampaigns: data.campaigns.size
});
}
return heatmapData.sort((a, b) => b.participationCount - a.participationCount);
}
/**
* Analyze peak usage times
* @param {Array} events - Array of event objects with timestamps
* @returns {Object} Peak usage analysis
*/
analyzePeakUsageTimes(events) {
const hourCounts = new Array(24).fill(0);
const dayOfWeekCounts = new Array(7).fill(0);
events.forEach(event => {
const date = new Date(event.timestamp);
const hour = date.getHours();
const dayOfWeek = date.getDay();
hourCounts[hour] += 1;
dayOfWeekCounts[dayOfWeek] += 1;
});
const peakHour = hourCounts.indexOf(Math.max(...hourCounts));
const peakDay = dayOfWeekCounts.indexOf(Math.max(...dayOfWeekCounts));
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const analysis = {
peakHour: `${peakHour}:00`,
peakDay: dayNames[peakDay],
hourlyDistribution: hourCounts,
dailyDistribution: dayOfWeekCounts,
totalEvents: events.length
};
logger.info('Peak usage times analyzed', {
event: 'analytics_peak_usage',
...analysis
});
return analysis;
}
/**
* Track device breakdown
* @param {string} userAgent - User agent string
*/
trackDeviceType(userAgent) {
const isMobile = /Mobile|Android|iPhone|iPad|iPod/i.test(userAgent);
const isTablet = /iPad|Android.*Tablet/i.test(userAgent);
const isDesktop = !isMobile && !isTablet;
const deviceType = isTablet ? 'tablet' : isMobile ? 'mobile' : 'desktop';
logger.info('Device type tracked', {
event: 'analytics_device_type',
deviceType,
userAgent: userAgent.substring(0, 100)
});
return deviceType;
}
/**
* Track email deliverability
* @param {string} status - Status (success, bounce, spam, failed)
* @param {string} campaignId - Campaign identifier
* @param {Object} details - Additional details
*/
trackEmailDeliverability(status, campaignId, details = {}) {
const deliverabilityData = {
status,
campaignId,
timestamp: new Date().toISOString(),
...details
};
logger.info('Email deliverability tracked', {
event: 'analytics_email_deliverability',
...deliverabilityData
});
return deliverabilityData;
}
/**
* Generate campaign analytics report
* @param {string} campaignId - Campaign identifier
* @param {Object} data - Campaign data including visitors, participants, emails sent, etc.
*/
generateCampaignReport(campaignId, data) {
const {
visitors = 0,
participants = 0,
emailsSent = 0,
emailsFailed = 0,
responsesReceived = 0,
shareCount = 0,
avgTimeOnPage = 0
} = data;
const conversionRate = visitors > 0 ? ((participants / visitors) * 100).toFixed(2) : 0;
const emailSuccessRate = emailsSent > 0 ? (((emailsSent - emailsFailed) / emailsSent) * 100).toFixed(2) : 0;
const responseRate = emailsSent > 0 ? ((responsesReceived / emailsSent) * 100).toFixed(2) : 0;
const shareRate = participants > 0 ? ((shareCount / participants) * 100).toFixed(2) : 0;
const report = {
campaignId,
metrics: {
visitors,
participants,
conversionRate: `${conversionRate}%`,
emailsSent,
emailsFailed,
emailSuccessRate: `${emailSuccessRate}%`,
responsesReceived,
responseRate: `${responseRate}%`,
shareCount,
shareRate: `${shareRate}%`,
avgTimeOnPage: `${avgTimeOnPage}s`
},
insights: {
performance: conversionRate > 5 ? 'excellent' : conversionRate > 2 ? 'good' : 'needs improvement',
emailHealth: emailSuccessRate > 95 ? 'excellent' : emailSuccessRate > 85 ? 'good' : 'poor',
engagement: responseRate > 10 ? 'high' : responseRate > 3 ? 'medium' : 'low'
},
generatedAt: new Date().toISOString()
};
logger.info('Campaign report generated', {
event: 'analytics_campaign_report',
campaignId,
...report.metrics
});
return report;
}
/**
* Get user retention summary
* @returns {Object} User retention statistics
*/
getUserRetentionSummary() {
let oneTimeUsers = 0;
let repeatUsers = 0;
let totalActions = 0;
for (const [userId, userData] of this.cache.userRetention.entries()) {
totalActions += userData.actionCount;
const daysBetween = this.getDaysBetween(userData.firstSeen, userData.lastSeen);
if (daysBetween > 0 || userData.actionCount > 1) {
repeatUsers += 1;
} else {
oneTimeUsers += 1;
}
}
const totalUsers = oneTimeUsers + repeatUsers;
const retentionRate = totalUsers > 0 ? ((repeatUsers / totalUsers) * 100).toFixed(2) : 0;
return {
totalUsers,
oneTimeUsers,
repeatUsers,
retentionRate: `${retentionRate}%`,
avgActionsPerUser: totalUsers > 0 ? (totalActions / totalUsers).toFixed(2) : 0
};
}
/**
* Helper: Calculate days between two dates
*/
getDaysBetween(date1, date2) {
const d1 = new Date(date1);
const d2 = new Date(date2);
const diffTime = Math.abs(d2 - d1);
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
}
/**
* Clear cache (for testing or memory management)
*/
clearCache() {
this.cache.campaignStats.clear();
this.cache.userRetention.clear();
this.cache.geographicData.clear();
logger.info('Analytics cache cleared');
}
}
module.exports = new Analytics();