spree_core 5.4.0.rc3 → 5.4.0.rc4.1

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/app/mailers/spree/webhook_mailer.rb +17 -0
  3. data/app/models/concerns/spree/metadata.rb +10 -0
  4. data/app/models/concerns/spree/product_scopes.rb +50 -16
  5. data/app/models/concerns/spree/search_indexable.rb +0 -2
  6. data/app/models/spree/data_feed/google.rb +19 -0
  7. data/app/models/spree/data_feed.rb +47 -0
  8. data/app/models/spree/metafield.rb +1 -1
  9. data/app/models/spree/metafield_definition.rb +1 -1
  10. data/app/models/spree/order/checkout.rb +0 -5
  11. data/app/models/spree/product.rb +1 -1
  12. data/app/models/spree/search_provider/base.rb +1 -1
  13. data/app/models/spree/search_provider/database.rb +14 -3
  14. data/app/models/spree/search_provider/meilisearch.rb +94 -8
  15. data/app/models/spree/store.rb +2 -0
  16. data/app/models/spree/variant.rb +3 -1
  17. data/app/models/spree/webhook_delivery.rb +28 -2
  18. data/app/models/spree/webhook_endpoint.rb +71 -1
  19. data/app/presenters/spree/data_feeds/base_presenter.rb +23 -0
  20. data/app/presenters/spree/data_feeds/google_presenter.rb +89 -0
  21. data/app/presenters/spree/search_provider/product_presenter.rb +9 -1
  22. data/app/services/spree/sample_data/loader.rb +7 -0
  23. data/app/services/spree/taxons/add_products.rb +3 -1
  24. data/app/services/spree/taxons/regenerate_products.rb +3 -1
  25. data/app/services/spree/taxons/remove_products.rb +3 -1
  26. data/app/views/spree/webhook_mailer/endpoint_disabled.html.erb +26 -0
  27. data/app/views/spree/webhook_mailer/endpoint_disabled.text.erb +10 -0
  28. data/config/locales/en.yml +20 -10
  29. data/db/migrate/20221229132350_create_spree_data_feed_settings.rb +14 -0
  30. data/db/migrate/20230415155958_rename_data_feed_settings_table.rb +5 -0
  31. data/db/migrate/20230415160828_rename_data_feed_table_columns.rb +7 -0
  32. data/db/migrate/20230415161226_add_indexes_to_data_feeds_table.rb +5 -0
  33. data/db/migrate/20230512094803_rename_data_feeds_column_provider_to_type.rb +5 -0
  34. data/db/migrate/20260326000001_improve_spree_webhooks.rb +17 -0
  35. data/db/sample_data/products.csv +1083 -1083
  36. data/lib/spree/core/engine.rb +5 -0
  37. data/lib/spree/core/version.rb +1 -1
  38. data/lib/spree/core.rb +8 -0
  39. data/lib/spree/permitted_attributes.rb +1 -1
  40. data/lib/spree/testing_support/factories/google_data_feed_factory.rb +7 -0
  41. data/lib/spree/testing_support/factories/product_factory.rb +1 -0
  42. data/lib/spree/testing_support/factories/webhook_endpoint_factory.rb +7 -0
  43. data/spec/fixtures/files/products_import.csv +2 -2
  44. metadata +18 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: feb2c702a17f0e10532d677c7c6cdb4a4092476ce33966db067ff095e0fd3d51
4
- data.tar.gz: 6cfb1613127976496083e9d11d5a073e20c7d94b139d2df2f621e4c18ae52936
3
+ metadata.gz: 0445fa1595f6f1abf4bc08b4f961ce9be32e84085e27cf64d0f85ffda00eec7f
4
+ data.tar.gz: 98519e65a1214f7b9a27e95c1cb29ecfcd5faea8b07f453bee23a96be92e2f29
5
5
  SHA512:
