spree_cm_commissioner 2.11.0 → 2.12.0.pre.pre2

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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/app/assets/javascripts/spree_cm_commissioner/backend.js +1 -0
  4. data/app/assets/javascripts/spree_cm_commissioner/copy_to_clipboard.js +49 -0
  5. data/app/controllers/spree/admin/inventory_locks_controller.rb +134 -0
  6. data/app/controllers/spree/admin/private_sales_controller.rb +116 -0
  7. data/app/controllers/spree/api/v2/operator/check_ins_controller.rb +22 -4
  8. data/app/controllers/spree/api/v2/operator/dashboard_crew_events_controller.rb +1 -1
  9. data/app/controllers/spree/api/v2/operator/event_charts_controller.rb +34 -12
  10. data/app/controllers/spree/api/v2/resource_controller_decorator.rb +7 -0
  11. data/app/controllers/spree/api/v2/storefront/draft_orders_controller.rb +6 -1
  12. data/app/controllers/spree/api/v2/storefront/private_sale_products_controller.rb +34 -0
  13. data/app/controllers/spree/api/v2/storefront/queue_draft_orders_controller.rb +60 -0
  14. data/app/controllers/spree/api/v2/storefront/waiting_room_sessions_controller.rb +4 -1
  15. data/app/controllers/spree/api/v2/tenant/draft_orders_controller.rb +6 -1
  16. data/app/interactors/spree_cm_commissioner/payment_incomplete_notification_sender.rb +12 -1
  17. data/app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb +57 -8
  18. data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold_job.rb +8 -4
  19. data/app/jobs/spree_cm_commissioner/queue_draft_orders/process_job.rb +23 -0
  20. data/app/jobs/spree_cm_commissioner/stock_status_poll_job.rb +26 -0
  21. data/app/models/concerns/spree_cm_commissioner/event_metadata.rb +2 -0
  22. data/app/models/spree_cm_commissioner/inventory_item.rb +27 -10
  23. data/app/models/spree_cm_commissioner/line_item_decorator.rb +12 -1
  24. data/app/models/spree_cm_commissioner/private_sale.rb +106 -0
  25. data/app/models/spree_cm_commissioner/redis_stock/cached_inventory_items_builder.rb +38 -19
  26. data/app/models/spree_cm_commissioner/stock/availability_checker.rb +15 -7
  27. data/app/models/spree_cm_commissioner/stock/availability_validator_decorator.rb +57 -5
  28. data/app/models/spree_cm_commissioner/stock/line_item_availability_checker.rb +7 -4
  29. data/app/models/spree_cm_commissioner/stock/order_availability_checker.rb +7 -1
  30. data/app/models/spree_cm_commissioner/taxon_decorator.rb +25 -0
  31. data/app/models/spree_cm_commissioner/variant_decorator.rb +5 -1
  32. data/app/models/spree_cm_commissioner/waiting_room_session.rb +9 -0
  33. data/app/overrides/spree/admin/shared/sub_menu/_product/private_sales_tab.html.erb.deface +3 -0
  34. data/app/overrides/spree/admin/taxons/_form/live_stock_window_form_date.html.erb.deface +41 -0
  35. data/app/queries/spree_cm_commissioner/dashboard_crew_event_query.rb +10 -5
  36. data/app/serializers/spree/v2/storefront/taxon_serializer_decorator.rb +7 -1
  37. data/app/services/spree_cm_commissioner/cart/add_guest.rb +12 -7
  38. data/app/services/spree_cm_commissioner/draft_orders/create.rb +37 -1
  39. data/app/services/spree_cm_commissioner/events/publish_stock_statuses_to_firestore.rb +178 -0
  40. data/app/services/spree_cm_commissioner/inventory_holds/acquire.rb +27 -12
  41. data/app/services/spree_cm_commissioner/inventory_holds/convert.rb +10 -9
  42. data/app/services/spree_cm_commissioner/inventory_holds/release.rb +12 -11
  43. data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities.rb +30 -9
  44. data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold.rb +33 -8
  45. data/app/services/spree_cm_commissioner/inventory_items/move_to_locked.rb +159 -0
  46. data/app/services/spree_cm_commissioner/inventory_items/reset.rb +38 -18
  47. data/app/services/spree_cm_commissioner/private_sales/authorize.rb +41 -0
  48. data/app/services/spree_cm_commissioner/queue_draft_orders/enqueue.rb +64 -0
  49. data/app/services/spree_cm_commissioner/queue_draft_orders/process.rb +81 -0
  50. data/app/services/spree_cm_commissioner/queue_draft_orders/publish_status.rb +39 -0
  51. data/app/services/spree_cm_commissioner/redis_stock/restock.rb +16 -5
  52. data/app/services/spree_cm_commissioner/redis_stock/unstock.rb +12 -5
  53. data/app/services/spree_cm_commissioner/waiting_room/bypass_eligibility.rb +66 -0
  54. data/app/views/spree/admin/inventory_locks/index.html.erb +70 -0
  55. data/app/views/spree/admin/private_sales/_form.html.erb +57 -0
  56. data/app/views/spree/admin/private_sales/_share_link.html.erb +24 -0
  57. data/app/views/spree/admin/private_sales/_stock.html.erb +83 -0
  58. data/app/views/spree/admin/private_sales/edit.html.erb +31 -0
  59. data/app/views/spree/admin/private_sales/index.html.erb +80 -0
  60. data/app/views/spree/admin/private_sales/new.html.erb +14 -0
  61. data/app/views/spree/admin/stock_managements/index.html.erb +8 -0
  62. data/config/initializers/spree_permitted_attributes.rb +2 -0
  63. data/config/locales/en.yml +90 -4
  64. data/config/locales/km.yml +12 -1
  65. data/config/routes.rb +12 -0
  66. data/db/migrate/20260820000000_add_locked_quantities_to_cm_inventory_items.rb +12 -0
  67. data/db/migrate/20260820100000_create_cm_private_sales.rb +27 -0
  68. data/db/migrate/20260824120000_add_user_id_to_cm_waiting_room_sessions.rb +6 -0
  69. data/db/migrate/20260825092416_add_live_stock_window_to_spree_taxons.rb +8 -0
  70. data/lib/spree_cm_commissioner/cached_inventory_item.rb +9 -0
  71. data/lib/spree_cm_commissioner/test_helper/factories/private_sale_factory.rb +25 -0
  72. data/lib/spree_cm_commissioner/version.rb +1 -1
  73. metadata +33 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58bbc65915f1f69096fabf6a1778dd9d810ebdbf8e788c2b1597e3b2173f2204
