spree_core 5.4.0.rc3 → 5.4.0.rc4

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: feb2c702a17f0e10532d677c7c6cdb4a4092476ce33966db067ff095e0fd3d51
4
- data.tar.gz: 6cfb1613127976496083e9d11d5a073e20c7d94b139d2df2f621e4c18ae52936
3
+ metadata.gz: 7bc3a50296fda5fe9d3f9cfa5ec4547b44e8dc7c718634f0de73314d55210479
4
+ data.tar.gz: 74757285458b4d4a322d5cdc0ea550c4fe7e9ca9de25095fba0371b6c1fb1e93
5
5
  SHA512:
6
- metadata.gz: 8abff3fa43c5226f1eb1c3dd1a5d1bd84fedfd9ef62895da5df6402df1bd9e0fcb68a9fd646790d90ea5efa04292330aeab50e9269d1352a8970c2c8ff362d39
7
- data.tar.gz: 2cde221d1575d9d48c633d4aacdc588d775aff1445c0bdc5c895ff82e77444e3a5341d5fc1677d6c5013accd73f182f1e2ea5bb5b9532470d13dae4a7e316061
6
+ metadata.gz: c9ce63a0797b37332944d4d1df8e60f132c27af526e970fd290f993d03b0d9fbf1fcbb1c2c3b55d37184cc10f72b884c79ec68b42398928cbc490a5b55a982c5
7
+ data.tar.gz: 14e3357af8ce8cdb75764e15b14759fe4ff37f132a32242b87c269d3137c6f41d11071415be24975c71e4086df5a45b5853840a68dca91ea9541857bd7b18d78
@@ -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
@@ -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)
@@ -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.
@@ -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
@@ -55,7 +55,7 @@ module Spree
55
55
  in_stock: product.in_stock?,
56
56
  store_ids: cached_store_ids,
57
57
  discontinue_on: product.discontinue_on&.to_i || 0,
58
- category_ids: product.taxons.map(&:prefixed_id),
58
+ category_ids: category_ids_with_ancestors,
59
59
  category_names: product.taxons.map { |t| translated(t, :name, fallback_locale) },
60
60
  option_type_ids: product.option_types.map(&:prefixed_id),
61
61
  option_type_names: product.option_types.map { |ot| translated(ot, :presentation, fallback_locale) },
@@ -103,6 +103,14 @@ module Spree
103
103
  @compare_at_cache[currency]
104
104
  end
105
105
 
106
+ # Include ancestor category IDs so filtering by a parent category
107
+ # matches products classified under its descendants.
108
+ def category_ids_with_ancestors
109
+ @category_ids_with_ancestors ||= product.taxons.flat_map { |t|
110
+ t.self_and_ancestors.map(&:prefixed_id)
111
+ }.uniq
112
+ end
113
+
106
114
  # Memoized — avoids N+1 when called per document
107
115
  def cached_store_ids
108
116
  @cached_store_ids ||= product.store_ids.map(&:to_s)
@@ -0,0 +1,26 @@
1
+ <h1><%= Spree.t('webhook_mailer.endpoint_disabled.heading') %></h1>
2
+
3
+ <p><%= Spree.t('webhook_mailer.endpoint_disabled.message', endpoint_name: @endpoint.name || @endpoint.url) %></p>
4
+
5
+ <table role="presentation" style="width: 100%; border-collapse: collapse; margin: 20px 0;">
6
+ <tr>
7
+ <td style="padding: 8px 0; color: #6b7280;"><strong><%= Spree.t('webhook_mailer.endpoint_disabled.url_label') %></strong></td>
8
+ <td style="padding: 8px 0;"><%= @endpoint.url %></td>
9
+ </tr>
10
+ <% if @endpoint.name.present? %>
11
+ <tr>
12
+ <td style="padding: 8px 0; color: #6b7280;"><strong><%= Spree.t('webhook_mailer.endpoint_disabled.name_label') %></strong></td>
13
+ <td style="padding: 8px 0;"><%= @endpoint.name %></td>
14
+ </tr>
15
+ <% end %>
16
+ <tr>
17
+ <td style="padding: 8px 0; color: #6b7280;"><strong><%= Spree.t('webhook_mailer.endpoint_disabled.reason_label') %></strong></td>
18
+ <td style="padding: 8px 0;"><%= @endpoint.disabled_reason %></td>
19
+ </tr>
20
+ <tr>
21
+ <td style="padding: 8px 0; color: #6b7280;"><strong><%= Spree.t('webhook_mailer.endpoint_disabled.disabled_at_label') %></strong></td>
22
+ <td style="padding: 8px 0;"><%= @endpoint.disabled_at&.strftime('%Y-%m-%d %H:%M %Z') %></td>
23
+ </tr>
24
+ </table>
25
+
26
+ <p><%= Spree.t('webhook_mailer.endpoint_disabled.instructions') %></p>
@@ -0,0 +1,10 @@
1
+ <%= Spree.t('webhook_mailer.endpoint_disabled.heading') %>
2
+
3
+ <%= Spree.t('webhook_mailer.endpoint_disabled.message', endpoint_name: @endpoint.name || @endpoint.url) %>
4
+
5
+ URL: <%= @endpoint.url %>
6
+ <% if @endpoint.name.present? %>Name: <%= @endpoint.name %><% end %>
7
+ Reason: <%= @endpoint.disabled_reason %>
8
+ Disabled at: <%= @endpoint.disabled_at&.strftime('%Y-%m-%d %H:%M %Z') %>
9
+
10
+ <%= Spree.t('webhook_mailer.endpoint_disabled.instructions') %>
@@ -2480,6 +2480,16 @@ en:
2480
2480
  we_are_busy_updating_page: We're busy updating this page.
