diff --git a/services/arbiter-3.0/src/routes/admin/audit.js b/services/arbiter-3.0/src/routes/admin/audit.js new file mode 100644 index 0000000..772122f --- /dev/null +++ b/services/arbiter-3.0/src/routes/admin/audit.js @@ -0,0 +1,38 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../../database'); + +router.get('/', (req, res) => { + res.render('admin/audit/index', { title: 'Admin Audit Log' }); +}); + +router.get('/feed', async (req, res) => { + const page = parseInt(req.query.page) || 1; + const limit = 20; + const offset = (page - 1) * limit; + const filterType = req.query.type || ''; + + let query = ` + SELECT id, admin_discord_id, admin_username, action_type, target_identifier, details, performed_at + FROM admin_audit_log + `; + const params = []; + + if (filterType) { + query += ` WHERE action_type = $1 `; + params.push(filterType); + } + + query += ` ORDER BY performed_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`; + params.push(limit, offset); + + try { + const { rows: logs } = await db.query(query, params); + res.render('admin/audit/_feed', { logs, page, filterType }); + } catch (error) { + console.error("Audit Log Error:", error); + res.status(500).send("
Error loading audit logs.
"); + } +}); + +module.exports = router; diff --git a/services/arbiter-3.0/src/routes/admin/grace.js b/services/arbiter-3.0/src/routes/admin/grace.js new file mode 100644 index 0000000..a97be19 --- /dev/null +++ b/services/arbiter-3.0/src/routes/admin/grace.js @@ -0,0 +1,96 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../../database'); +const { TIER_INFO } = require('./constants'); + +// Shell Route +router.get('/', (req, res) => { + res.render('admin/grace/index', { title: 'Grace Period Dashboard' }); +}); + +// HTMX Polling Endpoint (Stats + Table) +router.get('/list', async (req, res) => { + try { + // 1. Fetch At-Risk Subscribers + const { rows: atRisk } = await db.query(` + SELECT + u.minecraft_username, + u.discord_id, + s.tier_level, + s.mrr_value, + s.grace_period_started_at, + s.grace_period_ends_at, + s.payment_failure_reason, + EXTRACT(EPOCH FROM (s.grace_period_ends_at - NOW())) as seconds_remaining + FROM subscriptions s + JOIN users u ON s.discord_id = u.discord_id + WHERE s.status = 'grace_period' + ORDER BY s.grace_period_ends_at ASC; + `); + + // 2. Fetch High-Level Stats + const totalAtRiskMrr = atRisk.reduce((sum, sub) => sum + parseFloat(sub.mrr_value || 0), 0); + const atRiskCount = atRisk.length; + + // 3. Render the Partial + res.render('admin/grace/_list', { + atRisk, + totalAtRiskMrr, + atRiskCount, + TIER_INFO + }); + } catch (error) { + console.error("Grace Period List Error:", error); + res.status(500).send("Error loading data."); + } +}); + +// Action: Extend Grace Period by 24 Hours +router.post('/:discord_id/extend', async (req, res) => { + const { discord_id } = req.params; + const adminId = req.user.id; + const adminUsername = req.user.username; + + try { + await db.query(` + UPDATE subscriptions + SET grace_period_ends_at = grace_period_ends_at + INTERVAL '24 hours' + WHERE discord_id = $1 AND status = 'grace_period' + `, [discord_id]); + + await db.query(` + INSERT INTO admin_audit_log (admin_discord_id, admin_username, action_type, target_identifier, details) + VALUES ($1, $2, 'extend_grace_period', $3, '{"extension": "24h"}') + `, [adminId, adminUsername, discord_id]); + + res.send(`✅ Extended 24h`); + } catch (error) { + res.status(500).send(`❌ Error`); + } +}); + +// Action: Convert to Manual Payment (Resolves Grace Period) +router.post('/:discord_id/manual', async (req, res) => { + const { discord_id } = req.params; + const adminId = req.user.id; + const adminUsername = req.user.username; + + try { + await db.query(` + UPDATE subscriptions + SET status = 'active', grace_period_started_at = NULL, grace_period_ends_at = NULL + WHERE discord_id = $1 + `, [discord_id]); + + await db.query(` + INSERT INTO admin_audit_log (admin_discord_id, admin_username, action_type, target_identifier, details) + VALUES ($1, $2, 'manual_payment_override', $3, '{"reason": "admin_override"}') + `, [adminId, adminUsername, discord_id]); + + res.send(`✅ Activated`); + } catch (error) { + res.status(500).send(`❌ Error`); + } +}); + +module.exports = router; diff --git a/services/arbiter-3.0/src/routes/admin/index.js b/services/arbiter-3.0/src/routes/admin/index.js index 1ed060c..6b682f9 100644 --- a/services/arbiter-3.0/src/routes/admin/index.js +++ b/services/arbiter-3.0/src/routes/admin/index.js @@ -6,6 +6,9 @@ const { requireTrinityAccess } = require('./middleware'); const playersRouter = require('./players'); const serversRouter = require('./servers'); const financialsRouter = require('./financials'); +const graceRouter = require('./grace'); +const auditRouter = require('./audit'); +const rolesRouter = require('./roles'); router.use(requireTrinityAccess); @@ -20,5 +23,8 @@ router.get('/dashboard', (req, res) => { router.use('/players', playersRouter); router.use('/servers', serversRouter); router.use('/financials', financialsRouter); +router.use('/grace', graceRouter); +router.use('/audit', auditRouter); +router.use('/roles', rolesRouter); module.exports = router; diff --git a/services/arbiter-3.0/src/routes/admin/roles.js b/services/arbiter-3.0/src/routes/admin/roles.js new file mode 100644 index 0000000..d9990ea --- /dev/null +++ b/services/arbiter-3.0/src/routes/admin/roles.js @@ -0,0 +1,99 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../../database'); +const { getRoleMappings } = require('../../utils/roleMappings'); // From Arbiter 3.0 +const { TIER_INFO } = require('./constants'); + +router.get('/', (req, res) => { + res.render('admin/roles/index', { title: 'Discord Role Audit' }); +}); + +router.get('/mismatches', async (req, res) => { + try { + const client = req.app.locals.client; + const guild = client.guilds.cache.get(process.env.GUILD_ID); + if (!guild) throw new Error("Discord Guild not found in cache."); + + const mappings = getRoleMappings(); + + // Reverse mappings to match TIER to ROLE ID based on your existing logic + // Assuming your JSON maps "product-slug" -> "role_id" + // And your webhooks mapped "product-slug" -> tier_level + // *You may need to adapt this map based on your exact role-mappings.json structure* + const TIER_TO_ROLE = { + 1: mappings['the-awakened'], + 5: mappings['tier-elemental'], + 10: mappings['tier-knight'], + 15: mappings['tier-master'], + 20: mappings['tier-legend'], + 105: mappings['frost-elemental'], + // Add remaining mappings... + 499: mappings['the-sovereign'] + }; + + const { rows: activeSubs } = await db.query(` + SELECT u.discord_id, u.minecraft_username, s.tier_level + FROM subscriptions s + JOIN users u ON s.discord_id = u.discord_id + WHERE s.status IN ('active', 'lifetime', 'grace_period') + `); + + const mismatches = []; + + for (const sub of activeSubs) { + const expectedRoleId = TIER_TO_ROLE[sub.tier_level]; + if (!expectedRoleId) continue; + + try { + const member = await guild.members.fetch(sub.discord_id); + const hasRole = member.roles.cache.has(expectedRoleId); + + if (!hasRole) { + mismatches.push({ + discord_id: sub.discord_id, + username: sub.minecraft_username || 'Unknown', + tier_name: TIER_INFO[sub.tier_level]?.name || 'Unknown', + expected_role: expectedRoleId + }); + } + } catch (err) { + // User might have left the server + mismatches.push({ + discord_id: sub.discord_id, + username: sub.minecraft_username || 'Unknown', + tier_name: TIER_INFO[sub.tier_level]?.name || 'Unknown', + expected_role: 'User left Discord server' + }); + } + } + + res.render('admin/roles/_mismatches', { mismatches }); + } catch (error) { + console.error("Role Audit Error:", error); + res.status(500).send("
Error communicating with Discord API.
"); + } +}); + +// Resync Individual +router.post('/resync/:discord_id', async (req, res) => { + const { expected_role } = req.body; + try { + const client = req.app.locals.client; + const guild = client.guilds.cache.get(process.env.GUILD_ID); + const member = await guild.members.fetch(req.params.discord_id); + + await member.roles.add(expected_role); + + // Log it! + await db.query(` + INSERT INTO admin_audit_log (admin_discord_id, admin_username, action_type, target_identifier, details) + VALUES ($1, $2, 'manual_role_assign', $3, $4) + `, [req.user.id, req.user.username, req.params.discord_id, JSON.stringify({ assigned_role: expected_role })]); + + res.send(`✅ Fixed`); + } catch (error) { + res.send(`❌ Failed`); + } +}); + +module.exports = router; diff --git a/services/arbiter-3.0/src/views/admin/audit/_feed.ejs b/services/arbiter-3.0/src/views/admin/audit/_feed.ejs new file mode 100644 index 0000000..f92e104 --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/audit/_feed.ejs @@ -0,0 +1,53 @@ +
+ <% if (logs.length === 0) { %> +
No audit logs found for this criteria.
+ <% } %> + + <% logs.forEach(log => { + let icon = '📝'; + let colorClass = 'text-blue-500 bg-blue-100 dark:bg-blue-900/30'; + + if (log.action_type.includes('grace') || log.action_type.includes('payment')) { + icon = '💰'; colorClass = 'text-green-500 bg-green-100 dark:bg-green-900/30'; + } else if (log.action_type.includes('sync') || log.action_type.includes('whitelist')) { + icon = '⚡'; colorClass = 'text-purple-500 bg-purple-100 dark:bg-purple-900/30'; + } else if (log.action_type.includes('ban') || log.action_type.includes('remove')) { + icon = '🚨'; colorClass = 'text-red-500 bg-red-100 dark:bg-red-900/30'; + } + %> +
+
+
+ <%= icon %> +
+
+
+
+
+ <%= log.admin_username || 'Trinity Member' %> + performed + <%= log.action_type %> +
+ <%= new Date(log.performed_at).toLocaleString() %> +
+
+ Target: <%= log.target_identifier || 'Global' %> +
+ <% if (log.details) { %> +
+ <%= typeof log.details === 'object' ? JSON.stringify(log.details) : log.details %> +
+ <% } %> +
+
+ <% }) %> +
+ +
+ +
diff --git a/services/arbiter-3.0/src/views/admin/audit/index.ejs b/services/arbiter-3.0/src/views/admin/audit/index.ejs new file mode 100644 index 0000000..ff8b4d2 --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/audit/index.ejs @@ -0,0 +1,28 @@ +<%- include('../../layout', { body: ` +
+
+

+ ⚖️ Accountability Audit +

+

Permanent record of Trinity operations

+
+
+ +
+
+ +
+
+
Loading secure audit logs...
+
+
+`}) %> diff --git a/services/arbiter-3.0/src/views/admin/grace/_list.ejs b/services/arbiter-3.0/src/views/admin/grace/_list.ejs new file mode 100644 index 0000000..2074cd0 --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/grace/_list.ejs @@ -0,0 +1,88 @@ +
+
+