4
- data.tar.gz: c32858179db78672e07bc34c5f7395b59b228d5585e35647c3d5486cf0c0ea9c
3
+ metadata.gz: c7223999a2cd0d551f0694c7cd4103195db0ca0473b7ee577a17be13d6686f5f
4
+ data.tar.gz: bf3fa4a68bf67915a77bd37b2cc2fdaa7f05bece78be8101b76c786f9d8daad5
5
5
  SHA512:
6
- metadata.gz: 6493ccc10ddcb0dfae06115f11168207bdee8f8d687367108ba59e41885e10a989f8adcb17a220019039ef3a5c224ab3fe4356c6f1e90ae9130e4a607347e015
7
- data.tar.gz: 363d64fd5b2aa2f809163134dd050b85d46d529fe35a34994381c3bc7861acd4f57afab353fdd2772081e019a5f49eb78f4d9141ee9d7a44e06991b07170d764
6
+ metadata.gz: 7e4b196cc0f1426f4672f511546509ddcbf41981203f8affb32e26df9c2e2f0d08cefefb2530810405fa8d9cdf15f3a680e6ea78fcf0751b0efe7dba25e7ce8d
7
+ data.tar.gz: 9de8a0f14b88d42174f53d13cf3ac2cf0fd7cf5aea024a31f8e5c8a76aa86dc0dc40ac2f6d5774d7ecc35ca3a54868e4f48fb5084321bbb0c8066138a304669f
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.11.0)
37
+ spree_cm_commissioner (2.12.0.pre.pre2)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -12,3 +12,4 @@
12
12
  //= require spree_cm_commissioner/location_checkbox_manager
13
13
  //= require spree_cm_commissioner/trip_stop_selection
14
14
  //= require spree_cm_commissioner/tenant_payment_icon_fields
15
+ //= require spree_cm_commissioner/copy_to_clipboard
@@ -0,0 +1,49 @@
1
+ // Copy-to-clipboard for any element carrying data-copy-value.
2
+ //
3
+ // Delegated from document rather than bound per button: the private sales index renders one of these
4
+ // per row, and re-binding on every Turbo page load would stack duplicate listeners on the same nodes.
5
+ //
6
+ // The icon swap is the whole feedback mechanism — without it a click looks like nothing happened.
7
+ document.addEventListener("click", function (event) {
8
+ const trigger = event.target.closest("[data-copy-value]");
9
+ if (!trigger) return;
10
+
11
+ event.preventDefault();
12
+ copyText(trigger.dataset.copyValue).then(function () {
13
+ confirmCopied(trigger);
14
+ });
15
+ });
16
+
17
+ // navigator.clipboard needs a secure context, which rules it out on plain-http staging boxes.
18
+ // execCommand is deprecated but is the only thing that works there, so it stays as the fallback.
19
+ function copyText(value) {
20
+ if (navigator.clipboard && window.isSecureContext) {
21
+ return navigator.clipboard.writeText(value);
22
+ }
23
+
24
+ const scratch = document.createElement("textarea");
25
+ scratch.value = value;
26
+ scratch.setAttribute("readonly", "");
27
+ scratch.style.position = "fixed";
28
+ scratch.style.opacity = "0";
29
+ document.body.appendChild(scratch);
30
+ scratch.select();
31
+ document.execCommand("copy");
32
+ document.body.removeChild(scratch);
33
+
34
+ return Promise.resolve();
35
+ }
36
+
37
+ function confirmCopied(trigger) {
38
+ const done = trigger.querySelector("[data-copy-done]");
39
+ const idle = trigger.querySelector("[data-copy-idle]");
40
+ if (!done || !idle) return;
41
+
42
+ idle.style.display = "none";
43
+ done.style.display = "inline";
44
+
45
+ setTimeout(function () {
46
+ idle.style.display = "inline";
47
+ done.style.display = "none";
48
+ }, 1500);
49
+ }
@@ -0,0 +1,134 @@
1
+ module Spree
2
+ module Admin
3
+ # Withholds units of one product from public sale.
4
+ #
5
+ # Reached only from a private sale — there is no tab or sidebar entry. `create` is the POST target
6
+ # for the per-variant inputs on Spree::Admin::PrivateSalesController#edit, and `index` is the
7
+ # date-range screen that page links to for permanent-stock products, where one variant has an
8
+ # inventory item per date and a single input cannot express the range.
9
+ #
10
+ # All the accounting lives in InventoryItems::MoveToLocked — this controller resolves the
11
+ # product, calls it, and turns its named failures into flash messages.
12
+ #
13
+ class InventoryLocksController < Spree::Admin::ResourceController
14
+ skip_before_action :load_resource
15
+
16
+ before_action :load_parent
17
+
18
+ # GET /admin/products/:product_id/inventory_locks
19
+ def index
20
+ # Only active items can be locked — a past date cannot be sold, so withholding from it
21
+ # protects nothing (MoveToLocked refuses it outright).
22
+ # Paginated like every other admin index here: a permanent-stock product has one row per
23
+ # variant per date, so an unpaginated page is thousands of rows.
24
+ @inventory_items = @product.inventory_items.active
25
+ .includes(variant: { option_values: :option_type })
26
+ .order(:inventory_date)
27
+ .page(params[:page]).per(50)
28
+ end
29
+
30
+ # POST /admin/products/:product_id/inventory_locks
31
+ #
32
+ # One action for both directions: a positive quantity locks, a negative one releases. Applied
33
+ # across a date range for permanent-stock products, where each date is its own inventory item —
34
+ # "lock 5" means 5 per date, not 5 across the range.
35
+ def create
36
+ items = target_inventory_items
37
+ return redirect_with_error(:invalid_date_range) if items.nil?
38
+ return redirect_with_error(:no_inventory_item) if items.empty?
39
+
40
+ moved, failed = items.map { |item| move(item) }.partition(&:success?)
41
+ return redirect_with_error(failed.first.error.value) if moved.empty?
42
+
43
+ # Each date is moved independently and nothing can undo one that already went through —
44
+ # Redis has no rollback. So a mixed outcome says how many landed: a bare error would read as
45
+ # "nothing changed" while some dates are already locked.
46
+ if failed.empty?
47
+ flash[:success] = t('.moved', count: moved.size)
48
+ else
49
+ reason = error_sentence(failed.first.error.value)
50
+ flash[:error] = t('.partially_moved', count: moved.size, total: items.size, reason: reason)
51
+ end
52
+
53
+ redirect_back_to_caller
54
+ end
55
+
56
+ private
57
+
58
+ def load_parent
59
+ # Handled here rather than left to ResourceController's rescue_from: its handler redirects to
60
+ # collection_url, which for this controller resolves to a route that does not exist
61
+ # (model_class is InventoryItem, which has no admin resource route) — turning one 500 into
62
+ # another. An unknown or renamed slug belongs back at the product list.
63
+ @product = Spree::Product.find_by(slug: params[:product_id])
64
+ return if @product
65
+
66
+ flash[:error] = t('spree.admin.inventory_locks.create.product_not_found')
67
+ redirect_to spree.admin_products_path
68
+ end
69
+
70
+ def move(inventory_item)
71
+ SpreeCmCommissioner::InventoryItems::MoveToLocked.call(
72
+ inventory_item: inventory_item,
73
+ quantity: params[:quantity],
74
+ caller_source: "#{self.class.name}#create"
75
+ )
76
+ end
77
+
78
+ # A date range covers one inventory item per date; without one, ONLY the undated row.
79
+ #
80
+ # Never falls back to "every active date". Both callers can omit the range — the private sale's
81
+ # inline button always does — and a permanent-stock variant has a row per date for a year, so
82
+ # withholding across all of them because an operator left "To date" empty is unrecoverable:
83
+ # MoveToLocked's Redis half cannot be rolled back.
84
+ #
85
+ # Returns nil for a malformed range so #create can tell it apart from "found nothing".
86
+ def target_inventory_items
87
+ scope = @product.inventory_items.active.where(variant_id: params[:variant_id])
88
+ return scope.where(inventory_date: nil).to_a if params[:from_date].blank? || params[:to_date].blank?
89
+
90
+ dates = parsed_date_range
91
+ return nil if dates.nil?
92
+
93
+ scope.where(inventory_date: dates).to_a
94
+ end
95
+
96
+ # nil when either date is unparseable, or when the range runs backwards — both are operator
97
+ # error, and an empty result would report the misleading "no stock on sale" instead.
98
+ def parsed_date_range
99
+ from = params[:from_date].to_date
100
+ to = params[:to_date].to_date
101
+ return nil if from > to
102
+
103
+ from..to
104
+ # Date::Error subclasses ArgumentError, so this covers both.
105
+ rescue ArgumentError
106
+ nil
107
+ end
108
+
109
+ def redirect_with_error(reason)
110
+ flash[:error] = error_sentence(reason)
111
+ redirect_back_to_caller
112
+ end
113
+
114
+ # MoveToLocked's named failures carry their sentences; this controller's own reasons (a variant
115
+ # with no inventory item) are local. Falls back to the humanized symbol so a new failure name
116
+ # still reads as English rather than as a missing-translation string.
117
+ def error_sentence(reason)
118
+ service_message = t("spree_cm_commissioner.inventory_items.move_to_locked.#{reason}", default: reason.to_s.humanize)
119
+
120
+ t(".#{reason}", default: service_message)
121
+ end
122
+
123
+ # Posted from the private sale's edit page for undated stock, and from this screen for
124
+ # permanent-stock date ranges, so return to whichever asked.
125
+ def redirect_back_to_caller
126
+ redirect_back fallback_location: admin_product_inventory_locks_path(@product)
127
+ end
128
+
129
+ def model_class
130
+ SpreeCmCommissioner::InventoryItem
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,116 @@
1
+ module Spree
2
+ module Admin
3
+ # Managing private sales: which products, sold to whom, until when — and, on edit, how much of
4
+ # each variant to withhold.
5
+ #
6
+ # New is deliberately thin: name, dates and three remote-search pickers, so opening it costs one
7
+ # query for roles and nothing else. Creating redirects straight to edit, which is where the
8
+ # allotment is actually set up now that the products are known.
9
+ #
10
+ class PrivateSalesController < Spree::Admin::ResourceController
11
+ # Also on update: ResourceController re-renders :edit when validation fails, and the stock
12
+ # partial reads these ivars — without this a rejected save is a 500 instead of a form.
13
+ before_action :load_stock_rows, only: %i[edit update]
14
+ before_action :load_assignable_roles, only: %i[new create edit update]
15
+
16
+ # GET /admin/private_sales
17
+ def index
18
+ # Off `collection`, which ResourceController already scoped with accessible_by(current_ability)
19
+ # — going through model_class directly would discard that.
20
+ @private_sales = collection.order(created_at: :desc).page(params[:page]).per(25)
21
+
22
+ load_audience_names
23
+ end
24
+
25
+ # PATCH /admin/private_sales/:id/revoke
26
+ #
27
+ # The point of storing the rule rather than signing it into the URL: one sale dies without
28
+ # touching the lock, so every other sale drawing on the same stock keeps working.
29
+ def revoke
30
+ # @private_sale is already loaded and authorized by ResourceController#load_resource.
31
+ @private_sale.revoked!
32
+
33
+ flash[:success] = t('.revoked')
34
+ redirect_to admin_private_sales_path
35
+ end
36
+
37
+ private
38
+
39
+ # Names for every product, user and role named by the sales ON THIS PAGE, in three queries.
40
+ # The ids live in jsonb rather than join tables, so there is no association for `includes` to
41
+ # preload — asking each row for its own would be 3 queries per row.
42
+ def load_audience_names
43
+ sales = @private_sales.to_a
44
+
45
+ @product_names = Spree::Product.where(id: sales.flat_map(&:product_ids)).pluck(:id, :name).to_h
46
+ @user_emails = Spree::User.where(id: sales.flat_map(&:user_ids)).pluck(:id, :email).to_h
47
+ @role_names = Spree::Role.where(id: sales.flat_map(&:role_ids))
48
+ .pluck(:id, :presentation, :name)
49
+ .to_h { |id, presentation, name| [id, presentation.presence || name] }
50
+ end
51
+
52
+ # Straight to edit rather than back to the list: a sale is not usable until stock is withheld
53
+ # for it, and edit is where that happens. Landing on the index would hide the next step.
54
+ # non_vendor: a private sale grants access across the store, and this repo's roles include
55
+ # per-vendor and per-event check-in roles whose `presentation` is only unique within a vendor —
56
+ # listing them all renders hundreds of indistinguishable duplicate options.
57
+ def load_assignable_roles
58
+ @assignable_roles = Spree::Role.non_vendor.order(:name)
59
+ end
60
+
61
+ def location_after_save
62
+ edit_admin_private_sale_path(@private_sale)
63
+ end
64
+
65
+ # Everything the edit page's per-variant lock controls need, in three queries regardless of how
66
+ # many products the sale names.
67
+ #
68
+ # Only UNDATED inventory items are loaded. A permanent-stock product has one row per date —
69
+ # hundreds per variant — which no inline control can express and which would make this page as
70
+ # slow as the table is large. Those link out to the product's own date-range screen instead.
71
+ def load_stock_rows
72
+ product_ids = @private_sale.product_ids
73
+ return @variants_by_product = {} if product_ids.empty?
74
+
75
+ @products_by_id = Spree::Product.where(id: product_ids).index_by(&:id)
76
+
77
+ # Master is excluded when a product has real variants — it carries no stock of its own there
78
+ # and would render as a blank row. A product with no variants sells through master, so it is
79
+ # the fallback. Same rule as Spree::Admin::StockManagementsController.
80
+ # option_values: :option_type, not just :option_values — sku_and_options_text walks the second
81
+ # hop, so preloading one level still fires a query per option value.
82
+ variants = Spree::Variant.where(product_id: product_ids)
83
+ .includes(option_values: :option_type)
84
+ .order(:id)
85
+ by_product = variants.group_by(&:product_id)
86
+
87
+ @variants_by_product = by_product.transform_values do |list|
88
+ list.reject(&:is_master?).presence || list
89
+ end
90
+
91
+ @inventory_item_by_variant = SpreeCmCommissioner::InventoryItem
92
+ .where(variant_id: @variants_by_product.values.flatten.map(&:id), inventory_date: nil)
93
+ .index_by(&:variant_id)
94
+ end
95
+
96
+ # override — the audience is posted as id arrays by the select2 pickers in _form.
97
+ def permitted_resource_params
98
+ params.require(:private_sale)
99
+ .permit(:name, :expires_at, product_ids: [], role_ids: [], user_ids: [])
100
+ end
101
+
102
+ # override — created_by is ours to set, never the form's.
103
+ def build_resource
104
+ model_class.new(created_by: try_spree_current_user)
105
+ end
106
+
107
+ def collection_url(options = {})
108
+ admin_private_sales_path(options)
109
+ end
110
+
111
+ def model_class
112
+ SpreeCmCommissioner::PrivateSale
113
+ end
114
+ end
115
+ end
116
+ end
@@ -10,10 +10,14 @@ module Spree
10
10
  CACHE_EXPIRES_IN = 1.minute
11
11
 
12
12
  def collection
13
- @collection ||= SpreeCmCommissioner::CheckIn
14
- .where(checkinable_type: 'Spree::Taxon', checkinable_id: params[:taxon_id])
15
- .page(params[:page])
16
- .per(params[:per_page])
13
+ @collection ||= if restricted?
14
+ SpreeCmCommissioner::CheckIn.none
15
+ else
16
+ SpreeCmCommissioner::CheckIn
17
+ .where(checkinable_type: 'Spree::Taxon', checkinable_id: params[:taxon_id])
18
+ .page(params[:page])
19
+ .per(params[:per_page])
20
+ end
17
21
  end
18
22
 
19
23
  # override
@@ -41,6 +45,20 @@ module Spree
41
45
 
42
46
  private
43
47
 
48
+ # Non-organizers may only see check-in history for an event they're assigned
49
+ # to, and only while it's ongoing — once it ends, it becomes organizer-owned
50
+ # history (admins assign themselves the organizer role explicitly if they
51
+ # need access). A missing/nonexistent taxon_id is restricted the same way.
52
+ def restricted?
53
+ return false if spree_current_user.organizer?
54
+
55
+ taxon = Spree::Taxon.find_by(id: params[:taxon_id])
56
+ return true if taxon.nil?
57
+ return true unless spree_current_user.events.exists?(id: taxon.id)
58
+
59
+ taxon.ended?
60
+ end
61
+
44
62
  def model_class
