c584369d5d
- Password Complexity: Added 4-level complexity selector (Simple/Moderate/Strong/Very Strong) in admin security settings with dynamic backend validation - Gallery Security: Removed event date from login page (security risk), replaced with event type badge - Analytics Config: Fixed "Not Configured" detection logic to check both admin settings and env variables - Analytics Accuracy: Aligned calculation logic between dashboard and analytics endpoints, added totals verification - Translations: Added missing activity keys (analytics_settings_updated, cms_page_updated, security_settings_updated, password_reset, admin_logout, system_activity) - UI Fixes: Fixed German text overflow in CMS page selector with proper CSS truncation - Date Format: Event creation now respects admin-configured date format instead of browser locale - Chrome Compatibility: Replaced emoji flags with SVG components for Windows Chrome support All changes maintain backward compatibility and production stability. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
347 lines
11 KiB
JavaScript
347 lines
11 KiB
JavaScript
const express = require('express');
|
|
const { db } = require('../database/db');
|
|
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
|
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
|
|
const { formatBoolean } = require('../utils/dbCompat');
|
|
const router = express.Router();
|
|
|
|
// Get dashboard statistics
|
|
router.get('/stats', adminAuth, async (req, res) => {
|
|
try {
|
|
// Get active events count
|
|
const activeEvents = await db('events')
|
|
.where('is_active', formatBoolean(true))
|
|
.where('is_archived', formatBoolean(false))
|
|
.count('id as count')
|
|
.first();
|
|
|
|
// Get events expiring within 7 days
|
|
const sevenDaysFromNow = new Date();
|
|
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
|
|
const now = new Date();
|
|
|
|
const expiringEvents = await db('events')
|
|
.where('is_active', formatBoolean(true))
|
|
.where('is_archived', formatBoolean(false))
|
|
.where('expires_at', '<=', sevenDaysFromNow.toISOString())
|
|
.where('expires_at', '>', now.toISOString())
|
|
.count('id as count')
|
|
.first();
|
|
|
|
// Get total photos count
|
|
const totalPhotos = await db('photos')
|
|
.count('id as count')
|
|
.first();
|
|
|
|
// Get storage usage (sum of all photo sizes)
|
|
const storageUsed = await db('photos')
|
|
.sum('size_bytes as total')
|
|
.first();
|
|
|
|
// Get total views (last 30 days)
|
|
const thirtyDaysAgo = new Date();
|
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
|
|
const totalViews = await db('access_logs')
|
|
.where('action', 'view')
|
|
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
|
|
.count('id as count')
|
|
.first();
|
|
|
|
// Get total downloads (last 30 days) - include both single and bulk downloads
|
|
const totalDownloads = await db('access_logs')
|
|
.whereIn('action', ['download', 'download_all'])
|
|
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
|
|
.count('id as count')
|
|
.first();
|
|
|
|
// Get archived events count
|
|
const archivedEvents = await db('events')
|
|
.where('is_archived', formatBoolean(true))
|
|
.count('id as count')
|
|
.first();
|
|
|
|
// Calculate trends (compare with previous 30 days)
|
|
const sixtyDaysAgo = new Date();
|
|
sixtyDaysAgo.setDate(sixtyDaysAgo.getDate() - 60);
|
|
|
|
const previousViews = await db('access_logs')
|
|
.where('action', 'view')
|
|
.where('timestamp', '>=', sixtyDaysAgo.toISOString())
|
|
.where('timestamp', '<', thirtyDaysAgo.toISOString())
|
|
.count('id as count')
|
|
.first();
|
|
|
|
const previousDownloads = await db('access_logs')
|
|
.whereIn('action', ['download', 'download_all'])
|
|
.where('timestamp', '>=', sixtyDaysAgo.toISOString())
|
|
.where('timestamp', '<', thirtyDaysAgo.toISOString())
|
|
.count('id as count')
|
|
.first();
|
|
|
|
// Calculate trend percentages
|
|
const viewsTrend = previousViews.count > 0
|
|
? ((totalViews.count - previousViews.count) / previousViews.count) * 100
|
|
: 0;
|
|
|
|
const downloadsTrend = previousDownloads.count > 0
|
|
? ((totalDownloads.count - previousDownloads.count) / previousDownloads.count) * 100
|
|
: 0;
|
|
|
|
res.json({
|
|
activeEvents: activeEvents.count || 0,
|
|
expiringEvents: expiringEvents.count || 0,
|
|
totalPhotos: totalPhotos.count || 0,
|
|
storageUsed: storageUsed.total || 0,
|
|
totalViews: totalViews.count || 0,
|
|
totalDownloads: totalDownloads.count || 0,
|
|
viewsTrend: Math.round(viewsTrend * 10) / 10,
|
|
downloadsTrend: Math.round(downloadsTrend * 10) / 10,
|
|
archivedEvents: archivedEvents.count || 0
|
|
});
|
|
} catch (error) {
|
|
console.error('Dashboard stats error:', error);
|
|
res.status(500).json({ error: 'Failed to fetch dashboard statistics' });
|
|
}
|
|
});
|
|
|
|
// Get recent activity
|
|
router.get('/activity', adminAuth, async (req, res) => {
|
|
try {
|
|
const limit = parseInt(req.query.limit) || 10;
|
|
|
|
const activities = await db('activity_logs')
|
|
.select('activity_logs.*', 'events.event_name')
|
|
.leftJoin('events', 'activity_logs.event_id', 'events.id')
|
|
.orderBy('activity_logs.created_at', 'desc')
|
|
.limit(limit);
|
|
|
|
// Format activities
|
|
const formattedActivities = activities.map(activity => ({
|
|
id: activity.id,
|
|
type: activity.activity_type,
|
|
actorType: activity.actor_type,
|
|
actorName: activity.actor_name,
|
|
eventName: activity.event_name,
|
|
metadata: (() => {
|
|
try {
|
|
if (!activity.metadata) return {};
|
|
if (typeof activity.metadata === 'object') return activity.metadata;
|
|
return JSON.parse(activity.metadata);
|
|
} catch (e) {
|
|
console.warn('Failed to parse metadata for activity:', activity.id, e.message);
|
|
return {};
|
|
}
|
|
})(),
|
|
createdAt: activity.created_at
|
|
}));
|
|
|
|
res.json(formattedActivities);
|
|
} catch (error) {
|
|
console.error('Activity log error:', error);
|
|
res.status(500).json({ error: 'Failed to fetch activity log' });
|
|
}
|
|
});
|
|
|
|
// Get system health status
|
|
router.get('/health', adminAuth, async (req, res) => {
|
|
try {
|
|
const os = require('os');
|
|
|
|
// Check database connectivity
|
|
let dbStatus = 'healthy';
|
|
try {
|
|
await db.raw('SELECT 1');
|
|
} catch (error) {
|
|
dbStatus = 'error';
|
|
}
|
|
|
|
// Check email queue
|
|
const [pendingEmails] = await db('email_queue')
|
|
.where('status', 'pending')
|
|
.count('* as count');
|
|
|
|
const twentyFourHoursAgo = new Date();
|
|
twentyFourHoursAgo.setHours(twentyFourHoursAgo.getHours() - 24);
|
|
|
|
const [failedEmails] = await db('email_queue')
|
|
.where('status', 'failed')
|
|
.where('created_at', '>=', twentyFourHoursAgo.toISOString())
|
|
.count('* as count');
|
|
|
|
const emailStatus = failedEmails.count > 10 ? 'warning' : 'healthy';
|
|
|
|
// Check disk space (simplified)
|
|
const storageStatus = 'healthy'; // In production, check actual disk usage
|
|
|
|
// Memory usage
|
|
const memoryUsage = {
|
|
total: os.totalmem(),
|
|
free: os.freemem(),
|
|
used: os.totalmem() - os.freemem(),
|
|
percentage: Math.round(((os.totalmem() - os.freemem()) / os.totalmem()) * 100)
|
|
};
|
|
|
|
const memoryStatus = memoryUsage.percentage > 90 ? 'warning' : 'healthy';
|
|
|
|
// Overall health
|
|
const statuses = [dbStatus, emailStatus, storageStatus, memoryStatus];
|
|
let overallHealth = 'healthy';
|
|
if (statuses.includes('error')) overallHealth = 'error';
|
|
else if (statuses.includes('warning')) overallHealth = 'warning';
|
|
|
|
res.json({
|
|
overall: overallHealth,
|
|
services: {
|
|
database: dbStatus,
|
|
email: emailStatus,
|
|
storage: storageStatus,
|
|
memory: memoryStatus
|
|
},
|
|
details: {
|
|
emailQueue: {
|
|
pending: pendingEmails.count,
|
|
failed: failedEmails.count
|
|
},
|
|
memory: memoryUsage
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Health check error:', error);
|
|
res.status(500).json({
|
|
overall: 'error',
|
|
error: 'Failed to check system health'
|
|
});
|
|
}
|
|
});
|
|
|
|
// Get analytics data for charts
|
|
router.get('/analytics', adminAuth, async (req, res) => {
|
|
try {
|
|
const days = sanitizeDays(req.query.days || 7);
|
|
|
|
// Generate date range
|
|
const dates = [];
|
|
for (let i = days - 1; i >= 0; i--) {
|
|
dates.push({
|
|
date: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
|
views: 0,
|
|
downloads: 0,
|
|
uniqueVisitors: 0
|
|
});
|
|
}
|
|
|
|
// Calculate the start date for queries
|
|
const startDate = new Date();
|
|
startDate.setDate(startDate.getDate() - days);
|
|
const startDateStr = startDate.toISOString();
|
|
|
|
// Get views per day
|
|
const viewsData = await db('access_logs')
|
|
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count'))
|
|
.where('action', 'view')
|
|
.where('timestamp', '>=', startDateStr)
|
|
.groupByRaw('DATE(timestamp)');
|
|
|
|
// Get downloads per day - include both single and bulk downloads
|
|
const downloadsData = await db('access_logs')
|
|
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count'))
|
|
.whereIn('action', ['download', 'download_all'])
|
|
.where('timestamp', '>=', startDateStr)
|
|
.groupByRaw('DATE(timestamp)');
|
|
|
|
// Get unique visitors per day
|
|
const visitorsData = await db('access_logs')
|
|
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(DISTINCT ip_address) as count'))
|
|
.where('timestamp', '>=', startDateStr)
|
|
.groupByRaw('DATE(timestamp)');
|
|
|
|
// Merge data into dates array
|
|
viewsData.forEach(row => {
|
|
const dateObj = dates.find(d => d.date === row.date);
|
|
if (dateObj) dateObj.views = row.count;
|
|
});
|
|
|
|
downloadsData.forEach(row => {
|
|
const dateObj = dates.find(d => d.date === row.date);
|
|
if (dateObj) dateObj.downloads = row.count;
|
|
});
|
|
|
|
visitorsData.forEach(row => {
|
|
const dateObj = dates.find(d => d.date === row.date);
|
|
if (dateObj) dateObj.uniqueVisitors = row.count;
|
|
});
|
|
|
|
// Get top galleries by views with additional metrics
|
|
const topGalleries = await db('access_logs')
|
|
.select('events.id', 'events.event_name', 'events.slug')
|
|
.select(db.raw('COUNT(CASE WHEN action = \'view\' THEN 1 END) as views'))
|
|
.select(db.raw('COUNT(DISTINCT CASE WHEN action = \'view\' THEN ip_address END) as uniqueVisitors'))
|
|
.select(db.raw('COUNT(CASE WHEN action IN (\'download\', \'download_all\') THEN 1 END) as downloads'))
|
|
.join('events', 'access_logs.event_id', 'events.id')
|
|
.where('access_logs.timestamp', '>=', startDateStr)
|
|
.groupBy('events.id', 'events.event_name', 'events.slug')
|
|
.orderBy('views', 'desc')
|
|
.limit(5);
|
|
|
|
// Get device breakdown (simplified - based on user agent)
|
|
const deviceData = await db('access_logs')
|
|
.select(
|
|
db.raw(`
|
|
CASE
|
|
WHEN user_agent LIKE '%Mobile%' THEN 'mobile'
|
|
WHEN user_agent LIKE '%Tablet%' OR user_agent LIKE '%iPad%' THEN 'tablet'
|
|
ELSE 'desktop'
|
|
END as device_type
|
|
`),
|
|
db.raw('COUNT(*) as count')
|
|
)
|
|
.where('timestamp', '>=', startDateStr)
|
|
.groupBy('device_type');
|
|
|
|
const totalDevices = deviceData.reduce((sum, d) => sum + d.count, 0);
|
|
const devices = {
|
|
desktop: 0,
|
|
mobile: 0,
|
|
tablet: 0
|
|
};
|
|
|
|
deviceData.forEach(d => {
|
|
devices[d.device_type] = Math.round((d.count / totalDevices) * 100);
|
|
});
|
|
|
|
// Calculate totals for the period (matching /stats logic)
|
|
const totalViews = await db('access_logs')
|
|
.where('action', 'view')
|
|
.where('timestamp', '>=', startDateStr)
|
|
.count('id as count')
|
|
.first();
|
|
|
|
const totalDownloadsCount = await db('access_logs')
|
|
.whereIn('action', ['download', 'download_all'])
|
|
.where('timestamp', '>=', startDateStr)
|
|
.count('id as count')
|
|
.first();
|
|
|
|
const totalUniqueVisitors = await db('access_logs')
|
|
.where('timestamp', '>=', startDateStr)
|
|
.countDistinct('ip_address as count')
|
|
.first();
|
|
|
|
res.json({
|
|
chartData: dates,
|
|
topGalleries,
|
|
devices,
|
|
totals: {
|
|
views: totalViews?.count || 0,
|
|
downloads: totalDownloadsCount?.count || 0,
|
|
uniqueVisitors: totalUniqueVisitors?.count || 0
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Analytics error:', error);
|
|
res.status(500).json({ error: 'Failed to fetch analytics data' });
|
|
}
|
|
});
|
|
|
|
module.exports = router; |