Total At-Risk MRR

+

$<%= totalAtRiskMrr.toFixed(2) %>

+
+
+

Subscribers in Grace

+

<%= atRiskCount %>

+
+
+

7-Day Recovery Rate

+

--%

+

Metrics gathering in progress...

+
+
+ +
+
+ + + + + + + + + + + + <% if (atRisk.length === 0) { %> + + + + <% } %> + + <% atRisk.forEach(sub => { + const hoursRemaining = Math.max(0, Math.floor(sub.seconds_remaining / 3600)); + let timeColor = 'text-green-500'; + let timeBg = 'bg-green-100 dark:bg-green-900/30'; + if (hoursRemaining <= 48) { timeColor = 'text-yellow-600 dark:text-yellow-500'; timeBg = 'bg-yellow-100 dark:bg-yellow-900/30'; } + if (hoursRemaining <= 24) { timeColor = 'text-red-600 dark:text-red-500'; timeBg = 'bg-red-100 dark:bg-red-900/30'; } + + const tierName = TIER_INFO[sub.tier_level]?.name || 'Unknown'; + %> + + + + + + + + <% }) %> + +
PlayerTier & MRRFailure ReasonTime RemainingActions
+ 🎉 + No subscribers in grace period. MRR is secure! +
+
<%= sub.minecraft_username || 'Unlinked' %>
+
<%= sub.discord_id %>
+
+
<%= tierName %>
+
$<%= parseFloat(sub.mrr_value).toFixed(2) %>/mo
+
+ + <%= sub.payment_failure_reason || 'Card Declined' %> + + + + + <%= hoursRemaining %>h remaining + + +
+ + +
+
+
+
diff --git a/services/arbiter-3.0/src/views/admin/grace/index.ejs b/services/arbiter-3.0/src/views/admin/grace/index.ejs new file mode 100644 index 0000000..41fd828 --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/grace/index.ejs @@ -0,0 +1,24 @@ +<%- include('../../layout', { body: ` +
+
+

+ ⚠️ Recovery Mission Control +

+

Manage Task #87 cancellations and at-risk MRR

+
+
+ +
+
+ +
+
+
+
+

Loading At-Risk Subscriptions...

+
+
+
+`}) %> diff --git a/services/arbiter-3.0/src/views/admin/roles/_mismatches.ejs b/services/arbiter-3.0/src/views/admin/roles/_mismatches.ejs new file mode 100644 index 0000000..2df0e57 --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/roles/_mismatches.ejs @@ -0,0 +1,40 @@ +<% if (mismatches.length === 0) { %> +
+ 🎉 Perfect Sync! No role mismatches found. +
+<% } else { %> + + + + + + + + + + + <% mismatches.forEach(m => { %> + + + + + + + <% }) %> + +
PlayerExpected TierMissing Role IDAction
+
<%= m.username %>
+
<%= m.discord_id %>
+
<%= m.tier_name %><%= m.expected_role %> + <% if (m.expected_role.includes('left')) { %> + User left server + <% } else { %> + + <% } %> +
+<% } %> diff --git a/services/arbiter-3.0/src/views/admin/roles/index.ejs b/services/arbiter-3.0/src/views/admin/roles/index.ejs new file mode 100644 index 0000000..d97799c --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/roles/index.ejs @@ -0,0 +1,23 @@ +<%- include('../../layout', { body: ` +
+
+

+ 🛡️ Role Diagnostics +

+

Find and fix Discord role mismatches

+
+
+ +
+
+ +
+
+ Click "Run Diagnostic Scan" to query the Discord API. (This takes a few seconds). +
+
+`}) %>