spree_cm_commissioner 2.14.3 → 2.14.5

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/.env.example +10 -6
  3. data/Gemfile.lock +1 -1
  4. data/app/controllers/concerns/spree_cm_commissioner/content_cachable.rb +108 -69
  5. data/app/controllers/concerns/spree_cm_commissioner/waiting_room_authorization.rb +41 -8
  6. data/app/controllers/spree/admin/api_caches_controller.rb +26 -0
  7. data/app/controllers/spree/admin/base_controller_decorator.rb +0 -14
  8. data/app/controllers/spree/admin/guests_controller.rb +43 -4
  9. data/app/controllers/spree/admin/homepage_section_controller.rb +63 -5
  10. data/app/controllers/spree/api/v2/storefront/draft_orders_controller.rb +6 -2
  11. data/app/controllers/spree/api/v2/storefront/homepage_sections_controller.rb +1 -0
  12. data/app/controllers/spree/api/v2/storefront/preview_sections_controller.rb +1 -0
  13. data/app/controllers/spree/api/v2/storefront/queue_draft_orders_controller.rb +6 -2
  14. data/app/controllers/spree/api/v2/tenant/draft_orders_controller.rb +6 -2
  15. data/app/controllers/spree/api/v2/tenant/homepage_sections_controller.rb +1 -0
  16. data/app/controllers/spree/api/v2/tenant/preview_sections_controller.rb +1 -0
  17. data/app/models/active_storage/variation_decorator.rb +18 -0
  18. data/app/models/concerns/spree_cm_commissioner/event_metadata.rb +4 -0
  19. data/app/models/concerns/spree_cm_commissioner/order_holdable.rb +3 -2
  20. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +17 -0
  21. data/app/models/spree_cm_commissioner/homepage_section.rb +33 -0
  22. data/app/models/spree_cm_commissioner/homepage_section_app_background.rb +13 -0
  23. data/app/models/spree_cm_commissioner/homepage_section_web_background.rb +13 -0
  24. data/app/models/spree_cm_commissioner/product_decorator.rb +7 -0
  25. data/app/overrides/spree/admin/menus/index/clear_cache_button.html.erb.deface +4 -0
  26. data/app/overrides/spree/admin/payment_methods/index/clear_cache_button.html.erb.deface +6 -0
  27. data/app/overrides/spree/admin/products/edit/clear_cache_button.html.erb.deface +1 -1
  28. data/app/overrides/spree/admin/taxons/_form/enforce_waiting_room_session.html.erb.deface +10 -0
  29. data/app/overrides/spree/admin/taxons/edit/clear_cache_button.html.erb.deface +1 -4
  30. data/app/overrides/spree/admin/vendors/edit/clear_cache_button.html.erb.deface +5 -0
  31. data/app/serializers/spree/api/v2/base_serializer_decorator.rb +24 -0
  32. data/app/serializers/spree/v2/storefront/homepage_section_serializer.rb +4 -1
  33. data/app/serializers/spree/v2/tenant/homepage_section_serializer.rb +3 -1
  34. data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +39 -87
  35. data/app/services/spree_cm_commissioner/api_caches/invalidate_all.rb +61 -0
  36. data/app/services/spree_cm_commissioner/guests/build_dynamic_field_attributes.rb +34 -0
  37. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trip_stop.rb +21 -4
  38. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trips.rb +1 -0
  39. data/app/services/spree_cm_commissioner/line_items/validate_purchase_limits.rb +56 -5
  40. data/app/services/spree_cm_commissioner/order/guests/update.rb +2 -24
  41. data/app/views/spree/admin/guests/_dynamic_fields.html.erb +67 -0
  42. data/app/views/spree/admin/guests/_form.html.erb +2 -0
  43. data/app/views/spree/admin/homepage_background/index.html.erb +1 -1
  44. data/app/views/spree/admin/homepage_section/_form.html.erb +44 -0
  45. data/app/views/spree/admin/homepage_section/edit.html.erb +0 -1
  46. data/app/views/spree/admin/homepage_section/index.html.erb +1 -1
  47. data/app/views/spree/admin/shared/_api_cache_button.html.erb +5 -0
  48. data/config/initializers/spree_permitted_attributes.rb +1 -0
  49. data/config/locales/dry_validations.en.yml +4 -0
  50. data/config/locales/en.yml +22 -0
  51. data/config/locales/km.yml +22 -0
  52. data/config/routes.rb +10 -1
  53. data/db/migrate/20261021000001_add_section_type_to_cm_homepage_sections.rb +6 -0
  54. data/lib/spree_cm_commissioner/version.rb +1 -1
  55. metadata +16 -2
