Merge branch 'task-126-appeals-admin'

This commit is contained in:
Claude
2026-04-12 00:18:05 +00:00
5 changed files with 198 additions and 0 deletions

View 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;

View File

@@ -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);

View 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>

View 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>

View File

@@ -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>