Discord Action Log: audit trail for Arbiter's Discord actions (Issue #1)

- Migration 142: discord_action_log table (action_type, discord_id, username, details JSONB, success, error_message)
- src/services/discordActionLog.js: silent-fail logAction() writes to DB with console fallback
- src/discord/reactionRoles.js: log reaction_role_add / reaction_role_remove with roleId + emoji + messageId
- src/discord/events.js: log wanderer_assigned + welcome_dm on guildMemberAdd
- src/routes/stripe.js: log link_reminder_dm success/failure on post-checkout
- src/routes/admin/discord-log.js: GET /admin/discord-log (recent 100, filter by action_type + search by discord_id/username)
- src/views/admin/discord-log.ejs: color-coded action type badges, success/fail pills, details column
- layout.ejs: sidebar link under Monitoring section
- admin/index.js: wired discordLogRouter

All JS node --check clean. EJS ejs.compile() clean.
This commit is contained in:
Claude Code
2026-04-16 00:07:06 -05:00
parent 6b25f9fbe3
commit 49f8f79c2f
11 changed files with 263 additions and 7 deletions

View File

@@ -0,0 +1,20 @@
-- Migration 142: Discord Action Log (REQ-2026-04-15-discord-action-log)
-- Audit trail for Arbiter's Discord actions: reaction roles, welcomes, DMs.
CREATE TABLE IF NOT EXISTS discord_action_log (
id SERIAL PRIMARY KEY,
action_type VARCHAR(50) NOT NULL,
discord_id VARCHAR(50),
username VARCHAR(100),
details JSONB DEFAULT '{}',
success BOOLEAN DEFAULT TRUE,
error_message TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_discord_action_log_discord_id
ON discord_action_log(discord_id);
CREATE INDEX IF NOT EXISTS idx_discord_action_log_action_type
ON discord_action_log(action_type);
CREATE INDEX IF NOT EXISTS idx_discord_action_log_created_at
ON discord_action_log(created_at DESC);

View File

@@ -5,6 +5,7 @@ const { handleTasksCommand, handleTaskButton } = require('./tasks');
const { handleVerifyMvcCommand } = require('./verifymvc');
const { handleReactionAdd, handleReactionRemove } = require('./reactionRoles');
const discordRoleSync = require('../services/discordRoleSync');
const { logAction } = require('../services/discordActionLog');
// Carl-bot migration: Wanderer role assigned to every new join.
const WANDERER_ROLE_ID = '1487267974367805545';
@@ -61,17 +62,25 @@ function registerEvents(client) {
// Carl-bot migration — new member onboarding
client.on('guildMemberAdd', async (member) => {
try {
let wandererErr = null;
await member.roles.add(WANDERER_ROLE_ID).catch(err => {
console.warn(`[Welcome] failed to add Wanderer to ${member.user.username}:`, err.message);
wandererErr = err.message;
console.warn(`[Welcome] failed to add Wanderer to ${member.user.username}:`, wandererErr);
});
console.log(`👋 [Welcome] ${member.user.username} joined → Wanderer`);
logAction('wanderer_assigned', member.user.id, member.user.username,
{ roleId: WANDERER_ROLE_ID }, !wandererErr, wandererErr);
if (!wandererErr) console.log(`👋 [Welcome] ${member.user.username} joined → Wanderer`);
// DM welcome (silent-fail if user has DMs closed)
let dmErr = null;
try {
await member.send(WELCOME_DM(member.user.username));
} catch (err) {
console.log(`[Welcome] DM to ${member.user.username} skipped: ${err.message}`);
dmErr = err.message;
console.log(`[Welcome] DM to ${member.user.username} skipped: ${dmErr}`);
}
logAction('welcome_dm', member.user.id, member.user.username,
{}, !dmErr, dmErr);
} catch (err) {
console.error('[Welcome] guildMemberAdd error:', err.message);
}

View File

@@ -7,6 +7,8 @@
* reaction can never crash the gateway listener.
*/
const { logAction } = require('../services/discordActionLog');
// Keyed by message ID → { emoji (unicode or custom name) → role ID }
const REACTION_ROLE_MAP = {
// Message 1 — Choose Your Path
@@ -65,10 +67,15 @@ async function handleReactionAdd(reaction, user) {
if (!guild) return;
const member = await guild.members.fetch(user.id).catch(() => null);
if (!member) return;
let addErr = null;
await member.roles.add(resolved.roleId).catch(err => {
console.warn(`[ReactionRoles] add failed for ${user.username}${resolved.roleId}:`, err.message);
addErr = err.message;
console.warn(`[ReactionRoles] add failed for ${user.username}${resolved.roleId}:`, addErr);
});
console.log(`🎭 [ReactionRoles] +${resolved.roleId}${user.username}`);
logAction('reaction_role_add', user.id, user.username,
{ roleId: resolved.roleId, emoji: reaction.emoji.name, messageId: reaction.message.id },
!addErr, addErr);
if (!addErr) console.log(`🎭 [ReactionRoles] +${resolved.roleId}${user.username}`);
} catch (err) {
console.error('[ReactionRoles] add handler error:', err.message);
}
@@ -83,10 +90,15 @@ async function handleReactionRemove(reaction, user) {
if (!guild) return;
const member = await guild.members.fetch(user.id).catch(() => null);
if (!member) return;
let rmErr = null;
await member.roles.remove(resolved.roleId).catch(err => {
console.warn(`[ReactionRoles] remove failed for ${user.username}${resolved.roleId}:`, err.message);
rmErr = err.message;
console.warn(`[ReactionRoles] remove failed for ${user.username}${resolved.roleId}:`, rmErr);
});
console.log(`🎭 [ReactionRoles] -${resolved.roleId}${user.username}`);
logAction('reaction_role_remove', user.id, user.username,
{ roleId: resolved.roleId, emoji: reaction.emoji.name, messageId: reaction.message.id },
!rmErr, rmErr);
if (!rmErr) console.log(`🎭 [ReactionRoles] -${resolved.roleId}${user.username}`);
} catch (err) {
console.error('[ReactionRoles] remove handler error:', err.message);
}

View File

@@ -0,0 +1,61 @@
/**
* Discord Action Log — Trinity Console view
* REQ-2026-04-15-discord-action-log (Chronicler #92, Issue #1)
*
* GET /admin/discord-log — recent 100 actions, filterable by action_type and discord_id search.
*/
const express = require('express');
const router = express.Router();
const db = require('../../database');
const ACTION_TYPES = [
'reaction_role_add',
'reaction_role_remove',
'wanderer_assigned',
'welcome_dm',
'link_reminder_dm'
];
router.get('/', async (req, res) => {
try {
const { action_type, search } = req.query;
const params = [];
let where = 'WHERE 1=1';
let p = 0;
if (action_type && ACTION_TYPES.includes(action_type)) {
p++;
where += ` AND action_type = $${p}`;
params.push(action_type);
}
if (search && search.trim()) {
p++;
where += ` AND (discord_id = $${p} OR username ILIKE $${p + 1})`;
params.push(search.trim());
p++;
params.push(`%${search.trim()}%`);
}
const result = await db.query(
`SELECT * FROM discord_action_log ${where}
ORDER BY created_at DESC LIMIT 100`,
params
);
res.render('admin/discord-log', {
title: 'Discord Action Log',
currentPath: '/discord-log',
logs: result.rows,
actionTypes: ACTION_TYPES,
filters: { action_type, search },
adminUser: req.user,
layout: 'layout'
});
} catch (err) {
console.error('[DiscordLog] Route error:', err);
res.status(500).send('Error loading action log');
}
});
module.exports = router;

View File

@@ -25,6 +25,7 @@ const tasksRouter = require('./tasks');
const forgeRouter = require('./forge');
const nodeHealthRouter = require('./node-health');
const issuesRouter = require('./issues');
const discordLogRouter = require('./discord-log');
router.use(requireTrinityAccess);
@@ -145,5 +146,6 @@ router.use('/tasks', tasksRouter);
router.use('/forge', forgeRouter);
router.use('/node-health', nodeHealthRouter);
router.use('/issues', issuesRouter);
router.use('/discord-log', discordLogRouter);
module.exports = router;

View File

@@ -14,6 +14,7 @@ const db = require('../database');
const { syncRole, removeAllRoles, downgradeToAwakened } = require('../services/discordRoleSync');
const { welcomeNewMember } = require('../services/awakenedConcierge');
const { syncLuckPermsMeta } = require('../services/luckpermsSync');
const { logAction } = require('../services/discordActionLog');
// CORS configuration for checkout endpoint
const corsOptions = {
@@ -312,8 +313,10 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r
`See you in-game!`
);
console.log(`💌 [LinkReminder] sent to ${user.username}`);
logAction('link_reminder_dm', discordId, user.username, { tierLevel }, true, null);
} catch (err) {
console.log(`[LinkReminder] DM to ${discordId} skipped: ${err.message}`);
logAction('link_reminder_dm', discordId, null, { tierLevel }, false, err.message);
}
})();
}

View File

@@ -0,0 +1,48 @@
/**
* Discord Action Log — persistent audit trail for Arbiter's Discord actions.
* REQ-2026-04-15-discord-action-log (Chronicler #92, Issue #1)
*
* Silent-fail pattern: never let logging break the action it's logging.
* Every function swallows errors internally and logs to console as fallback.
*
* Action types:
* reaction_role_add — emoji reaction → role assigned
* reaction_role_remove — emoji reaction removed → role removed
* wanderer_assigned — new member → Wanderer role
* welcome_dm — new member → welcome DM (success or DMs closed)
* link_reminder_dm — post-checkout → link reminder DM
*/
const db = require('../database');
/**
* @param {string} actionType — one of the action type constants above
* @param {string|null} discordId
* @param {string|null} username
* @param {object} details — arbitrary JSON payload (role ID, emoji, etc.)
* @param {boolean} success
* @param {string|null} errorMessage
*/
async function logAction(actionType, discordId, username, details, success, errorMessage) {
try {
await db.query(
`INSERT INTO discord_action_log
(action_type, discord_id, username, details, success, error_message)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
actionType,
discordId || null,
username || null,
JSON.stringify(details || {}),
success !== false,
errorMessage || null
]
);
} catch (err) {
// Fallback: at least the console has it
console.error('[DiscordActionLog] DB write failed:', err.message,
{ actionType, discordId, username });
}
}
module.exports = { logAction };

View File

@@ -0,0 +1,98 @@
<!-- Discord Action Log — Trinity Console -->
<!-- REQ-2026-04-15-discord-action-log (Chronicler #92, Issue #1) -->
<style>
.badge { display:inline-block; padding:2px 8px; border-radius:9999px; font-size:10px; font-weight:600; }
.badge-success { background:#065f46; color:#a7f3d0; }
.badge-fail { background:#7f1d1d; color:#fecaca; }
.at-reaction_role_add { background:#1e3a8a; color:#bfdbfe; }
.at-reaction_role_remove { background:#4c1d95; color:#ddd6fe; }
.at-wanderer_assigned { background:#065f46; color:#a7f3d0; }
.at-welcome_dm { background:#92400e; color:#fde68a; }
.at-link_reminder_dm { background:#164e63; color:#a5f3fc; }
</style>
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold">📜 Discord Action Log</h1>
</div>
<!-- Filters -->
<form method="get" action="/admin/discord-log" class="flex flex-wrap gap-2 mb-4 items-center">
<select name="action_type" onchange="this.form.submit()" class="bg-gray-800 text-gray-200 text-xs rounded px-2 py-1 border border-gray-700">
<option value="">All types</option>
<% actionTypes.forEach(function(t) { %>
<option value="<%= t %>" <%= filters.action_type === t ? 'selected' : '' %>><%= t %></option>
<% }) %>
</select>
<input type="text" name="search" placeholder="Search Discord ID or username"
value="<%= filters.search || '' %>"
class="bg-gray-800 text-gray-200 text-xs rounded px-2 py-1 border border-gray-700 w-56">
<button type="submit" class="bg-cyan-600 hover:bg-cyan-700 text-white px-3 py-1 rounded text-xs">Filter</button>
<% if (filters.action_type || filters.search) { %>
<a href="/admin/discord-log" class="text-xs text-cyan-400 hover:underline ml-2">Clear</a>
<% } %>
</form>
<!-- Table -->
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<% if (logs.length === 0) { %>
<div class="p-6 text-center text-gray-500">No actions logged yet.</div>
<% } else { %>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-100 dark:bg-gray-800 text-xs uppercase text-gray-500 dark:text-gray-400">
<tr>
<th class="px-3 py-2 text-left">Time</th>
<th class="px-3 py-2 text-left">Action</th>
<th class="px-3 py-2 text-left">User</th>
<th class="px-3 py-2 text-left">Status</th>
<th class="px-3 py-2 text-left">Details</th>
</tr>
</thead>
<tbody>
<% logs.forEach(function(l) { %>
<tr class="border-t border-gray-200 dark:border-gray-700">
<td class="px-3 py-2 text-gray-500 text-xs whitespace-nowrap"><%= new Date(l.created_at).toLocaleString() %></td>
<td class="px-3 py-2">
<span class="badge at-<%= l.action_type %>"><%= l.action_type %></span>
</td>
<td class="px-3 py-2">
<% if (l.username) { %>
<span class="text-gray-200"><%= l.username %></span>
<span class="text-gray-600 text-xs ml-1">(<%= l.discord_id %>)</span>
<% } else if (l.discord_id) { %>
<span class="text-gray-400 font-mono text-xs"><%= l.discord_id %></span>
<% } else { %>
<span class="text-gray-600 text-xs">—</span>
<% } %>
</td>
<td class="px-3 py-2">
<% if (l.success) { %>
<span class="badge badge-success">OK</span>
<% } else { %>
<span class="badge badge-fail">FAIL</span>
<% } %>
</td>
<td class="px-3 py-2 text-gray-400 text-xs">
<% if (l.error_message) { %>
<span class="text-red-400" title="<%= l.error_message %>">❌ <%= l.error_message.substring(0, 60) %></span>
<% } else { %>
<%
var d = typeof l.details === 'string' ? JSON.parse(l.details || '{}') : (l.details || {});
var parts = [];
if (d.roleId) parts.push('role:' + d.roleId);
if (d.emoji) parts.push('emoji:' + d.emoji);
if (d.tierLevel) parts.push('tier:' + d.tierLevel);
%>
<span class="text-gray-500"><%= parts.join(' · ') || '—' %></span>
<% } %>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
</div>
<div class="text-xs text-gray-500 mt-3">Showing most recent 100 actions.</div>

View File

@@ -177,6 +177,9 @@
<a href="/admin/mcp-logs" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/mcp-logs') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
🖥️ MCP Logs
</a>
<a href="/admin/discord-log" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/discord-log') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
📜 Discord Actions
</a>
</div>
</nav>
<div class="p-4 border-t border-gray-200 dark:border-gray-700">