@@ -0,0 +1,18 @@
1
+ module ActiveStorage
2
+ module VariationDecorator
3
+ # ActiveStorage reuses a JPEG upload's own extension as the variant format, but libvips
4
+ # picks its encoder from the output extension and rejects aliases like `.jfif`
5
+ # ("is not a known file format"). Mapping here rather than renaming blobs also fixes
6
+ # variant URLs already handed out, since their signed key embeds `format: "jfif"`.
7
+ JPEG_FORMAT_ALIASES = %w[jfif jpe].freeze
8
+
9
+ def format
10
+ format = super
11
+ JPEG_FORMAT_ALIASES.include?(format.to_s.downcase) ? 'jpg' : format
12
+ end
13
+ end
14
+ end
15
+
16
+ unless ActiveStorage::Variation.ancestors.include?(ActiveStorage::VariationDecorator)
17
+ ActiveStorage::Variation.prepend(ActiveStorage::VariationDecorator)
18
+ end
@@ -18,6 +18,10 @@ module SpreeCmCommissioner
18
18
  # For certain large events, admin can disable this manually.
19
19
  store_public_metadata :allow_manual_search_for_operator, :boolean, default: true
20
20
 
21
+ # Requires an active waiting-room session to create a draft order for this event's products
22
+ # (Product#enforce_waiting_room_session?). Server-only switch, so private.
23
+ store_private_metadata :enforce_waiting_room_session, :boolean, default: false
24
+
21
25
  # ⚠️ CRITICAL: Cached seat layout IDs kept in sync by SeatLayout callback.
22
26
  # If callback is removed/modified without updating this, availability checks will fail.
23
27
  #
@@ -4,8 +4,9 @@ module SpreeCmCommissioner
4
4
 
5
5
  included do
6
6
  # Store the earliest expiration time across all holds (seats + inventory)
7
- # in `public_metadata[:hold_expires_at]`. This is used only for UI display
8
- # (e.g., a countdown timer) and does not control the release logic.
7
+ # in `public_metadata[:hold_expires_at]`. This is used for UI display
8
+ # (e.g., a countdown timer) and by LineItems::ValidatePurchaseLimits to count an
9
+ # unpaid cart against the buyer's caps; it does not control the release logic.
9
10
  # Each hold is released independently when its own expiration time is reached.
10
11
  store_public_metadata :hold_expires_at, :datetime
11
12
 
@@ -7,6 +7,7 @@ module SpreeCmCommissioner
7
7
  included do # rubocop:disable Metrics/BlockLength
8
8
  after_commit :clear_has_incomplete_guest_info_cache, on: %i[create update]
9
9
 
10
+ state_machine.before_transition to: :address, do: :ensure_within_purchase_limits
10
11
  state_machine.before_transition to: :address, do: :hold_order_holds
11
12
 
12
13
  # Same transitions core calls create_tax_charge! from (gems/core/app/models/spree/order/checkout.rb,
@@ -107,6 +108,22 @@ module SpreeCmCommissioner
107
108
  end
108
109
  end
109
110
 
111
+ # Line items are only validated when they change, so a cart sent back to `cart` (hold
112
+ # released) and advanced again would skip the purchase-limit check and could be held next to
113
+ # another cart the buyer opened meanwhile. Runs before hold_order_holds so a rejection never
114
+ # leaves a hold behind.
115
+ def ensure_within_purchase_limits
116
+ line_items.each do |line_item|
117
+ result = SpreeCmCommissioner::LineItems::ValidatePurchaseLimits.call(line_item: line_item)
118
+ next if result.success?
119
+
120
+ errors.add(:base, result.error.to_s)
121
+ return false
122
+ end
123
+
124
+ true
125
+ end
126
+
110
127
  # Best-effort, like apply_prepaid_payment!'s other callers: a user with no (or no
111
128
  # currency-matching) store credit just doesn't get a payment here, and the app can still
112
129
  # remove/reapply it afterwards through the existing checkout store-credit endpoints.
@@ -10,10 +10,26 @@ module SpreeCmCommissioner
10
10
  attribute :segment, :integer, default: 0
11
11
  attribute :mini_app, :integer, default: 0
12
12
 
13
+ enum section_type: { default: 0, trending: 1, big_banner: 2 }
14
+
13
15
  self.whitelisted_ransackable_attributes = %w[title active]
