spree_api 5.5.3 → 5.6.0.rc1

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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/spree/api/v3/admin/role_grant_guard.rb +3 -1
  3. data/app/controllers/concerns/spree/api/v3/admin_authentication.rb +1 -1
  4. data/app/controllers/concerns/spree/api/v3/channel_resolution.rb +4 -2
  5. data/app/controllers/concerns/spree/api/v3/locale_and_currency.rb +4 -1
  6. data/app/controllers/concerns/spree/api/v3/storefront_gating.rb +65 -0
  7. data/app/controllers/spree/api/v3/admin/admin_users_controller.rb +1 -1
  8. data/app/controllers/spree/api/v3/admin/categories/products_controller.rb +98 -0
  9. data/app/controllers/spree/api/v3/admin/categories_controller.rb +102 -2
  10. data/app/controllers/spree/api/v3/admin/customers_controller.rb +8 -2
  11. data/app/controllers/spree/api/v3/admin/exports_controller.rb +3 -1
  12. data/app/controllers/spree/api/v3/admin/import_rows_controller.rb +49 -0
  13. data/app/controllers/spree/api/v3/admin/imports_controller.rb +274 -0
  14. data/app/controllers/spree/api/v3/admin/locales_controller.rb +29 -0
  15. data/app/controllers/spree/api/v3/admin/me_controller.rb +39 -9
  16. data/app/controllers/spree/api/v3/admin/orders/fulfillments_controller.rb +62 -2
  17. data/app/controllers/spree/api/v3/admin/password_resets_controller.rb +105 -0
  18. data/app/controllers/spree/api/v3/admin/payment_methods_controller.rb +1 -8
  19. data/app/controllers/spree/api/v3/admin/products/variants_controller.rb +1 -1
  20. data/app/controllers/spree/api/v3/admin/products_controller.rb +8 -7
  21. data/app/controllers/spree/api/v3/admin/promotions_controller.rb +1 -1
  22. data/app/controllers/spree/api/v3/admin/resource_controller.rb +31 -0
  23. data/app/controllers/spree/api/v3/admin/store_controller.rb +3 -0
  24. data/app/controllers/spree/api/v3/admin/translatable_resources_controller.rb +38 -0
  25. data/app/controllers/spree/api/v3/admin/translations/batches_controller.rb +113 -0
  26. data/app/controllers/spree/api/v3/admin/translations_controller.rb +106 -0
  27. data/app/controllers/spree/api/v3/resource_controller.rb +0 -2
  28. data/app/controllers/spree/api/v3/store/auth_controller.rb +3 -1
  29. data/app/controllers/spree/api/v3/store/base_controller.rb +5 -0
  30. data/app/controllers/spree/api/v3/store/carts/fulfillments_controller.rb +1 -3
  31. data/app/controllers/spree/api/v3/store/carts_controller.rb +4 -0
  32. data/app/controllers/spree/api/v3/store/countries_controller.rb +1 -0
  33. data/app/controllers/spree/api/v3/store/currencies_controller.rb +1 -0
  34. data/app/controllers/spree/api/v3/store/customer/password_resets_controller.rb +5 -1
  35. data/app/controllers/spree/api/v3/store/customers_controller.rb +3 -1
  36. data/app/controllers/spree/api/v3/store/digitals_controller.rb +1 -0
  37. data/app/controllers/spree/api/v3/store/locales_controller.rb +3 -8
  38. data/app/controllers/spree/api/v3/store/markets/countries_controller.rb +1 -0
  39. data/app/controllers/spree/api/v3/store/markets_controller.rb +1 -0
  40. data/app/controllers/spree/api/v3/store/newsletter_subscribers_controller.rb +1 -0
  41. data/app/controllers/spree/api/v3/store/products/filters_controller.rb +5 -2
  42. data/app/controllers/spree/api/v3/store/products_controller.rb +1 -1
  43. data/app/controllers/spree/api/v3/store/resource_controller.rb +1 -0
  44. data/app/serializers/concerns/spree/api/v3/admin/translatable.rb +38 -0
  45. data/app/serializers/spree/api/v3/admin/admin_user_serializer.rb +7 -1
  46. data/app/serializers/spree/api/v3/admin/category_serializer.rb +5 -3
  47. data/app/serializers/spree/api/v3/admin/channel_serializer.rb +5 -1
  48. data/app/serializers/spree/api/v3/admin/customer_serializer.rb +7 -2
  49. data/app/serializers/spree/api/v3/admin/discount_serializer.rb +4 -0
  50. data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +9 -0
  51. data/app/serializers/spree/api/v3/admin/gift_card_serializer.rb +7 -0
  52. data/app/serializers/spree/api/v3/admin/import_mapping_serializer.rb +20 -0
  53. data/app/serializers/spree/api/v3/admin/import_row_serializer.rb +17 -0
  54. data/app/serializers/spree/api/v3/admin/import_serializer.rb +70 -0
  55. data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +11 -0
  56. data/app/serializers/spree/api/v3/admin/option_type_serializer.rb +2 -0
  57. data/app/serializers/spree/api/v3/admin/order_serializer.rb +15 -1
  58. data/app/serializers/spree/api/v3/admin/payment_serializer.rb +4 -0
  59. data/app/serializers/spree/api/v3/admin/product_serializer.rb +1 -0
  60. data/app/serializers/spree/api/v3/admin/promotion_rule_serializer.rb +8 -0
  61. data/app/serializers/spree/api/v3/admin/resource_translations_serializer.rb +76 -0
  62. data/app/serializers/spree/api/v3/admin/setup_task_serializer.rb +18 -0
  63. data/app/serializers/spree/api/v3/admin/store_serializer.rb +27 -1
  64. data/app/serializers/spree/api/v3/admin/variant_serializer.rb +8 -1
  65. data/app/serializers/spree/api/v3/base_serializer.rb +13 -0
  66. data/app/serializers/spree/api/v3/cart_serializer.rb +25 -22
  67. data/app/serializers/spree/api/v3/discount_serializer.rb +6 -2
  68. data/app/serializers/spree/api/v3/fulfillment_serializer.rb +16 -13
  69. data/app/serializers/spree/api/v3/gift_card_serializer.rb +15 -11
  70. data/app/serializers/spree/api/v3/import_row_serializer.rb +1 -1
  71. data/app/serializers/spree/api/v3/line_item_serializer.rb +35 -17
  72. data/app/serializers/spree/api/v3/locale_serializer.rb +6 -1
  73. data/app/serializers/spree/api/v3/order_serializer.rb +25 -21
  74. data/app/serializers/spree/api/v3/payment_serializer.rb +6 -2
  75. data/app/serializers/spree/api/v3/product_serializer.rb +9 -1
  76. data/app/serializers/spree/api/v3/variant_serializer.rb +12 -1
  77. data/app/services/spree/api/v3/filters_aggregator.rb +4 -4
  78. data/app/subscribers/spree/webhook_event_subscriber.rb +6 -0
  79. data/config/initializers/typelizer.rb +10 -0
  80. data/config/locales/en.yml +4 -0
  81. data/config/routes.rb +54 -4
  82. data/lib/spree/api/dependencies.rb +5 -0
  83. data/lib/spree/api/openapi/schema_helper.rb +29 -0
  84. metadata +21 -6