45
63
  SpreeCmCommissioner::CheckIn
46
64
  end
@@ -7,7 +7,7 @@ module Spree
7
7
 
8
8
  def collection
9
9
  SpreeCmCommissioner::DashboardCrewEventQuery.new(
10
- user_id: spree_current_user.id,
10
+ user: spree_current_user,
11
11
  section: params[:section] || 'incoming'
12
12
  ).events
13
13
  end
@@ -7,23 +7,31 @@ module Spree
7
7
  ADDITIONAL_FIELDS = %i[participation entry_type].freeze
8
8
 
9
9
  def index
10
- kyc_fields = fetch_filtered_kyc_fields
11
- types = ADDITIONAL_FIELDS + kyc_fields
12
-
13
- resources = types.map do |type|
14
- # Create unique ID by using chart_type as the identifier
15
- SpreeCmCommissioner::EventChart.new(
16
- id: type.to_s,
17
- chart_type: type,
18
- product_charts: nil
19
- )
20
- end
10
+ resources = if restricted?
11
+ []
12
+ else
13
+ kyc_fields = fetch_filtered_kyc_fields
14
+ types = ADDITIONAL_FIELDS + kyc_fields
15
+
16
+ types.map do |type|
17
+ # Create unique ID by using chart_type as the identifier
18
+ SpreeCmCommissioner::EventChart.new(
19
+ id: type.to_s,
20
+ chart_type: type,
21
+ product_charts: nil
22
+ )
23
+ end
24
+ end
21
25
 