14
16
 
15
17
  has_many :homepage_section_relatables, -> { active }, inverse_of: :homepage_section, dependent: :destroy
16
18
 
19
+ has_one :app_background, as: :viewable, dependent: :destroy, class_name: 'SpreeCmCommissioner::HomepageSectionAppBackground'
20
+ has_one :web_background, as: :viewable, dependent: :destroy, class_name: 'SpreeCmCommissioner::HomepageSectionWebBackground'
21
+
22
+ # `font`/backgrounds only apply to `trending` sections. The admin form only hides those
23
+ # fields via JS when another type is picked — it doesn't clear them — and a direct API call
24
+ # could set them on any type, so normalize here rather than relying on the form. A hard
25
+ # validation would instead reject the common "switch an existing trending section back to
26
+ # default" edit, since the hidden `font` field still submits its stale value.
27
+ #
28
+ # Guarded beyond `unless: :trending?` so a routine save of an already-default/big_banner
29
+ # section (the steady-state case) skips the `app_background`/`web_background` association
30
+ # loads entirely instead of querying them on every save forever.
31
+ before_save :clear_non_trending_fields, if: :clear_non_trending_fields?
32
+
17
33
  scope :active, lambda {
18
34
  where(active: true)
19
35
  .joins(:homepage_section_relatables)
@@ -47,5 +63,22 @@ module SpreeCmCommissioner
47
63
 
48
64
  # Create maintaining task to purge homepage section related caches
49
65
  after_save { SpreeCmCommissioner::MaintenanceTasks::CacheInvalidation.pending.create_or_find_by(maintainable: self) }
66
+
67
+ private
68
+
69
+ # `.target` (not `.loaded?`) so this only fires for a background actually built/loaded in
70
+ # memory on *this* save. `.loaded?` stays true forever once an object has been saved once
71
+ # with the association touched, even after the target is nil'd out by a prior destroy —
72
+ # which made every later save of that same in-memory record re-run the clear unnecessarily.
73
+ def clear_non_trending_fields?
74
+ !trending? && (font.present? || section_type_changed? ||
75
+ association(:app_background).target.present? || association(:web_background).target.present?)
76
+ end
77
+
78
+ def clear_non_trending_fields
79
+ self.font = nil
80
+ app_background&.destroy
81
+ web_background&.destroy
82
+ end
50
83
  end
51
84
  end
@@ -0,0 +1,13 @@
1
+ module SpreeCmCommissioner
2
+ class HomepageSectionAppBackground < Asset
3
+ # 3:2 — taller than 16:9 so the image fills a `trending` card's full height instead of
4
+ # letterboxing. `medium` (960x640) is the upload target; `mini`/`small` are downscaled copies.
5
+ def asset_styles
6
+ {
7
+ mini: '160x107>',
8
+ small: '480x320>',
9
+ medium: '960x640>'
10
+ }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ module SpreeCmCommissioner
2
+ class HomepageSectionWebBackground < Asset
3
+ # 5:1 — wide and short so the image spans a `trending` banner's full width without pushing
4
+ # page content down. `medium` (1440x288) is the upload target; `mini`/`small` are downscaled copies.
5
+ def asset_styles
6
+ {
7
+ mini: '160x32>',
8
+ small: '720x144>',
9
+ medium: '1440x288>'
10
+ }
11
+ end
12
+ end
13
+ end
@@ -222,6 +222,13 @@ module SpreeCmCommissioner
222
222
  end
223
223
  end
224
224
 
225
+ # Configured once on the event rather than per ticket product. Other product types (trips,
226
+ # rooms, ...) that need it later resolve their own source here; the draft-order guard
227
+ # (WaitingRoomAuthorization#ensure_waiting_room_session!) only ever asks the product.
228
+ def enforce_waiting_room_session?
229
+ event&.enforce_waiting_room_session? || false
230
+ end
231
+
225
232
  # Not yet on sale. Kept separate from available? so a scheduled product's real stock still
226
233
  # shows via in_stock? — Stock::AvailabilityValidatorDecorator checks this to block add-to-cart.
227
234
  def scheduled?
@@ -0,0 +1,4 @@
1
+ <!-- insert_after "erb[silent]:contains('content_for :page_actions')" -->
2
+
3
+ <%# Menus are only served through homepage_data, whatever the menu, so one clear covers them all. %>
4
+ <%= render 'spree/admin/shared/api_cache_button', model: Spree::Menu.name %>
@@ -0,0 +1,6 @@
1
+ <!-- insert_after "erb[silent]:contains('content_for :page_actions')" -->
2
+
3
+ <%# A payment method's paths don't depend on the record (vendor_id is a query param), so one clear
4
+ on the list covers them all. This admin lists BookMe+'s storefront; a tenant clears its own
5
+ from the dashboard. %>
6
+ <%= render 'spree/admin/shared/api_cache_button', model: Spree::PaymentMethod.name %>
@@ -1,3 +1,3 @@
1
1
  <!-- insert_after "erb[silent]:contains('content_for :page_actions')" -->
2
2
 
3
- <%= button_link_to Spree.t(:clear_cache), admin_invalidate_api_caches_path(model: Spree::Product.name, id: @product.id), method: :post, class: "btn btn-outline-primary" %>
3
+ <%= render 'spree/admin/shared/api_cache_button', model: Spree::Product.name, id: @product.id %>
@@ -0,0 +1,10 @@
1
+ <!-- insert_before "erb[loud]:contains('field_container :hide_from_nav')" -->
2
+
3
+ <%= f.field_container :enforce_waiting_room_session, class: ['custom-control', 'custom-checkbox', 'my-4'] do %>
4
+ <%= f.check_box :enforce_waiting_room_session, class: 'custom-control-input' %>
5
+ <%= f.label :enforce_waiting_room_session, Spree.t(:enforce_waiting_room_session), class: 'custom-control-label' %>
6
+ <%= f.error_message_on :enforce_waiting_room_session %>
7
+ <small class="form-text text-muted">
8
+ <%= Spree.t(:enforce_waiting_room_session_hint) %>
9
+ </small>
10
+ <% end if @taxon.event? && @taxon.depth == 1 %> %>
@@ -1,8 +1,5 @@
1
1
  <!-- insert_after "erb[silent]:contains('content_for :page_actions')" -->
2
2
 
3
3
  <% if @taxon.depth == 1 %>
4
- <%= button_link_to Spree.t(:clear_cache),
5
- admin_invalidate_api_caches_path(model: Spree::Taxon.name, id: @taxon.id),
6
- method: :post,
7
- class: "btn btn-outline-primary" %>
4
+ <%= render 'spree/admin/shared/api_cache_button', model: Spree::Taxon.name, id: @taxon.id %>
8
5
  <% end %>
@@ -0,0 +1,5 @@
1
+ <!-- insert_before "erb[loud]:contains('form_for [:admin, @vendor]')" -->
2
+
3
+ <% content_for :page_actions do %>
4
+ <%= render 'spree/admin/shared/api_cache_button', model: Spree::Vendor.name, id: @vendor.id %>
5
+ <% end %>
@@ -0,0 +1,24 @@
1
+ module Spree
2
+ module Api
3
+ module V2
4
+ # Spree caches every serializer's records keyed by each record's updated_at, so anything
5
+ # computed from other tables stays stale, and behind CloudFront an invalidation can't purge
6
+ # it. CACHE_SERIALIZER_DISABLED=yes turns it off everywhere.
7
+ module BaseSerializerDecorator
8
+ def self.prepended(base)
9
+ base.singleton_class.prepend(ClassMethods)
10
+ end
11
+
12
+ module ClassMethods
13
+ # jsonapi-serializer copies Spree's store into each subclass as it is defined, so this
14
+ # reader, which every subclass resolves through, is the one place to turn it off.
15
+ def cache_store_instance
16
+ ENV['CACHE_SERIALIZER_DISABLED'] == 'yes' ? nil : super
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ Spree::Api::V2::BaseSerializer.prepend(Spree::Api::V2::BaseSerializerDecorator)
@@ -2,8 +2,11 @@ module Spree
2
2
  module V2
3
3
  module Storefront
4
4
  class HomepageSectionSerializer < BaseSerializer
5
- attributes :id, :title, :segments, :mini_apps, :description, :active, :position, :preview, :created_at, :updated_at
5
+ attributes :id, :title, :segments, :mini_apps, :description, :active, :position, :preview,
6
+ :section_type, :font, :created_at, :updated_at
6
7
  has_many :homepage_section_relatables
8
+ has_one :app_background, serializer: ::SpreeCmCommissioner::V2::Storefront::AssetSerializer
9
+ has_one :web_background, serializer: ::SpreeCmCommissioner::V2::Storefront::AssetSerializer
7
10
  end
8
11
  end
9
12
  end
@@ -3,9 +3,11 @@ module Spree
3
3
  module Tenant
4
4
  class HomepageSectionSerializer < BaseSerializer
5
5
  attributes :id, :title, :segments, :description,
6
- :active, :position, :preview, :created_at, :updated_at
6
+ :active, :position, :preview, :section_type, :font, :created_at, :updated_at
7
7
 
8
8
  has_many :homepage_section_relatables, serializer: ::Spree::V2::Tenant::HomepageSectionRelatableSerializer
9
+ has_one :app_background, serializer: ::Spree::V2::Tenant::AssetSerializer
10
+ has_one :web_background, serializer: ::Spree::V2::Tenant::AssetSerializer
9
11
  end
10
12
  end
11
13
  end
@@ -1,5 +1,6 @@
1
1
  module SpreeCmCommissioner
2
2
  module ApiCaches
3
+ # Clears one record's cached API responses. Every record of a model goes through InvalidateAll.
3
4
  class Invalidate
4
5
  prepend ::Spree::ServiceModule::Base
5
6
 
@@ -16,17 +17,39 @@ module SpreeCmCommissioner
16
17
  'SpreeCmCommissioner::VotingSession' => SpreeCmCommissioner::VotingSession
17
18
  }.freeze