@@ -39,6 +39,9 @@ module Spree
39
39
  :preferred_timezone,
40
40
  :preferred_weight_unit,
41
41
  :preferred_unit_system,
42
+ :preferred_storefront_access,
43
+ :preferred_storefront_url,
44
+ :preferred_guest_checkout,
42
45
  :mail_from_address,
43
46
  :customer_support_email,
44
47
  :new_order_notifications_email,
@@ -0,0 +1,38 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # Self-describing discovery of every translatable resource and its
6
+ # translatable fields (the Spree.translatable_resources registry made
7
+ # public). Lets the dashboard render translation editors and the
8
+ # centralized translations grid generically, with no per-model code.
9
+ class TranslatableResourcesController < Admin::BaseController
10
+ scoped_resource :settings
11
+
12
+ # Public resource types with a dedicated nested read route
13
+ # (`…/:id/translations`). Other registered types are writable via the
14
+ # batch endpoint and readable inline (e.g. option values are returned
15
+ # as children of an option type), so the dashboard must not GET a
16
+ # standalone matrix for them. Keep in sync with the `:translatable`
17
+ # route concern mounts in `spree/api/config/routes.rb`.
18
+ READABLE_RESOURCE_TYPES = %w[product category option_type].freeze
19
+
20
+ # GET /api/v3/admin/translatable_resources
21
+ def index
22
+ data = Spree::Translations.registry.map do |entry|
23
+ entry.merge('readable' => READABLE_RESOURCE_TYPES.include?(entry['resource_type']))
24
+ end
25
+
26
+ render json: { data: data }
27
+ end
28
+
29
+ private
30
+
31
+ def action_kind
32
+ 'read'
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,113 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ module Translations
6
+ # Atomically upserts translations across MANY records of (possibly)
7
+ # different translatable resource types in a single request. Powers
8
+ # the dashboard's combined editors (e.g. an option type + all its
9
+ # option values saved in one go).
10
+ #
11
+ # The body is a flat list of independent registry writes — NOT a
12
+ # nested/parent-owns-children payload. Each entry names its own
13
+ # resource_type + resource_id, so the controller stays free of
14
+ # per-model branching.
15
+ #
16
+ # POST /api/v3/admin/translations/batch
17
+ # Body:
18
+ # { "translations": [
19
+ # { "resource_type": "option_type", "resource_id": "opt_…",
20
+ # "values": { "de": { "label": "Größe" } } },
21
+ # { "resource_type": "option_value", "resource_id": "optval_…",
22
+ # "values": { "de": { "label": "Klein" } } }
23
+ # ] }
24
+ #
25
+ # All entries succeed or none do — a validation failure on any entry
26
+ # rolls back the whole transaction and returns per-entry detail.
27
+ # The orchestration (resolve → upsert → rollback) lives in
28
+ # Spree::Translations::Batch; the controller only handles params,
29
+ # authorization, and rendering.
30
+ class BatchesController < Admin::BaseController
31
+ # The batch spans heterogeneous resource types, so a single static
32
+ # scope can't gate it. We verify the API key holds the matching
33
+ # write_<resource> scope for EVERY entry (see require_batch_scopes!),
34
+ # which is stricter than the static check and keeps the audit model.
35
+ skip_scope_check!
36
+
37
+ # POST /api/v3/admin/translations/batch
38
+ def create
39
+ raw = params[:translations]
40
+ return render_empty_batch_error unless raw.is_a?(Array) && raw.any?
41
+
42
+ batch = Spree::Translations::Batch.new(batch_params)
43
+ return unless require_batch_scopes!(batch)
44
+
45
+ records = batch.process! { |record| authorize!(:update, record) }
46
+ render json: { data: records.map { |record| serialize_translations(record) } }
47
+ rescue Spree::Translations::Batch::EntryError => e
48
+ render_error(
49
+ code: ERROR_CODES[:validation_error],
50
+ message: e.message,
51
+ status: :unprocessable_content,
52
+ details: { translations: { e.index.to_s => [e.message] } }
53
+ )
54
+ end
55
+
56
+ private
57
+
58
+ # For API-key callers, require write_<resource> for every distinct
59
+ # resource type in the batch. JWT callers bypass (current_api_key is
60
+ # nil) and rely on the per-record authorize!(:update, record) above.
61
+ # Returns false (and renders 403) when a scope is missing.
62
+ def require_batch_scopes!(batch)
63
+ return true unless current_api_key
64
+
65
+ missing = batch.required_scopes.reject { |scope| current_api_key.has_scope?(scope) }
66
+ return true if missing.empty?
67
+
68
+ render_error(
69
+ code: ERROR_CODES[:access_denied],
70
+ message: "API key lacks scope(s): #{missing.join(', ')}",
71
+ status: :forbidden,
72
+ details: { required_scopes: missing }
73
+ )
74
+ false
75
+ end
76
+
77
+ # [{ resource_type:, resource_id:, values: { locale => { field => value } } }]
78
+ def batch_params
79
+ Array(params[:translations]).map do |entry|
80
+ permitted = entry.respond_to?(:permit) ? entry.permit(:resource_type, :resource_id, values: {}) : entry
81
+ values = permitted[:values]
82
+ {
83
+ resource_type: permitted[:resource_type].to_s,
84
+ resource_id: permitted[:resource_id].to_s,
85
+ values: values.respond_to?(:to_unsafe_h) ? values.to_unsafe_h : (values || {}).to_h
86
+ }
87
+ end
88
+ end
89
+
90
+ def render_empty_batch_error
91
+ render_error(
92
+ code: ERROR_CODES[:validation_error],
93
+ message: 'translations must be a non-empty array',
94
+ status: :unprocessable_content
95
+ )
96
+ end
97
+
98
+ # Write echo: matrix + locale envelope, no discovery fields/children.
99
+ def serialize_translations(record)
100
+ Spree.api.admin_resource_translations_serializer.new(
101
+ record, params: serializer_params.merge(fields: false, envelope: true)
102
+ ).to_h
103
+ end
104
+
105
+ def action_kind
106
+ 'write'
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,106 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # Read-only translation matrix for any parent in the
6
+ # +Spree.translatable_resources+ registry. Mounted via the
7
+ # +:translatable+ route concern; the parent class is inferred from
8
+ # whichever +<segment>_id+ route param matches a registered translatable
9
+ # resource. One controller serves every model.
10
+ #
11
+ # Writes go through the batch endpoint (+POST /admin/translations/batch+)
12
+ # — a single atomic surface that also handles multi-record saves (an
13
+ # option type plus all its option values). There is no per-resource
14
+ # write here.
15
+ class TranslationsController < ResourceController
16
+ # This controller acts on the @parent (set by set_parent), never on a
17
+ # @resource of its own, so the base set_resource lookup (which needs a
18
+ # model_class) must not run.
19
+ skip_before_action :set_resource
20
+ before_action :ensure_translatable!
21
+
22
+ # GET /api/v3/admin/<parent>/<parent_id>/translations
23
+ # Returns the matrix + discovery fields, with nested translatable
24
+ # children (e.g. an option type's values) so an editor fetches the
25
+ # whole tree in one read.
26
+ def index
27
+ render json: { data: serialize_translations(@parent) }
28
+ end
29
+
30
+ protected
31
+
32
+ def set_parent
33
+ raise ActiveRecord::RecordNotFound, 'Parent resource not found' unless parent_lookup
34
+
35
+ @parent = parent_relation.find_by_prefix_id!(parent_lookup.value)
36
+ authorize!(:show, @parent)
37
+ end
38
+
39
+ # Resolves the parent within the current store so a token for one
40
+ # store can't read or write another store's translations. Preloads
41
+ # translatable children + their translation rows so the nested matrix
42
+ # read (e.g. an option type's values) avoids an N+1 over the child
43
+ # translation tables.
44
+ def parent_relation
45
+ klass = parent_lookup.klass
46
+ relation = klass.respond_to?(:for_store) ? klass.for_store(current_store) : klass
47
+
48
+ children = klass.try(:translatable_children)
49
+ children ? relation.includes(children => :translations) : relation
50
+ end
51
+
52
+ # Per-parent scope check: reading a product's translations needs
53
+ # `read_products`, a category's `read_categories`, etc.
54
+ def scoped_resource_name
55
+ parent_lookup&.segment&.pluralize&.to_sym
56
+ end
57
+
58
+ # Translatable by class hierarchy, not strict equality, so a subclass
59
+ # instance (e.g. Spree::Category < Spree::Taxon) passes when its base
60
+ # is registered.
61
+ def ensure_translatable!
62
+ # Compare by class NAME, not object identity: in dev the registry is
63
+ # populated at boot, but the model constants are reloaded on edits,
64
+ # so `is_a?(registered_class)` fails against a stale class object even
65
+ # when @parent's live class (or an ancestor) matches by name. Walk the
66
+ # ancestor names so a subclass (Spree::Category < Spree::Taxon) passes.
67
+ registered = Spree.translatable_resources.map(&:name).to_set
68
+ translatable = @parent.class.ancestors.any? { |ancestor| registered.include?(ancestor.name) }
69
+ raise ActiveRecord::RecordNotFound unless translatable
70
+ end
71
+
72
+ ParentLookup = Struct.new(:klass, :value, :segment)
73
+
74
+ # Maps route segment ('product', 'category', …) to model class name.
75
+ # Class names (not objects) survive dev-mode reloads. Aliases
76
+ # 'category' because routes expose taxons as categories (5.5 rename)
77
+ # while the model element is still 'taxon'.
78
+ def parent_route_map
79
+ @parent_route_map ||= Spree.translatable_resources.each_with_object({}) do |klass, m|
80
+ m[klass.model_name.element.to_s] = klass.name
81
+ end.merge('category' => 'Spree::Taxon')
82
+ end
83
+
84
+ def parent_lookup
85
+ return @parent_lookup if defined?(@parent_lookup)
86
+
87
+ match = parent_route_map.find { |segment, _| params[:"#{segment}_id"].present? }
88
+ @parent_lookup =
89
+ if match
90
+ segment, klass_name = match
91
+ ParentLookup.new(klass_name.constantize, params[:"#{segment}_id"], segment)
92
+ end
93
+ end
94
+
95
+ # Full read shape: matrix + discovery fields + nested children +
96
+ # the locale envelope.
97
+ def serialize_translations(record)
98
+ Spree.api.admin_resource_translations_serializer.new(
99
+ record, params: serializer_params.merge(fields: true, envelope: true)
100
+ ).to_h
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -114,8 +114,6 @@ module Spree
114
114
  model_class.new(permitted_params)
115
115
  end
116
116
  resource.store = current_store if resource.respond_to?(:store_id) && resource.store_id.blank?
117
- # very ugly code we need to still support for promotion/payment_method until we migrate them into single store in spree 6.0
118
- resource.store_ids = [current_store.id] if resource.respond_to?(:store_ids) && resource.store_ids.blank? && !resource.respond_to?(:store_id)
119
117
  resource.created_by = try_spree_current_user if resource.respond_to?(:created_by_id)
120
118
  resource
121
119
  end
@@ -3,6 +3,7 @@ module Spree
3
3
  module V3
4
4
  module Store
5
5
  class AuthController < Store::BaseController
6
+ allow_guest_storefront_access!
6
7
  # Tighter rate limits for auth endpoints (per IP to prevent brute force)
7
8
  rate_limit to: Spree::Api::Config[:rate_limit_login], within: Spree::Api::Config[:rate_limit_window].seconds, store: Rails.cache, only: :create, with: RATE_LIMIT_RESPONSE
8
9
  rate_limit to: Spree::Api::Config[:rate_limit_refresh], within: Spree::Api::Config[:rate_limit_window].seconds, store: Rails.cache, only: :refresh, with: RATE_LIMIT_RESPONSE
@@ -87,7 +88,8 @@ module Spree
87
88
  locale: current_locale,
