spree_cm_commissioner 2.12.0.pre.pre1 → 2.12.0

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 (45) 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/storefront/draft_orders_controller.rb +6 -1
  8. data/app/controllers/spree/api/v2/storefront/private_sale_products_controller.rb +34 -0
  9. data/app/controllers/spree/api/v2/storefront/queue_draft_orders_controller.rb +6 -1
  10. data/app/controllers/spree/api/v2/storefront/waiting_room_sessions_controller.rb +4 -1
  11. data/app/controllers/spree/api/v2/tenant/draft_orders_controller.rb +6 -1
  12. data/app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb +57 -8
  13. data/app/jobs/spree_cm_commissioner/publish_stock_statuses_to_firestore_job.rb +19 -0
  14. data/app/jobs/spree_cm_commissioner/stock_status_consolidation_job.rb +27 -0
  15. data/app/models/spree_cm_commissioner/inventory_item.rb +25 -0
  16. data/app/models/spree_cm_commissioner/line_item_decorator.rb +4 -0
  17. data/app/models/spree_cm_commissioner/private_sale.rb +106 -0
  18. data/app/models/spree_cm_commissioner/taxon_decorator.rb +21 -0
  19. data/app/models/spree_cm_commissioner/waiting_room_session.rb +9 -0
  20. data/app/overrides/spree/admin/shared/sub_menu/_product/private_sales_tab.html.erb.deface +3 -0
  21. data/app/overrides/spree/admin/taxons/_form/live_stock_window_form_date.html.erb.deface +41 -0
  22. data/app/serializers/spree/v2/storefront/taxon_serializer_decorator.rb +7 -1
  23. data/app/services/spree_cm_commissioner/draft_orders/create.rb +37 -1
  24. data/app/services/spree_cm_commissioner/events/publish_stock_statuses_to_firestore.rb +214 -0
  25. data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities.rb +1 -1
  26. data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold.rb +1 -1
  27. data/app/services/spree_cm_commissioner/private_sales/authorize.rb +41 -0
  28. data/app/services/spree_cm_commissioner/waiting_room/bypass_eligibility.rb +66 -0
  29. data/app/views/spree/admin/inventory_locks/index.html.erb +70 -0
  30. data/app/views/spree/admin/private_sales/_form.html.erb +57 -0
  31. data/app/views/spree/admin/private_sales/_share_link.html.erb +24 -0
  32. data/app/views/spree/admin/private_sales/_stock.html.erb +83 -0
  33. data/app/views/spree/admin/private_sales/edit.html.erb +31 -0
  34. data/app/views/spree/admin/private_sales/index.html.erb +80 -0
  35. data/app/views/spree/admin/private_sales/new.html.erb +14 -0
  36. data/config/initializers/spree_permitted_attributes.rb +2 -0
  37. data/config/locales/en.yml +79 -0
  38. data/config/locales/km.yml +11 -0
  39. data/config/routes.rb +11 -0
  40. data/db/migrate/20260820100000_create_cm_private_sales.rb +27 -0
  41. data/db/migrate/20260824120000_add_user_id_to_cm_waiting_room_sessions.rb +6 -0
  42. data/db/migrate/20260825092416_add_live_stock_window_to_spree_taxons.rb +8 -0
  43. data/lib/spree_cm_commissioner/test_helper/factories/private_sale_factory.rb +25 -0
  44. data/lib/spree_cm_commissioner/version.rb +1 -1
  45. metadata +26 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1bcaa0a7cc8d39b507cd072bfd4576b28f72c83f2344b11ef2097306aa8454dd
4
- data.tar.gz: bf8156fe0e0f2a41febcacc07188e8b0bbd9b2cd79c3ccad5a8d1394fa307db7
3
+ metadata.gz: 10936d8185cc09d4bd8eca8c3c2626cb9bb113d2e94695030be009f9b38edbcc
4
+ data.tar.gz: 8b1493341611af89e52c06017d1abc3ad6adc838a541dd3ec93dd76877985eaf
5
5
  SHA512:
6
- metadata.gz: 19b750a2f5c27cabbc5c507a0e50424a26e295aefa94369787ff490376475f63e56993950e87a9a1b2c3be624f6ec29f2192a51a7e019bfed05e3ee4dcb54018
7
- data.tar.gz: 4acf6f94be21c3960010398b9a431b950b2e3e7bffed455096ea0dc2ed05fca769bd53884b6ed26be86cc11db2a8c220c73711be800bccebf7fc005854454f11
6
+ metadata.gz: f945b868565fa7e4f699c07c35f20bb324b4c87d910f96738d4d4049e5b17d7c1455fd3227c4ab699bf85a449349ab4bc0cdd52b1b3d55bd519c48762da39ab6
7
+ data.tar.gz: d6fd25adafc7a2ad5e710043fbd8dc062008292df46d4819598a0a45333fd4dbbccae1428b6bc389ec6c1bcde2ffd9f7ddd1665a5974ff14989148622fabcadd
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.12.0.pre.pre1)
37
+ spree_cm_commissioner (2.12.0)
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,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: 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
@@ -15,6 +15,7 @@ module Spree
15
15
  # Endpoint: POST /api/v2/storefront/queue_draft_orders