18
19
 
19
- # eg. 'Spree::Product', 123 or 'Spree::Product', nil
20
- def call(model:, id: nil)
20
+ # CloudFront bills per path and only allows `*` at the end, so one prefix per namespace covers
21
+ # every segment's homepage_sections and homepage_background, plus homepage_data, instead of a
22
+ # path per BIT_SEGMENT key.
23
+ HOMEPAGE_SECTION_PATTERNS = [
24
+ '/api/v2/storefront/homepage*',
25
+ '/api/v2/tenant/homepage*'
26
+ ].freeze
27
+
28
+ HOMEPAGE_BACKGROUND_PATTERNS = ['/api/v2/storefront/homepage*'].freeze
29
+
30
+ # Deliberately broad, and identical for single-record and all-record
31
+ # invalidation: a payment method is either vendor-owned or store-level, and a
32
+ # store-level one is served for every vendor through the fallback in
33
+ # SpreeCmCommissioner::PaymentMethods::Find. From the record alone we cannot
34
+ # tell which vendors are affected.
35
+ #
36
+ # vendor_id is a query param, so the trailing `*` is what clears every
37
+ # vendor's cached variant of this path in one go.
38
+ #
39
+ # Payment methods change a handful of times a year, so a broad invalidation
40
+ # is cheap.
41
+ PAYMENT_METHOD_PATTERNS = [
42
+ '/api/v2/tenant/payment_methods*',
43
+ '/api/v2/storefront/payment_methods*'
44
+ ].freeze
45
+
46
+ # eg. 'Spree::Product', 123
47
+ def call(model:, id:)
21
48
  model_class = SUPPORTED_MODELS[model]
