feat: support ticket system with private threads and channel mode#142
feat: support ticket system with private threads and channel mode#142BillChirico merged 28 commits intomainfrom
Conversation
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. 📒 Files selected for processing (19)
📝 WalkthroughWalkthroughImplements a comprehensive support ticket system enabling guild members to create, manage, and resolve support tickets. Includes database schema, Discord slash commands and interactive UI components, backend ticket logic supporting thread/channel modes, API endpoints with pagination and filtering, web dashboard interface with ticket listing and transcript viewing, and scheduler-integrated auto-close functionality. Changes
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Review Summary
Issues Found
🔴 Critical (1)
- Close button broken for channel-mode tickets (
src/modules/events.js:677-683):registerTicketCloseButtonHandleronly accepts threads (!thread?.isThread()), but channel-mode tickets use text channels. Users clicking "Close Ticket" in channel mode get an error.
🟡 Warning (2)
- Panel description hardcodes "private thread" (
src/modules/ticketHandler.js:486-489):buildTicketPanelsays "A private thread will be created" even when channel mode is active. - Unbounded
checkAutoClosequery (src/modules/ticketHandler.js:410-413): Fetches ALL open tickets from ALL guilds every 60s. Should filter by connected guilds.
🔵 Nitpick (2)
- Unused variable (
src/modules/events.js:585):guildConfigdeclared but never used inregisterTicketOpenButtonHandler. - Stale JSDoc (
web/src/types/config.ts:218-219): "Daily challenge scheduler settings" comment belongs toChallengesConfig, notTicketsConfig.
AI fix prompt (click to expand)
Fix the following issues in the volvox-bot repo on branch feat/ticket-system:
1. src/modules/events.js, lines 677-683: The registerTicketCloseButtonHandler
only accepts threads via `!thread?.isThread()`. Channel-mode tickets use
GuildText channels, so this breaks the close button for them. Change the
guard to accept both threads and GuildText channels (type === 0), matching
the isTicketContext() pattern in src/commands/ticket.js:71-76. Update the
error message to say "ticket thread or channel".
2. src/modules/events.js, line 585: Remove the unused `guildConfig` variable.
The line `const guildConfig = getConfig(interaction.guildId);` is dead code
— only `ticketConfig` (line 586) is used. Delete line 585 entirely.
3. src/modules/ticketHandler.js, lines 486-489: Change the buildTicketPanel()
embed description from "A private thread will be created" to
"A private space will be created" so it's accurate for both thread and
channel modes.
4. src/modules/ticketHandler.js, lines 410-413: In checkAutoClose(), change the
query from `SELECT * FROM tickets WHERE status = $1` (which loads ALL open
tickets from ALL guilds) to filter by guilds the bot is connected to:
`SELECT * FROM tickets WHERE status = $1 AND guild_id = ANY($2)` with
params ['open', Array.from(client.guilds.cache.keys())]. Pass `client` info
to scope the query. Note: the function already receives `client` as a param.
5. web/src/types/config.ts, lines 218-219: Remove the stale JSDoc comment
"/** Daily challenge scheduler settings. */" that is incorrectly placed
before TicketsConfig. Keep only "/** Ticket system settings. */".
After fixes, run `pnpm biome check --write .` to format, then `pnpm test` to
verify all tests pass.
There was a problem hiding this comment.
Actionable comments posted: 17
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@migrations/014_tickets.cjs`:
- Around line 31-39: Add an index to support lookups by thread/channel id and
status used by closeTicket() and related flows: in the migrations file where
other ticket indexes are created (indexes on tickets(guild_id, status) and
tickets(guild_id, user_id)), add a CREATE INDEX IF NOT EXISTS for the tickets
table on (thread_id, status) (suggested name like idx_tickets_thread_status) so
queries that filter by thread_id + status will use the index and scale properly.
In `@src/api/routes/tickets.js`:
- Around line 89-97: Validate the ticketId before hitting the database: where
you extract const { id: guildId, ticketId } = req.params (before pool.query),
parse ticketId to an integer and check that it's a valid positive integer (e.g.,
Number.parseInt and Number.isFinite/Number.isInteger and > 0 or !Number.isNaN).
If invalid, short-circuit and return res.status(400).json({ error: 'Invalid
ticketId' }) so pool.query('SELECT * FROM tickets WHERE guild_id = $1 AND id =
$2', [guildId, Number.parseInt(ticketId, 10)]) only runs with a validated
numeric id.
In `@src/commands/ticket.js`:
- Around line 233-239: The permission-error message is misleading: the check
allows either Administrator permission or isModerator(interaction.member,
guildConfig), but the reply text says admins only; update the message in the
safeEditReply call to reflect both allowed roles (e.g., "You must be a moderator
or administrator to use this command.") so it matches the permission check
involving interaction.member.permissions.has(PermissionFlagsBits.Administrator)
and isModerator(...).
- Around line 71-75: The isTicketContext function is too permissive (it treats
any GuildText as a ticket), causing /ticket add/remove/close to modify unrelated
channels; restrict ticket detection to explicit ticket channels only by updating
isTicketContext to return true only for threads OR channels that match your
ticket-identifying criteria (e.g., channel.name pattern like
startsWith('ticket-'), channel.parentId === TICKET_CATEGORY_ID,
channel.topic/includes a ticket marker, or an entry exists in your tickets
datastore) and false otherwise, and ensure the code paths that change permission
overwrites (the handlers that call isTicketContext before performing permission
updates) reject requests when isTicketContext returns false.
In `@src/modules/events.js`:
- Around line 670-683: The handler in registerTicketCloseButtonHandler currently
rejects any interaction where interaction.channel?.isThread() is false, which
blocks channel-mode tickets; instead, change the context check to accept either
a thread or a channel that is a ticket by using the repository/lookup the
codebase already uses for tickets (e.g., call the existing getTicketByChannelId
or isTicketChannel utility) so the button works for both thread-mode and
channel-mode tickets; adjust the early-return block to fetch the ticket via that
lookup (referencing registerTicketCloseButtonHandler, Events.InteractionCreate,
the 'ticket_close_' customId, and safeEditReply) and only reply with the "only
inside a ticket" message if no ticket is found.
In `@src/modules/scheduler.js`:
- Around line 192-193: The scheduler currently calls checkAutoClose() every tick
which is too heavy; add an interval gate so checkAutoClose() runs at most once
per configured period (e.g. AUTO_CLOSE_INTERVAL_MS). Introduce a module-scoped
lastAutoCloseRun timestamp and a constant AUTO_CLOSE_INTERVAL_MS, then in the
scheduler before awaiting checkAutoClose(client) check (Date.now() -
lastAutoCloseRun) >= AUTO_CLOSE_INTERVAL_MS, update lastAutoCloseRun when you
invoke checkAutoClose, and skip the call otherwise; keep the existing call site
(the line that currently awaits checkAutoClose(client)) but wrap it with this
guard to throttle executions and avoid API pressure.
In `@src/modules/ticketHandler.js`:
- Around line 454-465: The current warning detection using
recentMessages/hasWarning and msg.content.includes('auto-close') is fragile—add
a persistent warning_sent_at timestamp column to the tickets table and use it
instead: in the logic around recentMessages/hasWarning replace the string match
check with a check of ticket.warning_sent_at (e.g., if ticket.warning_sent_at is
null or older than your threshold then send warning), and when sending the
warning via safeSend set ticket.warning_sent_at = now() (persist to DB) and log
it (info('Auto-close warning sent', { ticketId: ticket.id, warning_sent_at }));
also ensure you clear or update warning_sent_at when the ticket receives
activity or is closed so the state stays consistent.
- Around line 331-340: The fire-and-forget setTimeout (using
CHANNEL_DELETE_DELAY_MS) can orphan channels if the process restarts; instead,
when scheduling a channel deletion in ticketHandler.js, persist a deletion
marker (e.g., set ticket.deleteScheduledAt or ticket.markedForDeletion) in the
DB before calling setTimeout, update the scheduled-delete handler to clear that
marker on successful channel.delete (and leave it if deletion fails), and
implement a startup cleanup routine (e.g., cleanupScheduledDeletions or
runScheduledDeletionJobs invoked during app bootstrap) that queries tickets with
deleteScheduledAt/markedForDeletion and attempts immediate deletion of their
channels; keep the existing setTimeout handler as a best-effort fallback and
ensure error logging (logError with ticket.id and err.message) remains.
- Around line 86-94: The code uses guild.members.me.id when building an
OverwriteType.Member permission object which can throw if guild.members.me is
null; update the logic in the ticket creation path to safely obtain the bot's ID
(use guild.members.me?.id fallback to guild.client.user?.id or fetch the
GuildMember via guild.members.fetch(guild.client.user.id) if needed) before
constructing the overwrite object (the block referencing OverwriteType.Member
and PermissionFlagsBits.ViewChannel/SendMessages/ManageChannels), and ensure you
handle and log failures to resolve the bot ID so you don't pass undefined into
the overwrite.
- Around line 481-501: Change buildTicketPanel to accept a guildId parameter
(e.g., buildTicketPanel(guildId)) and use the module's existing ticket
configuration lookup for that guild to determine mode ('thread' vs 'channel');
then build the embed description dynamically (use "A private thread will be
created…" for thread mode and "A private channel will be created…" for channel
mode), leaving other embed/button fields unchanged. Update the call site that
currently invokes buildTicketPanel() to pass interaction.guildId (or equivalent
guild identifier) and update the unit test that constructs the panel to call the
new signature with a test guildId and appropriate mocked guild ticket config.
Ensure the customId 'ticket_open' and the ActionRow/ButtonBuilder remain the
same.
In `@web/src/app/dashboard/tickets/`[ticketId]/page.tsx:
- Around line 41-86: Replace the local fetch/state logic inside
TicketDetailPage: remove useState hooks (data, loading, error), the fetchDetail
function and the useEffect call, and instead call the dashboard/zustand store's
fetch-on-demand method and selectors (e.g.,
useTicketStore.getState().fetchTicket or useTicketStore(ticket => ({ ticket,
loading, error, fetchTicket }))) to load and subscribe to the ticket by guildId
and ticketId; ensure the router.replace('/login') handling is done via the
store's auth/error behavior or by reading the store error and redirecting in
TicketDetailPage, and use the store's ticket/ticketDetail, loading and error
selectors in the component render.
- Around line 52-54: The fetchDetail callback returns early when guildId or
ticketId is missing which leaves the component stuck in a loading state; update
fetchDetail to clear the loading flag before returning (e.g., call the
component's loading state setter such as setLoading(false) or
setIsLoading(false)) or rework the guard to avoid entering the loading state
when guildId/ticketId are undefined, referencing fetchDetail, guildId and
ticketId to locate the change.
In `@web/src/app/dashboard/tickets/page.tsx`:
- Around line 352-356: The TableRow currently only uses onClick (TableRow with
onClick={() => handleRowClick(ticket.id)}) which is not keyboard-accessible;
update the row to be focusable and respond to keyboard activation by either
rendering a semantic interactive element inside the row (e.g., wrap the
clickable cell content in a <button> or <a> that calls
handleRowClick(ticket.id)) or add accessibility attributes and handlers (add
tabIndex={0} to the TableRow, implement onKeyDown to call
handleRowClick(ticket.id) on Enter/Space, and set role="button"), and ensure
visible focus styles are preserved so keyboard users can discover and activate
the row.
- Around line 76-205: The page currently manages ticket list, stats and fetch
orchestration locally (TicketsPage, fetchTickets, abortControllerRef,
requestIdRef, stats, tickets, total, loading, error) but per guidelines this
logic must live in the shared Zustand store and follow a fetch-on-demand
pattern; move the network logic into the store by adding actions like
fetchTickets({ guildId, status, user, page }) and fetchStats(guildId) that
manage AbortController, request deduping (requestId), PAGE_SIZE, loading/error
state and 401 handling (call onUnauthorized or surface an unauthorized flag),
expose tickets, total, stats, loading, error and a refresh action; then refactor
TicketsPage to remove local ticket/stats/fetch state and simply read from the
store and call store.fetchTickets/store.fetchStats inside the existing effects
and handleRefresh, keeping useGuildSelection and the debounced search logic but
invoking the store actions instead of the local fetchTickets function.
In `@web/src/components/dashboard/config-editor.tsx`:
- Around line 1728-1732: The tickets ID fields are persisted using
e.target.value || null which treats whitespace-only strings as non-null IDs;
update the onChange handlers that call updateDraftConfig to trim the input
before null-coalescing (e.g., use e.target.value.trim() and set
tickets.supportRole to trimmedValue === "" ? null : trimmedValue) so inputs like
supportRole (and the other tickets ID fields at the referenced handlers) store
null for empty/whitespace-only values.
- Around line 1684-1814: The Tickets UI is mutating local component state via
draftConfig and updateDraftConfig; move this editing logic into the shared
Zustand config store and use the store's fetch-on-demand pattern instead.
Replace direct reads/writes of draftConfig and updateDraftConfig in the Tickets
Card (the select, inputs, ToggleSwitch and number handlers) to call the store's
getters and setter actions (the store's fetchConfig/fetchOnDemand and
updateConfig or equivalent actions) so the component reads from the zustand
state and updates via store actions; keep input validation (parseNumberInput)
but pass the parsed values into the store update action rather than local state.
Ensure enabled, mode, supportRole, category, autoCloseHours, maxOpenPerUser and
transcriptChannel are sourced from and persisted to the store rather than
component-local state.
In `@web/src/types/config.ts`:
- Around line 219-220: The JSDoc for "Daily challenge scheduler settings" is
currently placed above TicketsConfig; move that comment block so it sits
immediately above ChallengesConfig instead. Locate the interfaces TicketsConfig
and ChallengesConfig in this file and cut the misplaced JSDoc from above
TicketsConfig and paste it above ChallengesConfig, leaving TicketsConfig with
its correct "Ticket system settings." JSDoc content should remain unchanged and
positioned directly before the ChallengesConfig interface declaration.
ℹ️ Review info
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
📒 Files selected for processing (17)
migrations/014_tickets.cjssrc/api/index.jssrc/api/routes/tickets.jssrc/commands/ticket.jssrc/modules/events.jssrc/modules/scheduler.jssrc/modules/ticketHandler.jstests/api/routes/tickets.test.jstests/modules/ticketHandler.test.jsweb/src/app/api/guilds/[guildId]/tickets/[ticketId]/route.tsweb/src/app/api/guilds/[guildId]/tickets/route.tsweb/src/app/api/guilds/[guildId]/tickets/stats/route.tsweb/src/app/dashboard/tickets/[ticketId]/page.tsxweb/src/app/dashboard/tickets/page.tsxweb/src/components/dashboard/config-editor.tsxweb/src/components/layout/sidebar.tsxweb/src/types/config.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
- GitHub Check: Greptile Review
- GitHub Check: Agent
- GitHub Check: claude-review
🧰 Additional context used
📓 Path-based instructions (7)
**/*.{js,ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{js,ts,tsx}: Always use semicolons
Use single quotes — enforced by Biome
Use 2-space indentation — enforced by Biome
Files:
web/src/app/api/guilds/[guildId]/tickets/stats/route.tsweb/src/app/api/guilds/[guildId]/tickets/[ticketId]/route.tssrc/commands/ticket.jsweb/src/app/api/guilds/[guildId]/tickets/route.tsweb/src/types/config.tssrc/modules/scheduler.jsweb/src/app/dashboard/tickets/page.tsxsrc/modules/events.jsweb/src/app/dashboard/tickets/[ticketId]/page.tsxtests/modules/ticketHandler.test.jssrc/api/routes/tickets.jsweb/src/components/dashboard/config-editor.tsxtests/api/routes/tickets.test.jsweb/src/components/layout/sidebar.tsxsrc/api/index.jssrc/modules/ticketHandler.js
web/**/*.{tsx,ts}
📄 CodeRabbit inference engine (AGENTS.md)
Use next/image Image component with appropriate layout and sizing props in Next.js components
Files:
web/src/app/api/guilds/[guildId]/tickets/stats/route.tsweb/src/app/api/guilds/[guildId]/tickets/[ticketId]/route.tsweb/src/app/api/guilds/[guildId]/tickets/route.tsweb/src/types/config.tsweb/src/app/dashboard/tickets/page.tsxweb/src/app/dashboard/tickets/[ticketId]/page.tsxweb/src/components/dashboard/config-editor.tsxweb/src/components/layout/sidebar.tsx
web/src/**/*.{tsx,ts}
📄 CodeRabbit inference engine (AGENTS.md)
Use Zustand store (zustand) for state management in React components; implement fetch-on-demand pattern in stores
Files:
web/src/app/api/guilds/[guildId]/tickets/stats/route.tsweb/src/app/api/guilds/[guildId]/tickets/[ticketId]/route.tsweb/src/app/api/guilds/[guildId]/tickets/route.tsweb/src/types/config.tsweb/src/app/dashboard/tickets/page.tsxweb/src/app/dashboard/tickets/[ticketId]/page.tsxweb/src/components/dashboard/config-editor.tsxweb/src/components/layout/sidebar.tsx
**/*.js
📄 CodeRabbit inference engine (AGENTS.md)
**/*.js: Use ESM modules only — import/export, never require()
Always use node: protocol for Node.js builtin imports (e.g., import { readFileSync } from 'node:fs')
Files:
src/commands/ticket.jssrc/modules/scheduler.jssrc/modules/events.jstests/modules/ticketHandler.test.jssrc/api/routes/tickets.jstests/api/routes/tickets.test.jssrc/api/index.jssrc/modules/ticketHandler.js
src/**/*.js
📄 CodeRabbit inference engine (AGENTS.md)
src/**/*.js: NEVER use console.log, console.warn, console.error, or any console.* method in src/ files — always use Winston logger instead: import { info, warn, error } from '../logger.js'
Pass structured metadata to Winston logger: info('Message processed', { userId, channelId })
Use custom error classes from src/utils/errors.js and always log errors with context before re-throwing
Use getConfig(guildId?) from src/modules/config.js to read config; use setConfigValue(path, value, guildId?) to update at runtime
Use safeSend() utility for outgoing Discord messages to sanitize mentions and enforce allowedMentions
Use splitMessage() utility for messages exceeding Discord's 2000-character limit
Add tests for all new code with mandatory 80% coverage threshold on statements, branches, functions, and lines; run pnpm test before every commit
Files:
src/commands/ticket.jssrc/modules/scheduler.jssrc/modules/events.jssrc/api/routes/tickets.jssrc/api/index.jssrc/modules/ticketHandler.js
src/commands/*.js
📄 CodeRabbit inference engine (AGENTS.md)
src/commands/*.js: Use parseDuration() from src/utils/duration.js for duration-based commands (timeout, tempban, slowmode); enforce Discord duration caps (timeouts max 28 days, slowmode max 6 hours)
Create slash commands by exporting data (SlashCommandBuilder) and execute() function from src/commands/*.js; export adminOnly = true for mod-only commands; commands are auto-discovered on startup
Files:
src/commands/ticket.js
src/modules/*.js
📄 CodeRabbit inference engine (AGENTS.md)
src/modules/*.js: Register event handlers in src/modules/events.js by importing handler functions and calling client.on() with config parameter
Check config.yourModule.enabled before processing in module event handlers
Prefer per-request getConfig() pattern in new modules over reactive onConfigChange() wiring; only add onConfigChange() listeners for stateful resources that cannot re-read config on each use
Files:
src/modules/scheduler.jssrc/modules/events.jssrc/modules/ticketHandler.js
🧠 Learnings (6)
📚 Learning: 2026-02-27T16:24:15.054Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-27T16:24:15.054Z
Learning: Applies to src/commands/*.js : Create slash commands by exporting data (SlashCommandBuilder) and execute() function from src/commands/*.js; export adminOnly = true for mod-only commands; commands are auto-discovered on startup
Applied to files:
src/commands/ticket.js
📚 Learning: 2026-02-27T16:24:15.054Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-27T16:24:15.054Z
Learning: Applies to src/commands/*.js : Use parseDuration() from src/utils/duration.js for duration-based commands (timeout, tempban, slowmode); enforce Discord duration caps (timeouts max 28 days, slowmode max 6 hours)
Applied to files:
src/commands/ticket.js
📚 Learning: 2026-02-27T16:24:15.054Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-27T16:24:15.054Z
Learning: Applies to src/commands/*mod*.js : Moderation commands must follow the shared pattern: deferReply({ ephemeral: true }), validate inputs, sendDmNotification(), execute Discord action, createCase(), sendModLogEmbed(), checkEscalation()
Applied to files:
src/commands/ticket.js
📚 Learning: 2026-02-27T16:24:15.054Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-27T16:24:15.054Z
Learning: Applies to src/index.js : The tempban scheduler runs on a 60s interval and catches up on missed unbans after restart; started in index.js startup and stopped in graceful shutdown
Applied to files:
src/modules/scheduler.js
📚 Learning: 2026-02-27T16:24:15.054Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-27T16:24:15.054Z
Learning: Applies to src/modules/*.js : Register event handlers in src/modules/events.js by importing handler functions and calling client.on() with config parameter
Applied to files:
src/modules/events.js
📚 Learning: 2026-02-27T16:24:15.054Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-27T16:24:15.054Z
Learning: Applies to src/**/*.js : Add tests for all new code with mandatory 80% coverage threshold on statements, branches, functions, and lines; run pnpm test before every commit
Applied to files:
tests/modules/ticketHandler.test.js
🧬 Code graph analysis (11)
web/src/app/api/guilds/[guildId]/tickets/[ticketId]/route.ts (1)
web/src/lib/bot-api-proxy.ts (4)
authorizeGuildAdmin(37-69)getBotApiConfig(82-92)buildUpstreamUrl(100-113)proxyToBotApi(135-176)
src/commands/ticket.js (5)
src/modules/ticketHandler.js (13)
channel(423-423)ticketConfig(125-125)ticketConfig(301-301)ticketConfig(417-417)getTicketConfig(58-61)openTicket(121-242)ticket(210-210)ticket(266-266)closeTicket(252-350)embed(213-221)embed(289-296)embed(482-490)row(492-498)src/modules/config.js (2)
getConfig(282-313)err(94-94)src/utils/safeSend.js (2)
safeEditReply(178-185)safeSend(116-123)src/utils/permissions.js (1)
isModerator(143-173)src/logger.js (1)
info(230-232)
web/src/app/api/guilds/[guildId]/tickets/route.ts (3)
web/src/app/api/guilds/[guildId]/tickets/[ticketId]/route.ts (1)
GET(14-37)web/src/app/api/guilds/[guildId]/tickets/stats/route.ts (1)
GET(14-37)web/src/lib/bot-api-proxy.ts (4)
authorizeGuildAdmin(37-69)getBotApiConfig(82-92)buildUpstreamUrl(100-113)proxyToBotApi(135-176)
src/modules/scheduler.js (1)
src/modules/ticketHandler.js (1)
checkAutoClose(406-474)
src/modules/events.js (1)
src/modules/ticketHandler.js (6)
getTicketConfig(58-61)row(492-498)openTicket(121-242)ticket(210-210)ticket(266-266)closeTicket(252-350)
tests/modules/ticketHandler.test.js (1)
src/modules/ticketHandler.js (12)
getTicketConfig(58-61)row(492-498)guild(304-304)guild(420-420)openTicket(121-242)overwrites(72-95)messages(270-270)closeTicket(252-350)channel(423-423)addMember(361-375)removeMember(386-397)checkAutoClose(406-474)
src/api/routes/tickets.js (3)
src/api/index.js (1)
router(19-19)src/api/middleware/rateLimit.js (1)
rateLimit(15-56)src/api/routes/guilds.js (1)
validateGuild(292-302)
web/src/components/dashboard/config-editor.tsx (2)
web/src/components/ui/card.tsx (3)
Card(55-55)CardContent(55-55)CardTitle(55-55)web/src/components/dashboard/toggle-switch.tsx (1)
ToggleSwitch(14-32)
tests/api/routes/tickets.test.js (3)
src/api/routes/tickets.js (1)
req(124-124)src/api/server.js (1)
createApp(27-87)src/modules/ticketHandler.js (2)
ticket(210-210)ticket(266-266)
src/api/index.js (2)
src/api/routes/tickets.js (1)
router(13-13)src/api/middleware/auth.js (1)
requireAuth(36-70)
src/modules/ticketHandler.js (4)
src/modules/config.js (2)
getConfig(282-313)err(94-94)src/db.js (1)
getPool(142-147)src/utils/safeSend.js (1)
safeSend(116-123)src/logger.js (1)
info(230-232)
🪛 GitHub Check: CodeQL
src/api/routes/tickets.js
[failure] 37-37: Missing rate limiting
This route handler performs authorization, but is not rate-limited.
[failure] 86-86: Missing rate limiting
This route handler performs authorization, but is not rate-limited.
[failure] 122-122: Missing rate limiting
This route handler performs authorization, but is not rate-limited.
src/api/index.js
[failure] 43-43: Missing rate limiting
This route handler performs authorization, but is not rate-limited.
This route handler performs authorization, but is not rate-limited.
🔇 Additional comments (10)
web/src/components/layout/sidebar.tsx (1)
11-12: Looks good — Tickets nav integration is clean.Import and sidebar entry are consistent with existing navigation patterns.
Also applies to: 45-49
web/src/app/api/guilds/[guildId]/tickets/route.ts (1)
14-45: Proxy flow is solid and consistent.Auth, config checks, upstream URL construction, and query-param allowlisting are implemented correctly.
web/src/app/api/guilds/[guildId]/tickets/stats/route.ts (1)
14-37: LGTM — consistent proxy endpoint implementation.Validation, authorization, and upstream forwarding are all aligned with sibling routes.
web/src/app/api/guilds/[guildId]/tickets/[ticketId]/route.ts (1)
14-37: Looks good — detail route is correctly structured.The guard/auth/config/proxy sequence is implemented cleanly.
src/api/index.js (1)
16-44: Route registration order is correct.Mounting tickets before the generic guilds router prevents path shadowing for
/:id/tickets/*.src/modules/ticketHandler.js (5)
1-50: LGTM!Well-documented module header, proper ESM imports, and clearly defined constants with sensible defaults. The documentation accurately describes the dual-mode (thread/channel) functionality.
52-61: LGTM!Clean implementation following the per-request
getConfig()pattern as recommended in the coding guidelines.
121-242: LGTM!The
openTicketfunction correctly implements both thread and channel modes with proper permission handling, rate limiting via DB check, and structured logging. Good use ofsafeSendfor Discord messaging.
352-375: LGTM!Clean dual-mode implementation with proper safeSend usage and structured logging.
377-397: LGTM!Mirrors
addMemberwith appropriate removal logic for both modes.
There was a problem hiding this comment.
Pull request overview
This PR implements a complete support ticket system for the Volvox Discord bot, allowing members to open private support threads with staff. It is configurable via the dashboard and supports two modes: thread (private Discord threads) and channel (dedicated text channels with permission overrides). It closes issue #134.
Changes:
- Adds the full ticket backend: migration, core handler module, slash command, API routes, and scheduler integration
- Adds the web dashboard frontend: ticket list page, ticket detail/transcript page, 3 Next.js API proxy routes, config editor section, and sidebar navigation entry
- Adds 47 new tests across two test files covering the handler module and API routes
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
migrations/014_tickets.cjs |
Creates the tickets table with indexes for guild/status and guild/user lookups |
src/modules/ticketHandler.js |
Core ticket logic: open/close/add/remove/autoclose, supporting both thread and channel modes |
src/commands/ticket.js |
/ticket slash command with subcommands for open, close, add, remove, and panel |
src/api/routes/tickets.js |
REST API endpoints: list tickets, ticket detail, and stats with pagination and filtering |
src/api/index.js |
Mounts the new tickets router |
src/modules/events.js |
Registers button/modal handlers for the ticket panel and close button |
src/modules/scheduler.js |
Integrates checkAutoClose into the scheduler polling loop |
web/src/types/config.ts |
Adds TicketsConfig interface and 'tickets' to ConfigSection union |
web/src/components/layout/sidebar.tsx |
Adds Tickets nav item to the dashboard sidebar |
web/src/components/dashboard/config-editor.tsx |
Adds the Tickets configuration card to the config editor |
web/src/app/dashboard/tickets/page.tsx |
Ticket list dashboard page with filters, stats, and pagination |
web/src/app/dashboard/tickets/[ticketId]/page.tsx |
Ticket detail page with transcript viewer |
web/src/app/api/guilds/[guildId]/tickets/route.ts |
Next.js API proxy for ticket list endpoint |
web/src/app/api/guilds/[guildId]/tickets/stats/route.ts |
Next.js API proxy for ticket stats endpoint |
web/src/app/api/guilds/[guildId]/tickets/[ticketId]/route.ts |
Next.js API proxy for ticket detail endpoint |
tests/modules/ticketHandler.test.js |
Unit tests for the ticketHandler module (both modes) |
tests/api/routes/tickets.test.js |
API route integration tests |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
| Filename | Overview |
|---|---|
| migrations/014_tickets.cjs | creates tickets table with proper indexes and check constraints for status field |
| src/modules/ticketHandler.js | core ticket logic supporting both thread and channel modes; includes auto-close, transcripts, and member management |
| src/commands/ticket.js | slash command implementation with proper validation and context checks for all subcommands |
| src/api/routes/tickets.js | REST endpoints for ticket list, detail, and stats with proper auth, rate limiting, and parameterized queries |
| web/src/app/dashboard/tickets/page.tsx | ticket list view with filters, pagination, stats cards, and proper locale handling |
| tests/modules/ticketHandler.test.js | comprehensive tests for ticket handler covering both thread and channel modes with 85%+ branch coverage |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[User clicks Open Ticket button] --> B{Ticket system enabled?}
B -->|No| C[Show error message]
B -->|Yes| D[Show modal for topic input]
D --> E[User submits topic]
E --> F{Check max open tickets}
F -->|Limit reached| G[Show error message]
F -->|Under limit| H{Config mode?}
H -->|thread| I[Create private thread]
H -->|channel| J[Create text channel with permissions]
I --> K[Add user & support role members]
J --> K
K --> L[Insert ticket into database]
L --> M[Post ticket embed with close button]
M --> N[Ticket active - users interact]
N --> O{User clicks close OR /ticket close}
N --> P{48h+ inactivity?}
P -->|Yes| Q[Send warning message]
Q --> R{72h+ inactivity?}
R -->|Yes| S[Auto-close ticket]
O --> T[Fetch last 100 messages as transcript]
S --> T
T --> U[Update DB: status=closed, save transcript]
U --> V[Post close embed]
V --> W{Mode?}
W -->|thread| X[Archive thread]
W -->|channel| Y[Delete channel after 10s delay]
X --> Z[Send transcript to log channel]
Y --> Z
Z --> AA[Ticket closed]
Last reviewed commit: 03a5c34
|
Note Docstrings generation - SUCCESS |
|
Note Unit test generation is a public access feature. Expect some limitations and changes as we gather feedback and continue to improve it. Generating unit tests... This may take up to 20 minutes. |
- Add mode: 'thread' | 'channel' to TICKET_DEFAULTS - openTicket: channel mode creates text channel with permission overrides (deny @everyone ViewChannel, allow user/support/bot with appropriate perms) - closeTicket: channel mode deletes channel after 10s delay (vs archive) - addMember/removeMember: channel mode uses permissionOverwrites - checkAutoClose: accepts both threads and text channels - ticket.js: isTicketContext() replaces strict isThread() check
- Add mode: 'thread' | 'channel' to TicketsConfig type - Add 'Ticket Mode' select dropdown in tickets config section - Thread mode (default) creates private threads - Channel mode creates dedicated text channels with permission overrides
- Channel mode: openTicket creates text channel with permission overrides - Channel mode: closeTicket deletes channel after delay (not archive) - Channel mode: addMember uses permissionOverwrites.edit - Channel mode: removeMember uses permissionOverwrites.delete - Channel mode: checkAutoClose works with text channels - All existing thread-mode tests preserved and passing
The close button handler only checked isThread(), rejecting channel-mode tickets. Now accepts both thread and GuildText channel contexts. Renamed 'thread' to 'ticketChannel' for clarity and updated error message to mention both channel and thread.
…tion bypass isTicketContext() previously returned true for ANY GuildText channel, allowing /ticket add and /ticket remove to modify permission overwrites on unrelated channels. Now queries the database to verify the channel has an open ticket before allowing operations. Also: fixed permission error message to mention both moderator and administrator roles, and removed unused ticketConfig param from handleOpen() call.
- Remove unused guildConfig in registerTicketOpenButtonHandler (events.js) - Fix misplaced JSDoc: challenge doc was on TicketsConfig instead of ChallengesConfig - Remove unused Clock import from tickets page
…e guild.members.me - Add index on (thread_id, status) to support closeTicket() query - Validate ticketId is a valid integer before DB query (returns 400 on NaN) - Use guild.members.me?.id ?? guild.client.user.id for null safety
…arning detection
- checkAutoClose now filters tickets by guilds the bot is actually in
- Scheduler runs checkAutoClose every 5th tick (5 min) instead of every 60s
- Replace fragile msg.content.includes('auto-close') with module-level
warningsSent Set keyed by ticket ID
Changed 'A private thread will be created' to 'A private ticket will be opened' so the panel works correctly for both thread and channel modes.
…ort, trim - Fix infinite spinner when guildId/ticketId absent (set loading=false) - Fix formatDuration: >= 24 hours shows days instead of > 24 - Add keyboard accessibility (Enter/Space) to ticket table rows - Add AbortController to stats fetch for guild switch cleanup - Trim whitespace on ticket config ID inputs before null-coalescing
…ut limitation - Skip CategoryChannel when resolving parent for thread creation (only GuildText/GuildAnnouncement support threads.create()) - Add null guard for guild.members.me in fallback channel search - Document known limitation: setTimeout channel deletion won't survive process restarts
- buildTicketPanel: make description dynamic based on ticket mode
(channel → 'A private channel will be created...',
thread → 'A private thread will be created...')
Pass guildId from call site so config can be resolved.
- events.js: move ModalBuilder, TextInputBuilder, TextInputStyle,
ActionRowBuilder from dynamic import() to top-level static import;
eliminates unnecessary async resolution on every button click.
- checkAutoClose: filter bot messages when computing lastActivity
so the warning message itself does not reset the inactivity timer,
which would create an infinite warning loop on bot restarts.
- migrations/014_tickets.cjs: add CHECK (status IN ('open', 'closed'))
constraint to match the pattern from 010_reviews.cjs.
- Fix stale auto-close test: checkAutoClose returns early when no guilds, so mock query count is 0 not 1 - Remove double mock for thread.messages.fetch (only one fetch call) - Fix pre-existing checkAutoClose test failures: use Array.from() fallback for Map.find() since plain Map lacks Collection.find() - Fix pre-existing ticket command test failures: mock getPool with a query that returns rows for thread1 (valid ticket) vs channel1 (not a ticket) - Fix panel rejection error message mismatch in ticket command tests - Filter GuildAnnouncement channels in thread mode: only GuildText supports PrivateThread creation - Sanitize user.username for channel-mode ticket names (lowercase, alphanumeric + hyphens, max 100 chars) - Remove role='link' from <tr> element in tickets dashboard page - Update category placeholder text to 'Category for tickets' (mode-agnostic) - Restructure Tickets card to use CardHeader for title+toggle, CardContent for fields (matches other config section patterns)
914e2ae to
03a5c34
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 19 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Close the ticket | ||
| await closeTicket(channel, client.user, 'Auto-closed due to inactivity'); |
There was a problem hiding this comment.
The checkAutoClose function at line 491 calls closeTicket(channel, client.user, ...). However, client.user is a ClientUser object, not a User object — and closeTicket accesses closer.id, which exists on ClientUser. But the closeTicket function also does <@${closer.id}> in the embed (line 315) and accesses closer.id in the DB update. While ClientUser does have an id, the function's JSDoc types say it expects import('discord.js').User. This could cause issues with any future code that relies on user-specific methods not available on ClientUser. The JSDoc for closeTicket should be updated to accept User | ClientUser, or client.user should be cast appropriately.
| // Close the ticket | |
| await closeTicket(channel, client.user, 'Auto-closed due to inactivity'); | |
| // Close the ticket using the bot user as the closer. | |
| /** @type {import('discord.js').User} */ | |
| const autoCloserUser = client.user; | |
| await closeTicket(channel, autoCloserUser, 'Auto-closed due to inactivity'); |
| // Auto-close inactive support tickets (every 5 minutes / 5th tick) | ||
| tickCount++; | ||
| if (tickCount % 5 === 0) { | ||
| await checkAutoClose(client); | ||
| } |
There was a problem hiding this comment.
The tickCount variable in scheduler.js (line 23) is a module-level variable that is never reset. On a long-running bot process, tickCount will eventually overflow JavaScript's Number.MAX_SAFE_INTEGER after trillions of minutes of operation. While this is not a practical concern in the near term, using tickCount % 5 === 0 after the increment also means auto-close never runs on the very first tick (tick 1). If you want auto-close to run immediately on startup (first tick), the condition should be checked before incrementing. This is a minor semantic issue but may confuse future maintainers.
| } | ||
|
|
||
| module.exports = { up }; |
There was a problem hiding this comment.
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.
| /** 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 */ |
There was a problem hiding this comment.
The warningsSent in-memory Set at line 53 tracks ticket IDs that have received auto-close warnings. This state is lost on process restart. After a bot restart, tickets that were already warned will receive a second warning before being closed — and worse, they may be closed sooner than expected because the auto-close timer resets from when the process restarted, not from when the warning was sent. This is a known limitation but it is not documented in the code. The comment only describes the "process run" scope but doesn't describe the behavior after a restart (incorrect close timing). Consider adding a note about this limitation in the comment.
| /** Track ticket IDs that have received an auto-close warning in this process run */ | |
| /** | |
| * Tracks ticket IDs that have received an auto-close warning during this process run. | |
| * | |
| * NOTE: This state is stored in-memory only and is lost when the bot process restarts. | |
| * After a restart, tickets that were already warned may receive a second warning, | |
| * and the auto-close timing will effectively be recalculated from the restart time, | |
| * which can cause tickets to close sooner than the intended interval after the | |
| * original warning. | |
| */ |
Summary
Complete support ticket system for member support. Configurable via dashboard — choose between private threads or dedicated channels.
Features
/ticket open [topic]— Opens a ticket (thread or channel based on config)/ticket close [reason]— Closes ticket, saves transcript, archives/deletes/ticket add @user//ticket remove @user— Manage ticket membership/ticket panel [channel]— Posts persistent "Open Ticket" button panel (admin only)thread(private threads, default) orchannel(dedicated text channel with permission overrides)Files (20+ new)
migrations/014_tickets.cjs— tickets table with indexessrc/modules/ticketHandler.js— Core logic (open/close/add/remove/autoclose)src/commands/ticket.js— Slash command with subcommandssrc/api/routes/tickets.js— REST API (list, detail, stats)web/src/app/dashboard/tickets/page.tsx— Ticket list with filtersweb/src/app/dashboard/tickets/[ticketId]/page.tsx— Detail with transcriptTests
Config Options
{ "tickets": { "enabled": true, "mode": "thread", // "thread" or "channel" "supportRole": "role-id", "category": "category-id", "autoCloseHours": 48, "transcriptChannel": "channel-id", "maxOpenPerUser": 3 } }Closes #134