diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..7d3b02ad4 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +SEARCH_SERVER_URL="http://localhost:9200" \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 6f16202c1..47c55da05 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -39,7 +39,6 @@ PATH aws-sdk-s3 byebug dry-validation (~> 1.10) - elasticsearch (~> 8.5) exception_notification font-awesome-sass (~> 6.4.0) google-cloud-recaptcha_enterprise @@ -47,11 +46,13 @@ PATH interactor (~> 3.1) jwt (>= 2.5.0) noticed (~> 1.6) + oj + opensearch-ruby phonelib premailer-rails rails (~> 7.0.4) rqrcode (~> 2.0) - searchkick (~> 5.1) + searchkick (~> 5.3.1) simple_calendar (~> 2.4) spree (>= 4.5.0) spree_api_v1 (>= 4.5.0) @@ -147,6 +148,9 @@ GEM activerecord (>= 4.2) addressable (2.8.5) public_suffix (>= 2.0.2, < 6.0) + annotate (3.2.0) + activerecord (>= 3.2, < 8.0) + rake (>= 10.4, < 14.0) appraisal (2.5.0) bundler rake @@ -201,6 +205,7 @@ GEM execjs (~> 2.0) base64 (0.1.1) bcrypt (3.1.19) + bigdecimal (3.1.8) bootstrap (4.6.2) autoprefixer-rails (>= 9.1.0) popper_js (>= 1.16.1, < 2) @@ -226,7 +231,7 @@ GEM activesupport (>= 3.0.0) chunky_png (1.4.0) coderay (1.1.3) - concurrent-ruby (1.2.2) + concurrent-ruby (1.3.3) connection_pool (2.4.1) console (1.23.2) fiber-annotation @@ -297,14 +302,6 @@ GEM zeitwerk (~> 2.6) ecma-re-validator (0.4.0) regexp_parser (~> 2.2) - elastic-transport (8.3.0) - faraday (< 3) - multi_json - elasticsearch (8.10.0) - elastic-transport (~> 8) - elasticsearch-api (= 8.10.0) - elasticsearch-api (8.10.0) - multi_json erubi (1.12.0) exception_notification (4.5.0) actionmailer (>= 5.2, < 8) @@ -430,7 +427,7 @@ GEM domain_name (~> 0.5) http-form_data (2.3.0) httpclient (2.8.3) - i18n (1.14.1) + i18n (1.14.5) concurrent-ruby (~> 1.0) image_processing (1.12.2) mini_magick (>= 4.9.5, < 5) @@ -494,7 +491,7 @@ GEM mini_magick (4.12.0) mini_mime (1.1.5) mini_portile2 (2.8.4) - minitest (5.20.0) + minitest (5.24.1) monetize (1.12.0) money (~> 6.12) money (6.16.0) @@ -524,6 +521,11 @@ GEM octokit (4.25.1) faraday (>= 1, < 3) sawyer (~> 0.9) + oj (3.16.4) + bigdecimal (>= 3.0) + opensearch-ruby (3.3.0) + faraday (>= 1.0, < 3) + multi_json (>= 1.0) orm_adapter (0.5.0) os (1.1.4) parallel (1.23.0) @@ -680,7 +682,7 @@ GEM sawyer (0.9.2) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) - searchkick (5.3.0) + searchkick (5.3.1) activemodel (>= 6.1) hashie select2-rails (4.0.13) @@ -862,6 +864,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + annotate brakeman net-smtp pg diff --git a/app/models/concerns/spree_cm_commissioner/custom_product_serializable.rb b/app/models/concerns/spree_cm_commissioner/custom_product_serializable.rb new file mode 100644 index 000000000..319c93990 --- /dev/null +++ b/app/models/concerns/spree_cm_commissioner/custom_product_serializable.rb @@ -0,0 +1,30 @@ +module SpreeCmCommissioner + module CustomProductSerializable + def product_master_images(product, ignore_detailed_images=false) + product.master.images.map do |image| + data = { + id: image.id, + viewable_type: image.viewable_type, + viewable_id: image.viewable_id, + styles: [ image.image_style(:product) ], + } + + # Improve unnecesary loading v1.2.0 and maintain backward compatibility + data[:mobile_image_styles] = image.mobile_image_styles if ignore_detailed_images == false + data + end + end + + def product_vendor(product) + return {} if product.vendor.nil? + + { + id: product.vendor.id, + is_type_vmall: product.vendor.type_vmall?, + vendor_type: product.vendor.vendor_type, + slug: product.vendor.slug, + name: product.vendor.name, + } + end + end +end diff --git a/app/models/concerns/spree_cm_commissioner/product_indexable.rb b/app/models/concerns/spree_cm_commissioner/product_indexable.rb new file mode 100644 index 000000000..4adb4fdef --- /dev/null +++ b/app/models/concerns/spree_cm_commissioner/product_indexable.rb @@ -0,0 +1,130 @@ +module SpreeCmCommissioner + module ProductIndexable + extend ActiveSupport::Concern + + included do + searchkick word_middle: %i[name vendor_name], + searchable: %i[name vendor_name description], + text_middle: %i[description], + suggest: %i[name vendor_name] + end + + def review_rating + 0 + end + + def vendor_rating + 0 + end + + def impressions_count + 0 + end + + def conversions_count + 0 + end + + def whishedlists_count + 0 + end + + # [ variants_including_master: {option_values: { option_type: :translations } }] + def filterable_option_types_for_es_index + json = {} + + # [{ size: 'M', color: 'Red', conditon: 'Good'}, { size: 'L', color: 'Red', conditon: 'Good'}] + variants_including_master.each do |v| + v.option_values.each do |option_value| + option_type = option_value.option_type + next if !option_type.filterable? + + index_key = option_type.filter_name + json[index_key] ||= [] + json[index_key] << option_value.id + end + end + + json + end + + # product_properties: [:translations, :property] + # product_properties: [:translations, property: :translations ] + def filterable_properties_for_es + json = {} + + product_properties.includes(:property ).each do |pp| + # data is empty somehow. + next if !pp.property_id.present? + next if !pp.property.filterable? + + json[pp.property.filter_name] = pp.id + end + + json + end + + def vendor_for_es_index + { + vendor_id: vendor&.id, + vendor_slug: vendor&.slug, + vendor_name: vendor&.name + } + end + + def index_data + {} + end + + def search_data + # possible: Infinity + discount = compare_at_price.present? && compare_at_price.to_f > 0 ? (100 * (compare_at_price.to_f - price.to_f) / compare_at_price.to_f).to_i : 0 + + # possible: Infinity + on_hand = (total_on_hand.infinite? ? nil : total_on_hand) + + all_variants = variants_including_master.pluck(:id, :sku) + skus = all_variants.map(&:last).reject(&:blank?) + + es_fields = { + id: id, + name: name, + product_slug: slug, + description: description, + active: available?, + in_stock: in_stock?, + price: price.to_f, + compare_at_price: compare_at_price.to_f, + discount_amount: discount, + skus: skus, + total_on_hand: on_hand, + product_type: product_type, + + # boost user purchase + purchaser_ids: purchasers.uniq.pluck(:id), + + # meta data + created_at: created_at, + updated_at: updated_at, + + # analytics + impressions_count: impressions_count, + conversions_count: conversions_count, + whishedlists_count: whishedlists_count, + review_rating: review_rating, + vendor_rating: vendor_rating, + + # TODO: category, brand, industry + taxon_ids: taxon_and_ancestors.map(&:id), + taxon_names: taxon_and_ancestors.map(&:name) + } + + es_fields.merge!(filterable_option_types_for_es_index) + es_fields.merge!(filterable_properties_for_es) + es_fields.merge!(vendor_for_es_index) + es_fields.merge!(index_data) + + es_fields + end + end +end diff --git a/app/models/concerns/spree_cm_commissioner/product_searchable.rb b/app/models/concerns/spree_cm_commissioner/product_searchable.rb new file mode 100644 index 000000000..4d100a73e --- /dev/null +++ b/app/models/concerns/spree_cm_commissioner/product_searchable.rb @@ -0,0 +1,221 @@ +module SpreeCmCommissioner + module ProductSearchable + extend ActiveSupport::Concern + + PRICE_RANGES = [ + {to: 20.0}, + {from: 20.0, to: 50.0}, + {from: 50.0, to: 199.0}, + {from: 199.0 } + ].freeze + + class_methods do + def autocomplete(keyword, options = {}) + term = keyword.presence || '*' + + search_options = { + fields: autocomplete_fields, + load: false, + limit: 10, + misspellings: { below: 3 }, + where: where_options(options), + } + + search_options.merge!(boost_options(options)) + + result = search(term, **search_options) + result.map(&:name).map(&:strip) + end + + def boost_options(options) + { + boost_where: boost_where(options), + boost_by: boost_by(options), + boost_by_recency: boost_by_recency + } + end + + def autocomplete_fields + fields_options + end + + def fields_options + [ + { 'name^10': :word_middle }, + { 'vendor_name^5': :word_middle }, + { 'description^1': :text_middle } + ] + end + + def load_includes_assoc + [ + master: [ + :prices, + { images: { attachment_attachment: :blob }} + ], + ] + end + + def advanced_search_options(keyword_query, options = {}) + page = page(options) + search_options = { + fields: fields_options, + suggest: true, + operator: "or", + where: where_options(options), + page: page, + per_page: per_page(options), + } + + if options[:order_by].blank? + search_options.merge!(boost_options(options)) + else + search_options.merge!(order: order_options(options)) + end + + search_options[:misspellings] = keyword_query.length >= 10 ? false : ({ below: 3 }) + + if options[:ignore_aggs] == false + search_options.merge!(smart_aggs: true, aggs: aggregations) + end + + if options[:ignore_products] == false + search_options[:includes] = load_includes_assoc + end + + # must contains keywords ( no empty ) and the first page + if keyword_query.present? && keyword_query != "*" && page == 1 && options[:user_id].present? + search_options.merge!(track: { user_id: options[:user_id] }) + end + + Rails.logger.info { "search options: #{search_options.inspect}"} + search_options + end + + def advanced_search(keyword_query, options = {}) + search_options = advanced_search_options(keyword_query, options) + search(keyword_query, **search_options) + end + + def boost_where(options) + Rails.logger.info { "boost_where: #{options[:user_id]}" } + return { purchaser_ids: { value: options[:user_id], factor: 100 } } if options[:user_id].present? + + nil + end + + def boost_by_recency + { created_at: { scale: '7d', decay: 0.5 } } + end + + def boost_by(options) + result = { + conversions_count: { factor: 10 }, + review_rating: { factor: 5 }, + vendor_rating: { factor: 3 }, + whishedlists_count: { factor: 3 }, + impressions_count: { factor: 1 }, + } + + result[price] = { factor: 2 } if options[:boost_price].present? + result + end + + def where_options(options) + filters = { + active: true + } + + filters[:product_type] = options[:product_type] if options[:product_type].present? + filters[:vendor_id] = options[:vendor_id] if options[:vendor_id].present? + + filters + end + + def page(options) + return 1 if options[:page].blank? + + options[:page].to_i + end + + def per_page(options) + return 12 if options[:per_page].blank? + + options[:per_page].to_i + end + + def order_options(options) + order_params = {} + + order_column = options[:order_by] + + if order_column == 'newest-first' + order_params[:created_at] = :desc + elsif order_column == 'trending' + order_params[:conversions] = :desc + elsif order_column == 'discount' + order_params[:discount] = :desc + elsif order_column == 'price-low-to-high' + order_params[:price] = :asc + elsif order_column == 'price-high-to-low' + order_params[:price] = :desc + else + order_params[:_score] = :desc + end + + order_params + end + + # *-10.0, 10.0-20.0, 20.0-* + # gte, lte + def price_range(price) + return nil if price_param.blank? + + (from, to) = price_param.split('-') + if(from == '*') + { lt: to } + elsif (to == "*" ) + {gte: from } + else + { gte: from, lt: to } + end + end + + def aggregations + # query_options = { where: where_query } + # limit aggregations items to be 24 max + query_options = { min_doc_count: 1, limit: 24 } + aggs = { } + aggregation_fields = fetch_aggregation_fields + + aggregation_fields.each do |filter_name| + if filter_name == :price + aggs[filter_name] = { ranges: PRICE_RANGES } + else + aggs[filter_name] = query_options + end + end + + aggs + end + + def fetch_aggregation_fields + fields = [:vendor_id, :price] + + aggregation_classes = [ + Spree::Property, + Spree::OptionType + ] + + # property(1), option_type(2) => p_1, o_2 + aggregation_classes.each do |agg_class| + agg_class.filterable.each do |record| + fields << record.filter_name + end + end + + fields + end + end + end +end diff --git a/app/models/spree_cm_commissioner/option_type_decorator.rb b/app/models/spree_cm_commissioner/option_type_decorator.rb index 6b0a4e857..3d6dd439a 100644 --- a/app/models/spree_cm_commissioner/option_type_decorator.rb +++ b/app/models/spree_cm_commissioner/option_type_decorator.rb @@ -1,6 +1,10 @@ module SpreeCmCommissioner module OptionTypeDecorator def self.prepended(base) + def base.filter_separator + "fo_" + end + base.include SpreeCmCommissioner::ParameterizeName base.include SpreeCmCommissioner::OptionTypeAttrType @@ -14,6 +18,14 @@ def self.prepended(base) base.whitelisted_ransackable_attributes = %w[kind] end + + def filter_name + # name.downcase.to_s + # name_en = translations_by_locale[:en].name + # "fo_#{name_en.downcase.to_s}" + "#{Spree::OptionType.filter_separator}#{id}" + end + private def kind_has_updated diff --git a/app/models/spree_cm_commissioner/product_decorator.rb b/app/models/spree_cm_commissioner/product_decorator.rb index 9fffedede..ac5eb2966 100644 --- a/app/models/spree_cm_commissioner/product_decorator.rb +++ b/app/models/spree_cm_commissioner/product_decorator.rb @@ -2,6 +2,8 @@ module SpreeCmCommissioner module ProductDecorator def self.prepended(base) + base.include SpreeCmCommissioner::ProductIndexable + base.include SpreeCmCommissioner::ProductSearchable base.include SpreeCmCommissioner::ProductType base.include SpreeCmCommissioner::KycBitwise @@ -26,6 +28,8 @@ def self.prepended(base) # after finish purchase an order, user must complete these steps base.has_many :product_completion_steps, class_name: 'SpreeCmCommissioner::ProductCompletionStep', dependent: :destroy + base.has_many :purchasers, through: :orders, source: :user, class_name: 'Spree::User' + base.has_one :default_state, through: :vendor base.has_one :google_wallet, class_name: 'SpreeCmCommissioner::GoogleWallet', dependent: :destroy diff --git a/app/models/spree_cm_commissioner/property_decorator.rb b/app/models/spree_cm_commissioner/property_decorator.rb index 1010545f4..c93c275d0 100644 --- a/app/models/spree_cm_commissioner/property_decorator.rb +++ b/app/models/spree_cm_commissioner/property_decorator.rb @@ -2,6 +2,17 @@ module SpreeCmCommissioner module PropertyDecorator def self.prepended(base) base.include SpreeCmCommissioner::ParameterizeName + + def base.filter_separator + "fp_" + end + end + + def filter_name + # name.downcase.to_s + # name_en = translations_by_locale[:en].name + # "fp_#{name_en.downcase.to_s}" + "#{Spree::Property.filter_separator}#{id}" end end end diff --git a/app/services/spree_cm_commissioner/product_aggregation.rb b/app/services/spree_cm_commissioner/product_aggregation.rb new file mode 100644 index 000000000..6128cc6f5 --- /dev/null +++ b/app/services/spree_cm_commissioner/product_aggregation.rb @@ -0,0 +1,181 @@ +module SpreeCmCommissioner + class ProductAggregation + def initialize(aggs, options={}) + @aggs = aggs + @options = options + end + + def add_aggregator(agg_name) + buckets = @aggs[agg_name]['buckets'] + buckets = agg_bucket_display_name(agg_name,buckets) + + display_name = dynamic_display_name(agg_name) + aggregator = SpreeCmCommissioner::ProductAggregator.new(agg_name, display_name, buckets, @options) + @result << aggregator + end + + def dynamic_display_name(name) + if name == 'category_ids' + I18n.t('aggs.categories.name') + elsif name == 'brand_ids' + I18n.t('aggs.brands.name') + elsif name == 'price' + I18n.t('aggs.price.name') + elsif( property_agg?(name ) ) + id = name[3..-1] + @properties[id.to_i]&.name || name + elsif(option_type_agg?(name)) + id = name[3..-1] + @option_types[id.to_i]&.name || name + else + name + end + end + + def agg_bucket_display_name(agg_name, buckets) + buckets.each do |bucket| + object_id = bucket['key'] + if taxon_agg?(agg_name) + bucket['display_name'] = @categories_brands[object_id]&.name || bucket['key'] + elsif property_agg?(agg_name) + bucket['display_name'] = @product_properties[object_id]&.value || bucket['key'] + elsif option_type_agg?(agg_name) + bucket['display_name'] = @option_values[object_id]&.name || bucket['key'] + elsif price_agg?(agg_name) + bucket['display_name'] = SpreeCmCommissioner::PriceAggregator.new(bucket['key'], @options[:currency]).display + end + end + + buckets + end + + def any_filter_applied?(params) + @aggs.keys.each do |key| + return true if !params[key].nil? + end + false + end + + def call + return @result if !@result.nil? + @result = [] + load_filter_record_from_keys + + prioritized_keys = ['category_ids', 'brand_ids', 'price' ] + other_keys = @aggs.keys - prioritized_keys + keys = prioritized_keys + other_keys + + keys.each do |key| + add_aggregator(key) if @aggs[key].present? + end + + @result + end + + # prefixes: fp_, fo_ + def tranform_key_to_id(key) + key[3..-1] + end + + def taxon_agg?(key) + key == 'category_ids' || key == 'brand_ids' + end + + def price_agg?(key) + key == 'price' + end + + def property_agg?(key) + key.start_with?(Spree::Property.filter_separator) + end + + def option_type_agg?(key) + key.start_with?(Spree::OptionType.filter_separator) + end + + # fo_, fp_ + def load_filter_record_from_keys + # aggs field + option_type_ids = {} + property_ids = {} + + # aggs value + product_property_ids = {} + option_value_ids = {} + taxon_ids = {} + + @aggs.keys.each do |key| + next if key == 'price' + + # category and brand aggs + if taxon_agg?(key) + @aggs[key]['buckets'].each do |bucket| + id = bucket['key'] + taxon_ids[id] = id + end + + # property aggs + elsif property_agg?(key) + agg_field_id = tranform_key_to_id(key) + property_ids[agg_field_id] = agg_field_id + + @aggs[key]['buckets'].each do |bucket| + id = bucket['key'] + product_property_ids[id] = id + end + + # option type aggs + elsif option_type_agg?(key) + agg_field_id = tranform_key_to_id(key) + option_type_ids[agg_field_id] = agg_field_id + + @aggs[key]['buckets'].each do |bucket| + id = bucket['key'] + option_value_ids[id] = id + end + end + end + + # load categories and brands + if(taxon_ids.present?) + query_loader = Spree::Taxon.select('id').where(id: taxon_ids.keys) + @categories_brands = query_loader.index_by(&:id) + else + @categories_brands = {} + end + + # load properties + if(property_ids.present?) + query_loader = Spree::Property.select('id').where(id: property_ids.keys) + @properties = query_loader.index_by(&:id) + else + @properties = {} + end + + # load option types + if(option_type_ids.present?) + query_loader = Spree::OptionType.select('id').where(id: option_type_ids.keys) + @option_types = query_loader.index_by(&:id) + else + @option_types = {} + end + + # load product properties + if(product_property_ids.present?) + query_loader = Spree::ProductProperty.select('id').where(id: product_property_ids.keys) + @product_properties = query_loader.index_by(&:id) + else + @product_properties = {} + end + + # load option values + if(option_value_ids.present?) + query_loader = Spree::OptionValue.select('id').where(id: option_value_ids.keys) + @option_values = query_loader.index_by(&:id) + else + @option_values = {} + end + + end + end +end diff --git a/app/services/spree_cm_commissioner/product_aggregator.rb b/app/services/spree_cm_commissioner/product_aggregator.rb new file mode 100644 index 000000000..90d64ef46 --- /dev/null +++ b/app/services/spree_cm_commissioner/product_aggregator.rb @@ -0,0 +1,46 @@ +module SpreeCmCommissioner + + class ProductAggregator + attr_accessor :name, :display_name, :count, :buckets + + VISIBLE_BUCKET_SIZE = 5 + + def initialize(name, display_name, buckets, options = {}) + @name = name + @display_name = display_name + @buckets = buckets || [] + @options = options + end + + def count + return @count if @count.present? + + @count = 0 + + @buckets.each do |bucket| + @count += bucket['doc_count'] + end + + @count + end + + def should_show_more? + buckets.count > Vshop::ProductAggregator::VISIBLE_BUCKET_SIZE + end + + def should_display? + return true if name == 'category_ids' && @buckets.count > 0 + return true if name == 'brand_ids' && @buckets.count > 0 + @buckets.count > 1 + end + + def display_value(bucket) + if(name == 'price') + result = Vshop::PriceAggregator.new(bucket['key'], @options[:currency]).display + else + result = bucket['display_name'] || bucket['key'] + end + result + end + end +end diff --git a/app/services/spree_cm_commissioner/search_keyword.rb b/app/services/spree_cm_commissioner/search_keyword.rb new file mode 100644 index 000000000..cdb574fbc --- /dev/null +++ b/app/services/spree_cm_commissioner/search_keyword.rb @@ -0,0 +1,213 @@ +module SpreeCmCommissioner + class SearchKeyword + attr_reader :keywork, :options + + include ::Spree::Api::V2::DisplayMoneyHelper + include SpreeCmCommissioner::CustomProductSerializable + + # current_user:, current_currency:, reranking: , per_page:, + # ignore_aggs:, ignore_products:, keywords: + def initialize(keyword, options = {}) + @keyword = keyword + @options = options + + set_page + set_per_page + set_ignore_aggs + set_ignore_products + end + + def call + result = { + id: SecureRandom::hex, + aggregators: aggregators, + suggestions: suggestions, + products: products, + taxon: taxon, + meta: meta, + sjid: search_result.search&.id, + recommendation_id: recommendation_id, + } + + SearchKeywordResult.new(result) + end + + private + + def default_rerank_per_page + 12 + end + + def page + @options[:page] + end + + def per_page + @options[:per_page] + end + + def allow_search_rerank? + ENV['SEARCH_RERANK'] == 'yes' + end + + def enable_reranking? + return false if !allow_search_rerank? + @options[:reranking].present? && page == 1 + end + + def ignore_aggs? + @options[:ignore_aggs] + end + + def ignore_products? + @options[:ignore_products] + end + + def ignore_detailed_images? + @options[:ignore_detailed_images] == 'yes' + end + + def search_result + @search_result ||= Spree::Product.advanced_search(keyword, options) + end + + def taxon + return nil if ignore_products? + return nil if @options[:taxon].blank? + @taxon = Spree::Taxon.find(@options[:taxon]) + { + id: @taxon.id, + name: @taxon.name, + description: @taxon.description, + permalink: @taxon.permalink, + header_banner: @taxon.icon&.styles || [], + } + + # style_resize_and_pad(:large) + end + + def aggregators + return [] if ignore_aggs? + aggregation = SpreeCmCommissioner::ProductAggregation.new(search_result.aggs, currency: @options[:current_currency] ) + result = aggregation.call + result + end + + def suggestions + search_result.suggestions + end + + def meta + { + total_count: search_result.total_count, + total_pages: search_result.total_pages, + current_page: search_result.current_page, + } + end + + def product_ids + search_result.map(&:id) + end + + def products_index_by_id + return @products_index_by_id if !@products_index_by_id.nil? + @products_index_by_id = {} + + search_result.each do |product| + product_id = product.id + @products_index_by_id["#{product_id}"] = product_data(product) + end + + @products_index_by_id + end + + def product_data(product) + { + id: product.id, + sku: product.sku, + name: product.name, + slug: product.slug, + + available_on: product.available_on, + + price: SpreeCmCommissioner::SearchKeyword.price(product, @options[:current_currency]), + display_price: SpreeCmCommissioner::SearchKeyword.display_price(product, @options[:current_currency]), + compare_at_price: SpreeCmCommissioner::SearchKeyword.compare_at_price(product, @options[:current_currency]), + display_compare_at_price: SpreeCmCommissioner::SearchKeyword.display_compare_at_price(product, @options[:current_currency]), + currency: @options[:current_currency], + + event_start_date: product.event_start_date, + event_end_date: product.event_end_date, + event_discount: product.event_discount, + is_effective_flash_sale: product.is_effective_flash_sale?, + + images: product_master_images(product, ignore_detailed_images?), + vendor: product_vendor(product), + recommendation_id: recommendation_id + } + end + + def products_from_es + search_result.map {|product| product_data(product)} + end + + def user_id + @options[:user_id] || @options[:current_user]&.id + end + + def recommendation_id + @recommendation_id + end + + def products_from_reranking + products_from_es + end + + def total_count + search_result.total_count + end + + def rerankable? + return false if total_count < 10 + return false if !enable_reranking? + return false if !user_id + return false if @options[:keywords].blank? || @options[:keywords] == '*' + true + end + + def products + return [] if ignore_products? + rerankable? ? products_from_reranking : products_from_es + end + + def set_page + if @options[:page].present? && @options[:page].to_i > 1 + @options[:page] = @options[:page].to_i + else + @options[:page] = 1 + end + end + + def set_per_page + if @options[:per_page].present? + @options[:per_page] = @options[:per_page].to_i + elsif enable_reranking? + @options[:per_page] = default_rerank_per_page + else + @options[:per_page] = 12 + end + end + + def set_ignore_aggs + if @options[:ignore_aggs].nil? && page == 1 + @options[:ignore_aggs] = false + end + end + + def set_ignore_products + if @options[:ignore_products].nil? + @options[:ignore_products] = false + end + end + end +end diff --git a/app/services/spree_cm_commissioner/search_keyword_result.rb b/app/services/spree_cm_commissioner/search_keyword_result.rb new file mode 100644 index 000000000..f461e10c4 --- /dev/null +++ b/app/services/spree_cm_commissioner/search_keyword_result.rb @@ -0,0 +1,8 @@ +module Personalization + class SearchKeywordResult + attr_accessor :id, :aggregators, :suggestions, :products, :taxon, :sjid, :meta, :recommendation_id + + include ActiveModel::Serialization + # include AssignableFieldObject + end +end diff --git a/config/initializers/searchkick_decorator.rb b/config/initializers/searchkick_decorator.rb index 354d76813..8e207a2b2 100644 --- a/config/initializers/searchkick_decorator.rb +++ b/config/initializers/searchkick_decorator.rb @@ -1,4 +1,11 @@ -# TODO: research why searchkick doesn't comparitable with pagination -Searchkick::Relation.class_eval do - alias_method :per, :per_page unless respond_to?(:per) -end +# TODO: research why searchkick is not compatible with pagination +# Searchkick::Relation.class_eval do +# alias_method :per, :per_page unless respond_to?(:per) +# end + +Searchkick.client = OpenSearch::Client.new( + url: ENV['SEARCH_SERVER_URL'] || 'http://localhost:9200', + transport_options: { + request: { timeout: 600, open_timeout: 600 } + } +) diff --git a/lib/spree_cm_commissioner.rb b/lib/spree_cm_commissioner.rb index d1cbf9948..fc218bef7 100644 --- a/lib/spree_cm_commissioner.rb +++ b/lib/spree_cm_commissioner.rb @@ -13,9 +13,11 @@ require 'spree_cm_commissioner/s3_url_generator' require 'google/cloud/recaptcha_enterprise' +require 'opensearch-ruby' require 'searchkick' require 'twilio-ruby' -require 'elasticsearch' +# require 'elasticsearch' + require 'interactor' require 'phonelib' require 'jwt' diff --git a/spree_cm_commissioner.gemspec b/spree_cm_commissioner.gemspec index b1cf1645d..065955e4d 100644 --- a/spree_cm_commissioner.gemspec +++ b/spree_cm_commissioner.gemspec @@ -31,10 +31,15 @@ Gem::Specification.new do |s| s.add_dependency 'phonelib' s.add_dependency 'google-cloud-recaptcha_enterprise' s.add_dependency 'jwt', '>= 2.5.0' - s.add_dependency 'elasticsearch', '~> 8.5' + s.add_dependency 'interactor', '~> 3.1' s.add_dependency 'rails', '~> 7.0.4' - s.add_dependency 'searchkick', '~> 5.1' + + s.add_dependency 'oj' + s.add_dependency 'opensearch-ruby' + s.add_dependency 'searchkick', '~> 5.3.1' + # s.add_dependency 'typhoeus' + s.add_dependency 'twilio-ruby', '~> 5.48.0' s.add_dependency 'dry-validation', '~> 1.10' s.add_dependency 'byebug' @@ -49,6 +54,7 @@ Gem::Specification.new do |s| s.add_dependency "rqrcode", "~> 2.0" s.add_dependency "premailer-rails" + s.add_development_dependency 'annotate' s.add_development_dependency 'pg' s.add_development_dependency 'spree_dev_tools' s.metadata['rubygems_mfa_required'] = 'true'