22
49
 
23
50
  return failure(nil, 'Model is not supported') if model_class.nil?
24
51
 
25
- if id.present?
26
- invalidate_single_record(model_class: model_class, id: id)
27
- else
28
- invalidate_all_records(model_class: model_class)
29
- end
52
+ invalidate_single_record(model_class: model_class, id: id)
30
53
  end
31
54
 
32
55
  # eg. 'Spree::Product', 123 => ['/api/v2/tenant/products/123', '/api/v2/tenant/products/slug-of-product', ...]
@@ -38,7 +61,10 @@ module SpreeCmCommissioner
38
61
 
39
62
  refresh_record_cache_key(record)
40
63
 
41
- api_patterns = single_api_pattern[record.class.name].map do |pattern|
64
+ # The record's own class first (a Show found as a Spree::Taxon keeps its show paths), then the
65
+ # model asked for, since STI subclasses such as Spree::Gateway::Payway have no entry of their own.
66
+ patterns = single_api_pattern[record.class.name] || single_api_pattern[model_class.name]
67
+ api_patterns = patterns.map do |pattern|
42
68
  pattern.gsub(/:(\w+)/) do
43
69
  key = Regexp.last_match(1)
44
70
  record.public_send(key).to_s
@@ -50,26 +76,13 @@ module SpreeCmCommissioner
50
76
  success(record: record, api_patterns: api_patterns)
51
77
  end
52
78
 
