spree_cm_commissioner 2.14.7 → 2.14.9

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/app/controllers/concerns/spree_cm_commissioner/content_cachable.rb +9 -1
  4. data/app/controllers/concerns/spree_cm_commissioner/guest_owner_authorization.rb +30 -0
  5. data/app/controllers/spree/admin/stock_managements_controller.rb +12 -1
  6. data/app/controllers/spree/api/v2/organizer/invites_controller.rb +5 -1
  7. data/app/controllers/spree/api/v2/storefront/guests_controller.rb +13 -7
  8. data/app/controllers/spree/api/v2/storefront/id_cards_controller.rb +6 -4
  9. data/app/controllers/spree/api/v2/storefront/products_controller_decorator.rb +1 -1
  10. data/app/controllers/spree/api/v2/storefront/s3_signed_urls_controller.rb +4 -3
  11. data/app/controllers/spree/api/v2/tenant/base_controller.rb +23 -3
  12. data/app/controllers/spree/api/v2/tenant/cart_controller.rb +1 -1
  13. data/app/controllers/spree/api/v2/tenant/id_cards_controller.rb +6 -4
  14. data/app/controllers/spree/api/v2/tenant/payment_methods_controller.rb +1 -2
  15. data/app/controllers/spree/api/v2/tenant/products_controller.rb +1 -1
  16. data/app/controllers/spree/api/v2/tenant/s3_signed_urls_controller.rb +4 -3
  17. data/app/interactors/spree_cm_commissioner/guest_id_card_manager.rb +18 -9
  18. data/app/interactors/spree_cm_commissioner/id_card_image_updater.rb +6 -3
  19. data/app/interactors/spree_cm_commissioner/image_saver.rb +4 -3
  20. data/app/interactors/spree_cm_commissioner/profile_image_updater.rb +6 -3
  21. data/app/jobs/concerns/spree_cm_commissioner/idempotent_job.rb +7 -6
  22. data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_job.rb +4 -13
  23. data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_convert_job.rb +2 -21
  24. data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold_job.rb +3 -29
  25. data/app/models/concerns/spree_cm_commissioner/firestore_connection.rb +7 -9
  26. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +1 -9
  27. data/app/models/concerns/spree_cm_commissioner/sales_channel_bitwise.rb +44 -0
  28. data/app/models/concerns/spree_cm_commissioner/user_uploaded_image.rb +17 -0
  29. data/app/models/spree_cm_commissioner/back_image.rb +2 -0
  30. data/app/models/spree_cm_commissioner/front_image.rb +2 -0
  31. data/app/models/spree_cm_commissioner/inventory_hold.rb +5 -0
  32. data/app/models/spree_cm_commissioner/order_decorator.rb +11 -0
  33. data/app/models/spree_cm_commissioner/product_decorator.rb +4 -0
  34. data/app/models/spree_cm_commissioner/s3_presigned_url.rb +8 -7
  35. data/app/models/spree_cm_commissioner/s3_presigned_url_builder.rb +9 -3
  36. data/app/models/spree_cm_commissioner/tenant.rb +3 -0
  37. data/app/models/spree_cm_commissioner/user_profile.rb +2 -0
  38. data/app/overrides/spree/admin/products/_form/enable_confirmation_email.html.erb.deface +13 -0
  39. data/app/overrides/spree/admin/products/_form/sellable_on_platform.html.erb.deface +12 -0
  40. data/app/serializers/spree/v2/storefront/taxon_serializer_decorator.rb +4 -1
  41. data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +28 -17
  42. data/app/services/spree_cm_commissioner/api_caches/invalidate_all.rb +5 -7
  43. data/app/services/spree_cm_commissioner/draft_orders/create.rb +6 -3
  44. data/app/services/spree_cm_commissioner/events/publish_stock_statuses_to_firestore.rb +30 -4
  45. data/app/services/spree_cm_commissioner/inventory_holds/acquire.rb +28 -17
  46. data/app/services/spree_cm_commissioner/inventory_holds/convert.rb +1 -0
  47. data/app/services/spree_cm_commissioner/inventory_holds/release.rb +1 -0
  48. data/app/services/spree_cm_commissioner/queue_draft_orders/publish_status.rb +8 -8
  49. data/app/services/spree_cm_commissioner/redis_stock/base.rb +7 -1
  50. data/app/services/spree_cm_commissioner/waiting_room/publish_lobby_path.rb +1 -8
  51. data/app/services/spree_cm_commissioner/waiting_room_system_metadata_fetcher.rb +2 -8
  52. data/config/initializers/firestore.rb +19 -0
  53. data/config/initializers/spree_permitted_attributes.rb +4 -0
  54. data/config/locales/en.yml +2 -0
  55. data/config/locales/km.yml +1 -0
  56. data/config/routes.rb +14 -6
  57. data/db/migrate/20261103000001_add_sales_channel_to_spree_products.rb +14 -0
  58. data/lib/spree_cm_commissioner/firestore_clients.rb +49 -0
  59. data/lib/spree_cm_commissioner/s3_url_generator.rb +13 -1
  60. data/lib/spree_cm_commissioner/tenant_slug_constraint.rb +23 -0
  61. data/lib/spree_cm_commissioner/version.rb +1 -1
  62. data/lib/spree_cm_commissioner.rb +2 -0
  63. metadata +11 -9
  64. data/app/controllers/spree/api/v2/organizer/images_controller.rb +0 -56
  65. data/app/controllers/spree/api/v2/organizer/s3_signed_urls_controller.rb +0 -29
  66. data/app/controllers/spree/api/v2/organizer/ticket_images_controller.rb +0 -27
  67. data/app/controllers/spree/api/v2/organizer/tickets_controller.rb +0 -97
  68. data/app/serializers/spree/v2/organizer/image_serializer.rb +0 -11
  69. data/app/serializers/spree/v2/organizer/s3_signed_url_serializer.rb +0 -10
  70. data/app/serializers/spree/v2/organizer/ticket_serializer.rb +0 -9
