spree_cm_commissioner 2.14.7 → 2.14.8

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 (55) 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/models/concerns/spree_cm_commissioner/order_state_machine.rb +1 -9
  22. data/app/models/concerns/spree_cm_commissioner/sales_channel_bitwise.rb +44 -0
  23. data/app/models/concerns/spree_cm_commissioner/user_uploaded_image.rb +17 -0
  24. data/app/models/spree_cm_commissioner/back_image.rb +2 -0
  25. data/app/models/spree_cm_commissioner/front_image.rb +2 -0
  26. data/app/models/spree_cm_commissioner/order_decorator.rb +11 -0
  27. data/app/models/spree_cm_commissioner/product_decorator.rb +4 -0
  28. data/app/models/spree_cm_commissioner/s3_presigned_url.rb +8 -7
  29. data/app/models/spree_cm_commissioner/s3_presigned_url_builder.rb +9 -3
  30. data/app/models/spree_cm_commissioner/tenant.rb +3 -0
  31. data/app/models/spree_cm_commissioner/user_profile.rb +2 -0
  32. data/app/overrides/spree/admin/products/_form/enable_confirmation_email.html.erb.deface +13 -0
  33. data/app/overrides/spree/admin/products/_form/sellable_on_platform.html.erb.deface +12 -0
  34. data/app/serializers/spree/v2/storefront/taxon_serializer_decorator.rb +4 -1
  35. data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +28 -17
  36. data/app/services/spree_cm_commissioner/api_caches/invalidate_all.rb +5 -7
  37. data/app/services/spree_cm_commissioner/draft_orders/create.rb +6 -3
  38. data/app/services/spree_cm_commissioner/events/publish_stock_statuses_to_firestore.rb +30 -4
  39. data/config/initializers/spree_permitted_attributes.rb +4 -0
  40. data/config/locales/en.yml +2 -0
  41. data/config/locales/km.yml +1 -0
  42. data/config/routes.rb +14 -6
  43. data/db/migrate/20261103000001_add_sales_channel_to_spree_products.rb +14 -0
  44. data/lib/spree_cm_commissioner/s3_url_generator.rb +13 -1
  45. data/lib/spree_cm_commissioner/tenant_slug_constraint.rb +23 -0
  46. data/lib/spree_cm_commissioner/version.rb +1 -1
  47. data/lib/spree_cm_commissioner.rb +1 -0
  48. metadata +8 -8
  49. data/app/controllers/spree/api/v2/organizer/images_controller.rb +0 -56
  50. data/app/controllers/spree/api/v2/organizer/s3_signed_urls_controller.rb +0 -29
  51. data/app/controllers/spree/api/v2/organizer/ticket_images_controller.rb +0 -27
  52. data/app/controllers/spree/api/v2/organizer/tickets_controller.rb +0 -97
  53. data/app/serializers/spree/v2/organizer/image_serializer.rb +0 -11
  54. data/app/serializers/spree/v2/organizer/s3_signed_url_serializer.rb +0 -10
  55. data/app/serializers/spree/v2/organizer/ticket_serializer.rb +0 -9
@@ -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))
@@ -16,8 +16,12 @@ module Spree
16
16
  # store_accessor key (public_metadata), not a DB column, so it must be permitted explicitly
17
17
  @@product_attributes << :enable_telegram_alert unless @@product_attributes.include?(:enable_telegram_alert)
18
18
  @@product_attributes << :enable_send_receipt unless @@product_attributes.include?(:enable_send_receipt)
19
+ @@product_attributes << :enable_confirmation_email unless @@product_attributes.include?(:enable_confirmation_email)
19
20
  @@product_attributes << :enable_social_sharing_email unless @@product_attributes.include?(:enable_social_sharing_email)
20
21
 
22
+ # Virtual writer toggling the platform bit of sales_channel (SalesChannelBitwise)
23
+ @@product_attributes << :sellable_on_platform unless @@product_attributes.include?(:sellable_on_platform)
24
+
21
25
  # Permitted all guest attributes for now as permitting only some guest attributes is not working by design
