diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 0b35f0bda..7043b4b7e 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -1,7 +1,7 @@ class SearchController < ApplicationController - before_action :authenticate_user!, only: %i[tags chat_channels] + before_action :authenticate_user!, only: %i[tags chat_channels reactions] before_action :format_integer_params - before_action :sanitize_params, only: %i[classified_listings] + before_action :sanitize_params, only: %i[classified_listings reactions] CLASSIFIED_LISTINGS_PARAMS = [ :category, @@ -13,6 +13,17 @@ class SearchController < ApplicationController }, ].freeze + REACTION_PARAMS = [ + :page, + :per_page, + :category, + :search_fields, + { + tag_names: [], + status: [] + }, + ].freeze + USER_PARAMS = %i[ search_fields page @@ -76,6 +87,14 @@ class SearchController < ApplicationController render json: { result: feed_docs } end + def reactions + result = Search::Reaction.search_documents( + params: reaction_params.merge(user_id: current_user.id).to_h, + ) + + render json: { result: result["reactions"], total: result["total"] } + end + private def feed_content_search @@ -111,6 +130,10 @@ class SearchController < ApplicationController params.permit(FEED_PARAMS) end + def reaction_params + params.permit(REACTION_PARAMS) + end + def format_integer_params params[:page] = params[:page].to_i if params[:page].present? params[:per_page] = params[:per_page].to_i if params[:per_page].present? diff --git a/app/services/search/query_builders/reaction.rb b/app/services/search/query_builders/reaction.rb new file mode 100644 index 000000000..be3a68203 --- /dev/null +++ b/app/services/search/query_builders/reaction.rb @@ -0,0 +1,84 @@ +module Search + module QueryBuilders + class Reaction < QueryBase + QUERY_KEYS = { + search_fields: [ + "reactable.tags.keywords_for_search", + "reactable.tags.name^3", + "reactable.body_text^2", + "reactable.title^6", + "reactable.user.name", + "reactable.user.username", + ] + }.freeze + + # Search keys from our controllers may not match what we have stored in Elasticsearch so we map them here, + # this allows us to change our Elasticsearch docs without worrying about the frontend + TERM_KEYS = { + category: "category", + tag_names: "reactable.tags.name", + user_id: "user_id", + status: "status" + }.freeze + + DEFAULT_PARAMS = { + sort_by: "id", + sort_direction: "desc", + size: 0 + }.freeze + + SOURCE = %i[ + id + reactable.path + reactable.published_date_string + reactable.reading_time + reactable.tags + reactable.title + reactable.user + ].freeze + + attr_accessor :params, :body + + def initialize(params:) + @params = params.deep_symbolize_keys + + # Default to only readinglist reactions + @params[:category] = "readinglist" + + build_body + end + + private + + def build_queries + @body[:query] = { bool: {} } + @body[:query][:bool][:filter] = terms_keys if terms_keys_present? + @body[:query][:bool][:must] = query_conditions if query_keys_present? + end + + def terms_keys_present? + TERM_KEYS.detect { |key, _| @params[key].present? } + end + + def terms_keys + TERM_KEYS.map do |term_key, search_key| + next unless @params.key? term_key + + { terms: { search_key => Array.wrap(@params[term_key]) } } + end.compact + end + + def query_hash(key, fields) + { + simple_query_string: { + query: key, + fields: fields, + lenient: true, + analyze_wildcard: true, + minimum_should_match: 2 + } + } + end + end + end +end diff --git a/app/services/search/reaction.rb b/app/services/search/reaction.rb index 1ab1fce4d..f95216569 100644 --- a/app/services/search/reaction.rb +++ b/app/services/search/reaction.rb @@ -7,6 +7,18 @@ module Search DEFAULT_PER_PAGE = 80 class << self + def search_documents(params:) + set_query_size(params) + query_hash = "Search::QueryBuilders::#{name.demodulize}".safe_constantize.new(params: params).as_hash + + results = search(body: query_hash) + hits = results.dig("hits", "hits").map { |hit| prepare_doc(hit) } + { + "reactions" => paginate_hits(hits, params), + "total" => results.dig("hits", "total", "value") + } + end + private def index_settings diff --git a/config/routes.rb b/config/routes.rb index e6cb5667b..83a17916b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -234,6 +234,7 @@ Rails.application.routes.draw do get "/search/classified_listings" => "search#classified_listings" get "/search/users" => "search#users" get "/search/feed_content" => "search#feed_content" + get "/search/reactions" => "search#reactions" get "/chat_channel_memberships/find_by_chat_channel_id" => "chat_channel_memberships#find_by_chat_channel_id" get "/listings/dashboard" => "classified_listings#dashboard" get "/listings/:category" => "classified_listings#index" diff --git a/spec/requests/search_spec.rb b/spec/requests/search_spec.rb index 8283497b1..9339263df 100644 --- a/spec/requests/search_spec.rb +++ b/spec/requests/search_spec.rb @@ -113,4 +113,31 @@ RSpec.describe "Search", type: :request, proper_status: true do expect(Search::FeedContent).to have_received(:search_documents) end end + + describe "GET /search/reactions" do + let(:authorized_user) { create(:user) } + let(:mock_response) do + { "reactions" => [{ id: 123 }], "total" => 100 } + end + + it "returns json with reactions and total" do + sign_in authorized_user + allow(Search::Reaction).to receive(:search_documents).and_return( + mock_response, + ) + get "/search/reactions" + expect(response.parsed_body).to eq("result" => [{ "id" => 123 }], "total" => 100) + end + + it "accepts array of tag names" do + sign_in authorized_user + allow(Search::Reaction).to receive(:search_documents).and_return( + mock_response, + ) + get "/search/reactions?tag_names[]=1&tag_names[]=2" + expect(Search::Reaction).to have_received( + :search_documents, + ).with(params: { "tag_names" => %w[1 2], "user_id" => authorized_user.id }) + end + end end diff --git a/spec/services/search/query_builders/reaction_spec.rb b/spec/services/search/query_builders/reaction_spec.rb new file mode 100644 index 000000000..6107804e0 --- /dev/null +++ b/spec/services/search/query_builders/reaction_spec.rb @@ -0,0 +1,90 @@ +require "rails_helper" + +RSpec.describe Search::QueryBuilders::Reaction, type: :service do + describe "::initialize" do + it "sets params" do + filter_params = { foo: "bar" } + filter = described_class.new(params: filter_params) + expect(filter.params).to include(filter_params) + end + + it "builds query body" do + filter = described_class.new(params: {}) + expect(filter.body).not_to be_nil + end + + it "sets category to readinglist" do + filter = described_class.new(params: {}) + expect(filter.params).to include(category: "readinglist") + end + end + + describe "#as_hash" do + let(:query_fields) { described_class::QUERY_KEYS[:search_fields] } + + it "applies QUERY_KEYS from params" do + params = { search_fields: "test" } + filter = described_class.new(params: params) + exepcted_query = [{ + "simple_query_string" => { + "query" => "test", + "fields" => query_fields, + "lenient" => true, + "analyze_wildcard" => true, + "minimum_should_match" => 2 + } + }] + expect(search_bool_clause(filter)["must"]).to match_array(exepcted_query) + end + + it "applies TERM_KEYS from params" do + params = { tag_names: "beginner", user_id: 777, status: "valid" } + filter = described_class.new(params: params) + exepcted_filters = [ + { "terms" => { "status" => ["valid"] } }, + { "terms" => { "reactable.tags.name" => ["beginner"] } }, + { "terms" => { "user_id" => [777] } }, + { "terms" => { "category" => ["readinglist"] } }, + ] + expect(search_bool_clause(filter)["filter"]).to match_array(exepcted_filters) + end + + it "applies QUERY_KEYS and TERM_KEYS from params" do + Timecop.freeze(Time.current) do + params = { search_fields: "ruby", tag_names: "cfp" } + filter = described_class.new(params: params) + exepcted_query = [{ + "simple_query_string" => { "query" => "ruby", "fields" => query_fields, "lenient" => true, "analyze_wildcard" => true, "minimum_should_match" => 2 } + }] + exepcted_filters = [ + { "terms" => { "reactable.tags.name" => ["cfp"] } }, + { "terms" => { "category" => ["readinglist"] } }, + ] + expect(search_bool_clause(filter)["must"]).to match_array(exepcted_query) + expect(search_bool_clause(filter)["filter"]).to match_array(exepcted_filters) + end + end + + it "ignores params we don't support" do + params = { not_supported: "trash", search_fields: "cfp" } + filter = described_class.new(params: params) + exepcted_query = [{ + "simple_query_string" => { + "query" => "cfp", "fields" => query_fields, "lenient" => true, "analyze_wildcard" => true, "minimum_should_match" => 2 + } + }] + expect(search_bool_clause(filter)["must"]).to match_array(exepcted_query) + end + + it "allows default params to be overriden" do + params = { sort_by: "status", sort_direction: "asc", size: 20 } + filter = described_class.new(params: params).as_hash + expect(filter.dig("sort")).to eq("status" => "asc") + expect(filter.dig("size")).to eq(20) + end + end + + def search_bool_clause(query_builder) + query_builder.as_hash.dig("query", "bool") + end +end diff --git a/spec/services/search/reaction_spec.rb b/spec/services/search/reaction_spec.rb index 6f995c31b..334f4a9f8 100644 --- a/spec/services/search/reaction_spec.rb +++ b/spec/services/search/reaction_spec.rb @@ -6,4 +6,102 @@ RSpec.describe Search::Reaction, type: :service do expect(described_class::INDEX_ALIAS).not_to be_nil expect(described_class::MAPPINGS).not_to be_nil end + + describe "::search_documents", elasticsearch: true do + let(:article1) { create(:article) } + let(:article2) { create(:article) } + let(:reaction1) { create(:reaction, category: "readinglist", reactable: article1) } + let(:reaction2) { create(:reaction, category: "readinglist", reactable: article2) } + let(:query_params) { { size: 5 } } + + it "parses reaction document hits from search response" do + mock_search_response = { "hits" => { "hits" => {} } } + allow(described_class).to receive(:search) { mock_search_response } + described_class.search_documents(params: {}) + expect(described_class).to have_received(:search).with(body: a_kind_of(Hash)) + end + + it "returns fields necessary for the view" do + view_keys = %w[id reactable] + reactable_keys = %w[title path published_date_string tags user] + user_keys = %w[username name profile_image_90] + index_documents([reaction1]) + + result = described_class.search_documents(params: { size: 1 }) + reaction_doc = result["reactions"].first + expect(result["total"]).to be_present + expect(reaction_doc.keys).to include(*view_keys) + expect(reaction_doc["reactable"].keys).to include(*reactable_keys) + expect(reaction_doc.dig("reactable", "user").keys).to include(*user_keys) + end + + context "with a query" do + it "searches by search_fields" do + allow(article1).to receive(:title).and_return("ruby") + allow(article2).to receive(:body_text).and_return("Ruby Tuesday") + index_documents([reaction1, reaction2]) + query_params[:search_fields] = "ruby" + + reaction_docs = described_class.search_documents(params: query_params)["reactions"] + expect(reaction_docs.count).to eq(2) + doc_ids = reaction_docs.map { |t| t.dig("id") } + expect(doc_ids).to include(reaction1.id, reaction2.id) + end + end + + context "with a filter term" do + it "filters by tag names" do + article1.tags << create(:tag, name: "ruby") + article2.tags << create(:tag, name: "python") + index_documents([reaction1, reaction2]) + query_params[:tag_names] = "ruby" + + reaction_docs = described_class.search_documents(params: query_params)["reactions"] + expect(reaction_docs.count).to eq(1) + doc_ids = reaction_docs.map { |t| t.dig("id") } + expect(doc_ids).to include(reaction1.id) + end + + it "filters by user_id" do + index_documents([reaction1, reaction2]) + query_params[:user_id] = reaction1.user_id + + reaction_docs = described_class.search_documents(params: query_params)["reactions"] + expect(reaction_docs.count).to eq(1) + doc_ids = reaction_docs.map { |t| t.dig("id") } + expect(doc_ids).to include(reaction1.id) + end + + it "filters by status" do + reaction1.update(status: "invalid") + index_documents([reaction1, reaction2]) + query_params[:status] = ["valid"] + + reaction_docs = described_class.search_documents(params: query_params)["reactions"] + expect(reaction_docs.count).to eq(1) + doc_ids = reaction_docs.map { |t| t.dig("id") } + expect(doc_ids).to include(reaction2.id) + end + + it "filters by category by default" do + reaction1.update(category: "like") + index_documents([reaction1, reaction2]) + + reaction_docs = described_class.search_documents(params: query_params)["reactions"] + expect(reaction_docs.count).to eq(1) + doc_ids = reaction_docs.map { |t| t.dig("id") } + expect(doc_ids).to include(reaction2.id) + end + end + + context "with default sorting" do + it "sorts by id" do + index_documents([reaction1, reaction2]) + + reaction_docs = described_class.search_documents(params: query_params)["reactions"] + doc_ids = reaction_docs.map { |t| t.dig("id") } + expect(doc_ids).to eq([reaction2.id, reaction1.id]) + end + end + end end