Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
db39e72
feat(tickets): add migration 014 for tickets table
BillChirico Feb 28, 2026
c609a3f
feat(tickets): add ticketHandler module
BillChirico Feb 28, 2026
bd5c883
feat(tickets): add /ticket slash command
BillChirico Feb 28, 2026
24bf617
feat(tickets): add button/modal handlers and scheduler integration
BillChirico Feb 28, 2026
8b85b25
feat(tickets): add ticket API routes
BillChirico Feb 28, 2026
495649a
feat(tickets): add dashboard pages and config UI
BillChirico Feb 28, 2026
d2e3690
test(tickets): add comprehensive tests for ticket system
BillChirico Feb 28, 2026
729d6c8
fix(tickets): fix compatibility issues and rate limiter scoping
BillChirico Feb 28, 2026
9b34e6c
style(tickets): apply Biome formatting and fix config type syntax
BillChirico Feb 28, 2026
d74caf1
feat(tickets): add channel mode support to ticket handler and commands
BillChirico Feb 28, 2026
9f82338
feat(tickets): add ticket mode config to dashboard
BillChirico Feb 28, 2026
a8b0016
test(tickets): add channel mode tests for ticket system
BillChirico Feb 28, 2026
a94c77a
style(tickets): apply Biome formatting to channel mode changes
BillChirico Feb 28, 2026
08fba54
fix(tickets): support channel-mode tickets in close button handler
BillChirico Feb 28, 2026
654da13
fix(tickets): validate ticket context against DB to prevent authoriza…
BillChirico Feb 28, 2026
e41b83b
fix(tickets): cleanup unused variables and misplaced JSDoc
BillChirico Feb 28, 2026
ddc2172
fix(tickets): add thread_id+status index, validate ticketId, null-saf…
BillChirico Feb 28, 2026
20c7a49
fix(tickets): scope auto-close to bot guilds, throttle to 5min, fix w…
BillChirico Feb 28, 2026
4cca66e
fix(tickets): use mode-agnostic language in ticket panel description
BillChirico Feb 28, 2026
c9e1afe
fix(tickets): web dashboard fixes — loading state, duration, a11y, ab…
BillChirico Feb 28, 2026
a897e97
fix(tickets): guard CategoryChannel in thread mode, document setTimeo…
BillChirico Feb 28, 2026
e3f23ef
style: format CodeRabbit generated ticket tests
BillChirico Feb 28, 2026
bb2e96e
fix(tickets): address PR review nitpicks
BillChirico Feb 28, 2026
ddb0432
fix(tickets): address all unresolved PR #142 review threads
BillChirico Feb 28, 2026
d7c4ad9
test: cover ticket event handlers and edge branches
BillChirico Feb 28, 2026
ae6ed89
style(tests): format ticket handler test file
BillChirico Feb 28, 2026
0baa397
test(tickets): increase ticketHandler branch coverage for review paths
BillChirico Feb 28, 2026
03a5c34
fix(tickets-ui): use runtime locale for ticket date formatting
BillChirico Feb 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions migrations/014_tickets.cjs
Original file line number Diff line number Diff line change
@@ -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 };
Comment on lines +45 to +47
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All other migrations in this project (e.g., 010_reviews.cjs, 011_challenges.cjs, 012_flagged_messages.cjs, 013_public_profiles.cjs) export both an up and a down function. Migration 014_tickets.cjs is missing the down function, which means the migration cannot be rolled back. Following the established convention, a down function should be added that drops the indexes and the tickets table in reverse order.

Copilot uses AI. Check for mistakes.
5 changes: 5 additions & 0 deletions src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);

Expand Down
180 changes: 180 additions & 0 deletions src/api/routes/tickets.js
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading