spree_core 5.4.0.beta8 → 5.4.0.beta10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/app/finders/spree/products/find.rb +1 -1
  3. data/app/jobs/spree/search_provider/index_job.rb +22 -0
  4. data/app/jobs/spree/search_provider/remove_job.rb +19 -0
  5. data/app/models/concerns/spree/product_scopes.rb +95 -135
  6. data/app/models/concerns/spree/search_indexable.rb +83 -0
  7. data/app/models/concerns/spree/{multi_searchable.rb → searchable.rb} +10 -3
  8. data/app/models/concerns/spree/user_methods.rb +11 -8
  9. data/app/models/spree/address.rb +3 -15
  10. data/app/models/spree/credit_card.rb +1 -0
  11. data/app/models/spree/inventory_unit.rb +1 -1
  12. data/app/models/spree/line_item.rb +4 -0
  13. data/app/models/spree/metafield_definition.rb +7 -4
  14. data/app/models/spree/option_type.rb +3 -0
  15. data/app/models/spree/option_value.rb +3 -0
  16. data/app/models/spree/order.rb +27 -9
  17. data/app/models/spree/order_promotion.rb +1 -1
  18. data/app/models/spree/product.rb +9 -31
  19. data/app/models/spree/refresh_token.rb +60 -0
  20. data/app/models/spree/search_provider/base.rb +81 -0
  21. data/app/models/spree/search_provider/database.rb +95 -0
  22. data/app/models/spree/search_provider/meilisearch.rb +302 -0
  23. data/app/models/spree/search_provider/search_result.rb +5 -0
  24. data/app/models/spree/shipment.rb +1 -1
  25. data/app/models/spree/shipping_method.rb +1 -1
  26. data/app/models/spree/shipping_rate.rb +1 -1
  27. data/app/models/spree/taxon.rb +2 -7
  28. data/app/models/spree/variant.rb +6 -25
  29. data/app/models/spree/wishlist.rb +1 -0
  30. data/app/presenters/spree/search_provider/product_presenter.rb +131 -0
  31. data/app/services/spree/carts/update.rb +5 -4
  32. data/db/migrate/20260317000000_create_spree_refresh_tokens.rb +16 -0
  33. data/db/sample_data/payment_methods.rb +2 -0
  34. data/lib/spree/core/version.rb +1 -1
  35. data/lib/spree/core.rb +16 -3
  36. data/lib/tasks/search.rake +15 -0
  37. metadata +30 -6
  38. data/app/models/spree/cart_promotion.rb +0 -7
@@ -83,6 +83,8 @@ module Spree
83
83
  delegate :abbr, to: :state, prefix: true, allow_nil: true
84
84
 
85
85
  alias_attribute :postal_code, :zipcode
86
+ alias_attribute :first_name, :firstname
87
+ alias_attribute :last_name, :lastname
86
88
 
87
89
  # Writer methods for API convenience - these set country/state from ISO/abbr codes
88
90
  # The reader methods (country_iso, state_abbr) are delegates to country.iso and state.abbr
@@ -111,21 +113,7 @@ module Spree
111
113
  user.present? && id == user.ship_address_id
112
114
  end
113
115
 
114
- def first_name
115
- firstname
116
- end
117
-
118
- def first_name=(value)
119
- self.firstname = value
120
- end
121
-
122
- def last_name
123
- lastname
124
- end
125
-
126
- def last_name=(value)
127
- self.lastname = value
128
- end
116
+ # first_name / last_name aliases are defined via alias_attribute above
129
117
 
130
118
  def full_name
131
119
  "#{firstname} #{lastname}".strip
@@ -48,6 +48,7 @@ module Spree
48
48
  scope :not_removed, -> { where(deleted_at: nil) }
49
49
 
50
50
  alias_attribute :brand, :cc_type
51
+ alias_attribute :last4, :last_digits
51
52
 
52
53
  store_accessor :private_metadata, :wallet
53
54
 
@@ -1,6 +1,6 @@
1
1
  module Spree
2
2
  class InventoryUnit < Spree.base_class