53
- # eg. 'Spree::Product' => ['/api/v2/tenant/products/*', '/api/v2/storefront/products/*']
54
- def invalidate_all_records(model_class:)
55
- model_class.find_each do |record|
56
- refresh_record_cache_key(record)
57
- end
58
-
59
- enqueue_invalidate(invalidate_uris[model_class.name])
60
-
61
- success(api_patterns: invalidate_uris[model_class.name])
62
- end
63
-
64
79
  def single_api_pattern
65
80
  {
66
- 'SpreeCmCommissioner::HomepageSection' => homepage_section_patterns,
67
- 'SpreeCmCommissioner::HomepageBackground' => homepage_background_patterns,
81
+ 'SpreeCmCommissioner::HomepageSection' => HOMEPAGE_SECTION_PATTERNS,
82
+ 'SpreeCmCommissioner::HomepageBackground' => HOMEPAGE_BACKGROUND_PATTERNS,
68
83
  'SpreeCmCommissioner::SeatLayout' => ['/api/v2/storefront/seat_layouts/:id*'],
69
- 'Spree::Menu' => [
70
- '/api/v2/storefront/menus/:id*',
71
- '/api/v2/storefront/homepage_data*'
72
- ],
84
+ # The app reads menus from homepage_data; nothing calls /menus any more.
85
+ 'Spree::Menu' => ['/api/v2/storefront/homepage_data*'],
73
86
  'Spree::Taxon' => [
74
87
  '/api/v2/tenant/taxons/:id*',
75
88
  '/api/v2/tenant/taxons/:permalink*',
@@ -92,50 +105,12 @@ module SpreeCmCommissioner
92
105
  '/api/v2/storefront/accommodations/:id*',
93
106
  '/api/v2/storefront/accommodations/:slug*'
94
107
  ],
95
- 'Spree::PaymentMethod' => payment_method_patterns
96
- }
97
- end
98
-
99
- def invalidate_uris
100
- {
101
- 'SpreeCmCommissioner::HomepageSection' => homepage_section_patterns,
102
- 'SpreeCmCommissioner::HomepageBackground' => homepage_background_patterns,
103
- 'SpreeCmCommissioner::SeatLayout' => ['/api/v2/storefront/seat_layouts/*'],
104
- 'Spree::Menu' => ['/api/v2/storefront/menus/*'],
105
- 'Spree::Taxon' => ['/api/v2/storefront/taxons/*'],
106
- 'Spree::Product' => [
107
- '/api/v2/tenant/products/*',
108
- '/api/v2/storefront/products/*'
109
- ],
110
- 'Spree::Vendor' => [
111
- '/api/v2/tenant/vendors/*',
112
- '/api/v2/storefront/vendors/*',
113
- '/api/v2/storefront/accommodations/*'
114
- ],
115
- 'Spree::PaymentMethod' => payment_method_patterns
108
+ 'Spree::PaymentMethod' => PAYMENT_METHOD_PATTERNS
116
109
  }
117
110
  end
118
111
 
119
112
  private
120
113
 
121
- # Deliberately broad, and identical for single-record and all-record
122
- # invalidation: a payment method is either vendor-owned or store-level, and a
123
- # store-level one is served for every vendor through the fallback in
124
- # SpreeCmCommissioner::PaymentMethods::Find. From the record alone we cannot
125
- # tell which vendors are affected.
126
- #
127
- # vendor_id is a query param, so the trailing `*` is what clears every
128
- # vendor's cached variant of this path in one go.
129
- #
130
- # Payment methods change a handful of times a year, so a broad invalidation
131
- # is cheap.
132
- def payment_method_patterns
133
- [
134
- '/api/v2/tenant/payment_methods*',
135
- '/api/v2/storefront/payment_methods*'
136
- ]
137
- end
138
-
139
114
  # eg. updated_at: Fri, 26 Dec 2025 10:40:56.505450000 +07 +07:00
140
115
  def refresh_record_cache_key(record)
141
116
  # rubocop:disable Rails/SkipsModelValidations
@@ -147,29 +122,6 @@ module SpreeCmCommissioner
147
122
  def enqueue_invalidate(patterns)
148
123
  SpreeCmCommissioner::InvalidateCacheRequestJob.perform_later(patterns: patterns)
149
124
  end
