diff --git a/TASK2.md b/TASK2.md new file mode 100644 index 000000000..ff5595f76 --- /dev/null +++ b/TASK2.md @@ -0,0 +1,69 @@ +# TASK2: Remaining Issue #144 Items + +Already done (skip these): +- Loading skeletons ✅ +- Error boundaries ✅ +- Toast notifications ✅ +- XP proxy validation ✅ +- Rate limit stale cleanup ✅ + +## Items to implement now + +### 1. Zustand store for member page +- File: `web/src/app/dashboard/[guildId]/members/page.tsx` (or similar) +- Find all useState/useEffect hooks managing member state +- Create `web/src/stores/members-store.ts` with Zustand +- Migrate: member list, loading state, search query, selected member, pagination +- Keep component using the store hooks + +### 2. Zustand store for moderation page +- File: `web/src/app/dashboard/[guildId]/moderation/page.tsx` (or similar) +- Same pattern — create `web/src/stores/moderation-store.ts` +- Migrate: cases list, filters, loading, pagination + +### 3. console.error cleanup in browser code +- `rg -rn "console\.error" web/src/` — find all occurrences in client components +- Replace with: toast.error() for user-facing errors, or keep console.error where it's truly logging-only +- Do NOT replace server-side console.error (only client components) + +### 4. events.js function extraction +- File: `src/modules/events/` — check if there's a monolithic events file or inline handlers in interactionCreate.js +- Run: `wc -l src/modules/events/interactionCreate.js` to check size +- If handlers are inline (ticket_open, ticket_close, poll, etc.), extract each to separate files in `src/modules/handlers/` +- Update interactionCreate.js to import from handlers + +### 5. Mobile responsiveness — critical fixes only +- `rg -rn "grid-cols-2\|grid-cols-3\|table\b" web/src/components/dashboard/ --include="*.tsx" | head -30` +- Add `sm:grid-cols-2` fallbacks where fixed grid-cols are used +- Add `overflow-x-auto` wrapper around data tables +- Focus on: member table, moderation cases table, config sections with grids + +### 6. Migration gap — add placeholder comment +- Create `migrations/012_placeholder.cjs` with a comment explaining the gap +- Content: migration that logs a warning about the gap, does nothing (no-op) + +### 7. Fix totalMessagesSent stat accuracy +- File: find where community page stats are calculated +- `rg -rn "totalMessagesSent\|messages_sent" web/src/ src/` +- Add a comment explaining the known limitation, or filter to last 30 days +- If it's a simple query change, fix it; if architectural, add a TODO comment + +### 8. Review bot consolidation (GitHub config) +- Disable Copilot and Greptile PR reviewers — keep Claude (coderabbitai style) + CodeRabbit +- Check `.github/` for reviewer config files +- Check `CODEOWNERS` or `.github/pull_request_review_protection.yml` +- If via GitHub API: `gh api repos/VolvoxLLC/volvox-bot/automated-security-fixes` +- Note: Greptile may be configured in `.greptile.yaml` or via webhook — find and disable + +## Architectural items (document only, don't implement) +These need separate planning — just add TODO comments: +- Server-side member search (needs Discord member cache or DB index) +- Conversation search pagination (needs full-text search index) +- Config patch deep validation (needs schema per config key) +- Logger browser shim (nice-to-have, low impact) + +## Rules +- Commit each fix separately with conventional commits +- Run `pnpm format && pnpm lint && pnpm test` +- Run `pnpm --prefix web lint && pnpm --prefix web typecheck` +- Do NOT push diff --git a/migrations/012_placeholder.cjs b/migrations/012_placeholder.cjs new file mode 100644 index 000000000..c508e9a42 --- /dev/null +++ b/migrations/012_placeholder.cjs @@ -0,0 +1,21 @@ +/** + * Migration 012 — Placeholder (gap filler) + * + * There are two migrations numbered 004 in this project: + * - 004_performance_indexes.cjs + * - 004_voice_sessions.cjs + * + * This no-op migration occupies the 012 slot to make the numbering sequence + * explicit going forward. No schema changes are made here. + */ + +/** @type {import('node-pg-migrate').MigrationBuilder} */ +exports.up = async (pgm) => { + // No-op — this migration exists only to document the sequence gap + pgm.noTransaction(); +}; + +/** @type {import('node-pg-migrate').MigrationBuilder} */ +exports.down = async (_pgm) => { + // Nothing to undo +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d6bac8a1..4e1376ea0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -151,6 +151,9 @@ importers: tailwind-merge: specifier: ^3.5.0 version: 3.5.0 + zustand: + specifier: ^5.0.11 + version: 5.0.11(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) devDependencies: '@tailwindcss/postcss': specifier: ^4.2.1 @@ -5472,6 +5475,24 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zustand@5.0.11: + resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@adobe/css-tools@4.4.4': {} @@ -6266,7 +6287,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.3.3 + '@types/node': 22.19.13 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -7951,7 +7972,7 @@ snapshots: '@types/pg@8.11.0': dependencies: - '@types/node': 25.3.3 + '@types/node': 22.19.13 pg-protocol: 1.13.0 pg-types: 4.1.0 @@ -7975,7 +7996,7 @@ snapshots: '@types/sqlite3@3.1.11': dependencies: - '@types/node': 25.3.3 + '@types/node': 22.19.13 '@types/stack-utils@2.0.3': {} @@ -9260,7 +9281,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.3.3 + '@types/node': 22.19.13 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -10089,7 +10110,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 25.3.3 + '@types/node': 22.19.13 long: 5.3.2 proxy-addr@2.0.7: @@ -11105,3 +11126,10 @@ snapshots: zod@3.25.76: {} zod@4.3.6: {} + + zustand@5.0.11(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + optionalDependencies: + '@types/react': 19.2.14 + immer: 11.1.4 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) diff --git a/src/api/routes/conversations.js b/src/api/routes/conversations.js index a188d5970..0b479535b 100644 --- a/src/api/routes/conversations.js +++ b/src/api/routes/conversations.js @@ -221,7 +221,10 @@ router.get('/', conversationsRateLimit, requireGuildAdmin, validateGuild, async if (req.query.search && typeof req.query.search === 'string') { paramIndex++; - // Uses idx_conversations_content_trgm (GIN/trgm) added in migration 004 + // Uses idx_conversations_content_trgm (GIN/trgm) added in migration 004. + // TODO: ILIKE + OFFSET pagination is O(n) on large datasets. For better + // performance at scale, switch to a full-text search index (e.g. tsvector + // with GIN) and use keyset/cursor pagination instead of OFFSET. whereParts.push(`content ILIKE $${paramIndex}`); values.push(`%${escapeIlike(req.query.search)}%`); } diff --git a/src/api/routes/guilds.js b/src/api/routes/guilds.js index bc2a51c34..c75a7525b 100644 --- a/src/api/routes/guilds.js +++ b/src/api/routes/guilds.js @@ -1185,6 +1185,11 @@ router.get('/:id/analytics', requireGuildAdmin, validateGuild, async (req, res) }), dbPool .query( + // NOTE: totalMessagesSent (and related stats) reflect cumulative all-time counts + // from user_stats, which has no time-series granularity. The user_stats table + // stores running totals per user with no timestamp column for filtering. + // TODO: For time-bounded accuracy (e.g. "last 30 days"), add a + // message_events log table and aggregate from that instead. `SELECT COUNT(DISTINCT user_id)::int AS tracked_users, COALESCE(SUM(messages_sent), 0)::bigint AS total_messages_sent, diff --git a/src/api/routes/members.js b/src/api/routes/members.js index 05d4a7730..86f55ff4f 100644 --- a/src/api/routes/members.js +++ b/src/api/routes/members.js @@ -314,6 +314,13 @@ router.get('/:id/members', membersRateLimit, requireGuildAdmin, validateGuild, a // (searches all guild members by username/nickname prefix), otherwise use // cursor-based listing. Sort is applied after enrichment and is scoped to // the returned page; it does NOT globally sort all guild members. + // + // TODO: Server-side member search accuracy — Discord's guild.members.search() + // only searches the bot's in-memory member cache (populated by the GUILD_MEMBERS + // privileged intent). For large guilds (100k+ members) the cache is incomplete. + // For full coverage, consider: (a) adding a members DB table populated from + // guildMemberAdd events + bulk sync on startup, or (b) using the Discord HTTP + // API directly with the Search Guild Members endpoint which searches all members. let memberList; let paginationCursor = null; if (search) { diff --git a/src/api/utils/validateConfigPatch.js b/src/api/utils/validateConfigPatch.js index 33c9fa4f4..755d2a0fb 100644 --- a/src/api/utils/validateConfigPatch.js +++ b/src/api/utils/validateConfigPatch.js @@ -80,5 +80,10 @@ export function validateConfigPatchBody(body, SAFE_CONFIG_KEYS) { return { error: 'Value validation failed', status: 400, details: valErrors }; } + // TODO: Deep per-key schema validation — currently validateSingleValue only checks + // type/range for known paths. Unknown paths pass through without structural validation. + // For full coverage, add a per-key JSON schema registry (one schema per top-level config + // section) and run deep validation against it here before accepting the patch. + return { path, value, topLevelKey }; } diff --git a/src/logger.js b/src/logger.js index 5ed1243ac..9ca6c5be2 100644 --- a/src/logger.js +++ b/src/logger.js @@ -6,6 +6,12 @@ * - Timestamp formatting * - Structured output * - Console transport (file transport added in phase 3) + * + * TODO: Logger browser shim — this module uses Winston + Node.js APIs (fs, path) and cannot + * be imported in browser/Next.js client components. If client-side structured logging is + * needed (e.g. for error tracking or debug mode), create a thin `web/src/lib/logger.ts` + * shim that wraps the browser console with the same interface (info/warn/error/debug) + * and optionally forwards to a remote logging endpoint. */ import { existsSync, mkdirSync, readFileSync } from 'node:fs'; diff --git a/src/modules/events/interactionCreate.js b/src/modules/events/interactionCreate.js index c4361a587..9612eac50 100644 --- a/src/modules/events/interactionCreate.js +++ b/src/modules/events/interactionCreate.js @@ -1,525 +1,21 @@ /** * InteractionCreate Event Handlers - * Handles all Discord interaction events (buttons, modals, select menus) - */ - -import { - ActionRowBuilder, - ChannelType, - Events, - ModalBuilder, - TextInputBuilder, - TextInputStyle, -} from 'discord.js'; -import { handleShowcaseModalSubmit, handleShowcaseUpvote } from '../../commands/showcase.js'; -import { error as logError, warn } from '../../logger.js'; -import { safeEditReply, safeReply } from '../../utils/safeSend.js'; -import { handleHintButton, handleSolveButton } from '../challengeScheduler.js'; -import { getConfig } from '../config.js'; -import { handlePollVote } from '../pollHandler.js'; -import { handleReminderDismiss, handleReminderSnooze } from '../reminderHandler.js'; -import { handleReviewClaim } from '../reviewHandler.js'; -import { closeTicket, getTicketConfig, openTicket } from '../ticketHandler.js'; -import { - handleRoleMenuSelection, - handleRulesAcceptButton, - ROLE_MENU_SELECT_ID, - RULES_ACCEPT_BUTTON_ID, -} from '../welcomeOnboarding.js'; - -/** - * Register an interactionCreate handler for poll vote buttons. - * Listens for button clicks with customId matching `poll_vote__`. - * - * @param {Client} client - Discord client instance - */ -export function registerPollButtonHandler(client) { - client.on(Events.InteractionCreate, async (interaction) => { - if (!interaction.isButton()) return; - if (!interaction.customId.startsWith('poll_vote_')) return; - - // Gate on poll feature being enabled for this guild - const guildConfig = getConfig(interaction.guildId); - if (!guildConfig.poll?.enabled) return; - - try { - await handlePollVote(interaction); - } catch (err) { - logError('Poll vote handler failed', { - customId: interaction.customId, - userId: interaction.user?.id, - error: err.message, - }); - - // Try to send an ephemeral error if we haven't replied yet - if (!interaction.replied && !interaction.deferred) { - try { - await safeReply(interaction, { - content: '❌ Something went wrong processing your vote.', - ephemeral: true, - }); - } catch { - // Ignore — we tried - } - } - } - }); -} - -/** - * Register an interactionCreate handler for review claim buttons. - * Listens for button clicks with customId matching `review_claim_`. - * - * @param {Client} client - Discord client instance - */ -export function registerReviewClaimHandler(client) { - client.on(Events.InteractionCreate, async (interaction) => { - if (!interaction.isButton()) return; - if (!interaction.customId.startsWith('review_claim_')) return; - - // Gate on review feature being enabled for this guild - const guildConfig = getConfig(interaction.guildId); - if (!guildConfig.review?.enabled) return; - - try { - await handleReviewClaim(interaction); - } catch (err) { - logError('Review claim handler failed', { - customId: interaction.customId, - userId: interaction.user?.id, - error: err.message, - }); - - if (!interaction.replied && !interaction.deferred) { - try { - await safeReply(interaction, { - content: '❌ Something went wrong processing your claim.', - ephemeral: true, - }); - } catch { - // Ignore — we tried - } - } - } - }); -} - -/** - * Register an interactionCreate handler for showcase upvote buttons. - * Listens for button clicks with customId matching `showcase_upvote_`. - * - * @param {Client} client - Discord client instance - */ -export function registerShowcaseButtonHandler(client) { - client.on(Events.InteractionCreate, async (interaction) => { - if (!interaction.isButton()) return; - if (!interaction.customId.startsWith('showcase_upvote_')) return; - - // Gate on showcase feature being enabled for this guild - const guildConfig = getConfig(interaction.guildId); - if (guildConfig.showcase?.enabled === false) return; - - let pool; - try { - pool = (await import('../../db.js')).getPool(); - } catch { - try { - await safeReply(interaction, { - content: '❌ Database is not available.', - ephemeral: true, - }); - } catch { - // Ignore - } - return; - } - - try { - await handleShowcaseUpvote(interaction, pool); - } catch (err) { - logError('Showcase upvote handler failed', { - customId: interaction.customId, - userId: interaction.user?.id, - error: err.message, - }); - - try { - const reply = interaction.deferred || interaction.replied ? safeEditReply : safeReply; - await reply(interaction, { - content: '❌ Something went wrong processing your upvote.', - ephemeral: true, - }); - } catch { - // Ignore — we tried - } - } - }); -} - -/** - * Register an interactionCreate handler for showcase modal submissions. - * Listens for modal submits with customId `showcase_submit_modal`. - * - * @param {Client} client - Discord client instance - */ -export function registerShowcaseModalHandler(client) { - client.on(Events.InteractionCreate, async (interaction) => { - if (!interaction.isModalSubmit()) return; - if (interaction.customId !== 'showcase_submit_modal') return; - - // Gate on showcase feature being enabled for this guild - const guildConfig = getConfig(interaction.guildId); - if (guildConfig.showcase?.enabled === false) return; - - let pool; - try { - pool = (await import('../../db.js')).getPool(); - } catch { - try { - await safeReply(interaction, { - content: '❌ Database is not available.', - ephemeral: true, - }); - } catch { - // Ignore - } - return; - } - - try { - await handleShowcaseModalSubmit(interaction, pool); - } catch (err) { - logError('Showcase modal error', { error: err.message }); - try { - const reply = interaction.deferred || interaction.replied ? safeEditReply : safeReply; - await reply(interaction, { content: '❌ Something went wrong.' }); - } catch (replyErr) { - logError('Failed to send fallback reply', { error: replyErr?.message }); - } - } - }); -} - -/** - * Register an interactionCreate handler for challenge solve and hint buttons. - * Listens for button clicks with customId matching `challenge_solve_` or `challenge_hint_`. - * - * @param {Client} client - Discord client instance - */ -export function registerChallengeButtonHandler(client) { - client.on(Events.InteractionCreate, async (interaction) => { - if (!interaction.isButton()) return; - - const isSolve = interaction.customId.startsWith('challenge_solve_'); - const isHint = interaction.customId.startsWith('challenge_hint_'); - if (!isSolve && !isHint) return; - - // Gate on challenges feature being enabled for this guild - const guildConfig = getConfig(interaction.guildId); - if (!guildConfig.challenges?.enabled) return; - - const prefix = isSolve ? 'challenge_solve_' : 'challenge_hint_'; - const indexStr = interaction.customId.slice(prefix.length); - const challengeIndex = Number.parseInt(indexStr, 10); - - if (Number.isNaN(challengeIndex)) { - warn('Invalid challenge button customId', { customId: interaction.customId }); - return; - } - - try { - if (isSolve) { - await handleSolveButton(interaction, challengeIndex); - } else { - await handleHintButton(interaction, challengeIndex); - } - } catch (err) { - logError('Challenge button handler failed', { - customId: interaction.customId, - userId: interaction.user?.id, - error: err.message, - }); - - if (!interaction.replied && !interaction.deferred) { - try { - await safeReply(interaction, { - content: '❌ Something went wrong. Please try again.', - ephemeral: true, - }); - } catch { - // Ignore - } - } - } - }); -} - -/** - * Register onboarding interaction handlers: - * - Rules acceptance button - * - Role selection menu - * - * @param {Client} client - Discord client instance - */ -export function registerWelcomeOnboardingHandlers(client) { - client.on(Events.InteractionCreate, async (interaction) => { - const guildId = interaction.guildId; - if (!guildId) return; - - const guildConfig = getConfig(guildId); - if (!guildConfig.welcome?.enabled) return; - - if (interaction.isButton() && interaction.customId === RULES_ACCEPT_BUTTON_ID) { - try { - await handleRulesAcceptButton(interaction, guildConfig); - } catch (err) { - logError('Rules acceptance handler failed', { - guildId, - userId: interaction.user?.id, - error: err?.message, - }); - - try { - // Handler already deferred, so we can safely edit - await safeEditReply(interaction, { - content: '❌ Failed to verify. Please ping an admin.', - }); - } catch { - // ignore - } - } - return; - } - - if (interaction.isStringSelectMenu() && interaction.customId === ROLE_MENU_SELECT_ID) { - try { - await handleRoleMenuSelection(interaction, guildConfig); - } catch (err) { - logError('Role menu handler failed', { - guildId, - userId: interaction.user?.id, - error: err?.message, - }); - - try { - // Handler already deferred, so we can safely edit - await safeEditReply(interaction, { - content: '❌ Failed to update roles. Please try again.', - }); - } catch { - // ignore - } - } - } - }); -} - -/** - * Register an interactionCreate handler for reminder snooze/dismiss buttons. - * Listens for button clicks with customId matching `reminder_snooze__` - * or `reminder_dismiss_`. - * - * @param {Client} client - Discord client instance - */ -export function registerReminderButtonHandler(client) { - client.on(Events.InteractionCreate, async (interaction) => { - if (!interaction.isButton()) return; - - const isSnooze = interaction.customId.startsWith('reminder_snooze_'); - const isDismiss = interaction.customId.startsWith('reminder_dismiss_'); - if (!isSnooze && !isDismiss) return; - - // Gate on reminders feature being enabled for this guild - const guildConfig = getConfig(interaction.guildId); - if (!guildConfig.reminders?.enabled) return; - - try { - if (isSnooze) { - await handleReminderSnooze(interaction); - } else { - await handleReminderDismiss(interaction); - } - } catch (err) { - logError('Reminder button handler failed', { - customId: interaction.customId, - userId: interaction.user?.id, - error: err.message, - }); - - if (!interaction.replied && !interaction.deferred) { - try { - await safeReply(interaction, { - content: '❌ Something went wrong processing your request.', - ephemeral: true, - }); - } catch { - // Ignore - } - } - } - }); -} - -/** - * Register an interactionCreate handler for ticket open button clicks. - * Listens for button clicks with customId `ticket_open` (from the persistent panel). - * Shows a modal to collect the ticket topic, then opens the ticket. - * - * @param {Client} client - Discord client instance - */ -export function registerTicketOpenButtonHandler(client) { - client.on(Events.InteractionCreate, async (interaction) => { - if (!interaction.isButton()) return; - if (interaction.customId !== 'ticket_open') return; - - const ticketConfig = getTicketConfig(interaction.guildId); - if (!ticketConfig.enabled) { - try { - await safeReply(interaction, { - content: '❌ The ticket system is not enabled on this server.', - ephemeral: true, - }); - } catch { - // Ignore - } - return; - } - - // Show a modal to collect the topic - const modal = new ModalBuilder() - .setCustomId('ticket_open_modal') - .setTitle('Open Support Ticket'); - - const topicInput = new TextInputBuilder() - .setCustomId('ticket_topic') - .setLabel('What do you need help with?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Describe your issue...') - .setMaxLength(200) - .setRequired(false); - - const row = new ActionRowBuilder().addComponents(topicInput); - modal.addComponents(row); - - try { - await interaction.showModal(modal); - } catch (err) { - logError('Failed to show ticket modal', { - userId: interaction.user?.id, - error: err.message, - }); - } - }); -} - -/** - * Register an interactionCreate handler for ticket modal submissions. - * Listens for modal submits with customId `ticket_open_modal`. * - * @param {Client} client - Discord client instance - */ -export function registerTicketModalHandler(client) { - client.on(Events.InteractionCreate, async (interaction) => { - if (!interaction.isModalSubmit()) return; - if (interaction.customId !== 'ticket_open_modal') return; - - try { - await interaction.deferReply({ ephemeral: true }); - } catch (err) { - logError('Failed to defer ticket modal reply', { - userId: interaction.user?.id, - guildId: interaction.guildId, - error: err?.message, - }); - return; - } - - const topic = interaction.fields.getTextInputValue('ticket_topic') || null; - - try { - const { ticket, thread } = await openTicket( - interaction.guild, - interaction.user, - topic, - interaction.channelId, - ); - - await safeEditReply(interaction, { - content: `✅ Ticket #${ticket.id} created! Head to <#${thread.id}>.`, - }); - } catch (err) { - logError('Ticket modal handler failed', { - userId: interaction.user?.id, - guildId: interaction.guildId, - error: err?.message, - }); - - // We already successfully deferred, so use safeEditReply - try { - await safeEditReply(interaction, { - content: '❌ An error occurred processing your ticket.', - }); - } catch (replyErr) { - logError('Failed to send fallback reply', { error: replyErr?.message }); - } - } - }); -} - -/** - * Register an interactionCreate handler for ticket close button clicks. - * Listens for button clicks with customId matching `ticket_close_`. - * - * @param {Client} client - Discord client instance - */ -export function registerTicketCloseButtonHandler(client) { - client.on(Events.InteractionCreate, async (interaction) => { - if (!interaction.isButton()) return; - if (!interaction.customId.startsWith('ticket_close_')) return; - - try { - await interaction.deferReply({ ephemeral: true }); - } catch (err) { - logError('Failed to defer ticket close reply', { - userId: interaction.user?.id, - guildId: interaction.guildId, - error: err?.message, - }); - return; - } - - const ticketChannel = interaction.channel; - const isThread = typeof ticketChannel?.isThread === 'function' && ticketChannel.isThread(); - const isTextChannel = ticketChannel?.type === ChannelType.GuildText; - - if (!isThread && !isTextChannel) { - await safeEditReply(interaction, { - content: '❌ This button can only be used inside a ticket channel or thread.', - }); - return; - } - - try { - const ticket = await closeTicket(ticketChannel, interaction.user, 'Closed via button'); - await safeEditReply(interaction, { - content: `✅ Ticket #${ticket.id} has been closed.`, - }); - } catch (err) { - logError('Ticket close handler failed', { - userId: interaction.user?.id, - guildId: interaction.guildId, - channelId: ticketChannel?.id, - error: err?.message, - }); - - // We already successfully deferred, so use safeEditReply - try { - await safeEditReply(interaction, { - content: '❌ An error occurred while closing the ticket.', - }); - } catch (replyErr) { - logError('Failed to send fallback reply', { error: replyErr?.message }); - } - } - }); -} + * This file re-exports all interaction handlers from the handlers/ directory. + * Each handler type lives in its own focused module under src/modules/handlers/. + */ + +export { registerChallengeButtonHandler } from '../handlers/challengeHandler.js'; +export { registerPollButtonHandler } from '../handlers/pollHandler.js'; +export { registerReminderButtonHandler } from '../handlers/reminderHandler.js'; +export { registerReviewClaimHandler } from '../handlers/reviewHandler.js'; +export { + registerShowcaseButtonHandler, + registerShowcaseModalHandler, +} from '../handlers/showcaseHandler.js'; +export { + registerTicketCloseButtonHandler, + registerTicketModalHandler, + registerTicketOpenButtonHandler, +} from '../handlers/ticketHandler.js'; +export { registerWelcomeOnboardingHandlers } from '../handlers/welcomeOnboardingHandler.js'; diff --git a/src/modules/handlers/challengeHandler.js b/src/modules/handlers/challengeHandler.js new file mode 100644 index 000000000..3aa77bd35 --- /dev/null +++ b/src/modules/handlers/challengeHandler.js @@ -0,0 +1,64 @@ +/** + * Challenge Button Handler + * Handles Discord button interactions for challenge solve and hint buttons. + */ + +import { Events } from 'discord.js'; +import { error as logError, warn } from '../../logger.js'; +import { safeReply } from '../../utils/safeSend.js'; +import { handleHintButton, handleSolveButton } from '../challengeScheduler.js'; +import { getConfig } from '../config.js'; + +/** + * Register an interactionCreate handler for challenge solve and hint buttons. + * Listens for button clicks with customId matching `challenge_solve_` or `challenge_hint_`. + * + * @param {Client} client - Discord client instance + */ +export function registerChallengeButtonHandler(client) { + client.on(Events.InteractionCreate, async (interaction) => { + if (!interaction.isButton()) return; + + const isSolve = interaction.customId.startsWith('challenge_solve_'); + const isHint = interaction.customId.startsWith('challenge_hint_'); + if (!isSolve && !isHint) return; + + // Gate on challenges feature being enabled for this guild + const guildConfig = getConfig(interaction.guildId); + if (!guildConfig.challenges?.enabled) return; + + const prefix = isSolve ? 'challenge_solve_' : 'challenge_hint_'; + const indexStr = interaction.customId.slice(prefix.length); + const challengeIndex = Number.parseInt(indexStr, 10); + + if (Number.isNaN(challengeIndex)) { + warn('Invalid challenge button customId', { customId: interaction.customId }); + return; + } + + try { + if (isSolve) { + await handleSolveButton(interaction, challengeIndex); + } else { + await handleHintButton(interaction, challengeIndex); + } + } catch (err) { + logError('Challenge button handler failed', { + customId: interaction.customId, + userId: interaction.user?.id, + error: err.message, + }); + + if (!interaction.replied && !interaction.deferred) { + try { + await safeReply(interaction, { + content: '❌ Something went wrong. Please try again.', + ephemeral: true, + }); + } catch { + // Ignore + } + } + } + }); +} diff --git a/src/modules/handlers/pollHandler.js b/src/modules/handlers/pollHandler.js new file mode 100644 index 000000000..664cd00c2 --- /dev/null +++ b/src/modules/handlers/pollHandler.js @@ -0,0 +1,49 @@ +/** + * Poll Vote Button Handler + * Handles Discord button interactions for poll voting. + */ + +import { Events } from 'discord.js'; +import { error as logError } from '../../logger.js'; +import { safeReply } from '../../utils/safeSend.js'; +import { getConfig } from '../config.js'; +import { handlePollVote } from '../pollHandler.js'; + +/** + * Register an interactionCreate handler for poll vote buttons. + * Listens for button clicks with customId matching `poll_vote__`. + * + * @param {Client} client - Discord client instance + */ +export function registerPollButtonHandler(client) { + client.on(Events.InteractionCreate, async (interaction) => { + if (!interaction.isButton()) return; + if (!interaction.customId.startsWith('poll_vote_')) return; + + // Gate on poll feature being enabled for this guild + const guildConfig = getConfig(interaction.guildId); + if (!guildConfig.poll?.enabled) return; + + try { + await handlePollVote(interaction); + } catch (err) { + logError('Poll vote handler failed', { + customId: interaction.customId, + userId: interaction.user?.id, + error: err.message, + }); + + // Try to send an ephemeral error if we haven't replied yet + if (!interaction.replied && !interaction.deferred) { + try { + await safeReply(interaction, { + content: '❌ Something went wrong processing your vote.', + ephemeral: true, + }); + } catch { + // Ignore — we tried + } + } + } + }); +} diff --git a/src/modules/handlers/reminderHandler.js b/src/modules/handlers/reminderHandler.js new file mode 100644 index 000000000..2957541cb --- /dev/null +++ b/src/modules/handlers/reminderHandler.js @@ -0,0 +1,56 @@ +/** + * Reminder Button Handler + * Handles Discord button interactions for reminder snooze and dismiss. + */ + +import { Events } from 'discord.js'; +import { error as logError } from '../../logger.js'; +import { safeReply } from '../../utils/safeSend.js'; +import { getConfig } from '../config.js'; +import { handleReminderDismiss, handleReminderSnooze } from '../reminderHandler.js'; + +/** + * Register an interactionCreate handler for reminder snooze/dismiss buttons. + * Listens for button clicks with customId matching `reminder_snooze__` + * or `reminder_dismiss_`. + * + * @param {Client} client - Discord client instance + */ +export function registerReminderButtonHandler(client) { + client.on(Events.InteractionCreate, async (interaction) => { + if (!interaction.isButton()) return; + + const isSnooze = interaction.customId.startsWith('reminder_snooze_'); + const isDismiss = interaction.customId.startsWith('reminder_dismiss_'); + if (!isSnooze && !isDismiss) return; + + // Gate on reminders feature being enabled for this guild + const guildConfig = getConfig(interaction.guildId); + if (!guildConfig.reminders?.enabled) return; + + try { + if (isSnooze) { + await handleReminderSnooze(interaction); + } else { + await handleReminderDismiss(interaction); + } + } catch (err) { + logError('Reminder button handler failed', { + customId: interaction.customId, + userId: interaction.user?.id, + error: err.message, + }); + + if (!interaction.replied && !interaction.deferred) { + try { + await safeReply(interaction, { + content: '❌ Something went wrong processing your request.', + ephemeral: true, + }); + } catch { + // Ignore + } + } + } + }); +} diff --git a/src/modules/handlers/reviewHandler.js b/src/modules/handlers/reviewHandler.js new file mode 100644 index 000000000..3dd686151 --- /dev/null +++ b/src/modules/handlers/reviewHandler.js @@ -0,0 +1,48 @@ +/** + * Review Claim Button Handler + * Handles Discord button interactions for review claiming. + */ + +import { Events } from 'discord.js'; +import { error as logError } from '../../logger.js'; +import { safeReply } from '../../utils/safeSend.js'; +import { getConfig } from '../config.js'; +import { handleReviewClaim } from '../reviewHandler.js'; + +/** + * Register an interactionCreate handler for review claim buttons. + * Listens for button clicks with customId matching `review_claim_`. + * + * @param {Client} client - Discord client instance + */ +export function registerReviewClaimHandler(client) { + client.on(Events.InteractionCreate, async (interaction) => { + if (!interaction.isButton()) return; + if (!interaction.customId.startsWith('review_claim_')) return; + + // Gate on review feature being enabled for this guild + const guildConfig = getConfig(interaction.guildId); + if (!guildConfig.review?.enabled) return; + + try { + await handleReviewClaim(interaction); + } catch (err) { + logError('Review claim handler failed', { + customId: interaction.customId, + userId: interaction.user?.id, + error: err.message, + }); + + if (!interaction.replied && !interaction.deferred) { + try { + await safeReply(interaction, { + content: '❌ Something went wrong processing your claim.', + ephemeral: true, + }); + } catch { + // Ignore — we tried + } + } + } + }); +} diff --git a/src/modules/handlers/showcaseHandler.js b/src/modules/handlers/showcaseHandler.js new file mode 100644 index 000000000..153b343c2 --- /dev/null +++ b/src/modules/handlers/showcaseHandler.js @@ -0,0 +1,106 @@ +/** + * Showcase Button and Modal Handlers + * Handles Discord button interactions for showcase upvotes and modal submissions. + */ + +import { Events } from 'discord.js'; +import { handleShowcaseModalSubmit, handleShowcaseUpvote } from '../../commands/showcase.js'; +import { error as logError } from '../../logger.js'; +import { safeEditReply, safeReply } from '../../utils/safeSend.js'; +import { getConfig } from '../config.js'; + +/** + * Register an interactionCreate handler for showcase upvote buttons. + * Listens for button clicks with customId matching `showcase_upvote_`. + * + * @param {Client} client - Discord client instance + */ +export function registerShowcaseButtonHandler(client) { + client.on(Events.InteractionCreate, async (interaction) => { + if (!interaction.isButton()) return; + if (!interaction.customId.startsWith('showcase_upvote_')) return; + + // Gate on showcase feature being enabled for this guild + const guildConfig = getConfig(interaction.guildId); + if (guildConfig.showcase?.enabled === false) return; + + let pool; + try { + pool = (await import('../../db.js')).getPool(); + } catch { + try { + await safeReply(interaction, { + content: '❌ Database is not available.', + ephemeral: true, + }); + } catch { + // Ignore + } + return; + } + + try { + await handleShowcaseUpvote(interaction, pool); + } catch (err) { + logError('Showcase upvote handler failed', { + customId: interaction.customId, + userId: interaction.user?.id, + error: err.message, + }); + + try { + const reply = interaction.deferred || interaction.replied ? safeEditReply : safeReply; + await reply(interaction, { + content: '❌ Something went wrong processing your upvote.', + ephemeral: true, + }); + } catch { + // Ignore — we tried + } + } + }); +} + +/** + * Register an interactionCreate handler for showcase modal submissions. + * Listens for modal submits with customId `showcase_submit_modal`. + * + * @param {Client} client - Discord client instance + */ +export function registerShowcaseModalHandler(client) { + client.on(Events.InteractionCreate, async (interaction) => { + if (!interaction.isModalSubmit()) return; + if (interaction.customId !== 'showcase_submit_modal') return; + + // Gate on showcase feature being enabled for this guild + const guildConfig = getConfig(interaction.guildId); + if (guildConfig.showcase?.enabled === false) return; + + let pool; + try { + pool = (await import('../../db.js')).getPool(); + } catch { + try { + await safeReply(interaction, { + content: '❌ Database is not available.', + ephemeral: true, + }); + } catch { + // Ignore + } + return; + } + + try { + await handleShowcaseModalSubmit(interaction, pool); + } catch (err) { + logError('Showcase modal error', { error: err.message }); + try { + const reply = interaction.deferred || interaction.replied ? safeEditReply : safeReply; + await reply(interaction, { content: '❌ Something went wrong.' }); + } catch (replyErr) { + logError('Failed to send fallback reply', { error: replyErr?.message }); + } + } + }); +} diff --git a/src/modules/handlers/ticketHandler.js b/src/modules/handlers/ticketHandler.js new file mode 100644 index 000000000..370f948d6 --- /dev/null +++ b/src/modules/handlers/ticketHandler.js @@ -0,0 +1,180 @@ +/** + * Ticket Button and Modal Handlers + * Handles Discord interactions for opening and closing support tickets. + */ + +import { + ActionRowBuilder, + ChannelType, + Events, + ModalBuilder, + TextInputBuilder, + TextInputStyle, +} from 'discord.js'; +import { error as logError } from '../../logger.js'; +import { safeEditReply, safeReply } from '../../utils/safeSend.js'; +import { closeTicket, getTicketConfig, openTicket } from '../ticketHandler.js'; + +/** + * Register an interactionCreate handler for ticket open button clicks. + * Listens for button clicks with customId `ticket_open` (from the persistent panel). + * Shows a modal to collect the ticket topic, then opens the ticket. + * + * @param {Client} client - Discord client instance + */ +export function registerTicketOpenButtonHandler(client) { + client.on(Events.InteractionCreate, async (interaction) => { + if (!interaction.isButton()) return; + if (interaction.customId !== 'ticket_open') return; + + const ticketConfig = getTicketConfig(interaction.guildId); + if (!ticketConfig.enabled) { + try { + await safeReply(interaction, { + content: '❌ The ticket system is not enabled on this server.', + ephemeral: true, + }); + } catch { + // Ignore + } + return; + } + + // Show a modal to collect the topic + const modal = new ModalBuilder() + .setCustomId('ticket_open_modal') + .setTitle('Open Support Ticket'); + + const topicInput = new TextInputBuilder() + .setCustomId('ticket_topic') + .setLabel('What do you need help with?') + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder('Describe your issue...') + .setMaxLength(200) + .setRequired(false); + + const row = new ActionRowBuilder().addComponents(topicInput); + modal.addComponents(row); + + try { + await interaction.showModal(modal); + } catch (err) { + logError('Failed to show ticket modal', { + userId: interaction.user?.id, + error: err.message, + }); + } + }); +} + +/** + * Register an interactionCreate handler for ticket modal submissions. + * Listens for modal submits with customId `ticket_open_modal`. + * + * @param {Client} client - Discord client instance + */ +export function registerTicketModalHandler(client) { + client.on(Events.InteractionCreate, async (interaction) => { + if (!interaction.isModalSubmit()) return; + if (interaction.customId !== 'ticket_open_modal') return; + + try { + await interaction.deferReply({ ephemeral: true }); + } catch (err) { + logError('Failed to defer ticket modal reply', { + userId: interaction.user?.id, + guildId: interaction.guildId, + error: err?.message, + }); + return; + } + + const topic = interaction.fields.getTextInputValue('ticket_topic') || null; + + try { + const { ticket, thread } = await openTicket( + interaction.guild, + interaction.user, + topic, + interaction.channelId, + ); + + await safeEditReply(interaction, { + content: `✅ Ticket #${ticket.id} created! Head to <#${thread.id}>.`, + }); + } catch (err) { + logError('Ticket modal handler failed', { + userId: interaction.user?.id, + guildId: interaction.guildId, + error: err?.message, + }); + + // We already successfully deferred, so use safeEditReply + try { + await safeEditReply(interaction, { + content: '❌ An error occurred processing your ticket.', + }); + } catch (replyErr) { + logError('Failed to send fallback reply', { error: replyErr?.message }); + } + } + }); +} + +/** + * Register an interactionCreate handler for ticket close button clicks. + * Listens for button clicks with customId matching `ticket_close_`. + * + * @param {Client} client - Discord client instance + */ +export function registerTicketCloseButtonHandler(client) { + client.on(Events.InteractionCreate, async (interaction) => { + if (!interaction.isButton()) return; + if (!interaction.customId.startsWith('ticket_close_')) return; + + try { + await interaction.deferReply({ ephemeral: true }); + } catch (err) { + logError('Failed to defer ticket close reply', { + userId: interaction.user?.id, + guildId: interaction.guildId, + error: err?.message, + }); + return; + } + + const ticketChannel = interaction.channel; + const isThread = typeof ticketChannel?.isThread === 'function' && ticketChannel.isThread(); + const isTextChannel = ticketChannel?.type === ChannelType.GuildText; + + if (!isThread && !isTextChannel) { + await safeEditReply(interaction, { + content: '❌ This button can only be used inside a ticket channel or thread.', + }); + return; + } + + try { + const ticket = await closeTicket(ticketChannel, interaction.user, 'Closed via button'); + await safeEditReply(interaction, { + content: `✅ Ticket #${ticket.id} has been closed.`, + }); + } catch (err) { + logError('Ticket close handler failed', { + userId: interaction.user?.id, + guildId: interaction.guildId, + channelId: ticketChannel?.id, + error: err?.message, + }); + + // We already successfully deferred, so use safeEditReply + try { + await safeEditReply(interaction, { + content: '❌ An error occurred while closing the ticket.', + }); + } catch (replyErr) { + logError('Failed to send fallback reply', { error: replyErr?.message }); + } + } + }); +} diff --git a/src/modules/handlers/welcomeOnboardingHandler.js b/src/modules/handlers/welcomeOnboardingHandler.js new file mode 100644 index 000000000..2fa253598 --- /dev/null +++ b/src/modules/handlers/welcomeOnboardingHandler.js @@ -0,0 +1,75 @@ +/** + * Welcome Onboarding Handlers + * Handles Discord button and select menu interactions for rules acceptance and role selection. + */ + +import { Events } from 'discord.js'; +import { error as logError } from '../../logger.js'; +import { safeEditReply } from '../../utils/safeSend.js'; +import { getConfig } from '../config.js'; +import { + handleRoleMenuSelection, + handleRulesAcceptButton, + ROLE_MENU_SELECT_ID, + RULES_ACCEPT_BUTTON_ID, +} from '../welcomeOnboarding.js'; + +/** + * Register onboarding interaction handlers: + * - Rules acceptance button + * - Role selection menu + * + * @param {Client} client - Discord client instance + */ +export function registerWelcomeOnboardingHandlers(client) { + client.on(Events.InteractionCreate, async (interaction) => { + const guildId = interaction.guildId; + if (!guildId) return; + + const guildConfig = getConfig(guildId); + if (!guildConfig.welcome?.enabled) return; + + if (interaction.isButton() && interaction.customId === RULES_ACCEPT_BUTTON_ID) { + try { + await handleRulesAcceptButton(interaction, guildConfig); + } catch (err) { + logError('Rules acceptance handler failed', { + guildId, + userId: interaction.user?.id, + error: err?.message, + }); + + try { + // Handler already deferred, so we can safely edit + await safeEditReply(interaction, { + content: '❌ Failed to verify. Please ping an admin.', + }); + } catch { + // ignore + } + } + return; + } + + if (interaction.isStringSelectMenu() && interaction.customId === ROLE_MENU_SELECT_ID) { + try { + await handleRoleMenuSelection(interaction, guildConfig); + } catch (err) { + logError('Role menu handler failed', { + guildId, + userId: interaction.user?.id, + error: err?.message, + }); + + try { + // Handler already deferred, so we can safely edit + await safeEditReply(interaction, { + content: '❌ Failed to update roles. Please try again.', + }); + } catch { + // ignore + } + } + } + }); +} diff --git a/src/modules/rateLimit.js b/src/modules/rateLimit.js index 8dddea9d0..df6fa1373 100644 --- a/src/modules/rateLimit.js +++ b/src/modules/rateLimit.js @@ -236,9 +236,11 @@ let cleanupInterval = null; /** * Start periodic cleanup of stale windowMap entries. * Removes entries when the latest activity is older than the tracked retention window. - * Runs every 5 minutes. + * Runs every 5 minutes. Safe to call multiple times — no-ops if already running. + * + * Exported for testing purposes (allows restarting with fake timers). */ -function startRateLimitCleanup() { +export function startRateLimitCleanup() { if (cleanupInterval) return; const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes const DEFAULT_WINDOW_MS = 10 * 1000; // fallback window diff --git a/tests/modules/rateLimit.test.js b/tests/modules/rateLimit.test.js index 5ed01849b..cdfd52fe4 100644 --- a/tests/modules/rateLimit.test.js +++ b/tests/modules/rateLimit.test.js @@ -358,7 +358,7 @@ describe('checkRateLimit — warns user', () => { }); }); -import { stopRateLimitCleanup } from '../../src/modules/rateLimit.js'; +import { startRateLimitCleanup, stopRateLimitCleanup } from '../../src/modules/rateLimit.js'; describe('checkRateLimit — handleRepeatOffender edge cases', () => { it('should return early if message.member is null', async () => { @@ -639,3 +639,69 @@ describe('checkRateLimit — muteWindowMinutes singular/plural', () => { expect(alertSend).toHaveBeenCalled(); }); }); + +describe('stale entry cleanup (interval sweep)', () => { + beforeEach(() => { + // Stop the auto-started real-timer interval, then switch to fake timers + // and restart so the interval is captured by vi's fake clock. + stopRateLimitCleanup(); + vi.useFakeTimers(); + startRateLimitCleanup(); + clearRateLimitState(); + }); + + afterEach(() => { + stopRateLimitCleanup(); + vi.useRealTimers(); + // Restart the real cleanup interval for subsequent test suites + startRateLimitCleanup(); + clearRateLimitState(); + }); + + it('removes entries whose last activity is older than their retention window', async () => { + const config = { + moderation: { + rateLimit: { + enabled: true, + maxMessages: 100, // high threshold — we just want to seed an entry + windowSeconds: 1, + muteAfterTriggers: 10, + muteWindowSeconds: 1, + muteDurationSeconds: 60, + }, + exemptRoles: [], + exemptUsers: [], + }, + }; + + const msg = { + author: { + id: 'user-stale-cleanup', + tag: 'Stale#0001', + bot: false, + }, + channel: { id: 'chan-stale', type: 0 }, + guild: { + id: 'guild-stale', + members: { me: { permissions: { has: vi.fn().mockReturnValue(false) } } }, + }, + member: { + permissions: { has: vi.fn().mockReturnValue(false) }, + roles: { cache: { some: vi.fn().mockReturnValue(false) } }, + }, + delete: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue({ delete: vi.fn() }), + client: { channels: { fetch: vi.fn() } }, + }; + + // Seed one entry + await checkRateLimit(msg, config); + expect(getTrackedCount()).toBe(1); + + // Advance time past the cleanup interval (5 min) + retention window (1 s) + vi.advanceTimersByTime(5 * 60 * 1000 + 2000); + + // Stale entry should be swept + expect(getTrackedCount()).toBe(0); + }); +}); diff --git a/web/package.json b/web/package.json index a8f79629a..b8764bdf5 100644 --- a/web/package.json +++ b/web/package.json @@ -34,7 +34,8 @@ "recharts": "^3.7.0", "server-only": "^0.0.1", "sonner": "^2.0.7", - "tailwind-merge": "^3.5.0" + "tailwind-merge": "^3.5.0", + "zustand": "^5.0.11" }, "devDependencies": { "@tailwindcss/postcss": "^4.2.1", diff --git a/web/src/app/api/guilds/[guildId]/members/[userId]/xp/route.ts b/web/src/app/api/guilds/[guildId]/members/[userId]/xp/route.ts index 1924b9dd4..3378be7a7 100644 --- a/web/src/app/api/guilds/[guildId]/members/[userId]/xp/route.ts +++ b/web/src/app/api/guilds/[guildId]/members/[userId]/xp/route.ts @@ -37,8 +37,39 @@ export async function POST( let body: string; try { - const json = await request.json(); - body = JSON.stringify(json); + const json: unknown = await request.json(); + + // Validate payload shape before forwarding (defense-in-depth) + if (typeof json !== 'object' || json === null || Array.isArray(json)) { + return NextResponse.json({ error: 'Body must be a JSON object' }, { status: 400 }); + } + const payload = json as Record; + + if (!('amount' in payload)) { + return NextResponse.json({ error: 'Missing required field: amount' }, { status: 400 }); + } + if ( + typeof payload.amount !== 'number' || + !Number.isFinite(payload.amount) || + !Number.isInteger(payload.amount) + ) { + return NextResponse.json( + { error: 'Field "amount" must be a finite integer' }, + { status: 400 }, + ); + } + if ('reason' in payload && payload.reason !== undefined && typeof payload.reason !== 'string') { + return NextResponse.json( + { error: 'Field "reason" must be a string when provided' }, + { status: 400 }, + ); + } + + // Only forward the known fields — strip unknown keys + const sanitized: { amount: number; reason?: string } = { amount: payload.amount as number }; + if (typeof payload.reason === 'string') sanitized.reason = payload.reason; + + body = JSON.stringify(sanitized); } catch { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); } diff --git a/web/src/app/dashboard/audit-log/page.tsx b/web/src/app/dashboard/audit-log/page.tsx index 67e4db798..d9ce3b357 100644 --- a/web/src/app/dashboard/audit-log/page.tsx +++ b/web/src/app/dashboard/audit-log/page.tsx @@ -13,6 +13,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Skeleton } from '@/components/ui/skeleton'; import { Table, TableBody, @@ -62,6 +63,49 @@ function actionVariant(action: string): 'default' | 'secondary' | 'destructive' const PAGE_SIZE = 25; /** Common action types for the filter dropdown */ +function AuditLogSkeleton() { + return ( +
+ + + + + Action + User + Target + Date + IP + + + + {Array.from({ length: 8 }).map((_, i) => ( + + + + + + + + + + + + + + + + + + + + + ))} + +
+
+ ); +} + const ACTION_OPTIONS = [ 'config.update', 'members.update', @@ -341,7 +385,9 @@ export default function AuditLogPage() { )} {/* Table */} - {entries.length > 0 ? ( + {loading && entries.length === 0 ? ( + + ) : entries.length > 0 ? (
@@ -409,15 +455,13 @@ export default function AuditLogPage() {
) : ( - !loading && ( -
-

- {actionFilter || debouncedUserSearch || startDate || endDate - ? 'No audit entries match your filters.' - : 'No audit log entries found.'} -

-
- ) +
+

+ {actionFilter || debouncedUserSearch || startDate || endDate + ? 'No audit entries match your filters.' + : 'No audit log entries found.'} +

+
)} {/* Pagination */} diff --git a/web/src/app/dashboard/conversations/page.tsx b/web/src/app/dashboard/conversations/page.tsx index 125658880..b3099ae72 100644 --- a/web/src/app/dashboard/conversations/page.tsx +++ b/web/src/app/dashboard/conversations/page.tsx @@ -13,6 +13,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Skeleton } from '@/components/ui/skeleton'; import { Table, TableBody, @@ -23,6 +24,49 @@ import { } from '@/components/ui/table'; import { useGuildSelection } from '@/hooks/use-guild-selection'; +function ConversationsSkeleton() { + return ( +
+ + + + Channel + Participants + Messages + Duration + Preview + Date + + + + {Array.from({ length: 8 }).map((_, i) => ( + + + + + + + + + + + + + + + + + + + + + ))} + +
+
+ ); +} + interface Participant { username: string; role: string; @@ -324,7 +368,9 @@ export default function ConversationsPage() { )} {/* Table */} - {conversations.length > 0 ? ( + {loading && conversations.length === 0 ? ( + + ) : conversations.length > 0 ? (
@@ -388,15 +434,13 @@ export default function ConversationsPage() {
) : ( - !loading && ( -
-

- {debouncedSearch || channelFilter - ? 'No conversations match your filters.' - : 'No conversations found.'} -

-
- ) +
+

+ {debouncedSearch || channelFilter + ? 'No conversations match your filters.' + : 'No conversations found.'} +

+
)} {/* Pagination */} diff --git a/web/src/app/dashboard/members/[userId]/page.tsx b/web/src/app/dashboard/members/[userId]/page.tsx index cfa248a49..06698015e 100644 --- a/web/src/app/dashboard/members/[userId]/page.tsx +++ b/web/src/app/dashboard/members/[userId]/page.tsx @@ -13,6 +13,7 @@ import { import Image from 'next/image'; import { useParams, useRouter } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; +import { toast } from 'sonner'; import { ActionBadge } from '@/components/dashboard/action-badge'; import type { ModAction } from '@/components/dashboard/moderation-types'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; @@ -276,9 +277,9 @@ export default function MemberDetailPage() { throw new Error(body.error || `Failed to adjust XP (${res.status})`); } const result = await res.json(); - setXpSuccess( - `XP adjusted by ${amount > 0 ? '+' : ''}${amount}. New total: ${result.xp?.toLocaleString() ?? 'updated'}`, - ); + const successMsg = `XP adjusted by ${amount > 0 ? '+' : ''}${amount}. New total: ${result.xp?.toLocaleString() ?? 'updated'}`; + setXpSuccess(successMsg); + toast.success('XP adjusted', { description: successMsg }); setXpAmount(''); setXpReason(''); @@ -298,7 +299,9 @@ export default function MemberDetailPage() { ); } } catch (err) { - setXpError(err instanceof Error ? err.message : 'Failed to adjust XP'); + const errMsg = err instanceof Error ? err.message : 'Failed to adjust XP'; + setXpError(errMsg); + toast.error('XP adjustment failed', { description: errMsg }); } finally { setXpSubmitting(false); } @@ -325,8 +328,11 @@ export default function MemberDetailPage() { a.download = `members-${guildId}.csv`; a.click(); URL.revokeObjectURL(url); + toast.success('Export downloaded', { description: `members-${guildId}.csv` }); } catch (err) { - setExportError(err instanceof Error ? err.message : 'Failed to export CSV'); + const errMsg = err instanceof Error ? err.message : 'Failed to export CSV'; + setExportError(errMsg); + toast.error('Export failed', { description: errMsg }); } finally { setExporting(false); } diff --git a/web/src/app/dashboard/members/page.tsx b/web/src/app/dashboard/members/page.tsx index fc9f41206..a6f0b09de 100644 --- a/web/src/app/dashboard/members/page.tsx +++ b/web/src/app/dashboard/members/page.tsx @@ -2,7 +2,7 @@ import { RefreshCw, Search, Users, X } from 'lucide-react'; import { useRouter } from 'next/navigation'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { type MemberRow, MemberTable, @@ -12,6 +12,7 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { useGuildSelection } from '@/hooks/use-guild-selection'; +import { useMembersStore } from '@/stores/members-store'; interface MembersApiResponse { members: MemberRow[]; @@ -32,20 +33,34 @@ interface MembersApiResponse { export default function MembersPage() { const router = useRouter(); - const [members, setMembers] = useState([]); - const [nextAfter, setNextAfter] = useState(null); - const [total, setTotal] = useState(0); - const [filteredTotal, setFilteredTotal] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const [search, setSearch] = useState(''); - const [sortColumn, setSortColumn] = useState('xp'); - const [sortOrder, setSortOrder] = useState('desc'); + const { + members, + nextAfter, + total, + filteredTotal, + loading, + error, + search, + debouncedSearch, + sortColumn, + sortOrder, + setMembers, + appendMembers, + setNextAfter, + setTotal, + setFilteredTotal, + setLoading, + setError, + setSearch, + setDebouncedSearch, + setSortColumn, + setSortOrder, + resetPagination, + resetAll, + } = useMembersStore(); // Debounce search const searchTimerRef = useRef>(undefined); - const [debouncedSearch, setDebouncedSearch] = useState(''); // AbortController and request sequencing to prevent stale responses const abortControllerRef = useRef(null); @@ -57,7 +72,7 @@ export default function MembersPage() { setDebouncedSearch(search); }, 300); return () => clearTimeout(searchTimerRef.current); - }, [search]); + }, [search, setDebouncedSearch]); // Abort in-flight request on unmount useEffect(() => { @@ -67,12 +82,8 @@ export default function MembersPage() { }, []); const onGuildChange = useCallback(() => { - setMembers([]); - setNextAfter(null); - setTotal(0); - setFilteredTotal(null); - setError(null); - }, []); + resetAll(); + }, [resetAll]); const guildId = useGuildSelection({ onGuildChange }); @@ -122,7 +133,7 @@ export default function MembersPage() { } const data = (await res.json()) as MembersApiResponse; if (opts.append) { - setMembers((prev) => [...prev, ...data.members]); + appendMembers(data.members); } else { setMembers(data.members); } @@ -142,7 +153,16 @@ export default function MembersPage() { } } }, - [onUnauthorized], + [ + onUnauthorized, + appendMembers, + setMembers, + setNextAfter, + setTotal, + setFilteredTotal, + setLoading, + setError, + ], ); // Fetch on guild/search/sort change @@ -161,15 +181,14 @@ export default function MembersPage() { const handleSort = useCallback( (col: SortColumn) => { if (col === sortColumn) { - setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc')); + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); } else { setSortColumn(col); setSortOrder('desc'); } - setMembers([]); - setNextAfter(null); + resetPagination(); }, - [sortColumn], + [sortColumn, sortOrder, setSortColumn, setSortOrder, resetPagination], ); const handleLoadMore = useCallback(() => { @@ -186,8 +205,7 @@ export default function MembersPage() { const handleRefresh = useCallback(() => { if (!guildId) return; - setMembers([]); - setNextAfter(null); + resetPagination(); void fetchMembers({ guildId, search: debouncedSearch, @@ -196,7 +214,7 @@ export default function MembersPage() { after: null, append: false, }); - }, [guildId, fetchMembers, debouncedSearch, sortColumn, sortOrder]); + }, [guildId, fetchMembers, debouncedSearch, sortColumn, sortOrder, resetPagination]); const handleRowClick = useCallback( (userId: string) => { @@ -209,7 +227,7 @@ export default function MembersPage() { const handleClearSearch = useCallback(() => { setSearch(''); setDebouncedSearch(''); - }, []); + }, [setSearch, setDebouncedSearch]); return (
diff --git a/web/src/app/dashboard/moderation/page.tsx b/web/src/app/dashboard/moderation/page.tsx index cdba80fb9..873e3e8bb 100644 --- a/web/src/app/dashboard/moderation/page.tsx +++ b/web/src/app/dashboard/moderation/page.tsx @@ -2,35 +2,44 @@ import { RefreshCw, Search, Shield, X } from 'lucide-react'; import { useRouter } from 'next/navigation'; -import { useCallback, useState } from 'react'; +import { useCallback } from 'react'; import { CaseTable } from '@/components/dashboard/case-table'; import { ModerationStats } from '@/components/dashboard/moderation-stats'; import { Button } from '@/components/ui/button'; +import { ErrorBoundary } from '@/components/ui/error-boundary'; import { Input } from '@/components/ui/input'; import { useGuildSelection } from '@/hooks/use-guild-selection'; import { useModerationCases } from '@/hooks/use-moderation-cases'; import { useModerationStats } from '@/hooks/use-moderation-stats'; import { useUserHistory } from '@/hooks/use-user-history'; +import { useModerationStore } from '@/stores/moderation-store'; export default function ModerationPage() { const router = useRouter(); - // Filters & pagination - const [page, setPage] = useState(1); - const [sortDesc, setSortDesc] = useState(true); - const [actionFilter, setActionFilter] = useState('all'); - const [userSearch, setUserSearch] = useState(''); - - // User history lookup - const [userHistoryInput, setUserHistoryInput] = useState(''); - const [lookupUserId, setLookupUserId] = useState(null); - const [userHistoryPage, setUserHistoryPage] = useState(1); + const { + page, + sortDesc, + actionFilter, + userSearch, + userHistoryInput, + lookupUserId, + userHistoryPage, + setPage, + toggleSortDesc, + setActionFilter, + setUserSearch, + setUserHistoryInput, + setLookupUserId, + setUserHistoryPage, + clearFilters, + clearUserHistory, + resetOnGuildChange, + } = useModerationStore(); const onGuildChange = useCallback(() => { - setPage(1); - setLookupUserId(null); - setUserHistoryInput(''); - }, []); + resetOnGuildChange(); + }, [resetOnGuildChange]); const guildId = useGuildSelection({ onGuildChange }); @@ -65,12 +74,6 @@ export default function ModerationPage() { if (lookupUserId && guildId) fetchUserHistory(guildId, lookupUserId, userHistoryPage); }, [refetchStats, refetchCases, lookupUserId, guildId, fetchUserHistory, userHistoryPage]); - const handleClearFilters = useCallback(() => { - setActionFilter('all'); - setUserSearch(''); - setPage(1); - }, []); - const handleUserHistorySearch = useCallback( (e: React.FormEvent) => { e.preventDefault(); @@ -81,15 +84,21 @@ export default function ModerationPage() { setUserHistoryData(null); void fetchUserHistory(guildId, trimmed, 1); }, - [guildId, userHistoryInput, fetchUserHistory, setUserHistoryData], + [ + guildId, + userHistoryInput, + fetchUserHistory, + setUserHistoryData, + setLookupUserId, + setUserHistoryPage, + ], ); const handleClearUserHistory = useCallback(() => { - setLookupUserId(null); + clearUserHistory(); setUserHistoryData(null); setUserHistoryError(null); - setUserHistoryInput(''); - }, [setUserHistoryData, setUserHistoryError]); + }, [clearUserHistory, setUserHistoryData, setUserHistoryError]); return (
@@ -130,7 +139,9 @@ export default function ModerationPage() { {guildId && ( <> {/* Stats */} - + + + {/* Cases */}
@@ -145,10 +156,10 @@ export default function ModerationPage() { userSearch={userSearch} guildId={guildId} onPageChange={setPage} - onSortToggle={() => setSortDesc((d) => !d)} + onSortToggle={toggleSortDesc} onActionFilterChange={setActionFilter} onUserSearchChange={setUserSearch} - onClearFilters={handleClearFilters} + onClearFilters={clearFilters} />
diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx index 72b0933fa..fa974f267 100644 --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -1,5 +1,13 @@ import { AnalyticsDashboard } from '@/components/dashboard/analytics-dashboard'; +import { ErrorBoundary } from '@/components/ui/error-boundary'; export default function DashboardPage() { - return ; + return ( + + + + ); } diff --git a/web/src/app/dashboard/temp-roles/page.tsx b/web/src/app/dashboard/temp-roles/page.tsx index edd373127..df3a3a2d3 100644 --- a/web/src/app/dashboard/temp-roles/page.tsx +++ b/web/src/app/dashboard/temp-roles/page.tsx @@ -11,6 +11,7 @@ import { Clock, RefreshCw, Shield, Trash2 } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; import { useGuildSelection } from '@/hooks/use-guild-selection'; interface TempRole { @@ -199,7 +200,17 @@ export default function TempRolesPage() { {guildId && !error && (
{loading && rows.length === 0 ? ( -
Loading…
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + + + + +
+ ))} +
) : rows.length === 0 ? (
No active temporary roles. diff --git a/web/src/app/dashboard/tickets/page.tsx b/web/src/app/dashboard/tickets/page.tsx index d39c6d16a..fa7f5a618 100644 --- a/web/src/app/dashboard/tickets/page.tsx +++ b/web/src/app/dashboard/tickets/page.tsx @@ -13,6 +13,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Skeleton } from '@/components/ui/skeleton'; import { Table, TableBody, @@ -23,6 +24,49 @@ import { } from '@/components/ui/table'; import { useGuildSelection } from '@/hooks/use-guild-selection'; +function TicketsSkeleton() { + return ( +
+ + + + ID + Topic + User + Status + Created + Closed + + + + {Array.from({ length: 8 }).map((_, i) => ( + + + + + + + + + + + + + + + + + + + + + ))} + +
+
+ ); +} + interface TicketSummary { id: number; guild_id: string; @@ -338,7 +382,9 @@ export default function TicketsPage() { )} {/* Table */} - {tickets.length > 0 ? ( + {loading && tickets.length === 0 ? ( + + ) : tickets.length > 0 ? (
@@ -389,15 +435,13 @@ export default function TicketsPage() {
) : ( - !loading && ( -
-

- {statusFilter || debouncedSearch - ? 'No tickets match your filters.' - : 'No tickets found.'} -

-
- ) +
+

+ {statusFilter || debouncedSearch + ? 'No tickets match your filters.' + : 'No tickets found.'} +

+
)} {/* Pagination */} diff --git a/web/src/components/dashboard/ai-feedback-stats.tsx b/web/src/components/dashboard/ai-feedback-stats.tsx index 676efaa93..75bc43f4a 100644 --- a/web/src/components/dashboard/ai-feedback-stats.tsx +++ b/web/src/components/dashboard/ai-feedback-stats.tsx @@ -90,7 +90,7 @@ export function AiFeedbackStats() { {!loading && !error && stats && (
{/* Summary row */} -
+
diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx index d8800b019..f03fdaa65 100644 --- a/web/src/components/dashboard/config-editor.tsx +++ b/web/src/components/dashboard/config-editor.tsx @@ -1349,7 +1349,7 @@ export function ConfigEditor() { label="Rate Limiting" />
-
+
-
+
-
+