Files
scrabble-game/backend/internal/account/userlist.go
T
Ilia Denisov f916d5e0ca
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 29s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m14s
Stage 17 round 5 (L2): robot play-to-win intent + next-move ETA in the admin game card
The admin game detail now shows, per robot seat, the game's deterministic play-to-win
decision (from the bag seed) and — while it is that robot's turn — its scheduled next-move
ETA (sampled think-time delay, deferred past the sleep window), plus a caption with the
~40% global target. Wiring: robot.PlayToWin/NextMoveAt/PlayToWinTargetPercent exports,
account.IsRobot, game RobotSchedule (seed + turn-start). Tests: NextMoveAt invariants
(never early, never in the sleep window), PlayToWin export, and an admin render integration
test asserting the intent + ETA + target appear.
2026-06-07 09:42:23 +02:00

109 lines
3.9 KiB
Go

package account
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// UserListItem is the admin user-list projection: a small subset of the account plus
// whether it is a robot (derived from its identities), so the console can label the kind
// without a per-row identity query.
type UserListItem struct {
ID uuid.UUID
DisplayName string
PreferredLanguage string
IsGuest bool
IsRobot bool
CreatedAt time.Time
}
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' =
// one char) matched case-insensitively against the display name / any identity's external
// id. An empty mask means no filter on that field.
type UserFilter struct {
Robots bool
NameMask string
ExternalIDMask string
}
// robotExists is the correlated subquery testing whether account a is a robot.
const robotExists = `EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot')`
// IsRobot reports whether the account is a robot pool member (it carries a robot
// identity). The admin console uses it to label a game's robot seats.
func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error) {
var ok bool
err := s.db.QueryRowContext(ctx,
`SELECT EXISTS (SELECT 1 FROM backend.identities WHERE account_id = $1 AND kind = 'robot')`,
accountID).Scan(&ok)
if err != nil {
return false, fmt.Errorf("account: is-robot %s: %w", accountID, err)
}
return ok, nil
}
// userListWhere builds the shared WHERE clause and its positional args (from $1).
func userListWhere(f UserFilter) (string, []any) {
args := []any{f.Robots}
where := robotExists + ` = $1`
if name := likePattern(f.NameMask); name != "" {
args = append(args, name)
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
}
if ext := likePattern(f.ExternalIDMask); ext != "" {
args = append(args, ext)
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args))
}
return where, args
}
// ListUsers returns the filtered admin user list, newest first, paginated.
func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) {
where, args := userListWhere(f)
q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.created_at, ` + robotExists + ` AS is_robot
FROM backend.accounts a WHERE ` + where +
fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("account: list users: %w", err)
}
defer rows.Close()
var out []UserListItem
for rows.Next() {
var it UserListItem
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &it.CreatedAt, &it.IsRobot); err != nil {
return nil, fmt.Errorf("account: scan user: %w", err)
}
out = append(out, it)
}
return out, rows.Err()
}
// CountUsers counts the filtered admin user list, for pagination.
func (s *Store) CountUsers(ctx context.Context, f UserFilter) (int, error) {
where, args := userListWhere(f)
var n int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM backend.accounts a WHERE `+where, args...).Scan(&n); err != nil {
return 0, fmt.Errorf("account: count users: %w", err)
}
return n, nil
}
// likePattern converts a glob mask ('*' any run, '?' one char) to an ILIKE pattern,
// escaping the SQL wildcards already in the input first. An empty/blank mask returns "".
func likePattern(mask string) string {
mask = strings.TrimSpace(mask)
if mask == "" {
return ""
}
escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(mask)
escaped = strings.ReplaceAll(escaped, "*", "%")
return strings.ReplaceAll(escaped, "?", "_")
}