Files
velox-bot/internal/db/repos/ticketrepo/repo.go
T
FernandoJVideiraandClaude Sonnet 5.5 acdb5ead35 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]>
2026-10-23 02:05:45 +01:00

413 lines
13 KiB
Go

// Package ticketrepo stores the ticket system's settings, categories, tickets
// and the dashboard's requests to the bot. Discord IDs are int64; 0 means
// "not set" for optional ones.
package ticketrepo
import (
"context"
"database/sql"
"errors"
"strconv"
"strings"
"time"
)
type Repo struct {
db *sql.DB
}
func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db}
}
const (
DefaultPanelTitle = "Need help?"
DefaultPanelDescription = "Pick a category below to open a private ticket with the staff team."
DefaultMaxOpenPerUser = 1
StatusOpen = "open"
StatusClosed = "closed"
)
type Settings struct {
GuildID int64
Enabled bool
ChannelID int64
LogChannelID int64
PanelMessageID int64
PanelTitle string
PanelDescription string
MaxOpenPerUser int
// Configured is false when the guild has no settings row yet; the other
// fields then hold the defaults.
Configured bool
}
type Category struct {
ID int64
GuildID int64
Name string
Emoji string
Description string
StaffRoleID int64
Position int
}
type Ticket struct {
ID int64
GuildID int64
Number int
CategoryID int64
CategoryName string
ThreadID int64
IntroMessage int64
OpenerID int64
Subject string
Status string
ClaimedBy int64
ClosedBy int64
CloseReason string
CreatedAt time.Time
ClosedAt time.Time // zero while open
}
type Command struct {
ID int64
GuildID int64
CommandType string
RequestedBy int64
}
func nullID(v int64) sql.NullInt64 {
return sql.NullInt64{Int64: v, Valid: v != 0}
}
func nullText(v string) sql.NullString {
return sql.NullString{String: v, Valid: v != ""}
}
func (r *Repo) GetSettings(ctx context.Context, guildID int64) (*Settings, error) {
const q = `
SELECT enabled, channel_id, log_channel_id, panel_message_id, panel_title, panel_description, max_open_per_user
FROM ticket_settings WHERE guild_id = $1`
var ch, logCh, panel sql.NullInt64
s := &Settings{GuildID: guildID}
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&s.Enabled, &ch, &logCh, &panel, &s.PanelTitle, &s.PanelDescription, &s.MaxOpenPerUser)
switch {
case errors.Is(err, sql.ErrNoRows):
// Modules start off until the server sets them up.
return &Settings{
GuildID: guildID,
Enabled: false,
PanelTitle: DefaultPanelTitle,
PanelDescription: DefaultPanelDescription,
MaxOpenPerUser: DefaultMaxOpenPerUser,
}, nil
case err != nil:
return nil, err
}
s.ChannelID, s.LogChannelID, s.PanelMessageID = ch.Int64, logCh.Int64, panel.Int64
s.Configured = true
return s, nil
}
// SetChannels saves where tickets live and where transcripts go. A zero log
// channel clears it. Setting tickets up for the first time turns them on
// (they start off); after that, the on/off switch is left alone.
func (r *Repo) SetChannels(ctx context.Context, guildID, channelID, logChannelID int64) error {
const q = `
INSERT INTO ticket_settings (guild_id, channel_id, log_channel_id, enabled)
VALUES ($1, $2, $3, TRUE)
ON CONFLICT (guild_id) DO UPDATE SET
channel_id = EXCLUDED.channel_id,
log_channel_id = EXCLUDED.log_channel_id`
_, err := r.db.ExecContext(ctx, q, guildID, nullID(channelID), nullID(logChannelID))
return err
}
func (r *Repo) SetPanelMessage(ctx context.Context, guildID, messageID int64) error {
const q = `
INSERT INTO ticket_settings (guild_id, panel_message_id) VALUES ($1, $2)
ON CONFLICT (guild_id) DO UPDATE SET panel_message_id = EXCLUDED.panel_message_id`
_, err := r.db.ExecContext(ctx, q, guildID, nullID(messageID))
return err
}
// NextNumber hands out the guild's next ticket number. It is a single
// statement, so two tickets opened at the same moment never share a number.
func (r *Repo) NextNumber(ctx context.Context, guildID int64) (int, error) {
const q = `
INSERT INTO ticket_settings (guild_id, next_number) VALUES ($1, 2)
ON CONFLICT (guild_id) DO UPDATE SET next_number = ticket_settings.next_number + 1
RETURNING next_number - 1`
var n int
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&n)
return n, err
}
const categoryCols = `id, guild_id, name, COALESCE(emoji, ''), description, COALESCE(staff_role_id, 0), position`
func scanCategory(row interface{ Scan(...any) error }) (Category, error) {
var c Category
err := row.Scan(&c.ID, &c.GuildID, &c.Name, &c.Emoji, &c.Description, &c.StaffRoleID, &c.Position)
return c, err
}
func (r *Repo) ListCategories(ctx context.Context, guildID int64) ([]Category, error) {
q := `SELECT ` + categoryCols + ` FROM ticket_categories WHERE guild_id = $1 ORDER BY position, id`
rows, err := r.db.QueryContext(ctx, q, guildID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Category
for rows.Next() {
c, err := scanCategory(rows)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// GetCategory returns nil when the category does not exist in that guild.
func (r *Repo) GetCategory(ctx context.Context, guildID, id int64) (*Category, error) {
q := `SELECT ` + categoryCols + ` FROM ticket_categories WHERE guild_id = $1 AND id = $2`
c, err := scanCategory(r.db.QueryRowContext(ctx, q, guildID, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &c, nil
}
// AddCategory appends a category after the existing ones.
func (r *Repo) AddCategory(ctx context.Context, c Category) (*Category, error) {
q := `
INSERT INTO ticket_categories (guild_id, name, emoji, description, staff_role_id, position)
VALUES ($1, $2, $3, $4, $5,
(SELECT COALESCE(MAX(position) + 1, 0) FROM ticket_categories WHERE guild_id = $1))
RETURNING ` + categoryCols
out, err := scanCategory(r.db.QueryRowContext(ctx, q, c.GuildID, c.Name, nullText(c.Emoji), c.Description, nullID(c.StaffRoleID)))
if err != nil {
return nil, err
}
return &out, nil
}
// DeleteCategoryByName removes a category (case-insensitively) and reports
// whether one was found. Tickets keep their copied category name.
func (r *Repo) DeleteCategoryByName(ctx context.Context, guildID int64, name string) (bool, error) {
const q = `DELETE FROM ticket_categories WHERE guild_id = $1 AND lower(name) = lower($2)`
res, err := r.db.ExecContext(ctx, q, guildID, name)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
func (r *Repo) CountOpenByOpener(ctx context.Context, guildID, openerID int64) (int, error) {
const q = `SELECT COUNT(*) FROM tickets WHERE guild_id = $1 AND opener_id = $2 AND status = 'open'`
var n int
err := r.db.QueryRowContext(ctx, q, guildID, openerID).Scan(&n)
return n, err
}
func (r *Repo) CreateTicket(ctx context.Context, t Ticket) (int64, error) {
const q = `
INSERT INTO tickets (guild_id, number, category_id, category_name, thread_id, intro_message_id, opener_id, subject)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id`
var id int64
err := r.db.QueryRowContext(ctx, q, t.GuildID, t.Number, nullID(t.CategoryID), t.CategoryName, t.ThreadID, nullID(t.IntroMessage), t.OpenerID, t.Subject).Scan(&id)
return id, err
}
const ticketCols = `id, guild_id, number, COALESCE(category_id, 0), category_name, thread_id, COALESCE(intro_message_id, 0), opener_id, subject, status,
COALESCE(claimed_by, 0), COALESCE(closed_by, 0), COALESCE(close_reason, ''), created_at, closed_at`
func scanTicket(row interface{ Scan(...any) error }) (Ticket, error) {
var t Ticket
var closedAt sql.NullTime
err := row.Scan(&t.ID, &t.GuildID, &t.Number, &t.CategoryID, &t.CategoryName, &t.ThreadID, &t.IntroMessage, &t.OpenerID, &t.Subject, &t.Status,
&t.ClaimedBy, &t.ClosedBy, &t.CloseReason, &t.CreatedAt, &closedAt)
t.ClosedAt = closedAt.Time
return t, err
}
// GetTicketByThread returns nil when the thread is not a ticket.
func (r *Repo) GetTicketByThread(ctx context.Context, threadID int64) (*Ticket, error) {
q := `SELECT ` + ticketCols + ` FROM tickets WHERE thread_id = $1`
t, err := scanTicket(r.db.QueryRowContext(ctx, q, threadID))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &t, nil
}
// Claim sets the ticket's handler unless someone already has it, and returns
// whoever holds it afterwards (the caller when the claim worked).
func (r *Repo) Claim(ctx context.Context, threadID, staffID int64) (int64, error) {
const q = `
UPDATE tickets SET claimed_by = COALESCE(claimed_by, $2)
WHERE thread_id = $1 AND status = 'open'
RETURNING claimed_by`
var holder int64
err := r.db.QueryRowContext(ctx, q, threadID, staffID).Scan(&holder)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
return holder, err
}
// Close marks an open ticket closed and reports whether this call closed it,
// so two people pressing Close at once only produce one transcript.
func (r *Repo) Close(ctx context.Context, threadID, closedBy int64, reason string) (bool, error) {
const q = `
UPDATE tickets SET status = 'closed', closed_by = $2, close_reason = $3, closed_at = NOW()
WHERE thread_id = $1 AND status = 'open'`
res, err := r.db.ExecContext(ctx, q, threadID, nullID(closedBy), nullText(reason))
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
func (r *Repo) ListUnprocessedCommands(ctx context.Context) ([]Command, error) {
const q = `SELECT id, guild_id, command_type, requested_by FROM ticket_commands WHERE processed_at IS NULL ORDER BY id`
rows, err := r.db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Command
for rows.Next() {
var c Command
if err := rows.Scan(&c.ID, &c.GuildID, &c.CommandType, &c.RequestedBy); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
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()
}