22
26
  # of spree add_item service, if we want to only permit some guest attributes,
23
27
  # we need to override most part of add_item service.
@@ -320,6 +320,7 @@ en:
320
320
  preset: "Preset"
321
321
  enable_telegram_alert: "Enable Telegram Alert"
322
322
  enable_send_receipt: "Enable Send Receipt"
323
+ enable_confirmation_email: "Enable Confirmation Email"
323
324
  enable_social_sharing_email: "Enable Social Sharing Email"
324
325
  telegram_alert_channels: "Telegram Alert Channels"
325
326
  telegram_chat_name: "Telegram Chat Name"
@@ -1342,6 +1343,7 @@ en:
1342
1343
  failed: "Failed"
1343
1344
  draft_order:
1344
1345
  line_items_missing: "Line items are missing"
1346
+ variant_not_available: "This item is not available for purchase here"
1345
1347
  inventory_item:
1346
1348
  variant_taken_on: "The variant is taken on %{date}"
1347
1349
  topup_draft_order:
@@ -919,6 +919,7 @@ km:
919
919
  failed: "បរាជ័យ"
920
920
  draft_order:
921
921
  line_items_missing: "គ្មានទំនិញនៅក្នុងកន្ត្រកទេ"
922
+ variant_not_available: "ទំនិញនេះមិនអាចទិញនៅទីនេះបានទេ"
922
923
  inventory_item:
923
924
  variant_taken_on: "វ៉ារ្យ៉ង់នេះមានរួចហើយនៅថ្ងៃ %{date}"
924
925
  topup_draft_order:
data/config/routes.rb CHANGED
@@ -513,17 +513,15 @@ Spree::Core::Engine.add_routes do
513
513
  end
514
514
 
515
515
  namespace :organizer do
516
- resources :tickets do
517
- resources :ticket_images
518
- end
519
- resources :images
520
- resource :s3_signed_urls
521
516
  resources :invites
522
517
  resources :invite_guests, only: %i[show update]
523
518
  resources :invite_crews, only: %i[show update]
524
519
  end
525
520
 
526
- namespace :tenant do
521
+ # Drawn twice below: legacy /api/v2/tenant/... (tenant from the OAuth token) and
522
+ # /api/v2/:tenant_slug/... (tenant from the URL). Tenant APIs are CDN-cached by URL, so only the
523
+ # slug form gives each tenant its own cache key; see Spree::Api::V2::Tenant::BaseController.
524
+ concern :tenant_api do
527
525
  resource :token, controller: :access_tokens, only: [:create]
528
526
  resource :telegram_oauth_token, controller: :telegram_oauth_tokens, only: [:create]
529
527
  resources :vendors
@@ -665,6 +663,10 @@ Spree::Core::Engine.add_routes do
665
663
  resources :pricing_previews, only: [:index]
666
664
  end
667
665
 
666
+ namespace :tenant do
667
+ concerns :tenant_api
668
+ end
669
+
668
670
  namespace :storefront do
669
671
  resource :token, controller: :access_tokens, only: [:create]
670
672
  resource :telegram_oauth_token, controller: :telegram_oauth_tokens, only: [:create]
@@ -869,6 +871,12 @@ Spree::Core::Engine.add_routes do
869
871
  resources :event_charts, only: %i[index show]
870
872
  end
871
873
  end
874
+
875
+ # Keep last in v2: the slug segment overlaps every literal namespace above, which
876
+ # TenantSlugConstraint reserves.
877
+ scope ':tenant_slug', module: :tenant, as: :tenant_scoped, constraints: SpreeCmCommissioner::TenantSlugConstraint.new do
878
+ concerns :tenant_api
879
+ end
872
880
  end
873
881
 
874
882
  namespace :json_ld do