88
89
  currency: current_currency,
89
90
  user: current_user,
90
- includes: []
91
+ includes: [],
92
+ hide_prices: hide_prices?
91
93
  }
92
94
  end
93
95
 
@@ -8,6 +8,11 @@ module Spree
8
8
  # this here keeps the +X-Spree-Channel+ header from accidentally
9
9
  # narrowing admin queries.
10
10
  include Spree::Api::V3::ChannelResolution
11
+ # Mirrors Store::ResourceController. Both branches must carry the gate so
12
+ # login_required / prices_hidden apply to every Store endpoint, not just
13
+ # ResourceController ones. Public endpoints opt out with
14
+ # +allow_guest_storefront_access!+.
15
+ include Spree::Api::V3::StorefrontGating
11
16
 
12
17
  # Require publishable API key for all Store API requests
13
18
  before_action :authenticate_api_key!
@@ -25,9 +25,7 @@ module Spree
25
25
  fulfillment = @cart.shipments.find_by_prefix_id!(params[:id])
26
26
 
27
27
  if permitted_params[:selected_delivery_rate_id].present?
28
- delivery_rate = fulfillment.shipping_rates.find_by_prefix_id!(permitted_params[:selected_delivery_rate_id])
29
- fulfillment.selected_shipping_rate_id = delivery_rate.id
30
- fulfillment.save!
28
+ fulfillment.selected_delivery_rate_id = permitted_params[:selected_delivery_rate_id]
31
29
  end