22
26
  render json: SpreeCmCommissioner::V2::Operator::PieChartEventAggregatorSerializer.new(resources).serializable_hash
23
27
  end
24
28
 
25
29
  def resource
26
- @resource = event_chart_aggregator_queries.call
30
+ @resource = if restricted?
31
+ SpreeCmCommissioner::EventChart.new(id: params[:id], chart_type: params[:id] || 'participation', product_charts: [])
32
+ else
33
+ event_chart_aggregator_queries.call
34
+ end
27
35
  end
28
36
 
29
37
  def resource_serializer
@@ -32,6 +40,20 @@ module Spree
32
40
 
33
41
  private
34
42
 
43
+ # Non-organizers may only see chart data for an event they're assigned to,
44
+ # and only while it's ongoing — once it ends, it becomes organizer-owned
45
+ # history (admins assign themselves the organizer role explicitly if they
46
+ # need access). A missing/nonexistent taxon_id is restricted the same way.
47
+ def restricted?
48
+ return false if spree_current_user&.organizer?
49
+
50
+ taxon = Spree::Taxon.find_by(id: params[:taxon_id])
51
+ return true if taxon.nil?
52
+ return true unless spree_current_user&.events&.exists?(id: taxon.id)
53
+
54
+ taxon.ended?
55
+ end
56
+
35
57
  def event_chart_aggregator_queries