@@ -0,0 +1,14 @@
1
+ class AddSalesChannelToSpreeProducts < ActiveRecord::Migration[7.0]
2
+ def up
3
+ # Default 3 (direct + platform) backfills every existing row with today's behaviour:
4
+ # tenant products stay visible on both their tenant storefront and BookMe+.
5
+ add_column :spree_products, :sales_channel, :integer, default: 0b11, null: false
6
+
7
+ # BookMe+'s own products have no tenant storefront to sell on.
8
+ execute 'UPDATE spree_products SET sales_channel = 2 WHERE tenant_id IS NULL'
9
+ end
10
+
11
+ def down
12
+ remove_column :spree_products, :sales_channel
13
+ end
14
+ end
@@ -1,10 +1,17 @@
1
1
  module SpreeCmCommissioner
2
2
  class S3UrlGenerator
3
+ # Only keys S3PresignedUrlBuilder hands out to API callers. Without this, a client-supplied
4
+ # URL could point at any object in the bucket (e.g. another guest's ID card) and have it
5
+ # copied into their own attachment.
6
+ UPLOAD_KEY_FORMAT = %r{\Auploads/[\w-]+/[^/]+\z}
7
+
3
8
  # https://docs.aws.amazon.com/sdk-for-ruby/v1/api/AWS/S3/S3Object.html
4
9
  # The bucket is private, ie all the public access is blocked
5
10
  # key = "uploads/5f31/1697038252270.jpg"
11
+ # Returns nil for any other key; callers treat that as a failed fetch.
6
12
  def self.s3_presigned_url(url)
7
- key = url.split('amazonaws.com/').last
13
+ key = upload_key(url)
14
+ return nil if key.nil?
8
15
 
