- Shows cross-platform totals (posts, views, likes, comments) - Breaks down by platform with icons - Clickable link to full Social Analytics page Chronicler #76
475 lines
16 KiB
JavaScript
475 lines
16 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const { getRoleMappings, saveRoleMappings } = require('../utils/roleMappings');
|
|
const { getFinancialMetrics } = require('../services/FinancialsService');
|
|
|
|
const isAdmin = (req, res, next) => {
|
|
if (req.isAuthenticated()) {
|
|
const admins = process.env.ADMIN_USERS.split(',');
|
|
if (admins.includes(req.user.id)) return next();
|
|
}
|
|
res.status(403).send('Forbidden: Admin access only.');
|
|
};
|
|
|
|
// TODO: Replace with full beautiful UI from live bot.js
|
|
router.get('/', isAdmin, async (req, res) => {
|
|
try {
|
|
const mappings = getRoleMappings();
|
|
|
|
// Fetch social stats across all platforms
|
|
const socialStats = await db.query(`
|
|
SELECT
|
|
platform,
|
|
COUNT(*) as post_count,
|
|
COALESCE(SUM(views), 0) as total_views,
|
|
COALESCE(SUM(likes), 0) as total_likes,
|
|
COALESCE(SUM(comments), 0) as total_comments
|
|
FROM social_posts
|
|
GROUP BY platform
|
|
`);
|
|
|
|
// Aggregate totals
|
|
const socialTotals = {
|
|
posts: 0,
|
|
views: 0,
|
|
likes: 0,
|
|
comments: 0,
|
|
platforms: {}
|
|
};
|
|
|
|
for (const row of socialStats.rows) {
|
|
socialTotals.posts += parseInt(row.post_count);
|
|
socialTotals.views += parseInt(row.total_views);
|
|
socialTotals.likes += parseInt(row.total_likes);
|
|
socialTotals.comments += parseInt(row.total_comments);
|
|
socialTotals.platforms[row.platform] = {
|
|
posts: parseInt(row.post_count),
|
|
views: parseInt(row.total_views),
|
|
likes: parseInt(row.total_likes)
|
|
};
|
|
}
|
|
|
|
res.render('admin/dashboard', {
|
|
title: 'Dashboard',
|
|
adminUser: req.user,
|
|
csrfToken: req.csrfToken(),
|
|
mappings: mappings,
|
|
currentPath: '/dashboard',
|
|
socialTotals
|
|
});
|
|
} catch (error) {
|
|
console.error('Admin dashboard error:', error);
|
|
res.status(500).send('Internal Server Error: ' + error.message);
|
|
}
|
|
});
|
|
|
|
// Server Matrix Module
|
|
router.get('/servers', isAdmin, async (req, res) => {
|
|
try {
|
|
res.render('admin/servers/index', {
|
|
title: 'Server Matrix',
|
|
adminUser: req.user,
|
|
csrfToken: req.csrfToken(),
|
|
currentPath: '/servers'
|
|
});
|
|
} catch (error) {
|
|
console.error('Server matrix error:', error);
|
|
res.status(500).send('Internal Server Error: ' + error.message);
|
|
}
|
|
});
|
|
|
|
// Player Management Module
|
|
router.get('/players', isAdmin, async (req, res) => {
|
|
try {
|
|
res.render('admin/players/index', {
|
|
title: 'Player Management',
|
|
adminUser: req.user,
|
|
csrfToken: req.csrfToken(),
|
|
currentPath: '/players'
|
|
});
|
|
} catch (error) {
|
|
console.error('Player management error:', error);
|
|
res.status(500).send('Internal Server Error: ' + error.message);
|
|
}
|
|
});
|
|
|
|
// Role Management Module
|
|
router.get('/roles', isAdmin, async (req, res) => {
|
|
try {
|
|
const mappings = getRoleMappings();
|
|
res.render('admin/roles/index', {
|
|
title: 'Role Management',
|
|
adminUser: req.user,
|
|
csrfToken: req.csrfToken(),
|
|
mappings: mappings,
|
|
currentPath: '/roles'
|
|
});
|
|
} catch (error) {
|
|
console.error('Role management error:', error);
|
|
res.status(500).send('Internal Server Error: ' + error.message);
|
|
}
|
|
});
|
|
|
|
// Grace Period Management Module
|
|
router.get('/grace', isAdmin, async (req, res) => {
|
|
try {
|
|
res.render('admin/grace/index', {
|
|
title: 'Grace Period Management',
|
|
adminUser: req.user,
|
|
csrfToken: req.csrfToken(),
|
|
currentPath: '/grace'
|
|
});
|
|
} catch (error) {
|
|
console.error('Grace period management error:', error);
|
|
res.status(500).send('Internal Server Error: ' + error.message);
|
|
}
|
|
});
|
|
|
|
// Audit Log Module
|
|
router.get('/audit', isAdmin, async (req, res) => {
|
|
try {
|
|
res.render('admin/audit/index', {
|
|
title: 'Audit Log',
|
|
adminUser: req.user,
|
|
csrfToken: req.csrfToken(),
|
|
currentPath: '/audit'
|
|
});
|
|
} catch (error) {
|
|
console.error('Audit log error:', error);
|
|
res.status(500).send('Internal Server Error: ' + error.message);
|
|
}
|
|
});
|
|
|
|
// Financials Module
|
|
router.get('/financials', isAdmin, async (req, res) => {
|
|
try {
|
|
// Fetch real financial data from PostgreSQL
|
|
const financialData = await getFinancialMetrics();
|
|
|
|
res.render('admin/financials/index', {
|
|
title: 'Financials',
|
|
adminUser: req.user,
|
|
csrfToken: req.csrfToken(),
|
|
currentPath: '/financials',
|
|
metrics: financialData.metrics,
|
|
paths: financialData.paths,
|
|
tierBreakdown: financialData.tierBreakdown
|
|
});
|
|
} catch (error) {
|
|
console.error('Financials error:', error);
|
|
res.status(500).send('Internal Server Error: ' + error.message);
|
|
}
|
|
});
|
|
|
|
router.post('/mappings', isAdmin, express.json(), (req, res) => {
|
|
const newMappings = req.body;
|
|
if (saveRoleMappings(newMappings)) {
|
|
res.status(200).send('Mappings updated');
|
|
} else {
|
|
res.status(500).send('Failed to save mappings');
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|
|
|
|
// ==========================================
|
|
// HTMX API ENDPOINTS (Return HTML fragments)
|
|
// ==========================================
|
|
|
|
// Servers Matrix Endpoint
|
|
router.get('/servers/matrix', isAdmin, async (req, res) => {
|
|
// Static server list from infrastructure
|
|
const servers = [
|
|
{ name: 'Awakened Survival', machine: 'TX1', status: 'online', players: '0/20' },
|
|
{ name: 'Fire PvP Arena', machine: 'TX1', status: 'online', players: '0/50' },
|
|
{ name: 'Frost Creative', machine: 'TX1', status: 'online', players: '0/30' },
|
|
{ name: 'Knight Hardcore', machine: 'NC1', status: 'online', players: '0/25' },
|
|
{ name: 'Master Skyblock', machine: 'NC1', status: 'online', players: '0/40' },
|
|
{ name: 'Legend Factions', machine: 'NC1', status: 'online', players: '0/60' },
|
|
{ name: 'Sovereign Network Hub', machine: 'TX1', status: 'online', players: '0/100' }
|
|
];
|
|
|
|
let html = '<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">';
|
|
|
|
servers.forEach(server => {
|
|
const statusColor = server.status === 'online' ? 'bg-green-500' : 'bg-red-500';
|
|
html += `
|
|
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="font-medium dark:text-white">${server.name}</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="w-2 h-2 rounded-full ${statusColor}"></span>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">${server.status}</span>
|
|
</div>
|
|
</div>
|
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
<div>Machine: ${server.machine}</div>
|
|
<div>Players: ${server.players}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += '</div>';
|
|
html += `
|
|
<div class="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
|
<p class="text-sm text-blue-800 dark:text-blue-200">
|
|
💡 <strong>Note:</strong> This is static data. Real-time Pterodactyl API integration coming soon.
|
|
</p>
|
|
</div>
|
|
`;
|
|
|
|
res.send(html);
|
|
});
|
|
|
|
// Players Table Endpoint
|
|
router.get('/players/table', isAdmin, async (req, res) => {
|
|
const { Pool } = require('pg');
|
|
const pool = new Pool({
|
|
host: '127.0.0.1',
|
|
user: 'arbiter',
|
|
password: 'FireFrost2026!Arbiter',
|
|
database: 'arbiter_db'
|
|
});
|
|
|
|
try {
|
|
const result = await pool.query(`
|
|
SELECT
|
|
s.id,
|
|
s.discord_id,
|
|
s.tier_level,
|
|
p.tier_name,
|
|
s.status,
|
|
s.created_at,
|
|
s.mrr_value
|
|
FROM subscriptions s
|
|
LEFT JOIN stripe_products p ON s.tier_level = p.tier_level
|
|
ORDER BY s.created_at DESC
|
|
LIMIT 100
|
|
`);
|
|
|
|
if (result.rows.length === 0) {
|
|
res.send(`
|
|
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
|
<p class="text-lg">👥 No subscribers yet</p>
|
|
<p class="text-sm mt-2">Subscribers will appear here after first signup</p>
|
|
</div>
|
|
`);
|
|
} else {
|
|
let html = `
|
|
<table class="w-full">
|
|
<thead class="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
|
<tr>
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Discord ID</th>
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Tier</th>
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
|
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">MRR</th>
|
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Since</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
|
`;
|
|
|
|
result.rows.forEach(row => {
|
|
const statusColor = row.status === 'active' ? 'text-green-600' :
|
|
row.status === 'lifetime' ? 'text-purple-600' :
|
|
row.status === 'grace_period' ? 'text-yellow-600' :
|
|
'text-gray-600';
|
|
const date = new Date(row.created_at);
|
|
html += `
|
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
<td class="px-4 py-3 text-sm font-mono dark:text-white">${row.discord_id || 'N/A'}</td>
|
|
<td class="px-4 py-3 text-sm dark:text-white">${row.tier_name || 'Tier ' + row.tier_level}</td>
|
|
<td class="px-4 py-3">
|
|
<span class="text-sm font-medium ${statusColor}">${row.status}</span>
|
|
</td>
|
|
<td class="px-4 py-3 text-sm text-right dark:text-white">$${parseFloat(row.mrr_value || 0).toFixed(2)}</td>
|
|
<td class="px-4 py-3 text-sm text-right text-gray-500 dark:text-gray-400">${date.toLocaleDateString()}</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
|
|
html += '</tbody></table>';
|
|
res.send(html);
|
|
}
|
|
} catch (error) {
|
|
res.send(`<div class="p-6 text-red-600">Error: ${error.message}</div>`);
|
|
}
|
|
});
|
|
|
|
// Grace Period List Endpoint
|
|
router.get('/grace/list', isAdmin, async (req, res) => {
|
|
const { Pool } = require('pg');
|
|
const pool = new Pool({
|
|
host: '127.0.0.1',
|
|
user: 'arbiter',
|
|
password: 'FireFrost2026!Arbiter',
|
|
database: 'arbiter_db'
|
|
});
|
|
|
|
try {
|
|
const result = await pool.query(`
|
|
SELECT discord_id, tier_level, grace_period_started_at, grace_period_ends_at
|
|
FROM subscriptions
|
|
WHERE status = 'grace_period'
|
|
ORDER BY grace_period_ends_at ASC
|
|
`);
|
|
|
|
if (result.rows.length === 0) {
|
|
res.send(`
|
|
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
|
<p class="text-lg">✅ No users in grace period!</p>
|
|
<p class="text-sm mt-2">All subscribers are current on payments</p>
|
|
</div>
|
|
`);
|
|
} else {
|
|
let html = '<div class="divide-y divide-gray-200 dark:divide-gray-700">';
|
|
result.rows.forEach(row => {
|
|
const endsAt = new Date(row.grace_period_ends_at);
|
|
const hoursLeft = Math.round((endsAt - new Date()) / (1000 * 60 * 60));
|
|
html += `
|
|
<div class="p-4 hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
<div class="flex justify-between items-center">
|
|
<div>
|
|
<div class="font-medium dark:text-white">Discord ID: ${row.discord_id}</div>
|
|
<div class="text-sm text-gray-500">Tier ${row.tier_level}</div>
|
|
</div>
|
|
<div class="text-right">
|
|
<div class="text-yellow-600 dark:text-yellow-400 font-medium">${hoursLeft}h remaining</div>
|
|
<div class="text-xs text-gray-500">${endsAt.toLocaleString()}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
html += '</div>';
|
|
res.send(html);
|
|
}
|
|
} catch (error) {
|
|
res.send(`<div class="p-6 text-red-600">Error: ${error.message}</div>`);
|
|
}
|
|
});
|
|
|
|
// Audit Log Feed Endpoint
|
|
router.get('/audit/feed', isAdmin, async (req, res) => {
|
|
const { Pool } = require('pg');
|
|
const pool = new Pool({
|
|
host: '127.0.0.1',
|
|
user: 'arbiter',
|
|
password: 'FireFrost2026!Arbiter',
|
|
database: 'arbiter_db'
|
|
});
|
|
|
|
try {
|
|
const result = await pool.query(`
|
|
SELECT *
|
|
FROM webhook_events_processed
|
|
ORDER BY processed_at DESC
|
|
LIMIT 50
|
|
`);
|
|
|
|
if (result.rows.length === 0) {
|
|
res.send(`
|
|
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
|
<p class="text-lg">📋 No webhook events yet</p>
|
|
<p class="text-sm mt-2">Events will appear here as Stripe webhooks are processed</p>
|
|
</div>
|
|
`);
|
|
} else {
|
|
let html = '<div class="divide-y divide-gray-200 dark:divide-gray-700">';
|
|
result.rows.forEach(row => {
|
|
const timestamp = new Date(row.processed_at);
|
|
const eventType = row.event_type || 'unknown';
|
|
const eventId = row.stripe_event_id || row.id || 'unknown';
|
|
const eventColor = eventType.includes('succeeded') ? 'text-green-600' :
|
|
eventType.includes('failed') ? 'text-red-600' :
|
|
eventType.includes('dispute') ? 'text-red-600' :
|
|
'text-blue-600';
|
|
html += `
|
|
<div class="p-4 hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
<div class="flex justify-between items-start">
|
|
<div class="flex-1">
|
|
<div class="font-mono text-xs text-gray-500 mb-1">${eventId}</div>
|
|
<div class="font-medium ${eventColor}">${eventType}</div>
|
|
</div>
|
|
<div class="text-right text-sm text-gray-500 dark:text-gray-400">
|
|
${timestamp.toLocaleString()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
html += '</div>';
|
|
res.send(html);
|
|
}
|
|
} catch (error) {
|
|
res.send(`<div class="p-6 text-red-600">Error: ${error.message}</div>`);
|
|
}
|
|
});
|
|
|
|
// Role Mismatches Diagnostic Endpoint
|
|
router.get('/roles/mismatches', isAdmin, async (req, res) => {
|
|
const { Pool } = require('pg');
|
|
const pool = new Pool({
|
|
host: '127.0.0.1',
|
|
user: 'arbiter',
|
|
password: 'FireFrost2026!Arbiter',
|
|
database: 'arbiter_db'
|
|
});
|
|
|
|
try {
|
|
// Get subscription counts by tier
|
|
const result = await pool.query(`
|
|
SELECT tier_level, COUNT(*) as count, status
|
|
FROM subscriptions
|
|
WHERE status IN ('active', 'lifetime')
|
|
GROUP BY tier_level, status
|
|
ORDER BY tier_level
|
|
`);
|
|
|
|
if (result.rows.length === 0) {
|
|
res.send(`
|
|
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
|
<p class="text-lg">✅ No active subscriptions</p>
|
|
<p class="text-sm mt-2">Role diagnostics will run when users subscribe</p>
|
|
</div>
|
|
`);
|
|
} else {
|
|
let html = `
|
|
<div class="p-6">
|
|
<div class="mb-4">
|
|
<h3 class="text-lg font-medium dark:text-white mb-2">📊 Subscription Summary</h3>
|
|
<p class="text-sm text-gray-600 dark:text-gray-400">Active subscribers by tier (Discord role sync coming soon)</p>
|
|
</div>
|
|
<div class="space-y-2">
|
|
`;
|
|
|
|
result.rows.forEach(row => {
|
|
const statusColor = row.status === 'active' ? 'text-green-600' : 'text-purple-600';
|
|
html += `
|
|
<div class="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-800 rounded">
|
|
<span class="text-sm dark:text-white">Tier ${row.tier_level}</span>
|
|
<div class="flex items-center gap-3">
|
|
<span class="${statusColor} text-sm font-medium">${row.status}</span>
|
|
<span class="text-sm dark:text-white">${row.count} subscriber${row.count > 1 ? 's' : ''}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += `
|
|
</div>
|
|
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
|
<p class="text-sm text-blue-800 dark:text-blue-200">
|
|
💡 <strong>Coming Soon:</strong> Discord API integration to compare database tiers with actual Discord roles
|
|
</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
res.send(html);
|
|
}
|
|
} catch (error) {
|
|
res.send(`<div class="p-6 text-red-600">Error: ${error.message}</div>`);
|
|
}
|
|
});
|