diff --git a/migrations/014_tickets.cjs b/migrations/014_tickets.cjs new file mode 100644 index 000000000..f5505f6ed --- /dev/null +++ b/migrations/014_tickets.cjs @@ -0,0 +1,47 @@ +/** + * Migration 014 — Tickets + * Creates the tickets table for the support ticket system. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/134 + */ + +'use strict'; + +/** + * @param {import('pg').Pool} pool + */ +async function up(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS tickets ( + id SERIAL PRIMARY KEY, + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + topic TEXT, + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')), + thread_id TEXT NOT NULL, + channel_id TEXT, + closed_by TEXT, + close_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + closed_at TIMESTAMPTZ, + transcript JSONB + ); + `); + + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_tickets_guild_status + ON tickets(guild_id, status); + `); + + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_tickets_user + ON tickets(guild_id, user_id); + `); + + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_tickets_thread_status + ON tickets(thread_id, status); + `); +} + +module.exports = { up }; diff --git a/src/api/index.js b/src/api/index.js index cd9793eca..938d2f378 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -13,6 +13,7 @@ import guildsRouter from './routes/guilds.js'; import healthRouter from './routes/health.js'; import membersRouter from './routes/members.js'; import moderationRouter from './routes/moderation.js'; +import ticketsRouter from './routes/tickets.js'; import webhooksRouter from './routes/webhooks.js'; const router = Router(); @@ -37,6 +38,10 @@ router.use('/guilds', requireAuth(), membersRouter); // (mounted before guilds to handle /:id/conversations/* before the catch-all guild endpoint) router.use('/guilds/:id/conversations', requireAuth(), conversationsRouter); +// Ticket routes — require API secret or OAuth2 JWT +// (mounted before guilds to handle /:id/tickets/* before the catch-all guild endpoint) +router.use('/guilds', requireAuth(), ticketsRouter); + // Guild routes — require API secret or OAuth2 JWT router.use('/guilds', requireAuth(), guildsRouter); diff --git a/src/api/routes/tickets.js b/src/api/routes/tickets.js new file mode 100644 index 000000000..f6942597c --- /dev/null +++ b/src/api/routes/tickets.js @@ -0,0 +1,180 @@ +/** + * Ticket API Routes + * Exposes ticket data for the web dashboard. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/134 + */ + +import { Router } from 'express'; +import { error as logError } from '../../logger.js'; +import { rateLimit } from '../middleware/rateLimit.js'; +import { requireGuildAdmin, validateGuild } from './guilds.js'; + +const router = Router(); + +/** Rate limiter for ticket API endpoints — 30 req/min per IP. */ +const ticketRateLimit = rateLimit({ windowMs: 60 * 1000, max: 30 }); + +/** + * Helper to get the database pool from app.locals. + * + * @param {import('express').Request} req + * @returns {import('pg').Pool | null} + */ +function getDbPool(req) { + return req.app.locals.dbPool || null; +} + +// ─── GET /:id/tickets/stats ─────────────────────────────────────────────────── + +/** + * GET /:id/tickets/stats — Ticket statistics for a guild. + * Returns open count, avg resolution time, and tickets this week. + */ +router.get( + '/:id/tickets/stats', + ticketRateLimit, + requireGuildAdmin, + validateGuild, + async (req, res) => { + const { id: guildId } = req.params; + const pool = getDbPool(req); + if (!pool) return res.status(503).json({ error: 'Database not available' }); + + try { + const [openResult, avgResult, weekResult] = await Promise.all([ + pool.query( + 'SELECT COUNT(*)::int AS count FROM tickets WHERE guild_id = $1 AND status = $2', + [guildId, 'open'], + ), + pool.query( + `SELECT COALESCE( + EXTRACT(EPOCH FROM AVG(closed_at - created_at))::int, 0 + ) AS avg_seconds + FROM tickets + WHERE guild_id = $1 AND status = 'closed' AND closed_at IS NOT NULL`, + [guildId], + ), + pool.query( + `SELECT COUNT(*)::int AS count + FROM tickets + WHERE guild_id = $1 AND created_at >= NOW() - INTERVAL '7 days'`, + [guildId], + ), + ]); + + res.json({ + openCount: openResult.rows[0].count, + avgResolutionSeconds: avgResult.rows[0].avg_seconds, + ticketsThisWeek: weekResult.rows[0].count, + }); + } catch (err) { + logError('Failed to fetch ticket stats', { guildId, error: err.message }); + res.status(500).json({ error: 'Failed to fetch ticket stats' }); + } + }, +); + +// ─── GET /:id/tickets/:ticketId ─────────────────────────────────────────────── + +/** + * GET /:id/tickets/:ticketId — Ticket detail with transcript. + */ +router.get( + '/:id/tickets/:ticketId', + ticketRateLimit, + requireGuildAdmin, + validateGuild, + async (req, res) => { + const { id: guildId, ticketId } = req.params; + const pool = getDbPool(req); + if (!pool) return res.status(503).json({ error: 'Database not available' }); + + const parsedId = Number.parseInt(ticketId, 10); + if (Number.isNaN(parsedId)) { + return res.status(400).json({ error: 'Invalid ticket ID' }); + } + + try { + const { rows } = await pool.query('SELECT * FROM tickets WHERE guild_id = $1 AND id = $2', [ + guildId, + parsedId, + ]); + + if (rows.length === 0) { + return res.status(404).json({ error: 'Ticket not found' }); + } + + res.json(rows[0]); + } catch (err) { + logError('Failed to fetch ticket detail', { guildId, ticketId, error: err.message }); + res.status(500).json({ error: 'Failed to fetch ticket' }); + } + }, +); + +// ─── GET /:id/tickets ───────────────────────────────────────────────────────── + +/** + * GET /:id/tickets — List tickets with pagination and filters. + * + * Query params: + * status — Filter by status (open, closed) + * user — Filter by user ID + * page — Page number (default 1) + * limit — Items per page (default 25, max 100) + */ +router.get('/:id/tickets', ticketRateLimit, requireGuildAdmin, validateGuild, async (req, res) => { + const { id: guildId } = req.params; + const { status, user } = req.query; + const page = Math.max(1, Number.parseInt(req.query.page, 10) || 1); + const limit = Math.min(100, Math.max(1, Number.parseInt(req.query.limit, 10) || 25)); + const offset = (page - 1) * limit; + const pool = getDbPool(req); + if (!pool) return res.status(503).json({ error: 'Database not available' }); + + try { + const conditions = ['guild_id = $1']; + const params = [guildId]; + let paramIndex = 2; + + if (status && (status === 'open' || status === 'closed')) { + conditions.push(`status = $${paramIndex}`); + params.push(status); + paramIndex++; + } + + if (user) { + conditions.push(`user_id = $${paramIndex}`); + params.push(user); + paramIndex++; + } + + const whereClause = conditions.join(' AND '); + + const [countResult, ticketsResult] = await Promise.all([ + pool.query(`SELECT COUNT(*)::int AS total FROM tickets WHERE ${whereClause}`, params), + pool.query( + `SELECT id, guild_id, user_id, topic, status, thread_id, channel_id, + closed_by, close_reason, created_at, closed_at + FROM tickets + WHERE ${whereClause} + ORDER BY created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + [...params, limit, offset], + ), + ]); + + res.json({ + tickets: ticketsResult.rows, + total: countResult.rows[0].total, + page, + limit, + }); + } catch (err) { + logError('Failed to fetch tickets', { guildId, error: err.message }); + res.status(500).json({ error: 'Failed to fetch tickets' }); + } +}); + +export default router; diff --git a/src/commands/ticket.js b/src/commands/ticket.js new file mode 100644 index 000000000..4f33beba9 --- /dev/null +++ b/src/commands/ticket.js @@ -0,0 +1,271 @@ +/** + * Ticket Command + * Create and manage support tickets via /ticket. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/134 + */ + +import { ChannelType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { getPool } from '../db.js'; +import { info } from '../logger.js'; +import { getConfig } from '../modules/config.js'; +import { + addMember, + buildTicketPanel, + closeTicket, + getTicketConfig, + openTicket, + removeMember, +} from '../modules/ticketHandler.js'; +import { isModerator } from '../utils/permissions.js'; +import { safeEditReply, safeSend } from '../utils/safeSend.js'; + +export const data = new SlashCommandBuilder() + .setName('ticket') + .setDescription('Create and manage support tickets') + .addSubcommand((sub) => + sub + .setName('open') + .setDescription('Open a new support ticket') + .addStringOption((opt) => + opt.setName('topic').setDescription('Topic for the ticket').setRequired(false), + ), + ) + .addSubcommand((sub) => + sub + .setName('close') + .setDescription('Close the current ticket') + .addStringOption((opt) => + opt.setName('reason').setDescription('Reason for closing').setRequired(false), + ), + ) + .addSubcommand((sub) => + sub + .setName('add') + .setDescription('Add a user to the current ticket') + .addUserOption((opt) => opt.setName('user').setDescription('User to add').setRequired(true)), + ) + .addSubcommand((sub) => + sub + .setName('remove') + .setDescription('Remove a user from the current ticket') + .addUserOption((opt) => + opt.setName('user').setDescription('User to remove').setRequired(true), + ), + ) + .addSubcommand((sub) => + sub + .setName('panel') + .setDescription('Post a persistent ticket panel (Admin only)') + .addChannelOption((opt) => + opt.setName('channel').setDescription('Channel to post the panel in').setRequired(false), + ), + ); + +/** + * Check if a channel is a valid ticket context (thread or ticket text channel) + * by verifying the channel has an open ticket in the database. + * + * @param {import('discord.js').Channel} channel + * @returns {Promise} + */ +async function isTicketContext(channel) { + if (!channel) return false; + const isThread = typeof channel.isThread === 'function' && channel.isThread(); + const isTextChannel = channel.type === ChannelType.GuildText; + if (!isThread && !isTextChannel) return false; + + // Verify this channel/thread is actually an open ticket + const pool = getPool(); + if (!pool) return false; + const { rows } = await pool.query('SELECT 1 FROM tickets WHERE thread_id = $1 AND status = $2', [ + channel.id, + 'open', + ]); + return rows.length > 0; +} + +/** + * Execute the /ticket command. + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + await interaction.deferReply({ ephemeral: true }); + + const guildConfig = getConfig(interaction.guildId); + const ticketConfig = getTicketConfig(interaction.guildId); + + if (!ticketConfig.enabled) { + await safeEditReply(interaction, { + content: '❌ The ticket system is not enabled on this server.', + }); + return; + } + + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'open') { + await handleOpen(interaction); + } else if (subcommand === 'close') { + await handleClose(interaction); + } else if (subcommand === 'add') { + await handleAdd(interaction); + } else if (subcommand === 'remove') { + await handleRemove(interaction); + } else if (subcommand === 'panel') { + await handlePanel(interaction, guildConfig); + } +} + +/** + * Handle /ticket open — create a new ticket. + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +async function handleOpen(interaction) { + const topic = interaction.options.getString('topic'); + + 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) { + await safeEditReply(interaction, { + content: `❌ ${err.message}`, + }); + } +} + +/** + * Handle /ticket close — close the current ticket. + * Works in both thread-mode and channel-mode tickets. + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +async function handleClose(interaction) { + const reason = interaction.options.getString('reason'); + const channel = interaction.channel; + + if (!(await isTicketContext(channel))) { + await safeEditReply(interaction, { + content: '❌ This command must be used inside a ticket thread or channel.', + }); + return; + } + + try { + const ticket = await closeTicket(channel, interaction.user, reason); + await safeEditReply(interaction, { + content: `✅ Ticket #${ticket.id} has been closed.`, + }); + } catch (err) { + await safeEditReply(interaction, { + content: `❌ ${err.message}`, + }); + } +} + +/** + * Handle /ticket add — add a user to the current ticket. + * Works in both thread-mode (thread.members) and channel-mode (permission overrides). + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +async function handleAdd(interaction) { + const user = interaction.options.getUser('user'); + const channel = interaction.channel; + + if (!(await isTicketContext(channel))) { + await safeEditReply(interaction, { + content: '❌ This command must be used inside a ticket thread or channel.', + }); + return; + } + + try { + await addMember(channel, user); + await safeEditReply(interaction, { + content: `✅ <@${user.id}> has been added to the ticket.`, + }); + } catch (err) { + await safeEditReply(interaction, { + content: `❌ Failed to add user: ${err.message}`, + }); + } +} + +/** + * Handle /ticket remove — remove a user from the current ticket. + * Works in both thread-mode (thread.members) and channel-mode (permission overrides). + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +async function handleRemove(interaction) { + const user = interaction.options.getUser('user'); + const channel = interaction.channel; + + if (!(await isTicketContext(channel))) { + await safeEditReply(interaction, { + content: '❌ This command must be used inside a ticket thread or channel.', + }); + return; + } + + try { + await removeMember(channel, user); + await safeEditReply(interaction, { + content: `✅ <@${user.id}> has been removed from the ticket.`, + }); + } catch (err) { + await safeEditReply(interaction, { + content: `❌ Failed to remove user: ${err.message}`, + }); + } +} + +/** + * Handle /ticket panel — post a persistent ticket panel (Admin only). + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {object} guildConfig + */ +async function handlePanel(interaction, guildConfig) { + // Check admin permissions + if ( + !interaction.member.permissions.has(PermissionFlagsBits.Administrator) && + !isModerator(interaction.member, guildConfig) + ) { + await safeEditReply(interaction, { + content: '❌ You need moderator or administrator permissions to use this command.', + }); + return; + } + + const targetChannel = interaction.options.getChannel('channel') || interaction.channel; + + const { embed, row } = buildTicketPanel(interaction.guildId); + + try { + await safeSend(targetChannel, { embeds: [embed], components: [row] }); + await safeEditReply(interaction, { + content: `✅ Ticket panel posted in <#${targetChannel.id}>.`, + }); + info('Ticket panel posted', { + guildId: interaction.guildId, + channelId: targetChannel.id, + postedBy: interaction.user.id, + }); + } catch (err) { + await safeEditReply(interaction, { + content: `❌ Failed to post panel: ${err.message}`, + }); + } +} diff --git a/src/modules/events.js b/src/modules/events.js index 9035be39b..b48538a8d 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -3,7 +3,15 @@ * Handles Discord event listeners and handlers */ -import { Client, Events } from 'discord.js'; +import { + ActionRowBuilder, + ChannelType, + Client, + Events, + ModalBuilder, + TextInputBuilder, + TextInputStyle, +} from 'discord.js'; import { handleShowcaseModalSubmit, handleShowcaseUpvote } from '../commands/showcase.js'; import { info, error as logError, warn } from '../logger.js'; import { getUserFriendlyMessage } from '../utils/errors.js'; @@ -22,6 +30,7 @@ import { handleXpGain } from './reputation.js'; import { handleReviewClaim } from './reviewHandler.js'; import { isSpam, sendSpamAlert } from './spam.js'; import { handleReactionAdd, handleReactionRemove } from './starboard.js'; +import { closeTicket, getTicketConfig, openTicket } from './ticketHandler.js'; import { accumulateMessage, evaluateNow } from './triage.js'; import { recordCommunityActivity, sendWelcomeMessage } from './welcome.js'; @@ -563,5 +572,131 @@ export function registerEventHandlers(client, config, healthMonitor) { registerReviewClaimHandler(client); registerShowcaseButtonHandler(client); registerShowcaseModalHandler(client); + registerTicketOpenButtonHandler(client); + registerTicketModalHandler(client); + registerTicketCloseButtonHandler(client); registerErrorHandlers(client); } + +/** + * 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; + + await interaction.deferReply({ ephemeral: true }); + + 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) { + await safeEditReply(interaction, { + content: `❌ ${err.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; + + await interaction.deferReply({ ephemeral: true }); + + 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) { + await safeEditReply(interaction, { + content: `❌ ${err.message}`, + }); + } + }); +} diff --git a/src/modules/scheduler.js b/src/modules/scheduler.js index 69aabebde..eb78437ce 100644 --- a/src/modules/scheduler.js +++ b/src/modules/scheduler.js @@ -11,6 +11,7 @@ import { safeSend } from '../utils/safeSend.js'; import { checkDailyChallenge } from './challengeScheduler.js'; import { closeExpiredPolls } from './pollHandler.js'; import { expireStaleReviews } from './reviewHandler.js'; +import { checkAutoClose } from './ticketHandler.js'; /** @type {ReturnType | null} */ let schedulerInterval = null; @@ -18,6 +19,9 @@ let schedulerInterval = null; /** Re-entrancy guard */ let pollInFlight = false; +/** Tick counter for throttling heavy tasks */ +let tickCount = 0; + /** * Parse a 5-field cron expression into its component arrays. * Supports: numbers, wildcards (*), and single values. @@ -188,6 +192,11 @@ async function pollScheduledMessages(client) { await checkDailyChallenge(client); // Expire stale review requests await expireStaleReviews(client); + // Auto-close inactive support tickets (every 5 minutes / 5th tick) + tickCount++; + if (tickCount % 5 === 0) { + await checkAutoClose(client); + } } catch (err) { logError('Scheduler poll error', { error: err.message }); } finally { diff --git a/src/modules/ticketHandler.js b/src/modules/ticketHandler.js new file mode 100644 index 000000000..5e31cf96e --- /dev/null +++ b/src/modules/ticketHandler.js @@ -0,0 +1,543 @@ +/** + * Ticket Handler Module + * Business logic for support ticket creation, closing, member management, and auto-close. + * + * Supports two modes: + * - "thread" (default): creates a private thread per ticket + * - "channel": creates a dedicated text channel per ticket with permission overrides + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/134 + */ + +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ChannelType, + EmbedBuilder, + OverwriteType, + PermissionFlagsBits, +} from 'discord.js'; +import { getPool } from '../db.js'; +import { info, error as logError } from '../logger.js'; +import { safeSend } from '../utils/safeSend.js'; +import { getConfig } from './config.js'; + +/** Default configuration values for the ticket system */ +const TICKET_DEFAULTS = { + enabled: false, + mode: 'thread', + supportRole: null, + category: null, + autoCloseHours: 48, + transcriptChannel: null, + maxOpenPerUser: 3, +}; + +/** Warning hours before auto-close (sent after autoCloseHours, then closed after this) */ +const AUTO_CLOSE_WARNING_HOURS = 24; + +/** Embed colour for tickets */ +const TICKET_COLOR = 0x5865f2; + +/** Embed colour for closed tickets */ +const TICKET_CLOSED_COLOR = 0xed4245; + +/** Embed colour for the ticket panel */ +const TICKET_PANEL_COLOR = 0x57f287; + +/** Delay (ms) before deleting a channel-mode ticket so the close message is visible */ +const CHANNEL_DELETE_DELAY_MS = 10_000; + +/** Track ticket IDs that have received an auto-close warning in this process run */ +const warningsSent = new Set(); + +/** + * Resolve ticket config from guild config with defaults. + * + * @param {string} guildId - Guild ID + * @returns {object} Merged ticket config + */ +export function getTicketConfig(guildId) { + const cfg = getConfig(guildId); + return { ...TICKET_DEFAULTS, ...cfg.tickets }; +} + +/** + * Build the permission-override array used when creating a channel-mode ticket. + * + * @param {import('discord.js').Guild} guild + * @param {string} userId - The ticket opener + * @param {string|null} supportRoleId + * @returns {Array} + */ +function buildChannelPermissions(guild, userId, supportRoleId) { + const overwrites = [ + // Deny @everyone + { + id: guild.id, + type: OverwriteType.Role, + deny: [PermissionFlagsBits.ViewChannel], + }, + // Allow ticket user + { + id: userId, + type: OverwriteType.Member, + allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages], + }, + // Allow bot + { + id: guild.members.me?.id ?? guild.client.user.id, + type: OverwriteType.Member, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.ManageChannels, + ], + }, + ]; + + if (supportRoleId) { + overwrites.push({ + id: supportRoleId, + type: OverwriteType.Role, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.ManageMessages, + ], + }); + } + + return overwrites; +} + +/** + * Open a new support ticket by creating a private thread or a dedicated text channel. + * + * @param {import('discord.js').Guild} guild - The Discord guild + * @param {import('discord.js').User} user - The user opening the ticket + * @param {string|null} topic - Optional topic for the ticket + * @param {string|null} channelId - The channel the ticket panel lives in (for DB tracking) + * @returns {Promise<{ticket: object, thread: import('discord.js').ThreadChannel|import('discord.js').TextChannel}>} + */ +export async function openTicket(guild, user, topic, channelId = null) { + const pool = getPool(); + if (!pool) throw new Error('Database not available'); + + const ticketConfig = getTicketConfig(guild.id); + + // Check max open tickets per user + const { rows: openTickets } = await pool.query( + 'SELECT COUNT(*)::int AS count FROM tickets WHERE guild_id = $1 AND user_id = $2 AND status = $3', + [guild.id, user.id, 'open'], + ); + + if (openTickets[0].count >= ticketConfig.maxOpenPerUser) { + throw new Error( + `You already have ${ticketConfig.maxOpenPerUser} open tickets. Please close one before opening another.`, + ); + } + + const ticketName = topic + ? `ticket-${user.username}-${topic.slice(0, 20).replace(/\s+/g, '-').toLowerCase()}` + : `ticket-${user.username}`; + + /** Sanitize a string to meet Discord channel name rules (lowercase, alphanumeric + hyphens, max 100 chars) */ + const sanitizeChannelName = (name) => + name + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 100) || 'ticket'; + + let ticketChannel; + + if (ticketConfig.mode === 'channel') { + // ── Channel mode: create a text channel with permission overrides ── + const parent = ticketConfig.category + ? guild.channels.cache.get(ticketConfig.category) + : undefined; + + const safeUsername = sanitizeChannelName(user.username); + const channelTicketName = topic + ? sanitizeChannelName(`ticket-${safeUsername}-${topic.slice(0, 20).replace(/\s+/g, '-')}`) + : sanitizeChannelName(`ticket-${safeUsername}`); + + ticketChannel = await guild.channels.create({ + name: channelTicketName, + type: ChannelType.GuildText, + parent: parent?.id ?? undefined, + permissionOverwrites: buildChannelPermissions(guild, user.id, ticketConfig.supportRole), + reason: `Support ticket opened by ${user.tag}`, + }); + } else { + // ── Thread mode (default): create a private thread ── + let parentChannel; + if (ticketConfig.category) { + const resolved = guild.channels.cache.get(ticketConfig.category); + // CategoryChannel can't create threads — only GuildText supports PrivateThread + if (resolved && resolved.type === ChannelType.GuildText) { + parentChannel = resolved; + } + } + if (!parentChannel && channelId) { + parentChannel = guild.channels.cache.get(channelId); + } + if (!parentChannel) { + parentChannel = guild.channels.cache.find( + (ch) => + ch.type === ChannelType.GuildText && + guild.members.me && + ch.permissionsFor(guild.members.me)?.has(PermissionFlagsBits.CreatePrivateThreads), + ); + } + + if (!parentChannel) { + throw new Error('No suitable channel found to create a ticket thread.'); + } + + ticketChannel = await parentChannel.threads.create({ + name: ticketName, + type: ChannelType.PrivateThread, + reason: `Support ticket opened by ${user.tag}`, + }); + + // Add the user to the thread + await ticketChannel.members.add(user.id); + + // Add support role members if configured + if (ticketConfig.supportRole) { + const role = guild.roles.cache.get(ticketConfig.supportRole); + if (role) { + for (const [, member] of role.members) { + try { + await ticketChannel.members.add(member.id); + } catch { + // Some members may not be fetchable + } + } + } + } + } + + // Insert into database (channel ID stored in thread_id for both modes) + const { rows } = await pool.query( + `INSERT INTO tickets (guild_id, user_id, topic, thread_id, channel_id) + VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [guild.id, user.id, topic, ticketChannel.id, channelId], + ); + + const ticket = rows[0]; + + // Post initial embed + const embed = new EmbedBuilder() + .setColor(TICKET_COLOR) + .setTitle(`🎫 Ticket #${ticket.id}`) + .setDescription(topic || 'No topic provided') + .addFields( + { name: 'Opened by', value: `<@${user.id}>`, inline: true }, + { name: 'Status', value: '🟢 Open', inline: true }, + ) + .setTimestamp(); + + const closeButton = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`ticket_close_${ticket.id}`) + .setLabel('Close Ticket') + .setStyle(ButtonStyle.Danger) + .setEmoji('🔒'), + ); + + await safeSend(ticketChannel, { embeds: [embed], components: [closeButton] }); + + info('Ticket opened', { + ticketId: ticket.id, + guildId: guild.id, + userId: user.id, + topic, + mode: ticketConfig.mode, + }); + + return { ticket, thread: ticketChannel }; +} + +/** + * Close a ticket: save transcript, update DB, archive thread or delete channel. + * + * @param {import('discord.js').ThreadChannel|import('discord.js').TextChannel} channel - The ticket thread or channel + * @param {import('discord.js').User} closer - The user closing the ticket + * @param {string|null} reason - Optional close reason + * @returns {Promise} The closed ticket row + */ +export async function closeTicket(channel, closer, reason) { + const pool = getPool(); + if (!pool) throw new Error('Database not available'); + + // Find the ticket by thread_id (stores either thread or channel ID) + const { rows } = await pool.query('SELECT * FROM tickets WHERE thread_id = $1 AND status = $2', [ + channel.id, + 'open', + ]); + + if (rows.length === 0) { + throw new Error('No open ticket found for this thread.'); + } + + const ticket = rows[0]; + const isThread = typeof channel.isThread === 'function' && channel.isThread(); + + // Fetch transcript (last 100 messages) + const messages = await channel.messages.fetch({ limit: 100 }); + const transcript = Array.from(messages.values()) + .reverse() + .map((msg) => ({ + author: msg.author?.tag || 'Unknown', + authorId: msg.author?.id || null, + content: msg.content || '', + timestamp: msg.createdAt.toISOString(), + })); + + // Update the ticket in DB + const { rows: updated } = await pool.query( + `UPDATE tickets + SET status = 'closed', closed_by = $1, close_reason = $2, closed_at = NOW(), transcript = $3 + WHERE id = $4 RETURNING *`, + [closer.id, reason, JSON.stringify(transcript), ticket.id], + ); + + // Post closing embed + const embed = new EmbedBuilder() + .setColor(TICKET_CLOSED_COLOR) + .setTitle(`🔒 Ticket #${ticket.id} Closed`) + .addFields( + { name: 'Closed by', value: `<@${closer.id}>`, inline: true }, + { name: 'Reason', value: reason || 'No reason provided', inline: true }, + ) + .setTimestamp(); + + await safeSend(channel, { embeds: [embed], components: [] }); + + // Send transcript to transcript channel if configured + const ticketConfig = getTicketConfig(ticket.guild_id); + if (ticketConfig.transcriptChannel) { + try { + const guild = channel.guild; + const transcriptCh = guild.channels.cache.get(ticketConfig.transcriptChannel); + if (transcriptCh) { + const transcriptEmbed = new EmbedBuilder() + .setColor(TICKET_CLOSED_COLOR) + .setTitle(`📋 Ticket #${ticket.id} Transcript`) + .setDescription(`Topic: ${ticket.topic || 'None'}\nMessages: ${transcript.length}`) + .addFields( + { name: 'Opened by', value: `<@${ticket.user_id}>`, inline: true }, + { name: 'Closed by', value: `<@${closer.id}>`, inline: true }, + { name: 'Reason', value: reason || 'No reason provided', inline: true }, + ) + .setTimestamp(); + await safeSend(transcriptCh, { embeds: [transcriptEmbed] }); + } + } catch (err) { + logError('Failed to send ticket transcript', { ticketId: ticket.id, error: err.message }); + } + } + + // Archive (thread) or delete (channel) + if (isThread) { + try { + await channel.setArchived(true); + } catch (err) { + logError('Failed to archive ticket thread', { ticketId: ticket.id, error: err.message }); + } + } else { + // Channel mode: delete after a short delay so the close message is visible + // NOTE: known limitation — if the process restarts during the delay, + // the channel won't be deleted (orphaned). A startup cleanup job could address this. + setTimeout(async () => { + try { + await channel.delete(`Ticket #${ticket.id} closed`); + } catch (err) { + logError('Failed to delete ticket channel', { ticketId: ticket.id, error: err.message }); + } + }, CHANNEL_DELETE_DELAY_MS); + } + + warningsSent.delete(ticket.id); + + info('Ticket closed', { + ticketId: ticket.id, + guildId: ticket.guild_id, + closedBy: closer.id, + reason, + }); + + return updated[0]; +} + +/** + * Add a user to a ticket thread or channel. + * + * For thread mode: adds via thread.members. + * For channel mode: grants ViewChannel + SendMessages via permission overrides. + * + * @param {import('discord.js').ThreadChannel|import('discord.js').TextChannel} channel - The ticket thread or channel + * @param {import('discord.js').User} user - The user to add + */ +export async function addMember(channel, user) { + const isThread = typeof channel.isThread === 'function' && channel.isThread(); + + if (isThread) { + await channel.members.add(user.id); + } else { + await channel.permissionOverwrites.edit(user.id, { + ViewChannel: true, + SendMessages: true, + }); + } + + await safeSend(channel, { content: `✅ <@${user.id}> has been added to the ticket.` }); + info('Member added to ticket', { channelId: channel.id, userId: user.id }); +} + +/** + * Remove a user from a ticket thread or channel. + * + * For thread mode: removes via thread.members. + * For channel mode: revokes ViewChannel via permission overrides. + * + * @param {import('discord.js').ThreadChannel|import('discord.js').TextChannel} channel - The ticket thread or channel + * @param {import('discord.js').User} user - The user to remove + */ +export async function removeMember(channel, user) { + const isThread = typeof channel.isThread === 'function' && channel.isThread(); + + if (isThread) { + await channel.members.remove(user.id); + } else { + await channel.permissionOverwrites.delete(user.id); + } + + await safeSend(channel, { content: `🚫 <@${user.id}> has been removed from the ticket.` }); + info('Member removed from ticket', { channelId: channel.id, userId: user.id }); +} + +/** + * Check for tickets that should be auto-closed due to inactivity. + * Sends a warning after autoCloseHours, then closes after an additional 24h. + * Works for both thread-mode and channel-mode tickets. + * + * @param {import('discord.js').Client} client - The Discord client + */ +export async function checkAutoClose(client) { + const pool = getPool(); + if (!pool) return; + + // Find all open tickets for guilds the bot is currently in + const guildIds = Array.from(client.guilds.cache.keys()); + if (guildIds.length === 0) return; + + const { rows: openTickets } = await pool.query( + 'SELECT * FROM tickets WHERE status = $1 AND guild_id = ANY($2::text[])', + ['open', guildIds], + ); + + for (const ticket of openTickets) { + try { + const ticketConfig = getTicketConfig(ticket.guild_id); + if (!ticketConfig.enabled) continue; + + const guild = client.guilds.cache.get(ticket.guild_id); + if (!guild) continue; + + let channel; + try { + channel = await guild.channels.fetch(ticket.thread_id); + } catch { + // Thread/channel was deleted — close the ticket in DB + await pool.query( + `UPDATE tickets SET status = 'closed', close_reason = 'Thread deleted', closed_at = NOW() WHERE id = $1`, + [ticket.id], + ); + continue; + } + + if (!channel) continue; + + // Accept both threads and text channels + const isThread = typeof channel.isThread === 'function' && channel.isThread(); + if (!isThread && channel.type !== ChannelType.GuildText) continue; + + // Get the last user (non-bot) message timestamp. + // Using the last bot message would cause a warning loop: the warning itself + // would reset lastActivity to now, deferring the close indefinitely. + const recentMessages = await channel.messages.fetch({ limit: 10 }); + // Collection.find exists in discord.js but plain Map does not have it; support both + const findFn = + typeof recentMessages.find === 'function' + ? (cb) => recentMessages.find(cb) + : (cb) => Array.from(recentMessages.values()).find(cb); + const lastUserMessage = findFn((m) => !m.author?.bot); + const lastActivity = lastUserMessage + ? lastUserMessage.createdAt + : new Date(ticket.created_at); + + const hoursSinceActivity = (Date.now() - lastActivity.getTime()) / (1000 * 60 * 60); + + const totalCloseThreshold = ticketConfig.autoCloseHours + AUTO_CLOSE_WARNING_HOURS; + + if (hoursSinceActivity >= totalCloseThreshold) { + // Close the ticket + await closeTicket(channel, client.user, 'Auto-closed due to inactivity'); + } else if (hoursSinceActivity >= ticketConfig.autoCloseHours) { + if (!warningsSent.has(ticket.id)) { + await safeSend(channel, { + content: `⚠️ This ticket will be **auto-closed in ${AUTO_CLOSE_WARNING_HOURS} hours** due to inactivity. Send a message to keep it open.`, + }); + warningsSent.add(ticket.id); + info('Auto-close warning sent', { ticketId: ticket.id }); + } + } + } catch (err) { + logError('Auto-close check failed for ticket', { + ticketId: ticket.id, + error: err.message, + }); + } + } +} + +/** + * Build the persistent ticket panel embed with an "Open Ticket" button. + * + * @param {string} [guildId] - Guild ID used to look up the ticket config mode. + * @returns {{ embed: EmbedBuilder, row: ActionRowBuilder }} + */ +export function buildTicketPanel(guildId) { + const config = guildId ? getTicketConfig(guildId) : null; + const mode = config?.mode ?? 'thread'; + const channelDescription = + mode === 'channel' + ? 'A private channel will be created where you can describe your issue ' + : 'A private thread will be created where you can describe your issue '; + + const embed = new EmbedBuilder() + .setColor(TICKET_PANEL_COLOR) + .setTitle('🎫 Support Tickets') + .setDescription( + 'Need help? Click the button below to open a support ticket.\n\n' + + channelDescription + + 'and our support team will assist you.', + ) + .setFooter({ text: 'Volvox Bot • Ticket System' }); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('ticket_open') + .setLabel('Open Ticket') + .setStyle(ButtonStyle.Primary) + .setEmoji('🎫'), + ); + + return { embed, row }; +} diff --git a/tests/api/routes/tickets.test.js b/tests/api/routes/tickets.test.js new file mode 100644 index 000000000..ea1171535 --- /dev/null +++ b/tests/api/routes/tickets.test.js @@ -0,0 +1,285 @@ +/** + * Tests for src/api/routes/tickets.js + * Covers ticket listing, detail, stats, filtering, pagination, and auth. + */ +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + permissions: { botOwners: [] }, + }), + setConfigValue: vi.fn(), +})); + +vi.mock('../../../src/api/middleware/oauthJwt.js', () => ({ + handleOAuthJwt: vi.fn().mockResolvedValue(false), + stopJwtCleanup: vi.fn(), +})); + +import { createApp } from '../../../src/api/server.js'; + +const TEST_SECRET = 'test-tickets-secret'; + +function authed(req) { + return req.set('x-api-secret', TEST_SECRET); +} + +describe('tickets routes', () => { + let app; + let mockPool; + + const mockGuild = { + id: 'guild1', + name: 'Test Server', + iconURL: () => 'https://cdn.example.com/icon.png', + memberCount: 100, + channels: { cache: new Map() }, + roles: { cache: new Map() }, + members: { cache: new Map() }, + }; + + beforeEach(() => { + vi.stubEnv('BOT_API_SECRET', TEST_SECRET); + + mockPool = { + query: vi.fn().mockResolvedValue({ rows: [] }), + connect: vi.fn(), + }; + + const client = { + guilds: { cache: new Map([['guild1', mockGuild]]) }, + ws: { status: 0, ping: 42 }, + user: { tag: 'Bot#1234' }, + }; + + app = createApp(client, mockPool); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + // ─── Auth ────────────────────────────────────────────────────── + + describe('authentication', () => { + it('should return 401 without auth', async () => { + const res = await request(app).get('/api/v1/guilds/guild1/tickets'); + expect(res.status).toBe(401); + }); + + it('should return 404 for unknown guild', async () => { + const res = await authed(request(app).get('/api/v1/guilds/unknown-guild/tickets')); + expect(res.status).toBe(404); + }); + }); + + // ─── GET /:id/tickets ───────────────────────────────────────── + + describe('GET /:id/tickets', () => { + it('should return empty tickets list', async () => { + mockPool.query.mockResolvedValue({ rows: [{ total: 0 }] }); + // Two queries: count + list + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: 0 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/tickets')); + expect(res.status).toBe(200); + expect(res.body.tickets).toEqual([]); + expect(res.body.total).toBe(0); + }); + + it('should return tickets with pagination', async () => { + const ticket = { + id: 1, + guild_id: 'guild1', + user_id: 'user1', + topic: 'Need help', + status: 'open', + thread_id: 'thread1', + channel_id: 'ch1', + closed_by: null, + close_reason: null, + created_at: '2024-01-01T00:00:00Z', + closed_at: null, + }; + + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: 1 }] }) + .mockResolvedValueOnce({ rows: [ticket] }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/tickets?page=1&limit=10')); + expect(res.status).toBe(200); + expect(res.body.tickets).toHaveLength(1); + expect(res.body.tickets[0].topic).toBe('Need help'); + expect(res.body.page).toBe(1); + expect(res.body.limit).toBe(10); + }); + + it('should filter by status', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: 0 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/tickets?status=open')); + expect(res.status).toBe(200); + + // Verify the query includes status filter + const countCall = mockPool.query.mock.calls[0]; + expect(countCall[0]).toContain('status = $'); + expect(countCall[1]).toContain('open'); + }); + + it('should filter by user', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: 0 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/tickets?user=user123')); + expect(res.status).toBe(200); + + const countCall = mockPool.query.mock.calls[0]; + expect(countCall[0]).toContain('user_id = $'); + expect(countCall[1]).toContain('user123'); + }); + + it('should filter by both status and user', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: 0 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await authed( + request(app).get('/api/v1/guilds/guild1/tickets?status=closed&user=user456'), + ); + expect(res.status).toBe(200); + + const countCall = mockPool.query.mock.calls[0]; + expect(countCall[1]).toContain('closed'); + expect(countCall[1]).toContain('user456'); + }); + + it('should clamp limit to max 100', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: 0 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/tickets?limit=999')); + expect(res.status).toBe(200); + expect(res.body.limit).toBe(100); + }); + + it('should default page to 1', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: 0 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/tickets?page=-1')); + expect(res.status).toBe(200); + expect(res.body.page).toBe(1); + }); + + it('should ignore invalid status values', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: 0 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/tickets?status=invalid')); + expect(res.status).toBe(200); + + // Should not include status filter + const countCall = mockPool.query.mock.calls[0]; + expect(countCall[0]).not.toContain('status = $'); + }); + + it('should handle database errors gracefully', async () => { + mockPool.query.mockRejectedValue(new Error('DB connection lost')); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/tickets')); + expect(res.status).toBe(500); + expect(res.body.error).toBe('Failed to fetch tickets'); + }); + }); + + // ─── GET /:id/tickets/:ticketId ─────────────────────────────── + + describe('GET /:id/tickets/:ticketId', () => { + it('should return ticket detail', async () => { + const ticket = { + id: 1, + guild_id: 'guild1', + user_id: 'user1', + topic: 'Bug report', + status: 'closed', + transcript: [{ author: 'Alice', content: 'Hello', timestamp: '2024-01-01T00:00:00Z' }], + }; + + mockPool.query.mockResolvedValueOnce({ rows: [ticket] }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/tickets/1')); + expect(res.status).toBe(200); + expect(res.body.id).toBe(1); + expect(res.body.transcript).toHaveLength(1); + }); + + it('should return 404 for non-existent ticket', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/tickets/999')); + expect(res.status).toBe(404); + expect(res.body.error).toBe('Ticket not found'); + }); + + it('should handle database errors', async () => { + mockPool.query.mockRejectedValue(new Error('Query failed')); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/tickets/1')); + expect(res.status).toBe(500); + }); + }); + + // ─── GET /:id/tickets/stats ──────────────────────────────────── + + describe('GET /:id/tickets/stats', () => { + it('should return ticket stats', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ count: 5 }] }) + .mockResolvedValueOnce({ rows: [{ avg_seconds: 3600 }] }) + .mockResolvedValueOnce({ rows: [{ count: 12 }] }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/tickets/stats')); + expect(res.status).toBe(200); + expect(res.body.openCount).toBe(5); + expect(res.body.avgResolutionSeconds).toBe(3600); + expect(res.body.ticketsThisWeek).toBe(12); + }); + + it('should return zero stats for empty guild', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ count: 0 }] }) + .mockResolvedValueOnce({ rows: [{ avg_seconds: 0 }] }) + .mockResolvedValueOnce({ rows: [{ count: 0 }] }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/tickets/stats')); + expect(res.status).toBe(200); + expect(res.body.openCount).toBe(0); + expect(res.body.avgResolutionSeconds).toBe(0); + expect(res.body.ticketsThisWeek).toBe(0); + }); + + it('should handle database errors', async () => { + mockPool.query.mockRejectedValue(new Error('Stats query failed')); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/tickets/stats')); + expect(res.status).toBe(500); + expect(res.body.error).toBe('Failed to fetch ticket stats'); + }); + }); +}); diff --git a/tests/commands/ticket.test.js b/tests/commands/ticket.test.js new file mode 100644 index 000000000..d6d26fdcb --- /dev/null +++ b/tests/commands/ticket.test.js @@ -0,0 +1,548 @@ +/** + * Tests for src/commands/ticket.js + * Covers /ticket command subcommands: open, close, add, remove, panel + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Mocks ──────────────────────────────────────────────────────── + +const mockOpenTicket = vi.fn(); +const mockCloseTicket = vi.fn(); +const mockAddMember = vi.fn(); +const mockRemoveMember = vi.fn(); +const mockBuildTicketPanel = vi.fn(); +const mockGetTicketConfig = vi.fn(); + +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(), +})); + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + permissions: { enabled: true, adminRoleId: null, usePermissions: true }, + }), +})); + +vi.mock('../../src/modules/ticketHandler.js', () => ({ + openTicket: (...args) => mockOpenTicket(...args), + closeTicket: (...args) => mockCloseTicket(...args), + addMember: (...args) => mockAddMember(...args), + removeMember: (...args) => mockRemoveMember(...args), + buildTicketPanel: (...args) => mockBuildTicketPanel(...args), + getTicketConfig: (...args) => mockGetTicketConfig(...args), +})); + +vi.mock('../../src/utils/permissions.js', () => ({ + isModerator: vi.fn().mockReturnValue(true), +})); + +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: vi.fn().mockResolvedValue(undefined), + safeReply: vi.fn().mockResolvedValue(undefined), + safeEditReply: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('discord.js', () => { + function chainable() { + const proxy = new Proxy(() => proxy, { + get: () => () => proxy, + apply: () => proxy, + }); + return proxy; + } + + class MockSlashCommandBuilder { + constructor() { + this.name = ''; + this.description = ''; + } + setName(name) { + this.name = name; + return this; + } + setDescription(desc) { + this.description = desc; + return this; + } + addSubcommand(fn) { + const sub = { + setName: () => ({ + setDescription: () => ({ + addStringOption: function self(fn2) { + fn2(chainable()); + return { + addStringOption: self, + addUserOption: self, + addChannelOption: self, + }; + }, + addUserOption: function self(fn2) { + fn2(chainable()); + return { + addStringOption: self, + addUserOption: self, + addChannelOption: self, + }; + }, + addChannelOption: function self(fn2) { + fn2(chainable()); + return { + addStringOption: self, + addUserOption: self, + addChannelOption: self, + }; + }, + }), + }), + }; + fn(sub); + return this; + } + toJSON() { + return { name: this.name, description: this.description }; + } + } + + return { + SlashCommandBuilder: MockSlashCommandBuilder, + ChannelType: { + GuildText: 0, + PrivateThread: 12, + }, + PermissionFlagsBits: { + Administrator: 8n, + }, + }; +}); + +import { getConfig } from '../../src/modules/config.js'; +import { isModerator } from '../../src/utils/permissions.js'; +import { safeEditReply, safeSend } from '../../src/utils/safeSend.js'; + +// ── Helpers ────────────────────────────────────────────────────── + +function createMockInteraction(overrides = {}) { + return { + guildId: 'guild1', + channelId: 'channel1', + user: { id: 'user1', tag: 'User#1234' }, + member: { + permissions: { + has: vi.fn().mockReturnValue(false), + }, + }, + options: { + getSubcommand: vi.fn().mockReturnValue('open'), + getString: vi.fn().mockReturnValue(null), + getUser: vi.fn().mockReturnValue(null), + getChannel: vi.fn().mockReturnValue(null), + }, + channel: { + id: 'channel1', + type: 0, // GuildText + isThread: () => false, + }, + guild: { + id: 'guild1', + tag: 'Guild#1234', + }, + deferReply: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function createMockThread(overrides = {}) { + return { + id: 'thread1', + type: 12, // PrivateThread + isThread: () => true, + ...overrides, + }; +} + +// ── Tests ──────────────────────────────────────────────────────── + +describe('/ticket command', () => { + let execute; + const mockQuery = vi.fn(); + const mockPool = { query: mockQuery }; + + beforeEach(async () => { + vi.clearAllMocks(); + mockGetTicketConfig.mockReturnValue({ enabled: true }); + + // Mock pool: thread1 is an open ticket, channel1 is not + const { getPool } = await import('../../src/db.js'); + getPool.mockReturnValue(mockPool); + mockQuery.mockImplementation((sql, params) => { + if (sql.includes('SELECT 1 FROM tickets') && params && params[0] === 'thread1') { + return Promise.resolve({ rows: [{}] }); + } + return Promise.resolve({ rows: [] }); + }); + + // Dynamically import after mocks are set up + const mod = await import('../../src/commands/ticket.js'); + execute = mod.execute; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + // ─── /ticket open ──────────────────────────────────────────── + + describe('/ticket open', () => { + it('should create a ticket successfully', async () => { + const interaction = createMockInteraction(); + interaction.options.getSubcommand.mockReturnValue('open'); + interaction.options.getString.mockReturnValue('Need help with bot'); + + mockOpenTicket.mockResolvedValue({ + ticket: { id: 1 }, + thread: { id: 'thread1' }, + }); + + await execute(interaction); + + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); + expect(mockOpenTicket).toHaveBeenCalledWith( + interaction.guild, + interaction.user, + 'Need help with bot', + 'channel1', + ); + expect(safeEditReply).toHaveBeenCalledWith(interaction, { + content: '✅ Ticket #1 created! Head to <#thread1>.', + }); + }); + + it('should create a ticket without a topic', async () => { + const interaction = createMockInteraction(); + interaction.options.getSubcommand.mockReturnValue('open'); + interaction.options.getString.mockReturnValue(null); + + mockOpenTicket.mockResolvedValue({ + ticket: { id: 2 }, + thread: { id: 'thread2' }, + }); + + await execute(interaction); + + expect(mockOpenTicket).toHaveBeenCalledWith( + interaction.guild, + interaction.user, + null, + 'channel1', + ); + expect(safeEditReply).toHaveBeenCalledWith(interaction, { + content: '✅ Ticket #2 created! Head to <#thread2>.', + }); + }); + + it('should handle ticket creation errors', async () => { + const interaction = createMockInteraction(); + interaction.options.getSubcommand.mockReturnValue('open'); + + mockOpenTicket.mockRejectedValue(new Error('Max open tickets reached')); + + await execute(interaction); + + expect(safeEditReply).toHaveBeenCalledWith(interaction, { + content: '❌ Max open tickets reached', + }); + }); + + it('should return error when ticket system is disabled', async () => { + const interaction = createMockInteraction(); + mockGetTicketConfig.mockReturnValue({ enabled: false }); + + await execute(interaction); + + expect(safeEditReply).toHaveBeenCalledWith(interaction, { + content: '❌ The ticket system is not enabled on this server.', + }); + expect(mockOpenTicket).not.toHaveBeenCalled(); + }); + }); + + // ─── /ticket close ─────────────────────────────────────────── + + describe('/ticket close', () => { + it('should close a ticket successfully', async () => { + const thread = createMockThread(); + const interaction = createMockInteraction({ channel: thread }); + interaction.options.getSubcommand.mockReturnValue('close'); + interaction.options.getString.mockReturnValue('Issue resolved'); + + mockCloseTicket.mockResolvedValue({ id: 1 }); + + await execute(interaction); + + expect(mockCloseTicket).toHaveBeenCalledWith(thread, interaction.user, 'Issue resolved'); + expect(safeEditReply).toHaveBeenCalledWith(interaction, { + content: '✅ Ticket #1 has been closed.', + }); + }); + + it('should close a ticket without a reason', async () => { + const thread = createMockThread(); + const interaction = createMockInteraction({ channel: thread }); + interaction.options.getSubcommand.mockReturnValue('close'); + interaction.options.getString.mockReturnValue(null); + + mockCloseTicket.mockResolvedValue({ id: 3 }); + + await execute(interaction); + + expect(mockCloseTicket).toHaveBeenCalledWith(thread, interaction.user, null); + expect(safeEditReply).toHaveBeenCalledWith(interaction, { + content: '✅ Ticket #3 has been closed.', + }); + }); + + it('should reject close outside a ticket context', async () => { + const interaction = createMockInteraction(); + interaction.options.getSubcommand.mockReturnValue('close'); + + await execute(interaction); + + expect(mockCloseTicket).not.toHaveBeenCalled(); + expect(safeEditReply).toHaveBeenCalledWith(interaction, { + content: '❌ This command must be used inside a ticket thread or channel.', + }); + }); + + it('should handle close errors', async () => { + const thread = createMockThread(); + const interaction = createMockInteraction({ channel: thread }); + interaction.options.getSubcommand.mockReturnValue('close'); + + mockCloseTicket.mockRejectedValue(new Error('No open ticket found')); + + await execute(interaction); + + expect(safeEditReply).toHaveBeenCalledWith(interaction, { + content: '❌ No open ticket found', + }); + }); + }); + + // ─── /ticket add ───────────────────────────────────────────── + + describe('/ticket add', () => { + it('should add a user to the ticket', async () => { + const thread = createMockThread(); + const interaction = createMockInteraction({ channel: thread }); + const targetUser = { id: 'user2', tag: 'Helper#5678' }; + + interaction.options.getSubcommand.mockReturnValue('add'); + interaction.options.getUser.mockReturnValue(targetUser); + + mockAddMember.mockResolvedValue(undefined); + + await execute(interaction); + + expect(mockAddMember).toHaveBeenCalledWith(thread, targetUser); + expect(safeEditReply).toHaveBeenCalledWith(interaction, { + content: '✅ <@user2> has been added to the ticket.', + }); + }); + + it('should reject add outside a ticket context', async () => { + const interaction = createMockInteraction(); + const targetUser = { id: 'user2' }; + + interaction.options.getSubcommand.mockReturnValue('add'); + interaction.options.getUser.mockReturnValue(targetUser); + + await execute(interaction); + + expect(mockAddMember).not.toHaveBeenCalled(); + expect(safeEditReply).toHaveBeenCalledWith(interaction, { + content: '❌ This command must be used inside a ticket thread or channel.', + }); + }); + + it('should handle add errors', async () => { + const thread = createMockThread(); + const interaction = createMockInteraction({ channel: thread }); + const targetUser = { id: 'user2' }; + + interaction.options.getSubcommand.mockReturnValue('add'); + interaction.options.getUser.mockReturnValue(targetUser); + + mockAddMember.mockRejectedValue(new Error('Missing permissions')); + + await execute(interaction); + + expect(safeEditReply).toHaveBeenCalledWith(interaction, { + content: '❌ Failed to add user: Missing permissions', + }); + }); + }); + + // ─── /ticket remove ────────────────────────────────────────── + + describe('/ticket remove', () => { + it('should remove a user from the ticket', async () => { + const thread = createMockThread(); + const interaction = createMockInteraction({ channel: thread }); + const targetUser = { id: 'user2', tag: 'Helper#5678' }; + + interaction.options.getSubcommand.mockReturnValue('remove'); + interaction.options.getUser.mockReturnValue(targetUser); + + mockRemoveMember.mockResolvedValue(undefined); + + await execute(interaction); + + expect(mockRemoveMember).toHaveBeenCalledWith(thread, targetUser); + expect(safeEditReply).toHaveBeenCalledWith(interaction, { + content: '✅ <@user2> has been removed from the ticket.', + }); + }); + + it('should reject remove outside a ticket context', async () => { + const interaction = createMockInteraction(); + const targetUser = { id: 'user2' }; + + interaction.options.getSubcommand.mockReturnValue('remove'); + interaction.options.getUser.mockReturnValue(targetUser); + + await execute(interaction); + + expect(mockRemoveMember).not.toHaveBeenCalled(); + expect(safeEditReply).toHaveBeenCalledWith(interaction, { + content: '❌ This command must be used inside a ticket thread or channel.', + }); + }); + + it('should handle remove errors', async () => { + const thread = createMockThread(); + const interaction = createMockInteraction({ channel: thread }); + const targetUser = { id: 'user2' }; + + interaction.options.getSubcommand.mockReturnValue('remove'); + interaction.options.getUser.mockReturnValue(targetUser); + + mockRemoveMember.mockRejectedValue(new Error('User not in ticket')); + + await execute(interaction); + + expect(safeEditReply).toHaveBeenCalledWith(interaction, { + content: '❌ Failed to remove user: User not in ticket', + }); + }); + }); + + // ─── /ticket panel ─────────────────────────────────────────── + + describe('/ticket panel', () => { + it('should post a ticket panel with admin permissions', async () => { + const interaction = createMockInteraction(); + interaction.options.getSubcommand.mockReturnValue('panel'); + interaction.member.permissions.has.mockReturnValue(true); // Admin + + const mockEmbed = { data: { title: '🎫 Support Tickets' } }; + const mockRow = { components: [{ data: { custom_id: 'ticket_open' } }] }; + mockBuildTicketPanel.mockReturnValue({ embed: mockEmbed, row: mockRow }); + + await execute(interaction); + + expect(mockBuildTicketPanel).toHaveBeenCalled(); + expect(safeSend).toHaveBeenCalledWith(interaction.channel, { + embeds: [mockEmbed], + components: [mockRow], + }); + expect(safeEditReply).toHaveBeenCalledWith(interaction, { + content: '✅ Ticket panel posted in <#channel1>.', + }); + }); + + it('should post panel to specified channel', async () => { + const targetChannel = { id: 'channel2', tag: 'Channel2' }; + const interaction = createMockInteraction(); + interaction.options.getSubcommand.mockReturnValue('panel'); + interaction.options.getChannel.mockReturnValue(targetChannel); + interaction.member.permissions.has.mockReturnValue(true); + + const mockEmbed = { data: {} }; + const mockRow = { components: [] }; + mockBuildTicketPanel.mockReturnValue({ embed: mockEmbed, row: mockRow }); + + await execute(interaction); + + expect(safeSend).toHaveBeenCalledWith(targetChannel, { + embeds: [mockEmbed], + components: [mockRow], + }); + expect(safeEditReply).toHaveBeenCalledWith(interaction, { + content: '✅ Ticket panel posted in <#channel2>.', + }); + }); + + it('should reject panel without admin permissions', async () => { + const interaction = createMockInteraction(); + interaction.options.getSubcommand.mockReturnValue('panel'); + interaction.member.permissions.has.mockReturnValue(false); + isModerator.mockReturnValue(false); + + await execute(interaction); + + expect(mockBuildTicketPanel).not.toHaveBeenCalled(); + expect(safeEditReply).toHaveBeenCalledWith(interaction, { + content: '❌ You need moderator or administrator permissions to use this command.', + }); + }); + + it('should allow panel with moderator role', async () => { + const interaction = createMockInteraction(); + interaction.options.getSubcommand.mockReturnValue('panel'); + interaction.member.permissions.has.mockReturnValue(false); + isModerator.mockReturnValue(true); + + const mockEmbed = { data: {} }; + const mockRow = { components: [] }; + mockBuildTicketPanel.mockReturnValue({ embed: mockEmbed, row: mockRow }); + + await execute(interaction); + + expect(mockBuildTicketPanel).toHaveBeenCalled(); + expect(safeSend).toHaveBeenCalled(); + }); + + it('should handle panel posting errors', async () => { + const interaction = createMockInteraction(); + interaction.options.getSubcommand.mockReturnValue('panel'); + interaction.member.permissions.has.mockReturnValue(true); + + mockBuildTicketPanel.mockReturnValue({ embed: {}, row: {} }); + safeSend.mockRejectedValue(new Error('Missing Permissions')); + + await execute(interaction); + + expect(safeEditReply).toHaveBeenCalledWith(interaction, { + content: '❌ Failed to post panel: Missing Permissions', + }); + }); + }); + + // ─── Command definition ────────────────────────────────────── + + describe('command definition', () => { + it('should have correct command structure', async () => { + const mod = await import('../../src/commands/ticket.js'); + const { data } = mod; + + expect(data.name).toBe('ticket'); + expect(data.description).toContain('ticket'); + }); + }); +}); diff --git a/tests/modules/events.tickets.test.js b/tests/modules/events.tickets.test.js new file mode 100644 index 000000000..6391470b4 --- /dev/null +++ b/tests/modules/events.tickets.test.js @@ -0,0 +1,292 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn(), +})); + +vi.mock('../../src/modules/afkHandler.js', () => ({ + handleAfkMentions: vi.fn(), +})); + +vi.mock('../../src/modules/challengeScheduler.js', () => ({ + handleSolveButton: vi.fn(), + handleHintButton: vi.fn(), +})); + +vi.mock('../../src/modules/engagement.js', () => ({ + trackMessage: vi.fn(), + trackReaction: vi.fn(), +})); + +vi.mock('../../src/modules/linkFilter.js', () => ({ + checkLinks: vi.fn(), +})); + +vi.mock('../../src/modules/pollHandler.js', () => ({ + handlePollVote: vi.fn(), +})); + +vi.mock('../../src/modules/rateLimit.js', () => ({ + checkRateLimit: vi.fn(), +})); + +vi.mock('../../src/modules/reputation.js', () => ({ + handleXpGain: vi.fn(), +})); + +vi.mock('../../src/modules/reviewHandler.js', () => ({ + handleReviewClaim: vi.fn(), +})); + +vi.mock('../../src/modules/spam.js', () => ({ + isSpam: vi.fn(), + sendSpamAlert: vi.fn(), +})); + +vi.mock('../../src/modules/starboard.js', () => ({ + handleReactionAdd: vi.fn(), + handleReactionRemove: vi.fn(), +})); + +vi.mock('../../src/modules/triage.js', () => ({ + accumulateMessage: vi.fn(), + evaluateNow: vi.fn(), +})); + +vi.mock('../../src/modules/welcome.js', () => ({ + recordCommunityActivity: vi.fn(), + sendWelcomeMessage: vi.fn(), +})); + +vi.mock('../../src/commands/showcase.js', () => ({ + handleShowcaseModalSubmit: vi.fn(), + handleShowcaseUpvote: vi.fn(), +})); + +vi.mock('../../src/utils/errors.js', () => ({ + getUserFriendlyMessage: vi.fn(() => 'friendly'), +})); + +const safeReplyMock = vi.fn(); +const safeEditReplyMock = vi.fn(); +vi.mock('../../src/utils/safeSend.js', () => ({ + safeReply: (...args) => safeReplyMock(...args), + safeEditReply: (...args) => safeEditReplyMock(...args), +})); + +const getTicketConfigMock = vi.fn(); +const openTicketMock = vi.fn(); +const closeTicketMock = vi.fn(); +vi.mock('../../src/modules/ticketHandler.js', () => ({ + getTicketConfig: (...args) => getTicketConfigMock(...args), + openTicket: (...args) => openTicketMock(...args), + closeTicket: (...args) => closeTicketMock(...args), +})); + +import { ChannelType, Events } from 'discord.js'; +import { + registerTicketCloseButtonHandler, + registerTicketModalHandler, + registerTicketOpenButtonHandler, +} from '../../src/modules/events.js'; + +function setupClientAndHandler(registerFn) { + const handlers = new Map(); + const client = { + on: vi.fn((event, cb) => handlers.set(event, cb)), + }; + + registerFn(client); + return handlers.get(Events.InteractionCreate); +} + +describe('events ticket handlers', () => { + beforeEach(() => { + vi.clearAllMocks(); + getTicketConfigMock.mockReturnValue({ enabled: true }); + }); + + describe('registerTicketOpenButtonHandler', () => { + it('shows a modal for ticket_open button when enabled', async () => { + const handler = setupClientAndHandler(registerTicketOpenButtonHandler); + + const interaction = { + isButton: () => true, + customId: 'ticket_open', + guildId: 'guild1', + user: { id: 'user1' }, + showModal: vi.fn().mockResolvedValue(undefined), + }; + + await handler(interaction); + + expect(interaction.showModal).toHaveBeenCalledTimes(1); + const modal = interaction.showModal.mock.calls[0][0]; + expect(modal.data.custom_id).toBe('ticket_open_modal'); + }); + + it('replies with disabled message when tickets are off', async () => { + const handler = setupClientAndHandler(registerTicketOpenButtonHandler); + getTicketConfigMock.mockReturnValue({ enabled: false }); + + const interaction = { + isButton: () => true, + customId: 'ticket_open', + guildId: 'guild1', + }; + + await handler(interaction); + + expect(safeReplyMock).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ + content: expect.stringContaining('not enabled'), + ephemeral: true, + }), + ); + }); + }); + + describe('registerTicketModalHandler', () => { + it('opens ticket and sends success message', async () => { + const handler = setupClientAndHandler(registerTicketModalHandler); + + openTicketMock.mockResolvedValue({ + ticket: { id: 42 }, + thread: { id: 'thread-42' }, + }); + + const interaction = { + isModalSubmit: () => true, + customId: 'ticket_open_modal', + deferReply: vi.fn().mockResolvedValue(undefined), + fields: { getTextInputValue: vi.fn().mockReturnValue('Need help') }, + guild: { id: 'guild1' }, + user: { id: 'user1' }, + channelId: 'channel1', + }; + + await handler(interaction); + + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); + expect(openTicketMock).toHaveBeenCalledWith( + interaction.guild, + interaction.user, + 'Need help', + 'channel1', + ); + expect(safeEditReplyMock).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('Ticket #42 created') }), + ); + }); + + it('sends error message when openTicket fails', async () => { + const handler = setupClientAndHandler(registerTicketModalHandler); + openTicketMock.mockRejectedValue(new Error('No suitable channel found')); + + const interaction = { + isModalSubmit: () => true, + customId: 'ticket_open_modal', + deferReply: vi.fn().mockResolvedValue(undefined), + fields: { getTextInputValue: vi.fn().mockReturnValue('') }, + guild: { id: 'guild1' }, + user: { id: 'user1' }, + channelId: 'channel1', + }; + + await handler(interaction); + + expect(safeEditReplyMock).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('No suitable channel found') }), + ); + }); + }); + + describe('registerTicketCloseButtonHandler', () => { + it('rejects non-ticket channels', async () => { + const handler = setupClientAndHandler(registerTicketCloseButtonHandler); + + const interaction = { + isButton: () => true, + customId: 'ticket_close_1', + deferReply: vi.fn().mockResolvedValue(undefined), + channel: { + isThread: () => false, + type: ChannelType.GuildVoice, + }, + }; + + await handler(interaction); + + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); + expect(safeEditReplyMock).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ + content: expect.stringContaining('only be used inside a ticket channel or thread'), + }), + ); + expect(closeTicketMock).not.toHaveBeenCalled(); + }); + + it('closes ticket and sends success reply', async () => { + const handler = setupClientAndHandler(registerTicketCloseButtonHandler); + closeTicketMock.mockResolvedValue({ id: 7 }); + + const interaction = { + isButton: () => true, + customId: 'ticket_close_7', + deferReply: vi.fn().mockResolvedValue(undefined), + channel: { + id: 'thread7', + isThread: () => true, + }, + user: { id: 'closer1' }, + }; + + await handler(interaction); + + expect(closeTicketMock).toHaveBeenCalledWith( + interaction.channel, + interaction.user, + 'Closed via button', + ); + expect(safeEditReplyMock).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: '✅ Ticket #7 has been closed.' }), + ); + }); + + it('sends error when closeTicket fails', async () => { + const handler = setupClientAndHandler(registerTicketCloseButtonHandler); + closeTicketMock.mockRejectedValue(new Error('No open ticket found for this thread.')); + + const interaction = { + isButton: () => true, + customId: 'ticket_close_9', + deferReply: vi.fn().mockResolvedValue(undefined), + channel: { + id: 'thread9', + isThread: () => true, + }, + user: { id: 'closer1' }, + }; + + await handler(interaction); + + expect(safeEditReplyMock).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ + content: expect.stringContaining('No open ticket found for this thread.'), + }), + ); + }); + }); +}); diff --git a/tests/modules/ticketHandler.test.js b/tests/modules/ticketHandler.test.js new file mode 100644 index 000000000..0d348eb56 --- /dev/null +++ b/tests/modules/ticketHandler.test.js @@ -0,0 +1,1243 @@ +/** + * Tests for src/modules/ticketHandler.js + * Covers openTicket, closeTicket, addMember, removeMember, + * checkAutoClose, buildTicketPanel, getTicketConfig. + * Tests both thread mode (default) and channel mode. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Mocks ──────────────────────────────────────────────────────── + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +const mockQuery = vi.fn(); +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(() => ({ query: mockQuery })), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + tickets: { + enabled: true, + mode: 'thread', + supportRole: 'role1', + category: null, + autoCloseHours: 48, + transcriptChannel: 'transcript-ch', + maxOpenPerUser: 3, + }, + }), +})); + +const mockSafeSend = vi.fn().mockResolvedValue(undefined); +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: (...args) => mockSafeSend(...args), + safeReply: vi.fn().mockResolvedValue(undefined), + safeEditReply: vi.fn().mockResolvedValue(undefined), +})); + +import { getConfig } from '../../src/modules/config.js'; +import { + addMember, + buildTicketPanel, + checkAutoClose, + closeTicket, + getTicketConfig, + openTicket, + removeMember, +} from '../../src/modules/ticketHandler.js'; + +// ── Helpers ────────────────────────────────────────────────────── + +function createMockThread(overrides = {}) { + return { + id: 'thread1', + isThread: () => true, + type: 12, // PrivateThread + guild: { + id: 'guild1', + channels: { cache: new Map() }, + }, + members: { + add: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + }, + messages: { + fetch: vi.fn().mockResolvedValue(new Map()), + }, + setArchived: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function createMockChannel(overrides = {}) { + return { + id: 'channel1', + isThread: () => false, + type: 0, // GuildText + guild: { + id: 'guild1', + channels: { cache: new Map() }, + }, + permissionOverwrites: { + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }, + messages: { + fetch: vi.fn().mockResolvedValue(new Map()), + }, + delete: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function createMockGuild(overrides = {}) { + const botMember = { + id: 'bot1', + permissions: { has: () => true }, + }; + const textChannel = { + id: 'ch1', + type: 0, // GuildText + permissionsFor: () => ({ has: () => true }), + threads: { + create: vi.fn().mockResolvedValue(createMockThread()), + }, + }; + const role = { + members: new Map([['member1', { id: 'member1' }]]), + }; + + return { + id: 'guild1', + channels: { + cache: new Map([['ch1', textChannel]]), + fetch: vi.fn(), + create: vi.fn().mockResolvedValue(createMockChannel()), + }, + roles: { cache: new Map([['role1', role]]) }, + members: { me: botMember }, + ...overrides, + }; +} + +function createMockUser(overrides = {}) { + return { + id: 'user1', + tag: 'User#1234', + username: 'testuser', + ...overrides, + }; +} + +// ── Tests ──────────────────────────────────────────────────────── + +describe('ticketHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockQuery.mockResolvedValue({ rows: [] }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ─── getTicketConfig ───────────────────────────────────────── + + describe('getTicketConfig', () => { + it('should merge defaults with guild config', () => { + const config = getTicketConfig('guild1'); + expect(config.enabled).toBe(true); + expect(config.mode).toBe('thread'); + expect(config.supportRole).toBe('role1'); + expect(config.autoCloseHours).toBe(48); + expect(config.maxOpenPerUser).toBe(3); + }); + + it('should use defaults when no tickets config', () => { + getConfig.mockReturnValueOnce({}); + const config = getTicketConfig('guild2'); + expect(config.enabled).toBe(false); + expect(config.mode).toBe('thread'); + expect(config.supportRole).toBeNull(); + expect(config.autoCloseHours).toBe(48); + expect(config.maxOpenPerUser).toBe(3); + }); + }); + + // ─── buildTicketPanel ───────────────────────────────────────── + + describe('buildTicketPanel', () => { + it('should return embed and button row', () => { + const { embed, row } = buildTicketPanel(); + expect(embed).toBeDefined(); + expect(embed.data.title).toBe('🎫 Support Tickets'); + expect(row).toBeDefined(); + expect(row.components).toHaveLength(1); + expect(row.components[0].data.custom_id).toBe('ticket_open'); + }); + + it('should use channel-mode copy when guild ticket mode is channel', () => { + getConfig.mockReturnValueOnce({ + tickets: { + enabled: true, + mode: 'channel', + }, + }); + + const { embed } = buildTicketPanel('guild1'); + expect(embed.data.description).toContain('A private channel will be created'); + }); + }); + + // ─── openTicket (thread mode) ───────────────────────────────── + + describe('openTicket (thread mode)', () => { + it('should create a ticket successfully', async () => { + const guild = createMockGuild(); + const user = createMockUser(); + + // Count query: 0 open tickets + mockQuery.mockResolvedValueOnce({ rows: [{ count: 0 }] }); + // Insert query + mockQuery.mockResolvedValueOnce({ + rows: [ + { id: 1, guild_id: 'guild1', user_id: 'user1', topic: 'test', thread_id: 'thread1' }, + ], + }); + + const result = await openTicket(guild, user, 'test', 'ch1'); + expect(result.ticket.id).toBe(1); + expect(result.thread).toBeDefined(); + }); + + it('should throw when user has max open tickets', async () => { + const guild = createMockGuild(); + const user = createMockUser(); + + mockQuery.mockResolvedValueOnce({ rows: [{ count: 3 }] }); + + await expect(openTicket(guild, user, 'test')).rejects.toThrow( + 'You already have 3 open tickets', + ); + }); + + it('should throw when database is not available', async () => { + const { getPool: getPoolFn } = await import('../../src/db.js'); + getPoolFn.mockReturnValueOnce(null); + + const guild = createMockGuild(); + const user = createMockUser(); + + await expect(openTicket(guild, user, 'test')).rejects.toThrow('Database not available'); + }); + + it('should add support role members to thread', async () => { + const guild = createMockGuild(); + const user = createMockUser(); + const thread = createMockThread(); + guild.channels.cache.get('ch1').threads.create.mockResolvedValue(thread); + + mockQuery.mockResolvedValueOnce({ rows: [{ count: 0 }] }); + mockQuery.mockResolvedValueOnce({ + rows: [ + { id: 1, guild_id: 'guild1', user_id: 'user1', topic: 'test', thread_id: 'thread1' }, + ], + }); + + await openTicket(guild, user, 'test', 'ch1'); + + // Should add the user + support role member + expect(thread.members.add).toHaveBeenCalledWith('user1'); + expect(thread.members.add).toHaveBeenCalledWith('member1'); + }); + + it('should work without topic', async () => { + const guild = createMockGuild(); + const user = createMockUser(); + + mockQuery.mockResolvedValueOnce({ rows: [{ count: 0 }] }); + mockQuery.mockResolvedValueOnce({ + rows: [{ id: 2, guild_id: 'guild1', user_id: 'user1', topic: null, thread_id: 'thread1' }], + }); + + const result = await openTicket(guild, user, null, 'ch1'); + expect(result.ticket.id).toBe(2); + }); + }); + + // ─── openTicket (channel mode) ──────────────────────────────── + + describe('openTicket (channel mode)', () => { + beforeEach(() => { + getConfig.mockReturnValue({ + tickets: { + enabled: true, + mode: 'channel', + supportRole: 'role1', + category: 'cat1', + autoCloseHours: 48, + transcriptChannel: null, + maxOpenPerUser: 3, + }, + }); + }); + + it('should create a text channel with permission overrides', async () => { + const categoryChannel = { id: 'cat1', type: 4 }; + const createdChannel = createMockChannel({ id: 'ticket-ch' }); + + const guild = createMockGuild(); + guild.channels.cache.set('cat1', categoryChannel); + guild.channels.create.mockResolvedValue(createdChannel); + + const user = createMockUser(); + + mockQuery.mockResolvedValueOnce({ rows: [{ count: 0 }] }); + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 10, + guild_id: 'guild1', + user_id: 'user1', + topic: 'help', + thread_id: 'ticket-ch', + }, + ], + }); + + const result = await openTicket(guild, user, 'help', 'ch1'); + + expect(result.ticket.id).toBe(10); + expect(result.thread.id).toBe('ticket-ch'); + + // Verify guild.channels.create was called with correct params + expect(guild.channels.create).toHaveBeenCalledTimes(1); + const createCall = guild.channels.create.mock.calls[0][0]; + expect(createCall.name).toBe('ticket-testuser-help'); + expect(createCall.type).toBe(0); // GuildText + expect(createCall.parent).toBe('cat1'); + expect(createCall.reason).toContain('User#1234'); + + // Verify permission overrides include @everyone deny, user allow, bot allow, role allow + const overwrites = createCall.permissionOverwrites; + expect(overwrites).toHaveLength(4); // everyone + user + bot + support role + + // @everyone deny ViewChannel + const everyoneOverwrite = overwrites.find((o) => o.id === 'guild1'); + expect(everyoneOverwrite).toBeDefined(); + + // User allow + const userOverwrite = overwrites.find((o) => o.id === 'user1'); + expect(userOverwrite).toBeDefined(); + + // Bot allow + const botOverwrite = overwrites.find((o) => o.id === 'bot1'); + expect(botOverwrite).toBeDefined(); + + // Support role allow + const roleOverwrite = overwrites.find((o) => o.id === 'role1'); + expect(roleOverwrite).toBeDefined(); + }); + + it('should not call thread.members.add in channel mode', async () => { + const createdChannel = createMockChannel({ id: 'ticket-ch' }); + const guild = createMockGuild(); + guild.channels.create.mockResolvedValue(createdChannel); + + const user = createMockUser(); + + mockQuery.mockResolvedValueOnce({ rows: [{ count: 0 }] }); + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 11, + guild_id: 'guild1', + user_id: 'user1', + topic: null, + thread_id: 'ticket-ch', + }, + ], + }); + + await openTicket(guild, user, null, 'ch1'); + + // Thread mode would call thread.members.add, channel mode should not + const threadInCache = guild.channels.cache.get('ch1'); + expect(threadInCache.threads.create).not.toHaveBeenCalled(); + }); + + it('should work without a category configured', async () => { + getConfig.mockReturnValue({ + tickets: { + enabled: true, + mode: 'channel', + supportRole: null, + category: null, + autoCloseHours: 48, + transcriptChannel: null, + maxOpenPerUser: 3, + }, + }); + + const createdChannel = createMockChannel({ id: 'ticket-ch2' }); + const guild = createMockGuild(); + guild.channels.create.mockResolvedValue(createdChannel); + + const user = createMockUser(); + + mockQuery.mockResolvedValueOnce({ rows: [{ count: 0 }] }); + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 12, + guild_id: 'guild1', + user_id: 'user1', + topic: null, + thread_id: 'ticket-ch2', + }, + ], + }); + + const result = await openTicket(guild, user, null, 'ch1'); + expect(result.ticket.id).toBe(12); + + // parent should be undefined when no category + const createCall = guild.channels.create.mock.calls[0][0]; + expect(createCall.parent).toBeUndefined(); + + // No support role → only 3 overwrites (everyone + user + bot) + expect(createCall.permissionOverwrites).toHaveLength(3); + }); + }); + + // ─── closeTicket (thread mode) ──────────────────────────────── + + describe('closeTicket (thread mode)', () => { + beforeEach(() => { + getConfig.mockReturnValue({ + tickets: { + enabled: true, + mode: 'thread', + supportRole: 'role1', + category: null, + autoCloseHours: 48, + transcriptChannel: 'transcript-ch', + maxOpenPerUser: 3, + }, + }); + }); + + it('should close a ticket and save transcript', async () => { + const closer = createMockUser({ id: 'closer1', tag: 'Closer#1234' }); + const messages = new Map([ + [ + 'msg1', + { + author: { tag: 'User#1234', id: 'user1' }, + content: 'Hello', + createdAt: new Date('2024-01-01'), + }, + ], + [ + 'msg2', + { + author: { tag: 'Staff#5678', id: 'staff1' }, + content: 'How can I help?', + createdAt: new Date('2024-01-01T00:01:00'), + }, + ], + ]); + + const transcriptChannel = { + id: 'transcript-ch', + }; + + const thread = createMockThread({ + guild: { + id: 'guild1', + channels: { cache: new Map([['transcript-ch', transcriptChannel]]) }, + }, + }); + thread.messages.fetch.mockResolvedValue(messages); + + // SELECT ticket + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 1, + guild_id: 'guild1', + user_id: 'user1', + topic: 'test', + thread_id: 'thread1', + }, + ], + }); + // UPDATE ticket + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 1, + status: 'closed', + closed_by: 'closer1', + close_reason: 'Resolved', + }, + ], + }); + + const result = await closeTicket(thread, closer, 'Resolved'); + expect(result.status).toBe('closed'); + expect(result.closed_by).toBe('closer1'); + + // Verify transcript was saved + const updateCall = mockQuery.mock.calls[1]; + expect(updateCall[0]).toContain('UPDATE tickets'); + const transcript = JSON.parse(updateCall[1][2]); + expect(transcript).toHaveLength(2); + }); + + it('should throw when no open ticket found', async () => { + const thread = createMockThread(); + const closer = createMockUser(); + + mockQuery.mockResolvedValueOnce({ rows: [] }); + + await expect(closeTicket(thread, closer, 'Done')).rejects.toThrow('No open ticket found'); + }); + + it('should archive the thread after closing', async () => { + const thread = createMockThread({ + guild: { + id: 'guild1', + channels: { cache: new Map() }, + }, + }); + const closer = createMockUser(); + + mockQuery.mockResolvedValueOnce({ + rows: [{ id: 1, guild_id: 'guild1', user_id: 'user1', thread_id: 'thread1' }], + }); + mockQuery.mockResolvedValueOnce({ + rows: [{ id: 1, status: 'closed' }], + }); + + await closeTicket(thread, closer, null); + expect(thread.setArchived).toHaveBeenCalledWith(true); + }); + + it('should handle thread archive failure gracefully', async () => { + const thread = createMockThread({ + guild: { id: 'guild1', channels: { cache: new Map() } }, + }); + thread.setArchived.mockRejectedValue(new Error('Cannot archive')); + const closer = createMockUser(); + + mockQuery.mockResolvedValueOnce({ + rows: [{ id: 1, guild_id: 'guild1', user_id: 'user1', thread_id: 'thread1' }], + }); + mockQuery.mockResolvedValueOnce({ + rows: [{ id: 1, status: 'closed' }], + }); + + // Should not throw + const result = await closeTicket(thread, closer, null); + expect(result.status).toBe('closed'); + }); + }); + + // ─── closeTicket (channel mode) ─────────────────────────────── + + describe('closeTicket (channel mode)', () => { + beforeEach(() => { + vi.useFakeTimers(); + getConfig.mockReturnValue({ + tickets: { + enabled: true, + mode: 'channel', + supportRole: null, + category: null, + autoCloseHours: 48, + transcriptChannel: null, + maxOpenPerUser: 3, + }, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should delete the channel after a delay instead of archiving', async () => { + const channel = createMockChannel({ + guild: { id: 'guild1', channels: { cache: new Map() } }, + }); + const closer = createMockUser(); + + mockQuery.mockResolvedValueOnce({ + rows: [{ id: 5, guild_id: 'guild1', user_id: 'user1', thread_id: 'channel1' }], + }); + mockQuery.mockResolvedValueOnce({ + rows: [{ id: 5, status: 'closed' }], + }); + + const result = await closeTicket(channel, closer, 'Done'); + expect(result.status).toBe('closed'); + + // Channel should NOT be deleted immediately + expect(channel.delete).not.toHaveBeenCalled(); + + // Advance timers past the 10s delay + await vi.advanceTimersByTimeAsync(11_000); + + expect(channel.delete).toHaveBeenCalledWith('Ticket #5 closed'); + }); + + it('should handle channel delete failure gracefully', async () => { + const channel = createMockChannel({ + guild: { id: 'guild1', channels: { cache: new Map() } }, + }); + channel.delete.mockRejectedValue(new Error('Missing Permissions')); + const closer = createMockUser(); + + mockQuery.mockResolvedValueOnce({ + rows: [{ id: 6, guild_id: 'guild1', user_id: 'user1', thread_id: 'channel1' }], + }); + mockQuery.mockResolvedValueOnce({ + rows: [{ id: 6, status: 'closed' }], + }); + + const result = await closeTicket(channel, closer, null); + expect(result.status).toBe('closed'); + + // Should not throw when delete fails + await vi.advanceTimersByTimeAsync(11_000); + expect(channel.delete).toHaveBeenCalled(); + }); + + it('should not call setArchived for channel mode', async () => { + const channel = createMockChannel({ + guild: { id: 'guild1', channels: { cache: new Map() } }, + }); + // Add setArchived to ensure it's not called + channel.setArchived = vi.fn(); + const closer = createMockUser(); + + mockQuery.mockResolvedValueOnce({ + rows: [{ id: 7, guild_id: 'guild1', user_id: 'user1', thread_id: 'channel1' }], + }); + mockQuery.mockResolvedValueOnce({ + rows: [{ id: 7, status: 'closed' }], + }); + + await closeTicket(channel, closer, null); + expect(channel.setArchived).not.toHaveBeenCalled(); + }); + }); + + // ─── addMember (thread mode) ────────────────────────────────── + + describe('addMember (thread mode)', () => { + it('should add a user to the thread', async () => { + const thread = createMockThread(); + const user = createMockUser({ id: 'newuser' }); + + await addMember(thread, user); + expect(thread.members.add).toHaveBeenCalledWith('newuser'); + expect(mockSafeSend).toHaveBeenCalled(); + }); + }); + + // ─── addMember (channel mode) ───────────────────────────────── + + describe('addMember (channel mode)', () => { + it('should update permission overrides to grant access', async () => { + const channel = createMockChannel(); + const user = createMockUser({ id: 'newuser' }); + + await addMember(channel, user); + + expect(channel.permissionOverwrites.edit).toHaveBeenCalledWith('newuser', { + ViewChannel: true, + SendMessages: true, + }); + expect(mockSafeSend).toHaveBeenCalled(); + }); + }); + + // ─── removeMember (thread mode) ─────────────────────────────── + + describe('removeMember (thread mode)', () => { + it('should remove a user from the thread', async () => { + const thread = createMockThread(); + const user = createMockUser({ id: 'olduser' }); + + await removeMember(thread, user); + expect(thread.members.remove).toHaveBeenCalledWith('olduser'); + expect(mockSafeSend).toHaveBeenCalled(); + }); + }); + + // ─── removeMember (channel mode) ────────────────────────────── + + describe('removeMember (channel mode)', () => { + it('should delete permission override to revoke access', async () => { + const channel = createMockChannel(); + const user = createMockUser({ id: 'olduser' }); + + await removeMember(channel, user); + + expect(channel.permissionOverwrites.delete).toHaveBeenCalledWith('olduser'); + expect(mockSafeSend).toHaveBeenCalled(); + }); + }); + + // ─── checkAutoClose ─────────────────────────────────────────── + + describe('checkAutoClose', () => { + it('should skip tickets in guilds where tickets are disabled', async () => { + getConfig.mockReturnValue({ tickets: { enabled: false } }); + + mockQuery.mockResolvedValueOnce({ + rows: [ + { id: 1, guild_id: 'guild1', thread_id: 'thread1', created_at: new Date().toISOString() }, + ], + }); + + const client = { + guilds: { cache: new Map([['guild1', createMockGuild()]]) }, + user: { id: 'bot1' }, + }; + + await checkAutoClose(client); + + // Should not have made any more queries (no close) + expect(mockQuery).toHaveBeenCalledTimes(1); + }); + + it('should close tickets that exceed total threshold', async () => { + getConfig.mockReturnValue({ + tickets: { enabled: true, autoCloseHours: 48 }, + }); + + const oldDate = new Date(Date.now() - 80 * 60 * 60 * 1000); // 80 hours ago + + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 1, + guild_id: 'guild1', + thread_id: 'thread1', + created_at: oldDate.toISOString(), + }, + ], + }); + + const thread = createMockThread({ + guild: { id: 'guild1', channels: { cache: new Map() } }, + }); + const lastMsg = { + createdAt: oldDate, + }; + thread.messages.fetch.mockResolvedValue(new Map([['msg1', lastMsg]])); + + const guild = createMockGuild(); + guild.channels.fetch = vi.fn().mockResolvedValue(thread); + + const client = { + guilds: { cache: new Map([['guild1', guild]]) }, + user: { id: 'bot1', tag: 'Bot#1234' }, + }; + + // closeTicket will query for the ticket and update it + mockQuery.mockResolvedValueOnce({ + rows: [{ id: 1, guild_id: 'guild1', user_id: 'user1', thread_id: 'thread1' }], + }); + mockQuery.mockResolvedValueOnce({ + rows: [{ id: 1, status: 'closed' }], + }); + + await checkAutoClose(client); + + // The update query should have been called + const updateCalls = mockQuery.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].includes('UPDATE tickets'), + ); + expect(updateCalls.length).toBeGreaterThan(0); + }); + + it('should auto-close channel-mode tickets (text channel)', async () => { + getConfig.mockReturnValue({ + tickets: { enabled: true, mode: 'channel', autoCloseHours: 48 }, + }); + + const oldDate = new Date(Date.now() - 80 * 60 * 60 * 1000); + + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 20, + guild_id: 'guild1', + thread_id: 'ticket-ch1', + created_at: oldDate.toISOString(), + }, + ], + }); + + const channel = createMockChannel({ + id: 'ticket-ch1', + guild: { id: 'guild1', channels: { cache: new Map() } }, + }); + const lastMsg = { createdAt: oldDate }; + channel.messages.fetch.mockResolvedValue(new Map([['msg1', lastMsg]])); + + const guild = createMockGuild(); + guild.channels.fetch = vi.fn().mockResolvedValue(channel); + + const client = { + guilds: { cache: new Map([['guild1', guild]]) }, + user: { id: 'bot1', tag: 'Bot#1234' }, + }; + + // closeTicket queries + mockQuery.mockResolvedValueOnce({ + rows: [{ id: 20, guild_id: 'guild1', user_id: 'user1', thread_id: 'ticket-ch1' }], + }); + mockQuery.mockResolvedValueOnce({ + rows: [{ id: 20, status: 'closed' }], + }); + + await checkAutoClose(client); + + const updateCalls = mockQuery.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].includes('UPDATE tickets'), + ); + expect(updateCalls.length).toBeGreaterThan(0); + }); + + it('should send warning for tickets past autoCloseHours but not total threshold', async () => { + getConfig.mockReturnValue({ + tickets: { enabled: true, autoCloseHours: 48 }, + }); + + const almostOldDate = new Date(Date.now() - 50 * 60 * 60 * 1000); // 50 hours ago + + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 2, + guild_id: 'guild1', + thread_id: 'thread2', + created_at: almostOldDate.toISOString(), + }, + ], + }); + + const lastMsg = { + createdAt: almostOldDate, + }; + + const thread = createMockThread({ id: 'thread2' }); + thread.messages.fetch.mockResolvedValueOnce(new Map([['msg1', lastMsg]])); // limit: 10 + + const guild = createMockGuild(); + guild.channels.fetch = vi.fn().mockResolvedValue(thread); + + const client = { + guilds: { cache: new Map([['guild1', guild]]) }, + user: { id: 'bot1' }, + }; + + await checkAutoClose(client); + + // Should have sent the warning message + expect(mockSafeSend).toHaveBeenCalledWith( + thread, + expect.objectContaining({ + content: expect.stringContaining('auto-close'), + }), + ); + }); + + it('should handle deleted threads by closing in DB', async () => { + getConfig.mockReturnValue({ + tickets: { enabled: true, autoCloseHours: 48 }, + }); + + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 3, + guild_id: 'guild1', + thread_id: 'thread-gone', + created_at: new Date().toISOString(), + }, + ], + }); + + const guild = createMockGuild(); + guild.channels.fetch = vi.fn().mockRejectedValue(new Error('Unknown Channel')); + + const client = { + guilds: { cache: new Map([['guild1', guild]]) }, + user: { id: 'bot1' }, + }; + + // DB close for deleted thread + mockQuery.mockResolvedValueOnce({ rows: [] }); + + await checkAutoClose(client); + + const closeCalls = mockQuery.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].includes('UPDATE tickets'), + ); + expect(closeCalls.length).toBe(1); + expect(closeCalls[0][0]).toContain('Thread deleted'); + }); + + it('should skip if pool is not available', async () => { + const { getPool: getPoolFn } = await import('../../src/db.js'); + getPoolFn.mockReturnValueOnce(null); + + const client = { guilds: { cache: new Map() }, user: { id: 'bot1' } }; + // Should not throw + await checkAutoClose(client); + }); + + it('should skip tickets in unknown guilds', async () => { + getConfig.mockReturnValue({ + tickets: { enabled: true, autoCloseHours: 48 }, + }); + + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 4, + guild_id: 'unknown-guild', + thread_id: 'thread4', + created_at: new Date().toISOString(), + }, + ], + }); + + const client = { + guilds: { cache: new Map() }, // No guilds + user: { id: 'bot1' }, + }; + + await checkAutoClose(client); + // checkAutoClose returns early (no guilds) before any query + expect(mockQuery).toHaveBeenCalledTimes(0); + }); + + it('should handle message-fetch errors per ticket without throwing', async () => { + getConfig.mockReturnValue({ + tickets: { enabled: true, autoCloseHours: 48 }, + }); + + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 8, + guild_id: 'guild1', + thread_id: 'thread8', + created_at: new Date().toISOString(), + }, + ], + }); + + const thread = createMockThread({ id: 'thread8' }); + thread.messages.fetch.mockRejectedValue(new Error('cannot fetch messages')); + + const guild = createMockGuild(); + guild.channels.fetch = vi.fn().mockResolvedValue(thread); + + const client = { + guilds: { cache: new Map([['guild1', guild]]) }, + user: { id: 'bot1' }, + }; + + await expect(checkAutoClose(client)).resolves.toBeUndefined(); + }); + + it('should continue when ticket processing throws inside loop', async () => { + getConfig.mockImplementation(() => { + throw new Error('config failure'); + }); + + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 88, + guild_id: 'guild1', + thread_id: 'thread88', + created_at: new Date().toISOString(), + }, + ], + }); + + const guild = createMockGuild(); + const client = { + guilds: { cache: new Map([['guild1', guild]]) }, + user: { id: 'bot1' }, + }; + + await expect(checkAutoClose(client)).resolves.toBeUndefined(); + }); + + it('should support discord Collection.find path for recent messages', async () => { + getConfig.mockReturnValue({ + tickets: { enabled: true, autoCloseHours: 48 }, + }); + + const almostOldDate = new Date(Date.now() - 50 * 60 * 60 * 1000); + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 9, + guild_id: 'guild1', + thread_id: 'thread9', + created_at: almostOldDate.toISOString(), + }, + ], + }); + + const thread = createMockThread({ id: 'thread9' }); + const recentMessagesLikeCollection = { + find: vi.fn((predicate) => { + const msgs = [ + { author: { bot: true }, createdAt: new Date() }, + { author: { bot: false }, createdAt: almostOldDate }, + ]; + return msgs.find(predicate); + }), + }; + thread.messages.fetch.mockResolvedValue(recentMessagesLikeCollection); + + const guild = createMockGuild(); + guild.channels.fetch = vi.fn().mockResolvedValue(thread); + + const client = { + guilds: { cache: new Map([['guild1', guild]]) }, + user: { id: 'bot1' }, + }; + + await checkAutoClose(client); + + expect(recentMessagesLikeCollection.find).toHaveBeenCalledTimes(1); + expect(mockSafeSend).toHaveBeenCalledWith( + thread, + expect.objectContaining({ content: expect.stringContaining('auto-closed in') }), + ); + }); + + it('should skip ticket when fetched channel is null', async () => { + mockQuery.mockReset(); + mockQuery.mockResolvedValue({ rows: [] }); + getConfig.mockReturnValue({ tickets: { enabled: true, autoCloseHours: 48 } }); + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 10, + guild_id: 'guild1', + thread_id: 'missing-channel', + created_at: new Date().toISOString(), + }, + ], + }); + + const guild = createMockGuild(); + guild.channels.fetch = vi.fn().mockResolvedValue(null); + + await checkAutoClose({ + guilds: { cache: new Map([['guild1', guild]]) }, + user: { id: 'bot1' }, + }); + + expect(guild.channels.fetch).toHaveBeenCalledTimes(1); + expect(mockSafeSend).not.toHaveBeenCalled(); + }); + + it('should skip unsupported non-thread, non-text channels', async () => { + getConfig.mockReturnValue({ tickets: { enabled: true, autoCloseHours: 48 } }); + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 11, + guild_id: 'guild1', + thread_id: 'voice1', + created_at: new Date().toISOString(), + }, + ], + }); + + const voiceLikeChannel = { + id: 'voice1', + type: 2, + isThread: () => false, + messages: { fetch: vi.fn() }, + }; + + const guild = createMockGuild(); + guild.channels.fetch = vi.fn().mockResolvedValue(voiceLikeChannel); + + await checkAutoClose({ + guilds: { cache: new Map([['guild1', guild]]) }, + user: { id: 'bot1' }, + }); + + expect(voiceLikeChannel.messages.fetch).not.toHaveBeenCalled(); + }); + + it('should not send duplicate warnings once warning was already sent', async () => { + mockQuery.mockReset(); + mockQuery.mockResolvedValue({ rows: [] }); + getConfig.mockReturnValue({ tickets: { enabled: true, autoCloseHours: 48 } }); + const almostOldDate = new Date(Date.now() - 50 * 60 * 60 * 1000); + + const ticketRow = { + id: 12, + guild_id: 'guild1', + thread_id: 'thread12', + created_at: almostOldDate.toISOString(), + }; + + mockQuery.mockResolvedValue({ rows: [ticketRow] }); + + const thread = createMockThread({ id: 'thread12' }); + thread.messages.fetch.mockResolvedValue(new Map([['msg1', { createdAt: almostOldDate }]])); + + const guild = createMockGuild(); + guild.channels.fetch = vi.fn().mockResolvedValue(thread); + const client = { + guilds: { cache: new Map([['guild1', guild]]) }, + user: { id: 'bot1' }, + }; + + await checkAutoClose(client); + expect(mockSafeSend).toHaveBeenCalledTimes(1); + + mockSafeSend.mockClear(); + await checkAutoClose(client); + expect(mockSafeSend).not.toHaveBeenCalled(); + }); + }); + + describe('openTicket edge paths', () => { + it('should throw when no suitable thread parent channel can be resolved', async () => { + getConfig.mockReturnValue({ + tickets: { + enabled: true, + mode: 'thread', + supportRole: null, + category: null, + autoCloseHours: 48, + transcriptChannel: null, + maxOpenPerUser: 3, + }, + }); + + const guild = { + id: 'guild1', + channels: { + cache: { + get: vi.fn().mockReturnValue(undefined), + find: vi.fn().mockReturnValue(undefined), + }, + }, + roles: { cache: new Map() }, + members: { me: { id: 'bot1' } }, + }; + + const user = createMockUser(); + + mockQuery.mockResolvedValueOnce({ rows: [{ count: 0 }] }); + + await expect(openTicket(guild, user, 'help', null)).rejects.toThrow( + 'No suitable channel found to create a ticket thread.', + ); + }); + }); + + describe('closeTicket edge paths', () => { + it('should swallow transcript channel send errors', async () => { + getConfig.mockReturnValue({ + tickets: { + enabled: true, + mode: 'thread', + supportRole: null, + category: null, + autoCloseHours: 48, + transcriptChannel: 'transcript-ch', + maxOpenPerUser: 3, + }, + }); + + const transcriptChannel = { id: 'transcript-ch' }; + const thread = createMockThread({ + guild: { + id: 'guild1', + channels: { cache: new Map([['transcript-ch', transcriptChannel]]) }, + }, + }); + + mockQuery.mockResolvedValueOnce({ + rows: [{ id: 16, guild_id: 'guild1', user_id: 'user1', thread_id: 'thread1' }], + }); + mockQuery.mockResolvedValueOnce({ + rows: [{ id: 16, status: 'closed' }], + }); + + mockSafeSend.mockImplementation((target) => { + if (target?.id === 'transcript-ch') { + return Promise.reject(new Error('missing perms')); + } + return Promise.resolve(undefined); + }); + + await expect(closeTicket(thread, createMockUser({ id: 'closer2' }), 'Done')).resolves.toEqual( + expect.objectContaining({ id: 16 }), + ); + }); + + it('should skip transcript send when configured channel is missing', async () => { + getConfig.mockReturnValue({ + tickets: { + enabled: true, + mode: 'thread', + supportRole: null, + category: null, + autoCloseHours: 48, + transcriptChannel: 'transcript-ch', + maxOpenPerUser: 3, + }, + }); + + const thread = createMockThread({ + guild: { + id: 'guild1', + channels: { cache: new Map() }, + }, + }); + + mockQuery.mockResolvedValueOnce({ + rows: [{ id: 15, guild_id: 'guild1', user_id: 'user1', thread_id: 'thread1' }], + }); + mockQuery.mockResolvedValueOnce({ + rows: [{ id: 15, status: 'closed' }], + }); + + await closeTicket(thread, createMockUser({ id: 'closer1' }), 'Done'); + + // Only one safeSend call: close embed in current ticket channel. + expect(mockSafeSend).toHaveBeenCalledTimes(1); + expect(mockSafeSend).toHaveBeenCalledWith( + thread, + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); + }); +}); diff --git a/web/src/app/api/guilds/[guildId]/tickets/[ticketId]/route.ts b/web/src/app/api/guilds/[guildId]/tickets/[ticketId]/route.ts new file mode 100644 index 000000000..eb5939722 --- /dev/null +++ b/web/src/app/api/guilds/[guildId]/tickets/[ticketId]/route.ts @@ -0,0 +1,37 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { + authorizeGuildAdmin, + buildUpstreamUrl, + getBotApiConfig, + proxyToBotApi, +} from '@/lib/bot-api-proxy'; + +const LOG_PREFIX = '[api/guilds/:guildId/tickets/:ticketId]'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ guildId: string; ticketId: string }> }, +) { + const { guildId, ticketId } = await params; + if (!guildId || !ticketId) { + return NextResponse.json({ error: 'Missing guildId or ticketId' }, { status: 400 }); + } + + const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + if (authError) return authError; + + const config = getBotApiConfig(LOG_PREFIX); + if (config instanceof NextResponse) return config; + + const upstreamUrl = buildUpstreamUrl( + config.baseUrl, + `/guilds/${encodeURIComponent(guildId)}/tickets/${encodeURIComponent(ticketId)}`, + LOG_PREFIX, + ); + if (upstreamUrl instanceof NextResponse) return upstreamUrl; + + return proxyToBotApi(upstreamUrl, config.secret, LOG_PREFIX, 'Failed to fetch ticket'); +} diff --git a/web/src/app/api/guilds/[guildId]/tickets/route.ts b/web/src/app/api/guilds/[guildId]/tickets/route.ts new file mode 100644 index 000000000..f6fd5f3e0 --- /dev/null +++ b/web/src/app/api/guilds/[guildId]/tickets/route.ts @@ -0,0 +1,45 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { + authorizeGuildAdmin, + buildUpstreamUrl, + getBotApiConfig, + proxyToBotApi, +} from '@/lib/bot-api-proxy'; + +const LOG_PREFIX = '[api/guilds/:guildId/tickets]'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ guildId: string }> }, +) { + const { guildId } = await params; + if (!guildId) { + return NextResponse.json({ error: 'Missing guildId' }, { status: 400 }); + } + + const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + if (authError) return authError; + + const config = getBotApiConfig(LOG_PREFIX); + if (config instanceof NextResponse) return config; + + const upstreamUrl = buildUpstreamUrl( + config.baseUrl, + `/guilds/${encodeURIComponent(guildId)}/tickets`, + LOG_PREFIX, + ); + if (upstreamUrl instanceof NextResponse) return upstreamUrl; + + const allowedParams = ['page', 'limit', 'status', 'user']; + for (const key of allowedParams) { + const value = request.nextUrl.searchParams.get(key); + if (value !== null) { + upstreamUrl.searchParams.set(key, value); + } + } + + return proxyToBotApi(upstreamUrl, config.secret, LOG_PREFIX, 'Failed to fetch tickets'); +} diff --git a/web/src/app/api/guilds/[guildId]/tickets/stats/route.ts b/web/src/app/api/guilds/[guildId]/tickets/stats/route.ts new file mode 100644 index 000000000..e5bb1052f --- /dev/null +++ b/web/src/app/api/guilds/[guildId]/tickets/stats/route.ts @@ -0,0 +1,37 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { + authorizeGuildAdmin, + buildUpstreamUrl, + getBotApiConfig, + proxyToBotApi, +} from '@/lib/bot-api-proxy'; + +const LOG_PREFIX = '[api/guilds/:guildId/tickets/stats]'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ guildId: string }> }, +) { + const { guildId } = await params; + if (!guildId) { + return NextResponse.json({ error: 'Missing guildId' }, { status: 400 }); + } + + const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + if (authError) return authError; + + const config = getBotApiConfig(LOG_PREFIX); + if (config instanceof NextResponse) return config; + + const upstreamUrl = buildUpstreamUrl( + config.baseUrl, + `/guilds/${encodeURIComponent(guildId)}/tickets/stats`, + LOG_PREFIX, + ); + if (upstreamUrl instanceof NextResponse) return upstreamUrl; + + return proxyToBotApi(upstreamUrl, config.secret, LOG_PREFIX, 'Failed to fetch ticket stats'); +} diff --git a/web/src/app/dashboard/tickets/[ticketId]/page.tsx b/web/src/app/dashboard/tickets/[ticketId]/page.tsx new file mode 100644 index 000000000..74b146072 --- /dev/null +++ b/web/src/app/dashboard/tickets/[ticketId]/page.tsx @@ -0,0 +1,231 @@ +'use client'; + +import { ArrowLeft, Clock, Ticket, User } from 'lucide-react'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import { useCallback, useEffect, useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +interface TranscriptMessage { + author: string; + authorId: string | null; + content: string; + timestamp: string; +} + +interface TicketDetail { + id: number; + guild_id: string; + user_id: string; + topic: string | null; + status: string; + thread_id: string; + channel_id: string | null; + closed_by: string | null; + close_reason: string | null; + created_at: string; + closed_at: string | null; + transcript: TranscriptMessage[] | null; +} + +function formatTimestamp(iso: string): string { + return new Date(iso).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +export default function TicketDetailPage() { + const router = useRouter(); + const params = useParams(); + const searchParams = useSearchParams(); + const ticketId = params.ticketId as string; + const guildId = searchParams.get('guildId'); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchDetail = useCallback(async () => { + if (!guildId || !ticketId) { + setLoading(false); + return; + } + + setLoading(true); + setError(null); + + try { + const res = await fetch( + `/api/guilds/${encodeURIComponent(guildId)}/tickets/${encodeURIComponent(ticketId)}`, + ); + + if (res.status === 401) { + router.replace('/login'); + return; + } + if (res.status === 404) { + setError('Ticket not found'); + return; + } + if (!res.ok) { + throw new Error(`Failed to fetch ticket (${res.status})`); + } + + const result = (await res.json()) as TicketDetail; + setData(result); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch ticket'); + } finally { + setLoading(false); + } + }, [guildId, ticketId, router]); + + useEffect(() => { + void fetchDetail(); + }, [fetchDetail]); + + return ( +
+ {/* Header */} +
+ +
+

+ + Ticket Detail +

+
+
+ + {/* Loading */} + {loading && ( +
+
+
+ )} + + {/* Error */} + {error && ( +
+ Error: {error} +
+ )} + + {/* Ticket Info */} + {data && !loading && ( + <> + + + + Ticket #{data.id} + + {data.status === 'open' ? '🟢 Open' : '🔒 Closed'} + + + + +
+
+ Topic +

{data.topic || 'No topic provided'}

+
+
+ Opened by +

+ + {data.user_id} +

+
+
+ Created +

+ + {formatTimestamp(data.created_at)} +

+
+ {data.closed_at && ( +
+ Closed +

+ + {formatTimestamp(data.closed_at)} +

+
+ )} + {data.closed_by && ( +
+ Closed by +

{data.closed_by}

+
+ )} + {data.close_reason && ( +
+ Close reason +

{data.close_reason}

+
+ )} +
+
+
+ + {/* Transcript */} + {data.transcript && data.transcript.length > 0 && ( + + + Transcript ({data.transcript.length} messages) + + +
+ {data.transcript.map((msg, i) => ( +
+
+ {msg.author.slice(0, 2).toUpperCase()} +
+
+
+ {msg.author} + + {formatTimestamp(msg.timestamp)} + +
+

+ {msg.content || ( + [no content] + )} +

+
+
+ ))} +
+
+
+ )} + + {data.transcript && data.transcript.length === 0 && ( +
+

No transcript available.

+
+ )} + + {!data.transcript && data.status === 'open' && ( +
+

+ Transcript will be saved when the ticket is closed. +

+
+ )} + + )} +
+ ); +} diff --git a/web/src/app/dashboard/tickets/page.tsx b/web/src/app/dashboard/tickets/page.tsx new file mode 100644 index 000000000..d39c6d16a --- /dev/null +++ b/web/src/app/dashboard/tickets/page.tsx @@ -0,0 +1,433 @@ +'use client'; + +import { RefreshCw, Search, Ticket, X } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { useGuildSelection } from '@/hooks/use-guild-selection'; + +interface TicketSummary { + id: number; + guild_id: string; + user_id: string; + topic: string | null; + status: string; + thread_id: string; + channel_id: string | null; + closed_by: string | null; + close_reason: string | null; + created_at: string; + closed_at: string | null; +} + +interface TicketsApiResponse { + tickets: TicketSummary[]; + total: number; + page: number; + limit: number; +} + +interface TicketStats { + openCount: number; + avgResolutionSeconds: number; + ticketsThisWeek: number; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function formatDuration(seconds: number): string { + if (seconds === 0) return 'N/A'; + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + if (hours >= 24) { + const days = Math.floor(hours / 24); + return `${days}d ${hours % 24}h`; + } + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; +} + +const PAGE_SIZE = 25; + +export default function TicketsPage() { + const router = useRouter(); + + const [tickets, setTickets] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [statusFilter, setStatusFilter] = useState(''); + const [search, setSearch] = useState(''); + const searchTimerRef = useRef>(undefined); + const [debouncedSearch, setDebouncedSearch] = useState(''); + + const [stats, setStats] = useState(null); + + const abortControllerRef = useRef(null); + const requestIdRef = useRef(0); + + useEffect(() => { + clearTimeout(searchTimerRef.current); + searchTimerRef.current = setTimeout(() => { + setDebouncedSearch(search); + setPage(1); + }, 300); + return () => clearTimeout(searchTimerRef.current); + }, [search]); + + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + const onGuildChange = useCallback(() => { + setTickets([]); + setTotal(0); + setPage(1); + setError(null); + setStats(null); + }, []); + + const guildId = useGuildSelection({ onGuildChange }); + + const onUnauthorized = useCallback(() => router.replace('/login'), [router]); + + // Fetch stats + useEffect(() => { + if (!guildId) return; + const controller = new AbortController(); + void (async () => { + try { + const res = await fetch(`/api/guilds/${encodeURIComponent(guildId)}/tickets/stats`, { + signal: controller.signal, + }); + if (res.ok) { + const data = (await res.json()) as TicketStats; + setStats(data); + } + } catch { + // Non-critical (includes AbortError) + } + })(); + return () => controller.abort(); + }, [guildId]); + + // Fetch tickets + const fetchTickets = useCallback( + async (opts: { guildId: string; status: string; user: string; page: number }) => { + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + const requestId = ++requestIdRef.current; + + setLoading(true); + setError(null); + + try { + const params = new URLSearchParams(); + params.set('page', String(opts.page)); + params.set('limit', String(PAGE_SIZE)); + if (opts.status) params.set('status', opts.status); + if (opts.user) params.set('user', opts.user); + + const res = await fetch( + `/api/guilds/${encodeURIComponent(opts.guildId)}/tickets?${params.toString()}`, + { signal: controller.signal }, + ); + + if (requestId !== requestIdRef.current) return; + + if (res.status === 401) { + onUnauthorized(); + return; + } + if (!res.ok) { + throw new Error(`Failed to fetch tickets (${res.status})`); + } + + const data = (await res.json()) as TicketsApiResponse; + setTickets(data.tickets); + setTotal(data.total); + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + if (requestId !== requestIdRef.current) return; + setError(err instanceof Error ? err.message : 'Failed to fetch tickets'); + } finally { + if (requestId === requestIdRef.current) { + setLoading(false); + } + } + }, + [onUnauthorized], + ); + + useEffect(() => { + if (!guildId) return; + void fetchTickets({ + guildId, + status: statusFilter, + user: debouncedSearch, + page, + }); + }, [guildId, statusFilter, debouncedSearch, page, fetchTickets]); + + const handleRefresh = useCallback(() => { + if (!guildId) return; + void fetchTickets({ + guildId, + status: statusFilter, + user: debouncedSearch, + page, + }); + }, [guildId, fetchTickets, statusFilter, debouncedSearch, page]); + + const handleRowClick = useCallback( + (ticketId: number) => { + if (!guildId) return; + router.push(`/dashboard/tickets/${ticketId}?guildId=${encodeURIComponent(guildId)}`); + }, + [router, guildId], + ); + + const handleClearSearch = useCallback(() => { + setSearch(''); + setDebouncedSearch(''); + setPage(1); + }, []); + + const totalPages = Math.ceil(total / PAGE_SIZE); + + return ( +
+ {/* Header */} +
+
+

+ + Tickets +

+

Manage support tickets and view transcripts.

+
+ + +
+ + {/* Stats Cards */} + {stats && ( +
+
+
Open Tickets
+
{stats.openCount}
+
+
+
Avg Resolution
+
+ {formatDuration(stats.avgResolutionSeconds)} +
+
+
+
This Week
+
{stats.ticketsThisWeek}
+
+
+ )} + + {/* No guild selected */} + {!guildId && ( +
+

+ Select a server from the sidebar to view tickets. +

+
+ )} + + {/* Content */} + {guildId && ( + <> + {/* Filters */} +
+
+ + setSearch(e.target.value)} + aria-label="Search tickets by user" + /> + {search && ( + + )} +
+ + + + {total > 0 && ( + + {total.toLocaleString()} {total === 1 ? 'ticket' : 'tickets'} + + )} +
+ + {/* Error */} + {error && ( +
+ Error: {error} +
+ )} + + {/* Table */} + {tickets.length > 0 ? ( +
+ + + + ID + Topic + User + Status + Created + Closed + + + + {tickets.map((ticket) => ( + handleRowClick(ticket.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleRowClick(ticket.id); + } + }} + > + #{ticket.id} + + {ticket.topic || ( + No topic + )} + + {ticket.user_id} + + + {ticket.status === 'open' ? '🟢 Open' : '🔒 Closed'} + + + + {formatDate(ticket.created_at)} + + + {ticket.closed_at ? formatDate(ticket.closed_at) : '—'} + + + ))} + +
+
+ ) : ( + !loading && ( +
+

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

+
+ ) + )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ + +
+
+ )} + + )} +
+ ); +} diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx index 47730345f..a54154b2c 100644 --- a/web/src/components/dashboard/config-editor.tsx +++ b/web/src/components/dashboard/config-editor.tsx @@ -68,6 +68,7 @@ function isGuildConfig(data: unknown): data is GuildConfig { 'github', 'review', 'challenges', + 'tickets', ] as const; const hasKnownSection = knownSections.some((key) => key in obj); if (!hasKnownSection) return false; @@ -1684,6 +1685,139 @@ export function ConfigEditor() { + {/* ═══ Tickets ═══ */} + + +
+ Tickets + + updateDraftConfig((prev) => ({ + ...prev, + tickets: { ...prev.tickets, enabled: v }, + })) + } + disabled={saving} + label="Tickets" + /> +
+
+ + +
+ + + + + +
+
+
{/* Diff view */} {hasChanges && savedConfig && }
diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx index 27c39d0d3..d2b688ee8 100644 --- a/web/src/components/layout/sidebar.tsx +++ b/web/src/components/layout/sidebar.tsx @@ -8,6 +8,7 @@ import { ScrollText, Settings, Shield, + Ticket, Users, } from 'lucide-react'; import Link from 'next/link'; @@ -41,6 +42,11 @@ const navigation = [ href: '/dashboard/conversations', icon: MessagesSquare, }, + { + name: 'Tickets', + href: '/dashboard/tickets', + icon: Ticket, + }, { name: 'Bot Config', href: '/dashboard/config', diff --git a/web/src/types/config.ts b/web/src/types/config.ts index c9f53e6f5..b494ba813 100644 --- a/web/src/types/config.ts +++ b/web/src/types/config.ts @@ -215,6 +215,16 @@ export interface ReviewConfig extends ToggleSectionConfig { xpReward: number; } +/** Ticket system settings. */ +export interface TicketsConfig extends ToggleSectionConfig { + mode: 'thread' | 'channel'; + supportRole: string | null; + category: string | null; + autoCloseHours: number; + transcriptChannel: string | null; + maxOpenPerUser: number; +} + /** Daily challenge scheduler settings. */ export interface ChallengesConfig extends ToggleSectionConfig { channelId: string | null; @@ -247,6 +257,7 @@ export interface BotConfig { github?: GithubConfig; review?: ReviewConfig; challenges?: ChallengesConfig; + tickets?: TicketsConfig; } /** All config sections shown in the editor. */ @@ -270,7 +281,8 @@ export type ConfigSection = | 'engagement' | 'github' | 'review' - | 'challenges'; + | 'challenges' + | 'tickets'; /** * @deprecated Use {@link ConfigSection} directly.