@@ -1,10 +1,11 @@
1
1
  require 'google/cloud/firestore'
2
2
 
3
3
  module SpreeCmCommissioner
4
- # Shared Cloud Firestore client construction, used across the waiting-room services (see
5
- # WaitingRoom::Admission::CallGuests, WaitingRoom::Positions::Stamp,
6
- # WaitingRoom::Sessions::Issue) plus a #firestore_available? guard so environments without the
7
- # credential (dev, test, CI) no-op instead of raising on connect.
4
+ # Cloud Firestore access for services (see WaitingRoom::Admission::CallGuests,
5
+ # WaitingRoom::Positions::Stamp, WaitingRoom::Sessions::Issue) plus a #firestore_available?
6
+ # guard so environments without the credential (dev, test, CI) no-op instead of raising on
7
+ # connect. The client comes from FirestoreClients, shared per process and per project; a service
8
+ # for another Firebase project overrides #service_account.
8
9
  module FirestoreConnection
9
10
  extend ActiveSupport::Concern
10
11
 
@@ -13,14 +14,11 @@ module SpreeCmCommissioner
13
14
  end
14
15
 
15
16
  def firestore
16
- @firestore ||= Google::Cloud::Firestore.new(
17
- project_id: service_account[:project_id],
18
- credentials: service_account
19
- )
17
+ @firestore ||= SpreeCmCommissioner::FirestoreClients.fetch(service_account)
20
18
  end
21
19
 
22
20
  def service_account
23
- @service_account ||= Rails.application.credentials.cloud_firestore_service_account
21
+ @service_account ||= SpreeCmCommissioner::FirestoreClients.default_service_account
24
22
  end
25
23
  end
26
24
  end
@@ -87,7 +87,7 @@ module SpreeCmCommissioner
87
87
  after_transition from: :requested, to: :accepted, do: :decrement_user_request_orders_count
88
88
  after_transition to: :accepted, do: :send_order_accepted_app_notification_to_user
89
89
  after_transition to: :accepted, do: :send_order_accepted_telegram_alert_to_store
90
- after_transition to: :accepted, if: :confirmation_delivered?, do: :deliver_order_confirmation_email
90
+ after_transition to: :accepted, unless: :confirmation_delivered?, do: :deliver_order_confirmation_email
91
91
 
92
92
  event :reject do
93
93
  transition from: :requested, to: :rejected
@@ -144,14 +144,6 @@ module SpreeCmCommissioner
144
144
  subscription? || prepaid_top_up_order?
145
145
  end
146
146
 
147
- # override
148
- def deliver_order_confirmation_email
149
- return if requested?
150
- return if any_app_only_products?
151
-
152
- super
153
- end
154
-
155
147
  def increment_user_request_orders_count
156
148
  return if user.nil?
157
149
 