6
- metadata.gz: 8abff3fa43c5226f1eb1c3dd1a5d1bd84fedfd9ef62895da5df6402df1bd9e0fcb68a9fd646790d90ea5efa04292330aeab50e9269d1352a8970c2c8ff362d39
7
- data.tar.gz: 2cde221d1575d9d48c633d4aacdc588d775aff1445c0bdc5c895ff82e77444e3a5341d5fc1677d6c5013accd73f182f1e2ea5bb5b9532470d13dae4a7e316061
6
+ metadata.gz: cf023f4b0a51304733d7a502bc59df945e3b09c67124f7c8b0578deb8d5f29a1ec3b92bd6cbdbcb3681b818189eb8615342fb25d1c6051e9c85ea5c667af0c53
7
+ data.tar.gz: 8c3a926ca0ac60e7ca2f4e99eae97d2686175095ec414066e04d9d5631d92ca94bf28fb4e6678499cdf7b28994e9d05744eae34c737fd369cfff7fce43ebbbdf
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class WebhookMailer < BaseMailer
5
+ def endpoint_disabled(webhook_endpoint)
6
+ @endpoint = webhook_endpoint
7
+ @current_store = webhook_endpoint.store
8
+
9
+ mail(
10
+ to: @current_store.new_order_notifications_email.presence || @current_store.mail_from_address,
11
+ from: from_address,
12
+ subject: Spree.t('webhook_mailer.endpoint_disabled.subject', endpoint_name: @endpoint.name || @endpoint.url),
13
+ store_url: @current_store.formatted_url
14
+ )
15
+ end
16
+ end
17
+ end
@@ -22,6 +22,16 @@ module Spree
22
22
  self.private_metadata = value
23
23
  end
24
24
 
25
+ def public_metadata=(value)
26
+ unless value.blank? || value == {}
27
+ Spree::Deprecation.warn(
28
+ 'public_metadata is deprecated and will be removed in Spree 6.0. ' \
29
+ 'Use metadata instead. For customer-visible structured data, use metafields with display_on: \'both\'.'
30
+ )
31
+ end
32
+ super
33
+ end
34
+
25
35
  # https://nandovieira.com/using-postgresql-and-jsonb-with-ruby-on-rails
26
36
  class HashSerializer
27
37
  def self.dump(hash)
@@ -147,8 +147,32 @@ module Spree
147
147
  where("#{Classification.table_name}.taxon_id" => taxon.cached_self_and_descendants_ids).distinct
148
148
  }
149
149
 
150
- # Alias for in_taxon public API name
151
- scope :in_category, ->(category) { in_taxon(category) }
150
+ # Products in a category AND all its descendants.
151
+ # Accepts a Category record or a prefixed ID string (e.g. 'ctg_xxx').
152
+ def self.in_category(category_or_id)
153
+ category = category_or_id.is_a?(String) ? Spree::Taxon.find_by_prefix_id(category_or_id) : category_or_id
154
+ return none unless category
155
+
156
+ in_taxon(category)
157
+ end
158
+
159
+ # Products in ANY of the given categories (OR logic), each including descendants.
160
+ # Accepts an array of Category records, prefixed ID strings, or a mix.
161
+ def self.in_categories(*categories_or_ids)
162
+ categories_or_ids = categories_or_ids.flatten.compact
163
+ return none if categories_or_ids.empty?
164
+
165
+ ids, records = categories_or_ids.partition { |c| c.is_a?(String) }
166
+ if ids.any?
167
+ decoded = ids.filter_map { |id| Spree::Taxon.decode_prefixed_id(id) }
168
+ records += Spree::Taxon.where(id: decoded).to_a if decoded.any?
169
+ end
170
+ return none if records.empty?
171
+
172
+ taxon_ids = records.flat_map(&:cached_self_and_descendants_ids).uniq
173
+
174
+ joins(:classifications).where(Classification.table_name => { taxon_id: taxon_ids }).distinct
175
+ end
152
176
 
153
177
  # Deprecated — remove in 6.0. Use in_taxon instead.
154
178
  def self.in_taxons(*taxons)
@@ -200,21 +224,31 @@ module Spree
200
224
  where(Spree::OptionValue.table_name => { name: value, option_type_id: option_type_id })
201
225
  }
202
226
 
