diff --git a/backend/internal/account/userlist.go b/backend/internal/account/userlist.go
new file mode 100644
index 0000000..2f22777
--- /dev/null
+++ b/backend/internal/account/userlist.go
@@ -0,0 +1,95 @@
+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')`
+
+// 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, "?", "_")
+}
diff --git a/backend/internal/adminconsole/templates/pages/users.gohtml b/backend/internal/adminconsole/templates/pages/users.gohtml
index bf2d777..ef8cf21 100644
--- a/backend/internal/adminconsole/templates/pages/users.gohtml
+++ b/backend/internal/adminconsole/templates/pages/users.gohtml
@@ -1,6 +1,16 @@
{{define "content" -}}
Users
{{with .Data}}
+
+
| Account | Display name | Kind | Lang | Created | Move min | avg | max |
@@ -19,9 +29,9 @@
{{end}}
{{- end}}
diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go
index 9874d50..8293dd9 100644
--- a/backend/internal/adminconsole/views.go
+++ b/backend/internal/adminconsole/views.go
@@ -50,6 +50,12 @@ type DashboardView struct {
type UsersView struct {
Items []UserRow
Pager Pager
+ // Robots is the active people/robots toggle; NameMask/ExternalIDMask are the current
+ // glob filters; FilterQuery is those encoded for pager/toggle links.
+ Robots bool
+ NameMask string
+ ExternalIDMask string
+ FilterQuery string
}
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's
diff --git a/backend/internal/inttest/userlist_test.go b/backend/internal/inttest/userlist_test.go
new file mode 100644
index 0000000..8e47e5f
--- /dev/null
+++ b/backend/internal/inttest/userlist_test.go
@@ -0,0 +1,86 @@
+//go:build integration
+
+package inttest
+
+import (
+ "context"
+ "testing"
+
+ "github.com/google/uuid"
+
+ "scrabble/backend/internal/account"
+)
+
+// TestUserListFilter checks the admin user-list filter: the people/robots split (by a
+// robot identity) and the case-insensitive glob masks on display name and external id.
+func TestUserListFilter(t *testing.T) {
+ ctx := context.Background()
+ st := account.NewStore(testDB)
+ uniq := uuid.NewString()
+
+ human, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman")
+ if err != nil {
+ t.Fatalf("provision human: %v", err)
+ }
+ robot, err := st.ProvisionRobot(ctx, "robot-uxz-"+uniq, "Zzqxbot")
+ if err != nil {
+ t.Fatalf("provision robot: %v", err)
+ }
+ guest, err := st.ProvisionGuest(ctx)
+ if err != nil {
+ t.Fatalf("provision guest: %v", err)
+ }
+
+ collect := func(f account.UserFilter) map[uuid.UUID]account.UserListItem {
+ items, err := st.ListUsers(ctx, f, 5000, 0)
+ if err != nil {
+ t.Fatalf("list users %+v: %v", f, err)
+ }
+ m := make(map[uuid.UUID]account.UserListItem, len(items))
+ for _, it := range items {
+ m[it.ID] = it
+ }
+ return m
+ }
+
+ people := collect(account.UserFilter{})
+ if _, ok := people[human.ID]; !ok {
+ t.Error("human missing from people")
+ }
+ if _, ok := people[guest.ID]; !ok {
+ t.Error("guest missing from people")
+ }
+ if _, ok := people[robot.ID]; ok {
+ t.Error("robot must not appear in people")
+ }
+ if it := people[human.ID]; it.IsRobot || it.IsGuest {
+ t.Errorf("human flags wrong: robot=%v guest=%v (want both false)", it.IsRobot, it.IsGuest)
+ }
+
+ robots := collect(account.UserFilter{Robots: true})
+ if it, ok := robots[robot.ID]; !ok || !it.IsRobot {
+ t.Errorf("robot missing from robots or IsRobot=false (ok=%v)", ok)
+ }
+ if _, ok := robots[human.ID]; ok {
+ t.Error("human must not appear in robots")
+ }
+
+ // Name mask (people).
+ if _, ok := collect(account.UserFilter{NameMask: "Zzqx*"})[human.ID]; !ok {
+ t.Error("name mask Zzqx* should match the human")
+ }
+ if _, ok := collect(account.UserFilter{NameMask: "nomatch*"})[human.ID]; ok {
+ t.Error("name mask nomatch* should not match the human")
+ }
+ // External-id mask (robots).
+ if _, ok := collect(account.UserFilter{Robots: true, ExternalIDMask: "robot-uxz-*"})[robot.ID]; !ok {
+ t.Error("external-id mask robot-uxz-* should match the robot")
+ }
+ if _, ok := collect(account.UserFilter{Robots: true, ExternalIDMask: "robot-zzz-*"})[robot.ID]; ok {
+ t.Error("external-id mask robot-zzz-* should not match the robot")
+ }
+ // CountUsers agrees that robots exist.
+ if n, err := st.CountUsers(ctx, account.UserFilter{Robots: true, ExternalIDMask: "robot-uxz-*"}); err != nil || n != 1 {
+ t.Errorf("count robots robot-uxz-* = (%d, %v), want 1", n, err)
+ }
+}
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index 5fdf3aa..91da11c 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"net/http"
+ "net/url"
"path/filepath"
"strconv"
"strings"
@@ -75,24 +76,45 @@ func (s *Server) consoleDashboard(c *gin.Context) {
func (s *Server) consoleUsers(c *gin.Context) {
ctx := c.Request.Context()
page := consolePage(c)
- total, _ := s.accounts.CountAccounts(ctx)
- accs, err := s.accounts.ListAccounts(ctx, adminPageSize, (page-1)*adminPageSize)
+ filter := account.UserFilter{
+ Robots: c.Query("kind") == "robots",
+ NameMask: c.Query("name"),
+ ExternalIDMask: c.Query("ext"),
+ }
+ total, _ := s.accounts.CountUsers(ctx, filter)
+ items, err := s.accounts.ListUsers(ctx, filter, adminPageSize, (page-1)*adminPageSize)
if err != nil {
s.consoleError(c, err)
return
}
- view := adminconsole.UsersView{Pager: adminconsole.NewPager(page, adminPageSize, total)}
- ids := make([]uuid.UUID, 0, len(accs))
- for _, a := range accs {
+ q := url.Values{}
+ if filter.Robots {
+ q.Set("kind", "robots")
+ }
+ if strings.TrimSpace(filter.NameMask) != "" {
+ q.Set("name", filter.NameMask)
+ }
+ if strings.TrimSpace(filter.ExternalIDMask) != "" {
+ q.Set("ext", filter.ExternalIDMask)
+ }
+ view := adminconsole.UsersView{
+ Pager: adminconsole.NewPager(page, adminPageSize, total),
+ Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
+ FilterQuery: q.Encode(),
+ }
+ ids := make([]uuid.UUID, 0, len(items))
+ for _, it := range items {
kind := "registered"
- if a.IsGuest {
+ if it.IsRobot {
+ kind = "robot"
+ } else if it.IsGuest {
kind = "guest"
}
view.Items = append(view.Items, adminconsole.UserRow{
- ID: a.ID.String(), DisplayName: a.DisplayName, Kind: kind,
- Language: a.PreferredLanguage, Guest: a.IsGuest, CreatedAt: fmtTime(a.CreatedAt),
+ ID: it.ID.String(), DisplayName: it.DisplayName, Kind: kind,
+ Language: it.PreferredLanguage, Guest: it.IsGuest, CreatedAt: fmtTime(it.CreatedAt),
})
- ids = append(ids, a.ID)
+ ids = append(ids, it.ID)
}
if stats, err := s.games.MoveDurationStats(ctx, ids); err == nil {
for i := range view.Items {