32
30
 
33
31
  # Auto-advance (e.g. delivery → payment) after rate selection.
@@ -104,6 +104,10 @@ module Spree
104
104
  def complete
105
105
  find_cart!
106
106
 
107
+ if @cart.guest_checkout_disallowed?
108
+ return render_authentication_required('api.errors.guest_checkout_not_allowed', 'You must be signed in to complete checkout')
109
+ end
110
+
107
111
  result = Spree::Dependencies.carts_complete_service.constantize.call(cart: @cart)
108
112
 
109
113
  if result.success?
@@ -3,6 +3,7 @@ module Spree
3
3
  module V3
4
4
  module Store
5
5
  class CountriesController < Store::BaseController
6
+ allow_guest_storefront_access!
6
7
  include Spree::Api::V3::HttpCaching
7
8
 
8
9
  # GET /api/v3/store/countries
@@ -3,6 +3,7 @@ module Spree
3
3
  module V3
4
4
  module Store
5
5
  class CurrenciesController < Store::BaseController
6
+ allow_guest_storefront_access!
6
7
  include Spree::Api::V3::HttpCaching
7
8
 
8
9
  # GET /api/v3/store/currencies
@@ -6,6 +6,7 @@ module Spree
6
6
  module Store
7
7
  module Customer
8
8
  class PasswordResetsController < Store::BaseController
