# frozen_string_literal: true class UserSearch MAX_SIZE_PRIORITY_MENTION ||= 500 def initialize(term, opts = {}) @term = term.downcase @term_like = @term.gsub("_", "\\_") + "%" @topic_id = opts[:topic_id] @category_id = opts[:category_id] @topic_allowed_users = opts[:topic_allowed_users] @searching_user = opts[:searching_user] @include_staged_users = opts[:include_staged_users] || false @last_seen_users = opts[:last_seen_users] || false @limit = opts[:limit] || 20 @groups = opts[:groups] @topic = Topic.find(@topic_id) if @topic_id @category = Category.find(@category_id) if @category_id @guardian = Guardian.new(@searching_user) @guardian.ensure_can_see_groups_members!(@groups) if @groups @guardian.ensure_can_see_category!(@category) if @category @guardian.ensure_can_see_topic!(@topic) if @topic end def scoped_users users = User.where(active: true) users = users.where(approved: true) if SiteSetting.must_approve_users? users = users.where(staged: false) unless @include_staged_users users = users.not_suspended unless @searching_user&.staff? if @groups users = users.joins(:group_users).where("group_users.group_id IN (?)", @groups.map(&:id)) end # Only show users who have access to private topic if @topic_allowed_users == "true" && @topic&.category&.read_restricted users = users .references(:categories) .includes(:secure_categories) .where("users.admin OR categories.id = ?", @topic.category_id) end users end def filtered_by_term_users if @term.blank? scoped_users elsif SiteSetting.enable_names? && @term !~ /[_\.-]/ query = Search.ts_query(term: @term, ts_config: "simple") scoped_users .includes(:user_search_data) .where("user_search_data.search_data @@ #{query}") .order(DB.sql_fragment("CASE WHEN username_lower LIKE ? THEN 0 ELSE 1 END ASC", @term_like)) else scoped_users.where("username_lower LIKE :term_like", term_like: @term_like) end end def search_ids users = Set.new # 1. exact username matches if @term.present? exact_matches = scoped_users.where(username_lower: @term) # don't pollute mentions with users who haven't shown up in over a year exact_matches = exact_matches.where("last_seen_at > ?", 1.year.ago) if @topic_id || @category_id exact_matches.limit(@limit).pluck(:id).each { |id| users << id } end return users.to_a if users.size >= @limit # 2. in topic if @topic_id in_topic = filtered_by_term_users.where( "users.id IN (SELECT user_id FROM posts WHERE topic_id = ? AND post_type = ? AND deleted_at IS NULL)", @topic_id, Post.types[:regular], ) in_topic = in_topic.where("users.id <> ?", @searching_user.id) if @searching_user.present? in_topic .order("last_seen_at DESC NULLS LAST") .limit(@limit - users.size) .pluck(:id) .each { |id| users << id } end return users.to_a if users.size >= @limit # 3. in category secure_category_id = if @category_id DB.query_single(<<~SQL, @category_id).first SELECT id FROM categories WHERE read_restricted AND id = ? SQL elsif @topic_id DB.query_single(<<~SQL, @topic_id).first SELECT id FROM categories WHERE read_restricted AND id IN (SELECT category_id FROM topics WHERE id = ?) SQL end if secure_category_id category_groups = Group.where(<<~SQL, secure_category_id, MAX_SIZE_PRIORITY_MENTION) groups.id IN ( SELECT group_id FROM category_groups JOIN groups g ON group_id = g.id WHERE category_id = ? AND user_count < ? ) SQL if @searching_user.present? category_groups = category_groups.members_visible_groups(@searching_user) end in_category = filtered_by_term_users.where(<<~SQL, category_groups.pluck(:id)) users.id IN ( SELECT gu.user_id FROM group_users gu WHERE group_id IN (?) LIMIT 200 ) SQL if @searching_user.present? in_category = in_category.where("users.id <> ?", @searching_user.id) end in_category .order("last_seen_at DESC NULLS LAST") .limit(@limit - users.size) .pluck(:id) .each { |id| users << id } end return users.to_a if users.size >= @limit # 4. global matches if @term.present? filtered_by_term_users .order("last_seen_at DESC NULLS LAST") .limit(@limit - users.size) .pluck(:id) .each { |id| users << id } end return users.to_a if users.size >= @limit # 5. last seen users (for search auto-suggestions) if @last_seen_users scoped_users .order("last_seen_at DESC NULLS LAST") .limit(@limit - users.size) .pluck(:id) .each { |id| users << id } end return users.to_a if users.size >= @limit # 6. similar usernames / names if @term.present? && SiteSetting.user_search_similar_results if SiteSetting.enable_names? scoped_users .where("username_lower <-> ? < 1 OR name <-> ? < 1", @term, @term) .order(["LEAST(username_lower <-> ?, name <-> ?) ASC", @term, @term]) .limit(@limit - users.size) .pluck(:id) .each { |id| users << id } else scoped_users .where("username_lower <-> ? < 1", @term) .order(["username_lower <-> ? ASC", @term]) .limit(@limit - users.size) .pluck(:id) .each { |id| users << id } end end users.to_a end def search ids = search_ids ids = DiscoursePluginRegistry.apply_modifier(:user_search_ids, ids) return User.none if ids.empty? results = User.joins( "JOIN (SELECT unnest uid, row_number() OVER () AS rn FROM unnest('{#{ids.join(",")}}'::int[]) ) x on uid = users.id", ).order("rn") results = results.includes(:user_status) if SiteSetting.enable_user_status results end end