3
- has_prefix_id :iu
3
+ has_prefix_id :fi
4
4
 
5
5
  extend Spree::DisplayMoney
6
6
 
@@ -102,6 +102,10 @@ module Spree
102
102
  alias single_money display_price
103
103
  alias single_display_amount display_price
104
104
 
105
+ # 5.5 API naming bridges (DB column rename in 6.0)
106
+ alias_attribute :discount_total, :promo_total
107
+ alias display_discount_total display_promo_total
108
+
105
109
  def discounted_price
106
110
  return price if quantity.zero?
107
111
 
@@ -23,10 +23,10 @@ module Spree
23
23
  # Scopes
24
24
  #
25
25
  scope :for_resource_type, ->(resource_type) { where(resource_type: resource_type) }
26
- scope :multi_search, ->(search) do
27
- return all if search.blank?
26
+ scope :search, ->(query) do
27
+ return all if query.blank?
28
28
 
29
- search_term = "%#{search.downcase}%"
29
+ search_term = "%#{query.downcase}%"
30
30
  namespace_condition = arel_table[:namespace].lower.matches(search_term)
31
31
  key_condition = arel_table[:key].lower.matches(search_term)
32
32
  name_condition = arel_table[:name].lower.matches(search_term)
@@ -34,6 +34,9 @@ module Spree
34
34
  where(namespace_condition.or(key_condition).or(name_condition))
35
35
  end
36
36
 
37
+ # Backward compatibility alias — remove in Spree 6.0
38
+ scope :multi_search, ->(*args) { search(*args) }
39
+
37
40
  #
38
41
  # Callbacks
39
42
  #
@@ -46,7 +49,7 @@ module Spree
46
49
  # Ransack
47
50
  #
48
51
  self.whitelisted_ransackable_attributes = %w[key namespace name resource_type display_on]
49
- self.whitelisted_ransackable_scopes = %w[multi_search]
52
+ self.whitelisted_ransackable_scopes = %w[search multi_search]
50
53
 
51
54
  # Returns the full key with namespace
52
55
  # @return [String] eg. custom.id
@@ -33,6 +33,9 @@ module Spree
33
33
  has_many :option_type_prototypes, class_name: 'Spree::OptionTypePrototype'
34
34
  has_many :prototypes, through: :option_type_prototypes, class_name: 'Spree::Prototype'
35
35
 
36
+ # 5.5 API naming bridge (DB column rename in 6.0)
37
+ alias_attribute :label, :presentation
38
+
36
39
  #
37
40
  # Validations
38
41
  #
@@ -28,6 +28,9 @@ module Spree
28
28
  has_many :variants, through: :option_value_variants, class_name: 'Spree::Variant'
29
29
  has_many :products, through: :variants, class_name: 'Spree::Product'
30
30
 
31
+ # 5.5 API naming bridge (DB column rename in 6.0)
32
+ alias_attribute :label, :presentation
33
+
31
34
  #
32
35
  # Validations
33
36
  #
@@ -32,7 +32,7 @@ module Spree
32
32
  include Spree::MemoizedData
33
33
  include Spree::Metafields
34
34
  include Spree::Metadata
35
- include Spree::MultiSearchable
35
+ include Spree::Searchable
36
36
  if defined?(Spree::Security::Orders)
37
37
  include Spree::Security::Orders
38
38
  end
@@ -54,6 +54,12 @@ module Spree
54
54
  alias display_ship_total display_shipment_total
55
55
  alias_attribute :ship_total, :shipment_total
56
56
 
57
+ # 5.5 API naming bridges (DB columns rename in 6.0)
58
+ alias_attribute :discount_total, :promo_total
59
+ alias display_discount_total display_promo_total
60
+ alias_attribute :customer_note, :special_instructions
61
+ alias_attribute :total_quantity, :item_count
62
+
57
63
  MONEY_THRESHOLD = 100_000_000
58
64
  MONEY_VALIDATION = {
59
65
  presence: true,
@@ -87,7 +93,7 @@ module Spree
87
93
  completed_at email number state payment_state shipment_state
88
94
  total item_total item_count considered_risky channel
89
95
  ]