16
16
  # Params: same as POST /api/v2/storefront/draft_orders — line_items: Array of
17
17
  # { variant_id:, quantity:, options: {}, public_metadata: {}, private_metadata: {} } (required)
18
+ # st: private sale token granting this order the product's locked stock (optional).
18
19
  def create
19
20
  result = SpreeCmCommissioner::QueueDraftOrders::Enqueue.call(
20
21
  create_type: 'generic',
@@ -23,7 +24,11 @@ module Spree
23
24
  user: spree_current_user,
24
25
  store: current_store,
25
26
  currency: current_currency,
26
- options: { device_id: device_id, last_ip_address: request.remote_ip }
27
+ options: {
28
+ device_id: device_id,
29
+ last_ip_address: request.remote_ip,
30
+ private_sale_token: params[:st]
31
+ }
27
32
  },
28
33
  locale: I18n.locale.to_s
29
34
  )
@@ -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?
@@ -4,7 +4,7 @@ require 'zlib'
4
4
  module SpreeCmCommissioner
5
5
  class WaitingRoomSessionCreator < BaseInteractor
6
6
  delegate :remote_ip, :waiting_guest_firebase_doc_id, :page_path, :tenant_id,
7
- :device_attributes, to: :context
7
+ :device_attributes, :bypass_jwt, :bypass_order_token, :user, to: :context
8
8
 
9
9
  SESSION_CREATION_LOCK_KEY = 'waiting_room_session_creation'.freeze
10
10
 
@@ -18,6 +18,12 @@ module SpreeCmCommissioner
18
18
  # How long a session's JWT/expired_at is valid before it needs renewal. 3min default.
19
19
  WAITING_ROOM_SESSION_EXPIRE_DURATION_IN_SECOND = (ENV['WAITING_ROOM_SESSION_EXPIRE_DURATION_IN_SECOND'] || (60 * 3)).to_i
20
20
 
21
+ # A verified bypasser is admitted past the normal max, but only up to this bounded headroom
22
+ # (max * multiplier) — never without limit — so a burst of stuck-payment returns can't push the
23
+ # protected pages far past capacity. Default 1.1 (10% headroom); 1 gates bypassers at max. See
24
+ # #bypass_full?.
25
+ WAITING_ROOM_BYPASS_HEADROOM_MULTIPLIER = (ENV['WAITING_ROOM_BYPASS_HEADROOM_MULTIPLIER'] || 1.1).to_f
26
+
21
27
  # Device attributes the client may set on the session. device_id and
22
28
  # device_fingerprint are real columns; the rest are virtual columns backed by
23
29
  # public/private metadata (see WaitingRoomSession). Anything else the client
@@ -76,11 +82,17 @@ module SpreeCmCommissioner
76
82
  # a row that's lapsed but still under its own max_expired_at — goes through the normal
77
83
  # advisory-lock + full? gated path.
78
84
  def create_new_session
85
+ # Checked before the lock, not inside it — the lock is the capacity serialization point and
86
+ # shouldn't be held any longer than necessary. A normal joiner short-circuits here for free.
87
+ bypass = bypass_session?
88
+
79
89
  # Advisory lock ensures the capacity check and session creation are atomic across all app instances.
80
90
  advisory_lock(SESSION_CREATION_LOCK_KEY) do
81
- return context.fail!(message: 'sessions_reach_it_maximum') if full?
91
+ # Normal joiner is gated at max (full?); a verified bypasser at the higher, bounded ceiling
92
+ # (bypass_full?), so a full room still admits bypassers only up to the overshoot.
93
+ return context.fail!(message: 'sessions_reach_it_maximum') if bypass ? bypass_full? : full?
82
94
 
83
- assign_token_and_create_session_to_db
95
+ assign_token_and_create_session_to_db(bypass: bypass)
84
96
  end
85
97
 
86
98
  # commented because of following bug: https://github.com/channainfo/commissioner/issues/2185
@@ -88,14 +100,42 @@ module SpreeCmCommissioner
88
100
  # call_other_waiting_guests
89
101
  end
90
102
 
