package account import ( "context" "database/sql" "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 // IsDeleted marks a tombstoned account (deleted_at set), shown as a badge — a search // spans both lists, so a result can be either live or deleted. IsDeleted bool // FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown // as a badge in the console list. FlaggedHighRateAt time.Time CreatedAt time.Time } // UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the // non-robot "people"); Deleted selects tombstoned accounts (every other scope hides them); // NameMask and ExternalIDMask are glob masks ('*' = any run, '?' = one char) matched // case-insensitively against the display name / any identity's external id; EmailExact is a // strict (exact) match against an account's email identity. An empty value means no filter // on that field. type UserFilter struct { Robots bool Deleted bool NameMask string ExternalIDMask string EmailExact 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). On the // Robots tab it lists/searches robots only. Otherwise a search (any of the name / // external-id / email filters) spans live and deleted people alike — never robots — so the // operator finds a match from one query regardless of the People / Deleted tab; the search // also looks in the retention journal, so a deleted account is still found by the email / // external id it held (those rows moved from identities to retained_identities on deletion) // and by its retained real name. With no search, the People / Deleted tab scope applies. func userListWhere(f UserFilter) (string, []any) { name := LikePattern(f.NameMask) ext := LikePattern(f.ExternalIDMask) email := strings.ToLower(strings.TrimSpace(f.EmailExact)) searching := name != "" || ext != "" || email != "" var args []any var where string switch { case f.Robots: where = robotExists + ` = true` case searching: where = robotExists + ` = false` case f.Deleted: where = robotExists + ` = false AND a.deleted_at IS NOT NULL` default: where = robotExists + ` = false AND a.deleted_at IS NULL` } if name != "" { args = append(args, name) where += fmt.Sprintf(` AND (a.display_name ILIKE $%d ESCAPE '\' OR a.deleted_display_name ILIKE $%d ESCAPE '\')`, len(args), len(args)) } if 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 '\') OR EXISTS (SELECT 1 FROM backend.retained_identities r WHERE r.account_id = a.account_id AND r.external_id ILIKE $%d ESCAPE '\'))`, len(args), len(args)) } if email != "" { args = append(args, email) where += fmt.Sprintf(` AND (EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'email' AND i.external_id = $%d) OR EXISTS (SELECT 1 FROM backend.retained_identities r WHERE r.account_id = a.account_id AND r.kind = 'email' AND r.external_id = $%d))`, len(args), 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.flagged_high_rate_at, a.created_at, ` + robotExists + ` AS is_robot, (a.deleted_at IS NOT NULL) AS is_deleted 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 var flagged sql.NullTime if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot, &it.IsDeleted); err != nil { return nil, fmt.Errorf("account: scan user: %w", err) } if flagged.Valid { it.FlaggedHighRateAt = flagged.Time } out = append(out, it) } return out, rows.Err() } // FlaggedAccount is one row of the console's high-rate review queue. type FlaggedAccount struct { ID uuid.UUID DisplayName string FlaggedHighRateAt time.Time } // flaggedListCap bounds the console's flagged-account list; the operator clears // flags as they are reviewed, so the queue stays short in practice. const flaggedListCap = 200 // ListFlaggedHighRate returns the accounts carrying the high-rate flag, most // recently flagged first. func (s *Store) ListFlaggedHighRate(ctx context.Context) ([]FlaggedAccount, error) { rows, err := s.db.QueryContext(ctx, `SELECT account_id, display_name, flagged_high_rate_at FROM backend.accounts WHERE flagged_high_rate_at IS NOT NULL ORDER BY flagged_high_rate_at DESC LIMIT $1`, flaggedListCap) if err != nil { return nil, fmt.Errorf("account: list flagged: %w", err) } defer rows.Close() var out []FlaggedAccount for rows.Next() { var fa FlaggedAccount if err := rows.Scan(&fa.ID, &fa.DisplayName, &fa.FlaggedHighRateAt); err != nil { return nil, fmt.Errorf("account: scan flagged: %w", err) } out = append(out, fa) } 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, "?", "_") }