90
- self.whitelisted_ransackable_scopes = %w[refunded partially_refunded multi_search]
96
+ self.whitelisted_ransackable_scopes = %w[refunded partially_refunded search multi_search]
91
97
 
92
98
  attr_reader :coupon_code
93
99
  attr_accessor :temporary_address
@@ -108,11 +114,13 @@ module Spree
108
114
  optional: true, dependent: :destroy
109
115
  alias_method :billing_address, :bill_address
110
116
  alias_method :billing_address=, :bill_address=
117
+ alias_attribute :billing_address_id, :bill_address_id
111
118
 
112
119
  belongs_to :ship_address, foreign_key: :ship_address_id, class_name: 'Spree::Address',
113
120
  optional: true, dependent: :destroy
114
121
  alias_method :shipping_address, :ship_address
115
122
  alias_method :shipping_address=, :ship_address=
123
+ alias_attribute :shipping_address_id, :ship_address_id
116
124
 
117
125
  belongs_to :store, class_name: 'Spree::Store'
118
126
 
@@ -139,7 +147,6 @@ module Spree
139
147
  inverse_of: :order
140
148
 
141
149
  has_many :order_promotions, class_name: 'Spree::OrderPromotion'
142
- has_many :cart_promotions, class_name: 'Spree::CartPromotion', foreign_key: :order_id
143
150
  has_many :promotions, through: :order_promotions, class_name: 'Spree::Promotion'
144
151
 
145
152
  has_many :shipments, class_name: 'Spree::Shipment', dependent: :destroy, inverse_of: :order do
@@ -150,10 +157,18 @@ module Spree
150
157
  has_many :shipment_adjustments, through: :shipments, source: :adjustments
151
158
 
152
159
  alias items line_items
160
+ alias discounts order_promotions
161
+ alias fulfillments shipments
162
+ alias_attribute :delivery_total, :shipment_total
163
+ alias display_delivery_total display_shipment_total
164
+ alias_attribute :fulfillment_status, :shipment_state
165
+ alias_attribute :payment_status, :payment_state
153
166
 
154
167
  accepts_nested_attributes_for :line_items
155
168
  accepts_nested_attributes_for :bill_address
156
169
  accepts_nested_attributes_for :ship_address
170
+ alias shipping_address_attributes= ship_address_attributes=
171
+ alias billing_address_attributes= bill_address_attributes=
157
172
  accepts_nested_attributes_for :payments, reject_if: :credit_card_nil_payment?
158
173
  accepts_nested_attributes_for :shipments
159
174
 
@@ -220,8 +235,8 @@ module Spree
220
235
  # shows completed orders first, by their completed_at date, then uncompleted orders by their created_at
221
236
  scope :reverse_chronological, -> { order(Arel.sql('spree_orders.completed_at IS NULL'), completed_at: :desc, created_at: :desc) }
222
237
 
223
- def self.multi_search(query)
224
- sanitized_query = sanitize_query_for_multi_search(query)
238
+ def self.search(query)
239
+ sanitized_query = sanitize_query_for_search(query)
225
240
  return none if query.blank?
226
241
 
227
242
  query_pattern = "%#{sanitized_query}%"
@@ -229,19 +244,22 @@ module Spree
229
244
  conditions = []
230
245
  conditions << arel_table[:number].lower.matches(query_pattern)
231
246
 
232
- conditions << multi_search_condition(Spree::Address, :firstname, sanitized_query)
233
- conditions << multi_search_condition(Spree::Address, :lastname, sanitized_query)
247
+ conditions << search_condition(Spree::Address, :firstname, sanitized_query)
248
+ conditions << search_condition(Spree::Address, :lastname, sanitized_query)
234
249
 
235
250
  full_name = NameOfPerson::PersonName.full(sanitized_query)
236
251
 
237
252
  if full_name.first.present? && full_name.last.present?
238
- conditions << multi_search_condition(Spree::Address, :firstname, full_name.first)
239
- conditions << multi_search_condition(Spree::Address, :lastname, full_name.last)
253
+ conditions << search_condition(Spree::Address, :firstname, full_name.first)
254
+ conditions << search_condition(Spree::Address, :lastname, full_name.last)
240
255
  end