36
58
  SpreeCmCommissioner::EventChartQueries.new(
37
59
  taxon_id: params[:taxon_id],
@@ -18,6 +18,13 @@ module Spree
18
18
  request.headers['X-Cm-App-Name']
19
19
  end
20
20
 
21
+ # Set by DefaultInterceptor (cm-market-app/packages/core) on every request, across every
22
+ # app (customer, operator, terminal) that shares it — the single source for device_id,
23
+ # replacing the old per-endpoint params[:device_id] (never actually sent by any client).
24
+ def device_id
25
+ request.headers['X-Cm-Device-Id']
26
+ end
27
+
21
28
  def required_schema_validation!
22
29
  return true if required_schema.nil?
23
30
 
@@ -10,13 +10,18 @@ module Spree
10
10
  # Endpoint: POST /api/v2/storefront/draft_orders
11
11
  # Params:
12
12
  # - line_items: Array of { variant_id:, quantity:, options: {}, public_metadata: {}, private_metadata: {} } (required)
13
+ # - st: private sale token granting this order the product's locked stock (optional).
13
14
  def create
14
15
  result = SpreeCmCommissioner::DraftOrders::Create.call(
15
16
  line_items: draft_order_line_items,
16
17
  user: spree_current_user,
17
18
  store: current_store,
18
19
  currency: current_currency,
19
- options: { device_id: params[:device_id], last_ip_address: request.remote_ip }
20
+ options: {
21
+ device_id: device_id,
22
+ last_ip_address: request.remote_ip,
23
+ private_sale_token: params[:st]
24
+ }
20
25
  )
21
26
 
22
27
  if result.success?
@@ -0,0 +1,34 @@
1
+ module Spree
2
+ module Api
3
+ module V2
4
+ module Storefront
5
+ # GET /api/v2/storefront/private_sale_products?st=<private sale token>
6
+ class PrivateSaleProductsController < ::Spree::Api::V2::Storefront::ProductsController
7
+ before_action :require_spree_current_user
8
+ before_action :authorize_private_sale_token!
9
+
10
+ private
11
+
12
+ attr_reader :private_sale
13
+
14
+ def authorize_private_sale_token!
15
+ result = SpreeCmCommissioner::PrivateSales::Authorize.call(
16
+ token: params[:st],
17
+ user: spree_current_user
18
+ )
19
+
20
+ return @private_sale = result.value if result.success?
21
+
22
+ render_error_payload(SpreeCmCommissioner::PrivateSales::Authorize.error_message(result.error.value), 403)
23
+ end
24
+
25
+ # override — the token names the products, so the client sends no ids. Returns them as a
26
+ # collection: one link is an agent's whole allotment across an event, not a single product.
27
+ def scope(**args)
28
+ super.where(id: private_sale.product_ids)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,60 @@
1
+ module Spree
2
+ module Api
3
+ module V2
4
+ module Storefront
5
+ class QueueDraftOrdersController < ::Spree::Api::V2::ResourceController
6
+ # Creating the order (once the job runs) acquires the inventory hold, so honor the
7
+ # load-test bypass here too, same as DraftOrdersController.
8
+ include SpreeCmCommissioner::LoadTestBypassable
9
+
10
+ # Accepts a draft order request without building it: writes a queued Firestore status
11
+ # document and schedules the real build (SpreeCmCommissioner::DraftOrders::Create) on a
12
+ # background job. The app watches the returned document for the eventual outcome
13
+ # instead of waiting on this request.
14
+ #
15
+ # Endpoint: POST /api/v2/storefront/queue_draft_orders
16
+ # Params: same as POST /api/v2/storefront/draft_orders — line_items: Array of
17
+ # { variant_id:, quantity:, options: {}, public_metadata: {}, private_metadata: {} } (required)
18
+ # st: private sale token granting this order the product's locked stock (optional).
19
+ def create
20
+ result = SpreeCmCommissioner::QueueDraftOrders::Enqueue.call(
21
+ create_type: 'generic',
22
+ creation_params: {
23
+ line_items: draft_order_line_items,
24
+ user: spree_current_user,
25
+ store: current_store,
26
+ currency: current_currency,
27
+ options: {
28
+ device_id: device_id,
29
+ last_ip_address: request.remote_ip,
30
+ private_sale_token: params[:st]
31
+ }
32
+ },
33
+ locale: I18n.locale.to_s
34
+ )
35
+
36
+ if result.success?
37
+ render_serialized_payload { result.value }
38
+ else
39
+ render_error_payload(result.error)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ # .to_h.deep_symbolize_keys converts every level (including line_items and their nested
46
+ # options/metadata) from ActionController::Parameters into plain Ruby Hashes with symbol
47
+ # keys — required because this crosses an ActiveJob serialization boundary
48
+ # (QueueDraftOrders::ProcessJob), unlike DraftOrdersController's version of this method,
49
+ # which hands Parameters straight to DraftOrders::Create within the same request.
50
+ def draft_order_line_items
51
+ params.permit(line_items: [:variant_id, :quantity, { options: {}, public_metadata: {}, private_metadata: {} }])
52
+ .to_h
53
+ .deep_symbolize_keys
54
+ .fetch(:line_items, [])
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -15,7 +15,10 @@ module Spree
15
15
  remote_ip: request.remote_ip,
16
16
  waiting_guest_firebase_doc_id: params[:waiting_guest_firebase_doc_id],
17
17
  page_path: params[:page_path],
18
- device_attributes: permitted_device_attributes
18
+ device_attributes: permitted_device_attributes,
19
+ bypass_jwt: params[:bypass_jwt],
20
+ bypass_order_token: request.headers['X-Spree-Order-Token'],
21
+ user: spree_current_user
19
22
  )