9
+ allow_guest_storefront_access!
9
10
  rate_limit to: Spree::Api::Config[:rate_limit_password_reset],
10
11
  within: Spree::Api::Config[:rate_limit_window].seconds,
11
12
  store: Rails.cache,
@@ -32,7 +33,7 @@ module Spree
32
33
 
33
34
  if user
34
35
  token = user.generate_token_for(:password_reset)
35
- event_payload = { reset_token: token, email: user.email }
36
+ event_payload = { reset_token: token, email: user.email, store_id: current_store.prefixed_id }
36
37
  event_payload[:redirect_url] = redirect_url if redirect_url.present?
37
38
  user.publish_event('customer.password_reset_requested', event_payload)
38
39
  end
@@ -55,6 +56,9 @@ module Spree
55
56
 
56
57
  if user.update(password: params[:password], password_confirmation: params[:password_confirmation])
57
58
  jwt = generate_jwt(user)
59
+ # Kill every existing session before minting the new one —
60
+ # a stolen refresh token must not survive a password reset.
61
+ Spree::RefreshToken.revoke_all_for(user)
58
62
  refresh_token = Spree::RefreshToken.create_for(user, request_env: { ip_address: request.remote_ip, user_agent: request.user_agent&.truncate(255) })
59
63
  user.publish_event('customer.password_reset')
60
64
 
@@ -3,6 +3,7 @@ module Spree
3
3
  module V3
4
4
  module Store
5
5
  class CustomersController < Store::BaseController
6
+ allow_guest_storefront_access!
6
7
  rate_limit to: Spree::Api::Config[:rate_limit_register], within: Spree::Api::Config[:rate_limit_window].seconds, store: Rails.cache, only: :create, with: RATE_LIMIT_RESPONSE
7
8
 
8
9
  skip_before_action :authenticate_user, only: [:create]
@@ -64,7 +65,8 @@ module Spree
64
65
  locale: current_locale,
65
66
  currency: current_currency,
66
67
  user: current_user,
67
- includes: []
68
+ includes: [],
69
+ hide_prices: hide_prices?
68
70
  }
69
71
  end
70
72
 
@@ -3,6 +3,7 @@ module Spree
3
3
  module V3
4
4
  module Store
5
5
  class DigitalsController < Store::BaseController
6
+ allow_guest_storefront_access!
6
7
  skip_before_action :authenticate_api_key!
7
8
  before_action :set_resource
8
9
 
@@ -3,24 +3,19 @@ module Spree
3
3
  module V3
4
4
  module Store
5
5
  class LocalesController < Store::BaseController
6
+ allow_guest_storefront_access!
6
7
  include Spree::Api::V3::HttpCaching
7
8
 
8
9
  # GET /api/v3/store/locales
9
10
  def index
10
- locales = current_store.supported_locales_list
11
+ locales = current_store.supported_locales
11
12
 
12
13
  return unless cache_collection(locales)
13
14
 
14
15
  render json: {
15
- data: locales.map { |code| Spree.api.locale_serializer.new(OpenStruct.new(code: code, name: locale_name(code))).to_h }
16
+ data: locales.map { |locale| Spree.api.locale_serializer.new(locale).to_h }
16
17
  }
17
18
  end
18
-
19
- private
20
-
21
- def locale_name(code)
22
- I18n.t('spree.i18n.this_file_language', locale: code, default: code)
23
- end
24
19
  end
25
20
  end
26
21
  end
@@ -4,6 +4,7 @@ module Spree
4
4
  module Store
5
5
  module Markets