241
256
 
242
257
  left_joins(:bill_address).where(arel_table[:email].lower.eq(query.downcase)).or(where(conditions.reduce(:or)))
243
258
  end
244
259
 
260
+ # Backward compatibility alias — remove in Spree 6.0
261
+ def self.multi_search(query) = search(query)
262
+
245
263
  # Find an order by prefixed ID first, falling back to number, then integer id for backwards compatibility
246
264
  # @param param [String] the prefixed ID, number, or integer id to search for
247
265
  # @return [Spree::Order, nil] the found order or nil
@@ -1,6 +1,6 @@
1
1
  module Spree
2
2
  class OrderPromotion < Spree.base_class
3
- has_prefix_id :oprom
3
+ has_prefix_id :discount
4
4
 
5
5
  belongs_to :order, class_name: 'Spree::Order'
6
6
  belongs_to :promotion, class_name: 'Spree::Promotion'
@@ -34,6 +34,7 @@ module Spree
34
34
  include Spree::Metadata
35
35
  include Spree::Product::Webhooks
36
36
  include Spree::Product::Slugs
37
+ include Spree::SearchIndexable
37
38
  if defined?(Spree::VendorConcern)
38
39
  include Spree::VendorConcern
39
40
  end
@@ -58,12 +59,6 @@ module Spree
58
59
 
59
60
  self::Translation.class_eval do
60
61
  normalizes :name, :meta_title, with: ->(value) { value&.to_s&.squish&.presence }
61
-
62
- if defined?(PgSearch)
63
- include PgSearch::Model
64
-
65
- pg_search_scope :search_by_name, against: { name: 'A', meta_title: 'B' }, using: { trigram: { threshold: 0.3, word_similarity: true } }
66
- end
67
62
  end
68
63
 
69
64
  # we need to have this callback before any dependent: :destroy associations
@@ -169,32 +164,15 @@ module Spree
169
164
  where("#{Spree::Price.table_name}.compare_at_amount > #{Spree::Price.table_name}.amount")
170
165
  }
171
166
 
172
- if defined?(PgSearch)
173
- scope :multi_search, lambda { |query, include_options = false|
174
- return none if query.blank?
167
+ scope :search, ->(query) {
168
+ next none if query.blank?
175
169
 
176
- product_ids = if Spree.use_translations?
177
- Spree::Product::Translation.search_by_name(query).pluck(:spree_product_id)
178
- else
179
- Spree::Product.search_by_name(query).ids
180
- end
170
+ product_ids = Spree::Variant.search_by_product_name_or_sku(query).pluck(:product_id)
171
+ where(id: product_ids.uniq.compact)
172
+ }
181
173
 
182
- variant_product_ids = if include_options.present?
183
- Spree::Variant.search_by_sku_or_options(query).pluck(:product_id)
184
- else
185
- Spree::Variant.search_by_sku(query).pluck(:product_id)
186
- end
187
-
188
- where(id: (product_ids + variant_product_ids).uniq.compact)
189
- }
190
- else
191
- scope :multi_search, lambda { |query|
192
- return none if query.blank?
193
-
194
- product_ids = Spree::Variant.search_by_product_name_or_sku(query).pluck(:product_id)
195
- where(id: product_ids.uniq.compact)
196
- }
197
- end
174
+ # Backward compatibility alias — remove in Spree 6.0
175
+ scope :multi_search, ->(*args) { search(*args) }
198
176
 
199
177
  scope :archivable, -> { where(status: %w[active draft]) }
200
178
  scope :by_source, ->(source) { send(source) }
@@ -224,7 +202,7 @@ module Spree
224
202
  shipping_category classifications option_types]
225
203
  self.whitelisted_ransackable_scopes = %w[not_discontinued search_by_name in_taxon price_between
226
204
  price_lte price_gte
227
- multi_search in_stock out_of_stock with_option_value_ids
205
+ search multi_search in_stock out_of_stock with_option_value_ids
228
206
 
229
207
  ascend_by_price descend_by_price]
