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';
+ }
+ %>
+
+
+
+
+
+ <%= 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...
+
+
+
+
+
+
+
+
+ | Player |
+ Tier & MRR |
+ Failure Reason |
+ Time Remaining |
+ Actions |
+
+
+
+ <% if (atRisk.length === 0) { %>
+
+ |
+ 🎉
+ No subscribers in grace period. MRR is secure!
+ |
+
+ <% } %>
+
+ <% 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';
+ %>
+
+ |
+ <%= 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 { %>
+
+
+
+ | Player |
+ Expected Tier |
+ Missing Role ID |
+ Action |
+
+
+
+ <% mismatches.forEach(m => { %>
+
+ |
+ <%= 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).
+
+
+`}) %>