9
16
  s3 = Aws::S3::Resource.new(region: ENV.fetch('AWS_REGION'),
10
17
  access_key_id: ENV.fetch('AWS_ACCESS_KEY_ID'),
@@ -16,5 +23,10 @@ module SpreeCmCommissioner
16
23
  object = bucket.object(key)
17
24
  object.presigned_url(:get, expires_in: 3600)
18
25
  end
26
+
27
+ def self.upload_key(url)
28
+ key = url.to_s.split('amazonaws.com/').last
29
+ key if key&.match?(UPLOAD_KEY_FORMAT)
30
+ end
19
31
  end
20
32
  end
@@ -0,0 +1,23 @@
1
+ module SpreeCmCommissioner
2
+ # Guards the `/api/v2/:tenant_slug/...` routes, which share their first path segment with every
3
+ # literal v2 namespace. Without it `/api/v2/storefront/products` could resolve as tenant
4
+ # "storefront" depending on which engine draws its routes first. A pure string check, so routing
5
+ # never hits the DB; an unknown but well-formed slug still routes and 404s in
6
+ # Spree::Api::V2::Tenant::BaseController.
7
+ #
8
+ # RESERVED_SLUGS lives here rather than on SpreeCmCommissioner::Tenant so routes don't load an
9
+ # ActiveRecord model at boot. The routing spec fails when a new v2 namespace is missing from it.
10
+ class TenantSlugConstraint
11
+ RESERVED_SLUGS = %w[storefront platform tenant organizer operator].freeze
12
+ SLUG_FORMAT = /\A[a-z0-9][a-z0-9_-]*\z/
13
+
14
+ def self.valid_slug?(slug)
15
+ slug = slug.to_s
16
+ SLUG_FORMAT.match?(slug) && RESERVED_SLUGS.exclude?(slug)
17
+ end
18
+
19
+ def matches?(request)
20
+ self.class.valid_slug?(request.path_parameters[:tenant_slug])
21
+ end
22
+ end
23
+ end
@@ -1,5 +1,5 @@
1
1
  module SpreeCmCommissioner
2
- VERSION = '2.14.7'.freeze
2
+ VERSION = '2.14.8'.freeze
3
3
 
4
4
  module_function
5
5
 
@@ -10,6 +10,7 @@ require 'spree_vpago'
10
10
 
11
11
  require 'spree_cm_commissioner/engine'
12
12
  require 'spree_cm_commissioner/version'
13
+ require 'spree_cm_commissioner/tenant_slug_constraint'
13
14
  require 'spree_cm_commissioner/passenger_option'
14
15
  require 'spree_cm_commissioner/payment_method_group'
15
16
  require 'spree_cm_commissioner/calendar_event'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_cm_commissioner
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.14.7
4
+ version: 2.14.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - You
@@ -815,6 +815,7 @@ files:
815
815
  - app/controllers/concerns/spree_cm_commissioner/anonymous_booking_guard.rb
816
816
  - app/controllers/concerns/spree_cm_commissioner/content_cachable.rb
817
817
  - app/controllers/concerns/spree_cm_commissioner/exception_notificable.rb
818
+ - app/controllers/concerns/spree_cm_commissioner/guest_owner_authorization.rb
818
819
  - app/controllers/concerns/spree_cm_commissioner/load_test_account_management.rb
819
820
  - app/controllers/concerns/spree_cm_commissioner/load_test_account_scope.rb
820
821
  - app/controllers/concerns/spree_cm_commissioner/load_test_bypassable.rb
@@ -954,13 +955,9 @@ files:
954
955
  - app/controllers/spree/api/v2/operator/recalculate_tickets_controller.rb
955
956
  - app/controllers/spree/api/v2/operator/taxons_controller.rb
956
957
  - app/controllers/spree/api/v2/organizer/base_controller.rb
957
- - app/controllers/spree/api/v2/organizer/images_controller.rb
958
958
  - app/controllers/spree/api/v2/organizer/invite_crews_controller.rb
959
959
  - app/controllers/spree/api/v2/organizer/invite_guests_controller.rb
960
960
  - app/controllers/spree/api/v2/organizer/invites_controller.rb
961
- - app/controllers/spree/api/v2/organizer/s3_signed_urls_controller.rb
962
- - app/controllers/spree/api/v2/organizer/ticket_images_controller.rb
963
- - app/controllers/spree/api/v2/organizer/tickets_controller.rb
964
961
  - app/controllers/spree/api/v2/platform/homepage_section_relatable_options_controller.rb
965
962
  - app/controllers/spree/api/v2/platform/option_types_controller_decorator.rb
966
963
  - app/controllers/spree/api/v2/platform/places_controller.rb
@@ -1517,6 +1514,7 @@ files:
1517
1514
  - app/models/concerns/spree_cm_commissioner/reschedule_request_state_machine.rb
1518
1515
  - app/models/concerns/spree_cm_commissioner/route_order_countable.rb
1519
1516
  - app/models/concerns/spree_cm_commissioner/route_type.rb
1517
+ - app/models/concerns/spree_cm_commissioner/sales_channel_bitwise.rb
1520
1518
  - app/models/concerns/spree_cm_commissioner/scoped_fee_rule.rb
1521
1519
  - app/models/concerns/spree_cm_commissioner/service_calendar_type.rb
1522
1520
  - app/models/concerns/spree_cm_commissioner/service_origin.rb
@@ -1531,6 +1529,7 @@ files:
1531
1529
  - app/models/concerns/spree_cm_commissioner/user_identity.rb
1532
1530
  - app/models/concerns/spree_cm_commissioner/user_notification.rb
1533
1531
  - app/models/concerns/spree_cm_commissioner/user_preference.rb
1532
+ - app/models/concerns/spree_cm_commissioner/user_uploaded_image.rb
1534
1533
  - app/models/concerns/spree_cm_commissioner/variant_metadata.rb
1535
1534
  - app/models/concerns/spree_cm_commissioner/variant_options_concern.rb
1536
1535
  - app/models/concerns/spree_cm_commissioner/variant_rate_plannable.rb
@@ -1913,6 +1912,7 @@ files:
1913
1912
  - app/overrides/spree/admin/products/_form/allow_transfer.html.erb.deface
1914
1913
  - app/overrides/spree/admin/products/_form/available_on.html.erb.deface
1915
1914
  - app/overrides/spree/admin/products/_form/discontinue_on.html.erb.deface
1915
+ - app/overrides/spree/admin/products/_form/enable_confirmation_email.html.erb.deface
1916
1916
  - app/overrides/spree/admin/products/_form/enable_inventory_hold.html.erb.deface
1917
1917
  - app/overrides/spree/admin/products/_form/enable_send_receipt.html.erb.deface
1918
1918
  - app/overrides/spree/admin/products/_form/enable_social_sharing_email.html.erb.deface
@@ -1928,6 +1928,7 @@ files:
1928
1928
  - app/overrides/spree/admin/products/_form/replace_vendor_label.html.erb.deface
1929
1929
  - app/overrides/spree/admin/products/_form/required_self_check_in_location.html.erb.deface
1930
1930
  - app/overrides/spree/admin/products/_form/reveal_description_to_user.html.erb.deface
1931
+ - app/overrides/spree/admin/products/_form/sellable_on_platform.html.erb.deface
1931
1932
  - app/overrides/spree/admin/products/_form/subscribable_checkbox.html.erb.deface
1932
1933
  - app/overrides/spree/admin/products/_form/taxons.html.erb.deface
1933
1934
  - app/overrides/spree/admin/products/_form/use_video_as_default.html.erb.deface
@@ -2106,13 +2107,10 @@ files:
2106
2107
  - app/serializers/spree/api/v2/platform/service_fee_rule_serializer.rb
2107
2108
  - app/serializers/spree/v2/organizer/asset_serializer.rb
2108
2109
  - app/serializers/spree/v2/organizer/base_serializer.rb
2109
- - app/serializers/spree/v2/organizer/image_serializer.rb
2110
2110
  - app/serializers/spree/v2/organizer/invite_crews_serializer.rb
2111
2111
  - app/serializers/spree/v2/organizer/invite_guest_serializer.rb
2112
2112
  - app/serializers/spree/v2/organizer/invite_serializer.rb
2113
- - app/serializers/spree/v2/organizer/s3_signed_url_serializer.rb
2114
2113
  - app/serializers/spree/v2/organizer/taxon_serializer.rb
2115
- - app/serializers/spree/v2/organizer/ticket_serializer.rb
2116
2114
  - app/serializers/spree/v2/organizer/user_serializer.rb
2117
2115
  - app/serializers/spree/v2/storefront/accommodation_serializer.rb
2118
2116
  - app/serializers/spree/v2/storefront/active_homepage_event_serializer.rb
@@ -3776,6 +3774,7 @@ files:
3776
3774
  - db/migrate/20261013000002_move_hidden_from_spree_option_types_to_product_option_types.rb
3777
3775
  - db/migrate/20261016000001_add_index_to_spree_line_items_number.rb
3778
3776
  - db/migrate/20261021000001_add_section_type_to_cm_homepage_sections.rb
3777
+ - db/migrate/20261103000001_add_sales_channel_to_spree_products.rb
3779
3778
  - docker-compose.yml
3780
3779
  - docs/api/scoped-access-token-endpoints.md
3781
3780
  - docs/option_types/attr_types.md
@@ -3833,6 +3832,7 @@ files:
3833
3832
  - lib/spree_cm_commissioner/stay_prices.rb
3834
3833
  - lib/spree_cm_commissioner/store_credit_currency_summary.rb
3835
3834
  - lib/spree_cm_commissioner/store_credit_summary.rb
3835
+ - lib/spree_cm_commissioner/tenant_slug_constraint.rb
3836
3836
  - lib/spree_cm_commissioner/test_helper/factories/address_factory.rb
3837
3837
  - lib/spree_cm_commissioner/test_helper/factories/agency_factory.rb
3838
3838
  - lib/spree_cm_commissioner/test_helper/factories/asset_factory.rb