feat(admin): Task #126 — Trinity Appeals admin module
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
This commit is contained in:
88
services/arbiter-3.0/src/routes/admin/appeals.js
Normal file
88
services/arbiter-3.0/src/routes/admin/appeals.js
Normal file
@@ -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("<tr><td colspan='6' class='text-center text-red-500'>Error loading appeals.</td></tr>");
|
||||
}
|
||||
});
|
||||
|
||||
// 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(`<span class="text-red-500 font-bold text-sm">❌ Not found</span>`);
|
||||
}
|
||||
|
||||
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(`<span class="${colorClass} font-bold text-sm">✅ ${label}</span>`);
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(`Appeal ${newStatus} error:`, error);
|
||||
res.status(500).send(`<span class="text-red-500 font-bold text-sm">❌ Error</span>`);
|
||||
} 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;
|
||||
@@ -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);
|
||||
|
||||
88
services/arbiter-3.0/src/views/admin/appeals/_list.ejs
Normal file
88
services/arbiter-3.0/src/views/admin/appeals/_list.ejs
Normal file
@@ -0,0 +1,88 @@
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 border-yellow-500">
|
||||
<div class="text-xs uppercase text-gray-500">Pending</div>
|
||||
<div class="text-2xl font-bold dark:text-white"><%= counts.pending %></div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 border-blue-500">
|
||||
<div class="text-xs uppercase text-gray-500">Needs Info</div>
|
||||
<div class="text-2xl font-bold dark:text-white"><%= counts.needs_info %></div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 border-green-500">
|
||||
<div class="text-xs uppercase text-gray-500">Approved</div>
|
||||
<div class="text-2xl font-bold dark:text-white"><%= counts.approved %></div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 border-red-500">
|
||||
<div class="text-xs uppercase text-gray-500">Denied</div>
|
||||
<div class="text-2xl font-bold dark:text-white"><%= counts.denied %></div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 border-gray-500">
|
||||
<div class="text-xs uppercase text-gray-500">Total</div>
|
||||
<div class="text-2xl font-bold dark:text-white"><%= counts.total %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Submitted</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Submitter</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Appeal</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<% if (appeals.length === 0) { %>
|
||||
<tr><td colspan="6" class="px-4 py-8 text-center text-gray-500">No appeals yet. The wall holds.</td></tr>
|
||||
<% } %>
|
||||
<% appeals.forEach(function(a) { %>
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td class="px-4 py-3 text-sm font-mono dark:text-gray-300">#<%= a.id %></td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500 whitespace-nowrap"><%= new Date(a.created_at).toLocaleString() %></td>
|
||||
<td class="px-4 py-3 text-sm dark:text-gray-200">
|
||||
<div class="font-medium"><%= a.discord_username %></div>
|
||||
<div class="text-xs text-gray-500"><%= a.email %></div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs dark:text-gray-300 max-w-md">
|
||||
<details>
|
||||
<summary class="cursor-pointer text-blue-500 hover:underline">View details</summary>
|
||||
<div class="mt-2 space-y-2">
|
||||
<div><span class="font-semibold">Circumstances:</span><p class="whitespace-pre-wrap"><%= a.circumstances %></p></div>
|
||||
<div><span class="font-semibold">Requested outcome:</span><p class="whitespace-pre-wrap"><%= a.requested_outcome %></p></div>
|
||||
<% if (a.trinity_notes) { %>
|
||||
<div class="pt-2 border-t border-gray-200 dark:border-gray-600">
|
||||
<span class="font-semibold">Trinity notes:</span>
|
||||
<p class="whitespace-pre-wrap text-gray-600 dark:text-gray-400"><%= a.trinity_notes %></p>
|
||||
<% if (a.actioned_by) { %>
|
||||
<p class="text-gray-500 italic">— <%= a.actioned_by %>, <%= new Date(a.actioned_at).toLocaleString() %></p>
|
||||
<% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<% 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]; %>
|
||||
<span class="px-2 py-1 rounded text-xs font-semibold <%= badge %>"><%= a.status %></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<% if (a.status === 'pending' || a.status === 'needs_info') { %>
|
||||
<div class="flex flex-col gap-1 min-w-[160px]">
|
||||
<input type="text" id="notes-<%= a.id %>" name="notes" placeholder="Notes (optional)" class="px-2 py-1 text-xs border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white" />
|
||||
<div class="flex gap-1">
|
||||
<button hx-post="/admin/appeals/<%= a.id %>/approve" hx-include="#notes-<%= a.id %>" hx-swap="outerHTML" class="bg-green-600 hover:bg-green-700 text-white px-2 py-1 rounded">Approve</button>
|
||||
<button hx-post="/admin/appeals/<%= a.id %>/deny" hx-include="#notes-<%= a.id %>" hx-swap="outerHTML" class="bg-red-600 hover:bg-red-700 text-white px-2 py-1 rounded">Deny</button>
|
||||
<button hx-post="/admin/appeals/<%= a.id %>/info" hx-include="#notes-<%= a.id %>" hx-swap="outerHTML" class="bg-blue-600 hover:bg-blue-700 text-white px-2 py-1 rounded">Info</button>
|
||||
</div>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<span class="text-gray-400 italic">resolved</span>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
17
services/arbiter-3.0/src/views/admin/appeals/index.ejs
Normal file
17
services/arbiter-3.0/src/views/admin/appeals/index.ejs
Normal file
@@ -0,0 +1,17 @@
|
||||
<div class="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold dark:text-white flex items-center gap-2">
|
||||
<span class="text-blue-500">⚖️</span> Trinity Appeals
|
||||
</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">Review ban appeals submitted via the public form</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="appeals-container" hx-get="/admin/appeals/list" hx-trigger="load, every 30s">
|
||||
<div class="flex items-center justify-center h-64">
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-4 animate-pulse">⏳</div>
|
||||
<p class="text-gray-500 dark:text-gray-400">Loading appeals...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,6 +94,9 @@
|
||||
<a href="/admin/grace" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/grace') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||
⏳ Grace Period
|
||||
</a>
|
||||
<a href="/admin/appeals" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/appeals') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||
⚖️ Trinity Appeals
|
||||
</a>
|
||||
|
||||
<!-- Community -->
|
||||
<div class="text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-600 font-semibold px-4 pt-3 pb-1">Community</div>
|
||||
|
||||
Reference in New Issue
Block a user