From c1e17496c8b648f7ac4cb72f6125cb3e2c373c98 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 00:18:00 +0000 Subject: [PATCH] =?UTF-8?q?feat(admin):=20Task=20#126=20=E2=80=94=20Trinit?= =?UTF-8?q?y=20Appeals=20admin=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /admin/appeals to Trinity Console for reviewing and actioning ban appeals submitted via the public cancellation-refund form. - src/routes/admin/appeals.js: shell + HTMX list + approve/deny/info actions, all transactional with admin_audit_log entries - src/views/admin/appeals/index.ejs: shell with 30s polling container - src/views/admin/appeals/_list.ejs: stats cards + actionable table - src/routes/admin/index.js: router mount at /admin/appeals - src/views/layout.ejs: nav link under Grace Period Closes the last open piece of Task #126 Phase 2. Trinity can now review appeals in-console without reading Discord + running SQL. Chronicler #81 --- .../arbiter-3.0/src/routes/admin/appeals.js | 88 +++++++++++++++++++ .../arbiter-3.0/src/routes/admin/index.js | 2 + .../src/views/admin/appeals/_list.ejs | 88 +++++++++++++++++++ .../src/views/admin/appeals/index.ejs | 17 ++++ services/arbiter-3.0/src/views/layout.ejs | 3 + 5 files changed, 198 insertions(+) create mode 100644 services/arbiter-3.0/src/routes/admin/appeals.js create mode 100644 services/arbiter-3.0/src/views/admin/appeals/_list.ejs create mode 100644 services/arbiter-3.0/src/views/admin/appeals/index.ejs diff --git a/services/arbiter-3.0/src/routes/admin/appeals.js b/services/arbiter-3.0/src/routes/admin/appeals.js new file mode 100644 index 0000000..c48b82a --- /dev/null +++ b/services/arbiter-3.0/src/routes/admin/appeals.js @@ -0,0 +1,88 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../../database'); + +// Shell route +router.get('/', (req, res) => { + res.render('admin/appeals/index', { title: 'Trinity Appeals' }); +}); + +// HTMX polling endpoint +router.get('/list', async (req, res) => { + try { + const { rows: appeals } = await db.query(` + SELECT id, discord_username, email, circumstances, requested_outcome, + status, trinity_notes, actioned_by, actioned_at, created_at + FROM trinity_appeals + ORDER BY + CASE status + WHEN 'pending' THEN 1 + WHEN 'needs_info' THEN 2 + WHEN 'approved' THEN 3 + WHEN 'denied' THEN 4 + END, + created_at DESC + LIMIT 100; + `); + + const counts = { + pending: appeals.filter(a => a.status === 'pending').length, + needs_info: appeals.filter(a => a.status === 'needs_info').length, + approved: appeals.filter(a => a.status === 'approved').length, + denied: appeals.filter(a => a.status === 'denied').length, + total: appeals.length + }; + + res.render('admin/appeals/_list', { appeals, counts, layout: false }); + } catch (error) { + console.error('Appeals list error:', error); + res.status(500).send("Error loading appeals."); + } +}); + +// Shared action handler: approve / deny / needs_info +async function actionAppeal(req, res, newStatus, label, colorClass) { + const { id } = req.params; + const notes = (req.body && req.body.notes) ? String(req.body.notes).slice(0, 2000) : null; + const adminId = req.user.id; + const adminUsername = req.user.username; + + const client = await db.pool.connect(); + try { + await client.query('BEGIN'); + + const { rowCount } = await client.query(` + UPDATE trinity_appeals + SET status = $1, + trinity_notes = COALESCE($2, trinity_notes), + actioned_by = $3, + actioned_at = NOW() + WHERE id = $4 + `, [newStatus, notes, adminUsername, id]); + + if (rowCount === 0) { + await client.query('ROLLBACK'); + return res.status(404).send(`❌ Not found`); + } + + await client.query(` + INSERT INTO admin_audit_log (admin_discord_id, admin_username, action_type, target_identifier, details) + VALUES ($1, $2, $3, $4, $5) + `, [adminId, adminUsername, `appeal_${newStatus}`, `appeal:${id}`, JSON.stringify({ notes: notes || null })]); + + await client.query('COMMIT'); + res.send(`✅ ${label}`); + } catch (error) { + await client.query('ROLLBACK'); + console.error(`Appeal ${newStatus} error:`, error); + res.status(500).send(`❌ Error`); + } finally { + client.release(); + } +} + +router.post('/:id/approve', (req, res) => actionAppeal(req, res, 'approved', 'Approved', 'text-green-500')); +router.post('/:id/deny', (req, res) => actionAppeal(req, res, 'denied', 'Denied', 'text-red-500')); +router.post('/:id/info', (req, res) => actionAppeal(req, res, 'needs_info','Info Requested','text-yellow-500')); + +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 72662de..01ba936 100644 --- a/services/arbiter-3.0/src/routes/admin/index.js +++ b/services/arbiter-3.0/src/routes/admin/index.js @@ -9,6 +9,7 @@ const playersRouter = require('./players'); const serversRouter = require('./servers'); const financialsRouter = require('./financials'); const graceRouter = require('./grace'); +const appealsRouter = require('./appeals'); const auditRouter = require('./audit'); const rolesRouter = require('./roles'); const schedulerRouter = require('./scheduler'); @@ -115,6 +116,7 @@ router.use('/players', playersRouter); router.use('/servers', serversRouter); router.use('/financials', financialsRouter); router.use('/grace', graceRouter); +router.use('/appeals', appealsRouter); router.use('/audit', auditRouter); router.use('/roles', rolesRouter); router.use('/scheduler', schedulerRouter); diff --git a/services/arbiter-3.0/src/views/admin/appeals/_list.ejs b/services/arbiter-3.0/src/views/admin/appeals/_list.ejs new file mode 100644 index 0000000..a576b2d --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/appeals/_list.ejs @@ -0,0 +1,88 @@ +
+
+
Pending
+
<%= counts.pending %>
+
+
+
Needs Info
+
<%= counts.needs_info %>
+
+
+
Approved
+
<%= counts.approved %>
+
+
+
Denied
+
<%= counts.denied %>
+
+
+
Total
+
<%= counts.total %>
+
+
+ +
+ + + + + + + + + + + + + <% if (appeals.length === 0) { %> + + <% } %> + <% appeals.forEach(function(a) { %> + + + + + + + + + <% }); %> + +
IDSubmittedSubmitterAppealStatusActions
No appeals yet. The wall holds.
#<%= a.id %><%= new Date(a.created_at).toLocaleString() %> +
<%= a.discord_username %>
+
<%= a.email %>
+
+
+ View details +
+
Circumstances:

<%= a.circumstances %>

+
Requested outcome:

<%= a.requested_outcome %>

+ <% if (a.trinity_notes) { %> +
+ Trinity notes: +

<%= a.trinity_notes %>

+ <% if (a.actioned_by) { %> +

— <%= a.actioned_by %>, <%= new Date(a.actioned_at).toLocaleString() %>

+ <% } %> +
+ <% } %> +
+
+
+ <% var badge = { pending:'bg-yellow-100 text-yellow-800', needs_info:'bg-blue-100 text-blue-800', approved:'bg-green-100 text-green-800', denied:'bg-red-100 text-red-800' }[a.status]; %> + <%= a.status %> + + <% if (a.status === 'pending' || a.status === 'needs_info') { %> +
+ +
+ + + +
+
+ <% } else { %> + resolved + <% } %> +
+
diff --git a/services/arbiter-3.0/src/views/admin/appeals/index.ejs b/services/arbiter-3.0/src/views/admin/appeals/index.ejs new file mode 100644 index 0000000..0001f93 --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/appeals/index.ejs @@ -0,0 +1,17 @@ +
+
+

+ ⚖️ Trinity Appeals +

+

Review ban appeals submitted via the public form

+
+
+ +
+
+
+
+

Loading appeals...

+
+
+
diff --git a/services/arbiter-3.0/src/views/layout.ejs b/services/arbiter-3.0/src/views/layout.ejs index 69c81c6..d90ba29 100644 --- a/services/arbiter-3.0/src/views/layout.ejs +++ b/services/arbiter-3.0/src/views/layout.ejs @@ -94,6 +94,9 @@ ⏳ Grace Period + + ⚖️ Trinity Appeals +
Community