203
- # Filters products by option value IDs (prefix IDs like 'optval_xxx')
204
- # Accepts an array of option value IDs
205
- scope :with_option_value_ids, ->(*ids) {
227
+ # Filters products by option value IDs (prefix IDs like 'optval_xxx').
228
+ # Groups values by option type automatically:
229
+ # - Within the same option type: OR (Blue OR Red)
230
+ # - Across different option types: AND ((Blue OR Red) AND (S OR M))
231
+ def self.with_option_value_ids(*ids)
206
232
  ids = ids.flatten.compact
207
- next none if ids.empty?
208
-
209
- # Handle prefixed IDs (optval_xxx) by decoding to actual IDs
210
- actual_ids = ids.map do |id|
211
- id.to_s.include?('_') ? OptionValue.decode_prefixed_id(id) : id
212
- end.compact
213
-
214
- next none if actual_ids.empty?
215
-
216
- joins(variants: :option_values).where(Spree::OptionValue.table_name => { id: actual_ids })
217
- }
233
+ return none if ids.empty?
234
+
235
+ actual_ids = ids.map { |id| id.to_s.include?('_') ? OptionValue.decode_prefixed_id(id) : id }.compact
236
+ return none if actual_ids.empty?
237
+
238
+ grouped = OptionValue.where(id: actual_ids).group_by(&:option_type_id)
239
+ return none if grouped.empty?
240
+
241
+ scope = all
242
+ grouped.each_value do |option_values|
243
+ ov_ids = option_values.map(&:id)
244
+ matching_product_ids = Variant.where(deleted_at: nil)
245
+ .joins(:option_value_variants)
246
+ .where(OptionValueVariant.table_name => { option_value_id: ov_ids })
247
+ .select(:product_id)
248
+ scope = scope.where(id: matching_product_ids)
249
+ end
250
+ scope
251
+ end
218
252
 
219
253
  # Deprecated — remove in 6.0. Not used internally.
220
254
  def self.with(value)
@@ -45,8 +45,6 @@ module Spree
45
45
  end
46
46
  end
47
47
 
48
- private
49
-
50
48
  def enqueue_search_index
51
49
  return unless search_indexing_enabled?
52
50
 
@@ -0,0 +1,19 @@
1
+ require_dependency 'spree/data_feed'
2
+
3
+ module Spree
4
+ class DataFeed::Google < DataFeed
5
+ class << self
6
+ def label
7
+ 'Google Merchant Center Feed'
8
+ end
9
+
10
+ def provider_name
11
+ 'google'
12
+ end
13
+
14
+ def presenter_class
15
+ Spree::DataFeeds::GooglePresenter
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,47 @@
1
+ module Spree
2
+ class DataFeed < Spree.base_class
3
+ has_prefix_id :df
4
+
5
+ belongs_to :store, class_name: 'Spree::Store', foreign_key: 'store_id'
6
+
7
+ scope :for_store, ->(store) { where(store: store) }
8
+ scope :active, -> { where(active: true) }
9
+
10
+ before_validation :generate_slug
11
+
12
+ with_options presence: true do
13
+ validates :store
14
+ validates :name, uniqueness: true
15
+ validates :slug, uniqueness: { scope: :store_id }
16
+ end
17
+
18
+ def formatted_url
19
+ "#{store.formatted_url}/api/v3/store/feeds/#{slug}.xml"
20
+ end
21
+
22
+ private
23
+
24
+ def generate_slug
25
+ new_slug = slug.blank? ? SecureRandom.uuid : slug.parameterize
26
+ write_attribute(:slug, new_slug)
27
+ end
28
+
29
+ class << self
30
+ def label
31
+ raise NotImplementedError
32
+ end
33
+
34
+ def provider_name
35
+ raise NotImplementedError
36
+ end
37
+
38
+ def presenter_class
39
+ raise NotImplementedError
40
+ end
41
+
42
+ def available_types
43
+ Spree.data_feed_types
44
+ end
45
+ end
46
+ end
47
+ end
@@ -1,6 +1,6 @@
1
1
  module Spree