@@ -0,0 +1,44 @@
1
+ module SpreeCmCommissioner
2
+ module SalesChannelBitwise
3
+ extend ActiveSupport::Concern
4
+
5
+ # Append only: stored values are bit masks. Names follow the `channel_direct` /
6
+ # `channel_platform` channel types in docs/concepts/multi-channel-pricing.md.
7
+ BIT_SALES_CHANNEL = {
8
+ direct: 0b01, # the tenant's own app/web
9
+ platform: 0b10 # the BookMe+ marketplace
10
+ }.freeze
11
+
12
+ included do
13
+ validates :sales_channel, numericality: { greater_than: 0 }
14
+
15
+ # Runs after ProductDecorator#set_tenant: a product without a tenant has no direct channel to sell on.
16
+ before_save -> { self.sales_channel = BIT_SALES_CHANNEL[:platform] if tenant_id.nil? }
17
+
18
+ scope :sellable_on, lambda { |channel|
19
+ where("#{table_name}.sales_channel & ? != 0", BIT_SALES_CHANNEL.fetch(channel))
20
+ }
21
+ end
22
+
23
+ class_methods do
24
+ # The channel a customer order is placed on: tenant storefront APIs set a tenant, the platform ones don't.
25
+ def sales_channel_for(tenant)
26
+ tenant.present? ? :direct : :platform
27
+ end
28
+ end
29
+
30
+ BIT_SALES_CHANNEL.each do |channel, bit_value|
31
+ define_method "sellable_on_#{channel}?" do
32
+ sales_channel.to_i & bit_value != 0
33
+ end
34
+ end
35
+
36
+ # Checkbox reader/writer for the dashboard: a tenant product opts in/out of being sold on BookMe+.
37
+ alias sellable_on_platform sellable_on_platform?
38
+
39
+ def sellable_on_platform=(value)
40
+ bit = BIT_SALES_CHANNEL[:platform]
41
+ self.sales_channel = ActiveModel::Type::Boolean.new.cast(value) ? sales_channel.to_i | bit : sales_channel.to_i & ~bit
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,17 @@
1
+ module SpreeCmCommissioner
2
+ # For images a customer uploads themselves (profile photo, ID card). Spree's own validation accepts
3
+ # any `image/*`, which lets formats like SVG reach libvips when variants are generated
4
+ # (CVE-2026-66066), so these are limited to the formats phone cameras and galleries produce.
5
+ # MAX_SIZE is also the `content_length_range` on the storefront/tenant presigned POST, so an
6
+ # oversized file is rejected by S3 before it is ever fetched back.
7
+ module UserUploadedImage
8
+ extend ActiveSupport::Concern
9
+
10
+ CONTENT_TYPES = %w[image/jpeg image/png image/webp image/heic image/heif].freeze
11
+ MAX_SIZE = 10.megabytes
12
+
13
+ included do
14
+ validates :attachment, content_type: CONTENT_TYPES, size: { less_than_or_equal_to: MAX_SIZE }
15
+ end
16
+ end
17
+ end
@@ -1,5 +1,7 @@
1
1
  module SpreeCmCommissioner
2
2
  class BackImage < SpreeCmCommissioner::Asset
3
+ include SpreeCmCommissioner::UserUploadedImage
4
+
3
5
  def asset_styles