6
6
  class CountriesController < Store::BaseController
7
+ allow_guest_storefront_access!
7
8
  include Spree::Api::V3::HttpCaching
8
9
 
9
10
  before_action :load_market
@@ -3,6 +3,7 @@ module Spree
3
3
  module V3
4
4
  module Store
5
5
  class MarketsController < Store::BaseController
6
+ allow_guest_storefront_access!
6
7
  include Spree::Api::V3::HttpCaching
7
8
 
8
9
  # GET /api/v3/store/markets
@@ -3,6 +3,7 @@ module Spree
3
3
  module V3
4
4
  module Store
5
5
  class NewsletterSubscribersController < Store::BaseController
6
+ allow_guest_storefront_access!
6
7
  rate_limit to: Spree::Api::Config[:rate_limit_register],
7
8
  within: Spree::Api::Config[:rate_limit_window].seconds,
8
9
  store: Rails.cache,
@@ -30,7 +30,7 @@ module Spree
30
30
 
31
31
  def filters_cache_key
32
32
  products_table = Spree::Product.table_name
33
- stats = current_store.products.available(Time.current, current_currency)
33
+ stats = current_store.products.available(Time.current, current_currency, include_preorderable: true)
34
34
  .pick(Arel.sql("MAX(#{products_table}.updated_at)"), Arel.sql("COUNT(DISTINCT #{products_table}.id)"))
35
35
  max_updated = stats&.first&.to_i
36
36
  product_count = stats&.last || 0
@@ -51,7 +51,10 @@ module Spree
51
51
  end
52
52
 
53
53
  def filters_scope
54
- scope = current_store.products.available(Time.current, current_currency)
54
+ # Mirror ProductsController#scope so facet counts describe the
55
+ # same product set the listing returns (coming-soon pre-orders
56
+ # included).
57
+ scope = current_store.products.available(Time.current, current_currency, include_preorderable: true)
55
58
  scope = scope.in_category(category) if category.present?
56
59
  scope.accessible_by(current_ability, :show)
57
60
  end
@@ -29,7 +29,7 @@ module Spree
29
29
  end
30
30
 
31
31
  def scope
32
- super.available(Time.current, Spree::Current.currency)
32
+ super.available(Time.current, Spree::Current.currency, include_preorderable: true)
33
33
  end
34
34
 
35
35
  # these scopes are not automatically picked by ar_lazy_preload gem and we need to explicitly include them
@@ -7,6 +7,7 @@ module Spree
7
7
  # any concern added here MUST also be added to Store::BaseController.
8
8
  class ResourceController < Spree::Api::V3::ResourceController
9
9
  include Spree::Api::V3::ChannelResolution
10
+ include Spree::Api::V3::StorefrontGating
10
11
 
11
12
  protected
12
13
 
@@ -0,0 +1,38 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # Adds the opt-in per-locale translation matrix to an admin serializer
6
+ # for a model that includes +Spree::TranslatableResource+. Include it in
7
+ # every translatable resource's admin serializer (product, option type,
8
+ # category, …) so the +?expand=translations+ surface stays consistent
9
+ # and defined in one place.
10
+ #
11
+ # @example
12
+ # class ProductSerializer < V3::ProductSerializer
13
+ # include Spree::Api::V3::Admin::Translatable
14
+ # end
15
+ module Translatable
16
+ extend ActiveSupport::Concern
17
+
18
+ included do
19
+ # locale code => { field => value, translated_field_count } — mirrors
20
+ # the hand-written LocaleTranslations shape, inlined so the generated
21
+ # types need no import from the hand-written barrel.
22
+ typelize translations: 'Record<string, Record<string, string | number | null>>'
23
+
24
+ # Full per-locale translation matrix { locale => { field => value } }.
25
+ # Opt-in via ?expand=translations — the matrix (non-default locales ×
26
+ # translatable fields × translation-table joins) is wasteful on list
27
+ # views, so it is never rendered by default. params[:expand] is part
28
+ # of the HTTP cache key, so expanded/non-expanded responses cache
29
+ # separately. Writes go through the dedicated /translations endpoint.
30
+ attribute :translations, if: proc { expand?('translations') } do |record|
31
+ Spree::Translations.matrix_for(record)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -7,11 +7,17 @@ module Spree
7
7
  first_name: [:string, nullable: true],
8
8
  last_name: [:string, nullable: true],
9
9
  full_name: [:string, nullable: true],
10
+ selected_locale: [:string, nullable: true],
11
+ avatar_url: [:string, nullable: true],
10
12
  roles: 'Array<{ id: string; name: string }>'
11
13
 
12
- attributes :email, :first_name, :last_name, :full_name,
14
+ attributes :email, :first_name, :last_name, :full_name, :selected_locale,
13
15
  created_at: :iso8601, updated_at: :iso8601
14
16
 
17
+ attribute :avatar_url do |user|
18
+ image_url_for(user.avatar)
19
+ end
20
+
15
21
  # Roles assigned to this user *for the current store*. Each store
16
22
  # gets its own role set via `Spree::RoleUser`, so this attribute is
17
23
  # scoped against `current_store` rather than returning every role
@@ -5,11 +5,13 @@ module Spree
5
5
  # Admin API Category Serializer
6
6
  # Full category data including admin-only fields
7
7
  class CategorySerializer < V3::CategorySerializer
8
- typelize pretty_name: :string, lft: :number, rgt: :number, sort_order: :string,
8
+ include Spree::Api::V3::Admin::Translatable
9
+
10
+ typelize pretty_name: :string, lft: :number, rgt: :number, products_count: :number,
9
11
  metadata: 'Record<string, unknown>'
10
12
 
11
- # Admin-only attributes
12
- attributes :metadata, :pretty_name, :lft, :rgt, created_at: :iso8601, updated_at: :iso8601
13
+ attributes :metadata, :pretty_name, :lft, :rgt, :products_count,
14
+ created_at: :iso8601, updated_at: :iso8601
13
15
 
14
16
  # Override inherited associations to use admin serializers
15
17
  one :parent,
@@ -4,9 +4,13 @@ module Spree
4
4
  module Admin
5
5
  class ChannelSerializer < V3::ChannelSerializer
6
6
  typelize store_id: :string,
7
- preferred_order_routing_strategy: [:string, nullable: true]
7
+ preferred_order_routing_strategy: [:string, nullable: true],
8
+ preferred_storefront_access: [:string, nullable: true],
9
+ preferred_guest_checkout: [:boolean, nullable: true]
8
10
 
9
11
  attributes :preferred_order_routing_strategy,
12
+ :preferred_storefront_access,
13
+ :preferred_guest_checkout,
10
14
  created_at: :iso8601, updated_at: :iso8601
11
15
  end
12
16
  end
@@ -17,7 +17,8 @@ module Spree
17
17
  display_total_spent: :string,
18
18
  last_order_completed_at: [:string, nullable: true],
19
19
  default_billing_address_id: [:string, nullable: true],
20
- default_shipping_address_id: [:string, nullable: true]
20
+ default_shipping_address_id: [:string, nullable: true],
21
+ customer_group_ids: [:string, multi: true]
21
22
 
22
23
  # Admin-only attributes
23
24
  attributes :login, :metadata,
@@ -41,7 +42,7 @@ module Spree
41
42
  end
42
43
 
43
44
  attribute :tags do |user|
44
- user.tag_list.to_a
45
+ user.tags.map(&:name) # not pluck as we preload tags
45
46
  end
46
47
 
47
48
  attribute :internal_note_html do |user|
@@ -92,6 +93,10 @@ module Spree
92
93
  resource: proc { Spree.api.admin_store_credit_serializer },
93
94
  if: proc { expand?('store_credits') }
94
95
 
96
+ attribute :customer_group_ids do |user|
97
+ user.customer_groups.map(&:prefixed_id)
98
+ end
99
+
95
100
  many :customer_groups,
96
101
  resource: proc { Spree.api.admin_customer_group_serializer },
97
102
  if: proc { expand?('customer_groups') }
@@ -3,6 +3,10 @@ module Spree
3
3
  module V3
4
4
  module Admin
5
5
  class DiscountSerializer < V3::DiscountSerializer
6
+ # The Admin API has no guest gating — money fields inherited from the
7
+ # store serializer are always present, so override their nullability.
8
+ typelize amount: [:string, nullable: false], display_amount: [:string, nullable: false]
9
+
6
10
  attributes created_at: :iso8601, updated_at: :iso8601
7
11
  end
8
12
  end