103
+ # Whether this admission may skip full?. The client relays a signed token plus the order token it
104
+ # was signed with (the X-Spree-Order-Token header) — WaitingRoom::BypassEligibility decides, from that
105
+ # single verified decode plus the caller's own auth (see BypassEligibility#owned_by_caller?),
106
+ # whether it's genuine. Both must be present before we even look, so a normal joiner pays nothing.
107
+ def bypass_session?
108
+ return false if bypass_jwt.blank? || bypass_order_token.blank?
109
+
110
+ bypass_result.success?
111
+ end
112
+
113
+ # Memoized so #apply_bypass_attributes can read the verified payment number/source back off the
114
+ # result (result.value) without re-running the verify. `user` is the same doorkeeper-resolved
115
+ # caller already stamped on the session (nil for an app token/anonymous request).
116
+ def bypass_result
117
+ @bypass_result ||= SpreeCmCommissioner::WaitingRoom::BypassEligibility.call(
118
+ jwt: bypass_jwt, order_token: bypass_order_token, user: user
119
+ )
120
+ end
121
+
91
122
  def full?
92
- max = Rails.cache.fetch('waiting_room/max_sessions_count', expires_in: 1.hour) do
123
+ SpreeCmCommissioner::WaitingRoomSession.active.count >= max_sessions_count
124
+ end
125
+
126
+ # Higher, bounded ceiling used only for a verified bypasser: max plus the headroom
127
+ # (max * WAITING_ROOM_BYPASS_HEADROOM_MULTIPLIER, floored). Once the room reaches it, even a
128
+ # bypasser is rejected. Never called on the normal-joiner path, so the hot path is unchanged.
129
+ def bypass_full?
130
+ SpreeCmCommissioner::WaitingRoomSession.active.count >= (max_sessions_count * WAITING_ROOM_BYPASS_HEADROOM_MULTIPLIER).floor
131
+ end
132
+
133
+ def max_sessions_count
134
+ Rails.cache.fetch('waiting_room/max_sessions_count', expires_in: 1.hour) do
93
135
  fetcher = SpreeCmCommissioner::WaitingRoomSystemMetadataFetcher.new(firestore: firestore)
94
136
  fetcher.load_document_data
95
137
  fetcher.max_sessions_count_with_min
96
138
  end
97
-
98
- SpreeCmCommissioner::WaitingRoomSession.active.count >= max
99
139
  end
100
140
 
101
141
  def advisory_lock(lock_key)
@@ -111,7 +151,7 @@ module SpreeCmCommissioner
111
151
  context.jwt_token = JWT.encode(payload, ENV.fetch('WAITING_ROOM_SESSION_SIGNATURE'), 'HS256')
112
152
  end
113
153
 
114
- def assign_token_and_create_session_to_db
154
+ def assign_token_and_create_session_to_db(bypass: false)
115
155
  session = SpreeCmCommissioner::WaitingRoomSession.where(guest_identifier: waiting_guest_firebase_doc_id).first_or_initialize
116
156
  context.room_session = session
117
157
 
@@ -123,9 +163,17 @@ module SpreeCmCommissioner
123
163
  generate_jwt_token(session.max_expired_at)
124
164
 
125
165
  apply_session_attributes(session)
166
+ apply_bypass_attributes(session) if bypass
126
167
  session.save!
127
168
  end
128
169
 
170
+ # Server-set audit trail for a granted bypass — never client-supplied.
171
+ def apply_bypass_attributes(session)
172
+ session.bypassed = true
173
+ session.bypass_payment_number = bypass_result.value[:payment_number]
174
+ session.bypass_source = bypass_result.value[:source]
175
+ end
176
+
129
177
  def apply_session_attributes(session)
130
178
  session.assign_attributes(
131
179
  {
@@ -133,7 +181,8 @@ module SpreeCmCommissioner
133
181
  expired_at: expired_at,
134
182
  remote_ip: remote_ip,
135
183
  page_path: page_path,
136
- tenant_id: tenant_id
184
+ tenant_id: tenant_id,
185
+ user: user
137
186
  }.merge(sanitized_device_attributes)
138
187
  )
139
188
  end