4
6
  {
5
7
  mini: '187x67>',
@@ -1,5 +1,7 @@
1
1
  module SpreeCmCommissioner
2
2
  class FrontImage < SpreeCmCommissioner::Asset
3
+ include SpreeCmCommissioner::UserUploadedImage
4
+
3
5
  def asset_styles
4
6
  {
5
7
  mini: '187x67>',
@@ -23,6 +23,11 @@ module SpreeCmCommissioner
23
23
 
24
24
  store_public_metadata :total_quantity_on_hold, :integer
25
25
 
26
+ # The DB sync for this acquisition (see InventoryHolds::Acquire), kept so a re-entry can re-send
27
+ # it under the same ledger key when the original enqueue never happened.
28
+ store_private_metadata :on_hold_sync_idempotency_key, :string
29
+ store_private_metadata :on_hold_sync_payload, :array
30
+
26
31
  def finalized?
27
32
  converted? || released?
28
33
  end
@@ -190,6 +190,17 @@ module SpreeCmCommissioner
190
190
  super
191
191
  end
192
192
 
193
+ # Must live here, not in OrderStateMachine: that concern is included (below Spree::Order),
194
+ # while Spree::Emails::OrderDecorator is prepended above it and never calls super, so an
195
+ # override there is silently dead.
196
+ def deliver_order_confirmation_email
197
+ return if requested?
198
+ return if any_app_only_products?
199
+ return if products.none?(&:enable_confirmation_email?)
200
+
201
+ super
202
+ end
203
+
193
204
  def any_app_only_products?
194
205
  if products.loaded?
195
206
  products.any?(&:purchasable_on_app?)
@@ -143,6 +143,7 @@ module SpreeCmCommissioner
143
143
  base.store_public_metadata :enable_inventory_hold, :boolean, default: false
144
144
  base.store_public_metadata :enable_telegram_alert, :boolean, default: true
145
145
  base.store_public_metadata :enable_send_receipt, :boolean, default: true
146
+ base.store_public_metadata :enable_confirmation_email, :boolean, default: true
146
147
  base.store_public_metadata :enable_social_sharing_email, :boolean, default: false
147
148
 
148
149
  base.store_private_metadata :advertise_weight, :integer, default: 1
@@ -169,6 +170,9 @@ module SpreeCmCommissioner
169
170
  base.belongs_to :tenant, class_name: 'SpreeCmCommissioner::Tenant'
170
171
  base.before_save :set_tenant
171
172
 
173
+ # Included after set_tenant so its before_save sees the resolved tenant_id.
174
+ base.include SpreeCmCommissioner::SalesChannelBitwise
175
+
172
176
  # Create maintaining task to purge product related caches
173
177
  base.after_save { SpreeCmCommissioner::MaintenanceTasks::CacheInvalidation.pending.create_or_find_by(maintainable: self) }
174
178
 
@@ -11,25 +11,26 @@ module SpreeCmCommissioner
11
11
  URI.parse(url).host
12
12
  end
13
13
 
14
- def initialize(bucket_name, object_key, uuid = nil)
14
+ # content_length_range: [Range, nil] byte range S3 enforces on the upload itself; nil means no limit.
15
+ def initialize(bucket_name, object_key, uuid = nil, content_length_range: nil)
15
16
  @id = uuid.presence || generate_secure_random
16
17
 
17
18
  if object_key.blank?
18
19
  @error_message = I18n.t('s3_signed_url.missing_file_name')
19
20
  else
20
- @s3_direct_post = presigned_post(bucket_name, object_key)
21
+ @s3_direct_post = presigned_post(bucket_name, object_key, content_length_range)
21
22
  end
22
23
  end
23
24
 
24
25
  private
25
26
 
26
- def presigned_post(bucket_name, object_key)
27
+ def presigned_post(bucket_name, object_key, content_length_range)
28
+ options = { key: object_key, success_action_status: '201' }
29
+ options[:content_length_range] = content_length_range if content_length_range
30
+
27
31
  Aws::S3::Resource.new
28
32
  .bucket(bucket_name)
29
- .presigned_post(
30
- key: object_key,
31
- success_action_status: '201'
32
- )
33
+ .presigned_post(**options)
33
34
  end
34
35
 
35
36
  def generate_secure_random
@@ -6,13 +6,15 @@ module SpreeCmCommissioner
6
6
  new(options).call
7
7
  end
8
8
 
9
- # {:bucket_name?, (:object_key | :file_name)? }
9
+ # {:bucket_name?, (:object_key | :file_name)?, :content_length_range? }
10
+ # bucket_name/object_key are for trusted server-side callers only (admin media uploads); API
11
+ # controllers must pass just file_name, so callers can only write under uploads/<uuid>/.
10
12
  def initialize(options)
11
13
  @options = options
12
14
  end
13
15
 
14
16
  def call
15
- SpreeCmCommissioner::S3PresignedUrl.new(bucket_name, object_key, uuid)
17
+ SpreeCmCommissioner::S3PresignedUrl.new(bucket_name, object_key, uuid, content_length_range: options[:content_length_range])
16
18
  end
17
19
 
18
20
  private
@@ -20,7 +22,11 @@ module SpreeCmCommissioner
20
22
  def object_key
21
23
  return options[:object_key] if options[:object_key].present?
22
24
 
23
- "uploads/#{uuid}/#{options[:file_name]}"
25
+ # basename so a file_name like "../x" or "a/b" can't move the key out of its uuid folder.
26
+ file_name = File.basename(options[:file_name].to_s)
27
+ return nil if file_name.blank? || %w[. .. /].include?(file_name)
28
+
29
+ "uploads/#{uuid}/#{file_name}"
24
30
  end
25
31
 
26
32
  def bucket_name
@@ -21,6 +21,9 @@ module SpreeCmCommissioner
21
21
  allow_blank: true,
22
22
  format: { without: %r{\Ahttps?://}, message: :format }
23
23
 
24
+ # The slug is the first segment of /api/v2/:tenant_slug/..., so it must not shadow a v2 namespace.
25
+ validates :slug, exclusion: { in: SpreeCmCommissioner::TenantSlugConstraint::RESERVED_SLUGS }
26
+
24
27
  validates :sms_sender_id, length: { maximum: 11 }, allow_blank: true
25
28
 
26
29
  validates :default_currency, inclusion: { in: -> (tenant) { tenant.supported_currencies_list } }, allow_blank: true
@@ -1,5 +1,7 @@
1
1
  module SpreeCmCommissioner
2
2
  class UserProfile < Asset
3
+ include SpreeCmCommissioner::UserUploadedImage
4
+
3
5
  protected
4
6
 
5
7
  def asset_styles
@@ -0,0 +1,13 @@
1
+ <!-- insert_after "[data-hook='admin_product_form_promotionable']" -->
2
+
3
+ <div>
4
+ <%= f.field_container :enable_confirmation_email do %>
5
+ <%= f.label :enable_confirmation_email do %>
6
+ <%= f.check_box :enable_confirmation_email %>
7
+ <%= Spree.t(:enable_confirmation_email) %>
8
+ <% end %>
9
+ <small class="form-text text-muted">
10
+ When enabled, customers receive the booking confirmation email after their order is completed.
11
+ </small>
12
+ <% end %>
13
+ </div>
@@ -0,0 +1,12 @@
1
+ <!-- insert_after "[data-hook='admin_product_form_promotionable']" -->
2
+
3
+ <%# BookMe+'s own products are always sold on the platform, so only tenant products get the choice. %>
4
+ <% if f.object.vendor&.tenant_id.present? %>
5
+ <div data-hook="admin_product_form_sellable_on_platform">
6
+ <%= f.field_container :sellable_on_platform do %>
7
+ <%= f.check_box :sellable_on_platform %>
8
+ <%= f.label :sellable_on_platform, 'Also sell on BookMe+' %>
9
+ <%= f.error_message_on :sales_channel %>
10
+ <% end %>
11
+ </div>
12
+ <% end %>
@@ -4,7 +4,10 @@ module Spree
4
4
  module TaxonSerializerDecorator
5
5
  def self.prepended(base)
6
6
  base.has_many :vendors, serializer: ::Spree::V2::Storefront::VendorSerializer
7
- base.has_many :visible_products, serializer: ::Spree::V2::Storefront::ProductSerializer
7
+ # The app lists an event section's tickets from this relation, so products a tenant keeps off BookMe+ are dropped here.
8
+ base.has_many :visible_products, serializer: ::Spree::V2::Storefront::ProductSerializer do |taxon|
9
+ taxon.visible_products.sellable_on(:platform)
10
+ end
8
11
  base.has_many :places, serializer: ::Spree::V2::Storefront::PlaceSerializer
9
12
 
10
13
  base.has_one :vendor, serializer: ::Spree::V2::Storefront::VendorSerializer
@@ -4,13 +4,13 @@ module SpreeCmCommissioner
4
4
  # MaintenanceTasks::CacheInvalidation sends. Every record of a model goes through InvalidateAll.
5
5
  #
6
6
  # Only the record's own storefront is cleared, as InvalidateAll does: its tenant's API
7
- # (/api/v2/tenant/ paths) when it belongs to a tenant, the BookMe+ storefront otherwise. Each path
8
- # is billed, and the other storefront's copy, if anything ever fetched one, expires within a CDN
9
- # TTL anyway.
7
+ # (/api/v2/<tenant slug>/ paths) when it belongs to a tenant, the BookMe+ storefront otherwise.
8
+ # Each path is billed, and the other storefront's copy, if anything ever fetched one, expires
9
+ # within a CDN TTL anyway. Legacy /api/v2/tenant/ paths are never CDN-cached, so never cleared.
10
10
  class Invalidate
11
11
  prepend ::Spree::ServiceModule::Base
12
12
 
13
- TENANT_PREFIX = '/api/v2/tenant/'.freeze
13
+ TENANT_PREFIX = '/api/v2/:tenant_slug/'.freeze
14
14
 
15
15
  SUPPORTED_MODELS = {
16
16
  'SpreeCmCommissioner::HomepageSection' => SpreeCmCommissioner::HomepageSection,
@@ -30,7 +30,7 @@ module SpreeCmCommissioner
30
30
  # path per BIT_SEGMENT key.
31
31
  HOMEPAGE_SECTION_PATTERNS = [
32
32
  '/api/v2/storefront/homepage*',
33
- '/api/v2/tenant/homepage*'
33
+ '/api/v2/:tenant_slug/homepage*'
34
34
  ].freeze
35
35
 
36
36
  HOMEPAGE_BACKGROUND_PATTERNS = ['/api/v2/storefront/homepage*'].freeze
@@ -42,12 +42,13 @@ module SpreeCmCommissioner
42
42
  # which vendors are affected.
43
43
  #
44
44
  # vendor_id is a query param, so the trailing `*` is what clears every
45
- # vendor's cached variant of this path in one go.
45
+ # vendor's cached variant of this path in one go. CloudFront allows `*` only at the end, so the
46
+ # tenant path expands to one path per tenant.
46
47
  #
47
48
  # Payment methods change a handful of times a year, so a broad invalidation
48
49
  # is cheap.
49
50
  PAYMENT_METHOD_PATTERNS = [
50
- '/api/v2/tenant/payment_methods*',
51
+ '/api/v2/:tenant_slug/payment_methods*',
51
52
  '/api/v2/storefront/payment_methods*'
52
53
  ].freeze
53
54
 
@@ -60,7 +61,7 @@ module SpreeCmCommissioner
60
61
  invalidate_single_record(model_class: model_class, id: id)
61
62
  end
62
63
 
63
- # eg. 'Spree::Product', 123 => ['/api/v2/tenant/products/123', '/api/v2/tenant/products/slug-of-product', ...]
64
+ # eg. 'Spree::Product', 123 => ['/api/v2/hangmeas/products/123*', '/api/v2/hangmeas/products/slug-of-product*']
64
65
  def invalidate_single_record(model_class:, id:)
65
66
  return failure(nil, "#{model_class.name} ID is required") if id.blank?
66
67
 
@@ -79,17 +80,27 @@ module SpreeCmCommissioner
79
80
  # The record's own class first (a Show found as a Spree::Taxon keeps its show paths), then the
80
81
  # model asked for, since STI subclasses such as Spree::Gateway::Payway have no entry of their own.
81
82
  patterns = single_api_pattern[record.class.name] || single_api_pattern[model_class.name]
82
- return patterns if patterns.equal?(PAYMENT_METHOD_PATTERNS)
83
+ return expand_tenant_slug(patterns, SpreeCmCommissioner::Tenant.pluck(:slug)) if patterns.equal?(PAYMENT_METHOD_PATTERNS)
83
84
 
84
85
  tenant = tenant_of(record)
85
86
  patterns.select { |pattern| pattern.start_with?(TENANT_PREFIX) == tenant.present? }.map do |pattern|
86
87
  pattern.gsub(/:(\w+)/) do
87
88
  key = Regexp.last_match(1)
88
- record.public_send(key).to_s
89
+ key == 'tenant_slug' ? tenant.slug : record.public_send(key).to_s
89
90
  end
90
91
  end
91
92
  end
92
93
 
94
+ # One copy of each `:tenant_slug` pattern per tenant; storefront patterns pass through as is.
95
+ def expand_tenant_slug(patterns, slugs)
96
+ slugs = slugs.compact_blank
97
+ patterns.flat_map do |pattern|
98
+ next [pattern] unless pattern.start_with?(TENANT_PREFIX)
99
+
100
+ slugs.map { |slug| pattern.sub(':tenant_slug', slug) }
101
+ end
102
+ end
103
+
93
104
  # A product, vendor or homepage section has its own tenant; a taxon, show or voting session has
94
105
  # its vendor's. Menus, seat layouts and homepage backgrounds are BookMe+ only.
95
106
  def tenant_of(record)
@@ -106,23 +117,23 @@ module SpreeCmCommissioner
106
117
  # The app reads menus from homepage_data; nothing calls /menus any more.
107
118
  'Spree::Menu' => ['/api/v2/storefront/homepage_data*'],
108
119
  'Spree::Taxon' => [
109
- '/api/v2/tenant/taxons/:id*',
110
- '/api/v2/tenant/taxons/:permalink*',
120
+ '/api/v2/:tenant_slug/taxons/:id*',
121
+ '/api/v2/:tenant_slug/taxons/:permalink*',
111
122
  '/api/v2/storefront/taxons/:id*',
112
123
  '/api/v2/storefront/taxons/:permalink*'
113
124
  ],
114
125
  'Spree::Product' => [
115
- '/api/v2/tenant/products/:id*',
116
- '/api/v2/tenant/products/:slug*',
126
+ '/api/v2/:tenant_slug/products/:id*',
127
+ '/api/v2/:tenant_slug/products/:slug*',
117
128
  '/api/v2/storefront/products/:id*',
118
129
  '/api/v2/storefront/products/:slug*'
119
130
  ],
120
- 'SpreeCmCommissioner::Show' => ['/api/v2/tenant/shows/:slug*'],
131
+ 'SpreeCmCommissioner::Show' => ['/api/v2/:tenant_slug/shows/:slug*'],
121
132
  'SpreeCmCommissioner::VotingSession' => [
122
- '/api/v2/tenant/voting_sessions/:id/voting_contestants*'
133
+ '/api/v2/:tenant_slug/voting_sessions/:id/voting_contestants*'
123
134
  ],
124
135
  'Spree::Vendor' => [
125
- '/api/v2/tenant/vendors/:id*',
136
+ '/api/v2/:tenant_slug/vendors/:id*',
126
137
  '/api/v2/storefront/vendors/:id*',
127
138
  '/api/v2/storefront/accommodations/:id*',
128
139
  '/api/v2/storefront/accommodations/:slug*'
@@ -4,10 +4,7 @@ module SpreeCmCommissioner
4
4
  # serve them, which MaintenanceTasks::CacheInvalidation sends, as for Invalidate. `tenant` is
5
5
  # required so a clear-all never pays for both namespaces by accident: nil is the BookMe+
6
6
  # storefront (/api/v2/storefront/ paths), a SpreeCmCommissioner::Tenant is that tenant's API
7
- # (/api/v2/tenant/ paths).
8
- #
9
- # Tenant APIs take the tenant from the OAuth token, not the URL, so every tenant shares one path
10
- # today; taking the record keeps callers the same if tenant paths ever carry it.
7
+ # (/api/v2/<tenant slug>/ paths).
11
8
  #
12
9
  # eg. InvalidateAll.call(model: 'SpreeCmCommissioner::HomepageSection', tenant: nil)
13
10
  class InvalidateAll
@@ -20,11 +17,11 @@ module SpreeCmCommissioner
20
17
  'Spree::Menu' => ['/api/v2/storefront/homepage_data*'],
21
18
  'Spree::Taxon' => ['/api/v2/storefront/taxons/*'],
22
19
  'Spree::Product' => [
23
- '/api/v2/tenant/products/*',
20
+ '/api/v2/:tenant_slug/products/*',
24
21
  '/api/v2/storefront/products/*'
25
22
  ],
26
23
  'Spree::Vendor' => [
27
- '/api/v2/tenant/vendors/*',
24
+ '/api/v2/:tenant_slug/vendors/*',
28
25
  '/api/v2/storefront/vendors/*',
29
26
  '/api/v2/storefront/accommodations/*'
30
27
  ],
@@ -44,7 +41,8 @@ module SpreeCmCommissioner
44
41
 
45
42
  # Side-effect free, so the clear-cache dialog can show what a clear would send.
46
43
  def patterns_for(model, tenant:)
47
- API_PATTERNS.fetch(model, []).select { |pattern| pattern.start_with?(Invalidate::TENANT_PREFIX) == tenant.present? }
44
+ patterns = API_PATTERNS.fetch(model, []).select { |pattern| pattern.start_with?(Invalidate::TENANT_PREFIX) == tenant.present? }
45
+ tenant ? Invalidate.new.expand_tenant_slug(patterns, [tenant.slug]) : patterns
48
46
  end
49
47
 
50
48
  # updated_at keys Spree's serializer and collection caches, so only this storefront's records
@@ -72,7 +72,8 @@ module SpreeCmCommissioner
72
72
  device_id: options[:device_id],
73
73
  last_ip_address: options[:last_ip_address]
74
74
  )
75
- line_items_params.each { |line_item_params| build_line_item!(order, store, line_item_params) }
75
+ sellable_variants = store.variants.joins(:product).merge(Spree::Product.sellable_on(Spree::Product.sales_channel_for(tenant)))
76
+ line_items_params.each { |line_item_params| build_line_item!(order, sellable_variants, line_item_params) }
76
77
 
77
78
  ActiveRecord::Base.transaction do
78
79
  raise StandardError, order_error_message(order) unless order.save
@@ -105,8 +106,10 @@ module SpreeCmCommissioner
105
106
 
106
107
  # Builds (unsaved) an order.line_items member — persisted together with the order via
107
108
  # order.save's cascading autosave of new has_many records, same as TransitOrder::Create.
108
- def build_line_item!(order, store, line_item_params)
109
- variant = store.variants.find(line_item_params[:variant_id])
109
+ def build_line_item!(order, sellable_variants, line_item_params)
110
+ variant = sellable_variants.find_by(id: line_item_params[:variant_id])
111
+ raise StandardError, I18n.t('spree_cm_commissioner.draft_order.variant_not_available') if variant.nil?
112
+
110
113
  options = (line_item_params[:options] || {}).to_h.with_indifferent_access
111
114
  permitted_opts = ::Spree::PermittedAttributes.line_item_attributes.flatten.index_with { |attribute| options[attribute] }
112
115
  opts = permitted_opts.merge(currency: order.currency).compact
@@ -35,10 +35,10 @@ module SpreeCmCommissioner
35
35
  return success(:skipped) unless firestore_available?
36
36
 
37
37
  products = active_products(taxon).to_a
38
- return success(:skipped) if products.empty?
39
-
40
- sums = aggregate_quantities(products)
41
38
  existing_products = existing_products_data(taxon)
39
+ return success(:skipped) if products.empty? && existing_products.empty?
40
+
41
+ sums = products.empty? ? {} : aggregate_quantities(products)
42
42
  now = Time.zone.now
43
43
 
44
44
  changed = {}
@@ -51,6 +51,8 @@ module SpreeCmCommissioner
51
51
  changed[product.id.to_s] = build_entry(product, status, quantities, existing_products, now)
52
52
  end
53
53
 
54
+ prune_stale_entries(changed, products, existing_products)
55
+
54
56
  return success(:unchanged) if changed.empty?
55
57
 
56
58
  event_document(taxon).set({ products: changed, server_time: now }, merge: true)
@@ -66,6 +68,20 @@ module SpreeCmCommissioner
66
68
 
67
69
  private
68
70
 
71
+ # The write below is always `merge: true`, which only ever adds or updates map entries — it
72
+ # never drops one. So a product that stops being active (unlisted/unlinked from its section,
73
+ # discontinued, status flipped off — see `active_products`) would otherwise keep its last
74
+ # stock status in Firestore forever, and the app would go on rendering the removed product.
75
+ # Deleting the `products.<id>` field (nested FieldValue.delete, carried in the same merge set)
76
+ # is what actually removes it. existing_products is read even when no product is active, so an
77
+ # event whose every ticket got unlisted still gets its stale entries cleared.
78
+ def prune_stale_entries(changed, products, existing_products)
79
+ active_ids = products.map { |product| product.id.to_s }
80
+ (existing_products.keys - active_ids).each do |stale_id|
81
+ changed[stale_id] = firestore.field_delete
82
+ end
83
+ end
84
+
69
85
  # `quantities` is the [available, on_hold, locked, locked_on_hold] tuple from
70
86
  # `aggregate_quantities`. `previous_status` is read back from what's already stored, not
71
87
  # recomputed — it's the bucket this product was in on the last publish, if any.
@@ -117,6 +133,16 @@ module SpreeCmCommissioner
117
133
  # true. Caught via a real event whose products all had available_on: nil: the poll job was
118
134
  # returning :skipped for it on every cycle, so Firestore just sat frozen with stale data.
119
135
  #
136
+ # `preview: false` for the same reason unlisted products are excluded: events/{event_id} is
137
+ # publicly readable (no auth on the Firestore rule), so a preview product — active but held
138
+ # back from the public pending a PreviewRole, product_decorator.rb `publicly_available` — must
139
+ # not leak a live stock-status entry there before launch.
140
+ #
141
+ # `offline: false` because an offline product is deliberately not inventory-monitored (the
142
+ # admin form labels it "not monitored in Inventory Monitor", and InventoryItems finders like
143
+ # RecentlyChangedFinder already filter it out) — its Redis pools aren't maintained, so any
144
+ # stock status published for it would be meaningless.
145
+ #
120
146
  # `where(status: 'active')` instead of core's `Product.active` scope: that scope also joins
121
147
  # variants/prices in to filter by currency (`with_currency`), and `spree_variants` carries its
122
148
  # own `available_on`/`discontinue_on` columns — once that join is present, the raw,
@@ -133,7 +159,7 @@ module SpreeCmCommissioner
133
159
  # is excluded here too rather than still getting a live stock-status entry in Firestore.
134
160
  def active_products(taxon)
135
161
  now = Time.zone.now
136
- taxon.event_products.ecommerce.where(status: 'active')
162
+ taxon.event_products.ecommerce.where(status: 'active', preview: false, offline: false)
137
163
  .where('available_on IS NULL OR available_on <= ?', now)
138
164
  .where('discontinue_on IS NULL OR discontinue_on > ?', now)
139
165
  .where(id: taxon.children_classifications.where(visible: true).select(:product_id))
@@ -33,7 +33,10 @@ module SpreeCmCommissioner
33
33
  hold = acquire_hold_for_order!(order, line_items, expires_at)
34
34
 
35
35
  enqueue_release_job(hold)
36
- enqueue_sync_inventory_on_hold(order)
36
+ # Re-sent on every call, including re-entry with an existing hold: if this enqueue failed
37
+ # after the hold committed, the retry recovers it. The key stored with the acquisition makes
38
+ # a repeat a no-op in the ledger once the sync has applied.
39
+ enqueue_sync_inventory_on_hold(hold)
37
40
 
38
41
  CmAppLogger.log(
39
42
  label: "#{self.class.name}#call success",
@@ -88,7 +91,11 @@ module SpreeCmCommissioner
88
91
  payment_locked_at: nil,
89
92
  release_reason: nil,
90
93
  total_quantity_on_hold: line_items.sum(&:quantity),
91
- ip_address: order.last_ip_address
94
+ ip_address: order.last_ip_address,
95
+ # Saved in the same transaction as the Redis reservation, so the DB sync for exactly
96
+ # this acquisition can be re-sent later without inventing a new key or payload.
97
+ on_hold_sync_idempotency_key: SecureRandom.uuid,
98
+ on_hold_sync_payload: on_hold_sync_payload(line_items)
92
99
  )
93
100
 
94
101
  hold.save!
@@ -107,6 +114,15 @@ module SpreeCmCommissioner
107
114
  end
108
115
  end
109
116
 
117
+ # Same line items and active inventory items that acquire_redis_stock! reserves.
118
+ def on_hold_sync_payload(line_items)
119
+ line_items.flat_map do |line_item|
120
+ line_item.inventory_items.active.map do |inventory_item|
121
+ { inventory_id: inventory_item.id, quantity: line_item.quantity, locked: line_item.locked_stock? }
122
+ end
123
+ end
124
+ end
125
+
110
126
  def acquire_redis_stock!(line_items)
111
127
  stock_keys = []
112
128
  hold_keys = []
@@ -146,25 +162,20 @@ module SpreeCmCommissioner
146
162
  SpreeCmCommissioner::InventoryHolds::ReleaseJob.set(wait_until: hold.expires_at).perform_later(hold_id: hold.id)
147
163
  end
148
164
 
149
- def enqueue_sync_inventory_on_hold(order)
150
- inventory_id_and_quantities = order.line_items.flat_map do |line_item|
151
- next [] unless line_item.should_hold_inventory?
152
-
153
- line_item.inventory_items.active.map do |inventory_item|
154
- {
155
- inventory_id: inventory_item.id,
156
- quantity: line_item.quantity,
157
- locked: line_item.locked_stock?
158
- }
159
- end
160
- end
165
+ def enqueue_sync_inventory_on_hold(hold)
166
+ key = hold.on_hold_sync_idempotency_key
167
+ payload = hold.on_hold_sync_payload
161
168
 
162
- return if inventory_id_and_quantities.blank?
169
+ # Holds acquired before the sync event was stored carry neither; their sync was sent when they
170
+ # were acquired.
171
+ return if key.blank? || payload.blank?
163
172
 
164
173
  SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnHoldJob.perform_later(
165
- order_id: order.id,
174
+ order_id: hold.order_id,
175
+ idempotency_key: key,
166
176
  caller_source: "#{self.class.name}#enqueue_sync_inventory_on_hold",
167
- inventory_id_and_quantities: inventory_id_and_quantities
177
+ # Read back from jsonb with string keys; BulkAdjustQuantitiesOnHold reads symbol keys.
178
+ inventory_id_and_quantities: payload.map(&:symbolize_keys)
168
179
  )
169
180
  end
170
181
 
@@ -123,6 +123,7 @@ module SpreeCmCommissioner
123
123
 
124
124
  SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnConvertJob.perform_later(
125
125
  order_id: order.id,
126
+ idempotency_key: SecureRandom.uuid,
126
127
  caller_source: "#{self.class.name}#enqueue_sync_inventory",
127
128
  inventory_id_and_quantities: adjustments
128
129
  )