diff --git a/docs/code-bridge/requests/REQ-2026-04-15-discord-action-log.md b/docs/code-bridge/archive/REQ-2026-04-15-discord-action-log.md similarity index 100% rename from docs/code-bridge/requests/REQ-2026-04-15-discord-action-log.md rename to docs/code-bridge/archive/REQ-2026-04-15-discord-action-log.md diff --git a/docs/code-bridge/requests/RES-2026-04-15-discord-action-log.md b/docs/code-bridge/archive/RES-2026-04-15-discord-action-log.md similarity index 100% rename from docs/code-bridge/requests/RES-2026-04-15-discord-action-log.md rename to docs/code-bridge/archive/RES-2026-04-15-discord-action-log.md diff --git a/services/arbiter-3.0/migrations/142_discord_action_log.sql b/services/arbiter-3.0/migrations/142_discord_action_log.sql new file mode 100644 index 0000000..d106cba --- /dev/null +++ b/services/arbiter-3.0/migrations/142_discord_action_log.sql @@ -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); diff --git a/services/arbiter-3.0/src/discord/events.js b/services/arbiter-3.0/src/discord/events.js index 6e8f734..75f76c8 100644 --- a/services/arbiter-3.0/src/discord/events.js +++ b/services/arbiter-3.0/src/discord/events.js @@ -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); } diff --git a/services/arbiter-3.0/src/discord/reactionRoles.js b/services/arbiter-3.0/src/discord/reactionRoles.js index 496d6b9..724c284 100644 --- a/services/arbiter-3.0/src/discord/reactionRoles.js +++ b/services/arbiter-3.0/src/discord/reactionRoles.js @@ -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); } diff --git a/services/arbiter-3.0/src/routes/admin/discord-log.js b/services/arbiter-3.0/src/routes/admin/discord-log.js new file mode 100644 index 0000000..b66028a --- /dev/null +++ b/services/arbiter-3.0/src/routes/admin/discord-log.js @@ -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; diff --git a/services/arbiter-3.0/src/routes/admin/index.js b/services/arbiter-3.0/src/routes/admin/index.js index af9537d..428040f 100644 --- a/services/arbiter-3.0/src/routes/admin/index.js +++ b/services/arbiter-3.0/src/routes/admin/index.js @@ -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; diff --git a/services/arbiter-3.0/src/routes/stripe.js b/services/arbiter-3.0/src/routes/stripe.js index c0c5968..cad932d 100644 --- a/services/arbiter-3.0/src/routes/stripe.js +++ b/services/arbiter-3.0/src/routes/stripe.js @@ -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); } })(); } diff --git a/services/arbiter-3.0/src/services/discordActionLog.js b/services/arbiter-3.0/src/services/discordActionLog.js new file mode 100644 index 0000000..8ae6cb3 --- /dev/null +++ b/services/arbiter-3.0/src/services/discordActionLog.js @@ -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 }; diff --git a/services/arbiter-3.0/src/views/admin/discord-log.ejs b/services/arbiter-3.0/src/views/admin/discord-log.ejs new file mode 100644 index 0000000..934d69d --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/discord-log.ejs @@ -0,0 +1,98 @@ + + + + + +
| Time | +Action | +User | +Status | +Details | +
|---|---|---|---|---|
| <%= new Date(l.created_at).toLocaleString() %> | ++ <%= l.action_type %> + | ++ <% if (l.username) { %> + <%= l.username %> + (<%= l.discord_id %>) + <% } else if (l.discord_id) { %> + <%= l.discord_id %> + <% } else { %> + โ + <% } %> + | ++ <% if (l.success) { %> + OK + <% } else { %> + FAIL + <% } %> + | ++ <% if (l.error_message) { %> + โ <%= l.error_message.substring(0, 60) %> + <% } 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); + %> + <%= parts.join(' ยท ') || 'โ' %> + <% } %> + | +