150
-
151
- # Every key of BIT_SEGMENT (general, ticket, ..., ferry).
152
- def homepage_segment
153
- SpreeCmCommissioner::HomepageSectionBitwise::BIT_SEGMENT.keys
154
- end
155
-
156
- def homepage_section_patterns
157
- homepage_segment.flat_map do |segment|
158
- [
159
- "/api/v2/storefront/homepage/#{segment}/homepage_sections*",
160
- "/api/v2/tenant/homepage/#{segment}/homepage_sections*"
161
- ]
162
- end
163
- end
164
-
165
- def homepage_background_patterns
166
- patterns = homepage_segment.map do |segment|
167
- "/api/v2/storefront/homepage/#{segment}/homepage_background*"
168
- end
169
-
170
- patterns << '/api/v2/storefront/homepage_data*'
171
- patterns
172
- end
173
125
  end
174
126
  end
175
127
  end
@@ -0,0 +1,61 @@
1
+ module SpreeCmCommissioner
2
+ module ApiCaches
3
+ # Clears every record of a model for one storefront. `tenant` is required so a clear-all never
4
+ # pays for both namespaces by accident: nil is the BookMe+ storefront (/api/v2/storefront/
5
+ # paths), a SpreeCmCommissioner::Tenant is that tenant's API (/api/v2/tenant/ paths).
6
+ #
7
+ # Tenant APIs take the tenant from the OAuth token, not the URL, so every tenant shares one path
8
+ # today; taking the record keeps callers the same if tenant paths ever carry it.
9
+ #
10
+ # eg. InvalidateAll.call(model: 'SpreeCmCommissioner::HomepageSection', tenant: nil)
11
+ class InvalidateAll
12
+ prepend ::Spree::ServiceModule::Base
13
+
14
+ TENANT_PREFIX = '/api/v2/tenant/'.freeze
15
+
16
+ API_PATTERNS = {
17
+ 'SpreeCmCommissioner::HomepageSection' => Invalidate::HOMEPAGE_SECTION_PATTERNS,
18
+ 'SpreeCmCommissioner::HomepageBackground' => Invalidate::HOMEPAGE_BACKGROUND_PATTERNS,
19
+ 'SpreeCmCommissioner::SeatLayout' => ['/api/v2/storefront/seat_layouts/*'],
20
+ 'Spree::Menu' => ['/api/v2/storefront/homepage_data*'],
21
+ 'Spree::Taxon' => ['/api/v2/storefront/taxons/*'],
22
+ 'Spree::Product' => [
23
+ '/api/v2/tenant/products/*',
24
+ '/api/v2/storefront/products/*'
25
+ ],
26
+ 'Spree::Vendor' => [
27
+ '/api/v2/tenant/vendors/*',
28
+ '/api/v2/storefront/vendors/*',
29
+ '/api/v2/storefront/accommodations/*'
30
+ ],
31
+ 'Spree::PaymentMethod' => Invalidate::PAYMENT_METHOD_PATTERNS
32
+ }.freeze
33
+
34
+ def call(model:, tenant:)
35
+ model_class = Invalidate::SUPPORTED_MODELS[model] if API_PATTERNS.key?(model)
36
+ return failure(nil, 'Model is not supported') if model_class.nil?
37
+ return failure(nil, 'tenant must be a SpreeCmCommissioner::Tenant or nil') unless tenant.nil? || tenant.is_a?(SpreeCmCommissioner::Tenant)
38
+
39
+ refresh_records_cache_key(model_class, tenant)
40
+
41
+ api_patterns = API_PATTERNS[model].select { |pattern| pattern.start_with?(TENANT_PREFIX) == tenant.present? }
42
+
43
+ # Empty when the model has no API in this namespace, e.g. homepage backgrounds for a tenant,
44
+ # and CloudFront rejects an empty batch.
45
+ SpreeCmCommissioner::InvalidateCacheRequestJob.perform_later(patterns: api_patterns) if api_patterns.any?
46
+
47
+ success(api_patterns: api_patterns)
48
+ end
49
+
50
+ private
51
+
52
+ # updated_at keys Spree's serializer and collection caches, so only this storefront's records
53
+ # are touched when the model is tenant-scoped.
54
+ def refresh_records_cache_key(model_class, tenant)
55
+ scope = model_class.column_names.include?('tenant_id') ? model_class.where(tenant_id: tenant&.id) : model_class.all
56
+
57
+ scope.find_each(&:touch)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,34 @@
1
+ module SpreeCmCommissioner
2
+ module Guests
3
+ # Option-based fields (checkbox/radio/selection) become one GuestDynamicField row per selected
4
+ # option id; Guests::Update's own auto-cleanup destroys rows for deselected options. Fields not
5
+ # enabled on the guest's product are dropped.
6
+ class BuildDynamicFieldAttributes
7
+ def self.call(guest:, dynamic_fields:)
8
+ new(guest, dynamic_fields).call
9
+ end
10
+
11
+ def initialize(guest, dynamic_fields)
12
+ @guest = guest
13
+ @dynamic_fields = dynamic_fields
14
+ end
15
+
16
+ def call
17
+ return [] if @dynamic_fields.blank?
18
+
19
+ enabled_fields = @guest.line_item.product.dynamic_fields.index_by(&:id)
20
+
21
+ @dynamic_fields.flat_map do |dynamic_field_id, value|
22
+ dynamic_field = enabled_fields[dynamic_field_id.to_s.to_i]
23
+ next [] unless dynamic_field
24
+
25
+ if dynamic_field.requires_dynamic_field_options?
26
+ Array(value).compact_blank.map { |option_id| { dynamic_field_id: dynamic_field.id, dynamic_field_option_id: option_id } }
27
+ else
28
+ [{ dynamic_field_id: dynamic_field.id, value: value }]
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -38,6 +38,8 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
38
38
  stop_times = trip_mapping.external_payload&.dig('stop_times')