2
2
  class Metafield < Spree.base_class
3
- has_prefix_id :mf
3
+ has_prefix_id :cf
4
4
 
5
5
  #
6
6
  # Associations
@@ -1,6 +1,6 @@
1
1
  module Spree
2
2
  class MetafieldDefinition < Spree.base_class
3
- has_prefix_id :mfdef
3
+ has_prefix_id :cfdef
4
4
 
5
5
  include Spree::DisplayOn
6
6
 
@@ -122,7 +122,6 @@ module Spree
122
122
  after_transition to: :complete, do: :use_all_coupon_codes
123
123
  after_transition to: :complete, do: :redeem_gift_card
124
124
  after_transition to: :complete, do: :subscribe_to_newsletter
125
- after_transition to: :complete, do: :publish_order_completed_event
126
125
 
127
126
  after_transition from: any - :cart, to: any - [:confirm, :complete] do |order|
128
127
  order.update_totals
@@ -133,10 +132,6 @@ module Spree
133
132
  alias_method :save_state, :save
134
133
  end
135
134
 
136
- def publish_order_completed_event
137
- publish_event('order.completed')
138
- end
139
-
140
135
  def subscribe_to_newsletter
141
136
  return unless accept_marketing?
142
137
 
@@ -200,7 +200,7 @@ module Spree
200
200
  self.whitelisted_ransackable_attributes = %w[description name slug discontinue_on status available_on created_at updated_at]
201
201
  self.whitelisted_ransackable_associations = %w[taxons categories stores variants_including_master master variants tags labels
202
202
  shipping_category classifications option_types]