230
208
 
@@ -0,0 +1,60 @@
1
+ module Spree
2
+ class RefreshToken < Spree.base_class
3
+ has_prefix_id :rt
4
+
5
+ belongs_to :user, polymorphic: true
6
+
7
+ has_secure_token :token
8
+
9
+ validates :user, :expires_at, presence: true
10
+
11
+ scope :active, -> { where('expires_at > ?', Time.current) }
12
+ scope :expired, -> { where('expires_at <= ?', Time.current) }
13
+
14
+ def expired?
15
+ expires_at <= Time.current
16
+ end
17
+
18
+ # Rotate: destroy this token and create a new one.
19
+ # Returns the new token.
20
+ def rotate!(request_env: {})
21
+ new_token = nil
22
+ transaction do
23
+ new_token = self.class.create!(
24
+ user: user,
25
+ expires_at: self.class.default_expiry.from_now,
26
+ ip_address: request_env[:ip_address] || ip_address,
27
+ user_agent: request_env[:user_agent] || user_agent
28
+ )
29
+ destroy!
30
+ end
31
+ new_token
32
+ end
33
+
34
+ # Create a refresh token for a user
35
+ def self.create_for(user, request_env: {})
36
+ create!(
37
+ user: user,
38
+ expires_at: default_expiry.from_now,
39
+ ip_address: request_env[:ip_address],
40
+ user_agent: request_env[:user_agent]
41
+ )
42
+ end
43
+
44
+ # Revoke all refresh tokens for a user (e.g., on password change)
45
+ def self.revoke_all_for(user)
46
+ where(user: user).delete_all
47
+ end
48
+
49
+ # Clean up expired tokens
50
+ def self.cleanup_expired!
51
+ expired.delete_all
52
+ end
53
+
54
+ def self.default_expiry
55
+ Spree::Api::Config[:refresh_token_expiry].seconds
56
+ rescue StandardError
57
+ 30.days
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,81 @@
1
+ module Spree
2
+ module SearchProvider
3
+ class Base
4
+ attr_reader :store
5
+
6
+ # Whether this provider requires background indexing jobs.
7
+ # Override in subclasses. Database provider returns false.
8
+ def self.indexing_required?
9
+ false
10
+ end
11
+
12
+ def initialize(store)
13
+ @store = store
14
+ end
15
+
16
+ # Search, filter, and return facets in one call.
17
+ #
18
+ # @param scope [ActiveRecord::Relation] base scope (store-scoped, visibility-filtered, authorized)
19
+ # @param query [String, nil] text search query
20
+ # @param filters [Hash] structured filters (price_gte, with_option_value_ids, categories_id_eq, etc.)
21
+ # @param sort [String, nil] sort param (e.g. 'price', '-price', 'best_selling')
22
+ # @param page [Integer] page number
23
+ # @param limit [Integer] results per page
24
+ # @return [SearchResult]
25
+ def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
26
+ raise NotImplementedError
27
+ end
28
+
29
+ # Index a product — called after product save. No-op for database provider.
30
+ #
31
+ # @param product [Spree::Product] the product to index
32
+ def index(product)
33
+ # no-op by default
34
+ end
35
+
36
+ # Remove a product from the index.
37
+ #
38
+ # @param product [Spree::Product] the product to remove
39
+ def remove(product)
40
+ # no-op by default
41
+ end
42
+
43
+ # Remove a document from the index by prefixed ID (used when record is already deleted).
44
+ #
45
+ # @param prefixed_id [String] the prefixed ID (e.g. 'prod_abc')
46
+ def remove_by_id(prefixed_id)
47
+ # no-op by default
48
+ end
49
+
50
+ # Index a batch of documents. Called by rake task with pre-serialized documents.
51
+ #
52
+ # @param documents [Array<Hash>] serialized product documents
53
+ def index_batch(documents)
54
+ # no-op by default
55
+ end
56
+
57
+ # Configure index settings (filterable, sortable, searchable attributes).
58
+ # Called by rake task before indexing. No-op for database provider.
59
+ def ensure_index_settings!
60
+ # no-op by default
61
+ end
62
+
63
+ # Bulk reindex — full catalog sync. Called manually or via rake task.
64
+ #
65
+ # @param scope [ActiveRecord::Relation] products to reindex (default: all in store)
66
+ def reindex(scope = nil)
67
+ # no-op by default
68
+ end
69
+
70
+ private
71
+
72
+ def locale
73
+ Spree::Current.locale || store.default_market&.default_locale || I18n.locale.to_s
74
+ end
75
+
76
+ def currency
77
+ Spree::Current.currency || store.default_market&.currency
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,95 @@
1
+ module Spree
2
+ module SearchProvider
3
+ class Database < Base
4
+ CUSTOM_SORT_SCOPES = {
5
+ 'price' => :ascend_by_price,
6
+ '-price' => :descend_by_price,
7
+ 'best_selling' => :by_best_selling
8
+ }.freeze
9
+
10
+ def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
11
+ # 1. Text search
12
+ scope = scope.search(query) if query.present?
13
+
14
+ # 2. Extract internal params before passing to Ransack
15
+ category = filters.is_a?(Hash) ? filters.delete('_category') || filters.delete(:_category) : nil
16
+
17
+ # 3. Structured filtering via Ransack
18
+ ransack_filters = sanitize_filters(filters)
19
+ if ransack_filters.present?
20
+ search = scope.ransack(ransack_filters)
21
+ scope = search.result(distinct: true)
22
+ end
23
+
24
+ # 4. Facets (before sorting to avoid computed column conflicts with count)
25
+ filter_facets = build_facets(scope, category: category)
26
+
27
+ # 5. Total count (before sorting to avoid computed column conflicts with count)
28
+ total = scope.distinct.count
29
+
30
+ # 6. Sorting + pagination
31
+ scope = apply_sort(scope, sort)
32
+ page = [page.to_i, 1].max
33
+ limit = limit.to_i.clamp(1, 100)
34
+ products = scope.offset((page - 1) * limit).limit(limit)
35
+
36
+ SearchResult.new(
37
+ products: products,
38
+ filters: filter_facets[:filters],
39
+ sort_options: filter_facets[:sort_options],
40
+ default_sort: filter_facets[:default_sort],
41
+ total_count: total,
42
+ pagy: build_pagy(total, page, limit)
43
+ )
44
+ end
45
+
46
+ private
47
+
48
+ def build_pagy(count, page, limit)
49
+ require 'pagy'
50
+ Pagy::Offset.new(count: count, page: page, limit: limit)
51
+ end
52
+
53
+ def build_facets(scope, category: nil)
54
+ return { filters: [], sort_options: available_sort_options, default_sort: 'manual' } unless defined?(Spree::Api::V3::FiltersAggregator)
55
+
56
+ Spree::Api::V3::FiltersAggregator.new(
57
+ scope: scope,
58
+ currency: currency,
59
+ category: category
60
+ ).call
61
+ end
62
+
63
+ def sanitize_filters(filters)
64
+ return {} if filters.blank?
65
+
66
+ filters = filters.to_unsafe_h if filters.respond_to?(:to_unsafe_h)
67
+ filters.except('search', :search, '_category', :_category)
68
+ end
69
+
70
+ def apply_sort(scope, sort)
71
+ return scope if sort.blank?
72
+
73
+ scope_method = CUSTOM_SORT_SCOPES[sort]
74
+ if scope_method
75
+ scope.reorder(nil).send(scope_method)
76
+ else
77
+ # Standard Ransack sort: 'name' → 'name asc', '-name' → 'name desc'
78
+ ransack_sort = sort.split(',').map { |field|
79
+ if field.start_with?('-')
80
+ "#{field[1..]} desc"
81
+ else
82
+ "#{field} asc"
83
+ end
84
+ }.join(',')
85
+
86
+ scope.ransack(s: ransack_sort).result
87
+ end
88
+ end
89
+
90
+ def available_sort_options
91
+ %w[price -price best_selling name -name -available_on available_on].map { |id| { id: id } }
92
+ end
93
+ end
94
+ end
95
+ end