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 +4 -4
- data/app/mailers/spree/webhook_mailer.rb +17 -0
- data/app/models/concerns/spree/product_scopes.rb +50 -16
- data/app/models/spree/product.rb +1 -1
- data/app/models/spree/search_provider/base.rb +1 -1
- data/app/models/spree/search_provider/database.rb +14 -3
- data/app/models/spree/search_provider/meilisearch.rb +94 -8
- data/app/models/spree/webhook_delivery.rb +28 -2
- data/app/models/spree/webhook_endpoint.rb +71 -1
- data/app/presenters/spree/search_provider/product_presenter.rb +9 -1
- data/app/views/spree/webhook_mailer/endpoint_disabled.html.erb +26 -0
- data/app/views/spree/webhook_mailer/endpoint_disabled.text.erb +10 -0
- data/config/locales/en.yml +10 -0
- data/db/migrate/20260326000001_improve_spree_webhooks.rb +17 -0
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/permitted_attributes.rb +1 -1
- data/lib/spree/testing_support/factories/webhook_endpoint_factory.rb +7 -0
- metadata +8 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7bc3a50296fda5fe9d3f9cfa5ec4547b44e8dc7c718634f0de73314d55210479
|
|
4
|
+
data.tar.gz: 74757285458b4d4a322d5cdc0ea550c4fe7e9ca9de25095fba0371b6c1fb1e93
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
151
|
-
|
|
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
|
-
#
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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)
|
data/app/models/spree/product.rb
CHANGED
|
@@ -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,
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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 '
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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') %>
|
data/config/locales/en.yml
CHANGED
|
@@ -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
|
data/lib/spree/core/version.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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.
|
|
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.
|
|
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:
|