203
- self.whitelisted_ransackable_scopes = %w[not_discontinued search_by_name in_taxon price_between
203
+ self.whitelisted_ransackable_scopes = %w[not_discontinued search_by_name in_taxon in_category in_categories price_between
204
204
  price_lte price_gte
205
205
  search multi_search in_stock out_of_stock with_option_value_ids
206
206
 
@@ -17,7 +17,7 @@ module Spree
17
17
  #
18
18
  # @param scope [ActiveRecord::Relation] base scope (store-scoped, visibility-filtered, authorized)
19
19
  # @param query [String, nil] text search query
20
- # @param filters [Hash] structured filters (price_gte, with_option_value_ids, categories_id_eq, etc.)
20
+ # @param filters [Hash] structured filters (price_gte, with_option_value_ids, in_category, in_categories, etc.)
21
21
  # @param sort [String, nil] sort param (e.g. 'price', '-price', 'best_selling')
22
22
  # @param page [Integer] page number
23
23
  # @param limit [Integer] results per page
@@ -14,6 +14,9 @@ module Spree
14
14
  # 2. Extract internal params before passing to Ransack
15
15
  category = filters.is_a?(Hash) ? filters.delete('_category') || filters.delete(:_category) : nil
16
16
 
17
+ # 2b. Extract option value IDs — handled by scope (OR within type, AND across)
18
+ option_value_ids = filters.is_a?(Hash) ? filters.delete('with_option_value_ids') || filters.delete(:with_option_value_ids) : nil
19
+
17
20
  # 3. Structured filtering via Ransack
18
21
  ransack_filters = sanitize_filters(filters)
19
22
  if ransack_filters.present?
@@ -21,8 +24,14 @@ module Spree
21
24
  scope = search.result(distinct: true)
22
25
  end
23
26
 
27
+ # Save scope before option filters for disjunctive facet counts
28
+ scope_before_options = scope
29
+ if option_value_ids.present?
30
+ scope = scope.with_option_value_ids(Array(option_value_ids))
31
+ end
32
+
24
33
  # 4. Facets (before sorting to avoid computed column conflicts with count)
25
- filter_facets = build_facets(scope, category: category)
34
+ filter_facets = build_facets(scope, category: category, option_value_ids: Array(option_value_ids), scope_before_options: scope_before_options)
26
35
 
27
36
  # 5. Total count (before sorting to avoid computed column conflicts with count)
28
37
  total = scope.distinct.count
@@ -49,13 +58,15 @@ module Spree
49
58
  Pagy::Offset.new(count: count, page: page, limit: limit)
50
59
  end
51
60
 
52
- def build_facets(scope, category: nil)
61
+ def build_facets(scope, category: nil, option_value_ids: [], scope_before_options: nil)
53
62
  return { filters: [], sort_options: available_sort_options, default_sort: 'manual' } unless defined?(Spree::Api::V3::FiltersAggregator)
54
63
 
55
64
  Spree::Api::V3::FiltersAggregator.new(
56
65
  scope: scope,
57
66
  currency: currency,
58
- category: category
67
+ category: category,
68
+ option_value_ids: option_value_ids,
69
+ scope_before_options: scope_before_options || scope
59
70
  ).call
60
71
  end
61
72
 
@@ -20,9 +20,19 @@ module Spree
20
20
  def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
21
21
  page = [page.to_i, 1].max
22
22
  limit = limit.to_i.clamp(1, 100)
23
+ filters = filters.to_unsafe_h if filters.respond_to?(:to_unsafe_h)
24
+ filters = (filters || {}).stringify_keys
25
+
26
+ # Extract and group option values by option type for proper OR/AND semantics
27
+ option_value_ids = extract_and_delete(filters, 'with_option_value_ids')
28
+ grouped_options = group_option_values_by_type(Array(option_value_ids))
29
+
30
+ base_conditions = build_filters(filters)
31
+ option_conditions = build_grouped_option_conditions(grouped_options)
32
+ all_conditions = base_conditions + option_conditions
23
33
 
24
34
  search_params = {
25
- filter: build_filters(filters),
35
+ filter: all_conditions,
26
36
  facets: facet_attributes,
27
37
  sort: build_sort(sort),
28
38
  offset: (page - 1) * limit,
@@ -32,7 +42,55 @@ module Spree
32
42
  Rails.logger.debug { "[Meilisearch] index=#{index_name} query=#{query.inspect} #{search_params.compact.inspect}" }
33
43
 
34
44
  begin
35
- ms_result = client.index(index_name).search(query.to_s, search_params)
45
+ if grouped_options.any?
46
+ # N+1 multi-search: 1 hit query + 1 disjunctive facet query per active option type
47
+ queries = [{ indexUid: index_name, q: query.to_s, **search_params }]
48
+ option_type_ids_ordered = grouped_options.keys
49
+ option_type_ids_ordered.each do |option_type_id|
50
+ without_this = build_grouped_option_conditions(grouped_options.except(option_type_id))
51
+ queries << { indexUid: index_name, q: query.to_s, filter: base_conditions + without_this, facets: ['option_value_ids'], limit: 0 }
52
+ end
53
+
54
+ results = client.multi_search(queries)
55
+ ms_result = results['results'][0]
56
+
57
+ # Merge disjunctive counts per option type.
58
+ # Each disjunctive query excluded one option type's filter.
59
+ # Use that query's full option_value_ids distribution for that option type's values,
60
+ # and the main query's distribution for everything else.
61
+ main_ov_dist = ms_result.dig('facetDistribution', 'option_value_ids') || {}
62
+
63
+ # Build a set of prefixed IDs per option type (including unselected values)
64
+ # by looking up which option type each option value belongs to.
65
+ all_ov_prefixed_ids = Set.new
66
+ disjunctive_dists = {}
67
+ results['results'][1..].each_with_index do |r, idx|
68
+ dist = r.dig('facetDistribution', 'option_value_ids') || {}
69
+ disjunctive_dists[option_type_ids_ordered[idx]] = dist
70
+ all_ov_prefixed_ids.merge(dist.keys)
71
+ end
72
+
73
+ # Resolve which prefixed IDs belong to which option type
74
+ all_raw_ids = all_ov_prefixed_ids.filter_map { |pid| Spree::OptionValue.decode_prefixed_id(pid) }
75
+ ov_to_type = Spree::OptionValue.where(id: all_raw_ids).pluck(:id, :option_type_id).to_h
76
+ prefixed_to_type = all_ov_prefixed_ids.each_with_object({}) do |pid, h|
77
+ raw = Spree::OptionValue.decode_prefixed_id(pid)
78
+ h[pid] = ov_to_type[raw] if raw
79
+ end
80
+
81
+ # Start with main query's distribution, overlay disjunctive counts for active option types
82
+ merged_ov_dist = main_ov_dist.dup
83
+ disjunctive_dists.each do |option_type_id, dist|
84
+ dist.each do |pid, count|
85
+ merged_ov_dist[pid] = count if prefixed_to_type[pid] == option_type_id
86
+ end
87
+ end
88
+
89
+ facet_distribution = (ms_result['facetDistribution'] || {}).merge('option_value_ids' => merged_ov_dist)
90
+ else
91
+ ms_result = client.index(index_name).search(query.to_s, search_params)
92
+ facet_distribution = ms_result['facetDistribution'] || {}
93
+ end
36
94
  rescue ::Meilisearch::ApiError => e
37
95
  Rails.logger.warn { "[Meilisearch] Search failed: #{e.message}. Run `rake spree:search:reindex` to initialize the index." }
38
96
  Rails.error.report(e, handled: true, context: { index: index_name, query: query })
@@ -46,20 +104,17 @@ module Spree
46
104
  raw_ids = product_prefixed_ids.filter_map { |pid| Spree::Product.decode_prefixed_id(pid) }
47
105
 
48
106
  # Intersect with AR scope for security/visibility.
49
- # Since we filter by store/status/currency/discontinue_on in Meilisearch,
50
- # the AR scope is a safety net — it should not filter anything out.
51
107
  products = if raw_ids.any?
52
108
  scope.where(id: raw_ids).reorder(nil)
53
109
  else
54
110
  scope.none
55
111
  end
56
112
 
57
- # Build Pagy object from Meilisearch response (passive mode)
58
113
  pagy = build_pagy(ms_result, page, limit)
59
114
 
60
115
  SearchResult.new(
61
116
  products: products,
62
- filters: build_facet_response(ms_result['facetDistribution'] || {}),
117
+ filters: build_facet_response(facet_distribution),
63
118
  sort_options: available_sort_options.map { |id| { id: id } },
64
119
  default_sort: 'manual',
65
120
  total_count: ms_result['estimatedTotalHits'] || 0,
@@ -198,11 +253,42 @@ module Spree
198
253
  'in_stock = true' if value.to_s != '0'
199
254
  when 'out_of_stock'
200
255
  'in_stock = false' if value.to_s != '0'
201
- when 'categories_id_eq'
256
+ when 'in_category'
202
257
  "category_ids = '#{sanitize_prefixed_id(value)}'" if valid_prefixed_id?(value)
258
+ when 'in_categories'
259
+ parts = Array(value).filter_map { |id| "category_ids = '#{sanitize_prefixed_id(id)}'" if valid_prefixed_id?(id) }
260
+ parts.length > 1 ? "(#{parts.join(' OR ')})" : parts.first
203
261
  when 'with_option_value_ids'
204
- Array(value).filter_map { |ov| "option_value_ids = '#{sanitize_prefixed_id(ov)}'" if valid_prefixed_id?(ov) }
262
+ # Handled by grouped option conditions in search_and_filter — skip here
263
+ nil
264
+ end
265
+ end
266
+
267
+ # Group prefixed option value IDs by option type (single DB query).
268
+ # Returns { option_type_id => ['optval_abc', 'optval_def'], ... }
269
+ def group_option_values_by_type(prefixed_ids)
270
+ prefixed_ids = prefixed_ids.flatten.compact.select { |id| valid_prefixed_id?(id) }
271
+ return {} if prefixed_ids.empty?
272
+
273
+ raw_ids = prefixed_ids.filter_map { |id| Spree::OptionValue.decode_prefixed_id(id) }
274
+ Spree::OptionValue.where(id: raw_ids).group_by(&:option_type_id).transform_values { |ovs| ovs.map(&:prefixed_id) }
275
+ end
276
+
277
+ # Build Meilisearch filter conditions from grouped option values.
278
+ # OR within each option type, AND across option types.
279
+ def build_grouped_option_conditions(grouped)
280
+ grouped.map do |_, prefixed_ids|
281
+ parts = prefixed_ids.map { |id| "option_value_ids = '#{sanitize_prefixed_id(id)}'" }
282
+ parts.length > 1 ? "(#{parts.join(' OR ')})" : parts.first
283
+ end
284
+ end
285
+
286
+ def extract_and_delete(hash, *keys)
287
+ keys.each do |key|
288
+ value = hash.delete(key) || hash.delete(key.to_sym)
289
+ return value if value.present?
205
290
  end
291
+ nil
206
292
  end
207
293
 
208
294
  # Sort param to Meilisearch sort syntax.
@@ -91,6 +91,8 @@ module Spree
91
91
 
92
92
  has_many :wishlists, class_name: 'Spree::Wishlist'
93
93
 
94
+ has_many :data_feeds, class_name: 'Spree::DataFeed'
95
+
94
96
  belongs_to :default_country, class_name: 'Spree::Country'
95
97
  belongs_to :checkout_zone, class_name: 'Spree::Zone'
96
98
 
@@ -292,9 +292,11 @@ module Spree
292
292
  end
293
293
 
294
294
  # Returns first Image for Variant.
295
+ # @deprecated Use #primary_media instead.
295
296
  # @return [Spree::Image, nil]
296
297
  def primary_image
297
- images.first
298
+ Spree::Deprecation.warn('Spree::Variant#primary_image is deprecated and will be removed in Spree 6.0. Please use Spree::Variant#primary_media instead.')
299
+ primary_media
298
300
  end
299
301
 
300
302
  # Returns second Image for Variant (for hover effects).
@@ -42,21 +42,47 @@ module Spree
42
42
  delivered_at.nil?
43
43
  end
44
44
 
45
- # Mark delivery as completed with HTTP response
45
+ # Mark delivery as completed with HTTP response.
46
+ # Triggers auto-disable check on the endpoint after failures.
46
47
  #
47
48
  # @param response_code [Integer] HTTP response code
48
49
  # @param execution_time [Integer] time in milliseconds
49
50
  # @param response_body [String] response body from the webhook endpoint
50
51
  def complete!(response_code: nil, execution_time:, error_type: nil, request_errors: nil, response_body: nil)
52
+ is_success = response_code.present? && response_code.to_s.start_with?('2')
53
+
51
54
  update!(
52
55
  response_code: response_code,
53
56
  execution_time: execution_time,
54
57
  error_type: error_type,
55
58
  request_errors: request_errors,
56
59
  response_body: response_body,
57
- success: response_code.present? && response_code.to_s.start_with?('2'),
60
+ success: is_success,
58
61
  delivered_at: Time.current
59
62
  )
63
+
64
+ webhook_endpoint.check_auto_disable! unless is_success
65
+ end
66
+
67
+ # Create a new delivery with the same payload and queue it.
68
+ # Used to retry failed deliveries manually.
69
+ #
70
+ # @return [Spree::WebhookDelivery] the new delivery
71
+ def redeliver!
72
+ new_delivery = webhook_endpoint.webhook_deliveries.create!(
73
+ event_name: event_name,
74
+ event_id: nil, # new delivery, not a duplicate
75
+ payload: payload
76
+ )
77
+
78
+ new_delivery.queue_for_delivery!
79
+ new_delivery
80
+ end
81
+
82
+ # Queue this delivery for processing.
83
+ # Resolves the job class dynamically since it lives in the api gem.
84
+ def queue_for_delivery!
85
+ 'Spree::WebhookDeliveryJob'.constantize.perform_later(id)
60
86
  end
61
87
  end
62
88
  end
@@ -19,12 +19,18 @@ module Spree
19
19
  validates :store, :url, presence: true
20
20
  validates :url, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]), message: :invalid_url }
21
21
  validates :active, inclusion: { in: [true, false] }
22
- validate :url_must_not_resolve_to_private_ip, if: -> { url.present? && url_changed? }
22
+ validate :url_must_not_resolve_to_private_ip, if: -> { !Rails.env.development? && url.present? && url_changed? }
23
23
 
24
24
  before_create :generate_secret_key
25
25
 
26
+ self.whitelisted_ransackable_attributes = %w[name url active]
27
+
26
28
  scope :active, -> { where(active: true) }
27
29
  scope :inactive, -> { where(active: false) }
30
+ scope :enabled, -> { active.where(disabled_at: nil) }
31
+
32
+ # Number of consecutive failed deliveries before auto-disabling
33
+ AUTO_DISABLE_THRESHOLD = 15
28
34
 
29
35
  # Check if this endpoint is subscribed to a specific event
30
36
  #
@@ -52,6 +58,70 @@ module Spree
52
58
  subscriptions
53
59
  end
54
60
 
61
+ # Send a test/ping webhook to verify the endpoint is reachable.
62
+ # Creates a delivery record and queues it for delivery.
63
+ #
64
+ # @return [Spree::WebhookDelivery]
65
+ def send_test!
66
+ delivery = webhook_deliveries.create!(
67
+ event_name: 'webhook.test',
68
+ payload: {
69
+ id: SecureRandom.uuid,
70
+ name: 'webhook.test',
71
+ created_at: Time.current.iso8601,
72
+ data: { message: 'This is a test webhook from Spree.' },
73
+ metadata: { spree_version: Spree.version }
74
+ }
75
+ )
76
+
77
+ delivery.queue_for_delivery!
78
+ delivery
79
+ end
80
+
81
+ # Disable this endpoint due to repeated failures.
82
+ # Sends a notification email to the store staff.
83
+ #
84
+ # @param reason [String]
85
+ # @param notify [Boolean] whether to send an email notification (default: true)
86
+ def disable!(reason: 'Automatically disabled after repeated delivery failures', notify: true)
87
+ update!(active: false, disabled_reason: reason, disabled_at: Time.current)
88
+ Spree::WebhookMailer.endpoint_disabled(self).deliver_later if notify
89
+ end
90
+
91
+ # Re-enable a previously disabled endpoint.
92
+ def enable!
93
+ update!(active: true, disabled_reason: nil, disabled_at: nil)
94
+ end
95
+
96
+ # Check if the endpoint was auto-disabled
97
+ #
98
+ # @return [Boolean]
99
+ def auto_disabled?
100
+ disabled_at.present?
101
+ end
102
+
103
+ # Check if auto-disable threshold has been reached
104
+ # and disable if so.
105
+ def check_auto_disable!
106
+ return if auto_disabled?
107
+
108
+ consecutive_failures = webhook_deliveries
109
+ .where(success: false)
110
+ .where.not(delivered_at: nil)
111
+ .order(delivered_at: :desc)
112
+ .limit(AUTO_DISABLE_THRESHOLD)
113
+
114
+ return if consecutive_failures.count < AUTO_DISABLE_THRESHOLD
115
+
116
+ # Verify they're all failures (no successes interspersed)
117
+ last_success = webhook_deliveries.successful.order(delivered_at: :desc).pick(:delivered_at)
118
+ oldest_failure = consecutive_failures.last&.delivered_at
119
+
120
+ if last_success.nil? || (oldest_failure && oldest_failure > last_success)
121
+ disable!
122
+ end
123
+ end
124
+
55
125
  private
56
126
 
57
127
  def generate_secret_key
@@ -0,0 +1,23 @@
1
+ module Spree
2
+ module DataFeeds
3
+ class BasePresenter
4
+ def initialize(data_feed)
5
+ @data_feed = data_feed
6
+ @store = data_feed.store
7
+ end
8
+
9
+ attr_reader :data_feed, :store
10
+
11
+ # @return [String] the feed content (XML, CSV, etc.)
12
+ def call
13
+ raise NotImplementedError
14
+ end
15
+
16
+ private
17
+
18
+ def products
19
+ store.products.active
20
+ end
21
+ end
22
+ end
23
+ end