20
23
 
21
24
  if context.success?
@@ -7,6 +7,7 @@ module Spree
7
7
  # Endpoint: POST /api/v2/tenant/draft_orders
8
8
  # Params:
9
9
  # - line_items: Array of { variant_id:, quantity:, options: {}, public_metadata: {}, private_metadata: {} } (required)
10
+ # - st: private sale token granting this order the product's locked stock (optional).
10
11
  def create
11
12
  result = SpreeCmCommissioner::DraftOrders::Create.call(
12
13
  line_items: draft_order_line_items,
@@ -14,7 +15,11 @@ module Spree
14
15
  store: current_store,
15
16
  currency: current_currency,
16
17
  tenant: @tenant,
17
- options: { device_id: params[:device_id], last_ip_address: request.remote_ip }
18
+ options: {
19
+ device_id: params[:device_id],
20
+ last_ip_address: request.remote_ip,
21
+ private_sale_token: params[:st]
22
+ }
18
23
  )
19
24
 
20
25
  if result.success?
@@ -8,12 +8,23 @@ module SpreeCmCommissioner
8
8
  SpreeCmCommissioner::OrderGeneralNotification.with(
9
9
  notificable: order,
10
10
  title: title,
11
- message: message(order)
11
+ message: message(order),
12
+ payload: payload(order)
12
13
  ).deliver_later(order.user)
13
14
  end
14
15
 
15
16
  private
16
17
 
18
+ # Overrides notification_type so the app can route this tap to the payment screen instead of
19
+ # the booking details every other OrderGeneralNotification lands on. The screen loads the cart
20
+ # by token, not by number, so the token has to ride along or the tap has nothing to open.
21
+ def payload(order)
22
+ {
23
+ notification_type: 'payment_incomplete_notification',
24
+ order_token: order.token
25
+ }
26
+ end
27
+
17
28
  def title
18
29
  I18n.t('notifications.spree_cm_commissioner.payment_incomplete_notification.title')
19
30
  end