From 54497374e4d090a49d02f007ffdfc43c7d6dd77d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 6 Jun 2026 14:14:28 +0200 Subject: [PATCH] Stage 17 (#15): admin users people/robots toggle + display-name & external-id glob filters - account.ListUsers/CountUsers with a UserFilter: people vs robots (by a robot identity), case-insensitive '*'/'?' glob masks on display_name and any identity's external_id - admin users list shows the real kind (robot/guest/registered), defaults to people, with a People/Robots toggle + a filter form; pager preserves the filter - integration test for the filter; SQL verified against the live contour DB --- backend/internal/account/userlist.go | 95 +++++++++++++++++++ .../adminconsole/templates/pages/users.gohtml | 14 ++- backend/internal/adminconsole/views.go | 6 ++ backend/internal/inttest/userlist_test.go | 86 +++++++++++++++++ .../internal/server/handlers_admin_console.go | 40 ++++++-- 5 files changed, 230 insertions(+), 11 deletions(-) create mode 100644 backend/internal/account/userlist.go create mode 100644 backend/internal/inttest/userlist_test.go 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}} + +
+{{if .Robots}}{{end}} + + + +
@@ -19,9 +29,9 @@
AccountDisplay nameKindLangCreatedMove minavgmax
{{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 {