spree_api 5.4.3 → 5.5.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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +19 -0
  3. data/app/controllers/concerns/spree/api/v3/admin/auth_cookies.rb +62 -0
  4. data/app/controllers/concerns/spree/api/v3/admin/subclassed_resource.rb +149 -0
  5. data/app/controllers/concerns/spree/api/v3/admin_authentication.rb +54 -0
  6. data/app/controllers/concerns/spree/api/v3/bulk_operations.rb +103 -0
  7. data/app/controllers/concerns/spree/api/v3/channel_resolution.rb +60 -0
  8. data/app/controllers/concerns/spree/api/v3/error_handler.rb +4 -0
  9. data/app/controllers/concerns/spree/api/v3/params_normalizer.rb +84 -0
  10. data/app/controllers/concerns/spree/api/v3/scoped_authorization.rb +88 -0
  11. data/app/controllers/concerns/spree/api/v3/store/search_provider_support.rb +35 -1
  12. data/app/controllers/spree/api/v3/admin/admin_users_controller.rb +97 -0
  13. data/app/controllers/spree/api/v3/admin/allowed_origins_controller.rb +25 -0
  14. data/app/controllers/spree/api/v3/admin/api_keys_controller.rb +55 -0
  15. data/app/controllers/spree/api/v3/admin/auth_controller.rb +134 -0
  16. data/app/controllers/spree/api/v3/admin/base_controller.rb +3 -17
  17. data/app/controllers/spree/api/v3/admin/categories_controller.rb +25 -0
  18. data/app/controllers/spree/api/v3/admin/channels_controller.rb +65 -0
  19. data/app/controllers/spree/api/v3/admin/countries_controller.rb +38 -0
  20. data/app/controllers/spree/api/v3/admin/coupon_codes_controller.rb +33 -0
  21. data/app/controllers/spree/api/v3/admin/custom_field_definitions_controller.rb +34 -0
  22. data/app/controllers/spree/api/v3/admin/custom_fields_controller.rb +108 -0
  23. data/app/controllers/spree/api/v3/admin/customer_groups_controller.rb +31 -0
  24. data/app/controllers/spree/api/v3/admin/customers/addresses_controller.rb +88 -0
  25. data/app/controllers/spree/api/v3/admin/customers/credit_cards_controller.rb +31 -0
  26. data/app/controllers/spree/api/v3/admin/customers/store_credits_controller.rb +93 -0
  27. data/app/controllers/spree/api/v3/admin/customers_controller.rb +119 -0
  28. data/app/controllers/spree/api/v3/admin/dashboard_controller.rb +44 -0
  29. data/app/controllers/spree/api/v3/admin/direct_uploads_controller.rb +40 -0
  30. data/app/controllers/spree/api/v3/admin/exports_controller.rb +89 -0
  31. data/app/controllers/spree/api/v3/admin/gift_card_batches_controller.rb +31 -0
  32. data/app/controllers/spree/api/v3/admin/gift_cards_controller.rb +33 -0
  33. data/app/controllers/spree/api/v3/admin/invitation_acceptances_controller.rb +138 -0
  34. data/app/controllers/spree/api/v3/admin/invitations_controller.rb +70 -0
  35. data/app/controllers/spree/api/v3/admin/markets_controller.rb +42 -0
  36. data/app/controllers/spree/api/v3/admin/me_controller.rb +69 -0
  37. data/app/controllers/spree/api/v3/admin/media_controller.rb +119 -0
  38. data/app/controllers/spree/api/v3/admin/option_types_controller.rb +34 -0
  39. data/app/controllers/spree/api/v3/admin/orders/adjustments_controller.rb +27 -0
  40. data/app/controllers/spree/api/v3/admin/orders/base_controller.rb +26 -0
  41. data/app/controllers/spree/api/v3/admin/orders/fulfillments_controller.rb +104 -0
  42. data/app/controllers/spree/api/v3/admin/orders/gift_cards_controller.rb +79 -0
  43. data/app/controllers/spree/api/v3/admin/orders/items_controller.rb +92 -0
  44. data/app/controllers/spree/api/v3/admin/orders/payments_controller.rb +90 -0
  45. data/app/controllers/spree/api/v3/admin/orders/refunds_controller.rb +53 -0
  46. data/app/controllers/spree/api/v3/admin/orders/store_credits_controller.rb +59 -0
  47. data/app/controllers/spree/api/v3/admin/orders_controller.rb +190 -0
  48. data/app/controllers/spree/api/v3/admin/payment_methods_controller.rb +73 -0
  49. data/app/controllers/spree/api/v3/admin/price_lists_controller.rb +156 -0
  50. data/app/controllers/spree/api/v3/admin/prices_controller.rb +129 -0
  51. data/app/controllers/spree/api/v3/admin/products/variants_controller.rb +48 -0
  52. data/app/controllers/spree/api/v3/admin/products_controller.rb +237 -0
  53. data/app/controllers/spree/api/v3/admin/promotion_actions_controller.rb +78 -0
  54. data/app/controllers/spree/api/v3/admin/promotion_rules_controller.rb +56 -0
  55. data/app/controllers/spree/api/v3/admin/promotions_controller.rb +78 -0
  56. data/app/controllers/spree/api/v3/admin/resource_controller.rb +29 -11
  57. data/app/controllers/spree/api/v3/admin/roles_controller.rb +29 -0
  58. data/app/controllers/spree/api/v3/admin/stock_items_controller.rb +35 -0
  59. data/app/controllers/spree/api/v3/admin/stock_locations_controller.rb +36 -0
  60. data/app/controllers/spree/api/v3/admin/stock_reservations_controller.rb +29 -0
  61. data/app/controllers/spree/api/v3/admin/stock_transfers_controller.rb +75 -0
  62. data/app/controllers/spree/api/v3/admin/store_controller.rb +53 -0
  63. data/app/controllers/spree/api/v3/admin/store_credit_categories_controller.rb +21 -0
  64. data/app/controllers/spree/api/v3/admin/tags_controller.rb +51 -0
  65. data/app/controllers/spree/api/v3/admin/tax_categories_controller.rb +21 -0
  66. data/app/controllers/spree/api/v3/admin/variants_controller.rb +33 -0
  67. data/app/controllers/spree/api/v3/admin/webhook_deliveries_controller.rb +49 -0
  68. data/app/controllers/spree/api/v3/admin/webhook_endpoints_controller.rb +75 -0
  69. data/app/controllers/spree/api/v3/resource_controller.rb +90 -8
  70. data/app/controllers/spree/api/v3/store/auth_controller.rb +8 -28
  71. data/app/controllers/spree/api/v3/store/base_controller.rb +6 -0
  72. data/app/controllers/spree/api/v3/store/carts_controller.rb +1 -0
  73. data/app/controllers/spree/api/v3/store/customers_controller.rb +6 -0
  74. data/app/controllers/spree/api/v3/store/newsletter_subscribers_controller.rb +77 -0
  75. data/app/controllers/spree/api/v3/store/products/filters_controller.rb +2 -2
  76. data/app/controllers/spree/api/v3/store/products_controller.rb +3 -3
  77. data/app/controllers/spree/api/v3/store/resource_controller.rb +10 -2
  78. data/app/jobs/spree/webhook_delivery_job.rb +5 -0
  79. data/app/models/spree/api_key_ability.rb +16 -0
  80. data/app/serializers/spree/api/v3/admin/address_serializer.rb +2 -6
  81. data/app/serializers/spree/api/v3/admin/adjustment_serializer.rb +3 -15
  82. data/app/serializers/spree/api/v3/admin/admin_user_serializer.rb +19 -3
  83. data/app/serializers/spree/api/v3/admin/allowed_origin_serializer.rb +2 -6
  84. data/app/serializers/spree/api/v3/admin/api_key_serializer.rb +42 -0
  85. data/app/serializers/spree/api/v3/admin/category_serializer.rb +4 -3
  86. data/app/serializers/spree/api/v3/admin/channel_serializer.rb +15 -0
  87. data/app/serializers/spree/api/v3/admin/country_serializer.rb +1 -1
  88. data/app/serializers/spree/api/v3/admin/coupon_code_serializer.rb +30 -0
  89. data/app/serializers/spree/api/v3/admin/credit_card_serializer.rb +4 -2
  90. data/app/serializers/spree/api/v3/admin/custom_field_definition_serializer.rb +21 -0
  91. data/app/serializers/spree/api/v3/admin/custom_field_serializer.rb +8 -3
  92. data/app/serializers/spree/api/v3/admin/customer_group_serializer.rb +27 -0
  93. data/app/serializers/spree/api/v3/admin/customer_serializer.rb +58 -2
  94. data/app/serializers/spree/api/v3/admin/dashboard_analytics_serializer.rb +143 -0
  95. data/app/serializers/spree/api/v3/admin/export_serializer.rb +40 -0
  96. data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +2 -6
  97. data/app/serializers/spree/api/v3/admin/{asset_serializer.rb → gift_card_batch_serializer.rb} +1 -1
  98. data/app/serializers/spree/api/v3/admin/gift_card_serializer.rb +39 -4
  99. data/app/serializers/spree/api/v3/admin/invitation_serializer.rb +64 -0
  100. data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +4 -16
  101. data/app/serializers/spree/api/v3/admin/media_serializer.rb +24 -2
  102. data/app/serializers/spree/api/v3/admin/option_type_serializer.rb +4 -1
  103. data/app/serializers/spree/api/v3/admin/option_value_serializer.rb +4 -1
  104. data/app/serializers/spree/api/v3/admin/order_serializer.rb +21 -6
  105. data/app/serializers/spree/api/v3/admin/payment_method_serializer.rb +11 -2
  106. data/app/serializers/spree/api/v3/admin/payment_serializer.rb +2 -6
  107. data/app/serializers/spree/api/v3/admin/payment_source_serializer.rb +4 -1
  108. data/app/serializers/spree/api/v3/admin/price_list_serializer.rb +51 -0
  109. data/app/serializers/spree/api/v3/admin/price_rule_serializer.rb +55 -0
  110. data/app/serializers/spree/api/v3/admin/price_serializer.rb +4 -0
  111. data/app/serializers/spree/api/v3/admin/product_publication_serializer.rb +11 -0
  112. data/app/serializers/spree/api/v3/admin/product_serializer.rb +34 -10
  113. data/app/serializers/spree/api/v3/admin/promotion_action_serializer.rb +71 -0
  114. data/app/serializers/spree/api/v3/admin/promotion_rule_serializer.rb +85 -0
  115. data/app/serializers/spree/api/v3/admin/promotion_serializer.rb +41 -0
  116. data/app/serializers/spree/api/v3/admin/refund_serializer.rb +4 -2
  117. data/app/serializers/spree/api/v3/admin/role_serializer.rb +17 -0
  118. data/app/serializers/spree/api/v3/admin/stock_item_serializer.rb +16 -1
  119. data/app/serializers/spree/api/v3/admin/stock_location_serializer.rb +11 -2
  120. data/app/serializers/spree/api/v3/admin/stock_reservation_serializer.rb +46 -0
  121. data/app/serializers/spree/api/v3/admin/stock_transfer_serializer.rb +37 -0
  122. data/app/serializers/spree/api/v3/admin/store_credit_category_serializer.rb +19 -0
  123. data/app/serializers/spree/api/v3/admin/store_credit_serializer.rb +11 -5
  124. data/app/serializers/spree/api/v3/admin/store_serializer.rb +55 -0
  125. data/app/serializers/spree/api/v3/admin/tax_category_serializer.rb +4 -2
  126. data/app/serializers/spree/api/v3/admin/variant_serializer.rb +37 -6
  127. data/app/serializers/spree/api/v3/admin/webhook_delivery_serializer.rb +45 -0
  128. data/app/serializers/spree/api/v3/admin/webhook_endpoint_serializer.rb +69 -0
  129. data/app/serializers/spree/api/v3/channel_serializer.rb +14 -0
  130. data/app/serializers/spree/api/v3/custom_field_serializer.rb +9 -10
  131. data/app/serializers/spree/api/v3/customer_serializer.rb +5 -0
  132. data/app/serializers/spree/api/v3/market_serializer.rb +2 -1
  133. data/app/serializers/spree/api/v3/media_serializer.rb +8 -6
  134. data/app/serializers/spree/api/v3/order_serializer.rb +6 -1
  135. data/app/serializers/spree/api/v3/payment_method_serializer.rb +11 -2
  136. data/app/serializers/spree/api/v3/product_publication_serializer.rb +22 -0
  137. data/app/serializers/spree/api/v3/product_serializer.rb +6 -1
  138. data/app/serializers/spree/api/v3/stock_reservation_serializer.rb +10 -0
  139. data/config/locales/en.yml +2 -0
  140. data/config/routes.rb +235 -1
  141. data/lib/spree/api/configuration.rb +2 -2
  142. data/lib/spree/api/dependencies.rb +25 -1
  143. data/lib/spree/api/openapi/path_sorter.rb +126 -0
  144. data/lib/spree/api/openapi/schema_helper.rb +185 -6
  145. metadata +96 -8
  146. data/app/serializers/spree/api/v3/admin/shipping_category_serializer.rb +0 -14
@@ -0,0 +1,156 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # Admin CRUD for `Spree::PriceList`, plus the lifecycle transitions
6
+ # (`activate` / `deactivate`) and the spreadsheet's data feed
7
+ # (`prices`).
8
+ #
9
+ # Everything writable on the list — name, schedule, match policy,
10
+ # product membership (`product_ids: [...]`), nested rules
11
+ # (`rules: [...]`), and individual price overrides
12
+ # (`prices: [...]`) — flows through the regular PATCH payload, so
13
+ # the SPA saves the entire editor in one round-trip. No separate
14
+ # add_products / remove_products / add_rule / bulk_update_prices
15
+ # endpoints.
16
+ #
17
+ # Scoped under the `products` API-key scope — price lists are a
18
+ # product/pricing concern; we don't introduce a separate
19
+ # `read_price_lists` scope.
20
+ class PriceListsController < ResourceController
21
+ scoped_resource :products
22
+
23
+ # The base ResourceController limits `set_resource` to
24
+ # `show/update/destroy`. We need it on the custom member
25
+ # actions below too, so swap in our own filter — Rails keys
26
+ # before_actions by method name, so this would otherwise
27
+ # *replace* the parent's narrower filter and break the standard
28
+ # actions. Wrapping it under a different name keeps both.
29
+ before_action :load_member_resource, only: [:activate, :deactivate, :prices]
30
+
31
+ # GET /api/v3/admin/price_lists/price_rule_types
32
+ #
33
+ # Returns `[{ type, label, description, preference_schema }]`
34
+ # for every registered subclass in `Spree.pricing.rules`. The
35
+ # SPA uses this to build the "Add rule" picker + render a
36
+ # generic preferences form per subclass. Rules themselves are
37
+ # not a separate REST resource — they ride along on the price
38
+ # list's PATCH body via `rules: [...]`.
39
+ def price_rule_types
40
+ authorize! :read, Spree::PriceRule
41
+ render json: { data: Spree::PriceRule.subclasses_with_preference_schema }
42
+ end
43
+
44
+ # PATCH /api/v3/admin/price_lists/:id/activate
45
+ #
46
+ # State transition: draft|inactive → active (or → scheduled when
47
+ # `starts_at` is in the future). Mirrors the old Rails admin's
48
+ # "Activate" button which automatically scheduled future lists.
49
+ def activate
50
+ authorize! :update, @resource
51
+ event = @resource.starts_at.present? && @resource.starts_at.future? ? :schedule : :activate
52
+
53
+ if @resource.send(event)
54
+ render json: serialize_resource(@resource)
55
+ else
56
+ render_validation_error(@resource.errors)
57
+ end
58
+ end
59
+
60
+ # PATCH /api/v3/admin/price_lists/:id/deactivate
61
+ def deactivate
62
+ authorize! :update, @resource
63
+
64
+ if @resource.deactivate
65
+ render json: serialize_resource(@resource)
66
+ else
67
+ render_validation_error(@resource.errors)
68
+ end
69
+ end
70
+
71
+ # GET /api/v3/admin/price_lists/:id/prices
72
+ #
73
+ # The spreadsheet editor's data source. Returns every Price row
74
+ # in this list (filtered by `?currency=`), eager-loading
75
+ # `variant.product` + option values so each cell can render
76
+ # product name, variant options and SKU without N+1.
77
+ def prices
78
+ authorize! :read, @resource
79
+ currency = params[:currency].presence || current_store.default_currency
80
+ prices = @resource.prices
81
+ .includes(variant: [:product, { option_values: :option_type }])
82
+ .where(currency: currency)
83
+ .joins(variant: :product)
84
+ .order(Arel.sql("#{Spree::Product.table_name}.name ASC"))
85
+ .order(Arel.sql("#{Spree::Variant.table_name}.position ASC"))
86
+
87
+ render json: {
88
+ data: prices.map { |p| serialize_price(p) },
89
+ meta: { currency: currency, count: prices.size }
90
+ }
91
+ end
92
+
93
+ protected
94
+
95
+ def model_class
96
+ Spree::PriceList
97
+ end
98
+
99
+ def serializer_class
100
+ Spree.api.admin_price_list_serializer
101
+ end
102
+
103
+ def scope
104
+ super.for_store(current_store)
105
+ end
106
+
107
+ def permitted_params
108
+ normalize_params(
109
+ params.permit(
110
+ :name, :description, :position,
111
+ :starts_at, :ends_at, :match_policy,
112
+ product_ids: [],
113
+ rules: [:id, :type, { preferences: {} }],
114
+ prices: [:id, :variant_id, :currency, :amount, :compare_at_amount]
115
+ )
116
+ )
117
+ end
118
+
119
+ private
120
+
121
+ # Loads the record without the action-derived authorization
122
+ # `set_resource` runs (which would check `:activate` /
123
+ # `:deactivate` / `:prices` — actions that abilities don't
124
+ # grant). The per-action methods below explicitly call
125
+ # `authorize!` with the standard action that ability rules
126
+ # actually mention (`:update` / `:read`).
127
+ def load_member_resource
128
+ @resource = find_resource
129
+ end
130
+
131
+ # Hand-rolled flat shape for the spreadsheet — keeps the payload
132
+ # narrow (no nested variant/product/option_value objects) and
133
+ # avoids paying for the admin Price serializer when we only need
134
+ # ~6 fields per row. The grouping the UI does (rows → product
135
+ # header) is driven entirely off `product_id`/`product_name`.
136
+ def serialize_price(price)
137
+ variant = price.variant
138
+ product = variant.product
139
+ {
140
+ id: price.prefixed_id,
141
+ variant_id: variant.prefixed_id,
142
+ product_id: product.prefixed_id,
143
+ product_name: product.name,
144
+ variant_label: variant.options_text.presence,
145
+ sku: variant.sku,
146
+ currency: price.currency,
147
+ amount: price.amount&.to_s,
148
+ compare_at_amount: price.compare_at_amount&.to_s
149
+ }
150
+ end
151
+
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,129 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # Admin CRUD for `Spree::Price`. Covers both base prices
6
+ # (`price_list_id: nil`) and price-list overrides under one
7
+ # resource, filtered via Ransack predicates on the index.
8
+ class PricesController < ResourceController
9
+ include Spree::Api::V3::BulkOperations
10
+
11
+ scoped_resource :products
12
+
13
+ before_action :require_ids!, only: [:bulk_destroy]
14
+ before_action :require_prices!, only: [:bulk_upsert]
15
+
16
+ # Bulk-upserts prices on the unique-key triple
17
+ # `(variant_id, currency, price_list_id)`.
18
+ #
19
+ # @return [void]
20
+ def bulk_upsert
21
+ authorize! :create, Spree::Price
22
+ authorize! :update, Spree::Price
23
+
24
+ rows = Array(params[:prices]).map { |row| decode_price_row(row) }
25
+ invalid = rows.each_with_index.filter_map do |row, idx|
26
+ missing = %i[variant_id currency].reject { |k| row[k].present? }
27
+ { index: idx, missing: missing } if missing.any?
28
+ end
29
+ if invalid.any?
30
+ return render_error(
31
+ code: 'invalid_prices',
32
+ message: 'Each row must include variant_id and currency.',
33
+ status: :unprocessable_content,
34
+ details: { rows: invalid }
35
+ )
36
+ end
37
+
38
+ result = Spree::Prices::BulkUpsert.call(rows: rows)
39
+ render json: result.value
40
+ end
41
+
42
+ # Soft-deletes the listed prices.
43
+ #
44
+ # @return [void]
45
+ def bulk_destroy
46
+ authorize! :destroy, Spree::Price
47
+
48
+ destroy_scope = scope.where(id: decode_ids(params[:ids]))
49
+ destroyed = destroy_scope.count(&:destroy)
50
+
51
+ render json: { price_count: destroyed }
52
+ end
53
+
54
+ protected
55
+
56
+ def model_class
57
+ Spree::Price
58
+ end
59
+
60
+ def serializer_class
61
+ Spree.api.admin_price_serializer
62
+ end
63
+
64
+ def collection_includes
65
+ {
66
+ variant: [
67
+ :tax_category,
68
+ :prices,
69
+ product: :tax_category,
70
+ option_values: :option_type,
71
+ stock_items: [:stock_location, :active_stock_reservations]
72
+ ]
73
+ }
74
+ end
75
+
76
+ # Disabled: Ransack's default `result(distinct: true)` makes
77
+ # Postgres reject `sort=variant_product_name` because the order
78
+ # column isn't in the DISTINCT select list. The store scope
79
+ # already guarantees one Price row per result.
80
+ def collection_distinct?
81
+ false
82
+ end
83
+
84
+ def permitted_params
85
+ normalize_params(
86
+ params.permit(:variant_id, :currency, :amount, :compare_at_amount, :price_list_id)
87
+ )
88
+ end
89
+
90
+ private
91
+
92
+ def bulk_record_count_key
93
+ :price_count
94
+ end
95
+
96
+ def require_prices!
97
+ return if params.key?(:prices)
98
+
99
+ render_error(
100
+ code: 'missing_prices',
101
+ message: 'prices is required (send an empty array to no-op).',
102
+ status: :unprocessable_content
103
+ )
104
+ end
105
+
106
+ def decode_price_row(row)
107
+ row = row.respond_to?(:to_unsafe_h) ? row.to_unsafe_h : row.to_h
108
+ row = row.with_indifferent_access
109
+
110
+ {
111
+ id: decode_id(row[:id]),
112
+ variant_id: decode_id(row[:variant_id]),
113
+ price_list_id: row.key?(:price_list_id) ? decode_id(row[:price_list_id]) : nil,
114
+ currency: row[:currency],
115
+ amount: row[:amount],
116
+ compare_at_amount: row[:compare_at_amount]
117
+ }.compact
118
+ end
119
+
120
+ def decode_id(value)
121
+ return nil if value.blank?
122
+
123
+ Spree::PrefixedId.prefixed_id?(value) ? Spree::PrefixedId.decode_prefixed_id(value) : value
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,48 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ module Products
6
+ class VariantsController < ResourceController
7
+ scoped_resource :products
8
+
9
+ protected
10
+
11
+ def model_class
12
+ Spree::Variant
13
+ end
14
+
15
+ def serializer_class
16
+ Spree.api.admin_variant_serializer
17
+ end
18
+
19
+ def set_parent
20
+ @parent = current_store.products.find_by_prefix_id!(params[:product_id])
21
+ authorize!(:show, @parent)
22
+ end
23
+
24
+ def parent_association
25
+ :variants_including_master
26
+ end
27
+
28
+ def scope_includes
29
+ [:prices, stock_items: :stock_location]
30
+ end
31
+
32
+ def permitted_params
33
+ params.permit(
34
+ :sku, :barcode, :price, :compare_at_price,
35
+ :cost_price, :cost_currency,
36
+ :weight, :height, :width, :depth, :weight_unit, :dimensions_unit,
37
+ :track_inventory, :tax_category_id, :position,
38
+ options: [:name, :value],
39
+ prices: [:amount, :compare_at_amount, :currency],
40
+ stock_items: [:stock_location_id, :count_on_hand, :backorderable]
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,237 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ class ProductsController < ResourceController
6
+ include Spree::Api::V3::BulkOperations
7
+
8
+ scoped_resource :products
9
+
10
+ before_action :require_ids!, only: [
11
+ :bulk_status_update,
12
+ :bulk_add_to_categories,
13
+ :bulk_remove_from_categories,
14
+ :bulk_add_to_channels,
15
+ :bulk_remove_from_channels,
16
+ :bulk_destroy
17
+ ]
18
+
19
+ # POST /api/v3/admin/products/:id/clone
20
+ def clone
21
+ @resource = find_resource
22
+ authorize!(:create, @resource)
23
+
24
+ result = @resource.duplicate
25
+ if result.success?
26
+ render json: serialize_resource(result.value), status: :created
27
+ else
28
+ render_service_error(result.error)
29
+ end
30
+ end
31
+
32
+ # POST /api/v3/admin/products/bulk_status_update
33
+ # Body: { ids: [...], status: 'draft' | 'active' | 'archived' }
34
+ def bulk_status_update
35
+ authorize! :update, model_class
36
+
37
+ unless Spree::Product::STATUSES.include?(params[:status].to_s)
38
+ return render_error(
39
+ code: 'invalid_status',
40
+ message: Spree.t(:invalid_status, scope: 'errors.messages', default: 'Invalid status'),
41
+ status: :unprocessable_content
42
+ )
43
+ end
44
+
45
+ count = bulk_collection.update_all(status: params[:status], updated_at: Time.current)
46
+ # `update_all` skips `after_commit`, so the search index won't refresh on its own.
47
+ bulk_collection.each(&:enqueue_search_index)
48
+
49
+ render json: { product_count: count, status: params[:status] }
50
+ end
51
+
52
+ # POST /api/v3/admin/products/bulk_add_to_categories
53
+ # Body: { ids: [...], category_ids: [...] }
54
+ def bulk_add_to_categories
55
+ apply_categories(Spree::Taxons::AddProducts)
56
+ end
57
+
58
+ # POST /api/v3/admin/products/bulk_remove_from_categories
59
+ # Body: { ids: [...], category_ids: [...] }
60
+ def bulk_remove_from_categories
61
+ apply_categories(Spree::Taxons::RemoveProducts)
62
+ end
63
+
64
+ # POST /api/v3/admin/products/bulk_add_to_channels
65
+ # Body: { ids: [...], channel_ids: [...] }
66
+ def bulk_add_to_channels
67
+ authorize! :update, model_class
68
+
69
+ channels = scoped_channels
70
+ product_ids = bulk_collection.distinct.ids
71
+ channels.find_each { |channel| channel.add_products(product_ids) }
72
+
73
+ render json: { product_count: product_ids.size, channel_count: channels.size }
74
+ end
75
+
76
+ # POST /api/v3/admin/products/bulk_remove_from_channels
77
+ # Body: { ids: [...], channel_ids: [...] }
78
+ def bulk_remove_from_channels
79
+ authorize! :update, model_class
80
+
81
+ channels = scoped_channels
82
+ product_ids = bulk_collection.distinct.ids
83
+ removed = channels.sum { |channel| channel.remove_products(product_ids) }
84
+
85
+ render json: { product_count: product_ids.size, channel_count: channels.size, removed: removed }
86
+ end
87
+
88
+ # DELETE /api/v3/admin/products/bulk_destroy
89
+ # Body: { ids: [...] }
90
+ def bulk_destroy
91
+ authorize! :destroy, model_class
92
+
93
+ # Scope by `:destroy` rather than reusing `bulk_collection`
94
+ # (which is `:update`-scoped). Otherwise an admin with update
95
+ # rights but no destroy rights could soft-delete records.
96
+ destroy_scope = model_class.for_store(current_store)
97
+ .accessible_by(current_ability, :destroy)
98
+ .where(id: decode_ids(params[:ids]))
99
+ destroyed = destroy_scope.count(&:destroy)
100
+
101
+ render json: { product_count: destroyed }
102
+ end
103
+
104
+ protected
105
+
106
+ def model_class
107
+ Spree::Product
108
+ end
109
+
110
+ def serializer_class
111
+ Spree.api.admin_product_serializer
112
+ end
113
+
114
+ def scope_includes
115
+ [
116
+ :tax_category,
117
+ product_publications: :channel,
118
+ primary_media: [attachment_attachment: :blob],
119
+ master: [:prices, stock_items: [:stock_location, :active_stock_reservations]],
120
+ variants: [:prices, stock_items: [:stock_location, :active_stock_reservations]]
121
+ ]
122
+ end
123
+
124
+ # Use SearchProvider::Database for collection to handle price/best_selling
125
+ # sorting correctly (counts before sorting, avoiding PG/Mobility issues).
126
+ def collection
127
+ return @collection if @collection.present?
128
+
129
+ filters = params[:q]&.to_unsafe_h || params[:q] || {}
130
+ # Decode Stripe-style prefixed IDs in `*_id_in`/`id_eq`/etc. so SPA
131
+ # filters can pass `prod_…` keys; the search provider expects raw
132
+ # IDs because it goes straight to Ransack on the underlying scope.
133
+ filters = decode_prefixed_id_predicates(filters)
134
+ # `q[search]` is the global text-search predicate; pass it through
135
+ # the provider's `query` arg so it invokes `Product.search` rather
136
+ # than being treated as a Ransack predicate (which gets stripped
137
+ # by the provider's filter sanitizer).
138
+ query = filters['search'] || filters[:search]
139
+
140
+ result = search_provider.search_and_filter(
141
+ scope: scope.includes(collection_includes).preload_associations_lazily.accessible_by(current_ability, :show),
142
+ query: query,
143
+ filters: filters,
144
+ sort: sort_param,
145
+ page: page,
146
+ limit: limit
147
+ )
148
+
149
+ @pagy = result.pagy
150
+ @collection = result.products
151
+ end
152
+
153
+ def permitted_params
154
+ # Product is purely a catalog grouping in API v3. All purchasable
155
+ # attributes (sku, barcode, price, weight, dimensions, stock,
156
+ # track_inventory) live on variants. See
157
+ # docs/plans/6.0-remove-master-variant.md.
158
+ #
159
+ # Top-level `prices` is a convenience for simple (no-options)
160
+ # products: the merchant doesn't need to know the master variant
161
+ # exists, so they ship prices alongside name/status and the
162
+ # `Spree::Product#prices=` setter forwards them to the master.
163
+ params.permit(
164
+ :name, :description, :slug, :status,
165
+ :meta_title, :meta_description, :meta_keywords,
166
+ :tax_category_id,
167
+ :promotionable, :digital,
168
+ tags: [],
169
+ category_ids: [],
170
+ metadata: {},
171
+ prices: [:amount, :compare_at_amount, :currency],
172
+ # Inline custom field values keyed by definition id. The model
173
+ # setter (`Spree::Metafields#custom_fields=`) validates each
174
+ # entry against its definition. We permit `value` as both a
175
+ # scalar AND `value: {}` (any-shape Hash/Array) — Strong
176
+ # Parameters merges them so JSON metafields can ship parsed
177
+ # objects while text/number/boolean ship scalars.
178
+ custom_fields: [:id, :custom_field_definition_id, :value, value: {}],
179
+ # Inline media. Entries with `id` patch an existing asset
180
+ # (alt, position, variant_ids). Entries with `signed_id` create
181
+ # + attach a fresh upload. Lets the dashboard ship media changes
182
+ # alongside the rest of the product form. See
183
+ # `Spree::Product#media=`.
184
+ media: [:id, :signed_id, :alt, :position, :type, variant_ids: []],
185
+ product_publications: [:id, :channel_id, :published_at, :unpublished_at],
186
+ variants: [
187
+ :id, :sku, :barcode,
188
+ :cost_price, :cost_currency,
189
+ :weight, :height, :width, :depth, :weight_unit, :dimensions_unit,
190
+ :track_inventory, :tax_category_id, :position,
191
+ options: [:name, :value],
192
+ prices: [:amount, :compare_at_amount, :currency],
193
+ stock_items: [:id, :stock_location_id, :count_on_hand, :backorderable]
194
+ ]
195
+ )
196
+ end
197
+
198
+ private
199
+
200
+ def search_provider
201
+ @search_provider ||= Spree::SearchProvider::Database.new(current_store)
202
+ end
203
+
204
+ # Mirrors `Spree::Admin::ProductsController#after_bulk_tags_change`:
205
+ # tag changes can flip automatic-taxon matches, and `Tags::Bulk*`
206
+ # touch records via `touch_all` (which skips `after_commit`), so the
207
+ # search index needs an explicit kick.
208
+ def after_bulk_tags_change
209
+ Spree::Product.bulk_auto_match_taxons(current_store, bulk_collection.ids)
210
+ bulk_collection.each(&:enqueue_search_index)
211
+ end
212
+
213
+ def bulk_record_count_key
214
+ :product_count
215
+ end
216
+
217
+ def apply_categories(service)
218
+ authorize! :update, model_class
219
+
220
+ category_ids = decode_ids(params[:category_ids])
221
+ categories = current_store.taxons.accessible_by(current_ability, :update).where(id: category_ids)
222
+
223
+ service.call(taxons: categories, products: bulk_collection)
224
+ Spree::Product.bulk_auto_match_taxons(current_store, bulk_collection.ids)
225
+
226
+ render json: { product_count: bulk_collection.size, category_count: categories.size }
227
+ end
228
+
229
+ def scoped_channels
230
+ channel_ids = decode_ids(params[:channel_ids])
231
+ current_store.channels.accessible_by(current_ability, :manage).where(id: channel_ids)
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,78 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # CRUD for `Spree::PromotionAction` STI subclasses. Each action is
6
+ # nested under a promotion; the create body picks the subclass via
7
+ # `type` and the typed-preferences shape (`preferences: {...}`).
8
+ class PromotionActionsController < ResourceController
9
+ include Spree::Api::V3::Admin::SubclassedResource
10
+
11
+ scoped_resource :promotions
12
+
13
+ subclassed_via -> { Spree.promotions.actions },
14
+ unknown_type_error: 'unknown_promotion_action_type'
15
+
16
+ def types
17
+ authorize! :read, model_class
18
+
19
+ render json: { data: model_class.subclasses_with_preference_schema }
20
+ end
21
+
22
+ # Returns the calculator subclasses registered for the given action
23
+ # `type` (e.g. `?type=Spree::Promotion::Actions::CreateAdjustment`).
24
+ # Each entry includes the calculator's class name, label,
25
+ # description, and preference schema so the SPA can render the
26
+ # picker + nested fields without hardcoding the calculator list.
27
+ def calculators
28
+ authorize! :read, model_class
29
+
30
+ klass = resolve_subclass(params[:type])
31
+ return render_unknown_type unless klass && klass.respond_to?(:calculators)
32
+
33
+ data = klass.calculators.map do |calc|
34
+ {
35
+ type: calc.to_s,
36
+ label: calc.respond_to?(:description) ? calc.description : calc.to_s.demodulize.titleize,
37
+ preference_schema: calc.respond_to?(:serialized_preference_schema) ? calc.serialized_preference_schema : []
38
+ }
39
+ end.sort_by { |entry| entry[:label].to_s }
40
+
41
+ render json: { data: data }
42
+ end
43
+
44
+ protected
45
+
46
+ def model_class
47
+ Spree::PromotionAction
48
+ end
49
+
50
+ def serializer_class
51
+ Spree.api.admin_promotion_action_serializer
52
+ end
53
+
54
+ def permitted_params
55
+ params.permit(:type, preferences: {})
56
+ end
57
+
58
+ def set_parent
59
+ return if %w[types calculators].include?(action_name)
60
+
61
+ @parent = Spree::Promotion.accessible_by(current_ability, :show)
62
+ .find_by_prefix_id!(params[:promotion_id])
63
+ end
64
+
65
+ def parent_association
66
+ :promotion_actions
67
+ end
68
+
69
+ private
70
+
71
+ def build_subclassed_resource(klass, attrs)
72
+ klass.new(attrs.merge(promotion: @parent))
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end