2481
2481
  we_will_be_back: We will be back.
2482
2482
  webhook_endpoints: Webhook Endpoints
2483
+ webhook_mailer:
2484
+ endpoint_disabled:
2485
+ disabled_at_label: Disabled at
2486
+ heading: Webhook Endpoint Disabled
2487
+ instructions: Please check the endpoint URL and service, then re-enable it in Settings → Developers → Webhooks.
2488
+ message: The webhook endpoint "%{endpoint_name}" has been automatically disabled after repeated delivery failures.
2489
+ name_label: Name
2490
+ reason_label: Reason
2491
+ subject: 'Webhook endpoint disabled: %{endpoint_name}'
2492
+ url_label: URL
2483
2493
  webhooks: Webhooks
2484
2494
  weight: Weight
2485
2495
  weight_unit: Weight unit
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ImproveSpreeWebhooks < ActiveRecord::Migration[7.2]
4
+ def change
5
+ # Endpoint name + auto-disable tracking
6
+ add_column :spree_webhook_endpoints, :name, :string
7
+ add_column :spree_webhook_endpoints, :disabled_reason, :string
8
+ add_column :spree_webhook_endpoints, :disabled_at, :datetime
9
+
10
+ # Event ID for delivery deduplication
11
+ add_column :spree_webhook_deliveries, :event_id, :string
12
+ add_index :spree_webhook_deliveries, [:webhook_endpoint_id, :event_id],
13
+ unique: true,
14
+ where: 'event_id IS NOT NULL',
15
+ name: 'index_spree_webhook_deliveries_on_endpoint_and_event'
16
+ end
17
+ end
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '5.4.0.rc3'.freeze
2
+ VERSION = '5.4.0.rc4'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
@@ -319,7 +319,7 @@ module Spree
319
319
  }
320
320
  ]
321
321
 
322
- @@webhook_endpoint_attributes = [:url, :secret, :active, subscriptions: []]
322
+ @@webhook_endpoint_attributes = [:name, :url, :secret, :active, subscriptions: []]
323
323
 
324
324
  @@wishlist_attributes = [:name, :is_default, :is_private]
325
325
 
@@ -3,6 +3,7 @@
3
3
  FactoryBot.define do
4
4
  factory :webhook_endpoint, class: Spree::WebhookEndpoint do
5
5
  store
6
+ sequence(:name) { |n| "Endpoint #{n}" }
6
7
  sequence(:url) { |n| "https://example.com/webhooks/#{n}" }
7
8
  active { true }
8
9
  subscriptions { [] }
@@ -18,5 +19,11 @@ FactoryBot.define do
18
19
  trait :all_events do
19
20
  subscriptions { ['*'] }
20
21
  end
22
+
23
+ trait :auto_disabled do
24
+ active { false }
25
+ disabled_at { Time.current }
26
+ disabled_reason { 'Automatically disabled after repeated delivery failures' }
27
+ end
21
28
  end
22
29
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_core
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.4.0.rc3
4
+ version: 5.4.0.rc4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean Schofield
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2026-03-25 00:00:00.000000000 Z
13
+ date: 2026-03-26 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: i18n-tasks
@@ -869,6 +869,7 @@ files:
869
869
  - app/mailers/spree/export_mailer.rb
870
870
  - app/mailers/spree/invitation_mailer.rb
871
871
  - app/mailers/spree/report_mailer.rb
872
+ - app/mailers/spree/webhook_mailer.rb
872
873
  - app/models/acts_as_taggable_on/tag_decorator.rb
873
874
  - app/models/concerns/spree/adjustment_source.rb
874
875
  - app/models/concerns/spree/admin_user_methods.rb
@@ -1336,6 +1337,8 @@ files:
1336
1337
  - app/views/spree/shared/_mailer_line_item.html.erb
1337
1338
  - app/views/spree/shared/_mailer_logo.html.erb
1338
1339
  - app/views/spree/shared/_payment.html.erb
1340
+ - app/views/spree/webhook_mailer/endpoint_disabled.html.erb
1341
+ - app/views/spree/webhook_mailer/endpoint_disabled.text.erb
1339
1342
  - config/brakeman.ignore
1340
1343
  - config/i18n-tasks.yml
1341
1344
  - config/importmap.rb
@@ -1474,6 +1477,7 @@ files:
1474
1477
  - db/migrate/20260315100000_add_product_media_support.rb
1475
1478
  - db/migrate/20260317000000_create_spree_refresh_tokens.rb
1476
1479
  - db/migrate/20260323000000_create_spree_price_histories.rb
1480
+ - db/migrate/20260326000001_improve_spree_webhooks.rb
1477
1481
  - db/sample_data/customers.csv
1478
1482
  - db/sample_data/metafield_definitions.rb
1479
1483
  - db/sample_data/orders.rb
@@ -1699,9 +1703,9 @@ licenses:
1699
1703
  - BSD-3-Clause
1700
1704
  metadata:
1701
1705
  bug_tracker_uri: https://github.com/spree/spree/issues
1702
- changelog_uri: https://github.com/spree/spree/releases/tag/v5.4.0.rc3
1706
+ changelog_uri: https://github.com/spree/spree/releases/tag/v5.4.0.rc4
1703
1707
  documentation_uri: https://docs.spreecommerce.org/
1704
- source_code_uri: https://github.com/spree/spree/tree/v5.4.0.rc3
1708
+ source_code_uri: https://github.com/spree/spree/tree/v5.4.0.rc4
1705
1709
  post_install_message:
1706
1710
  rdoc_options: []
1707
1711
  require_paths: