// 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" "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): return &Settings{ GuildID: guildID, Enabled: true, 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. 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) VALUES ($1, $2, $3) 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 }