This commit is contained in:
lfirmin
2026-05-10 18:07:51 +02:00
parent 50af2452b1
commit a5f888b3c1
59 changed files with 6682 additions and 108 deletions
+114
View File
@@ -0,0 +1,114 @@
package db
import (
"context"
"database/sql"
"time"
)
type AuditLogEntry struct {
ID int64
AdminID int64
Action string
EntityType string
EntityID sql.NullInt64
OldValue sql.NullString
NewValue sql.NullString
IPAddress string
CreatedAt time.Time
}
// Audit action constants
const (
AuditCreatePanel = "create_panel"
AuditUpdatePanel = "update_panel"
AuditDeletePanel = "delete_panel"
AuditCreateType = "create_type"
AuditUpdateType = "update_type"
AuditDeleteType = "delete_type"
AuditReorderTypes = "reorder_types"
AuditUpdateConvoc = "update_convocation"
AuditSendPanelDiscord = "send_panel_discord"
AuditDeletePanelMsg = "delete_panel_discord"
AuditRevokeSession = "revoke_session"
AuditRevokeAllSess = "revoke_all_sessions"
AuditAdminLogin = "admin_login"
AuditAdminLoginFail = "admin_login_failed"
AuditAdminLocked = "admin_locked"
)
type AuditFilter struct {
AdminID int64
Action string
EntityType string
From, To time.Time
}
type AuditLogRepo struct{ db *sql.DB }
func NewAuditLogRepo(db *sql.DB) *AuditLogRepo { return &AuditLogRepo{db: db} }
func (r *AuditLogRepo) Insert(ctx context.Context, e *AuditLogEntry) error {
e.CreatedAt = time.Now().UTC()
_, err := r.db.ExecContext(ctx,
`INSERT INTO audit_log(admin_id,action,entity_type,entity_id,old_value,new_value,ip_address,created_at)
VALUES(?,?,?,?,?,?,?,?)`,
e.AdminID, e.Action, e.EntityType, e.EntityID, e.OldValue, e.NewValue, e.IPAddress, e.CreatedAt)
return err
}
func (r *AuditLogRepo) List(ctx context.Context, f AuditFilter, page, pageSize int) ([]*AuditLogEntry, int, error) {
where := "WHERE 1=1"
args := []any{}
if f.AdminID != 0 {
where += " AND admin_id=?"
args = append(args, f.AdminID)
}
if f.Action != "" {
where += " AND action=?"
args = append(args, f.Action)
}
if f.EntityType != "" {
where += " AND entity_type=?"
args = append(args, f.EntityType)
}
if !f.From.IsZero() {
where += " AND created_at>=?"
args = append(args, f.From)
}
if !f.To.IsZero() {
where += " AND created_at<=?"
args = append(args, f.To)
}
var total int
countArgs := make([]any, len(args))
copy(countArgs, args)
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_log `+where, countArgs...).Scan(&total); err != nil {
return nil, 0, err
}
if page < 1 {
page = 1
}
offset := (page - 1) * pageSize
args = append(args, pageSize, offset)
rows, err := r.db.QueryContext(ctx,
`SELECT id,admin_id,action,entity_type,entity_id,old_value,new_value,ip_address,created_at
FROM audit_log `+where+` ORDER BY created_at DESC LIMIT ? OFFSET ?`, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []*AuditLogEntry
for rows.Next() {
var e AuditLogEntry
if err := rows.Scan(&e.ID, &e.AdminID, &e.Action, &e.EntityType, &e.EntityID,
&e.OldValue, &e.NewValue, &e.IPAddress, &e.CreatedAt); err != nil {
return nil, 0, err
}
out = append(out, &e)
}
return out, total, rows.Err()
}
+88
View File
@@ -0,0 +1,88 @@
package db
import (
"context"
"database/sql"
"time"
)
// ConvocationConfig holds per-guild convocation configuration.
type ConvocationConfig struct {
ID int64
GuildID string
CategoryID string
LogChannelID string
ModalEnabled bool
UpdatedAt time.Time
PanelChannelID string
PanelMessageID string
PanelEmbedTitle string
PanelEmbedDescription string
PanelEmbedColor string
}
// ConvocationConfigRepo handles CRUD for convocation_config.
type ConvocationConfigRepo struct{ db *sql.DB }
// NewConvocationConfigRepo creates a ConvocationConfigRepo.
func NewConvocationConfigRepo(db *sql.DB) *ConvocationConfigRepo {
return &ConvocationConfigRepo{db: db}
}
// Get returns the convocation config for the given guild.
// Returns an empty (zero-value) config without error if none exists yet.
func (r *ConvocationConfigRepo) Get(ctx context.Context, guildID string) (*ConvocationConfig, error) {
row := r.db.QueryRowContext(ctx,
`SELECT id,guild_id,category_id,log_channel_id,modal_enabled,updated_at,
panel_channel_id,panel_message_id,panel_embed_title,panel_embed_description,panel_embed_color
FROM convocation_config WHERE guild_id=?`, guildID)
var c ConvocationConfig
var modalEnabled int
err := row.Scan(&c.ID, &c.GuildID, &c.CategoryID, &c.LogChannelID, &modalEnabled, &c.UpdatedAt,
&c.PanelChannelID, &c.PanelMessageID, &c.PanelEmbedTitle, &c.PanelEmbedDescription, &c.PanelEmbedColor)
if err == sql.ErrNoRows {
c.GuildID = guildID
c.ModalEnabled = true
c.PanelEmbedTitle = "Créer une convocation"
c.PanelEmbedColor = "#5865f2"
return &c, nil
}
if err != nil {
return nil, err
}
c.ModalEnabled = modalEnabled != 0
return &c, nil
}
// Upsert inserts or replaces the convocation config for a guild.
func (r *ConvocationConfigRepo) Upsert(ctx context.Context, c *ConvocationConfig) error {
c.UpdatedAt = time.Now().UTC()
modalInt := 0
if c.ModalEnabled {
modalInt = 1
}
_, err := r.db.ExecContext(ctx,
`INSERT INTO convocation_config(guild_id,category_id,log_channel_id,modal_enabled,updated_at,
panel_channel_id,panel_message_id,panel_embed_title,panel_embed_description,panel_embed_color)
VALUES(?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(guild_id) DO UPDATE SET
category_id=excluded.category_id,
log_channel_id=excluded.log_channel_id,
modal_enabled=excluded.modal_enabled,
updated_at=excluded.updated_at,
panel_channel_id=excluded.panel_channel_id,
panel_embed_title=excluded.panel_embed_title,
panel_embed_description=excluded.panel_embed_description,
panel_embed_color=excluded.panel_embed_color`,
c.GuildID, c.CategoryID, c.LogChannelID, modalInt, c.UpdatedAt,
c.PanelChannelID, c.PanelMessageID, c.PanelEmbedTitle, c.PanelEmbedDescription, c.PanelEmbedColor)
return err
}
// SetConvocPanelMessage stores the Discord message ID for the convocation panel.
func (r *ConvocationConfigRepo) SetConvocPanelMessage(ctx context.Context, guildID, channelID, messageID string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE convocation_config SET panel_channel_id=?, panel_message_id=?, updated_at=? WHERE guild_id=?`,
channelID, messageID, time.Now().UTC(), guildID)
return err
}
+103
View File
@@ -0,0 +1,103 @@
package db
import (
"context"
"database/sql"
"time"
)
// ConvocationPanel is a single convocation panel embed+button entry.
type ConvocationPanel struct {
ID int64
GuildID string
EmbedTitle string
EmbedDesc string
EmbedColor string
ChannelID string
MessageID string
CreatedAt time.Time
UpdatedAt time.Time
}
type ConvocationPanelRepo struct{ db *sql.DB }
func NewConvocationPanelRepo(db *sql.DB) *ConvocationPanelRepo {
return &ConvocationPanelRepo{db: db}
}
func (r *ConvocationPanelRepo) List(ctx context.Context, guildID string) ([]*ConvocationPanel, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id,guild_id,embed_title,embed_description,embed_color,channel_id,message_id,created_at,updated_at
FROM convocation_panels WHERE guild_id=? ORDER BY id`, guildID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*ConvocationPanel
for rows.Next() {
p, err := scanConvocPanel(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func (r *ConvocationPanelRepo) GetByID(ctx context.Context, id int64) (*ConvocationPanel, error) {
row := r.db.QueryRowContext(ctx,
`SELECT id,guild_id,embed_title,embed_description,embed_color,channel_id,message_id,created_at,updated_at
FROM convocation_panels WHERE id=?`, id)
var p ConvocationPanel
err := row.Scan(&p.ID, &p.GuildID, &p.EmbedTitle, &p.EmbedDesc, &p.EmbedColor,
&p.ChannelID, &p.MessageID, &p.CreatedAt, &p.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return &p, err
}
func (r *ConvocationPanelRepo) Create(ctx context.Context, p *ConvocationPanel) error {
now := time.Now().UTC()
res, err := r.db.ExecContext(ctx,
`INSERT INTO convocation_panels(guild_id,embed_title,embed_description,embed_color,channel_id,message_id,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`,
p.GuildID, p.EmbedTitle, p.EmbedDesc, p.EmbedColor, p.ChannelID, p.MessageID, now, now)
if err != nil {
return err
}
p.ID, _ = res.LastInsertId()
p.CreatedAt = now
p.UpdatedAt = now
return nil
}
func (r *ConvocationPanelRepo) Update(ctx context.Context, p *ConvocationPanel) error {
now := time.Now().UTC()
_, err := r.db.ExecContext(ctx,
`UPDATE convocation_panels SET embed_title=?,embed_description=?,embed_color=?,channel_id=?,updated_at=? WHERE id=?`,
p.EmbedTitle, p.EmbedDesc, p.EmbedColor, p.ChannelID, now, p.ID)
if err == nil {
p.UpdatedAt = now
}
return err
}
func (r *ConvocationPanelRepo) SetMessage(ctx context.Context, id int64, messageID string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE convocation_panels SET message_id=?,updated_at=? WHERE id=?`,
messageID, time.Now().UTC(), id)
return err
}
func (r *ConvocationPanelRepo) Delete(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM convocation_panels WHERE id=?`, id)
return err
}
func scanConvocPanel(rows *sql.Rows) (*ConvocationPanel, error) {
var p ConvocationPanel
err := rows.Scan(&p.ID, &p.GuildID, &p.EmbedTitle, &p.EmbedDesc, &p.EmbedColor,
&p.ChannelID, &p.MessageID, &p.CreatedAt, &p.UpdatedAt)
return &p, err
}
+122
View File
@@ -63,8 +63,130 @@ func migrate(db *sql.DB) error {
// v2: user-supplied ticket title and description (from modal)
`ALTER TABLE tickets ADD COLUMN ticket_title TEXT;
ALTER TABLE tickets ADD COLUMN ticket_description TEXT;`,
// v3: panel web admin tables
`CREATE TABLE IF NOT EXISTS panel_admins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
discord_id TEXT NOT NULL UNIQUE,
discord_username TEXT NOT NULL DEFAULT '',
discord_avatar TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
totp_secret TEXT,
totp_enabled INTEGER NOT NULL DEFAULT 0,
is_superadmin INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS panel_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
csrf_token TEXT NOT NULL,
admin_id INTEGER NOT NULL REFERENCES panel_admins(id) ON DELETE CASCADE,
ip_address TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
last_activity DATETIME NOT NULL,
created_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON panel_sessions(token);
CREATE TABLE IF NOT EXISTS panel_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
embed_title TEXT NOT NULL DEFAULT '',
embed_description TEXT,
embed_color TEXT NOT NULL DEFAULT '#5865f2',
guild_id TEXT NOT NULL DEFAULT '',
channel_id TEXT,
message_id TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS panel_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
panel_id INTEGER NOT NULL REFERENCES panel_configs(id) ON DELETE CASCADE,
name TEXT NOT NULL,
button_label TEXT NOT NULL DEFAULT '',
button_color TEXT NOT NULL DEFAULT 'primary',
button_emoji TEXT NOT NULL DEFAULT '',
embed_color TEXT NOT NULL DEFAULT '#5865f2',
embed_title TEXT NOT NULL DEFAULT '',
embed_text TEXT NOT NULL DEFAULT '',
staff_role_id TEXT NOT NULL DEFAULT '',
category_id TEXT NOT NULL DEFAULT '',
claim_channel_id TEXT NOT NULL DEFAULT '',
log_channel_id TEXT NOT NULL DEFAULT '',
max_per_user INTEGER NOT NULL DEFAULT 1,
claim_mode INTEGER NOT NULL DEFAULT 1,
close_rule TEXT NOT NULL DEFAULT 'staff_only',
modal_enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE(panel_id, name)
);
CREATE TABLE IF NOT EXISTS convocation_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT NOT NULL UNIQUE,
category_id TEXT NOT NULL DEFAULT '',
log_channel_id TEXT NOT NULL DEFAULT '',
modal_enabled INTEGER NOT NULL DEFAULT 1,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id INTEGER NOT NULL REFERENCES panel_admins(id),
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id INTEGER,
old_value TEXT,
new_value TEXT,
ip_address TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_admin ON audit_log(admin_id);
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id);
CREATE TABLE IF NOT EXISTS user_blacklist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
discord_id TEXT NOT NULL UNIQUE,
reason TEXT,
blacklisted_by TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL
);`,
}
migrations = append(migrations,
// v4: embed image fields on panel_types
`ALTER TABLE panel_types ADD COLUMN thumbnail_url TEXT NOT NULL DEFAULT '';
ALTER TABLE panel_types ADD COLUMN image_url TEXT NOT NULL DEFAULT '';`,
// v5: embed image/thumbnail on panel_configs (panel-level)
`ALTER TABLE panel_configs ADD COLUMN embed_image TEXT NOT NULL DEFAULT '';
ALTER TABLE panel_configs ADD COLUMN embed_thumbnail TEXT NOT NULL DEFAULT '';`,
// v6: per-ticket guild + channel routing + convoc panel config
`ALTER TABLE tickets ADD COLUMN guild_id TEXT NOT NULL DEFAULT '';
ALTER TABLE tickets ADD COLUMN claim_channel_id TEXT NOT NULL DEFAULT '';
ALTER TABLE tickets ADD COLUMN log_channel_id TEXT NOT NULL DEFAULT '';
ALTER TABLE convocation_config ADD COLUMN panel_channel_id TEXT NOT NULL DEFAULT '';
ALTER TABLE convocation_config ADD COLUMN panel_message_id TEXT NOT NULL DEFAULT '';
ALTER TABLE convocation_config ADD COLUMN panel_embed_title TEXT NOT NULL DEFAULT 'Créer une convocation';
ALTER TABLE convocation_config ADD COLUMN panel_embed_description TEXT NOT NULL DEFAULT '';
ALTER TABLE convocation_config ADD COLUMN panel_embed_color TEXT NOT NULL DEFAULT '#5865f2';`,
// v7: per-ticket staff role + reup time, per-type reup time, convocation panels table
`ALTER TABLE tickets ADD COLUMN staff_role_id TEXT NOT NULL DEFAULT '';
ALTER TABLE tickets ADD COLUMN claim_reup_minutes INTEGER NOT NULL DEFAULT 0;
ALTER TABLE panel_types ADD COLUMN claim_reup_minutes INTEGER NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS convocation_panels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT NOT NULL,
embed_title TEXT NOT NULL DEFAULT 'Créer une convocation',
embed_description TEXT NOT NULL DEFAULT '',
embed_color TEXT NOT NULL DEFAULT '#5865f2',
channel_id TEXT NOT NULL DEFAULT '',
message_id TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_convoc_panels_guild ON convocation_panels(guild_id);`,
)
for i, m := range migrations {
v := i + 1
if v <= version {
+131
View File
@@ -0,0 +1,131 @@
package db
import (
"context"
"database/sql"
"time"
)
type PanelAdmin struct {
ID int64
DiscordID string
DiscordUsername string
DiscordAvatar string
PasswordHash string
TOTPSecret sql.NullString
TOTPEnabled bool
IsSuperadmin bool
CreatedAt time.Time
UpdatedAt time.Time
}
type PanelAdminRepo struct{ db *sql.DB }
func NewPanelAdminRepo(db *sql.DB) *PanelAdminRepo { return &PanelAdminRepo{db: db} }
const adminCols = `id,discord_id,discord_username,discord_avatar,password_hash,totp_secret,totp_enabled,is_superadmin,created_at,updated_at`
func (r *PanelAdminRepo) Create(ctx context.Context, discordID, username, passwordHash string, isSuperadmin bool) (*PanelAdmin, error) {
now := time.Now().UTC()
res, err := r.db.ExecContext(ctx,
`INSERT INTO panel_admins(discord_id,discord_username,password_hash,is_superadmin,created_at,updated_at)
VALUES(?,?,?,?,?,?)`,
discordID, username, passwordHash, boolInt(isSuperadmin), now, now)
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
return r.GetByID(ctx, id)
}
func (r *PanelAdminRepo) GetByID(ctx context.Context, id int64) (*PanelAdmin, error) {
row := r.db.QueryRowContext(ctx, `SELECT `+adminCols+` FROM panel_admins WHERE id=?`, id)
return scanAdmin(row)
}
func (r *PanelAdminRepo) GetByDiscordID(ctx context.Context, discordID string) (*PanelAdmin, error) {
row := r.db.QueryRowContext(ctx, `SELECT `+adminCols+` FROM panel_admins WHERE discord_id=?`, discordID)
return scanAdmin(row)
}
func (r *PanelAdminRepo) List(ctx context.Context) ([]*PanelAdmin, error) {
rows, err := r.db.QueryContext(ctx, `SELECT `+adminCols+` FROM panel_admins ORDER BY created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*PanelAdmin
for rows.Next() {
a, err := scanAdminRows(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func (r *PanelAdminRepo) UpdateDiscordProfile(ctx context.Context, id int64, username, avatar string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE panel_admins SET discord_username=?,discord_avatar=?,updated_at=? WHERE id=?`,
username, avatar, time.Now().UTC(), id)
return err
}
func (r *PanelAdminRepo) UpdatePassword(ctx context.Context, id int64, hash string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE panel_admins SET password_hash=?,updated_at=? WHERE id=?`,
hash, time.Now().UTC(), id)
return err
}
func (r *PanelAdminRepo) UpdateTOTP(ctx context.Context, id int64, secret string, enabled bool) error {
_, err := r.db.ExecContext(ctx,
`UPDATE panel_admins SET totp_secret=?,totp_enabled=?,updated_at=? WHERE id=?`,
secret, boolInt(enabled), time.Now().UTC(), id)
return err
}
func (r *PanelAdminRepo) ResetTOTP(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx,
`UPDATE panel_admins SET totp_secret=NULL,totp_enabled=0,updated_at=? WHERE id=?`,
time.Now().UTC(), id)
return err
}
func (r *PanelAdminRepo) Delete(ctx context.Context, discordID string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM panel_admins WHERE discord_id=?`, discordID)
return err
}
func scanAdmin(row *sql.Row) (*PanelAdmin, error) {
a, err := scanAdminFields(func(dest ...any) error { return row.Scan(dest...) })
if err == sql.ErrNoRows {
return nil, nil
}
return a, err
}
func scanAdminRows(rows *sql.Rows) (*PanelAdmin, error) {
return scanAdminFields(func(dest ...any) error { return rows.Scan(dest...) })
}
func scanAdminFields(scan func(...any) error) (*PanelAdmin, error) {
var a PanelAdmin
var totpEnabled, isSuperadmin int
err := scan(&a.ID, &a.DiscordID, &a.DiscordUsername, &a.DiscordAvatar,
&a.PasswordHash, &a.TOTPSecret, &totpEnabled, &isSuperadmin, &a.CreatedAt, &a.UpdatedAt)
if err != nil {
return nil, err
}
a.TOTPEnabled = totpEnabled != 0
a.IsSuperadmin = isSuperadmin != 0
return &a, nil
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
+282
View File
@@ -0,0 +1,282 @@
package db
import (
"context"
"database/sql"
"time"
)
type PanelConfig struct {
ID int64
Name string
EmbedTitle string
EmbedDescription sql.NullString
EmbedColor string
EmbedImage string
EmbedThumbnail string
GuildID string
ChannelID sql.NullString
MessageID sql.NullString
SortOrder int
CreatedAt time.Time
UpdatedAt time.Time
Types []*PanelType
}
type PanelType struct {
ID int64
PanelID int64
Name string
ButtonLabel string
ButtonColor string
ButtonEmoji string
EmbedColor string
EmbedTitle string
EmbedText string
ThumbnailURL string
ImageURL string
StaffRoleID string
CategoryID string
ClaimChannelID string
LogChannelID string
MaxPerUser int
ClaimMode int
ClaimReupMinutes int
CloseRule string
ModalEnabled bool
SortOrder int
CreatedAt time.Time
UpdatedAt time.Time
}
type PanelConfigRepo struct{ db *sql.DB }
func NewPanelConfigRepo(db *sql.DB) *PanelConfigRepo { return &PanelConfigRepo{db: db} }
const panelCols = `id,name,embed_title,embed_description,embed_color,embed_image,embed_thumbnail,guild_id,channel_id,message_id,sort_order,created_at,updated_at`
const typeCols = `id,panel_id,name,button_label,button_color,button_emoji,embed_color,embed_title,embed_text,thumbnail_url,image_url,` +
`staff_role_id,category_id,claim_channel_id,log_channel_id,max_per_user,claim_mode,claim_reup_minutes,close_rule,modal_enabled,sort_order,created_at,updated_at`
func (r *PanelConfigRepo) List(ctx context.Context) ([]*PanelConfig, error) {
rows, err := r.db.QueryContext(ctx, `SELECT `+panelCols+` FROM panel_configs ORDER BY sort_order,name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*PanelConfig
for rows.Next() {
p, err := scanPanelConfig(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Load types for each panel in one extra pass
for _, p := range out {
p.Types, _ = r.ListTypes(ctx, p.ID)
}
return out, nil
}
func (r *PanelConfigRepo) GetByID(ctx context.Context, id int64) (*PanelConfig, error) {
row := r.db.QueryRowContext(ctx, `SELECT `+panelCols+` FROM panel_configs WHERE id=?`, id)
var p PanelConfig
err := row.Scan(&p.ID, &p.Name, &p.EmbedTitle, &p.EmbedDescription, &p.EmbedColor,
&p.EmbedImage, &p.EmbedThumbnail, &p.GuildID, &p.ChannelID, &p.MessageID, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
types, err := r.ListTypes(ctx, id)
if err != nil {
return nil, err
}
p.Types = types
return &p, nil
}
func (r *PanelConfigRepo) Create(ctx context.Context, p *PanelConfig) error {
now := time.Now().UTC()
res, err := r.db.ExecContext(ctx,
`INSERT INTO panel_configs(name,embed_title,embed_description,embed_color,embed_image,embed_thumbnail,guild_id,channel_id,sort_order,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
p.Name, p.EmbedTitle, p.EmbedDescription, p.EmbedColor, p.EmbedImage, p.EmbedThumbnail,
p.GuildID, p.ChannelID, p.SortOrder, now, now)
if err != nil {
return err
}
p.ID, _ = res.LastInsertId()
p.CreatedAt = now
p.UpdatedAt = now
return nil
}
func (r *PanelConfigRepo) Update(ctx context.Context, p *PanelConfig) error {
now := time.Now().UTC()
_, err := r.db.ExecContext(ctx,
`UPDATE panel_configs SET name=?,embed_title=?,embed_description=?,embed_color=?,embed_image=?,embed_thumbnail=?,
guild_id=?,channel_id=?,sort_order=?,updated_at=? WHERE id=?`,
p.Name, p.EmbedTitle, p.EmbedDescription, p.EmbedColor, p.EmbedImage, p.EmbedThumbnail,
p.GuildID, p.ChannelID, p.SortOrder, now, p.ID)
if err == nil {
p.UpdatedAt = now
}
return err
}
func (r *PanelConfigRepo) Delete(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM panel_configs WHERE id=?`, id)
return err
}
// ReplaceTypes deletes all types for a panel and inserts the provided list.
func (r *PanelConfigRepo) ReplaceTypes(ctx context.Context, panelID int64, types []*PanelType) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint
if _, err := tx.ExecContext(ctx, `DELETE FROM panel_types WHERE panel_id=?`, panelID); err != nil {
return err
}
now := time.Now().UTC()
for i, t := range types {
t.PanelID = panelID
t.SortOrder = i
res, err := tx.ExecContext(ctx,
`INSERT INTO panel_types(panel_id,name,button_label,button_color,button_emoji,embed_color,embed_title,embed_text,thumbnail_url,image_url,
staff_role_id,category_id,claim_channel_id,log_channel_id,max_per_user,claim_mode,claim_reup_minutes,close_rule,modal_enabled,sort_order,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
t.PanelID, t.Name, t.ButtonLabel, t.ButtonColor, t.ButtonEmoji, t.EmbedColor, t.EmbedTitle, t.EmbedText, t.ThumbnailURL, t.ImageURL,
t.StaffRoleID, t.CategoryID, t.ClaimChannelID, t.LogChannelID, t.MaxPerUser, t.ClaimMode, t.ClaimReupMinutes,
t.CloseRule, boolInt(t.ModalEnabled), t.SortOrder, now, now)
if err != nil {
return err
}
t.ID, _ = res.LastInsertId()
}
return tx.Commit()
}
// ReorderTypes updates sort_order for the given type IDs in order.
func (r *PanelConfigRepo) ReorderTypes(ctx context.Context, panelID int64, typeIDs []int64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint
for i, id := range typeIDs {
if _, err := tx.ExecContext(ctx,
`UPDATE panel_types SET sort_order=? WHERE id=? AND panel_id=?`, i, id, panelID); err != nil {
return err
}
}
return tx.Commit()
}
func (r *PanelConfigRepo) SetDiscordMessage(ctx context.Context, id int64, channelID, messageID string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE panel_configs SET channel_id=?,message_id=?,updated_at=? WHERE id=?`,
channelID, messageID, time.Now().UTC(), id)
return err
}
func (r *PanelConfigRepo) GetByName(ctx context.Context, name string) (*PanelConfig, error) {
row := r.db.QueryRowContext(ctx, `SELECT `+panelCols+` FROM panel_configs WHERE name=?`, name)
var p PanelConfig
err := row.Scan(&p.ID, &p.Name, &p.EmbedTitle, &p.EmbedDescription, &p.EmbedColor,
&p.EmbedImage, &p.EmbedThumbnail, &p.GuildID, &p.ChannelID, &p.MessageID, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
p.Types, _ = r.ListTypes(ctx, p.ID)
return &p, nil
}
func (r *PanelConfigRepo) IsEmpty(ctx context.Context) (bool, error) {
var count int
err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM panel_configs`).Scan(&count)
return count == 0, err
}
// --- Types ---
func (r *PanelConfigRepo) ListTypes(ctx context.Context, panelID int64) ([]*PanelType, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT `+typeCols+` FROM panel_types WHERE panel_id=? ORDER BY sort_order,name`, panelID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*PanelType
for rows.Next() {
t, err := scanPanelType(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func (r *PanelConfigRepo) CreateType(ctx context.Context, t *PanelType) error {
now := time.Now().UTC()
res, err := r.db.ExecContext(ctx,
`INSERT INTO panel_types(panel_id,name,button_label,button_color,button_emoji,embed_color,embed_title,embed_text,thumbnail_url,image_url,
staff_role_id,category_id,claim_channel_id,log_channel_id,max_per_user,claim_mode,claim_reup_minutes,close_rule,modal_enabled,sort_order,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
t.PanelID, t.Name, t.ButtonLabel, t.ButtonColor, t.ButtonEmoji, t.EmbedColor, t.EmbedTitle, t.EmbedText, t.ThumbnailURL, t.ImageURL,
t.StaffRoleID, t.CategoryID, t.ClaimChannelID, t.LogChannelID, t.MaxPerUser, t.ClaimMode, t.ClaimReupMinutes,
t.CloseRule, boolInt(t.ModalEnabled), t.SortOrder, now, now)
if err != nil {
return err
}
t.ID, _ = res.LastInsertId()
t.CreatedAt = now
t.UpdatedAt = now
return nil
}
func (r *PanelConfigRepo) UpdateType(ctx context.Context, t *PanelType) error {
now := time.Now().UTC()
_, err := r.db.ExecContext(ctx,
`UPDATE panel_types SET name=?,button_label=?,button_color=?,button_emoji=?,embed_color=?,embed_title=?,embed_text=?,thumbnail_url=?,image_url=?,
staff_role_id=?,category_id=?,claim_channel_id=?,log_channel_id=?,max_per_user=?,claim_mode=?,claim_reup_minutes=?,close_rule=?,modal_enabled=?,sort_order=?,updated_at=?
WHERE id=?`,
t.Name, t.ButtonLabel, t.ButtonColor, t.ButtonEmoji, t.EmbedColor, t.EmbedTitle, t.EmbedText, t.ThumbnailURL, t.ImageURL,
t.StaffRoleID, t.CategoryID, t.ClaimChannelID, t.LogChannelID, t.MaxPerUser, t.ClaimMode, t.ClaimReupMinutes,
t.CloseRule, boolInt(t.ModalEnabled), t.SortOrder, now, t.ID)
return err
}
func (r *PanelConfigRepo) DeleteType(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM panel_types WHERE id=?`, id)
return err
}
func scanPanelConfig(rows *sql.Rows) (*PanelConfig, error) {
var p PanelConfig
err := rows.Scan(&p.ID, &p.Name, &p.EmbedTitle, &p.EmbedDescription, &p.EmbedColor,
&p.EmbedImage, &p.EmbedThumbnail, &p.GuildID, &p.ChannelID, &p.MessageID, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt)
return &p, err
}
func scanPanelType(rows *sql.Rows) (*PanelType, error) {
var t PanelType
var modalEnabled int
err := rows.Scan(&t.ID, &t.PanelID, &t.Name, &t.ButtonLabel, &t.ButtonColor, &t.ButtonEmoji,
&t.EmbedColor, &t.EmbedTitle, &t.EmbedText, &t.ThumbnailURL, &t.ImageURL,
&t.StaffRoleID, &t.CategoryID, &t.ClaimChannelID, &t.LogChannelID,
&t.MaxPerUser, &t.ClaimMode, &t.ClaimReupMinutes, &t.CloseRule, &modalEnabled, &t.SortOrder, &t.CreatedAt, &t.UpdatedAt)
t.ModalEnabled = modalEnabled != 0
return &t, err
}
+102
View File
@@ -0,0 +1,102 @@
package db
import (
"context"
"database/sql"
"time"
)
type PanelSession struct {
ID int64
Token string
CSRFToken string
AdminID int64
IPAddress string
UserAgent string
LastActivity time.Time
CreatedAt time.Time
}
type PanelSessionRepo struct{ db *sql.DB }
func NewPanelSessionRepo(db *sql.DB) *PanelSessionRepo { return &PanelSessionRepo{db: db} }
func (r *PanelSessionRepo) Create(ctx context.Context, s *PanelSession) error {
res, err := r.db.ExecContext(ctx,
`INSERT INTO panel_sessions(token,csrf_token,admin_id,ip_address,user_agent,last_activity,created_at)
VALUES(?,?,?,?,?,?,?)`,
s.Token, s.CSRFToken, s.AdminID, s.IPAddress, s.UserAgent, s.LastActivity, s.CreatedAt)
if err != nil {
return err
}
s.ID, _ = res.LastInsertId()
return nil
}
func (r *PanelSessionRepo) GetByToken(ctx context.Context, token string) (*PanelSession, error) {
row := r.db.QueryRowContext(ctx,
`SELECT id,token,csrf_token,admin_id,ip_address,user_agent,last_activity,created_at
FROM panel_sessions WHERE token=?`, token)
var s PanelSession
err := row.Scan(&s.ID, &s.Token, &s.CSRFToken, &s.AdminID,
&s.IPAddress, &s.UserAgent, &s.LastActivity, &s.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &s, nil
}
func (r *PanelSessionRepo) UpdateLastActivity(ctx context.Context, token string, t time.Time) error {
_, err := r.db.ExecContext(ctx,
`UPDATE panel_sessions SET last_activity=? WHERE token=?`, t, token)
return err
}
func (r *PanelSessionRepo) DeleteByToken(ctx context.Context, token string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM panel_sessions WHERE token=?`, token)
return err
}
func (r *PanelSessionRepo) DeleteByID(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM panel_sessions WHERE id=?`, id)
return err
}
// DeleteAllExcept deletes all sessions for adminID except the one with exceptToken.
func (r *PanelSessionRepo) DeleteAllExcept(ctx context.Context, adminID int64, exceptToken string) error {
_, err := r.db.ExecContext(ctx,
`DELETE FROM panel_sessions WHERE admin_id=? AND token!=?`, adminID, exceptToken)
return err
}
func (r *PanelSessionRepo) List(ctx context.Context) ([]*PanelSession, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id,token,csrf_token,admin_id,ip_address,user_agent,last_activity,created_at
FROM panel_sessions ORDER BY last_activity DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanSessions(rows)
}
func (r *PanelSessionRepo) DeleteExpiredBefore(ctx context.Context, before time.Time) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM panel_sessions WHERE last_activity<?`, before)
return err
}
func scanSessions(rows *sql.Rows) ([]*PanelSession, error) {
var out []*PanelSession
for rows.Next() {
var s PanelSession
if err := rows.Scan(&s.ID, &s.Token, &s.CSRFToken, &s.AdminID,
&s.IPAddress, &s.UserAgent, &s.LastActivity, &s.CreatedAt); err != nil {
return nil, err
}
out = append(out, &s)
}
return out, rows.Err()
}
+239 -12
View File
@@ -14,6 +14,11 @@ type Ticket struct {
Panel string
Type string
ChannelID string
GuildID string
ClaimChannelID string
LogChannelID string
StaffRoleID string
ClaimReupMinutes int
OpenedAt time.Time
ClaimedBy sql.NullString
ClaimedAt sql.NullTime
@@ -53,10 +58,10 @@ func (r *TicketRepo) Insert(ctx context.Context, t *Ticket) error {
t.TicketNumber = n
res, err := tx.ExecContext(ctx, `
INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,opened_at,status,ticket_title,ticket_description)
VALUES(?,?,?,?,?,?,?,?,?)`,
n, t.UserID, t.Panel, t.Type, t.ChannelID, t.OpenedAt.UTC(), "open",
t.TicketTitle, t.TicketDescription,
INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,opened_at,status,ticket_title,ticket_description)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
n, t.UserID, t.Panel, t.Type, t.ChannelID, t.GuildID, t.ClaimChannelID, t.LogChannelID,
t.StaffRoleID, t.ClaimReupMinutes, t.OpenedAt.UTC(), "open", t.TicketTitle, t.TicketDescription,
)
if err != nil {
return fmt.Errorf("insert ticket: %w", err)
@@ -69,10 +74,10 @@ func (r *TicketRepo) Insert(ctx context.Context, t *Ticket) error {
// InsertWithNumber inserts a ticket where TicketNumber is already set (e.g. convocations).
func (r *TicketRepo) InsertWithNumber(ctx context.Context, t *Ticket) error {
res, err := r.db.ExecContext(ctx, `
INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,opened_at,status,ticket_title,ticket_description)
VALUES(?,?,?,?,?,?,?,?,?)`,
t.TicketNumber, t.UserID, t.Panel, t.Type, t.ChannelID, t.OpenedAt.UTC(), "open",
t.TicketTitle, t.TicketDescription,
INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,opened_at,status,ticket_title,ticket_description)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
t.TicketNumber, t.UserID, t.Panel, t.Type, t.ChannelID, t.GuildID, t.ClaimChannelID, t.LogChannelID,
t.StaffRoleID, t.ClaimReupMinutes, t.OpenedAt.UTC(), "open", t.TicketTitle, t.TicketDescription,
)
if err != nil {
return fmt.Errorf("insert ticket with number: %w", err)
@@ -84,7 +89,7 @@ func (r *TicketRepo) InsertWithNumber(ctx context.Context, t *Ticket) error {
func (r *TicketRepo) GetByChannelID(ctx context.Context, channelID string) (*Ticket, error) {
row := r.db.QueryRowContext(ctx, `
SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at,
SELECT id,ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,opened_at,
claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description
FROM tickets WHERE channel_id=?`, channelID)
return scanTicket(row)
@@ -92,7 +97,7 @@ func (r *TicketRepo) GetByChannelID(ctx context.Context, channelID string) (*Tic
func (r *TicketRepo) GetByID(ctx context.Context, id int64) (*Ticket, error) {
row := r.db.QueryRowContext(ctx, `
SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at,
SELECT id,ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,opened_at,
claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description
FROM tickets WHERE id=?`, id)
return scanTicket(row)
@@ -100,7 +105,7 @@ func (r *TicketRepo) GetByID(ctx context.Context, id int64) (*Ticket, error) {
func (r *TicketRepo) HasOpenTicket(ctx context.Context, userID, ticketType string) (*Ticket, error) {
row := r.db.QueryRowContext(ctx, `
SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at,
SELECT id,ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,opened_at,
claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description
FROM tickets WHERE user_id=? AND type=? AND status IN('open','claimed')
LIMIT 1`, userID, ticketType)
@@ -111,6 +116,44 @@ func (r *TicketRepo) HasOpenTicket(ctx context.Context, userID, ticketType strin
return t, err
}
// CountByStatus returns the number of tickets with the given status.
func (r *TicketRepo) CountByStatus(ctx context.Context, status string) (int, error) {
var n int
err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tickets WHERE status=?`, status).Scan(&n)
return n, err
}
// CountClosedSince returns the number of tickets closed on or after `since`.
func (r *TicketRepo) CountClosedSince(ctx context.Context, since time.Time) (int, error) {
var n int
err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tickets WHERE status='closed' AND closed_at>=?`, since).Scan(&n)
return n, err
}
// AvgClaimMinutes returns the average minutes between opened_at and claimed_at for tickets claimed since `since`.
func (r *TicketRepo) AvgClaimMinutes(ctx context.Context, since time.Time) (int, error) {
var avg sql.NullFloat64
err := r.db.QueryRowContext(ctx,
`SELECT AVG((julianday(claimed_at)-julianday(opened_at))*1440)
FROM tickets WHERE claimed_at IS NOT NULL AND claimed_at>=?`, since).Scan(&avg)
if err != nil || !avg.Valid {
return 0, err
}
return int(avg.Float64), nil
}
// AvgResolutionMinutes returns the average minutes between opened_at and closed_at for tickets closed since `since`.
func (r *TicketRepo) AvgResolutionMinutes(ctx context.Context, since time.Time) (int, error) {
var avg sql.NullFloat64
err := r.db.QueryRowContext(ctx,
`SELECT AVG((julianday(closed_at)-julianday(opened_at))*1440)
FROM tickets WHERE closed_at IS NOT NULL AND closed_at>=?`, since).Scan(&avg)
if err != nil || !avg.Valid {
return 0, err
}
return int(avg.Float64), nil
}
func (r *TicketRepo) SetClaimed(ctx context.Context, id int64, staffID string, at time.Time) error {
_, err := r.db.ExecContext(ctx,
`UPDATE tickets SET status='claimed', claimed_by=?, claimed_at=? WHERE id=?`,
@@ -136,7 +179,7 @@ func (r *TicketRepo) SetClosedByChannel(ctx context.Context, channelID, reason s
func (r *TicketRepo) ListOpen(ctx context.Context) ([]*Ticket, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at,
SELECT id,ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,opened_at,
claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description
FROM tickets WHERE status='open'`)
if err != nil {
@@ -164,6 +207,188 @@ func (r *TicketRepo) NextConvocationNumber(ctx context.Context) (int, error) {
return n, tx.Commit()
}
// DailyCount holds the ticket count for one day.
type DailyCount struct {
Day string // "2006-01-02"
Count int
}
// DailyTicketCounts returns the count of tickets opened per day for the last n days.
func (r *TicketRepo) DailyTicketCounts(ctx context.Context, days int) ([]DailyCount, error) {
since := time.Now().AddDate(0, 0, -days+1).Truncate(24 * time.Hour)
rows, err := r.db.QueryContext(ctx, `
SELECT strftime('%Y-%m-%d', opened_at) AS day, COUNT(*) AS cnt
FROM tickets
WHERE opened_at >= ?
GROUP BY day
ORDER BY day`, since.UTC())
if err != nil {
return nil, err
}
defer rows.Close()
var out []DailyCount
for rows.Next() {
var dc DailyCount
if err := rows.Scan(&dc.Day, &dc.Count); err != nil {
return nil, err
}
out = append(out, dc)
}
return out, rows.Err()
}
// StaffStat holds per-staff ticket stats.
type StaffStat struct {
StaffID string
Claimed int
Closed int
AvgClaimMinutes int
AvgResolutionMinutes int
}
// StaffStats returns per-staff statistics for tickets claimed in the last n days.
func (r *TicketRepo) StaffStats(ctx context.Context, days int) ([]StaffStat, error) {
since := time.Now().AddDate(0, 0, -days)
rows, err := r.db.QueryContext(ctx, `
SELECT
claimed_by,
COUNT(*) AS claimed,
SUM(CASE WHEN status='closed' THEN 1 ELSE 0 END) AS closed,
CAST(AVG(CASE WHEN claimed_at IS NOT NULL
THEN (julianday(claimed_at) - julianday(opened_at)) * 1440 END) AS INTEGER) AS avg_claim,
CAST(AVG(CASE WHEN closed_at IS NOT NULL AND claimed_at IS NOT NULL
THEN (julianday(closed_at) - julianday(opened_at)) * 1440 END) AS INTEGER) AS avg_resolution
FROM tickets
WHERE claimed_by IS NOT NULL AND claimed_at >= ?
GROUP BY claimed_by
ORDER BY claimed DESC`, since.UTC())
if err != nil {
return nil, err
}
defer rows.Close()
var out []StaffStat
for rows.Next() {
var s StaffStat
var avgClaim, avgRes sql.NullInt64
if err := rows.Scan(&s.StaffID, &s.Claimed, &s.Closed, &avgClaim, &avgRes); err != nil {
return nil, err
}
s.AvgClaimMinutes = int(avgClaim.Int64)
s.AvgResolutionMinutes = int(avgRes.Int64)
out = append(out, s)
}
return out, rows.Err()
}
// ListFiltered returns tickets matching optional filters with pagination.
type TicketFilter struct {
Status string
Type string
StaffID string
GuildID string
Search string
From, To time.Time
Page int
PageSize int
}
func (r *TicketRepo) ListFiltered(ctx context.Context, f TicketFilter) ([]*Ticket, int, error) {
if f.PageSize <= 0 {
f.PageSize = 20
}
if f.Page <= 0 {
f.Page = 1
}
offset := (f.Page - 1) * f.PageSize
args := []any{}
where := "1=1"
if f.Status != "" {
where += " AND status=?"
args = append(args, f.Status)
}
if f.Type != "" {
where += " AND type=?"
args = append(args, f.Type)
}
if f.StaffID != "" {
where += " AND claimed_by=?"
args = append(args, f.StaffID)
}
if f.GuildID != "" {
where += " AND guild_id=?"
args = append(args, f.GuildID)
}
if f.Search != "" {
where += " AND (ticket_title LIKE ? OR user_id LIKE ?)"
args = append(args, "%"+f.Search+"%", "%"+f.Search+"%")
}
if !f.From.IsZero() {
where += " AND opened_at >= ?"
args = append(args, f.From.UTC())
}
if !f.To.IsZero() {
where += " AND opened_at <= ?"
args = append(args, f.To.UTC())
}
var total int
countArgs := make([]any, len(args))
copy(countArgs, args)
if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM tickets WHERE "+where, countArgs...).Scan(&total); err != nil {
return nil, 0, err
}
args = append(args, f.PageSize, offset)
rows, err := r.db.QueryContext(ctx, `
SELECT id,ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,opened_at,
claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description
FROM tickets WHERE `+where+` ORDER BY opened_at DESC LIMIT ? OFFSET ?`, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
list, err := scanTickets(rows)
return list, total, err
}
// ListDistinctTypes returns all distinct ticket types present in the tickets table.
func (r *TicketRepo) ListDistinctTypes(ctx context.Context) ([]string, error) {
rows, err := r.db.QueryContext(ctx, `SELECT DISTINCT type FROM tickets ORDER BY type`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// ListDistinctStaff returns all distinct staff IDs (claimed_by) from the tickets table.
func (r *TicketRepo) ListDistinctStaff(ctx context.Context) ([]string, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT claimed_by FROM tickets WHERE claimed_by IS NOT NULL ORDER BY claimed_by`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// NullStr returns a valid NullString for non-empty strings, invalid (NULL) for empty.
func NullStr(s string) sql.NullString {
return sql.NullString{String: s, Valid: s != ""}
@@ -180,6 +405,7 @@ func (r *TicketRepo) SetTitleDescription(ctx context.Context, id int64, title, d
func scanTicket(row *sql.Row) (*Ticket, error) {
var t Ticket
err := row.Scan(&t.ID, &t.TicketNumber, &t.UserID, &t.Panel, &t.Type, &t.ChannelID,
&t.GuildID, &t.ClaimChannelID, &t.LogChannelID, &t.StaffRoleID, &t.ClaimReupMinutes,
&t.OpenedAt, &t.ClaimedBy, &t.ClaimedAt, &t.ClosedAt, &t.ClosedBy,
&t.Reason, &t.TranscriptPath, &t.Status, &t.TicketTitle, &t.TicketDescription)
if err != nil {
@@ -193,6 +419,7 @@ func scanTickets(rows *sql.Rows) ([]*Ticket, error) {
for rows.Next() {
var t Ticket
if err := rows.Scan(&t.ID, &t.TicketNumber, &t.UserID, &t.Panel, &t.Type, &t.ChannelID,
&t.GuildID, &t.ClaimChannelID, &t.LogChannelID, &t.StaffRoleID, &t.ClaimReupMinutes,
&t.OpenedAt, &t.ClaimedBy, &t.ClaimedAt, &t.ClosedAt, &t.ClosedBy,
&t.Reason, &t.TranscriptPath, &t.Status, &t.TicketTitle, &t.TicketDescription); err != nil {
return nil, err
+55
View File
@@ -0,0 +1,55 @@
package db
import (
"context"
"database/sql"
"time"
)
type BlacklistedUser struct {
ID int64
DiscordID string
Reason sql.NullString
BlacklistedBy string
CreatedAt time.Time
}
type UserBlacklistRepo struct{ db *sql.DB }
func NewUserBlacklistRepo(db *sql.DB) *UserBlacklistRepo { return &UserBlacklistRepo{db: db} }
func (r *UserBlacklistRepo) Add(ctx context.Context, discordID, reason, blacklistedBy string) error {
_, err := r.db.ExecContext(ctx,
`INSERT OR REPLACE INTO user_blacklist(discord_id,reason,blacklisted_by,created_at) VALUES(?,?,?,?)`,
discordID, NullStr(reason), blacklistedBy, time.Now().UTC())
return err
}
func (r *UserBlacklistRepo) Remove(ctx context.Context, discordID string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM user_blacklist WHERE discord_id=?`, discordID)
return err
}
func (r *UserBlacklistRepo) IsBlacklisted(ctx context.Context, discordID string) (bool, error) {
var count int
err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM user_blacklist WHERE discord_id=?`, discordID).Scan(&count)
return count > 0, err
}
func (r *UserBlacklistRepo) List(ctx context.Context) ([]*BlacklistedUser, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id,discord_id,reason,blacklisted_by,created_at FROM user_blacklist ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*BlacklistedUser
for rows.Next() {
var u BlacklistedUser
if err := rows.Scan(&u.ID, &u.DiscordID, &u.Reason, &u.BlacklistedBy, &u.CreatedAt); err != nil {
return nil, err
}
out = append(out, &u)
}
return out, rows.Err()
}