39
39
  return [] unless stop_times.is_a?(Array)
40
40
 
41
+ previous_time = nil
42
+
41
43
  synced_mappings = stop_times.each_with_index.map do |stop_time_data, index|
42
44
  # BookMeBus's stop_times[i]['arrival_time'] is the time the trip arrives at the NEXT
43
45
  # physical stop (i+1), not at stop i itself - it's 0/absent on the last entry, which has
@@ -50,7 +52,15 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
50
52
  # missing' on every booking attempt - never caught by specs because their fixtures set
51
53
  # arrival_time == departure_time on the same entry, sidestepping the real shape.
52
54
  incoming_arrival_time = index.positive? ? stop_times[index - 1]['arrival_time'] : nil
53
- sync_item!(trip_mapping, stop_time_data, incoming_arrival_time, index + 1, stop_times.size)
55
+
56
+ # BookMeBus sends time-of-day only, so an overnight trip (23:00 -> 05:00) needs its days rolled
57
+ # here: TransitOrder::Create trusts the stored datetimes and raises when arrival < departure.
58
+ arrival_time = roll_forward(parse_time(incoming_arrival_time), previous_time)
59
+ departure_time = roll_forward(parse_time(stop_time_data['departure_time']), arrival_time || previous_time)
60
+ previous_time = departure_time || arrival_time || previous_time
61
+
62
+ times = { arrival_time: arrival_time, departure_time: departure_time }
63
+ sync_item!(trip_mapping, stop_time_data, times, index + 1, stop_times.size)
54
64
  end.compact
55
65
 
56
66
  cleanup_stale_mappings!(trip_mapping, synced_mappings)
@@ -82,7 +92,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
82
92
  end
83
93
 
84
94
  # Sync a single trip stop
85
- def sync_item!(trip_mapping, stop_time_data, incoming_arrival_time, sequence, total_stops)
95
+ def sync_item!(trip_mapping, stop_time_data, times, sequence, total_stops)
86
96
  stop_data = stop_time_data['stop']
87
97
  location_data = stop_time_data['location']
88
98
  return nil unless stop_data.is_a?(Hash) && location_data.is_a?(Hash)
@@ -119,8 +129,8 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
119
129
  # BookMeBus returns one entry per search.
120
130
  allow_boarding: sequence == 1,
121
131
  allow_drop_off: sequence == total_stops,
122
- departure_time: parse_time(stop_time_data['departure_time']),
123
- arrival_time: parse_time(incoming_arrival_time)
132
+ departure_time: times[:departure_time],
133
+ arrival_time: times[:arrival_time]
124
134
  )
125
135
  tracker.save_if_changed!(trip_stop)
126
136
  end
@@ -206,6 +216,13 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
206
216
  mapping&.internal
207
217
  end
208
218
 
219
+ def roll_forward(time, not_before)
220
+ return time if time.nil? || not_before.nil?
221
+
222
+ time += 1.day while time < not_before
223
+ time
224
+ end
225
+
209
226
  def parse_time(seconds_from_midnight)
210
227
  return nil unless seconds_from_midnight.present? && seconds_from_midnight.to_i.positive?
211
228
 
@@ -762,6 +762,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
762
762
  def map_route_type(route_type)
763
763
  case route_type&.to_s&.downcase
764
764
  when 'ferry' then :ferry
765
+ when 'private_taxi' then :intercity_taxi
765
766
  else :bus
766
767
  end
767
768
  end