package store import ( "context" "database/sql" "strings" "time" ) // Rank history lives in sa_rank_log, which the panel owns (sql/rank-log.sql). Our SimpleAdmin fork // writes the same rows for in-game changes. A player's rank is their highest-immunity group, the // one the staff page lists them under. // Actor is who made a change. An empty SteamID is Console. type Actor struct { SteamID string Name string } // Where a rank change was made, stored in sa_rank_log.source. const ( SourcePanel = "panel" SourceGame = "game" ) // RankChange is a row of sa_rank_log. type RankChange struct { ID int64 `json:"id"` SteamID string `json:"steamid"` Name string `json:"name"` OldRank string `json:"oldRank,omitempty"` NewRank string `json:"newRank,omitempty"` Kind string `json:"kind"` AdminSteamID string `json:"adminSteamid,omitempty"` AdminName string `json:"admin"` Source string `json:"source"` Created time.Time `json:"created"` } // EnsureRankLog creates sa_rank_log if it's missing. func (s *Store) EnsureRankLog(ctx context.Context, ddl string) error { _, err := s.db.ExecContext(ctx, strings.TrimSuffix(strings.TrimSpace(ddl), ";")) return err } // RankChanges lists rank changes, newest first. func (s *Store) RankChanges(ctx context.Context, limit, offset int) ([]RankChange, int, error) { var total int if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sa_rank_log`).Scan(&total); err != nil { return nil, 0, err } rows, err := s.db.QueryContext(ctx, `SELECT id, player_steamid, player_name, COALESCE(old_rank,''), COALESCE(new_rank,''), kind, admin_steamid, admin_name, source, created FROM sa_rank_log ORDER BY id DESC LIMIT ? OFFSET ?`, limit, offset) if err != nil { return nil, 0, err } defer rows.Close() out := []RankChange{} for rows.Next() { var c RankChange var player, admin sql.NullInt64 if err := rows.Scan(&c.ID, &player, &c.Name, &c.OldRank, &c.NewRank, &c.Kind, &admin, &c.AdminName, &c.Source, &c.Created); err != nil { return nil, 0, err } c.SteamID, c.AdminSteamID = sid(player), sid(admin) if c.AdminSteamID == "" { c.AdminName = "Console" } c.Created = s.fromWall(c.Created) out = append(out, c) } return out, total, rows.Err() } type rankState struct { group string // "" for no rank immunity int } // rankTx reads a player's current rank inside tx, counting only unexpired admin rows and groups // that apply to this server, as Admins and Groups do. func (s *Store) rankTx(ctx context.Context, tx *sql.Tx, n int64) (rankState, error) { gsCond, gsArgs := s.serverFilter("gs.server_id") aCond, aArgs := s.serverFilter("a.server_id") args := append(gsArgs, n, s.wall(time.Now())) args = append(args, aArgs...) var r rankState err := tx.QueryRowContext(ctx, `SELECT g.name, g.immunity FROM sa_admins a JOIN sa_admins_flags f ON f.admin_id = a.id JOIN sa_groups g ON g.name = f.flag JOIN sa_groups_servers gs ON gs.group_id = g.id AND `+gsCond+` WHERE a.player_steamid = ? AND (a.ends IS NULL OR a.ends > ?) AND `+aCond+` ORDER BY g.immunity DESC, g.id LIMIT 1`, args...).Scan(&r.group, &r.immunity) if err == sql.ErrNoRows { return rankState{}, nil } return r, err } func rankKind(before, after rankState) string { switch { case before.group == "": return "added" case after.group == "": return "removed" case after.immunity > before.immunity: return "promoted" case after.immunity < before.immunity: return "demoted" } return "changed" } func nullIfEmpty(s string) any { if s == "" { return nil } return s } // logRank records a rank change inside tx, if the rank actually changed. func (s *Store) logRank(ctx context.Context, tx *sql.Tx, n int64, name string, before, after rankState, by Actor) error { if before.group == after.group { return nil } a, _ := parseSID(by.SteamID) _, err := tx.ExecContext(ctx, `INSERT INTO sa_rank_log (player_steamid, player_name, old_rank, new_rank, kind, admin_steamid, admin_name, source, created) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, n, name, nullIfEmpty(before.group), nullIfEmpty(after.group), rankKind(before, after), a, by.Name, SourcePanel, s.wall(time.Now())) return err } // adminNameTx is the name on a player's admin rows, or "". func adminNameTx(ctx context.Context, tx *sql.Tx, n int64) string { var name sql.NullString _ = tx.QueryRowContext(ctx, `SELECT player_name FROM sa_admins WHERE player_steamid = ? ORDER BY server_id IS NULL DESC, id LIMIT 1`, n).Scan(&name) return name.String }