- /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]>
413 lines
13 KiB
Go
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()
|
|
}
|