@@ -0,0 +1,19 @@
1
+ module SpreeCmCommissioner
2
+ # Fired from SpreeCmCommissioner::InventoryItem#publish_live_stock_status_to_firestore
3
+ # (after_commit), one enqueue per event whose stock actually changed, so publishing is near-instant
4
+ # instead of waiting for the next StockStatusConsolidationJob cron tick.
5
+ #
6
+ # ApplicationUniqueJob (args-keyed via activejob-uniqueness, on_conflict: :log) so a burst of
7
+ # concurrent stock changes for the same popular event collapses into one in-flight publish per
8
+ # event_id, instead of one job per changed InventoryItem row.
9
+ class PublishStockStatusesToFirestoreJob < ApplicationUniqueJob
10
+ queue_as :default
11
+
12
+ def perform(event_id:)
13
+ taxon = Spree::Taxon.find_by(id: event_id)
14
+ return unless taxon
15
+
16
+ SpreeCmCommissioner::Events::PublishStockStatusesToFirestore.call(taxon: taxon)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,27 @@
1
+ module SpreeCmCommissioner
2
+ # Safety-net reconciliation sweep, run on a low-frequency cron (cm-market-server/config/schedule.yml,
3
+ # `stock_status_consolidation` entry, dispatched through the existing `InvokeJob` cron pattern — same
4
+ # mechanism already used for `WaitingRoom::StampQueuePositionsJob`/`Integrations::PollingSchedulerJob`).
5
+ #
6
+ # SpreeCmCommissioner::PublishStockStatusesToFirestoreJob (enqueued directly off order-complete, see
7
+ # SpreeCmCommissioner::OrderStateMachine) is the primary, near-instant trigger now. This job exists
8
+ # only to catch anything that trigger misses — a failed job, a Firestore hiccup, or stock changing via
9
+ # a path other than order-complete (e.g. an admin stock adjustment) — by re-scanning every event
10
+ # currently inside its live_stock_starts_at/live_stock_ends_at window (see Spree::Taxon.live_stock_active)
11
+ # regardless of whether anything actually changed. Delegates the actual (batched, per-event) Firestore
12
+ # write to Events::PublishStockStatusesToFirestore, same as the real-time job.
13
+ #
14
+ # ApplicationUniqueJob (not ApplicationJob) so a cycle that runs long — many live events, a slow
15
+ # Firestore round trip — can't overlap with the next cron tick a minute later; `perform` takes no
16
+ # arguments, so this is a single global "is a consolidation cycle already in flight" lock, same pattern
17
+ # as WaitingRoom::StampQueuePositionsJob above.
18
+ class StockStatusConsolidationJob < ApplicationUniqueJob
19
+ queue_as :default
20
+
21
+ def perform
22
+ Spree::Taxon.event.live_stock_active.find_each do |taxon|
23
+ SpreeCmCommissioner::Events::PublishStockStatusesToFirestore.call(taxon: taxon)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -4,6 +4,7 @@ module SpreeCmCommissioner
4
4
  include SpreeCmCommissioner::StoreMetadata
5
5
 
6
6
  MAX_DISPLAY_STOCK = 20
7
+ LOW_STOCK_THRESHOLD = 20
7
8
 
8
9
  # Association
9
10
  belongs_to :variant, class_name: 'Spree::Variant', optional: false
@@ -34,6 +35,7 @@ module SpreeCmCommissioner
34
35
  before_save -> { self.product_type = variant.product_type }, if: -> { product_type.nil? }
35
36
 
36
37
  after_commit :schedule_product_cache_invalidation
38
+ after_commit :publish_live_stock_status_to_firestore
37
39
 
38
40
  # Only for ecommerce: the app uses product.in_stock? (derived from quantity_available) for product cards.
39
41
  # Other types (e.g. accommodation, bus) have many inventory items (including daily-generated ones),
@@ -54,6 +56,29 @@ module SpreeCmCommissioner
54
56
  SpreeCmCommissioner::MaintenanceTasks::CacheInvalidation.pending.create_or_find_by(maintainable: variant.product)
55
57
  end
56
58
 
59
+ # The single trigger point for real-time Firestore stock-status publishing — catches every
60
+ # stock-mutation path that eventually saves an InventoryItem row: order complete (unstock),
61
+ # order cancel (restock), hold acquire/release/convert (all synced async via
62
+ # InventoryItems::BulkAdjustQuantities(OnHold), which uses per-record `update!`, so this callback
63
+ # does fire for those, just after that job's own async DB sync lands), and admin stock edits via
64
+ # `adjust_quantity!`. A burst of concurrent changes to the same event collapses into one in-flight
65
+ # publish, since PublishStockStatusesToFirestoreJob is unique per event_id (ApplicationUniqueJob,
66
+ # until_executed).
67
+ #
68
+ # Ecommerce-only and quantity-changed-only, same guard shape as schedule_product_cache_invalidation
69
+ # above, but checking all four quantity columns (not just available/locked) since quantity_on_hold
70
+ # alone can flip the published bucket to/from pending_hold.
71
+ def publish_live_stock_status_to_firestore
72
+ return unless ecommerce?
73
+ return unless saved_change_to_quantity_available? || saved_change_to_quantity_on_hold? ||
74
+ saved_change_to_quantity_locked? || saved_change_to_quantity_locked_on_hold?
75
+
76
+ event_id = variant.product.event_id
77
+ return if event_id.nil?
78
+
79
+ SpreeCmCommissioner::PublishStockStatusesToFirestoreJob.perform_later(event_id: event_id)
80
+ end
81
+
57
82
  # Both pools as they stood before this save. saved_change_to_* is nil for a column that did not
58
83
  # change, so an unchanged pool falls back to its current value rather than to zero.
59
84
  def total_quantity_before_last_save