-
Notifications
You must be signed in to change notification settings - Fork 2
feat: support ticket system with private threads and channel mode #142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
db39e72
c609a3f
bd5c883
24bf617
8b85b25
495649a
d2e3690
729d6c8
9b34e6c
d74caf1
9f82338
a8b0016
a94c77a
08fba54
654da13
e41b83b
ddc2172
20c7a49
4cca66e
c9e1afe
a897e97
e3f23ef
bb2e96e
ddb0432
d7c4ad9
ae6ed89
0baa397
03a5c34
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| `); | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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
|
||
| 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, | ||
BillChirico marked this conversation as resolved.
Dismissed
Show dismissed
Hide dismissed
|
||
| 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, | ||
BillChirico marked this conversation as resolved.
Dismissed
Show dismissed
Hide dismissed
|
||
| 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, | ||
| ]); | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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) => { | ||
BillChirico marked this conversation as resolved.
Dismissed
Show dismissed
Hide dismissed
|
||
| 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; | ||
Uh oh!
There was an error while loading. Please reload this page.