oups
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user