Tickets: settings, category edit and ticket list from Discord; NOTIFY triggers in schema.sql

- /tickets settings (on or off, panel title and text, open tickets per member), /tickets category edit, /tickets list
- schema.sql now creates the NOTIFY triggers, so the integration tests run on a database built from it, and two stale expectations (tickets start off) are fixed

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
FernandoJVideiraandClaude Sonnet 5.5 committed 2026-10-23 02:05:45 +01:00
1 parent d95ebf473e
commit acdb5ead35
6 files changed
+483 -20

No files matched your search

+109
View File
@@ -7,6 +7,8 @@ import (
"context"
"database/sql"
"errors"
"strconv"
"strings"
"time"
)
@@ -301,3 +303,110 @@ func (r *Repo) MarkCommandProcessed(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `UPDATE ticket_commands SET processed_at = NOW() WHERE id = $1`, id)
return err
}
// SettingsPatch changes ticket settings. A nil field is left as it is.
type SettingsPatch struct {
Enabled *bool
PanelTitle *string
PanelDescription *string
MaxOpenPerUser *int
}
// UpdateSettings applies the fields that are set. The server gets a settings
// row if it has none yet, with everything else at its default and the system
// still off, until /tickets setup or this turns it on.
func (r *Repo) UpdateSettings(ctx context.Context, guildID int64, p SettingsPatch) error {
if _, err := r.db.ExecContext(ctx, `INSERT INTO ticket_settings (guild_id) VALUES ($1) ON CONFLICT DO NOTHING`, guildID); err != nil {
return err
}
var sets []string
args := []any{guildID}
add := func(col string, v any) {
args = append(args, v)
sets = append(sets, col+` = $`+strconv.Itoa(len(args)))
}
if p.Enabled != nil {
add("enabled", *p.Enabled)
}
if p.PanelTitle != nil {
add("panel_title", *p.PanelTitle)
}
if p.PanelDescription != nil {
add("panel_description", *p.PanelDescription)
}
if p.MaxOpenPerUser != nil {
add("max_open_per_user", *p.MaxOpenPerUser)
}
if len(sets) == 0 {
return nil
}
_, err := r.db.ExecContext(ctx, `UPDATE ticket_settings SET `+strings.Join(sets, ", ")+` WHERE guild_id = $1`, args...)
return err
}
// CategoryPatch changes a category. A nil field is left as it is; an empty
// emoji or description clears it, and a zero StaffRoleID clears the role.
type CategoryPatch struct {
Name *string
Emoji *string
Description *string
StaffRoleID *int64
}
// UpdateCategory applies the fields that are set and returns the category as
// saved, or nil when there is none with that ID in the guild.
func (r *Repo) UpdateCategory(ctx context.Context, guildID, id int64, p CategoryPatch) (*Category, error) {
var sets []string
args := []any{guildID, id}
add := func(col string, v any) {
args = append(args, v)
sets = append(sets, col+` = $`+strconv.Itoa(len(args)))
}
if p.Name != nil {
add("name", *p.Name)
}
if p.Emoji != nil {
add("emoji", nullText(*p.Emoji))
}
if p.Description != nil {
add("description", *p.Description)
}
if p.StaffRoleID != nil {
add("staff_role_id", nullID(*p.StaffRoleID))
}
if len(sets) == 0 {
return r.GetCategory(ctx, guildID, id)
}
q := `UPDATE ticket_categories SET ` + strings.Join(sets, ", ") + ` WHERE guild_id = $1 AND id = $2 RETURNING ` + categoryCols
c, err := scanCategory(r.db.QueryRowContext(ctx, q, args...))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &c, nil
}
// ListTickets returns a guild's most recent tickets with the given status
// ("open" or "closed"), newest first.
func (r *Repo) ListTickets(ctx context.Context, guildID int64, status string, limit int) ([]Ticket, error) {
if limit < 1 || limit > 100 {
limit = 25
}
q := `SELECT ` + ticketCols + ` FROM tickets WHERE guild_id = $1 AND status = $2 ORDER BY number DESC LIMIT $3`
rows, err := r.db.QueryContext(ctx, q, guildID, status, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Ticket
for rows.Next() {
t, err := scanTicket(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
@@ -1,6 +1,6 @@
//go:build integration
// Runs against a real Postgres with the dashboard API's migrations applied:
// Runs against a real Postgres with schema.sql loaded:
//
// TICKETS_TEST_DB=postgres://... go test -tags integration ./internal/db/repos/ticketrepo
package ticketrepo
@@ -46,7 +46,7 @@ func TestSettingsDefaultsAndChannels(t *testing.T) {
ctx := context.Background()
s, err := r.GetSettings(ctx, g)
if err != nil || s.Configured || !s.Enabled || s.PanelTitle != DefaultPanelTitle || s.MaxOpenPerUser != 1 {
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 {
@@ -204,3 +204,84 @@ func TestCommands(t *testing.T) {
}
}
}
func TestUpdateSettingsCategoriesAndList(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
str := func(v string) *string { return &v }
num := func(v int) *int { return &v }
on := true
// Changing the text of a server that never set tickets up creates its row
// and leaves the system off.
if err := r.UpdateSettings(ctx, g, SettingsPatch{PanelTitle: str("Help desk"), MaxOpenPerUser: num(3)}); err != nil {
t.Fatal(err)
}
st, _ := r.GetSettings(ctx, g)
if st.Enabled || st.PanelTitle != "Help desk" || st.MaxOpenPerUser != 3 || st.PanelDescription != DefaultPanelDescription {
t.Errorf("settings: %+v", st)
}
if err := r.UpdateSettings(ctx, g, SettingsPatch{Enabled: &on}); err != nil {
t.Fatal(err)
}
if st, _ = r.GetSettings(ctx, g); !st.Enabled || st.PanelTitle != "Help desk" {
t.Errorf("enabling keeps the rest: %+v", st)
}
if err := r.UpdateSettings(ctx, g, SettingsPatch{MaxOpenPerUser: num(11)}); err == nil {
t.Error("the database refuses more than 10 open tickets per member")
}
if err := r.UpdateSettings(ctx, g, SettingsPatch{}); err != nil {
t.Errorf("an empty patch is fine: %v", err)
}
cat, err := r.AddCategory(ctx, Category{GuildID: g, Name: "Support", Emoji: "🎫", Description: "Questions", StaffRoleID: 99})
if err != nil {
t.Fatal(err)
}
other, err := r.AddCategory(ctx, Category{GuildID: g, Name: "Report"})
if err != nil {
t.Fatal(err)
}
zero := int64(0)
empty := ""
got, err := r.UpdateCategory(ctx, g, cat.ID, CategoryPatch{Name: str("Help"), Emoji: &empty, StaffRoleID: &zero})
if err != nil || got == nil {
t.Fatalf("update: %v %v", got, err)
}
if got.Name != "Help" || got.Emoji != "" || got.StaffRoleID != 0 || got.Description != "Questions" {
t.Errorf("only the named fields change, and empty clears: %+v", got)
}
if got, _ = r.UpdateCategory(ctx, g, cat.ID, CategoryPatch{StaffRoleID: func() *int64 { v := int64(5); return &v }()}); got.StaffRoleID != 5 {
t.Errorf("setting the role: %+v", got)
}
if gone, err := r.UpdateCategory(ctx, g+1, cat.ID, CategoryPatch{Name: str("x")}); err != nil || gone != nil {
t.Errorf("another server's category can't be edited: %v %v", gone, err)
}
if _, err := r.UpdateCategory(ctx, g, other.ID, CategoryPatch{Name: str("")}); err == nil {
t.Error("the database refuses an empty name")
}
for n, status := range []string{"open", "open", "closed"} {
id, err := r.CreateTicket(ctx, Ticket{GuildID: g, Number: n + 1, CategoryID: cat.ID, CategoryName: "Help", ThreadID: int64(9100 + n), OpenerID: 7, Subject: "x"})
if err != nil {
t.Fatal(err)
}
if status == "closed" {
if _, err := r.Close(ctx, int64(9100+n), 7, "done"); err != nil {
t.Fatal(err)
}
}
_ = id
}
open, err := r.ListTickets(ctx, g, StatusOpen, 10)
if err != nil || len(open) != 2 || open[0].Number != 2 {
t.Errorf("open tickets, newest first: %v %v", open, err)
}
closed, _ := r.ListTickets(ctx, g, StatusClosed, 10)
if len(closed) != 1 {
t.Errorf("closed tickets: %v", closed)
}
if other, _ := r.ListTickets(ctx, g+1, StatusOpen, 10); len(other) != 0 {
t.Error("another server's tickets must not show")
}
}