Add support tickets that work with private threads

Members open a ticket from a panel in the ticket channel: one button per
category (a dropdown past five), then a short form. Each ticket is a private
thread named after the category, number and opener, with a first message
that mentions the category's staff role so its members join, and Claim and
Close buttons.

- Claim: staff (the category's role, or Manage Threads / Manage Server)
  take a ticket; the first message shows who has it.
- Close: the opener or staff close it, with an optional reason. The thread
  is locked and archived, and the log channel gets a summary and a .txt
  transcript of the whole thread.
- /ticket add|remove|claim|close inside a ticket; /tickets setup,
  /tickets category add|remove|list and /tickets panel for admins.
- Per-member limit on open tickets (default 1), per-server ticket numbers
  handed out atomically, a deleted thread closes its ticket.
- The dashboard asks the bot to post or refresh the panel through
  ticket_commands (LISTEN/NOTIFY, like music); the listener reconnects if
  the database connection drops.
- Setup warns about missing bot permissions in the ticket and log channels.

The flows live in internal/tickets behind small Discord and store
interfaces, with unit tests on fakes; the repo and the listener have
integration tests (-tags integration, TICKETS_TEST_DB).

Needs the ticket tables (velox-dashboard-api migration 000016_tickets).
.gitignore gains the /tldr benchmark rules so private chat exports and
benchmark results on disk can't be committed from this branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
FernandoJVideiraandClaude Opus 5.5 committed 2026-10-20 19:31:52 +01:00
1 parent b1ea5f17e3
commit d294b3463b
20 files changed
+2800

No files matched your search

@@ -0,0 +1,206 @@
//go:build integration
// Runs against a real Postgres with the dashboard API's migrations applied:
//
// TICKETS_TEST_DB=postgres://... go test -tags integration ./internal/db/repos/ticketrepo
package ticketrepo
import (
"context"
"database/sql"
"os"
"sync"
"testing"
_ "github.com/jackc/pgx/v5/stdlib"
)
func testRepo(t *testing.T) (*Repo, int64) {
t.Helper()
dsn := os.Getenv("TICKETS_TEST_DB")
if dsn == "" {
t.Skip("TICKETS_TEST_DB not set")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
const guild = 424242
for _, q := range []string{
`DELETE FROM tickets WHERE guild_id = $1`,
`DELETE FROM ticket_categories WHERE guild_id = $1`,
`DELETE FROM ticket_settings WHERE guild_id = $1`,
`DELETE FROM ticket_commands WHERE guild_id = $1`,
} {
if _, err := db.Exec(q, guild); err != nil {
t.Fatal(err)
}
}
return NewRepo(db), guild
}
func TestSettingsDefaultsAndChannels(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
s, err := r.GetSettings(ctx, g)
if err != nil || s.Configured || !s.Enabled || s.PanelTitle != DefaultPanelTitle || s.MaxOpenPerUser != 1 {
t.Fatalf("defaults wrong: %+v %v", s, err)
}
if err := r.SetChannels(ctx, g, 111, 222); err != nil {
t.Fatal(err)
}
if err := r.SetPanelMessage(ctx, g, 333); err != nil {
t.Fatal(err)
}
s, _ = r.GetSettings(ctx, g)
if !s.Configured || s.ChannelID != 111 || s.LogChannelID != 222 || s.PanelMessageID != 333 {
t.Fatalf("saved settings wrong: %+v", s)
}
// Clearing the log channel.
_ = r.SetChannels(ctx, g, 111, 0)
s, _ = r.GetSettings(ctx, g)
if s.LogChannelID != 0 {
t.Errorf("log channel not cleared: %+v", s)
}
}
func TestNextNumberIsUniqueUnderConcurrency(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
var mu sync.Mutex
seen := map[int]bool{}
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
n, err := r.NextNumber(ctx, g)
if err != nil {
t.Error(err)
return
}
mu.Lock()
defer mu.Unlock()
if seen[n] {
t.Errorf("number %d handed out twice", n)
}
seen[n] = true
}()
}
wg.Wait()
for n := 1; n <= 20; n++ {
if !seen[n] {
t.Errorf("number %d missing: numbers should be 1..20", n)
}
}
}
func TestCategories(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
a, err := r.AddCategory(ctx, Category{GuildID: g, Name: "Support", Emoji: "🛟", StaffRoleID: 9})
if err != nil {
t.Fatal(err)
}
b, _ := r.AddCategory(ctx, Category{GuildID: g, Name: "Appeal", Description: "Ban appeals"})
if a.Position != 0 || b.Position != 1 || b.Emoji != "" || b.StaffRoleID != 0 {
t.Fatalf("positions or defaults wrong: %+v %+v", a, b)
}
list, _ := r.ListCategories(ctx, g)
if len(list) != 2 || list[0].Name != "Support" || list[1].Description != "Ban appeals" {
t.Fatalf("list wrong: %+v", list)
}
if c, _ := r.GetCategory(ctx, g, a.ID); c == nil || c.Emoji != "🛟" {
t.Errorf("get wrong: %+v", c)
}
if c, _ := r.GetCategory(ctx, g+1, a.ID); c != nil {
t.Error("a category must not be readable from another guild")
}
if ok, _ := r.DeleteCategoryByName(ctx, g, "support"); !ok {
t.Error("delete should match case-insensitively")
}
if ok, _ := r.DeleteCategoryByName(ctx, g, "nope"); ok {
t.Error("deleting a missing category should report false")
}
}
func TestTicketLifecycle(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
cat, _ := r.AddCategory(ctx, Category{GuildID: g, Name: "Support"})
id, err := r.CreateTicket(ctx, Ticket{GuildID: g, Number: 1, CategoryID: cat.ID, CategoryName: "Support", ThreadID: 5001, IntroMessage: 6001, OpenerID: 77, Subject: "Help"})
if err != nil || id == 0 {
t.Fatal(err)
}
if n, _ := r.CountOpenByOpener(ctx, g, 77); n != 1 {
t.Errorf("open count = %d", n)
}
if holder, _ := r.Claim(ctx, 5001, 10); holder != 10 {
t.Errorf("first claim should win, holder = %d", holder)
}
if holder, _ := r.Claim(ctx, 5001, 11); holder != 10 {
t.Errorf("second claim must not take over, holder = %d", holder)
}
if ok, _ := r.Close(ctx, 5001, 10, "solved"); !ok {
t.Error("first close should succeed")
}
if ok, _ := r.Close(ctx, 5001, 11, ""); ok {
t.Error("closing twice must report false")
}
tk, _ := r.GetTicketByThread(ctx, 5001)
if tk == nil || tk.Status != StatusClosed || tk.ClosedBy != 10 || tk.CloseReason != "solved" || tk.ClosedAt.IsZero() || tk.ClaimedBy != 10 || tk.IntroMessage != 6001 {
t.Fatalf("closed ticket wrong: %+v", tk)
}
if n, _ := r.CountOpenByOpener(ctx, g, 77); n != 0 {
t.Errorf("closed ticket still counted as open")
}
if holder, _ := r.Claim(ctx, 5001, 12); holder != 0 {
t.Error("a closed ticket cannot be claimed")
}
// Deleting the category keeps the ticket and its copied name.
_, _ = r.DeleteCategoryByName(ctx, g, "Support")
tk, _ = r.GetTicketByThread(ctx, 5001)
if tk.CategoryID != 0 || tk.CategoryName != "Support" {
t.Errorf("ticket after category delete: %+v", tk)
}
if tk, _ := r.GetTicketByThread(ctx, 999); tk != nil {
t.Error("unknown thread should return nil")
}
}
func TestCommands(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
if _, err := r.db.Exec(`INSERT INTO ticket_commands (guild_id, command_type, requested_by) VALUES ($1, 'post_panel', 5)`, g); err != nil {
t.Fatal(err)
}
cmds, err := r.ListUnprocessedCommands(ctx)
if err != nil {
t.Fatal(err)
}
var mine *Command
for i := range cmds {
if cmds[i].GuildID == g {
mine = &cmds[i]
}
}
if mine == nil || mine.CommandType != "post_panel" || mine.RequestedBy != 5 {
t.Fatalf("command not listed: %+v", cmds)
}
_ = r.MarkCommandProcessed(ctx, mine.ID)
cmds, _ = r.ListUnprocessedCommands(ctx)
for _, c := range cmds {
if c.ID == mine.ID {
t.Error("processed command still listed")
}
}
}